import { i as inputRules, e as ellipsis, w as wrappingInputRule, t as textblockTypeInputRule, I as InputRule, k as keymap, u as undo, r as redo, a as undoInputRule, j as joinUp, b as joinDown, l as lift, s as selectParentNode, c as toggleMark, d as wrapInList, f as liftListItem, g as sinkListItem, h as setBlockType, m as chainCommands, n as exitCode, P as Plugin, o as wrapIn, p as deleteTable, q as addColumnAfter, v as addColumnBefore, x as deleteColumn, y as addRowAfter, z as addRowBefore, A as deleteRow, B as mergeCells, C as splitCell, T as TextSelection, D as liftTarget, E as autoJoin, R as ResolvedPos, F as tableNodes, S as Schema, G as splitListItem, H as DOMParser$2, J as DOMSerializer, K as Slice, L as tableEditing, M as columnResizing, N as history$1, O as gapCursor, Q as dropCursor, U as baseKeymap, V as AllSelection, W as EditorState, X as EditorView, Y as PluginKey, Z as Step, _ as index, $ as index$1, a0 as index$2, a1 as index$3, a2 as index$4, a3 as index$5, a4 as index$6, a5 as basicSetup, a6 as markdown, a7 as json, a8 as linter, a9 as javascript, aa as lintGutter, ab as esLint, ac as Linter, ad as html, ae as syntaxHighlighting, af as HighlightStyle, ag as tags, ah as markdownLanguage, ai as jsonLanguage, aj as javascriptLanguage, ak as htmlLanguage, al as jsonParseLinter, am as indentUnit, an as keymap$1, ao as indentWithTab, ap as EditorView$1, aq as Compartment, ar as EditorSelection } from './vendor.mjs';

/**
 * @import {ColorSource} from "../_types.mjs";
 */

/**
 * A representation of a color in hexadecimal format.
 * This class provides methods for transformations and manipulations of colors.
 */
class Color extends Number {

  /**
   * Is this a valid color?
   * @type {boolean}
   */
  get valid() {
    const v = this.valueOf();
    return Number.isInteger(v) && v >= 0 && v <= 0xFFFFFF;
  }

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

  /**
   * A CSS-compatible color string.
   * If this color is not valid, the empty string is returned.
   * An alias for Color#toString.
   * @type {string}
   */
  get css() {
    return this.toString(16);
  }

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

  /**
   * The color represented as an RGB array.
   * @type {[number, number, number]}
   */
  get rgb() {
    return [((this >> 16) & 0xFF) / 255, ((this >> 8) & 0xFF) / 255, (this & 0xFF) / 255];
  }

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

  /**
   * The numeric value of the red channel between [0, 1].
   * @type {number}
   */
  get r() {
    return ((this >> 16) & 0xFF) / 255;
  }

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

  /**
   * The numeric value of the green channel between [0, 1].
   * @type {number}
   */
  get g() {
    return ((this >> 8) & 0xFF) / 255;
  }

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

  /**
   * The numeric value of the blue channel between [0, 1].
   * @type {number}
   */
  get b() {
    return (this & 0xFF) / 255;
  }

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

  /**
   * The maximum value of all channels.
   * @type {number}
   */
  get maximum() {
    return Math.max(...this);
  }

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

  /**
   * The minimum value of all channels.
   * @type {number}
   */
  get minimum() {
    return Math.min(...this);
  }

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

  /**
   * Get the value of this color in little endian format.
   * @type {number}
   */
  get littleEndian() {
    return ((this >> 16) & 0xFF) + (this & 0x00FF00) + ((this & 0xFF) << 16);
  }

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

  /**
   * The color represented as an HSV array.
   * Conversion formula adapted from http://en.wikipedia.org/wiki/HSV_color_space.
   * Assumes r, g, and b are contained in the set [0, 1] and returns h, s, and v in the set [0, 1].
   * @type {[number, number, number]}
   */
  get hsv() {
    const [r, g, b] = this.rgb;
    const max = Math.max(r, g, b);
    const min = Math.min(r, g, b);
    const d = max - min;

    let h;
    const s = max === 0 ? 0 : d / max;
    const v = max;

    // Achromatic colors
    if (max === min) return [0, s, v];

    // Normal colors
    switch (max) {
      case r: h = ((g - b) / d) + (g < b ? 6 : 0); break;
      case g: h = ((b - r) / d) + 2; break;
      case b: h = ((r - g) / d) + 4; break;
    }
    h /= 6;
    return [h, s, v];
  }

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

  /**
   * The color represented as an HSL array.
   * Assumes r, g, and b are contained in the set [0, 1] and returns h, s, and l in the set [0, 1].
   * @type {[number, number, number]}
   */
  get hsl() {
    const [r, g, b] = this.rgb;

    // Compute luminosity, saturation and hue
    const l = Math.max(r, g, b);
    const s = l - Math.min(r, g, b);
    let h = 0;
    if ( s > 0 ) {
      if ( l === r ) {
        h = (g - b) / s;
      } else if ( l === g ) {
        h = 2 + ((b - r) / s);
      } else {
        h = 4 + ((r - g) / s);
      }
    }
    const finalHue = (60 * h < 0 ? (60 * h) + 360 : 60 * h) / 360;
    const finalSaturation = s ? (l <= 0.5 ? s / ((2 * l) - s) : s / (2 - ((2 * l) - s))) : 0;
    const finalLuminance = ((2 * l) - s) / 2;
    return [finalHue, finalSaturation, finalLuminance];
  }

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

  /**
   * The color represented as a linear RGB array.
   * Assumes r, g, and b are contained in the set [0, 1] and returns linear r, g, and b in the set [0, 1].
   * @see {@link https://en.wikipedia.org/wiki/SRGB#Transformation}
   * @type {Color}
   */
  get linear() {
    const toLinear = c => (c > 0.04045) ? Math.pow((c + 0.055) / 1.055, 2.4) : (c / 12.92);
    return this.constructor.fromRGB([toLinear(this.r), toLinear(this.g), toLinear(this.b)]);
  }

  /* ------------------------------------------ */
  /*  Color Manipulation Methods                */
  /* ------------------------------------------ */

  /** @override */
  toString(radix) {
    if ( !this.valid ) return "";
    return `#${super.toString(16).padStart(6, "0")}`;
  }

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

  /**
   * Serialize the Color.
   * @returns {string}    The color as a CSS string
   */
  toJSON() {
    return this.css;
  }

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

  /**
   * Returns the color as a CSS string.
   * @returns {string}    The color as a CSS string
   */
  toHTML() {
    return this.css;
  }

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

  /**
   * Test whether this color equals some other color
   * @param {Color|number} other  Some other color or hex number
   * @returns {boolean}           Are the colors equal?
   */
  equals(other) {
    return this.valueOf() === other.valueOf();
  }

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

  /**
   * Get a CSS-compatible RGBA color string.
   * @param {number} alpha      The desired alpha in the range [0, 1]
   * @returns {string}          A CSS-compatible RGBA string
   */
  toRGBA(alpha) {
    const rgba = [(this >> 16) & 0xFF, (this >> 8) & 0xFF, this & 0xFF, alpha];
    return `rgba(${rgba.join(", ")})`;
  }

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

  /**
   * Mix this Color with some other Color using a provided interpolation weight.
   * @param {Color} other       Some other Color to mix with
   * @param {number} weight     The mixing weight placed on this color where weight is placed on the other color
   * @returns {Color}           The resulting mixed Color
   */
  mix(other, weight) {
    return new Color(Color.mix(this, other, weight));
  }

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

  /**
   * Multiply this Color by another Color or a static scalar.
   * @param {Color|number} other  Some other Color or a static scalar.
   * @returns {Color}             The resulting Color.
   */
  multiply(other) {
    if ( other instanceof Color ) return new Color(Color.multiply(this, other));
    return new Color(Color.multiplyScalar(this, other));
  }

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

  /**
   * Add this Color by another Color or a static scalar.
   * @param {Color|number} other  Some other Color or a static scalar.
   * @returns {Color}             The resulting Color.
   */
  add(other) {
    if ( other instanceof Color ) return new Color(Color.add(this, other));
    return new Color(Color.addScalar(this, other));
  }

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

  /**
   * Subtract this Color by another Color or a static scalar.
   * @param {Color|number} other  Some other Color or a static scalar.
   * @returns {Color}             The resulting Color.
   */
  subtract(other) {
    if ( other instanceof Color ) return new Color(Color.subtract(this, other));
    return new Color(Color.subtractScalar(this, other));
  }

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

  /**
   * Max this color by another Color or a static scalar.
   * @param {Color|number} other  Some other Color or a static scalar.
   * @returns {Color}             The resulting Color.
   */
  maximize(other) {
    if ( other instanceof Color ) return new Color(Color.maximize(this, other));
    return new Color(Color.maximizeScalar(this, other));
  }

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

  /**
   * Min this color by another Color or a static scalar.
   * @param {Color|number} other  Some other Color or a static scalar.
   * @returns {Color}             The resulting Color.
   */
  minimize(other) {
    if ( other instanceof Color ) return new Color(Color.minimize(this, other));
    return new Color(Color.minimizeScalar(this, other));
  }

  /* ------------------------------------------ */
  /*  Iterator                                  */
  /* ------------------------------------------ */

  /**
   * Iterating over a Color is equivalent to iterating over its [r,g,b] color channels.
   * @returns {Generator<number>}
   */
  *[Symbol.iterator]() {
    yield this.r;
    yield this.g;
    yield this.b;
  }

  /* ------------------------------------------------------------------------------------------- */
  /*                      Real-time performance Methods and Properties                           */
  /*  Important Note:                                                                            */
  /*  These methods are not a replacement, but a tool when real-time performance is needed.      */
  /*  They do not have the flexibility of the "classic" methods and come with some limitations.  */
  /*  Unless you have to deal with real-time performance, you should use the "classic" methods.  */
  /* ------------------------------------------------------------------------------------------- */

  /**
   * Set an rgb array with the rgb values contained in this Color class.
   * @param {number[]} vec3  Receive the result. Must be an array with at least a length of 3.
   */
  applyRGB(vec3) {
    vec3[0] = ((this >> 16) & 0xFF) / 255;
    vec3[1] = ((this >> 8) & 0xFF) / 255;
    vec3[2] = (this & 0xFF) / 255;
  }

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

  /**
   * Apply a linear interpolation between two colors, according to the weight.
   * @param {number}        color1       The first color to mix.
   * @param {number}        color2       The second color to mix.
   * @param {number}        weight       Weight of the linear interpolation.
   * @returns {number}                   The resulting mixed color
   */
  static mix(color1, color2, weight) {
    return ((((((color1 >> 16) & 0xFF) * (1 - weight)) + (((color2 >> 16) & 0xFF) * weight)) << 16) & 0xFF0000)
      | ((((((color1 >> 8) & 0xFF) * (1 - weight)) + (((color2 >> 8) & 0xFF) * weight)) << 8) & 0x00FF00)
      | ((((color1 & 0xFF) * (1 - weight)) + ((color2 & 0xFF) * weight)) & 0x0000FF);
  }

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

  /**
   * Multiply two colors.
   * @param {number}        color1       The first color to multiply.
   * @param {number}        color2       The second color to multiply.
   * @returns {number}                   The result.
   */
  static multiply(color1, color2) {
    return ((((color1 >> 16) & 0xFF) / 255 * ((color2 >> 16) & 0xFF) / 255) * 255 << 16)
      | ((((color1 >> 8) & 0xFF) / 255 * ((color2 >> 8) & 0xFF) / 255) * 255 << 8)
      | (((color1 & 0xFF) / 255 * ((color2 & 0xFF) / 255)) * 255);
  }

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

  /**
   * Multiply a color by a scalar
   * @param {number} color        The color to multiply.
   * @param {number} scalar       A static scalar to multiply with.
   * @returns {number}            The resulting color as a number.
   */
  static multiplyScalar(color, scalar) {
    return (Math.clamp(((color >> 16) & 0xFF) / 255 * scalar, 0, 1) * 255 << 16)
      | (Math.clamp(((color >> 8) & 0xFF) / 255 * scalar, 0, 1) * 255 << 8)
      | (Math.clamp((color & 0xFF) / 255 * scalar, 0, 1) * 255);
  }

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

  /**
   * Maximize two colors.
   * @param {number}        color1       The first color.
   * @param {number}        color2       The second color.
   * @returns {number}                   The result.
   */
  static maximize(color1, color2) {
    return (Math.clamp(Math.max((color1 >> 16) & 0xFF, (color2 >> 16) & 0xFF), 0, 0xFF) << 16)
      | (Math.clamp(Math.max((color1 >> 8) & 0xFF, (color2 >> 8) & 0xFF), 0, 0xFF) << 8)
      | Math.clamp(Math.max(color1 & 0xFF, color2 & 0xFF), 0, 0xFF);
  }

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

  /**
   * Maximize a color by a static scalar.
   * @param {number} color         The color to maximize.
   * @param {number} scalar        Scalar to maximize with (normalized).
   * @returns {number}             The resulting color as a number.
   */
  static maximizeScalar(color, scalar) {
    return (Math.clamp(Math.max((color >> 16) & 0xFF, scalar * 255), 0, 0xFF) << 16)
      | (Math.clamp(Math.max((color >> 8) & 0xFF, scalar * 255), 0, 0xFF) << 8)
      | Math.clamp(Math.max(color & 0xFF, scalar * 255), 0, 0xFF);
  }

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

  /**
   * Add two colors.
   * @param {number}        color1       The first color.
   * @param {number}        color2       The second color.
   * @returns {number}                   The resulting color as a number.
   */
  static add(color1, color2) {
    return (Math.clamp((((color1 >> 16) & 0xFF) + ((color2 >> 16) & 0xFF)), 0, 0xFF) << 16)
      | (Math.clamp((((color1 >> 8) & 0xFF) + ((color2 >> 8) & 0xFF)), 0, 0xFF) << 8)
      | Math.clamp(((color1 & 0xFF) + (color2 & 0xFF)), 0, 0xFF);
  }

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

  /**
   * Add a static scalar to a color.
   * @param {number} color         The color.
   * @param {number} scalar        Scalar to add with (normalized).
   * @returns {number}             The resulting color as a number.
   */
  static addScalar(color, scalar) {
    return (Math.clamp((((color >> 16) & 0xFF) + (scalar * 255)), 0, 0xFF) << 16)
      | (Math.clamp((((color >> 8) & 0xFF) + (scalar * 255)), 0, 0xFF) << 8)
      | Math.clamp(((color & 0xFF) + (scalar * 255)), 0, 0xFF);
  }

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

  /**
   * Subtract two colors.
   * @param {number}        color1       The first color.
   * @param {number}        color2       The second color.
   */
  static subtract(color1, color2) {
    return (Math.clamp((((color1 >> 16) & 0xFF) - ((color2 >> 16) & 0xFF)), 0, 0xFF) << 16)
      | (Math.clamp((((color1 >> 8) & 0xFF) - ((color2 >> 8) & 0xFF)), 0, 0xFF) << 8)
      | Math.clamp(((color1 & 0xFF) - (color2 & 0xFF)), 0, 0xFF);
  }

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

  /**
   * Subtract a color by a static scalar.
   * @param {number} color         The color.
   * @param {number} scalar        Scalar to subtract with (normalized).
   * @returns {number}             The resulting color as a number.
   */
  static subtractScalar(color, scalar) {
    return (Math.clamp((((color >> 16) & 0xFF) - (scalar * 255)), 0, 0xFF) << 16)
      | (Math.clamp((((color >> 8) & 0xFF) - (scalar * 255)), 0, 0xFF) << 8)
      | Math.clamp(((color & 0xFF) - (scalar * 255)), 0, 0xFF);
  }

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

  /**
   * Minimize two colors.
   * @param {number}        color1       The first color.
   * @param {number}        color2       The second color.
   */
  static minimize(color1, color2) {
    return (Math.clamp(Math.min((color1 >> 16) & 0xFF, (color2 >> 16) & 0xFF), 0, 0xFF) << 16)
      | (Math.clamp(Math.min((color1 >> 8) & 0xFF, (color2 >> 8) & 0xFF), 0, 0xFF) << 8)
      | Math.clamp(Math.min(color1 & 0xFF, color2 & 0xFF), 0, 0xFF);
  }

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

  /**
   * Minimize a color by a static scalar.
   * @param {number} color         The color.
   * @param {number} scalar        Scalar to minimize with (normalized).
   */
  static minimizeScalar(color, scalar) {
    return (Math.clamp(Math.min((color >> 16) & 0xFF, scalar * 255), 0, 0xFF) << 16)
      | (Math.clamp(Math.min((color >> 8) & 0xFF, scalar * 255), 0, 0xFF) << 8)
      | Math.clamp(Math.min(color & 0xFF, scalar * 255), 0, 0xFF);
  }

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

  /**
   * Convert a color to RGB and assign values to a passed array.
   * @param {number} color   The color to convert to RGB values.
   * @param {number[]} vec3  Receive the result. Must be an array with at least a length of 3.
   */
  static applyRGB(color, vec3) {
    vec3[0] = ((color >> 16) & 0xFF) / 255;
    vec3[1] = ((color >> 8) & 0xFF) / 255;
    vec3[2] = (color & 0xFF) / 255;
  }

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

  /**
   * Create a Color instance from an RGB array.
   * @param {ColorSource} color     A color input
   * @returns {Color}               The hex color instance or NaN
   */
  static from(color) {
    if ( (color === null) || (color === undefined) ) return new this(NaN);
    if ( typeof color === "string" ) return this.fromString(color);
    if ( typeof color === "number" ) return new this(color);
    if ( (color instanceof Array) && (color.length === 3) ) return this.fromRGB(color);
    if ( color instanceof Color ) return color;
    return new this(color);
  }

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

  /**
   * Create a Color instance from a color string which either includes or does not include a leading #.
   * @param {string} color                      A color string
   * @returns {Color}                           The hex color instance
   */
  static fromString(color) {
    return new this(parseInt(color.startsWith("#") ? color.substring(1) : color, 16));
  }

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

  /**
   * Create a Color instance from an RGB array.
   * @param {[number, number, number]} rgb      An RGB tuple
   * @returns {Color}                           The hex color instance
   */
  static fromRGB(rgb) {
    return new this(((rgb[0] * 255) << 16) + ((rgb[1] * 255) << 8) + (rgb[2] * 255 | 0));
  }

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

  /**
   * Create a Color instance from an RGB normalized values.
   * @param {number} r                          The red value
   * @param {number} g                          The green value
   * @param {number} b                          The blue value
   * @returns {Color}                           The hex color instance
   */
  static fromRGBvalues(r, g, b) {
    return new this(((r * 255) << 16) + ((g * 255) << 8) + (b * 255 | 0));
  }

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

  /**
   * Create a Color instance from an HSV array.
   * Conversion formula adapted from http://en.wikipedia.org/wiki/HSV_color_space.
   * Assumes h, s, and v are contained in the set [0, 1].
   * @param {[number, number, number]} hsv      An HSV tuple
   * @returns {Color}                           The hex color instance
   */
  static fromHSV(hsv) {
    const [h, s, v] = hsv;
    const i = Math.floor(h * 6);
    const f = (h * 6) - i;
    const p = v * (1 - s);
    const q = v * (1 - (f * s));
    const t = v * (1 - ((1 - f) * s));
    let rgb;
    switch (i % 6) {
      case 0: rgb = [v, t, p]; break;
      case 1: rgb = [q, v, p]; break;
      case 2: rgb = [p, v, t]; break;
      case 3: rgb = [p, q, v]; break;
      case 4: rgb = [t, p, v]; break;
      case 5: rgb = [v, p, q]; break;
    }
    return this.fromRGB(rgb);
  }

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

  /**
   * Create a Color instance from an HSL array.
   * Assumes h, s, and l are contained in the set [0, 1].
   * @param {[number, number, number]} hsl      An HSL tuple
   * @returns {Color}                           The hex color instance
   */
  static fromHSL(hsl) {
    const [h, s, l] = hsl;

    // Calculate intermediate values for the RGB components
    const chroma = (1 - Math.abs((2 * l) - 1)) * s;
    const hue = h * 6;
    const x = chroma * (1 - Math.abs((hue % 2) - 1));
    const m = l - (chroma / 2);

    let r;
    let g;
    let b;
    switch (Math.floor(hue)) {
      case 0: [r, g, b] = [chroma, x, 0]; break;
      case 1: [r, g, b] = [x, chroma, 0]; break;
      case 2: [r, g, b] = [0, chroma, x]; break;
      case 3: [r, g, b] = [0, x, chroma]; break;
      case 4: [r, g, b] = [x, 0, chroma]; break;
      case 5:
      case 6: [r, g, b] = [chroma, 0, x]; break;
      default: [r, g, b] = [0, 0, 0]; break;
    }

    // Adjust for luminance
    r += m;
    g += m;
    b += m;
    return this.fromRGB([r, g, b]);
  }

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

  /**
   * Create a Color instance (sRGB) from a linear rgb array.
   * Assumes r, g, and b are contained in the set [0, 1].
   * @see {@link https://en.wikipedia.org/wiki/SRGB#Transformation}
   * @param {[number, number, number]} linear   The linear rgb array
   * @returns {Color}                           The hex color instance
   */
  static fromLinearRGB(linear) {
    const [r, g, b] = linear;
    const tosrgb = c => (c <= 0.0031308) ? (12.92 * c) : ((1.055 * Math.pow(c, 1 / 2.4)) - 0.055);
    return this.fromRGB([tosrgb(r), tosrgb(g), tosrgb(b)]);
  }
}

/** @module helpers */


/**
 * @import {DeepReadonly} from "../_types.mjs";
 * @import {ResolvedUUID} from "./_types.mjs";
 */

/**
 * Benchmark the performance of a function, calling it a requested number of iterations.
 * @param {Function} func       The function to benchmark
 * @param {number} iterations   The number of iterations to test
 * @param {...any} args         Additional arguments passed to the benchmarked function
 */
async function benchmark(func, iterations, ...args) {
  const start = performance.now();
  for ( let i=0; i<iterations; i++ ) {
    await func(...args, i);
  }
  const end = performance.now();
  const t = Math.round((end - start) * 100) / 100;
  const name = func.name ?? "Evaluated Function";
  console.log(`${name} | ${iterations} iterations | ${t}ms | ${t / iterations}ms per`);
}

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

/**
 * A debugging function to test latency or timeouts by forcibly locking the thread for an amount of time.
 * @param {number} ms         A number of milliseconds to lock
 * @param {boolean} debug     Log debugging information?
 * @returns {Promise<void>}
 */
async function threadLock(ms, debug=false) {
  const t0 = performance.now();
  let d = 0;
  while ( d < ms ) {
    d = performance.now() - t0;
    if ( debug && (d % 1000 === 0) ) {
      console.debug(`Thread lock for ${d / 1000} of ${ms / 1000} seconds`);
    }
  }
}

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

/**
 * Wrap a callback in a debounced timeout.
 * Delay execution of the callback function until the function has not been called for delay milliseconds
 * @param {Function} callback       A function to execute once the debounced threshold has been passed
 * @param {number} delay            An amount of time in milliseconds to delay
 * @returns {Function}              A wrapped function which can be called to debounce execution
 */
function debounce(callback, delay) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      callback.apply(this, args);
    }, delay);
  };
}

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

/**
 * Wrap a callback in a throttled timeout.
 * Delay execution of the callback function when the last time the function was called was delay milliseconds ago
 * @param {Function} callback       A function to execute once the throttled threshold has been passed
 * @param {number} delay            A maximum amount of time in milliseconds between to execution
 * @returns {Function}              A wrapped function which can be called to throttle execution
 */
function throttle(callback, delay) {
  let pending;
  let lastTime = -delay;
  return function(...args) {
    if ( pending ) {
      pending.thisArg = this;
      pending.args = args;
      return;
    }
    pending = {thisArg: this, args};
    setTimeout(() => {
      const {thisArg, args} = pending;
      pending = null;
      callback.apply(thisArg, args);
      lastTime = performance.now();
    }, Math.max(delay - (performance.now() - lastTime), 0));
  };
}

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

/**
 * A utility function to request a debounced page reload.
 * @type {() => void}
 */
const debouncedReload = debounce( () => window.location.reload(), 250);

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

/**
 * Recursively freezes (`Object.freeze`) the object (or value).
 * This method DOES NOT support cyclical data structures.
 * This method DOES NOT support advanced object types like Set, Map, or other specialized classes.
 * @template {object} const T
 * @param {T} obj                             The object (or value)
 * @param {object} [options]                  Options to configure the behaviour of deepFreeze
 * @param {boolean} [options.strict=false]    Throw an Error if deepFreeze is unable to seal something instead of
 *                                            returning the original
 * @returns {Readonly<T>}                     The same object (or value) that was passed in
 */
function deepFreeze(obj, {strict=false}={}) {
  return _deepFreeze(obj, strict, 0);
}

/**
 * An inner function does the work of {@link foundry.utils.deepFreeze}.
 * @param {any} obj           Some sort of data
 * @param {boolean} strict    Throw an Error if deepClone is unable to clone something instead of returning the original
 * @param {number} _d         The depth tracker
 */
function _deepFreeze(obj, strict, _d) {
  if ( _d > 100 ) {
    throw new Error("Maximum depth exceeded. Be sure your object does not contain cyclical data structures.");
  }
  _d++;

  if ( obj instanceof Array ) obj.forEach(o => _deepFreeze(o, strict, _d));
  else if ( typeof obj === "object" ) {
    if ( obj === null ) return null;

    // Unsupported advanced objects
    if ( obj.constructor && (obj.constructor !== Object) ) {
      if ( strict ) throw new Error("deepFreeze cannot freeze advanced objects");
      return obj;
    }

    for ( const key in obj ) _deepFreeze(obj[key], strict, _d);
  }
  return Object.freeze(obj);
}

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

/**
 * Recursively seals (`Object.seal`) the object (or value).
 * This method DOES NOT support cyclical data structures.
 * This method DOES NOT support advanced object types like Set, Map, or other specialized classes.
 * @template {object} T
 * @param {T} obj                             The object (or value)
 * @param {object} [options]                  Options to configure the behaviour of deepSeal
 * @param {boolean} [options.strict=false]    Throw an Error if deepSeal is unable to seal something
 * @returns {T}                               The same object (or value) that was passed in
 */
function deepSeal(obj, {strict=false}={}) {
  return _deepSeal(obj, strict, 0);
}

/**
 * An inner function does the work of {@link foundry.utils.deepSeal}.
 * @param {any} obj           Some sort of data
 * @param {boolean} strict    Throw an Error if deepClone is unable to clone something instead of returning the original
 * @param {number} _d         The depth tracker
 */
function _deepSeal(obj, strict, _d) {
  if ( _d > 100 ) {
    throw new Error("Maximum depth exceeded. Be sure your object does not contain cyclical data structures.");
  }
  _d++;

  if ( obj instanceof Array ) obj.forEach(o => _deepSeal(o, strict, _d));
  else if ( typeof obj === "object" ) {
    if ( obj === null ) return null;

    // Unsupported advanced objects
    if ( obj.constructor && (obj.constructor !== Object) ) {
      if ( strict ) throw new Error("deepSeal cannot seal advanced objects");
      return obj;
    }

    for ( const key in obj ) _deepSeal(obj[key], strict, _d);
  }
  return Object.seal(obj);
}

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

/**
 * Quickly clone a simple piece of data, returning a copy which can be mutated safely.
 * This method DOES support recursive data structures containing inner objects or arrays.
 * This method DOES NOT support cyclical data structures.
 * This method DOES NOT support advanced object types like Set, Map, or other specialized classes.
 * @template {object} T
 * @param {T} original                      Some sort of data
 * @param {object} [options]                Options to configure the behaviour of deepClone
 * @param {boolean} [options.strict=false]  Throw an Error if deepClone is unable to clone something instead of
 *                                          returning the original
 * @returns {T}                             The clone of that data
 */
function deepClone(original, {strict=false}={}) {
  return _deepClone(original, strict, 0);
}

/**
 * An inner function does the work of the deepClone operation and is optimized to avoid object creation.
 * @param {any} original      Some sort of data
 * @param {boolean} strict    Throw an Error if deepClone is unable to clone something instead of returning the original
 * @param {number} _d         The depth tracker
 */
function _deepClone(original, strict, _d) {
  if ( _d > 100 ) {
    throw new Error("Maximum depth exceeded. Be sure your object does not contain cyclical data structures.");
  }
  _d++;

  // Simple types
  if ( (typeof original !== "object") || (original === null) ) return original;

  // Arrays
  if ( original instanceof Array ) return original.map(o => _deepClone(o, strict, _d));

  // Dates
  if ( original instanceof Date ) return new Date(original);

  // Unsupported advanced objects
  if ( original.constructor && (original.constructor !== Object) ) {
    if ( strict ) throw new Error("deepClone cannot clone advanced objects");
    return original;
  }

  // Other objects
  const clone = {};
  for ( const k of Object.keys(original) ) {
    clone[k] = _deepClone(original[k], strict, _d);
  }
  return clone;
}

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

/**
 * Deeply difference an object against some other, returning the update keys and values.
 * @param {object} original       An object comparing data against which to compare
 * @param {object} other          An object containing potentially different data
 * @param {object} [options={}]   Additional options which configure the diff operation
 * @param {boolean} [options.inner=false]  Only recognize differences in other for keys which also exist in original
 * @param {boolean} [options.deletionKeys=false] Apply special logic to deletion keys. They will only be kept if the
 *                                               original object has a corresponding key that could be deleted.
 * @param {number} [options._d]           An internal depth tracker
 * @returns {object}              An object of the data in other which differs from that in original
 */
function diffObject(original, other, {inner=false, deletionKeys=false, _d=0}={}) {
  return _diffObject(original, other, inner, deletionKeys, _d);
}

/**
 * An inner function does the work of the diffObject operation and is optimized to avoid object creation.
 * @param {object} original
 * @param {object} other
 * @param {boolean} inner
 * @param {boolean} deletionKeys
 * @param {number} _d
 * @returns {object}
 */
function _diffObject(original, other, inner, deletionKeys, _d=0) {
  if ( _d > 100 ) throw new Error("Maximum diffObject depth exceeded. Be careful of cyclical data structures.");
  const diff = {};
  for ( const key in other ) {
    if ( deletionKeys && isDeletionKey(key) ) {
      const [isDifferent, difference] = _diffSpecial(original, key, other[key], inner);
      if ( isDifferent ) diff[key] = difference;
    } else {
      const [isDifferent, difference] = _diffValue(original, key, other[key], inner, deletionKeys, _d);
      if ( isDifferent ) diff[key] = difference;
    }
  }
  return diff;
}

/**
 * Special handling for deletion "-=" and forced replacement "==" keys.
 * @param {object} original
 * @param {string} key
 * @param {any} value
 * @param {boolean} inner
 * @returns {[boolean, any]}
 */
function _diffSpecial(original, key, value, inner) {
  const targetKey = key.substring(2);
  const hasKey = targetKey in original;
  if ( inner && !hasKey ) return [false, undefined];

  // Deletion
  if ( key[0] === "-" ) {
    if ( value !== null ) throw new Error("Removing a key using the -= deletion syntax requires the value of that"
      + " deletion key to be null, for example {-=key: null}");
    return [hasKey, null];
  }

  // Forced Replacement
  else if ( key[0] === "=" ) return [true, applySpecialKeys(value)];
  return [false, undefined];
}

/**
 * Identify differences in individual keys.
 * @param {object} original
 * @param {string} key
 * @param {any} v1
 * @param {boolean} inner
 * @param {boolean} deletionKeys
 * @param {number} _d
 * @returns {[boolean, any]}
 */
function _diffValue(original, key, v1, inner, deletionKeys, _d) {
  const hasKey = key in original;
  if ( inner && !hasKey ) return [false, undefined];
  const v0 = original[key];

  // Set to null or to undefined
  if ( (v1 === undefined) || (v1 === null) ) return [v0 !== v1, v1];

  // Change object type
  const t0 = getType(v0);
  const t1 = getType(v1);
  if ( t0 !== t1 ) return [true, applySpecialKeys(v1)];

  // Use an explicitly provided equality testing method, if available
  if ( v0?.equals instanceof Function ) {
    if ( v0.equals(v1) ) return [false, undefined];
    return [true, applySpecialKeys(v1)];
  }

  // Recursively diff objects
  if ( (t0 === "Object") && (t1 === "Object") ) {
    if ( isEmpty(v1) ) return [false, undefined];
    const d = _diffObject(v0, v1, inner, deletionKeys, _d+1);
    return [!isEmpty(d), d];
  }

  // Differences in primitives
  return [v0.valueOf() !== v1.valueOf(), v1];
}

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

/**
 * Recurse through an object, applying all special keys.
 * Deletion keys ("-=") are removed.
 * Forced replacement keys ("==") are assigned.
 * @param {*} obj
 * @returns {*}
 */
function applySpecialKeys(obj) {
  const type = getType(obj);
  if ( type === "Array" ) return obj.map(applySpecialKeys);
  if ( type !== "Object" ) return obj;
  const clone = {};
  for ( const key in obj ) {
    const v = obj[key];
    if ( isDeletionKey(key) ) {
      if ( key[0] === "-" ) {
        if ( v !== null ) throw new Error("Removing a key using the -= deletion syntax requires the value of that"
          + " deletion key to be null, for example {-=key: null}");
        delete clone[key.substring(2)];
        continue;
      }
      if ( key[0] === "=" ) {
        clone[key.substring(2)] = applySpecialKeys(v);
        continue;
      }
    }
    clone[key] = applySpecialKeys(v);
  }
  return clone;
}

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

/**
 * Test if two objects contain the same enumerable keys and values.
 * @param {object} a  The first object.
 * @param {object} b  The second object.
 * @returns {boolean}
 */
function objectsEqual(a, b) {
  if ( (a == null) || (b == null) ) return a === b;
  if ( (getType(a) !== "Object") || (getType(b) !== "Object") ) return a === b;
  if ( Object.keys(a).length !== Object.keys(b).length ) return false;
  return Object.entries(a).every(([k, v0]) => {
    const v1 = b[k];
    const t0 = getType(v0);
    const t1 = getType(v1);
    if ( t0 !== t1 ) return false;
    if ( v0?.equals instanceof Function ) return v0.equals(v1);
    if ( t0 === "Object" ) return objectsEqual(v0, v1);
    return v0 === v1;
  });
}

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

/**
 * A cheap data duplication trick which is relatively robust.
 * For a subset of cases the deepClone function will offer better performance.
 * @param {Object} original   Some sort of data
 */
function duplicate(original) {
  return JSON.parse(JSON.stringify(original));
}

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

/**
 * Is a string key of an object used for certain deletion or forced replacement operations.
 * @param {string} key
 * @returns {boolean}
 */
function isDeletionKey(key) {
  if ( !(typeof key === "string") ) return false;
  return (key[1] === "=") && ((key[0] === "=") || (key[0] === "-"));
}

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

/**
 * Test whether some class is a subclass of a parent.
 * Returns true if the classes are identical.
 * @param {Function} cls        The class to test
 * @param {Function} parent     Some other class which may be a parent
 * @returns {boolean}           Is the class a subclass of the parent?
 */
function isSubclass(cls, parent) {
  if ( typeof cls !== "function" ) return false;
  if ( cls === parent ) return true;
  return parent.isPrototypeOf(cls);
}

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

/**
 * Search up the prototype chain and return the class that defines the given property.
 * @param {Object|Constructor} obj    A class instance or class definition which contains a property.
 *                                    If a class instance is passed the property is treated as an instance attribute.
 *                                    If a class constructor is passed the property is treated as a static attribute.
 * @param {string} property           The property name
 * @returns {Constructor<Object>}             The class that defines the property
 */
function getDefiningClass(obj, property) {
  const isStatic = obj.hasOwnProperty("prototype");
  let target = isStatic ? obj : Object.getPrototypeOf(obj);
  while ( target ) {
    if ( target.hasOwnProperty(property) ) {
      target = isStatic ? target : target.constructor;
      break;
    }
    target = Object.getPrototypeOf(target);
  }
  return target;
}

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

/**
 * Encode an url-like string by replacing any characters which need encoding.
 * To reverse this encoding, the native decodeURIComponent can be used on the whole encoded string, without adjustment.
 * @param {string} path     A fully-qualified URL or url component (like a relative path)
 * @returns {string}         An encoded URL string
 */
function encodeURL(path) {

  // Determine whether the path is a well-formed URL
  const url = URL.parseSafe(path);

  // If URL, remove the initial protocol
  if ( url ) path = path.replace(url.protocol, "");

  // Split and encode each URL part
  path = path.split("/").map(p => encodeURIComponent(p).replace(/'/g, "%27")).join("/");

  // Return the encoded URL
  return url ? url.protocol + path : path;
}

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

/**
 * Expand a flattened object to be a standard nested Object by converting all dot-notation keys to inner objects.
 * Only simple objects will be expanded. Other Object types like class instances will be retained as-is.
 * @param {object} obj      The object to expand
 * @returns {object}        An expanded object
 */
function expandObject(obj) {
  const _expand = (value, depth) => {
    if ( depth > 32 ) throw new Error("Maximum object expansion depth exceeded");
    if ( !value ) return value;
    if ( Array.isArray(value) ) return value.map(v => _expand(v, depth+1)); // Map arrays
    if ( getType(value) !== "Object" ) return value;                        // Return advanced objects directly
    const expanded = {};                                                    // Expand simple objects
    for ( const [k, v] of Object.entries(value) ) {
      setProperty(expanded, k, _expand(v, depth+1));
    }
    return expanded;
  };
  return _expand(obj, 0);
}

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

/**
 * Filter the contents of some source object using the structure of a template object.
 * Only keys which exist in the template are preserved in the source object.
 *
 * @param {object} source           An object which contains the data you wish to filter
 * @param {object} template         An object which contains the structure you wish to preserve
 * @param {object} [options={}]     Additional options which customize the filtration
 * @param {boolean} [options.deletionKeys=false]    Whether to keep deletion keys
 * @param {boolean} [options.templateValues=false]  Instead of keeping values from the source, instead draw values
 *                                                  from the template
 * @returns {object}                The filtered object
 *
 * @example Filter an object
 * ```js
 * const source = {foo: {number: 1, name: "Tim", topping: "olives"}, bar: "baz"};
 * const template = {foo: {number: 0, name: "Mit", style: "bold"}, other: 72};
 * filterObject(source, template); // {foo: {number: 1, name: "Tim"}};
 * filterObject(source, template, {templateValues: true}); // {foo: {number: 0, name: "Mit"}};
 * ```
 */
function filterObject(source, template, {deletionKeys=false, templateValues=false}={}) {

  // Validate input
  const ts = getType(source);
  const tt = getType(template);
  if ( (ts !== "Object") || (tt !== "Object")) throw new Error("One of source or template are not Objects!");

  // Define recursive filtering function
  const _filter = function(s, t, filtered) {
    for ( const [k, v] of Object.entries(s) ) {
      const has = t.hasOwnProperty(k);
      const x = t[k];

      // Case 1 - inner object
      if ( has && (getType(v) === "Object") && (getType(x) === "Object") ) filtered[k] = _filter(v, x, {});

      // Case 2 - inner key
      else if ( has ) filtered[k] = templateValues ? x : v;

      // Case 3 - special key
      else if ( deletionKeys && isDeletionKey(k) ) filtered[k] = v;
    }
    return filtered;
  };

  // Begin filtering at the outermost layer
  return _filter(source, template, {});
}

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

/**
 * Flatten a possibly multidimensional object to a one-dimensional one by converting all nested keys to dot notation
 * @param {object} obj        The object to flatten
 * @param {number} [_d=0]     Track the recursion depth to prevent overflow
 * @returns {object}          A flattened object
 */
function flattenObject(obj, _d=0) {
  const flat = {};
  if ( _d > 100 ) {
    throw new Error("Maximum depth exceeded");
  }
  for ( const [k, v] of Object.entries(obj) ) {
    const t = getType(v);
    if ( t === "Object" ) {
      if ( isEmpty(v) ) flat[k] = v;
      const inner = flattenObject(v, _d+1);
      for ( const [ik, iv] of Object.entries(inner) ) {
        flat[`${k}.${ik}`] = iv;
      }
    }
    else flat[k] = v;
  }
  return flat;
}

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

/**
 * Obtain references to the parent classes of a certain class.
 * @param {Function} cls            An class definition
 * @returns {Array<typeof Object>}  An array of parent classes which the provided class extends
 */
function getParentClasses(cls) {
  if ( typeof cls !== "function" ) {
    throw new Error("The provided class is not a type of Function");
  }
  const parents = [];
  let parent = Object.getPrototypeOf(cls);
  while ( parent ) {
    parents.push(parent);
    parent = Object.getPrototypeOf(parent);
  }
  return parents.slice(0, -2);
}

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

/**
 * Get the URL route for a certain path which includes a path prefix, if one is set
 * @param {string} path             The Foundry URL path
 * @param {string|null} [prefix]    A path prefix to apply
 * @returns {string}                The absolute URL path
 */
function getRoute(path, {prefix}={}) {
  prefix = prefix === undefined ? globalThis.ROUTE_PREFIX : prefix || null;
  path = path.replace(/(^\/+)|(\/+$)/g, ""); // Strip leading and trailing slashes
  let paths = [""];
  if ( prefix ) paths.push(prefix);
  paths = paths.concat([path.replace(/(^\/)|(\/$)/g, "")]);
  return paths.join("/");
}

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

/**
 * The identifiable class types.
 * @type {Array<[class: Function, name: string]>}
 */
const typePrototypes = [
  [Array, "Array"],
  [Set, "Set"],
  [Map, "Map"],
  [Promise, "Promise"],
  [Error, "Error"],
  [Color, "number"]
];

/**
 * Learn the underlying data type of some variable. Supported identifiable types include:
 * undefined, null, number, string, boolean, function, Array, Set, Map, Promise, Error,
 * HTMLElement (client side only), Object (plain objects).
 * If the type isn't identifiable, Unknown is returned.
 * @param {*} variable  A provided variable
 * @returns {string}    The named type of the token
 */
function getType(variable) {

  // Primitive types, handled with simple typeof check
  const typeOf = typeof variable;
  if ( typeOf !== "object" ) return typeOf;

  // Special cases of object
  if ( variable === null ) return "null";
  if ( !variable.constructor ) return "Object"; // Object with the null prototype.
  if ( variable.constructor === Object ) return "Object"; // Simple objects

  // Match prototype instances
  for ( const [cls, type] of typePrototypes ) {
    if ( variable instanceof cls ) return type;
  }
  if ( ("HTMLElement" in globalThis) && (variable instanceof globalThis.HTMLElement) ) return "HTMLElement";

  // Unknown Object type
  return "Unknown";
}

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

/**
 * A helper function which tests whether an object has a property or nested property given a string key.
 * The method also supports arrays if the provided key is an integer index of the array.
 * The string key supports the notation a.b.c which would return true if object[a][b][c] exists
 * @param {object} object   The object to traverse
 * @param {string} key      An object property with notation a.b.c
 * @returns {boolean}       An indicator for whether the property exists
 */
function hasProperty(object, key) {
  if ( !key || !object ) return false;
  if ( key in object ) return true;
  let target = object;
  for ( const p of key.split(".") ) {
    if ( !target || (typeof target !== "object") ) return false;
    if ( p in target ) target = target[p];
    else return false;
  }
  return true;
}

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

/**
 * A helper function which searches through an object to retrieve a value by a string key.
 * The method also supports arrays if the provided key is an integer index of the array.
 * The string key supports the notation a.b.c which would return object[a][b][c]
 * @param {object} object   The object to traverse
 * @param {string} key      An object property with notation a.b.c
 * @returns {*}             The value of the found property
 */
function getProperty(object, key) {
  if ( !key || !object ) return undefined;
  if ( key in object ) return object[key];
  let target = object;
  for ( const p of key.split(".") ) {
    if ( !target ) return undefined;
    const type = typeof target;
    if ( (type !== "object") && (type !== "function") ) return undefined;
    if ( p in target ) target = target[p];
    else return undefined;
  }
  return target;
}

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

const SKIPPED_PROPERTIES = new Set(["__proto__", "constructor", "prototype"]);

/**
 * A helper function which searches through an object to assign a value using a string key
 * This string key supports the notation a.b.c which would target object[a][b][c]
 * @param {object} object   The object to update
 * @param {string} key      The string key
 * @param {*} value         The value to be assigned
 * @returns {boolean}       Whether the value was changed from its previous value
 */
function setProperty(object, key, value) {
  if ( !key || SKIPPED_PROPERTIES.has(key) ) return false;

  // Convert the key to an object reference if it contains dot notation
  let target = object;
  if ( key.indexOf(".") !== -1 ) {
    const parts = key.split(".");
    if ( parts.some(p => SKIPPED_PROPERTIES.has(p)) ) return false;
    key = parts.pop();
    target = parts.reduce((target, p) => {
      if ( !(p in target) ) target[p] = {};
      return target[p];
    }, object);
  }

  // Update the target
  if ( !(key in target) || (target[key] !== value) ) {
    target[key] = value;
    return true;
  }
  return false;
}

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

/**
 * A helper function which searches through an object to delete a value by a string key.
 * The string key supports the notation a.b.c which would delete object[a][b][c]
 * @param {object} object   The object to traverse
 * @param {string} key      An object property with notation a.b.c
 * @returns {boolean}       Was the property deleted?
 */
function deleteProperty(object, key) {
  if ( !key || !object ) return false;
  let parent;
  let target = object;
  const parts = key.split(".");
  for ( const p of parts ) {
    if ( !target ) return false;
    const type = typeof target;
    if ( (type !== "object") && (type !== "function") ) return false;
    if ( !(p in target) ) return false;
    parent = target;
    target = parent[p];
  }
  delete parent[parts.at(-1)];
  return true;
}

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

/**
 * Invert an object by assigning its values as keys and its keys as values.
 * @param {object} obj    The original object to invert
 * @returns {object}      The inverted object with keys and values swapped
 */
function invertObject(obj) {
  const inverted = {};
  for ( const [k, v] of Object.entries(obj) ) {
    if ( v in inverted ) throw new Error("The values of the provided object must be unique in order to invert it.");
    inverted[v] = k;
  }
  return inverted;
}

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

/**
 * Return whether a target version (v1) is more advanced than some other reference version (v0).
 * Supports either numeric or string version comparison with version parts separated by periods.
 * @param {number|string} v1    The target version
 * @param {number|string} v0    The reference version
 * @returns {boolean}           Is v1 a more advanced version than v0?
 */
function isNewerVersion(v1, v0) {

  // Handle numeric versions
  if ( (typeof v1 === "number") && (typeof v0 === "number") ) return v1 > v0;

  // Handle string parts
  const v1Parts = String(v1).split(".");
  const v0Parts = String(v0).split(".");

  // Iterate over version parts
  for ( const [i, p1] of v1Parts.entries() ) {
    const p0 = v0Parts[i];

    // If the prior version doesn't have a part, v1 wins
    if ( p0 === undefined ) return true;

    // If both parts are numbers, use numeric comparison to avoid cases like "12" < "5"
    if ( Number.isNumeric(p0) && Number.isNumeric(p1) ) {
      if ( Number(p1) !== Number(p0) ) return Number(p1) > Number(p0);
    }

    // Otherwise, compare as strings
    if ( p1 !== p0 ) return p1 > p0;
  }

  // If there are additional parts to v0, it is not newer
  if ( v0Parts.length > v1Parts.length ) return false;

  // If we have not returned false by now, it's either newer or the same
  return !v1Parts.equals(v0Parts);
}

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

/**
 * Test whether a value is empty-like; either undefined or a content-less object.
 * @param {*} value       The value to test
 * @returns {boolean}     Is the value empty-like?
 */
function isEmpty(value) {
  const t = getType(value);
  switch ( t ) {
    case "undefined":
      return true;
    case "null":
      return true;
    case "Array":
      return !value.length;
    case "Object":
      return !Object.keys(value).length;
    case "Set":
    case "Map":
      return !value.size;
    default:
      return false;
  }
}

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

/**
 * Update a source object by replacing its keys and values with those from a target object.
 *
 * @param {object} original                           The initial object which should be updated with values from the
 *                                                    target
 * @param {object} [other={}]                         A new object whose values should replace those in the source
 * @param {object} [options={}]                       Additional options which configure the merge
 * @param {boolean} [options.insertKeys=true]         Control whether to insert new top-level objects into the resulting
 *                                                    structure which do not previously exist in the original object.
 * @param {boolean} [options.insertValues=true]       Control whether to insert new nested values into child objects in
 *                                                    the resulting structure which did not previously exist in the
 *                                                    original object.
 * @param {boolean} [options.overwrite=true]          Control whether to replace existing values in the source, or only
 *                                                    merge values which do not already exist in the original object.
 * @param {boolean} [options.recursive=true]          Control whether to merge inner-objects recursively (if true), or
 *                                                    whether to simply replace inner objects with a provided new value.
 * @param {boolean} [options.inplace=true]            Control whether to apply updates to the original object in-place
 *                                                    (if true), otherwise the original object is duplicated and the
 *                                                    copy is merged.
 * @param {boolean} [options.enforceTypes=false]      Control whether strict type checking requires that the value of a
 *                                                    key in the other object must match the data type in the original
 *                                                    data to be merged.
 * @param {boolean} [options.performDeletions=false]  Control whether to perform deletions on the original object if
 *                                                    deletion keys are present in the other object.
 * @param {number} [_d=0]                             A privately used parameter to track recursion depth.
 * @returns {object}                                  The original source object including updated, inserted, or
 *                                                    overwritten records.
 *
 * @example Control how new keys and values are added
 * ```js
 * mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: false}); // {k1: "v1"}
 * mergeObject({k1: "v1"}, {k2: "v2"}, {insertKeys: true});  // {k1: "v1", k2: "v2"}
 * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: false}); // {k1: {i1: "v1"}}
 * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {insertValues: true}); // {k1: {i1: "v1", i2: "v2"}}
 * ```
 *
 * @example Control how existing data is overwritten
 * ```js
 * mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: true}); // {k1: "v2"}
 * mergeObject({k1: "v1"}, {k1: "v2"}, {overwrite: false}); // {k1: "v1"}
 * ```
 *
 * @example Control whether merges are performed recursively
 * ```js
 * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: false}); // {k1: {i2: "v2"}}
 * mergeObject({k1: {i1: "v1"}}, {k1: {i2: "v2"}}, {recursive: true}); // {k1: {i1: "v1", i2: "v2"}}
 * ```
 *
 * @example Deleting an existing object key
 * ```js
 * mergeObject({k1: "v1", k2: "v2"}, {"-=k1": null}, {performDeletions: true});   // {k2: "v2"}
 * ```
 *
 * @example Explicitly replacing an inner object key
 * ```js
 * mergeObject({k1: {i1: "v1"}}, {"==k1": {i2: "v2"}}, {performDeletions: true}); // {k1: {i2: "v2"}}
 * ```
 */
function mergeObject(original, other={}, {insertKeys=true, insertValues=true, overwrite=true, recursive=true,
  inplace=true, enforceTypes=false, performDeletions=false}={}, _d=0) {
  other = other || {};
  if (!(original instanceof Object) || !(other instanceof Object)) {
    throw new Error("One of original or other are not Objects!");
  }
  const options = {insertKeys, insertValues, overwrite, recursive, enforceTypes, performDeletions};

  // Special handling at depth 0
  if ( _d === 0 ) {
    if ( Object.keys(other).some(k => /\./.test(k)) ) other = expandObject(other);
    if ( Object.keys(original).some(k => /\./.test(k)) ) {
      const expanded = expandObject(original);
      if ( inplace ) {
        Object.keys(original).forEach(k => delete original[k]);
        Object.assign(original, expanded);
      }
      else original = expanded;
    }
    else if ( !inplace ) original = deepClone(original);
  }

  // Iterate over the other object
  for ( const k of Object.keys(other) ) {
    const v = other[k];
    if ( original.hasOwnProperty(k) ) _mergeUpdate(original, k, v, _d+1, options);
    else _mergeInsert(original, k, v, _d+1, options);
  }
  return original;
}

/**
 * A helper function for merging objects when the target key does not exist in the original.
 * @ignore
 */
function _mergeInsert(original, k, v, _d, {insertKeys, insertValues, performDeletions}={}) {

  // Force replace a specific key
  if ( performDeletions && k.startsWith("==") ) {
    original[k.slice(2)] = applySpecialKeys(v);
    return;
  }

  // Delete a specific key
  if ( performDeletions && k.startsWith("-=") ) {
    if ( v !== null ) throw new Error("Removing a key using the -= deletion syntax requires the value of that"
      + " deletion key to be null, for example {-=key: null}");
    delete original[k.slice(2)];
    return;
  }

  // Insert a new object, either recursively or directly
  const canInsert = ((_d <= 1) && insertKeys) || ((_d > 1) && insertValues);
  if ( !canInsert ) return;
  if ( getType(v) === "Object" ) {
    original[k] = mergeObject({}, v, {insertKeys: true, inplace: true, performDeletions});
    return;
  }
  original[k] = v;
}

/**
 * A helper function for merging objects when the target key exists in the original.
 * @ignore
 */
function _mergeUpdate(original, k, v, _d, {insertKeys, insertValues, enforceTypes, overwrite, recursive,
  performDeletions}={}) {
  const x = original[k];
  const tv = getType(v);
  const tx = getType(x);
  const ov = (tv === "Object") || (tv === "Unknown");
  const ox = (tx === "Object") || (tx === "Unknown");

  // Recursively merge an inner object
  if ( ov && ox && recursive ) {
    return mergeObject(x, v, {
      insertKeys, insertValues, overwrite, enforceTypes, performDeletions,
      inplace: true
    }, _d);
  }

  // Overwrite an existing value
  if ( overwrite ) {
    if ( (tx !== "undefined") && (tv !== tx) && enforceTypes ) {
      throw new Error("Mismatched data types encountered during object merge.");
    }
    original[k] = applySpecialKeys(v);
  }
}

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

/**
 * Parse an S3 key to learn the bucket and the key prefix used for the request.
 * @param {string} key  A fully qualified key name or prefix path.
 * @returns {{bucket: string|null, keyPrefix: string}}
 */
function parseS3URL(key) {
  const url = URL.parseSafe(key);
  if ( url ) return {
    bucket: url.host.split(".").shift(),
    keyPrefix: url.pathname.slice(1)
  };
  return {
    bucket: null,
    keyPrefix: ""
  };
}

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

/**
 * Generate a random alphanumeric string ID of a given requested length using `crypto.getRandomValues()`.
 * @param {number} length    The length of the random string to generate, which must be at most 16384.
 * @returns {string}         A string containing random letters (A-Z, a-z) and numbers (0-9).
 */
function randomID(length=16) {
  const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  const cutoff = 0x100000000 - (0x100000000 % chars.length);
  const random = new Uint32Array(length);
  do {
    crypto.getRandomValues(random);
  } while ( random.some(x => x >= cutoff) );
  let id = "";
  for ( let i = 0; i < length; i++ ) id += chars[random[i] % chars.length];
  return id;
}

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

/**
 * Format a file size to an appropriate order of magnitude.
 * @param {number} size  The size in bytes.
 * @param {object} [options]
 * @param {number} [options.decimalPlaces=2]  The number of decimal places to round to.
 * @param {2|10} [options.base=10]            The base to use. In base 10 a kilobyte is 1000 bytes. In base 2 it is
 *                                            1024 bytes.
 * @returns {string}
 */
function formatFileSize(size, {decimalPlaces=2, base=10}={}) {
  const units = ["B", "kB", "MB", "GB", "TB"];
  const divisor = base === 2 ? 1024 : 1000;
  let iterations = 0;
  while ( (iterations < units.length) && (size > divisor) ) {
    size /= divisor;
    iterations++;
  }
  return `${size.toFixed(decimalPlaces)} ${units[iterations]}`;
}

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

/**
 * Parse a UUID into its constituent parts, identifying the type and ID of the referenced document.
 * The ResolvedUUID result also identifies a "primary" document which is a root-level document either in the game
 * World or in a Compendium pack which is a parent of the referenced document.
 * @param {string} uuid                  The UUID to parse.
 * @param {object} [options]             Options to configure parsing behavior.
 * @param {foundry.abstract.Document} [options.relative]  A document to resolve relative UUIDs against.
 * @returns {ResolvedUUID|null} Returns, if possible, the Collection, Document Type, and Document ID to resolve the
 *                              parent document, as well as the remaining Embedded Document parts, if any.
 */
function parseUuid(uuid, {relative}={}) {
  if ( !uuid ) return null;

  // Relative UUID
  if ( uuid.startsWith(".") && relative ) return _resolveRelativeUuid(uuid, relative);

  // Split UUID parts
  const parts = uuid.split(".");
  let id;
  let type;
  let primaryId;
  let primaryType;
  let collection;

  // Compendium Documents
  if ( parts[0] === "Compendium" ) {
    // Re-interpret legacy compendium UUIDs which did not explicitly include their parent document type.
    let packType = _resolvePrimaryType(parts);
    if ( packType !== parts[3] ) parts.splice(3, 0, packType);

    // Check for redirects.
    if ( game.compendiumUUIDRedirects ) {
      const node = game.compendiumUUIDRedirects.nodeAtPrefix(parts, { hasLeaves: true });
      const leaves = node?.[foundry.utils.StringTree.leaves];
      if ( leaves.length ) {
        const redirect = leaves[0];
        if ( redirect?.length ) {
          parts.splice(0, redirect.length, ...redirect);
          packType ??= _resolvePrimaryType(parts);
          parts[3] = packType;
        }
      }
    }
    const [, scope, packName] = parts.splice(0, 3);
    collection = game.packs.get(`${scope}.${packName}`);
    [primaryType, primaryId] = parts.splice(0, 2);
    if ( primaryType && (primaryType === packType) ) {
      uuid = ["Compendium", scope, packName, primaryType, primaryId, ...parts].join(".");
    }
  }

  // World Documents
  else {
    [primaryType, primaryId] = parts.splice(0, 2);
    collection = globalThis.db?.[primaryType] ?? CONFIG[primaryType]?.collection?.instance;
  }

  // Embedded Documents
  if ( parts.length ) {
    if ( parts.length % 2 ) return null;
    id = parts.at(-1);
    type = parts.at(-2);
  }

  // Primary Documents
  else {
    id = primaryId;
    type = primaryType ?? undefined;
    primaryId = primaryType = undefined;
  }

  // Return resolved UUID
  return {uuid, type, id, collection, embedded: parts, primaryType, primaryId,
    documentType: primaryType ?? type, documentId: primaryId ?? id};
}

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

/**
 * Resolve a UUID relative to another document.
 * The general-purpose algorithm for resolving relative UUIDs is as follows:
 * 1. If the number of parts is odd, remove the first part and resolve it against the current document and update the
 *    current document.
 * 2. If the number of parts is even, resolve embedded documents against the current document.
 * @param {string} uuid        The UUID to resolve.
 * @param {foundry.abstract.Document} relative  The document to resolve against.
 * @returns {ResolvedUUID|null}     A resolved UUID object, if possible to create, or otherwise `null`.
 */
function _resolveRelativeUuid(uuid, relative) {
  if ( !(relative instanceof foundry.abstract.Document) ) {
    throw new Error("A relative Document instance must be provided to _resolveRelativeUuid");
  }
  uuid = uuid.substring(1);
  const parts = uuid.split(".");
  if ( !parts.length ) return null;
  let id;
  let type;
  let root;
  let primaryType;
  let primaryId;

  // Identify the root document and its collection
  const getRoot = doc => {
    if ( doc.parent ) parts.unshift(doc.documentName, doc.id);
    return doc.parent ? getRoot(doc.parent) : doc;
  };

  // Even-numbered parts include an explicit child document type
  if ( (parts.length % 2) === 0 ) {
    root = getRoot(relative);
    id = parts.at(-1);
    type = parts.at(-2);
    primaryType = root.documentName;
    primaryId = root.id;
    uuid = [primaryType, primaryId, ...parts].join(".");
  }

  // Relative Embedded Document
  else if ( relative.parent ) {
    id = parts.at(-1);
    type = relative.documentName;
    parts.unshift(type);
    root = getRoot(relative.parent);
    primaryType = root.documentName;
    primaryId = root.id;
    uuid = [primaryType, primaryId, ...parts].join(".");
  }

  // Relative Document
  else {
    root = relative;
    id = parts.pop();
    type = relative.documentName;
    uuid = [type, id].join(".");
  }

  // Recreate fully-qualified UUID and return the resolved result
  if ( root.pack ) uuid = `Compendium.${root.pack}.${uuid}`;
  return {uuid, type, id, collection: root.collection, primaryType, primaryId, embedded: parts,
    documentType: primaryType ?? type, documentId: primaryId ?? id};
}

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

/**
 * Attempt to resolve a possibly missing primary Document type for a legacy Compendium UUID.
 * @param {string[]} parts
 * @returns {string|null} The Document type, if found, or null
 */
function _resolvePrimaryType(parts) {
  if ( CONST.COMPENDIUM_DOCUMENT_TYPES.includes(parts[3]) || (parts[3] === "Folder") ) return parts[3];
  return game.packs.get(`${parts[1]}.${parts[2]}`)?.documentName ?? null;
}

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

/**
 * Build a Universally Unique Identifier (uuid) from possibly limited data. An attempt will be made to resolve omitted
 * components, but an identifier and at least one of documentName, parent, and pack are required.
 * @param {object} context  Data for building the uuid
 * @param {string} context.id              The identifier of the document
 * @param {string} [context.documentName]  The document name (or type)
 * @param {Document|null} [context.parent] The document's parent, if any
 * @param {string|null} [context.pack]     The document's compendium pack, if applicable
 * @returns {string|null} A well-formed Document uuid unless one is unable to be created
 */
function buildUuid({id, documentName, parent, pack}) {
  if ( !id || (!documentName && !parent && !pack) ) return null;
  if ( !pack && !parent && !CONST.WORLD_DOCUMENT_TYPES.includes(documentName) ) {
    console.warn("Only a documentName were provided, but it is not a valid world Document type.");
  }
  if ( pack ) documentName ||= game.packs.get(pack)?.documentName;
  if ( parent && !documentName ) {
    // Note: the possibility exists, however unlikely, that multiple embedded collections will have the same ID
    for ( const collection of Object.values(parent.collections) ) {
      if ( collection.has(id) ) {
        documentName = collection.documentName;
        break;
      }
    }
  }
  return [
    parent?.uuid,
    pack && !parent ? ["Compendium", pack] : null,
    documentName,
    id
  ].flat().filterJoin(".");
}

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

/**
 * The table used for escaping bad characters.
 * @type {Record<string, string>}
 */
const ESCAPE_HTML_TABLE = {
  "&": "&amp;",
  "<": "&lt;",
  ">": "&gt;",
  '"': "&quot;",
  "'": "&#x27;"
};

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

/**
 * The regex of bad characters that need to be escaped.
 * @type {RegExp}
 */
const ESCAPE_HTML_REGEX = new RegExp(`[${Object.keys(ESCAPE_HTML_TABLE).join("")}]`, "g");

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

/**
 * The table used for unescaping HTML character entities of bad characters.
 * @type {Record<string, string>}
 */
const UNESCAPE_HTML_TABLE = invertObject(ESCAPE_HTML_TABLE);

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

/**
 * The regex of HTML entities of bad characters that need to be unescaped.
 * @type {RegExp}
 */
const UNESCAPE_HTML_REGEX = new RegExp(`${Object.keys(UNESCAPE_HTML_TABLE).join("|")}`, "g");

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

/**
 * Escape the given unescaped string.
 *
 * Escaped strings are safe to use inside inner HTML of most tags and in most quoted HTML attributes.
 * They are not NOT safe to use in `<script>` tags, unquoted attributes, `href`, `onmouseover`, and similar.
 * They must be unescaped first if they are used inside a context that would escape them.
 *
 * Handles only `&`, `<`, `>`, `"`, and `'`.
 * @see {@link foundry.utils.unescapeHTML}
 * @param {string|any} value    An unescaped string
 * @returns {string}            The escaped string
 */
function escapeHTML(value) {
  return String(value).replace(ESCAPE_HTML_REGEX, c => ESCAPE_HTML_TABLE[c]);
}

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

/**
 * Unescape the given escaped string.
 *
 * Handles only `&amp;`, `&lt;`, `&gt;`, `&quot;`, and `&#x27;`.
 * @see {@link foundry.utils.escapeHTML}
 * @param {string} value    An escaped string
 * @returns {string}        The escaped string
 */
function unescapeHTML(value) {
  return value.replace(UNESCAPE_HTML_REGEX, c => UNESCAPE_HTML_TABLE[c]);
}

/**
 * Flatten nested arrays by concatenating their contents
 * @returns {any[]}    An array containing the concatenated inner values
 */
function deepFlatten() {
  return this.reduce((acc, val) => Array.isArray(val) ? acc.concat(val.deepFlatten()) : acc.concat(val), []);
}

/**
 * Test element-wise equality of the values of this array against the values of another array
 * @param {any[]} other   Some other array against which to test equality
 * @returns {boolean}     Are the two arrays element-wise equal?
 */
function equals$1(other) {
  if ( !(other instanceof Array) || (other.length !== this.length) ) return false;
  return this.every((v0, i) => {
    const v1 = other[i];
    const t0 = getType(v0);
    const t1 = getType(v1);
    if ( t0 !== t1 ) return false;
    if ( v0?.equals instanceof Function ) return v0.equals(v1);
    if ( t0 === "Object" ) return objectsEqual(v0, v1);
    return v0 === v1;
  });
}

/**
 * Partition an original array into two children array based on a logical test
 * Elements which test as false go into the first result while elements testing as true appear in the second
 * @template T
 * @param {(element: T) => boolean} rule
 * @returns {[T[], T[]]}    An Array of length two whose elements are the partitioned pieces of the original
 */
function partition(rule) {
  return this.reduce((acc, val) => {
    const test = rule(val);
    acc[Number(test)].push(val);
    return acc;
  }, [[], []]);
}

/**
 * Join an Array using a string separator, first filtering out any parts which return a false-y value
 * @param {string} sep    The separator string
 * @returns {string}      The joined string, filtered of any false values
 */
function filterJoin(sep) {
  return this.filter(p => !!p).join(sep);
}

/**
 * Find an element within the Array and remove it from the array
 * @template T
 * @param {(element: T) => boolean} find   A function to use as input to findIndex
 * @param {T} [replace]     A replacement for the spliced element
 * @returns {T|null}        The replacement element, the removed element, or null if no element was found.
 * @see Array#splice
 */
function findSplice(find, replace) {
  const idx = this.findIndex(find);
  if ( idx === -1 ) return null;
  if ( replace !== undefined ) {
    this.splice(idx, 1, replace);
    return replace;
  } else {
    const item = this[idx];
    this.splice(idx, 1);
    return item;
  }
}

/**
 * Create and initialize an array of length n with integers from 0 to n-1
 * @param {number} n        The desired array length
 * @param {number} [min=0]  A desired minimum number from which the created array starts
 * @returns {number[]}      An array of integers from min to min+n
 */
function fromRange(n, min=0) {
  return Array.from({length: n}, (v, i) => i + min);
}

// Define primitives on the Array prototype
Object.defineProperties(Array.prototype, {
  deepFlatten: {value: deepFlatten},
  equals: {value: equals$1},
  filterJoin: {value: filterJoin},
  findSplice: {value: findSplice},
  partition: {value: partition}
});
Object.defineProperties(Array, {
  fromRange: {value: fromRange}
});

/**
 * Test whether a Date instance is valid.
 * A valid date returns a number for its timestamp, and NaN otherwise.
 * NaN is never equal to itself.
 * @returns {boolean}
 */
function isValid() {
  // eslint-disable-next-line no-self-compare
  return this.getTime() === this.getTime();
}

/**
 * Return a standard YYYY-MM-DD string for the Date instance.
 * @returns {string}    The date in YYYY-MM-DD format
 */
function toDateInputString() {
  const yyyy = this.getFullYear();
  const mm = (this.getMonth() + 1).paddedString(2);
  const dd = this.getDate().paddedString(2);
  return `${yyyy}-${mm}-${dd}`;
}

/**
 * Return a standard H:M:S.Z string for the Date instance.
 * @returns {string}    The time in H:M:S format
 */
function toTimeInputString() {
  return this.toTimeString().split(" ")[0];
}

// Define primitives on the Date prototype
Object.defineProperties(Date.prototype, {
  isValid: {value: isValid},
  toDateInputString: {value: toDateInputString},
  toTimeInputString: {value: toTimeInputString}
});

/**
 * √3
 * @type {number}
 */
const SQRT3 = 1.7320508075688772;

/**
 * √⅓
 * @type {number}
 */
const SQRT1_3 = 0.5773502691896257;

/**
 * Bound a number between some minimum and maximum value, inclusively.
 * @param {number} num    The current value
 * @param {number} min    The minimum allowed value
 * @param {number} max    The maximum allowed value
 * @returns {number}      The clamped number
 */
function clamp(num, min, max) {
  return Math.min(Math.max(num, min), max);
}

/**
 * @deprecated since v12
 * @ignore
 */
function clamped(num, min, max) {
  const msg = "Math.clamped is deprecated in favor of Math.clamp.";
  foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
  return clamp(num, min, max);
}

/**
 * Linear interpolation function
 * @param {number} a   An initial value when weight is 0.
 * @param {number} b   A terminal value when weight is 1.
 * @param {number} w   A weight between 0 and 1.
 * @returns {number}   The interpolated value between a and b with weight w.
 */
function mix(a, b, w) {
  return (a * (1 - w)) + (b * w);
}

/**
 * Transform an angle in degrees to be bounded within the domain [0, 360)
 * @param {number} degrees  An angle in degrees
 * @param {number} _base    DEPRECATED
 * @returns {number}        The same angle on the range [0, 360)
 */
function normalizeDegrees(degrees, _base) {
  const d = degrees % 360;
  if ( _base !== undefined ) {
    const msg = "Math.normalizeDegrees(degrees, base) is deprecated.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    if ( _base === 360 ) return d <= 0 ? d + 360 : d;
  }
  return d < 0 ? d + 360 : d;
}

/**
 * Transform an angle in radians to be bounded within the domain [-PI, PI]
 * @param {number} radians  An angle in degrees
 * @returns {number}        The same angle on the range [-PI, PI]
 */
function normalizeRadians(radians) {
  const pi = Math.PI;
  const pi2 = pi * 2;
  return radians - (pi2 * Math.floor((radians + pi) / pi2));
}

/**
 * @deprecated since v12
 * @ignore
 */
function roundDecimals(number, places) {
  const msg = "Math.roundDecimals is deprecated.";
  foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
  places = Math.max(Math.trunc(places), 0);
  const scl = Math.pow(10, places);
  return Math.round(number * scl) / scl;
}

/**
 * Transform an angle in radians to a number in degrees
 * @param {number} angle    An angle in radians
 * @returns {number}        An angle in degrees
 */
function toDegrees(angle) {
  return angle * (180 / Math.PI);
}

/**
 * Transform an angle in degrees to an angle in radians
 * @param {number} angle    An angle in degrees
 * @returns {number}        An angle in radians
 */
function toRadians(angle) {
  return angle * (Math.PI / 180);
}

/**
 * Returns the value of the oscillation between `a` and `b` at time `t`.
 * @param {number} a                              The minimium value of the oscillation
 * @param {number} b                              The maximum value of the oscillation
 * @param {number} t                              The time
 * @param {number} [p=1]                          The period (must be nonzero)
 * @param {(x: number) => number} [f=Math.cos]    The periodic function (its period must be 2π)
 * @returns {number}                              `((b - a) * (f(2π * t / p) + 1) / 2) + a`
 */
function oscillation(a, b, t, p=1, f=Math.cos) {
  return ((b - a) * (f((2 * Math.PI * t) / p) + 1) / 2) + a;
}

// Define properties on the Math environment
Object.defineProperties(Math, {
  SQRT3: {value: SQRT3},
  SQRT1_3: {value: SQRT1_3},
  clamp: {
    value: clamp,
    configurable: true,
    writable: true
  },
  clamped: {
    value: clamped,
    configurable: true,
    writable: true
  },
  mix: {
    value: mix,
    configurable: true,
    writable: true
  },
  normalizeDegrees: {
    value: normalizeDegrees,
    configurable: true,
    writable: true
  },
  normalizeRadians: {
    value: normalizeRadians,
    configurable: true,
    writable: true
  },
  roundDecimals: {
    value: roundDecimals,
    configurable: true,
    writable: true
  },
  toDegrees: {
    value: toDegrees,
    configurable: true,
    writable: true
  },
  toRadians: {
    value: toRadians,
    configurable: true,
    writable: true
  },
  oscillation: {
    value: oscillation,
    configurable: true,
    writable: true
  }
});

/**
 * Test for near-equivalence of two numbers within some permitted epsilon
 * @param {number} n      Some other number
 * @param {number} e      Some permitted epsilon, by default 1e-8
 * @returns {boolean}     Are the numbers almost equal?
 */
function almostEqual(n, e=1e-8) {
  return Math.abs(this - n) < e;
}

/**
 * Transform a number to an ordinal string representation. i.e.
 * 1 => 1st
 * 2 => 2nd
 * 3 => 3rd
 * @returns {string}
 */
function ordinalString() {
  const s = ["th", "st", "nd", "rd"];
  const v = this % 100;
  return this + (s[(v-20)%10]||s[v]||s[0]);
}

/**
 * Return a string front-padded by zeroes to reach a certain number of numeral characters
 * @param {number} digits     The number of characters desired
 * @returns {string}          The zero-padded number
 */
function paddedString(digits) {
  return this.toString().padStart(digits, "0");
}

/**
 * Return a locally formatted string prefaced by the explicit sign of the number (+) or (-). Use of this method is
 * intended for display purposes only.
 * @this {number}
 * @returns {string}          The signed number as a locally formatted string
 */
function signedString() {
  const n = this.toLocaleString(game.i18n.lang);
  if ( this === 0 ) return n;
  if ( this < 0 ) return n.replace("-", "−"); // Minus sign character
  else return `+${n}`;
}

/**
 * Round a number to the closest number which substracted from the base is a multiple of the provided interval.
 * This is a convenience function intended to humanize issues of floating point precision.
 * The interval is treated as a standard string representation to determine the amount of decimal truncation applied.
 * @param {number} interval                            The step interval
 * @param {"round"|"floor"|"ceil"} [method="round"]    The rounding method
 * @param {number} [base=0]                            The step base
 * @returns {number}                                   The rounded number
 *
 * @example Round a number to the nearest step interval
 * ```js
 * let n = 17.18;
 * n.toNearest(5); // 15
 * n.toNearest(10); // 20
 * n.toNearest(10, "floor"); // 10
 * n.toNearest(10, "ceil"); // 20
 * n.toNearest(0.25); // 17.25
 * n.toNearest(2, "round", 1); // 17
 * ```
 */
function toNearest(interval=1, method="round", base=0) {
  if ( interval < 0 ) throw new Error("Number#toNearest interval must not be negative");
  const eps = method === "floor" ? 1e-8 : method === "ceil" ? -1e-8 : 0;
  const float = base + (Math[method](((this - base) / interval) + eps) * interval);
  const trunc1 = Number.isInteger(base) ? 0 : String(base).length - 2;
  const trunc2 = Number.isInteger(interval) ? 0 : String(interval).length - 2;
  return Number(float.toFixed(Math.max(trunc1, trunc2)));
}

/**
 * A faster numeric between check which avoids type coercion to the Number object.
 * Since this avoids coercion, if non-numbers are passed in unpredictable results will occur. Use with caution.
 * @param {number} a            The lower-bound
 * @param {number} b            The upper-bound
 * @param {boolean} inclusive   Include the bounding values as a true result?
 * @returns {boolean}           Is the number between the two bounds?
 */
function between(a, b, inclusive=true) {
  const min = Math.min(a, b);
  const max = Math.max(a, b);
  return inclusive ? (this >= min) && (this <= max) : (this > min) && (this < max);
}

/**
 * @see {@link Number#between}
 * @ignore
 */
Number.between = function(num, a, b, inclusive=true) {
  const min = Math.min(a, b);
  const max = Math.max(a, b);
  return inclusive ? (num >= min) && (num <= max) : (num > min) && (num < max);
};

/**
 * Test whether a value is numeric.
 * This is the highest performing algorithm currently available, per https://jsperf.com/isnan-vs-typeof/5
 * @param {*} n        A value to test
 * @returns {boolean}  Is it a number?
 */
function isNumeric(n) {
  if ( n instanceof Array ) return false;
  else if ( [null, ""].includes(n) ) return false;
  // eslint-disable-next-line no-implicit-coercion, no-self-compare
  return +n === +n;
}

/**
 * Attempt to create a number from a user-provided string.
 * @param {string|number} n    The value to convert; typically a string, but may already be a number.
 * @returns {number}           The number that the string represents, or NaN if no number could be determined.
 */
function fromString(n) {
  if ( typeof n === "number" ) return n;
  if ( (typeof n !== "string") || !n.length ) return NaN;
  n = n.replace(/\s+/g, "");
  return Number(n);
}

// Define properties on the Number environment
Object.defineProperties(Number.prototype, {
  almostEqual: {value: almostEqual},
  between: {value: between},
  ordinalString: {value: ordinalString},
  paddedString: {value: paddedString},
  signedString: {value: signedString},
  toNearest: {value: toNearest}
});
Object.defineProperties(Number, {
  isNumeric: {value: isNumeric},
  fromString: {value: fromString}
});

/**
 * Test whether this set is equal to some other set.
 * Sets are equal if they share the same members, independent of order
 * @param {Set<any>} other       Some other set to compare against
 * @returns {boolean}       Are the sets equal?
 */
function equals(other) {
  if ( !(other instanceof Set ) ) return false;
  if ( other.size !== this.size ) return false;
  for ( const element of this ) {
    if ( !other.has(element) ) return false;
  }
  return true;
}

/**
 * Return the first value from the set.
 * @template T
 * @returns {T|undefined} The first element in the set, or undefined
 */
function first() {
  return this.values().next().value;
}


/**
 * Test whether this set has an intersection with another set.
 * @param {Set} other       Another set to compare against
 * @returns {boolean}       Do the sets intersect?
 */
function intersects(other) {
  return !this.isDisjointFrom(other);
}

/**
 * Test whether this set is a subset of some other set.
 * A set is a subset if all its members are also present in the other set.
 * @param {Set} other       Some other set that may be a subset of this one
 * @returns {boolean}       Is the other set a subset of this one?
 * @deprecated since v13
 */
function isSubset(other) {
  const message = "Set#isSubset is deprecated in favor of the native Set#isSubsetOf.";
  foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15, once: true});
  return this.isSubsetOf(other);
}

/**
 * Convert a set to a JSON object by mapping its contents to an array
 * @template T
 * @returns {T[]}           The set elements as an array.
 */
function toObject() {
  return Array.from(this);
}

/**
 * Test whether every element in this Set satisfies a certain test criterion.
 * @template T
 * @param {(element: T, index: number, set: Set<T>) => boolean} test The test criterion to apply. Positional arguments
 *                                                                   are the value, the index of iteration, and the set
 *                                                                   being tested.
 * @returns {boolean}  Does every element in the set satisfy the test criterion?
 * @see Array#every
 */
function every(test) {
  let i = 0;
  for ( const v of this ) {
    if ( !test(v, i, this) ) return false;
    i++;
  }
  return true;
}

/**
 * Filter this set to create a subset of elements which satisfy a certain test criterion.
 * @template T
 * @param {(element: T, index: number, set: Set) => boolean} test The test criterion to apply. Positional arguments are
 *                                                                the value, the index of iteration, and the set being
 *                                                                filtered.
 * @returns {Set<T>} A new Set containing only elements which satisfy the test criterion.
 * @see Array#filter
 */
function filter(test) {
  const filtered = new Set();
  let i = 0;
  for ( const v of this ) {
    if ( test(v, i, this) ) filtered.add(v);
    i++;
  }
  return filtered;
}

/**
 * Find the first element in this set which satisfies a certain test criterion.
 * @template T
 * @param {(element: T, index: number, set: Set<T>) => boolean} test The test criterion to apply. Positional arguments
 *                                                                   are the value, the index of iteration, and the set
 *                                                                   being searched.
 * @returns {T|undefined} The first element in the set which satisfies the test criterion, or undefined.
 * @see Array#find
 */
function find(test) {
  let i = 0;
  for ( const v of this ) {
    if ( test(v, i, this) ) return v;
    i++;
  }
  return undefined;
}

/**
 * Create a new Set where every element is modified by a provided transformation function.
 * @template T
 * @template U
 * @param {(element: T, index: number, set: Set<T>) => U} transform The transformation function to apply. Positional
 *                                                                  arguments are the value, the index of iteration, and
 *                                                                  the set being transformed.
 * @returns {Set<U>} A new Set of equal size containing transformed elements.
 * @see Array#map
 */
function map(transform) {
  const mapped = new Set();
  let i = 0;
  for ( const v of this ) {
    mapped.add(transform(v, i, this));
    i++;
  }
  if ( mapped.size !== this.size ) {
    throw new Error("The Set#map operation illegally modified the size of the set");
  }
  return mapped;
}

/**
 * Create a new Set with elements that are filtered and transformed by a provided reducer function.
 * @template T
 * @param {(accum: any, element: T, index: number, set: Set<T>) => any} reducer A reducer function applied to each
 *                                                                              value. Positional arguments are the
 *                                                                              accumulator, the value, the index of
 *                                                                              iteration, and the set being reduced.
 * @param {any} [initial]         The initial value of the returned accumulator.
 * @returns {any}                 The final value of the accumulator.
 * @see Array#reduce
 */
function reduce(reducer, initial) {
  let i = 0;
  for ( const v of this ) {
    initial = reducer(initial, v, i, this);
    i++;
  }
  return initial;
}

/**
 * Test whether any element in this Set satisfies a certain test criterion.
 * @template T
 * @param {(element: T, index: number, set: Set<T>) => boolean} test The test criterion to apply. Positional arguments
 *                                                                   are the value, the index of iteration, and the set
 *                                                                   being tested.
 * @returns {boolean} Does any element in the set satisfy the test criterion?
 * @see Array#some
 */
function some(test) {
  let i = 0;
  for ( const v of this ) {
    if ( test(v, i, this) ) return true;
    i++;
  }
  return false;
}

// Assign primitives to Set prototype
Object.defineProperties(Set.prototype, {
  equals: {value: equals},
  every: {value: every},
  filter: {value: filter},
  find: {value: find},
  first: {value: first},
  intersects: {value: intersects},
  isSubset: {value: isSubset},
  map: {value: map},
  reduce: {value: reduce},
  some: {value: some},
  toObject: {value: toObject}
});

/**
 * Capitalize a string, transforming it's first character to a capital letter.
 * @returns {string}
 */
function capitalize() {
  if ( !this.length ) return this;
  return this.charAt(0).toUpperCase() + this.slice(1);
}

/**
 * Compare this string (x) with the other string (y) by comparing each character's Unicode code point value.
 * Returns a negative Number if x < y, a positive Number if x > y, or a zero otherwise.
 * This is the same comparision function that used by Array#sort if the compare function argument is omitted.
 * The result is host/locale-independent.
 * @param {string} other    The other string to compare this string to.
 * @returns {number}
 */
function compare(other) {
  return this < other ? -1 : this > other ? 1 : 0;
}

/**
 * Convert a string to Title Case where the first letter of each word is capitalized.
 * @returns {string}
 */
function titleCase() {
  if (!this.length) return this;
  return this.toLowerCase().split(' ').reduce((parts, word) => {
    if ( !word ) return parts;
    const title = word.replace(word[0], word[0].toUpperCase());
    parts.push(title);
    return parts;
  }, []).join(' ');
}

/**
 * Strip any script tags which were included within a provided string.
 * @returns {string}
 */
function stripScripts() {
  let el = document.createElement("div");
  el.innerHTML = this;
  for ( let s of el.getElementsByTagName("script") ) {
    s.parentNode.removeChild(s);
  }
  return el.innerHTML;
}

/**
 * Map characters to lower case ASCII
 * @type {Record<string, string>}
 */
const CHAR_MAP = JSON.parse('{"$":"dollar","%":"percent","&":"and","<":"less",">":"greater","|":"or","¢":"cent","£":"pound","¤":"currency","¥":"yen","©":"(c)","ª":"a","®":"(r)","º":"o","À":"A","Á":"A","Â":"A","Ã":"A","Ä":"A","Å":"A","Æ":"AE","Ç":"C","È":"E","É":"E","Ê":"E","Ë":"E","Ì":"I","Í":"I","Î":"I","Ï":"I","Ð":"D","Ñ":"N","Ò":"O","Ó":"O","Ô":"O","Õ":"O","Ö":"O","Ø":"O","Ù":"U","Ú":"U","Û":"U","Ü":"U","Ý":"Y","Þ":"TH","ß":"ss","à":"a","á":"a","â":"a","ã":"a","ä":"a","å":"a","æ":"ae","ç":"c","è":"e","é":"e","ê":"e","ë":"e","ì":"i","í":"i","î":"i","ï":"i","ð":"d","ñ":"n","ò":"o","ó":"o","ô":"o","õ":"o","ö":"o","ø":"o","ù":"u","ú":"u","û":"u","ü":"u","ý":"y","þ":"th","ÿ":"y","Ā":"A","ā":"a","Ă":"A","ă":"a","Ą":"A","ą":"a","Ć":"C","ć":"c","Č":"C","č":"c","Ď":"D","ď":"d","Đ":"DJ","đ":"dj","Ē":"E","ē":"e","Ė":"E","ė":"e","Ę":"e","ę":"e","Ě":"E","ě":"e","Ğ":"G","ğ":"g","Ģ":"G","ģ":"g","Ĩ":"I","ĩ":"i","Ī":"i","ī":"i","Į":"I","į":"i","İ":"I","ı":"i","Ķ":"k","ķ":"k","Ļ":"L","ļ":"l","Ľ":"L","ľ":"l","Ł":"L","ł":"l","Ń":"N","ń":"n","Ņ":"N","ņ":"n","Ň":"N","ň":"n","Ő":"O","ő":"o","Œ":"OE","œ":"oe","Ŕ":"R","ŕ":"r","Ř":"R","ř":"r","Ś":"S","ś":"s","Ş":"S","ş":"s","Š":"S","š":"s","Ţ":"T","ţ":"t","Ť":"T","ť":"t","Ũ":"U","ũ":"u","Ū":"u","ū":"u","Ů":"U","ů":"u","Ű":"U","ű":"u","Ų":"U","ų":"u","Ŵ":"W","ŵ":"w","Ŷ":"Y","ŷ":"y","Ÿ":"Y","Ź":"Z","ź":"z","Ż":"Z","ż":"z","Ž":"Z","ž":"z","ƒ":"f","Ơ":"O","ơ":"o","Ư":"U","ư":"u","ǈ":"LJ","ǉ":"lj","ǋ":"NJ","ǌ":"nj","Ș":"S","ș":"s","Ț":"T","ț":"t","˚":"o","Ά":"A","Έ":"E","Ή":"H","Ί":"I","Ό":"O","Ύ":"Y","Ώ":"W","ΐ":"i","Α":"A","Β":"B","Γ":"G","Δ":"D","Ε":"E","Ζ":"Z","Η":"H","Θ":"8","Ι":"I","Κ":"K","Λ":"L","Μ":"M","Ν":"N","Ξ":"3","Ο":"O","Π":"P","Ρ":"R","Σ":"S","Τ":"T","Υ":"Y","Φ":"F","Χ":"X","Ψ":"PS","Ω":"W","Ϊ":"I","Ϋ":"Y","ά":"a","έ":"e","ή":"h","ί":"i","ΰ":"y","α":"a","β":"b","γ":"g","δ":"d","ε":"e","ζ":"z","η":"h","θ":"8","ι":"i","κ":"k","λ":"l","μ":"m","ν":"n","ξ":"3","ο":"o","π":"p","ρ":"r","ς":"s","σ":"s","τ":"t","υ":"y","φ":"f","χ":"x","ψ":"ps","ω":"w","ϊ":"i","ϋ":"y","ό":"o","ύ":"y","ώ":"w","Ё":"Yo","Ђ":"DJ","Є":"Ye","І":"I","Ї":"Yi","Ј":"J","Љ":"LJ","Њ":"NJ","Ћ":"C","Џ":"DZ","А":"A","Б":"B","В":"V","Г":"G","Д":"D","Е":"E","Ж":"Zh","З":"Z","И":"I","Й":"J","К":"K","Л":"L","М":"M","Н":"N","О":"O","П":"P","Р":"R","С":"S","Т":"T","У":"U","Ф":"F","Х":"H","Ц":"C","Ч":"Ch","Ш":"Sh","Щ":"Sh","Ъ":"U","Ы":"Y","Ь":"","Э":"E","Ю":"Yu","Я":"Ya","а":"a","б":"b","в":"v","г":"g","д":"d","е":"e","ж":"zh","з":"z","и":"i","й":"j","к":"k","л":"l","м":"m","н":"n","о":"o","п":"p","р":"r","с":"s","т":"t","у":"u","ф":"f","х":"h","ц":"c","ч":"ch","ш":"sh","щ":"sh","ъ":"u","ы":"y","ь":"","э":"e","ю":"yu","я":"ya","ё":"yo","ђ":"dj","є":"ye","і":"i","ї":"yi","ј":"j","љ":"lj","њ":"nj","ћ":"c","ѝ":"u","џ":"dz","Ґ":"G","ґ":"g","Ғ":"GH","ғ":"gh","Қ":"KH","қ":"kh","Ң":"NG","ң":"ng","Ү":"UE","ү":"ue","Ұ":"U","ұ":"u","Һ":"H","һ":"h","Ә":"AE","ә":"ae","Ө":"OE","ө":"oe","฿":"baht","ა":"a","ბ":"b","გ":"g","დ":"d","ე":"e","ვ":"v","ზ":"z","თ":"t","ი":"i","კ":"k","ლ":"l","მ":"m","ნ":"n","ო":"o","პ":"p","ჟ":"zh","რ":"r","ს":"s","ტ":"t","უ":"u","ფ":"f","ქ":"k","ღ":"gh","ყ":"q","შ":"sh","ჩ":"ch","ც":"ts","ძ":"dz","წ":"ts","ჭ":"ch","ხ":"kh","ჯ":"j","ჰ":"h","Ẁ":"W","ẁ":"w","Ẃ":"W","ẃ":"w","Ẅ":"W","ẅ":"w","ẞ":"SS","Ạ":"A","ạ":"a","Ả":"A","ả":"a","Ấ":"A","ấ":"a","Ầ":"A","ầ":"a","Ẩ":"A","ẩ":"a","Ẫ":"A","ẫ":"a","Ậ":"A","ậ":"a","Ắ":"A","ắ":"a","Ằ":"A","ằ":"a","Ẳ":"A","ẳ":"a","Ẵ":"A","ẵ":"a","Ặ":"A","ặ":"a","Ẹ":"E","ẹ":"e","Ẻ":"E","ẻ":"e","Ẽ":"E","ẽ":"e","Ế":"E","ế":"e","Ề":"E","ề":"e","Ể":"E","ể":"e","Ễ":"E","ễ":"e","Ệ":"E","ệ":"e","Ỉ":"I","ỉ":"i","Ị":"I","ị":"i","Ọ":"O","ọ":"o","Ỏ":"O","ỏ":"o","Ố":"O","ố":"o","Ồ":"O","ồ":"o","Ổ":"O","ổ":"o","Ỗ":"O","ỗ":"o","Ộ":"O","ộ":"o","Ớ":"O","ớ":"o","Ờ":"O","ờ":"o","Ở":"O","ở":"o","Ỡ":"O","ỡ":"o","Ợ":"O","ợ":"o","Ụ":"U","ụ":"u","Ủ":"U","ủ":"u","Ứ":"U","ứ":"u","Ừ":"U","ừ":"u","Ử":"U","ử":"u","Ữ":"U","ữ":"u","Ự":"U","ự":"u","Ỳ":"Y","ỳ":"y","Ỵ":"Y","ỵ":"y","Ỷ":"Y","ỷ":"y","Ỹ":"Y","ỹ":"y","‘":"\'","’":"\'","“":"\\\"","”":"\\\"","†":"+","•":"*","…":"...","₠":"ecu","₢":"cruzeiro","₣":"french franc","₤":"lira","₥":"mill","₦":"naira","₧":"peseta","₨":"rupee","₩":"won","₪":"new shequel","₫":"dong","€":"euro","₭":"kip","₮":"tugrik","₯":"drachma","₰":"penny","₱":"peso","₲":"guarani","₳":"austral","₴":"hryvnia","₵":"cedi","₸":"kazakhstani tenge","₹":"indian rupee","₽":"russian ruble","₿":"bitcoin","℠":"sm","™":"tm","∂":"d","∆":"delta","∑":"sum","∞":"infinity","♥":"love","元":"yuan","円":"yen","﷼":"rial"}');

/**
 * Transform any string into an url-viable slug string
 * @param {object} [options]      Optional arguments which customize how the slugify operation is performed
 * @param {string} [options.replacement="-"]  The replacement character to separate terms, default is '-'
 * @param {boolean} [options.strict=false]    Replace all non-alphanumeric characters, or allow them? Default false
 * @param {boolean} [options.lowercase=true]  Lowercase the string.
 * @returns {string}              The slugified input string
 */
function slugify({replacement='-', strict=false, lowercase=true}={}) {
  let slug = this.split("").reduce((result, char) => result + (CHAR_MAP[char] || char), "").trim();
  if ( lowercase ) slug = slug.toLowerCase();

  // Convert any spaces to the replacement character and de-dupe
  slug = slug.replace(new RegExp('[\\s' + replacement + ']+', 'g'), replacement);

  // If we're being strict, replace anything that is not alphanumeric
  if ( strict ) slug = slug.replace(new RegExp('[^a-zA-Z0-9' + replacement + ']', 'g'), '');
  return slug;
}

// Define properties on the String environment
Object.defineProperties(String.prototype, {
  capitalize: {value: capitalize},
  compare: {value: compare},
  titleCase: {value: titleCase},
  stripScripts: {value: stripScripts},
  slugify: {value: slugify}
});

/**
 * Escape a given input string, prefacing special characters with backslashes for use in a regular expression
 * @param {string} string     The un-escaped input string
 * @returns {string}          The escaped string, suitable for use in regular expression
 */
function escape$1(string) {
  return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}

// Define properties on the RegExp environment
Object.defineProperties(RegExp, {
  escape: {value: escape$1}
});

/**
 * Attempt to parse a URL without throwing an error.
 * @param {string} url  The string to parse.
 * @returns {URL|null}  The parsed URL if successful, otherwise null.
 */
function parseSafe(url) {
  try {
    return new URL(url);
  } catch (err) {}
  return null;
}

// Define properties on the URL environment
Object.defineProperties(URL, {
  parseSafe: {value: parseSafe}
});

/**
 * Constant definitions used throughout the Foundry Virtual Tabletop framework.
 * @module CONST
 */


/**
 * The shortened software name
 */
const vtt = "Foundry VTT";

/**
 * The full software name
 */
const VTT = "Foundry Virtual Tabletop";

/**
 * The software website URL
 */
const WEBSITE_URL = "https://foundryvtt.com";

/**
 * The serverless API URL
 */
const WEBSITE_API_URL = "https://api.foundryvtt.com";

/**
 * An ASCII greeting displayed to the client
 * @type {string}
 */
const ASCII = `_______________________________________________________________
 _____ ___  _   _ _   _ ____  ______   __ __     _______ _____
|  ___/ _ \\| | | | \\ | |  _ \\|  _ \\ \\ / / \\ \\   / |_   _|_   _|
| |_ | | | | | | |  \\| | | | | |_) \\ V /   \\ \\ / /  | |   | |
|  _|| |_| | |_| | |\\  | |_| |  _ < | |     \\ V /   | |   | |
|_|   \\___/ \\___/|_| \\_|____/|_| \\_\\|_|      \\_/    |_|   |_|
===============================================================`;

/**
 * Define the allowed ActiveEffect application modes.
 * Other arbitrary mode numbers can be used by systems and modules to identify special behaviors and are ignored
 */
const ACTIVE_EFFECT_MODES = Object.freeze({
  /**
   * Used to denote that the handling of the effect is programmatically provided by a system or module.
   */
  CUSTOM: 0,

  /**
   * Multiplies a numeric base value by the numeric effect value
   * @example
   * 2 (base value) * 3 (effect value) = 6 (derived value)
   */
  MULTIPLY: 1,

  /**
   * Adds a numeric base value to a numeric effect value, or concatenates strings
   * @example
   * 2 (base value) + 3 (effect value) = 5 (derived value)
   * @example
   * "Hello" (base value) + " World" (effect value) = "Hello World"
   */
  ADD: 2,

  /**
   * Keeps the lower value of the base value and the effect value
   * @example
   * 2 (base value), 0 (effect value) = 0 (derived value)
   * @example
   * 2 (base value), 3 (effect value) = 2 (derived value)
   */
  DOWNGRADE: 3,

  /**
   * Keeps the greater value of the base value and the effect value
   * @example
   * 2 (base value), 4 (effect value) = 4 (derived value)
   * @example
   * 2 (base value), 1 (effect value) = 2 (derived value)
   */
  UPGRADE: 4,

  /**
   * Directly replaces the base value with the effect value
   * @example
   * 2 (base value), 4 (effect value) = 4 (derived value)
   */
  OVERRIDE: 5
});

/**
 * Define the string name used for the base document type when specific sub-types are not defined by the system
 */
const BASE_DOCUMENT_TYPE = "base";

/**
 * Define the methods by which a Card can be drawn from a Cards stack
 */
const CARD_DRAW_MODES = Object.freeze({
  /**
   * Draw the first card from the stack
   * Synonymous with `TOP`
   */
  FIRST: 0,

  /**
   * Draw the top card from the stack
   * Synonymous with `FIRST`
   */
  TOP: 0,

  /**
   * Draw the last card from the stack
   * Synonymous with `BOTTOM`
   */
  LAST: 1,

  /**
   * Draw the bottom card from the stack
   * Synonymous with `LAST`
   */
  BOTTOM: 1,

  /**
   * Draw a random card from the stack
   */
  RANDOM: 2
});

/**
 * An enumeration of canvas performance modes.
 */
const CANVAS_PERFORMANCE_MODES = Object.freeze({
  LOW: 0,
  MED: 1,
  HIGH: 2,
  MAX: 3
});

/**
 * @typedef {typeof CANVAS_PERFORMANCE_MODES[keyof typeof CANVAS_PERFORMANCE_MODES]} CanvasPerformanceMode
 */

/**
 * Valid Chat Message styles which affect how the message is presented in the chat log.
 */
const CHAT_MESSAGE_STYLES = /** @type {const} */ ({
  /**
   * An uncategorized chat message
   */
  OTHER: 0,

  /**
   * The message is spoken out of character (OOC).
   * OOC messages will be outlined by the player's color to make them more easily recognizable.
   */
  OOC: 1,

  /**
   * The message is spoken by an associated character.
   */
  IC: 2,

  /**
   * The message is an emote performed by the selected character.
   * Entering "/emote waves his hand." while controlling a character named Simon will send the message, "Simon waves his
   * hand."
   */
  EMOTE: 3
});
Object.defineProperties(CHAT_MESSAGE_STYLES, {
  /** @deprecated since v12 */
  ROLL: {
    get() {
      foundry.utils.logCompatibilityWarning("CONST.CHAT_MESSAGE_STYLES.ROLL is deprecated in favor of defining "
          + "rolls directly in ChatMessage#rolls", {since: 12, until: 14, once: true});
      return 0;
    }
  },
  /** @deprecated since v12 */
  WHISPER: {
    get() {
      foundry.utils.logCompatibilityWarning("CONST.CHAT_MESSAGE_STYLES.WHISPER is deprecated in favor of defining "
        + "whisper recipients directly in ChatMessage#whisper", {since: 12, until: 14, once: true});
      return 0;
    }
  }
});
Object.freeze(CHAT_MESSAGE_STYLES);

/**
 * @typedef {typeof CHAT_MESSAGE_STYLES[keyof typeof CHAT_MESSAGE_STYLES]} ChatMessageStyle
 */

/**
 * Define the set of languages which have built-in support in the core software.
 */
const CORE_SUPPORTED_LANGUAGES = Object.freeze(["en"]);

/**
 * Configure the severity of compatibility warnings.
 */
const COMPATIBILITY_MODES = Object.freeze({
  /**
   * Nothing will be logged
   */
  SILENT: 0,

  /**
   * A message will be logged at the "warn" level
   */
  WARNING: 1,

  /**
   * A message will be logged at the "error" level
   */
  ERROR: 2,

  /**
   * An Error will be thrown
   */
  FAILURE: 3
});


/**
 * Configure custom cursor images to use when interacting with the application.
 */
const CURSOR_STYLES = Object.freeze({
  default: "default",
  "default-down": "default",
  pointer: "pointer",
  "pointer-down": "pointer",
  grab: "grab",
  "grab-down": "grabbing",
  text: "text",
  "text-down": "text"
});

/**
 * The lighting illumination levels which are supported.
 */
const LIGHTING_LEVELS = Object.freeze({
  DARKNESS: -2,
  HALFDARK: -1,
  UNLIT: 0,
  DIM: 1,
  BRIGHT: 2,
  BRIGHTEST: 3
});

/**
 * @typedef {typeof LIGHTING_LEVELS[keyof typeof LIGHTING_LEVELS]} LightingLevel
 */

/**
 * The CSS themes which are currently supported for the V11 Setup menu.
 */
const CSS_THEMES = Object.freeze({
  dark: "THEME.foundry",
  fantasy: "THEME.fantasy",
  scifi: "THEME.scifi"
});

/**
 * The default artwork used for Token images if none is provided
 */
const DEFAULT_TOKEN = "icons/svg/mystery-man.svg";

/**
 * The primary Document types.
 */
const PRIMARY_DOCUMENT_TYPES = Object.freeze([
  "Actor",
  "Adventure",
  "Cards",
  "ChatMessage",
  "Combat",
  "FogExploration",
  "Folder",
  "Item",
  "JournalEntry",
  "Macro",
  "Playlist",
  "RollTable",
  "Scene",
  "Setting",
  "User"
]);

/**
 * The embedded Document types.
 */
const EMBEDDED_DOCUMENT_TYPES = Object.freeze([
  "ActiveEffect",
  "ActorDelta",
  "AmbientLight",
  "AmbientSound",
  "Card",
  "Combatant",
  "CombatantGroup",
  "Drawing",
  "Item",
  "JournalEntryCategory",
  "JournalEntryPage",
  "MeasuredTemplate",
  "Note",
  "PlaylistSound",
  "Region",
  "RegionBehavior",
  "TableResult",
  "Tile",
  "Token",
  "Wall"
]);

/**
 * A listing of all valid Document types, both primary and embedded.
 * @type {readonly ["ActiveEffect", "Actor", "ActorDelta", "Adventure", "AmbientLight", "AmbientSound", "Card", "Cards",
 *   "ChatMessage", "Combat", "Combatant", "CombatantGroup", "Drawing", "FogExploration", "Folder", "Item",
 *   "JournalEntry", "JournalEntryCategory", "JournalEntryPage", "Macro", "MeasuredTemplate", "Note", "Playlist",
 *   "PlaylistSound", "Region", "RegionBehavior", "RollTable", "Scene", "Setting", "TableResult", "Tile", "Token",
 *   "User", "Wall"]}
 */
const ALL_DOCUMENT_TYPES = Object.freeze(Array.from(new Set([
  ...PRIMARY_DOCUMENT_TYPES,
  ...EMBEDDED_DOCUMENT_TYPES
])).sort());

/**
 * The allowed primary Document types which may exist within a World.
 */
const WORLD_DOCUMENT_TYPES = Object.freeze([
  "Actor",
  "Cards",
  "ChatMessage",
  "Combat",
  "FogExploration",
  "Folder",
  "Item",
  "JournalEntry",
  "Macro",
  "Playlist",
  "RollTable",
  "Scene",
  "Setting",
  "User"
]);

/**
 * The allowed primary Document types which may exist within a Compendium pack.
 */
const COMPENDIUM_DOCUMENT_TYPES = Object.freeze([
  "Actor",
  "Adventure",
  "Cards",
  "Item",
  "JournalEntry",
  "Macro",
  "Playlist",
  "RollTable",
  "Scene"
]);

/**
 * Define the allowed ownership levels for a Document.
 * Each level is assigned a value in ascending order.
 * Higher levels grant more permissions.
 * @see {@link https://foundryvtt.com/article/users/}
 */
const DOCUMENT_OWNERSHIP_LEVELS = Object.freeze({
  /**
   * The User inherits permissions from the parent Folder.
   */
  INHERIT: -1,

  /**
   * Restricts the associated Document so that it may not be seen by this User.
   */
  NONE: 0,

  /**
   * Allows the User to interact with the Document in basic ways, allowing them to see it in sidebars and see only
   * limited aspects of its contents. The limits of this interaction are defined by the game system being used.
   */
  LIMITED: 1,

  /**
   * Allows the User to view this Document as if they were owner, but prevents them from making any changes to it.
   */
  OBSERVER: 2,

  /**
   * Allows the User to view and make changes to the Document as its owner. Owned documents cannot be deleted by anyone
   * other than a gamemaster level User.
   */
  OWNER: 3
});

/**
 * @typedef {typeof DOCUMENT_OWNERSHIP_LEVELS[keyof typeof DOCUMENT_OWNERSHIP_LEVELS]} DocumentOwnershipNumber
 * @typedef {keyof typeof DOCUMENT_OWNERSHIP_LEVELS|DocumentOwnershipNumber} DocumentOwnershipLevel
 */

/**
 * Meta ownership levels that are used in the UI but never stored.
 */
const DOCUMENT_META_OWNERSHIP_LEVELS = Object.freeze({
  DEFAULT: -20,
  NOCHANGE: -10
});

/**
 * Define the allowed Document types which may be dynamically linked in chat
 */
const DOCUMENT_LINK_TYPES = Object.freeze(["Actor", "Cards", "Item", "Scene", "JournalEntry", "Macro",
  "RollTable", "PlaylistSound"]);

/**
 * The supported dice roll visibility modes
 * @see {@link https://foundryvtt.com/article/dice/}
 */
const DICE_ROLL_MODES = Object.freeze({
  /**
   * This roll is visible to all players.
   */
  PUBLIC: "publicroll",

  /**
   * Rolls of this type are only visible to the player that rolled and any Game Master users.
   */
  PRIVATE: "gmroll",

  /**
   * A private dice roll only visible to Gamemaster users. The rolling player will not see the result of their own roll.
   */
  BLIND: "blindroll",

  /**
   * A private dice roll which is only visible to the user who rolled it.
   */
  SELF: "selfroll"
});

/**
 * The allowed fill types which a Drawing object may display
 * @see {@link https://foundryvtt.com/article/drawings/}
 */
const DRAWING_FILL_TYPES = Object.freeze({
  /**
   * The drawing is not filled
   */
  NONE: 0,

  /**
   * The drawing is filled with a solid color
   */
  SOLID: 1,

  /**
   * The drawing is filled with a tiled image pattern
   */
  PATTERN: 2
});

/**
 * Define the allowed Document types which Folders may contain
 */
const FOLDER_DOCUMENT_TYPES = Object.freeze(["Actor", "Adventure", "Item", "Scene", "JournalEntry", "Playlist",
  "RollTable", "Cards", "Macro", "Compendium"]);

/**
 * The maximum allowed level of depth for Folder nesting
 */
const FOLDER_MAX_DEPTH = 4;

/**
 * A list of allowed game URL names
 */
const GAME_VIEWS = Object.freeze(["game", "stream"]);

/**
 * The directions of movement.
 */
const MOVEMENT_DIRECTIONS = Object.freeze({
  UP: 0x1,
  DOWN: 0x2,
  LEFT: 0x4,
  RIGHT: 0x8,
  UP_LEFT: /** @type {5} */ (0x1 | 0x4),
  UP_RIGHT: /** @type {9} */ (0x1 | 0x8),
  DOWN_LEFT: /** @type {6} */ (0x2 | 0x4),
  DOWN_RIGHT: /** @type {10} */ (0x2 | 0x8),
  DESCEND: 0x10,
  ASCEND: 0x20
});

/**
 * The minimum allowed grid size which is supported by the software
 */
const GRID_MIN_SIZE = 20;

/**
 * The allowed Grid types which are supported by the software
 * @see {@link https://foundryvtt.com/article/scenes/}
 */
const GRID_TYPES = Object.freeze({
  /**
   * No fixed grid is used on this Scene allowing free-form point-to-point measurement without grid lines.
   */
  GRIDLESS: 0,

  /**
   * A square grid is used with width and height of each grid space equal to the chosen grid size.
   */
  SQUARE: 1,

  /**
   * A row-wise hexagon grid (pointy-topped) where odd-numbered rows are offset.
   */
  HEXODDR: 2,

  /**
   * A row-wise hexagon grid (pointy-topped) where even-numbered rows are offset.
   */
  HEXEVENR: 3,

  /**
   * A column-wise hexagon grid (flat-topped) where odd-numbered columns are offset.
   */
  HEXODDQ: 4,

  /**
   * A column-wise hexagon grid (flat-topped) where even-numbered columns are offset.
   */
  HEXEVENQ: 5
});

/**
 * @typedef {typeof GRID_TYPES[keyof typeof GRID_TYPES]} GridType
 */

/**
 * The different rules to define and measure diagonal distance/cost in a square grid.
 * The description of each option refers to the distance/cost of moving diagonally relative to the distance/cost of a
 * horizontal or vertical move.
 */
const GRID_DIAGONALS = Object.freeze({
  /**
   * The diagonal distance is 1. Diagonal movement costs the same as horizontal/vertical movement.
   */
  EQUIDISTANT: 0,

  /**
   * The diagonal distance is √2. Diagonal movement costs √2 times as much as horizontal/vertical movement.
   */
  EXACT: 1,

  /**
   * The diagonal distance is 1.5. Diagonal movement costs 1.5 times as much as horizontal/vertical movement.
   */
  APPROXIMATE: 2,

  /**
   * The diagonal distance is 2. Diagonal movement costs 2 times as much as horizontal/vertical movement.
   */
  RECTILINEAR: 3,

  /**
   * The diagonal distance alternates between 1 and 2 starting at 1.
   * The first diagonal movement costs the same as horizontal/vertical movement
   * The second diagonal movement costs 2 times as much as horizontal/vertical movement.
   * And so on...
   */
  ALTERNATING_1: 4,

  /**
   * The diagonal distance alternates between 2 and 1 starting at 2.
   * The first diagonal movement costs 2 times as much as horizontal/vertical movement.
   * The second diagonal movement costs the same as horizontal/vertical movement.
   * And so on...
   */
  ALTERNATING_2: 5,

  /**
   * The diagonal distance is ∞. Diagonal movement is not allowed/possible.
   */
  ILLEGAL: 6
});

/**
 * @typedef {typeof GRID_DIAGONALS[keyof typeof GRID_DIAGONALS]} GridDiagonalRule
 */

/**
 * The grid snapping modes.
 */
const GRID_SNAPPING_MODES = Object.freeze({
  /**
   * Nearest center point.
   */
  CENTER: 0x1,

  /**
   * Nearest edge midpoint.
   */
  EDGE_MIDPOINT: 0x2,

  /**
   * Nearest top-left vertex.
   */
  TOP_LEFT_VERTEX: 0x10,

  /**
   * Nearest top-right vertex.
   */
  TOP_RIGHT_VERTEX: 0x20,

  /**
   * Nearest bottom-left vertex.
   */
  BOTTOM_LEFT_VERTEX: 0x40,

  /**
   * Nearest bottom-right vertex.
   */
  BOTTOM_RIGHT_VERTEX: 0x80,

  /**
   * Nearest vertex.
   * Alias for `TOP_LEFT_VERTEX | TOP_RIGHT_VERTEX | BOTTOM_LEFT_VERTEX | BOTTOM_RIGHT_VERTEX`.
   */
  VERTEX: 0xF0,

  /**
   * Nearest top-left corner.
   */
  TOP_LEFT_CORNER: 0x100,

  /**
   * Nearest top-right corner.
   */
  TOP_RIGHT_CORNER: 0x200,

  /**
   * Nearest bottom-left corner.
   */
  BOTTOM_LEFT_CORNER: 0x400,

  /**
   * Nearest bottom-right corner.
   */
  BOTTOM_RIGHT_CORNER: 0x800,

  /**
   * Nearest corner.
   * Alias for `TOP_LEFT_CORNER | TOP_RIGHT_CORNER | BOTTOM_LEFT_CORNER | BOTTOM_RIGHT_CORNER`.
   */
  CORNER: 0xF00,

  /**
   * Nearest top side midpoint.
   */
  TOP_SIDE_MIDPOINT: 0x1000,

  /**
   * Nearest bottom side midpoint.
   */
  BOTTOM_SIDE_MIDPOINT: 0x2000,

  /**
   * Nearest left side midpoint.
   */
  LEFT_SIDE_MIDPOINT: 0x4000,

  /**
   * Nearest right side midpoint.
   */
  RIGHT_SIDE_MIDPOINT: 0x8000,

  /**
   * Nearest side midpoint.
   * Alias for `TOP_SIDE_MIDPOINT | BOTTOM_SIDE_MIDPOINT | LEFT_SIDE_MIDPOINT | RIGHT_SIDE_MIDPOINT`.
   */
  SIDE_MIDPOINT: 0xF000
});

/**
 * A list of supported setup URL names
 */
const SETUP_VIEWS = Object.freeze(["auth", "license", "setup", "players", "join", "update"]);

/**
 * An Array of valid MacroAction scope values
 */
const MACRO_SCOPES = Object.freeze(["global", "actors", "actor"]);

/**
 * An enumeration of valid Macro types
 * @see {@link https://foundryvtt.com/article/macros/}
 */
const MACRO_TYPES = Object.freeze({
  /**
   * Complex and powerful macros which leverage the FVTT API through plain JavaScript to perform functions as simple or
   * as advanced as you can imagine.
   */
  SCRIPT: "script",

  /**
   * Simple and easy to use, chat macros post pre-defined chat messages to the chat log when executed. All users can
   * execute chat macros by default.
   */
  CHAT: "chat"
});

/**
 * The allowed channels for audio playback.
 */
const AUDIO_CHANNELS = Object.freeze({
  music: "AUDIO.CHANNELS.MUSIC.label",
  environment: "AUDIO.CHANNELS.ENVIRONMENT.label",
  interface: "AUDIO.CHANNELS.INTERFACE.label"
});

/**
 * The allowed playback modes for an audio Playlist
 * @see {@link https://foundryvtt.com/article/playlists/}
 */
const PLAYLIST_MODES = Object.freeze({
  /**
   * The playlist does not play on its own, only individual Sound tracks played as a soundboard.
   */
  DISABLED: -1,

  /**
   * The playlist plays sounds one at a time in sequence.
   */
  SEQUENTIAL: 0,

  /**
   * The playlist plays sounds one at a time in randomized order.
   */
  SHUFFLE: 1,

  /**
   * The playlist plays all contained sounds at the same time.
   */
  SIMULTANEOUS: 2
});

/**
 * The available sort modes for an audio Playlist.
 * @see {@link https://foundryvtt.com/article/playlists/}
 */
const PLAYLIST_SORT_MODES = Object.freeze({
  /**
   * Sort sounds alphabetically.
   */
  ALPHABETICAL: "a",

  /**
   * Sort sounds by manual drag-and-drop.
   */
  MANUAL: "m"
});

/**
 * The available modes for searching within a DirectoryCollection
 */
const DIRECTORY_SEARCH_MODES = Object.freeze({
  FULL: "full",
  NAME: "name"
});

/**
 * The allowed package types
 */
const PACKAGE_TYPES$1 = Object.freeze(["world", "system", "module"]);

/**
 * Encode the reasons why a package may be available or unavailable for use
 */
const PACKAGE_AVAILABILITY_CODES = Object.freeze({
  /**
   * Package availability could not be determined
   */
  UNKNOWN: 0,

  /**
   * The Package is verified to be compatible with the current core software build
   */
  VERIFIED: 1,

  /**
   * Package is available for use, but not verified for the current core software build
   */
  UNVERIFIED_BUILD: 2,

  /**
   * One or more installed system is incompatible with the Package.
   */
  UNVERIFIED_SYSTEM: 3,

  /**
   * Package is available for use, but not verified for the current core software generation
   */
  UNVERIFIED_GENERATION: 4,

  /**
   * The System that the Package relies on is not available
   */
  MISSING_SYSTEM: 5,

  /**
   * A dependency of the Package is not available
   */
  MISSING_DEPENDENCY: 6,

  /**
   * The Package is compatible with an older version of Foundry than the currently installed version
   */
  REQUIRES_CORE_DOWNGRADE: 7,

  /**
   * The Package is compatible with a newer version of Foundry than the currently installed version, and that version is
   * Stable
   */
  REQUIRES_CORE_UPGRADE_STABLE: 8,

  /**
   * The Package is compatible with a newer version of Foundry than the currently installed version, and that version is
   * not yet Stable
   */
  REQUIRES_CORE_UPGRADE_UNSTABLE: 9,

  /**
   * A required dependency is not compatible with the current version of Foundry
   */
  REQUIRES_DEPENDENCY_UPDATE: 10
});

/**
 * A safe password string which can be displayed
 * @type {"••••••••••••••••"}
 */
const PASSWORD_SAFE_STRING = "•".repeat(16);

/**
 * The allowed software update channels
 */
const SOFTWARE_UPDATE_CHANNELS = Object.freeze({
  /**
   * The Stable release channel
   */
  stable: "SETUP.UpdateStable",

  /**
   * The User Testing release channel
   */
  testing: "SETUP.UpdateTesting",

  /**
   * The Development release channel
   */
  development: "SETUP.UpdateDevelopment",

  /**
   * The Prototype release channel
   */
  prototype: "SETUP.UpdatePrototype"
});

/**
 * The default sorting density for manually ordering child objects within a parent
 */
const SORT_INTEGER_DENSITY = 100000;

/**
 * The allowed types of a TableResult document
 * @see {@link https://foundryvtt.com/article/roll-tables/}
 */
const TABLE_RESULT_TYPES = /** @type {const} */ ({
  /**
   *  Plain text or HTML scripted entries which will be output to Chat.
   */
  TEXT: "text",

  /**
   * An in-World Document reference which will be linked to in the chat message.
   */
  DOCUMENT: "document"
});
Object.defineProperties(TABLE_RESULT_TYPES, {
  /** @deprecated since v13 */
  COMPENDIUM: {
    get() {
      const message = "CONST.TABLE_RESULT_TYPES.COMPENDIUM is is deprecated in favor of CONST.TABLE_RESULT_TYPES.DOCUMENT"
      + ' due to the "compendium" being merged with the "document" type.';
      foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15, once: true});
      return TABLE_RESULT_TYPES.DOCUMENT;
    }
  }
});
Object.freeze(TABLE_RESULT_TYPES);

/**
 * The allowed formats of a Journal Entry Page.
 * @see {@link https://foundryvtt.com/article/journal/}
 */
const JOURNAL_ENTRY_PAGE_FORMATS = Object.freeze({
  /**
   * The page is formatted as HTML.
   */
  HTML: 1,

  /**
   * The page is formatted as Markdown.
   */
  MARKDOWN: 2
});

/**
 * Define the valid anchor locations for a Tooltip displayed on a Placeable Object
 * @see {@link foundry.helpers.interaction.TooltipManager}
 */
const TEXT_ANCHOR_POINTS = Object.freeze({
  /**
   * Anchor the tooltip to the center of the element.
   */
  CENTER: 0,

  /**
   * Anchor the tooltip to the bottom of the element.
   */
  BOTTOM: 1,

  /**
   * Anchor the tooltip to the top of the element.
   */
  TOP: 2,

  /**
   * Anchor the tooltip to the left of the element.
   */
  LEFT: 3,

  /**
   * Anchor the tooltip to the right of the element.
   */
  RIGHT: 4
});

/**
 * @typedef {typeof TEXT_ANCHOR_POINTS[keyof typeof TEXT_ANCHOR_POINTS]} TextAnchorPoint
 */

/**
 * Define the valid occlusion modes which a tile can use
 * @see {@link https://foundryvtt.com/article/tiles/}
 */
const OCCLUSION_MODES = Object.freeze({
  /**
   * Turns off occlusion, making the tile never fade while tokens are under it.
   */
  NONE: 0,

  /**
   * Causes the whole tile to fade when an actor token moves under it.
   */
  FADE: 1,

  // ROOF: 2,  This mode is no longer supported so we don't use 2 for any other mode

  /**
   * Causes the tile to reveal the background in the vicinity of an actor token under it. The radius is determined by
   * the token's size.
   */
  RADIAL: 3,

  /**
   * Causes the tile to be partially revealed based on the vision of the actor, which does not need to be under the tile
   * to see what's beneath it.
   * This is useful for rooves on buildings where players could see through a window or door, viewing only a portion of
   * what is obscured by the roof itself.
   */
  VISION: 4
});

/**
 * Alias for old tile occlusion modes definition
 */
const TILE_OCCLUSION_MODES = OCCLUSION_MODES;

/**
 * The occlusion modes that define the set of tokens that trigger occlusion.
 */
const TOKEN_OCCLUSION_MODES = Object.freeze({

  /**
   * Owned tokens that aren't hidden.
   */
  OWNED: 0x1,

  /**
   * Controlled tokens.
   */
  CONTROLLED: 0x2,

  /**
   * Hovered tokens that are visible.
   */
  HOVERED: 0x4,

  /**
   * Highlighted tokens that are visible.
   */
  HIGHLIGHTED: 0x8,

  /**
   * All visible tokens.
   */
  VISIBLE: 0x10
});

/**
 * Describe the various thresholds of token control upon which to show certain pieces of information
 * @see {@link https://foundryvtt.com/article/tokens/}
 */
const TOKEN_DISPLAY_MODES = Object.freeze({
  /**
   * No information is displayed.
   */
  NONE: 0,

  /**
   * Displayed when the token is controlled.
   */
  CONTROL: 10,

  /**
   * Displayed when hovered by a GM or a user who owns the actor.
   */
  OWNER_HOVER: 20,

  /**
   * Displayed when hovered by any user.
   */
  HOVER: 30,

  /**
   * Always displayed for a GM or for a user who owns the actor.
   */
  OWNER: 40,

  /**
   * Always displayed for everyone.
   */
  ALWAYS: 50
});

/**
 * @typedef {typeof TOKEN_DISPLAY_MODES[keyof typeof TOKEN_DISPLAY_MODES]} TokenDisplayMode
 */

/**
 * The allowed Token disposition types
 * @see {@link https://foundryvtt.com/article/tokens/}
 */
const TOKEN_DISPOSITIONS = Object.freeze({
  /**
   * Displayed with a purple borders for owners and with no borders for others (and no pointer change).
   */
  SECRET: -2,

  /**
   * Displayed as an enemy with a red border.
   */
  HOSTILE: -1,

  /**
   * Displayed as neutral with a yellow border.
   */
  NEUTRAL: 0,

  /**
   * Displayed as an ally with a cyan border.
   */
  FRIENDLY: 1
});

/**
 * The allowed token turn markers modes.
 */
const TOKEN_TURN_MARKER_MODES = Object.freeze({
  /**
   * The turn marker is disabled for this token.
   */
  DISABLED: 0,

  /**
   * The turn marker for this token is using the combat tracker settings (which could be disabled).
   */
  DEFAULT: 1,

  /**
   * The turn marker is using the token settings (unless the combat tracker turn marker setting is disabled)
   */
  CUSTOM: 2
});

/**
 * The possible shapes of Tokens.
 */
const TOKEN_SHAPES = Object.freeze({
  /**
   * Ellipse (Variant 1)
   */
  ELLIPSE_1: 0,

  /**
   * Ellipse (Variant 2)
   */
  ELLIPSE_2: 1,

  /**
   * Trapezoid (Variant 1)
   */
  TRAPEZOID_1: 2,

  /**
   * Trapezoid (Variant 2)
   */
  TRAPEZOID_2: 3,

  /**
   * Rectangle (Variant 1)
   */
  RECTANGLE_1: 4,

  /**
   * Rectangle (Variant 2)
   */
  RECTANGLE_2: 5
});

/**
 * @typedef {typeof TOKEN_SHAPES[keyof typeof TOKEN_SHAPES]} TokenShapeType
 */

/**
 * Define the allowed User permission levels.
 * Each level is assigned a value in ascending order. Higher levels grant more permissions.
 * @see {@link https://foundryvtt.com/article/users/}
 */
const USER_ROLES = Object.freeze({
  /**
   * The User is blocked from taking actions in Foundry Virtual Tabletop.
   * You can use this role to temporarily or permanently ban a user from joining the game.
   */
  NONE: 0,

  /**
   * The User is able to join the game with permissions available to a standard player.
   * They cannot take some more advanced actions which require Trusted permissions, but they have the basic
   * functionalities needed to operate in the virtual tabletop.
   */
  PLAYER: 1,

  /**
   * Similar to the Player role, except a Trusted User has the ability to perform some more advanced actions like create
   * drawings, measured templates, or even to (optionally) upload media files to the server.
   */
  TRUSTED: 2,

  /**
   * A special User who has many of the same in-game controls as a Game Master User, but does not have the ability to
   * perform administrative actions like changing User roles or modifying World-level settings.
   */
  ASSISTANT: 3,

  /**
   * A special User who has administrative control over this specific World.
   * Game Masters behave quite differently than Players in that they have the ability to see all Documents and Objects
   * within the world as well as the capability to configure World settings.
   */
  GAMEMASTER: 4
});

/**
 * Invert the User Role mapping to recover role names from a role integer
 * @type {{0: "NONE"; 1: "PLAYER"; 2: "TRUSTED"; 3: "ASSISTANT"; 4: "GAMEMASTER"}}
 * @see {@link CONST.USER_ROLES}
 */
const USER_ROLE_NAMES = Object.entries(USER_ROLES).reduce((obj, r) => {
  obj[r[1]] = r[0];
  return obj;
}, {});

/**
 * An enumeration of the allowed types for a MeasuredTemplate embedded document
 * @see {@link https://foundryvtt.com/article/measurement/}
 */
const MEASURED_TEMPLATE_TYPES = Object.freeze({
  /**
   * Circular templates create a radius around the starting point.
   */
  CIRCLE: "circle",

  /**
   * Cones create an effect in the shape of a triangle or pizza slice from the starting point.
   */
  CONE: "cone",

  /**
   * A rectangle uses the origin point as a corner, treating the origin as being inside of the rectangle's area.
   */
  RECTANGLE: "rect",

  /**
   * A ray creates a single line that is one square in width and as long as you want it to be.
   */
  RAY: "ray"
});

/**
 * Define the recognized User capabilities which individual Users or role levels may be permitted to perform
 */
const USER_PERMISSIONS = deepFreeze({
  ACTOR_CREATE: {
    label: "PERMISSION.ActorCreate",
    hint: "PERMISSION.ActorCreateHint",
    disableGM: false,
    defaultRole: USER_ROLES.ASSISTANT
  },
  BROADCAST_AUDIO: {
    label: "PERMISSION.BroadcastAudio",
    hint: "PERMISSION.BroadcastAudioHint",
    disableGM: true,
    defaultRole: USER_ROLES.TRUSTED
  },
  BROADCAST_VIDEO: {
    label: "PERMISSION.BroadcastVideo",
    hint: "PERMISSION.BroadcastVideoHint",
    disableGM: true,
    defaultRole: USER_ROLES.TRUSTED
  },
  CARDS_CREATE: {
    label: "PERMISSION.CardsCreate",
    hint: "PERMISSION.CardsCreateHint",
    disableGM: false,
    defaultRole: USER_ROLES.ASSISTANT
  },
  DRAWING_CREATE: {
    label: "PERMISSION.DrawingCreate",
    hint: "PERMISSION.DrawingCreateHint",
    disableGM: false,
    defaultRole: USER_ROLES.TRUSTED
  },
  ITEM_CREATE: {
    label: "PERMISSION.ItemCreate",
    hint: "PERMISSION.ItemCreateHint",
    disableGM: false,
    defaultRole: USER_ROLES.ASSISTANT
  },
  FILES_BROWSE: {
    label: "PERMISSION.FilesBrowse",
    hint: "PERMISSION.FilesBrowseHint",
    disableGM: false,
    defaultRole: USER_ROLES.TRUSTED
  },
  FILES_UPLOAD: {
    label: "PERMISSION.FilesUpload",
    hint: "PERMISSION.FilesUploadHint",
    disableGM: false,
    defaultRole: USER_ROLES.ASSISTANT
  },
  JOURNAL_CREATE: {
    label: "PERMISSION.JournalCreate",
    hint: "PERMISSION.JournalCreateHint",
    disableGM: false,
    defaultRole: USER_ROLES.TRUSTED
  },
  MACRO_SCRIPT: {
    label: "PERMISSION.MacroScript",
    hint: "PERMISSION.MacroScriptHint",
    disableGM: false,
    defaultRole: USER_ROLES.PLAYER
  },
  MANUAL_ROLLS: {
    label: "PERMISSION.ManualRolls",
    hint: "PERMISSION.ManualRollsHint",
    disableGM: true,
    defaultRole: USER_ROLES.TRUSTED
  },
  MESSAGE_WHISPER: {
    label: "PERMISSION.MessageWhisper",
    hint: "PERMISSION.MessageWhisperHint",
    disableGM: false,
    defaultRole: USER_ROLES.PLAYER
  },
  NOTE_CREATE: {
    label: "PERMISSION.NoteCreate",
    hint: "PERMISSION.NoteCreateHint",
    disableGM: false,
    defaultRole: USER_ROLES.TRUSTED
  },
  PING_CANVAS: {
    label: "PERMISSION.PingCanvas",
    hint: "PERMISSION.PingCanvasHint",
    disableGM: true,
    defaultRole: USER_ROLES.PLAYER
  },
  PLAYLIST_CREATE: {
    label: "PERMISSION.PlaylistCreate",
    hint: "PERMISSION.PlaylistCreateHint",
    disableGM: false,
    defaultRole: USER_ROLES.ASSISTANT
  },
  SETTINGS_MODIFY: {
    label: "PERMISSION.SettingsModify",
    hint: "PERMISSION.SettingsModifyHint",
    disableGM: false,
    defaultRole: USER_ROLES.ASSISTANT
  },
  SHOW_CURSOR: {
    label: "PERMISSION.ShowCursor",
    hint: "PERMISSION.ShowCursorHint",
    disableGM: true,
    defaultRole: USER_ROLES.PLAYER
  },
  SHOW_RULER: {
    label: "PERMISSION.ShowRuler",
    hint: "PERMISSION.ShowRulerHint",
    disableGM: true,
    defaultRole: USER_ROLES.PLAYER
  },
  TEMPLATE_CREATE: {
    label: "PERMISSION.TemplateCreate",
    hint: "PERMISSION.TemplateCreateHint",
    disableGM: false,
    defaultRole: USER_ROLES.PLAYER
  },
  TOKEN_CREATE: {
    label: "PERMISSION.TokenCreate",
    hint: "PERMISSION.TokenCreateHint",
    disableGM: false,
    defaultRole: USER_ROLES.ASSISTANT
  },
  TOKEN_DELETE: {
    label: "PERMISSION.TokenDelete",
    hint: "PERMISSION.TokenDeleteHint",
    disableGM: false,
    defaultRole: USER_ROLES.ASSISTANT
  },
  TOKEN_CONFIGURE: {
    label: "PERMISSION.TokenConfigure",
    hint: "PERMISSION.TokenConfigureHint",
    disableGM: false,
    defaultRole: USER_ROLES.TRUSTED
  },
  WALL_DOORS: {
    label: "PERMISSION.WallDoors",
    hint: "PERMISSION.WallDoorsHint",
    disableGM: false,
    defaultRole: USER_ROLES.PLAYER
  },
  QUERY_USER: {
    label: "PERMISSION.QueryUser",
    hint: "PERMISSION.QueryUserHint",
    disableGM: false,
    defaultRole: USER_ROLES.PLAYER
  }
});

/**
 * The allowed directions of effect that a Wall can have
 * @see {@link https://foundryvtt.com/article/walls/}
 */
const WALL_DIRECTIONS = Object.freeze({
  /**
   * The wall collides from both directions.
   */
  BOTH: 0,

  /**
   * The wall collides only when a ray strikes its left side.
   */
  LEFT: 1,

  /**
   * The wall collides only when a ray strikes its right side.
   */
  RIGHT: 2
});

/**
 * @typedef {typeof WALL_DIRECTIONS[keyof typeof WALL_DIRECTIONS]} WallDirection
 */

/**
 * The allowed door types which a Wall may contain
 * @see {@link https://foundryvtt.com/article/walls/}
 */
const WALL_DOOR_TYPES = Object.freeze({
  /**
   * The wall does not contain a door.
   */
  NONE: 0,

  /**
   *  The wall contains a regular door.
   */
  DOOR: 1,

  /**
   * The wall contains a secret door.
   */
  SECRET: 2
});

/**
 * The allowed door states which may describe a Wall that contains a door
 * @see {@link https://foundryvtt.com/article/walls/}
 */
const WALL_DOOR_STATES = Object.freeze({
  /**
   * The door is closed.
   */
  CLOSED: 0,

  /**
   * The door is open.
   */
  OPEN: 1,

  /**
   * The door is closed and locked.
   */
  LOCKED: 2
});

/**
 * The possible ways to interact with a door
 */
const WALL_DOOR_INTERACTIONS = Object.freeze(["open", "close", "lock", "unlock", "test"]);

/**
 * The wall properties which restrict the way interaction occurs with a specific wall
 */
const WALL_RESTRICTION_TYPES = Object.freeze(["light", "sight", "sound", "move"]);

/**
 * @typedef {typeof WALL_RESTRICTION_TYPES[number]} WallRestrictionType
 */

/**
 * The types of sensory collision which a Wall may impose
 * @see {@link https://foundryvtt.com/article/walls/}
 */
const WALL_SENSE_TYPES = Object.freeze({
  /**
   * Senses do not collide with this wall.
   */
  NONE: 0,

  /**
   * Senses collide with this wall.
   */
  LIMITED: 10,

  /**
   * Senses collide with the second intersection, bypassing the first.
   */
  NORMAL: 20,

  /**
   * Senses bypass the wall within a certain proximity threshold.
   */
  PROXIMITY: 30,

  /**
   * Senses bypass the wall outside a certain proximity threshold.
   */
  DISTANCE: 40
});

/**
 * @typedef {typeof WALL_SENSE_TYPES[keyof typeof WALL_SENSE_TYPES]} WallSenseType
 */

/**
 * The types of movement collision which a Wall may impose
 * @see {@link https://foundryvtt.com/article/walls/}
 */
const WALL_MOVEMENT_TYPES = Object.freeze({
  /**
   * Movement does not collide with this wall.
   */
  NONE: WALL_SENSE_TYPES.NONE,

  /**
   * Movement collides with this wall.
   */
  NORMAL: WALL_SENSE_TYPES.NORMAL
});

/**
 * The possible precedence values a Keybinding might run in
 * @see {@link https://foundryvtt.com/article/keybinds/}
 */
const KEYBINDING_PRECEDENCE = Object.freeze({
  /**
   * Runs in the first group along with other PRIORITY keybindings.
   */
  PRIORITY: 0,

  /**
   * Runs after the PRIORITY group along with other NORMAL keybindings.
   */
  NORMAL: 1,

  /**
   * Runs in the last group along with other DEFERRED keybindings.
   */
  DEFERRED: 2
});

/**
 * Directories in the public storage path.
 */
const FILE_PICKER_PUBLIC_DIRS = Object.freeze([
  "cards", "css", "fonts", "icons", "lang", "scripts", "sounds", "ui"
]);

/**
 * The allowed set of HTML template extensions
 */
const HTML_FILE_EXTENSIONS = Object.freeze({
  handlebars: "text/x-handlebars-template",
  hbs: "text/x-handlebars-template",
  html: "text/html"
});

/**
 * The supported file extensions for image-type files, and their corresponding mime types.
 */
const IMAGE_FILE_EXTENSIONS = Object.freeze({
  apng: "image/apng",
  avif: "image/avif",
  bmp: "image/bmp",
  gif: "image/gif",
  jpeg: "image/jpeg",
  jpg: "image/jpeg",
  png: "image/png",
  svg: "image/svg+xml",
  tiff: "image/tiff",
  webp: "image/webp"
});

/**
 * The supported file extensions for video-type files, and their corresponding mime types.
 */
const VIDEO_FILE_EXTENSIONS = Object.freeze({
  m4v: "video/mp4",
  mp4: "video/mp4",
  ogv: "video/ogg",
  webm: "video/webm"
});

/**
 * The supported file extensions for audio-type files, and their corresponding mime types.
 */
const AUDIO_FILE_EXTENSIONS = Object.freeze({
  aac: "audio/aac",
  flac: "audio/flac",
  m4a: "audio/mp4",
  mid: "audio/midi",
  mp3: "audio/mpeg",
  ogg: "audio/ogg",
  opus: "audio/opus",
  wav: "audio/wav",
  webm: "audio/webm"
});

/**
 * The supported file extensions for text files, and their corresponding mime types.
 */
const TEXT_FILE_EXTENSIONS = Object.freeze({
  csv: "text/csv",
  json: "application/json",
  md: "text/markdown",
  pdf: "application/pdf",
  tsv: "text/tab-separated-values",
  txt: "text/plain",
  xml: "application/xml",
  yml: "application/yaml",
  yaml: "application/yaml"
});

/**
 * Supported file extensions for font files, and their corresponding mime types.
 */
const FONT_FILE_EXTENSIONS = Object.freeze({
  otf: "font/otf",
  ttf: "font/ttf",
  woff: "font/woff",
  woff2: "font/woff2"
});

/**
 * Supported file extensions for 3D files, and their corresponding mime types.
 */
const GRAPHICS_FILE_EXTENSIONS = Object.freeze({
  fbx: "application/octet-stream",
  glb: "model/gltf-binary",
  gltf: "model/gltf+json",
  mtl: "model/mtl",
  obj: "model/obj",
  stl: "model/stl",
  usdz: "model/vnd.usdz+zip"
});

/**
 * A consolidated mapping of all extensions permitted for upload.
 */
const UPLOADABLE_FILE_EXTENSIONS = Object.freeze({
  ...IMAGE_FILE_EXTENSIONS,
  ...AUDIO_FILE_EXTENSIONS,
  ...VIDEO_FILE_EXTENSIONS,
  ...TEXT_FILE_EXTENSIONS,
  ...FONT_FILE_EXTENSIONS,
  ...GRAPHICS_FILE_EXTENSIONS
});

/**
 * An enumeration of file type categories which can be selected.
 */
const FILE_CATEGORIES = {
  HTML: HTML_FILE_EXTENSIONS,
  IMAGE: IMAGE_FILE_EXTENSIONS,
  VIDEO: VIDEO_FILE_EXTENSIONS,
  AUDIO: AUDIO_FILE_EXTENSIONS,
  TEXT: TEXT_FILE_EXTENSIONS,
  FONT: FONT_FILE_EXTENSIONS,
  GRAPHICS: GRAPHICS_FILE_EXTENSIONS,

  /**
   * @deprecated since v13
   * @ignore
   */
  get MEDIA() {
    const message = "CONST.FILE_CATEGORIES.MEDIA is deprecated. Use CONST.MEDIA_MIME_TYPES instead.";
    foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15, once: true});
    return MEDIA_MIME_TYPES;
  }
};
Object.defineProperties(FILE_CATEGORIES, {MEDIA: {enumerable: false}});
Object.freeze(FILE_CATEGORIES);

/**
 * The list of file categories that are "media".
 */
const MEDIA_FILE_CATEGORIES = Object.freeze(["IMAGE", "VIDEO", "AUDIO", "TEXT", "FONT", "GRAPHICS"]);

/**
 * A list of MIME types which are treated as uploaded "media", which are allowed to overwrite existing files.
 * Any non-media MIME type is not allowed to replace an existing file.
 */
const MEDIA_MIME_TYPES = Array.from(new Set(MEDIA_FILE_CATEGORIES.flatMap(
  c => Object.values(FILE_CATEGORIES[c])))).sort();

/**
 * A font weight to name mapping.
 */
const FONT_WEIGHTS = Object.freeze({
  Thin: 100,
  ExtraLight: 200,
  Light: 300,
  Regular: 400,
  Medium: 500,
  SemiBold: 600,
  Bold: 700,
  ExtraBold: 800,
  Black: 900
});

/**
 * Stores shared commonly used timeouts, measured in MS
 */
const TIMEOUTS = Object.freeze({
  /**
   * The default timeout for interacting with the foundryvtt.com API.
   */
  FOUNDRY_WEBSITE: 10000,

  /**
   * The specific timeout for loading the list of packages from the foundryvtt.com API.
   */
  PACKAGE_REPOSITORY: 10000,

  /**
   * The specific timeout for the IP address lookup service.
   */
  IP_DISCOVERY: 5000,

  /**
   * A remote package manifest JSON or download ZIP.
   */
  REMOTE_PACKAGE: 5000
});

/**
 * A subset of Compendium types which require a specific system to be designated
 */
const SYSTEM_SPECIFIC_COMPENDIUM_TYPES = Object.freeze(["Actor", "Item"]);

/**
 * The configured showdown bi-directional HTML <-> Markdown converter options.
 */
const SHOWDOWN_OPTIONS = Object.freeze({
  disableForced4SpacesIndentedSublists: true,
  noHeaderId: true,
  parseImgDimensions: true,
  strikethrough: true,
  tables: true,
  tablesHeaderId: true
});

/**
 * The list of allowed HTML tags.
 */
const ALLOWED_HTML_TAGS = Object.freeze([
  "header", "main", "section", "article", "aside", "nav", "footer", "div", "address", // Structural Elements
  "h1", "h2", "h3", "h4", "h5", "h6", "hr", "br", // Headers and Dividers
  "p", "blockquote", "summary", "details", "span", "code", "pre", "a", "label", "abbr", "cite",
  "mark", "q", "ruby", "rp", "rt", "small", "time", "var", "kbd", "samp", // Text Types
  "dfn", "sub", "sup", "strong", "em", "b", "i", "u", "s", "del", "ins", // Text Styles
  "ol", "ul", "li", "dl", "dd", "dt", "menu", // Lists
  "table", "thead", "tbody", "tfoot", "tr", "th", "td", "col", "colgroup", // Tables
  "form", "input", "select", "option", "button", "datalist", "fieldset", "legend", "meter",
  "optgroup", "progress", "textarea", "output", // Forms
  "figure", "figcaption", "caption", "img", "video", "map", "area", "track", "picture",
  "source", "audio", // Media
  "iframe", // Embedded content
  "color-picker", "code-mirror", "document-embed", "document-tags", "enriched-content", "file-picker", "hue-slider",
  "multi-select", "multi-checkbox", "range-picker", "secret-block", "string-tags", "prose-mirror" // Custom elements
]);

/**
 * The list of allowed attributes in HTML elements.
 */
const ALLOWED_HTML_ATTRIBUTES = deepFreeze({
  "*": [
    "class", "data-*", "id", "title", "style", "draggable", "aria-*", "tabindex", "dir", "hidden", "inert", "role",
    "is", "lang", "popover", "autocapitalize", "autocorrect", "autofocus", "contenteditable", "spellcheck", "translate"
  ],
  a: ["href", "name", "target", "rel"],
  area: ["alt", "coords", "href", "rel", "shape", "target"],
  audio: ["controls", "loop", "muted", "src", "autoplay"],
  blockquote: ["cite"],
  button: ["disabled", "name", "type", "value"],
  col: ["span"],
  colgroup: ["span"],
  "code-mirror": ["disabled", "name", "value", "placeholder", "readonly", "required", "language", "indent", "nowrap"],
  "color-picker": ["disabled", "name", "value", "placeholder", "readonly", "required"],
  details: ["open"],
  "document-embed": ["uuid"],
  "document-tags": ["disabled", "name", "value", "placeholder", "readonly", "required", "type", "single", "max"],
  "enriched-content": ["enricher"],
  fieldset: ["disabled"],
  "file-picker": ["disabled", "name", "value", "placeholder", "readonly", "required", "type", "noupload"],
  form: ["name"],
  "hue-slider": ["disabled", "name", "value", "readonly", "required"],
  iframe: ["src", "srcdoc", "name", "height", "width", "loading", "sandbox"],
  img: ["height", "src", "width", "usemap", "sizes", "srcset", "alt"],
  input: [
    "checked", "disabled", "name", "value", "placeholder", "type", "alt", "height", "list",
    "max", "min", "readonly", "size", "src", "step", "width", "required"
  ],
  label: ["for"],
  li: ["value"],
  map: ["name"],
  meter: ["value", "min", "max", "low", "high", "optimum"],
  "multi-checkbox": ["disabled", "name", "required"],
  "multi-select": ["disabled", "name", "required"],
  ol: ["reversed", "start", "type"],
  optgroup: ["disabled", "label"],
  option: ["disabled", "selected", "label", "value"],
  output: ["for", "form", "name"],
  progress: ["max", "value"],
  "prose-mirror": ["disabled", "name", "value", "placeholder", "readonly", "required", "toggled", "open"],
  "range-picker": ["disabled", "name", "value", "placeholder", "readonly", "min", "max", "step"],
  select: ["name", "disabled", "multiple", "size", "required"],
  source: ["media", "sizes", "src", "srcset", "type"],
  "string-tags": ["disabled", "name", "value", "placeholder", "readonly", "required"],
  table: ["border"],
  td: ["colspan", "headers", "rowspan"],
  textarea: ["rows", "cols", "disabled", "name", "readonly", "wrap", "required"],
  time: ["datetime"],
  th: ["abbr", "colspan", "headers", "rowspan", "scope", "sorted"],
  track: ["default", "kind", "label", "src", "srclang"],
  video: ["controls", "height", "width", "loop", "muted", "poster", "src", "autoplay"]
});

/**
 * The list of allowed URL schemes.
 */
const ALLOWED_URL_SCHEMES = Object.freeze(["http", "https", "data", "mailto", "obsidian",
  "syrinscape-online"]);

/**
 * The list of attributes validated as URLs.
 */
const ALLOWED_URL_SCHEMES_APPLIED_TO_ATTRIBUTES = Object.freeze(["href", "src", "cite"]);

/**
 * The list of trusted iframe domains.
 */
const TRUSTED_IFRAME_DOMAINS = Object.freeze(["google.com", "youtube.com"]);

/**
 * Available themes for the world join page.
 */
const WORLD_JOIN_THEMES = Object.freeze({
  default: "WORLD.JOIN_THEMES.default",
  minimal: "WORLD.JOIN_THEMES.minimal"
});

/**
 * Setup page package progress protocol.
 */
const SETUP_PACKAGE_PROGRESS = deepFreeze({
  ACTIONS: {
    CREATE_BACKUP: "createBackup",
    RESTORE_BACKUP: "restoreBackup",
    DELETE_BACKUP: "deleteBackup",
    CREATE_SNAPSHOT: "createSnapshot",
    RESTORE_SNAPSHOT: "restoreSnapshot",
    DELETE_SNAPSHOT: "deleteSnapshot",
    INSTALL_PKG: "installPackage",
    LAUNCH_WORLD: "launchWorld",
    UPDATE_CORE: "updateCore",
    UPDATE_DOWNLOAD: "updateDownload"
  },
  STEPS: {
    ARCHIVE: "archive",
    CHECK_DISK_SPACE: "checkDiskSpace",
    CLEAN_WORLD: "cleanWorld",
    EXTRACT_DEMO: "extractDemo",
    CONNECT_WORLD: "connectWorld",
    MIGRATE_WORLD: "migrateWorld",
    CONNECT_PKG: "connectPackage",
    MIGRATE_PKG: "migratePackage",
    MIGRATE_CORE: "migrateCore",
    MIGRATE_SYSTEM: "migrateSystem",
    DOWNLOAD: "download",
    EXTRACT: "extract",
    INSTALL: "install",
    CLEANUP: "cleanup",
    COMPLETE: "complete",
    DELETE: "delete",
    ERROR: "error",
    VEND: "vend",
    SNAPSHOT_MODULES: "snapshotModules",
    SNAPSHOT_SYSTEMS: "snapshotSystems",
    SNAPSHOT_WORLDS: "snapshotWorlds"
  }
});

/**
 * The combat announcements.
 */
const COMBAT_ANNOUNCEMENTS = Object.freeze(["startEncounter", "nextUp", "yourTurn"]);

/**
 * The fit modes of {@link foundry.data.TextureData}.
 */
const TEXTURE_DATA_FIT_MODES = Object.freeze(["fill", "contain", "cover", "width", "height"]);

/**
 * @typedef {typeof TEXTURE_DATA_FIT_MODES[number]} TextureDataFitMode
 */

/**
 * The maximum depth to recurse to when embedding enriched text.
 */
const TEXT_ENRICH_EMBED_MAX_DEPTH = 5;

/**
 * The Region events that are supported by core.
 */
const REGION_EVENTS = /** @type {const} */ ({
  /**
   * Triggered when the shapes or bottom/top elevation of the Region are changed.
   *
   * @see {@link foundry.documents.types.RegionRegionBoundaryEvent}
   */
  REGION_BOUNDARY: "regionBoundary",

  /**
   * Triggered when the Region Behavior becomes active, i.e. is enabled or created without being disabled.
   *
   * The event is triggered only for this Region Behavior.
   *
   * @see {@link foundry.documents.types.RegionBehaviorActivatedEvent}
   */
  BEHAVIOR_ACTIVATED: "behaviorActivated",

  /**
   * Triggered when the Region Behavior becomes inactive, i.e. is disabled or deleted without being disabled.
   *
   * The event is triggered only for this Region Behavior.
   *
   * @see {@link foundry.documents.types.RegionBehaviorDeactivatedEvent}
   */
  BEHAVIOR_DEACTIVATED: "behaviorDeactivated",

  /**
   * Triggered when the Region Behavior becomes viewed, i.e. active and the Scene of its Region is viewed.
   *
   * The event is triggered only for this Region Behavior.
   *
   * @see {@link foundry.documents.types.RegionBehaviorViewedEvent}
   */
  BEHAVIOR_VIEWED: "behaviorViewed",

  /**
   * Triggered when the Region Behavior becomes unviewed, i.e. inactive or the Scene of its Region is unviewed.
   *
   * The event is triggered only for this Region Behavior.
   *
   * @see {@link foundry.documents.types.RegionBehaviorUnviewedEvent}
   */
  BEHAVIOR_UNVIEWED: "behaviorUnviewed",

  /**
   * Triggered when a Token enters a Region.
   *
   * A Token enters a Region whenever ...
   *   - it is created within the Region,
   *   - the boundary of the Region has changed such that the Token is now inside the Region,
   *   - the Token moves into the Region (the Token's x, y, elevation, width, height, or shape
   *     has changed such that it is now inside the Region), or
   *   - a Region Behavior becomes active (i.e., is enabled or created while enabled), in which case
   *     the event it triggered only for this Region Behavior.
   *
   * @see {@link foundry.documents.types.RegionTokenEnterEvent}
   */
  TOKEN_ENTER: "tokenEnter",

  /**
   * Triggered when a Token exits a Region.
   *
   * A Token exits a Region whenever ...
   *   - it is deleted while inside the Region,
   *   - the boundary of the Region has changed such that the Token is no longer inside the Region,
   *   - the Token moves out of the Region (the Token's x, y, elevation, width, height, or shape
   *     has changed such that it is no longer inside the Region), or
   *   - a Region Behavior becomes inactive (i.e., is disabled or deleted while enabled), in which case
   *     the event it triggered only for this Region Behavior.
   *
   * @see {@link foundry.documents.types.RegionTokenExitEvent}
   */
  TOKEN_EXIT: "tokenExit",

  /**
   * Triggered when a Token moves into a Region.
   *
   * A Token moves whenever its x, y, elevation, width, height, or shape is changed.
   *
   * @see {@link foundry.documents.types.RegionTokenMoveInEvent}
   */
  TOKEN_MOVE_IN: "tokenMoveIn",

  /**
   * Triggered when a Token moves out of a Region.
   *
   * A Token moves whenever its x, y, elevation, width, height, or shape is changed.
   *
   * @see {@link foundry.documents.types.RegionTokenMoveOutEvent}
   */
  TOKEN_MOVE_OUT: "tokenMoveOut",

  /**
   * Triggered when a Token moves within a Region.
   *
   * A token moves whenever its x, y, elevation, width, height, or shape is changed.
   *
   * @see {@link foundry.documents.types.RegionTokenMoveWithinEvent}
   */
  TOKEN_MOVE_WITHIN: "tokenMoveWithin",

  /**
   * Triggered when a Token animates into a Region.
   *
   * This event is only triggered only if the Scene the Token is in is viewed.
   *
   * @see {@link foundry.documents.types.RegionTokenAnimateInEvent}
   */
  TOKEN_ANIMATE_IN: "tokenAnimateIn",

  /**
   * Triggered when a Token animates out of a Region.
   *
   * This event is triggered only if the Scene the Token is in is viewed.
   *
   * @see {@link foundry.documents.types.RegionTokenAnimateOutEvent}
   */
  TOKEN_ANIMATE_OUT: "tokenAnimateOut",

  /**
   * Triggered when a Token starts its Combat turn in a Region.
   *
   * @see {@link foundry.documents.types.RegionTokenTurnStartEvent}
   */
  TOKEN_TURN_START: "tokenTurnStart",

  /**
   * Triggered when a Token ends its Combat turn in a Region.
   *
   * @see {@link foundry.documents.types.RegionTokenTurnEndEvent}
   */
  TOKEN_TURN_END: "tokenTurnEnd",

  /**
   * Triggered when a Token starts the Combat round in a Region.
   *
   * @see {@link foundry.documents.types.RegionTokenRoundStartEvent}
   */
  TOKEN_ROUND_START: "tokenRoundStart",

  /**
   * Triggered when a Token ends the Combat round in a Region.
   *
   * @see {@link foundry.documents.types.RegionTokenRoundEndEvent}
   */
  TOKEN_ROUND_END: "tokenRoundEnd"

});

Object.defineProperties(REGION_EVENTS, {
  /** @deprecated since v13 */
  BEHAVIOR_STATUS: {
    get() {
      const message = "CONST.REGION_EVENTS.BEHAVIOR_STATUS is deprecated in favor of BEHAVIOR_ACTIVATED"
    + "BEHAVIOR_DEACTIVATED, BEHAVIOR_VIEWED, and BEHAVIOR_UNVIEWED.";
      foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15, once: true});
      return "behaviorStatus";
    }
  },
  /** @deprecated since v13 */
  TOKEN_PRE_MOVE: {
    get() {
      foundry.utils.logCompatibilityWarning("CONST.REGION_EVENTS.TOKEN_PRE_MOVE is deprecated without replacement. "
          + "The TOKEN_PRE_MOVE event is not longer triggered.", {since: 13, until: 15, once: true});
      return "tokenPreMove";
    }
  },
  /** @deprecated since v13 */
  TOKEN_MOVE: {
    get() {
      foundry.utils.logCompatibilityWarning("CONST.REGION_EVENTS.TOKEN_MOVE is deprecated without replacement. "
        + "The TOKEN_MOVE event is not longer triggered.", {since: 13, until: 15, once: true});
      return "tokenMove";
    }
  }
});
Object.freeze(REGION_EVENTS);

/**
 * @typedef {typeof REGION_EVENTS[keyof typeof REGION_EVENTS]} RegionEventType
 */

/**
 * The possible visibility state of Region.
 */
const REGION_VISIBILITY = Object.freeze({

  /**
   * Only visible on the RegionLayer.
   */
  LAYER: 0,

  /**
   * Only visible to Gamemasters.
   */
  GAMEMASTER: 1,

  /**
   * Visible to anyone.
   */
  ALWAYS: 2
});

/**
 * The types of a Region movement segment.
 */
const REGION_MOVEMENT_SEGMENTS = 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
});

/**
 * @typedef {typeof REGION_MOVEMENT_SEGMENTS[keyof typeof REGION_MOVEMENT_SEGMENTS]} RegionMovementSegmentType
 */

/**
 * Available setting scopes.
 */
const SETTING_SCOPES = Object.freeze({
  /**
   * Settings scoped to the client device. Stored in localStorage.
   */
  CLIENT: "client",

  /**
   * Settings scoped to the game World. Applies to all Users in the World. Stored in the Settings database.
   */
  WORLD: "world",

  /**
   * Settings scoped to an individual User in the World. Stored in the Settings database.
   */
  USER: "user"
});

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

/**
 * The scaling factor that is used for Clipper polygons/paths consistently everywhere core performs Clipper operations.
 */
const CLIPPER_SCALING_FACTOR = 100;

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

/**
 * @deprecated since v12
 * @ignore
 */
const CHAT_MESSAGE_TYPES = new Proxy(CHAT_MESSAGE_STYLES, {
  get(target, prop, receiver) {
    const msg = "CONST.CHAT_MESSAGE_TYPES is deprecated in favor of CONST.CHAT_MESSAGE_STYLES because the "
      + "ChatMessage#type field has been renamed to ChatMessage#style";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return Reflect.get(...arguments);
  }
});


/**
 * @deprecated since v12
 * @ignore
 */
const _DOCUMENT_TYPES = Object.freeze(WORLD_DOCUMENT_TYPES.filter(t => {
  const excluded = ["FogExploration", "Setting"];
  return !excluded.includes(t);
}));

/**
 * @deprecated since v12
 * @ignore
 */
const DOCUMENT_TYPES = new Proxy(_DOCUMENT_TYPES, {
  get(target, prop, receiver) {
    const msg = "CONST.DOCUMENT_TYPES is deprecated in favor of either CONST.WORLD_DOCUMENT_TYPES or "
      + "CONST.COMPENDIUM_DOCUMENT_TYPES.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return Reflect.get(...arguments);
  }
});

/**
 * @deprecated since v13
 * @ignore
 */
const TOKEN_HEXAGONAL_SHAPES = new Proxy(TOKEN_SHAPES, {
  get(target, prop, receiver) {
    const msg = "CONST.TOKEN_HEXAGONAL_SHAPES is deprecated in favor of CONST.TOKEN_SHAPES.";
    foundry.utils.logCompatibilityWarning(msg, {since: 13, until: 15, once: true});
    return Reflect.get(...arguments);
  }
});

var constants = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ACTIVE_EFFECT_MODES: ACTIVE_EFFECT_MODES,
  ALLOWED_HTML_ATTRIBUTES: ALLOWED_HTML_ATTRIBUTES,
  ALLOWED_HTML_TAGS: ALLOWED_HTML_TAGS,
  ALLOWED_URL_SCHEMES: ALLOWED_URL_SCHEMES,
  ALLOWED_URL_SCHEMES_APPLIED_TO_ATTRIBUTES: ALLOWED_URL_SCHEMES_APPLIED_TO_ATTRIBUTES,
  ALL_DOCUMENT_TYPES: ALL_DOCUMENT_TYPES,
  ASCII: ASCII,
  AUDIO_CHANNELS: AUDIO_CHANNELS,
  AUDIO_FILE_EXTENSIONS: AUDIO_FILE_EXTENSIONS,
  BASE_DOCUMENT_TYPE: BASE_DOCUMENT_TYPE,
  CANVAS_PERFORMANCE_MODES: CANVAS_PERFORMANCE_MODES,
  CARD_DRAW_MODES: CARD_DRAW_MODES,
  CHAT_MESSAGE_STYLES: CHAT_MESSAGE_STYLES,
  CHAT_MESSAGE_TYPES: CHAT_MESSAGE_TYPES,
  CLIPPER_SCALING_FACTOR: CLIPPER_SCALING_FACTOR,
  COMBAT_ANNOUNCEMENTS: COMBAT_ANNOUNCEMENTS,
  COMPATIBILITY_MODES: COMPATIBILITY_MODES,
  COMPENDIUM_DOCUMENT_TYPES: COMPENDIUM_DOCUMENT_TYPES,
  CORE_SUPPORTED_LANGUAGES: CORE_SUPPORTED_LANGUAGES,
  CSS_THEMES: CSS_THEMES,
  CURSOR_STYLES: CURSOR_STYLES,
  DEFAULT_TOKEN: DEFAULT_TOKEN,
  DICE_ROLL_MODES: DICE_ROLL_MODES,
  DIRECTORY_SEARCH_MODES: DIRECTORY_SEARCH_MODES,
  DOCUMENT_LINK_TYPES: DOCUMENT_LINK_TYPES,
  DOCUMENT_META_OWNERSHIP_LEVELS: DOCUMENT_META_OWNERSHIP_LEVELS,
  DOCUMENT_OWNERSHIP_LEVELS: DOCUMENT_OWNERSHIP_LEVELS,
  DOCUMENT_TYPES: DOCUMENT_TYPES,
  DRAWING_FILL_TYPES: DRAWING_FILL_TYPES,
  EMBEDDED_DOCUMENT_TYPES: EMBEDDED_DOCUMENT_TYPES,
  FILE_CATEGORIES: FILE_CATEGORIES,
  FILE_PICKER_PUBLIC_DIRS: FILE_PICKER_PUBLIC_DIRS,
  FOLDER_DOCUMENT_TYPES: FOLDER_DOCUMENT_TYPES,
  FOLDER_MAX_DEPTH: FOLDER_MAX_DEPTH,
  FONT_FILE_EXTENSIONS: FONT_FILE_EXTENSIONS,
  FONT_WEIGHTS: FONT_WEIGHTS,
  GAME_VIEWS: GAME_VIEWS,
  GRAPHICS_FILE_EXTENSIONS: GRAPHICS_FILE_EXTENSIONS,
  GRID_DIAGONALS: GRID_DIAGONALS,
  GRID_MIN_SIZE: GRID_MIN_SIZE,
  GRID_SNAPPING_MODES: GRID_SNAPPING_MODES,
  GRID_TYPES: GRID_TYPES,
  HTML_FILE_EXTENSIONS: HTML_FILE_EXTENSIONS,
  IMAGE_FILE_EXTENSIONS: IMAGE_FILE_EXTENSIONS,
  JOURNAL_ENTRY_PAGE_FORMATS: JOURNAL_ENTRY_PAGE_FORMATS,
  KEYBINDING_PRECEDENCE: KEYBINDING_PRECEDENCE,
  LIGHTING_LEVELS: LIGHTING_LEVELS,
  MACRO_SCOPES: MACRO_SCOPES,
  MACRO_TYPES: MACRO_TYPES,
  MEASURED_TEMPLATE_TYPES: MEASURED_TEMPLATE_TYPES,
  MEDIA_FILE_CATEGORIES: MEDIA_FILE_CATEGORIES,
  MEDIA_MIME_TYPES: MEDIA_MIME_TYPES,
  MOVEMENT_DIRECTIONS: MOVEMENT_DIRECTIONS,
  OCCLUSION_MODES: OCCLUSION_MODES,
  PACKAGE_AVAILABILITY_CODES: PACKAGE_AVAILABILITY_CODES,
  PACKAGE_TYPES: PACKAGE_TYPES$1,
  PASSWORD_SAFE_STRING: PASSWORD_SAFE_STRING,
  PLAYLIST_MODES: PLAYLIST_MODES,
  PLAYLIST_SORT_MODES: PLAYLIST_SORT_MODES,
  PRIMARY_DOCUMENT_TYPES: PRIMARY_DOCUMENT_TYPES,
  REGION_EVENTS: REGION_EVENTS,
  REGION_MOVEMENT_SEGMENTS: REGION_MOVEMENT_SEGMENTS,
  REGION_VISIBILITY: REGION_VISIBILITY,
  SETTING_SCOPES: SETTING_SCOPES,
  SETUP_PACKAGE_PROGRESS: SETUP_PACKAGE_PROGRESS,
  SETUP_VIEWS: SETUP_VIEWS,
  SHOWDOWN_OPTIONS: SHOWDOWN_OPTIONS,
  SOFTWARE_UPDATE_CHANNELS: SOFTWARE_UPDATE_CHANNELS,
  SORT_INTEGER_DENSITY: SORT_INTEGER_DENSITY,
  SYSTEM_SPECIFIC_COMPENDIUM_TYPES: SYSTEM_SPECIFIC_COMPENDIUM_TYPES,
  TABLE_RESULT_TYPES: TABLE_RESULT_TYPES,
  TEXTURE_DATA_FIT_MODES: TEXTURE_DATA_FIT_MODES,
  TEXT_ANCHOR_POINTS: TEXT_ANCHOR_POINTS,
  TEXT_ENRICH_EMBED_MAX_DEPTH: TEXT_ENRICH_EMBED_MAX_DEPTH,
  TEXT_FILE_EXTENSIONS: TEXT_FILE_EXTENSIONS,
  TILE_OCCLUSION_MODES: TILE_OCCLUSION_MODES,
  TIMEOUTS: TIMEOUTS,
  TOKEN_DISPLAY_MODES: TOKEN_DISPLAY_MODES,
  TOKEN_DISPOSITIONS: TOKEN_DISPOSITIONS,
  TOKEN_HEXAGONAL_SHAPES: TOKEN_HEXAGONAL_SHAPES,
  TOKEN_OCCLUSION_MODES: TOKEN_OCCLUSION_MODES,
  TOKEN_SHAPES: TOKEN_SHAPES,
  TOKEN_TURN_MARKER_MODES: TOKEN_TURN_MARKER_MODES,
  TRUSTED_IFRAME_DOMAINS: TRUSTED_IFRAME_DOMAINS,
  UPLOADABLE_FILE_EXTENSIONS: UPLOADABLE_FILE_EXTENSIONS,
  USER_PERMISSIONS: USER_PERMISSIONS,
  USER_ROLES: USER_ROLES,
  USER_ROLE_NAMES: USER_ROLE_NAMES,
  VIDEO_FILE_EXTENSIONS: VIDEO_FILE_EXTENSIONS,
  VTT: VTT,
  WALL_DIRECTIONS: WALL_DIRECTIONS,
  WALL_DOOR_INTERACTIONS: WALL_DOOR_INTERACTIONS,
  WALL_DOOR_STATES: WALL_DOOR_STATES,
  WALL_DOOR_TYPES: WALL_DOOR_TYPES,
  WALL_MOVEMENT_TYPES: WALL_MOVEMENT_TYPES,
  WALL_RESTRICTION_TYPES: WALL_RESTRICTION_TYPES,
  WALL_SENSE_TYPES: WALL_SENSE_TYPES,
  WEBSITE_API_URL: WEBSITE_API_URL,
  WEBSITE_URL: WEBSITE_URL,
  WORLD_DOCUMENT_TYPES: WORLD_DOCUMENT_TYPES,
  WORLD_JOIN_THEMES: WORLD_JOIN_THEMES,
  vtt: vtt
});

/** @module validators */

/**
 * Test whether a string is a valid 16 character UID
 * @param {string} id
 * @return {boolean}
 */
function isValidId(id) {
  return /^[a-zA-Z0-9]{16}$/.test(id);
}

/**
 * Test whether a file path has an extension in a list of provided extensions
 * @param {string} path
 * @param {string[]} extensions
 * @return {boolean}
 */
function hasFileExtension(path, extensions) {
  const xts = extensions.map(ext => `\\.${ext}`).join("|");
  const rgx = new RegExp(`(${xts})(\\?.*)?$`, "i");
  return !!path && rgx.test(path);
}

/**
 * Test whether a string data blob contains base64 data, optionally of a specific type or types
 * @param {string} data       The candidate string data
 * @param {string[]} [types]  An array of allowed mime types to test
 * @return {boolean}
 */
function isBase64Data(data, types) {
  if ( types === undefined ) return /^data:([a-z]+)\/([a-z0-9]+);base64,/.test(data);
  return types.some(type => data.startsWith(`data:${type};base64,`))
}

/**
 * Test whether an input represents a valid 6-character color string
 * @param {string} color      The input string to test
 * @return {boolean}          Is the string a valid color?
 */
function isColorString(color) {
  return /^#[0-9A-Fa-f]{6}$/.test(color);
}

/**
 * Assert that the given value parses as a valid JSON string
 * @param {string} val        The value to test
 * @return {boolean}          Is the String valid JSON?
 */
function isJSON(val) {
  try {
    JSON.parse(val);
    return true;
  } catch(err) {
    return false;
  }
}

var validators = /*#__PURE__*/Object.freeze({
  __proto__: null,
  hasFileExtension: hasFileExtension,
  isBase64Data: isBase64Data,
  isColorString: isColorString,
  isJSON: isJSON,
  isValidId: isValidId
});

/**
 * @import {ElementValidationFailure} from "./_types.mjs";
 */

/**
 * A class responsible for recording information about a validation failure.
 */
class DataModelValidationFailure {
  /**
   * @param {object} [options]
   * @param {any} [options.invalidValue]       The value that failed validation for this field.
   * @param {any} [options.fallback]           The value it was replaced by, if any.
   * @param {boolean} [options.dropped=false]  Whether the value was dropped from some parent collection.
   * @param {string} [options.message]         The validation error message.
   * @param {boolean} [options.unresolved=false]     Whether this failure was unresolved
   */
  constructor({invalidValue, fallback, dropped=false, message, unresolved=false}={}) {
    this.invalidValue = invalidValue;
    this.fallback = fallback;
    this.dropped = dropped;
    this.message = message;
    this.unresolved = unresolved;
  }

  /**
   * The value that failed validation for this field.
   * @type {any}
   */
  invalidValue;

  /**
   * The value it was replaced by, if any.
   * @type {any}
   */
  fallback;

  /**
   * Whether the value was dropped from some parent collection.
   * @type {boolean}
   */
  dropped;

  /**
   * The validation error message.
   * @type {string}
   */
  message;

  /**
   * If this field contains other fields that are validated as part of its validation, their results are recorded here.
   * @type {Record<string, DataModelValidationFailure>}
   */
  fields = {};

  /**
   * If this field contains a list of elements that are validated as part of its validation, their results are recorded
   * here.
   * @type {ElementValidationFailure[]}
   */
  elements = [];

  /**
   * Record whether a validation failure is unresolved.
   * This reports as true if validation for this field or any hierarchically contained field is unresolved.
   * A failure is unresolved if the value was invalid and there was no valid fallback value available.
   * @type {boolean}
   */
  unresolved;

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

  /**
   * Return this validation failure as an Error object.
   * @returns {DataModelValidationError}
   */
  asError() {
    return new DataModelValidationError(this);
  }

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

  /**
   * Whether this failure contains other sub-failures.
   * @returns {boolean}
   */
  isEmpty() {
    return isEmpty(this.fields) && isEmpty(this.elements);
  }

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

  /**
   * Return the base properties of this failure, omitting any nested failures.
   * @returns {{invalidValue: any, fallback: any, dropped: boolean, message: string}}
   */
  toObject() {
    const {invalidValue, fallback, dropped, message} = this;
    return {invalidValue, fallback, dropped, message};
  }

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

  /**
   * Represent the DataModelValidationFailure as a string.
   * @returns {string}
   */
  toString() {
    return DataModelValidationFailure.#formatString(this);
  }

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

  /**
   * Format a DataModelValidationFailure instance as a string message.
   * @param {DataModelValidationFailure} failure    The failure instance
   * @param {number} _d                             An internal depth tracker
   * @returns {string}                              The formatted failure string
   */
  static #formatString(failure, _d=0) {
    let message = failure.message ?? "";
    _d++;
    if ( !isEmpty(failure.fields) ) {
      message += "\n";
      const messages = [];
      for ( const [key, subFailure] of Object.entries(failure.fields) ) {
        const name = isDeletionKey(key) ? key.slice(2) : key;
        const subMessage = DataModelValidationFailure.#formatString(subFailure, _d);
        messages.push(`${" ".repeat(2 * _d)}${name}: ${subMessage}`);
      }
      message += messages.join("\n");
    }
    if ( !isEmpty(failure.elements) ) {
      message += "\n";
      const messages = [];
      for ( const element of failure.elements ) {
        const subMessage = DataModelValidationFailure.#formatString(element.failure, _d);
        messages.push(`${" ".repeat(2 * _d)}${element.id}: ${subMessage}`);
      }
      message += messages.join("\n");
    }
    return message;
  }
}

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

/**
 * A specialised Error to indicate a model validation failure.
 * @extends {Error}
 */
class DataModelValidationError extends Error {
  /**
   * @param {DataModelValidationFailure|string} failure  The failure that triggered this error or an error message
   * @param {...any} [params]                            Additional Error constructor parameters
   */
  constructor(failure, ...params) {
    super(failure.toString(), ...params);
    if ( failure instanceof DataModelValidationFailure ) this.#failure = failure;
  }

  /**
   * The root validation failure that triggered this error.
   * @type {DataModelValidationFailure}
   */
  #failure;

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

  /**
   * Retrieve the root failure that caused this error, or a specific sub-failure via a path.
   * @param {string} [path]  The property path to the failure.
   * @returns {DataModelValidationFailure}
   *
   * @example Retrieving a failure.
   * ```js
   * const changes = {
   *   "foo.bar": "validValue",
   *   "foo.baz": "invalidValue"
   * };
   * try {
   *   doc.validate(expandObject(changes));
   * } catch ( err ) {
   *   const failure = err.getFailure("foo.baz");
   *   console.log(failure.invalidValue); // "invalidValue"
   * }
   * ```
   */
  getFailure(path) {
    if ( !this.#failure ) return;
    if ( !path ) return this.#failure;
    let failure = this.#failure;
    for ( const p of path.split(".") ) {
      if ( !failure ) return;
      if ( !isEmpty(failure.fields) ) failure = failure.fields[p];
      else if ( !isEmpty(failure.elements) ) failure = failure.elements.find(e => e.id?.toString() === p);
    }
    return failure;
  }

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

  /**
   * Retrieve a flattened object of all the properties that failed validation as part of this error.
   * @returns {Record<string, DataModelValidationFailure>}
   *
   * @example Removing invalid changes from an update delta.
   * ```js
   * const changes = {
   *   "foo.bar": "validValue",
   *   "foo.baz": "invalidValue"
   * };
   * try {
   *   doc.validate(expandObject(changes));
   * } catch ( err ) {
   *   const failures = err.getAllFailures();
   *   if ( failures ) {
   *     for ( const prop in failures ) delete changes[prop];
   *     doc.validate(expandObject(changes));
   *   }
   * }
   * ```
   */
  getAllFailures() {
    if ( !this.#failure || this.#failure.isEmpty() ) return;
    return DataModelValidationError.#aggregateFailures(this.#failure);
  }

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

  /**
   * Log the validation error as a table.
   */
  logAsTable() {
    const failures = this.getAllFailures();
    if ( isEmpty(failures) ) return;
    console.table(Object.entries(failures).reduce((table, [p, failure]) => {
      table[p] = failure.toObject();
      return table;
    }, {}));
  }

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

  /**
   * Generate a nested tree view of the error as an HTML string.
   * @returns {string}
   */
  asHTML() {
    const renderFailureNode = failure => {
      if ( failure.isEmpty() ) return `<li>${foundry.utils.escapeHTML(failure.message || "")}</li>`;
      const nodes = [];
      for ( const [field, subFailure] of Object.entries(failure.fields) ) {
        nodes.push(`<li><details><summary>${field}</summary><ul>${renderFailureNode(subFailure)}</ul></details></li>`);
      }
      for ( const element of failure.elements ) {
        const name = element.name || element.id;
        const html = `
          <li><details><summary>${name}</summary><ul>${renderFailureNode(element.failure)}</ul></details></li>
        `;
        nodes.push(html);
      }
      return nodes.join("");
    };
    return `<ul class="summary-tree">${renderFailureNode(this.#failure)}</ul>`;
  }

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

  /**
   * Collect nested failures into an aggregate object.
   * @param {DataModelValidationFailure} failure                               The failure.
   * @returns {DataModelValidationFailure|Record<string, DataModelValidationFailure>}  Returns the failure at the leaf of the
   *                                                                           tree, otherwise an object of
   *                                                                           sub-failures.
   */
  static #aggregateFailures(failure) {
    if ( failure.isEmpty() ) return failure;
    const failures = {};
    const recordSubFailures = (field, subFailures) => {
      if ( subFailures instanceof DataModelValidationFailure ) failures[field] = subFailures;
      else {
        for ( const [k, v] of Object.entries(subFailures) ) {
          failures[`${field}.${k}`] = v;
        }
      }
    };
    for ( const [field, subFailure] of Object.entries(failure.fields) ) {
      recordSubFailures(field, DataModelValidationError.#aggregateFailures(subFailure));
    }
    for ( const element of failure.elements ) {
      recordSubFailures(element.id, DataModelValidationError.#aggregateFailures(element.failure));
    }
    return failures;
  }
}

var validationFailure = /*#__PURE__*/Object.freeze({
  __proto__: null,
  DataModelValidationError: DataModelValidationError,
  DataModelValidationFailure: DataModelValidationFailure
});

/**
 * A reusable storage concept which blends the functionality of an Array with the efficient key-based lookup of a Map.
 * This concept is reused throughout Foundry VTT where a collection of uniquely identified elements is required.
 * @template {string} K
 * @template V
 * @extends {Map<K, V>}
 */
class Collection extends Map {

  /**
   * Then iterating over a Collection, we should iterate over its values instead of over its entries
   * @returns {MapIterator<V>}
   */
  [Symbol.iterator]() {
    return this.values();
  }

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

  /**
   * Return an Array of all the entry values in the Collection
   * @type {V[]}
   */
  get contents() {
    return Array.from(this.values());
  }

  /* -------------------------------------------- */
  /**
   * Find an entry in the Map using a functional condition.
   * @see {Array#find}
   * @param {(value: V, index: number, collection: Collection<K, V>) => unknown} condition The functional condition to
   *                                                                                       test.
   * @returns {V|undefined} The value, if found, otherwise undefined
   *
   * @example Create a new Collection and reference its contents
   * ```js
   * let c = new Collection([["a", "A"], ["b", "B"], ["c", "C"]]);
   * c.get("a") === c.find(entry => entry === "A"); // true
   * ```
   */
  find(condition) {
    let i = 0;
    for ( const v of this.values() ) {
      if ( condition(v, i, this) ) return v;
      i++;
    }
    return undefined;
  }

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

  /**
   * Filter the Collection, returning an Array of entries which match a functional condition.
   * @see {Array#filter}
   * @param {(value: V, index: number, collection: Collection<K, V>) => unknown} condition The functional condition to
   *                                                                                       test.
   * @returns {V[]}           An Array of matched values
   *
   * @example Filter the Collection for specific entries
   * ```js
   * let c = new Collection([["a", "AA"], ["b", "AB"], ["c", "CC"]]);
   * let hasA = c.filters(entry => entry.slice(0) === "A");
   * ```
   */
  filter(condition) {
    const entries = [];
    let i = 0;
    for ( const v of this.values() ) {
      if ( condition(v, i, this) ) entries.push(v);
      i++;
    }
    return entries;
  }

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

  /**
   * Apply a function to each element of the collection
   * @see Array#forEach
   * @param {(value: V) => void} fn A function to apply to each element
   *
   * @example Apply a function to each value in the collection
   * ```js
   * let c = new Collection([["a", {active: false}], ["b", {active: false}], ["c", {active: false}]]);
   * c.forEach(e => e.active = true);
   * ```
   */
  forEach(fn) {
    for ( const e of this.values() ) {
      fn(e);
    }
  }

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

  /**
   * Get an element from the Collection by its key.
   * @param {string} key      The key of the entry to retrieve
   * @param {object} [options]  Additional options that affect how entries are retrieved
   * @param {boolean} [options.strict=false] Throw an Error if the requested key does not exist. Default false.
   * @returns {V|undefined} The retrieved entry value, if the key exists, otherwise undefined
   *
   * @example Get an element from the Collection by key
   * ```js
   * let c = new Collection([["a", "Alfred"], ["b", "Bob"], ["c", "Cynthia"]]);
   * c.get("a"); // "Alfred"
   * c.get("d"); // undefined
   * c.get("d", {strict: true}); // throws Error
   * ```
   */
  get(key, {strict=false}={}) {
    const entry = super.get(key);
    if ( strict && (entry === undefined) ) {
      throw new Error(`The key ${key} does not exist in the ${this.constructor.name} Collection`);
    }
    return entry;
  }

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

  /**
   * Get an entry from the Collection by name.
   * Use of this method assumes that the objects stored in the collection have a "name" attribute.
   * @param {string} name       The name of the entry to retrieve
   * @param {object} [options]  Additional options that affect how entries are retrieved
   * @param {boolean} [options.strict=false] Throw an Error if the requested name does not exist. Default false.
   * @returns {V|undefined} The retrieved entry value, if one was found, otherwise undefined
   *
   * @example Get an element from the Collection by name (if applicable)
   * ```js
   * let c = new Collection([["a", "Alfred"], ["b", "Bob"], ["c", "Cynthia"]]);
   * c.getName("Alfred"); // "Alfred"
   * c.getName("D"); // undefined
   * c.getName("D", {strict: true}); // throws Error
   * ```
   */
  getName(name, {strict=false}={}) {
    const entry = this.find(e => e.name === name);
    if ( strict && (entry === undefined) ) {
      throw new Error(`An entry with name ${name} does not exist in the collection`);
    }
    return entry ?? undefined;
  }

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

  /**
   * Transform each element of the Collection into a new form, returning an Array of transformed values
   * @param {function(V,number,Collection): *} transformer  A transformation function applied to each entry value.
   * Positional arguments are the value, the index of iteration, and the collection being mapped.
   * @returns {Array<*>}  An Array of transformed values
   */
  map(transformer) {
    const transformed = [];
    let i = 0;
    for ( const v of this.values() ) {
      transformed.push(transformer(v, i, this));
      i++;
    }
    return transformed;
  }

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

  /**
   * Reduce the Collection by applying an evaluator function and accumulating entries
   * @see {Array#reduce}
   * @param {function(*,V,number,Collection): *} reducer  A reducer function applied to each entry value. Positional
   * arguments are the accumulator, the value, the index of iteration, and the collection being reduced.
   * @param {*} initial             An initial value which accumulates with each iteration
   * @returns {*}                    The accumulated result
   *
   * @example Reduce a collection to an array of transformed values
   * ```js
   * let c = new Collection([["a", "A"], ["b", "B"], ["c", "C"]]);
   * let letters = c.reduce((s, l) => {
   *   return s + l;
   * }, ""); // "ABC"
   * ```
   */
  reduce(reducer, initial) {
    let accumulator = initial;
    let i = 0;
    for ( const v of this.values() ) {
      accumulator = reducer(accumulator, v, i, this);
      i++;
    }
    return accumulator;
  }

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

  /**
   * Test whether a condition is met by some entry in the Collection.
   * @see {Array#some}
   * @param {function(V,number,Collection): boolean} condition  The functional condition to test. Positional
   * arguments are the value, the index of iteration, and the collection being tested.
   * @returns {boolean}  Was the test condition passed by at least one entry?
   */
  some(condition) {
    let i = 0;
    for ( const v of this.values() ) {
      const pass = condition(v, i, this);
      i++;
      if ( pass ) return true;
    }
    return false;
  }

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

  /**
   * Convert the Collection to a primitive array of its contents.
   * @returns {object[]}  An array of contained values
   */
  toJSON() {
    return this.map(e => e.toJSON ? e.toJSON() : e);
  }
}

/**
 * @import DataModel from "./data.mjs";
 * @import Document from "./document.mjs";
 * @import {DatabaseAction, DatabaseOperation} from "./_types.mjs";
 * @import BaseUser from "../documents/user.mjs";
 * @import {DocumentConstructionContext} from "./_types.mjs";
 */

/**
 * An extension of the Collection.
 * Used for the specific task of containing embedded Document instances within a parent Document.
 * @template {Document} TDocument
 * @extends Collection<string, TDocument>
 */
class EmbeddedCollection extends Collection {
  /**
   * @param {string} name           The name of this collection in the parent Document.
   * @param {Document} parent       The parent Document instance to which this collection belongs.
   * @param {object[]} sourceArray  The source data array for the collection in the parent Document data.
   */
  constructor(name, parent, sourceArray) {
    if ( typeof name !== "string" ) throw new Error("The signature of EmbeddedCollection has changed in v11.");
    super();
    Object.defineProperties(this, {
      _source: {value: sourceArray, writable: false},
      documentClass: {value: parent.constructor.hierarchy[name].model, writable: false},
      name: {value: name, writable: false},
      model: {value: parent, writable: false}
    });
  }

  /**
   * The Document implementation used to construct instances within this collection.
   * @type {typeof Document}
   */
  documentClass;

  /**
   * The Document name of Documents stored in this collection.
   * @returns {string|void}
   */
  get documentName() {
    return this.documentClass?.documentName;
  }

  /**
   * The name of this collection in the parent Document.
   * @type {string}
   */
  name;

  /**
   * The parent Document to which this EmbeddedCollection instance belongs.
   * @type {Document}
   */
  model; // TODO: Should we rename this property parentDocument?

  /**
   * Has this embedded collection been initialized as a one-time workflow?
   * @type {boolean}
   * @protected
   */
  _initialized = false;

  /**
   * The source data array from which the embedded collection is created
   * @type {object[]}
   * @public
   */
  _source;

  /**
   * Record the set of document ids where the Document was not initialized because of invalid source data
   * @type {Set<string>}
   */
  invalidDocumentIds = new Set();

  /**
   * A cache of this collection's contents grouped by subtype
   * @type {Record<string, TDocument[]>|null}
   */
  #documentsByType = null;

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

  /**
   * This collection's contents grouped by subtype, lazily (re-)computed as needed.
   * If the document type does not support subtypes, all will be in the "base" group.
   * @type {Record<string, TDocument[]>}
   */
  get documentsByType() {
    if ( this.#documentsByType ) return this.#documentsByType;
    const typeName = this.documentClass.metadata.name;
    const types = Object.fromEntries(game.documentTypes[typeName].map(t => [t, []]));
    for ( const document of this.values() ) {
      types[document._source.type ?? "base"]?.push(document);
    }
    return this.#documentsByType = types;
  }

  /* -------------------------------------------- */
  /*  Collection Initialization                   */
  /* -------------------------------------------- */

  /**
   * Initialize the EmbeddedCollection by synchronizing its Document instances with existing _source data.
   * Importantly, this method does not make any modifications to the _source array.
   * It is responsible for creating, updating, or removing Documents from the Collection.
   * @param {DocumentConstructionContext} [options]  Initialization options.
   */
  initialize(options={}) {
    this._initialized = false;
    this.#documentsByType = null;

    // Re-initialize all records in source
    const initializedIds = new Set();
    for ( const obj of this._source ) {
      const doc = this._initializeDocument(obj, options);
      if ( doc ) initializedIds.add(doc.id);
    }

    // Remove documents that no longer exist in source
    if ( this.size !== initializedIds.size ) {
      for ( const k of this.keys() ) {
        if ( !initializedIds.has(k) ) this.delete(k, {modifySource: false});
      }
    }
    this._initialized = true;
  }

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

  /**
   * Initialize an embedded document and store it in the collection.
   * The document may already exist, in which case we are reinitializing it with new _source data.
   * The document may not yet exist, in which case we create a new Document instance using the provided source.
   *
   * @param {object} data                    The Document data.
   * @param {DocumentConstructionContext} [options]  Initialization options.
   * @returns {TDocument|null}               The initialized document or null if no document was initialized
   * @protected
   */
  _initializeDocument(data, options) {
    let doc = this.get(data._id);

    // Re-initialize an existing document
    if ( doc ) {
      doc._initialize(options);
      return doc;
    }

    // Create a new document
    if ( !data._id ) data._id = randomID(16); // TODO should this throw an error?
    try {
      doc = this.createDocument(data, options);
      super.set(doc.id, doc);
    } catch(err) {
      this._handleInvalidDocument(data._id, err, options);
      return null;
    }
    return doc;
  }

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

  /**
   * Instantiate a Document for inclusion in the Collection.
   * @param {object} data       The Document data.
   * @param {DocumentConstructionContext} [context]  Document creation context.
   * @returns {TDocument}
   */
  createDocument(data, context={}) {
    return new this.documentClass(data, {
      ...context,
      parent: this.model,
      parentCollection: this.name,
      pack: this.model.pack
    });
  }

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

  /**
   * Log warnings or errors when a Document is found to be invalid.
   * @param {string} id                      The invalid Document's ID.
   * @param {Error} err                      The validation error.
   * @param {object} [options]               Options to configure invalid Document handling.
   * @param {boolean} [options.strict=true]  Whether to throw an error or only log a warning.
   * @protected
   */
  _handleInvalidDocument(id, err, {strict=true}={}) {
    const documentName = this.documentClass.documentName;
    const parent = this.model;
    this.invalidDocumentIds.add(id);

    // Wrap the error with more information
    const uuid = foundry.utils.buildUuid({id, documentName, parent});
    const msg = `Failed to initialize ${documentName} [${uuid}]:\n${err.message}`;
    const error = new Error(msg, {cause: err});

    if ( strict ) globalThis.logger.error(error);
    else globalThis.logger.warn(error);
    if ( strict ) {
      globalThis.Hooks?.onError(`${this.constructor.name}#_initializeDocument`, error, {id, documentName});
    }
  }

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

  /**
   * Get a document from the EmbeddedCollection by its ID.
   * @param {string} id                         The ID of the Embedded Document to retrieve.
   * @param {object} [options]                  Additional options to configure retrieval.
   * @param {boolean} [options.strict=false]    Throw an Error if the requested Embedded Document does not exist.
   * @param {boolean} [options.invalid=false]   Allow retrieving an invalid Embedded Document.
   * @returns {TDocument}                       The retrieved document instance, or undefined
   * @throws {Error}                            If strict is true and the Embedded 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;
  }

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

  /**
   * Add a document to the collection.
   * @param {string} key                           The embedded Document ID.
   * @param {TDocument} value                      The embedded Document instance.
   * @param {object} [options]                     Additional options to the set operation.
   * @param {boolean} [options.modifySource=true]  Whether to modify the collection's source as part of the operation.
   * */
  set(key, value, {modifySource=true, ...options}={}) {
    if ( modifySource ) this._set(key, value, options);
    if ( super.get(key) !== value ) this.#documentsByType = null;
    return super.set(key, value);
  }

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

  /**
   * Modify the underlying source array to include the Document.
   * @param {string} key      The Document ID key.
   * @param {Document} value  The Document.
   * @protected
   */
  _set(key, value) {
    if ( this.has(key) || this.invalidDocumentIds.has(key) ) this._source.findSplice(d => d._id === key, value._source);
    else this._source.push(value._source);
  }

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

  /**
   * Remove a document from the collection.
   * @param {string} key                           The embedded Document ID.
   * @param {object} [options]                     Additional options to the delete operation.
   * @param {boolean} [options.modifySource=true]  Whether to modify the collection's source as part of the operation.
   * */
  delete(key, {modifySource=true, ...options}={}) {
    if ( modifySource ) this._delete(key, options);
    const result = super.delete(key);
    if ( result ) this.#documentsByType = null;
    return result;
  }

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

  /**
   * Remove the value from the underlying source array.
   * @param {string} key        The Document ID key.
   * @param {object} [options]  Additional options to configure deletion behavior.
   * @protected
   */
  _delete(key, options={}) {
    if ( this.has(key) || this.invalidDocumentIds.has(key) ) this._source.findSplice(d => d._id === key);
  }

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

  /**
   * 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 {TDocument|void}               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), {parent: this.model});
  }

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

  /**
   * Convert the EmbeddedCollection to an array of simple objects.
   * @param {boolean} [source=true]     Draw data for contained Documents from the underlying data source?
   * @returns {object[]}                The extracted array of primitive objects
   */
  toObject(source=true) {
    const arr = [];
    for ( const doc of this.values() ) {
      arr.push(doc.toObject(source));
    }
    return arr;
  }

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

  /**
   * Follow-up actions to take when a database operation modifies Documents in this EmbeddedCollection.
   * @param {DatabaseAction} action         The database action performed
   * @param {TDocument[]} documents         The array of modified Documents
   * @param {any[]} result                  The result of the database operation
   *
   * @param {DatabaseOperation} operation   Database operation details
   * @param {BaseUser} user                 The User who performed the operation
   * @internal
   */
  _onModifyContents(action, documents, result, operation, user) {
    // Propagate upwards to the parent collection
    const parentResult = action === "delete" ? [this.toObject()] : result;
    this.model?.collection?._onModifyContents?.("update", [this.model], [{[this.name]: parentResult}], operation, user);
  }
}

/**
 * This class provides a {@link foundry.utils.Collection} wrapper around a singleton embedded Document
 * so that it can be interacted with via a common interface.
 */
class SingletonEmbeddedCollection extends EmbeddedCollection {
  /** @inheritdoc */
  set(key, value) {
    if ( this.size && !this.has(key) ) {
      const embeddedName = this.documentClass.documentName;
      const parentName = this.model.documentName;
      throw new Error(`Cannot create singleton embedded ${embeddedName} [${key}] in parent ${parentName} `
        + `[${this.model.id}] as it already has one assigned.`);
    }
    return super.set(key, value);
  }

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

  /** @override */
  _set(key, value) {
    this.model._source[this.name] = value?._source ?? null;
  }

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

  /** @override */
  _delete(key) {
    this.model._source[this.name] = null;
  }
}

/**
 * @import { DataModelUpdateOptions } from "./_types.mjs";
 */

/**
 * An embedded collection delta contains delta source objects that can be compared against other objects inside a base
 * embedded collection, and generate new embedded Documents by combining them.
 * @template {Document} TDocument
 * @extends EmbeddedCollection<string, TDocument>
 */
class EmbeddedCollectionDelta extends EmbeddedCollection {
  /**
   * Maintain a list of IDs that are managed by this collection delta to distinguish from those IDs that are inherited
   * from the base collection.
   * @type {Set<string>}
   */
  #managedIds = new Set();

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

  /**
   * Maintain a list of IDs that are tombstone Documents.
   * @type {Set<string>}
   */
  #tombstones = new Set();

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

  /**
   * A convenience getter to return the corresponding base collection.
   * @type {EmbeddedCollection}
   */
  get baseCollection() {
    return this.model.getBaseCollection?.(this.name);
  }

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

  /**
   * A convenience getter to return the corresponding synthetic collection.
   * @type {EmbeddedCollection}
   */
  get syntheticCollection() {
    return this.model.syntheticActor?.getEmbeddedCollection(this.name);
  }

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

  /**
   * Determine whether a given ID is managed directly by this collection delta or inherited from the base collection.
   * @param {string} key  The Document ID.
   * @returns {boolean}
   */
  manages(key) {
    return this.#managedIds.has(key);
  }

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

  /**
   * Determine whether a given ID exists as a tombstone Document in the collection delta.
   * @param {string} key  The Document ID.
   * @returns {boolean}
   */
  isTombstone(key) {
    return this.#tombstones.has(key);
  }

  /* -------------------------------------------- */
  /*  Collection Initialization                   */
  /* -------------------------------------------- */

  /** @override */
  initialize({full=false, ...options} = {}) {
    const sc = this.syntheticCollection;
    this._initialized = false;
    this.#tombstones.clear();
    this.#managedIds.clear();
    if ( full ) this.clear();

    // Register tombstones and add custom managed records
    const initializedIds = new Set();
    for ( const obj of this._source ) {
      if ( obj._tombstone ) {
        this.#tombstones.add(obj._id);
        this.#managedIds.add(obj._id);
        continue;
      }
      let doc = sc?.get(obj._id);
      if ( doc ) super.set(doc.id, doc, {modifySource: false});
      else doc = this._initializeDocument(obj, options);
      if ( doc ) {
        initializedIds.add(doc.id);
        this.#managedIds.add(doc.id);
      }
    }

    // Add non-tombstone documents from the base collection
    if ( this.baseCollection?.size ) {
      for ( const baseDoc of this.baseCollection ) {
        if ( this.#managedIds.has(baseDoc.id) ) continue; // Skip managed Documents.
        let doc = sc?.get(baseDoc.id);
        if ( doc ) super.set(doc.id, doc, {modifySource: false});
        else doc = this._initializeDocument(baseDoc, options);
        if ( doc ) initializedIds.add(doc.id);
      }
    }

    // Remove any documents that no longer exist in source
    if ( this.size !== initializedIds.size ) {
      for ( const k of this.keys() ) {
        if ( !initializedIds.has(k) ) super.delete(k, {modifySource: false});
      }
    }

    this._initialized = true;
  }

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

  /** @override */
  createDocument(data, context={}) {
    return new this.documentClass(data, {
      ...context,
      parent: this.model.syntheticActor ?? this.model,
      parentCollection: this.name,
      pack: this.model.pack
    });
  }

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

  /**
   * Restore a Document so that it is no longer managed by the collection delta and instead inherits from the base
   * Document.
   * @param {string} id            The Document ID.
   * @returns {Promise<TDocument>} The restored Document.
   */
  async restoreDocument(id) {
    const docs = await this.restoreDocuments([id]);
    return docs.shift();
  }

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

  /**
   * Restore the given Documents so that they are no longer managed by the collection delta and instead inherit directly
   * from their counterparts in the base Actor.
   * @param {string[]} ids           The IDs of the Documents to restore.
   * @returns {Promise<TDocument[]>} An array of updated Document instances.
   */
  async restoreDocuments(ids) {
    if ( !this.model.syntheticActor ) return [];
    const baseActor = this.model.parent.baseActor;
    const embeddedName = this.documentClass.documentName;
    const {deltas, tombstones} = ids.reduce((obj, id) => {
      if ( !this.manages(id) ) return obj;
      const doc = baseActor.getEmbeddedCollection(this.name).get(id);
      if ( this.isTombstone(id) ) obj.tombstones.push(doc.toObject());
      else obj.deltas.push(doc.toObject());
      return obj;
    }, {deltas: [], tombstones: []});

    // For the benefit of downstream CRUD workflows, we emulate events from the perspective of the synthetic Actor.
    // Restoring an Item to the version on the base Actor is equivalent to updating that Item on the synthetic Actor
    // with the version of the Item on the base Actor.
    // Restoring an Item that has been deleted on the synthetic Actor is equivalent to creating a new Item on the
    // synthetic Actor with the contents of the version on the base Actor.
    // On the ActorDelta, those Items are removed from this collection delta so that they are once again 'linked' to the
    // base Actor's Item, as though they had never been modified from the original in the first place.

    let updated = [];
    if ( deltas.length ) {
      updated = await this.model.syntheticActor.updateEmbeddedDocuments(embeddedName, deltas, {
        diff: false, recursive: false, restoreDelta: true
      });
    }

    let created = [];
    if ( tombstones.length ) {
      created = await this.model.syntheticActor.createEmbeddedDocuments(embeddedName, tombstones, {
        keepId: true, restoreDelta: true
      });
    }

    return updated.concat(created);
  }

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

  /**
   * Prepare changes to this delta collection.
   * @param {object[]} changes                Candidate source changes.
   * @param {DataModelUpdateOptions} options  Options which determine how the new data is merged.
   * @internal
   */
  _prepareDeltaUpdate(changes, options) {
    for ( const change of changes ) {
      // If this entry is already managed, the update can be applied as normal.
      if ( !change._id || this.manages(change._id) ) continue;

      // Otherwise we must first adopt entry from the base collection.
      const existing = this.baseCollection.get(change._id)?._source;
      if ( existing ) foundry.utils.mergeObject(change, existing, {
        performDeletions: true,
        overwrite: false
      });
    }
  }

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

  /** @inheritdoc */
  set(key, value, options={}) {
    super.set(key, value, options);
    this.syntheticCollection?.set(key, value, options);
  }

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

  /** @override */
  _set(key, value, {restoreDelta=false}={}) {
    if ( restoreDelta ) return this._delete(key, {restoreDelta});
    if ( this.manages(key) ) this._source.findSplice(d => d._id === key, value._source);
    else this._source.push(value._source);
    this.#managedIds.add(key);
  }

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

  /** @inheritdoc */
  delete(key, options={}) {
    super.delete(key, options);
    this.syntheticCollection?.delete(key, options);
  }

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

  /** @override */
  _delete(key, {restoreDelta=false}={}) {
    if ( !this.baseCollection ) return;

    // Remove the document from this collection, if it exists.
    if ( this.manages(key) ) {
      this._source.findSplice(entry => entry._id === key);
      this.#managedIds.delete(key);
      this.#tombstones.delete(key);
    }

    // If the document exists in the base collection, push a tombstone in its place.
    if ( !restoreDelta && this.baseCollection.has(key) ) {
      this._source.push({_id: key, _tombstone: true});
      this.#managedIds.add(key);
      this.#tombstones.add(key);
    }
  }
}

/**
 * @import {LineCircleIntersection, LineIntersection} from "./_types.mjs";
 */

/**
 * Determine the relative orientation of three points in two-dimensional space.
 * The result is also an approximation of twice the signed area of the triangle defined by the three points.
 * This method is fast - but not robust against issues of floating point precision. Best used with integer coordinates.
 * Adapted from https://github.com/mourner/robust-predicates.
 * @param {Point} a     An endpoint of segment AB, relative to which point C is tested
 * @param {Point} b     An endpoint of segment AB, relative to which point C is tested
 * @param {Point} c     A point that is tested relative to segment AB
 * @returns {number}    The relative orientation of points A, B, and C
 *                      A positive value if the points are in counter-clockwise order (C lies to the left of AB)
 *                      A negative value if the points are in clockwise order (C lies to the right of AB)
 *                      Zero if the points A, B, and C are collinear.
 */
function orient2dFast(a, b, c) {
  return (a.y - c.y) * (b.x - c.x) - (a.x - c.x) * (b.y - c.y);
}

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

/**
 * Quickly test whether the line segment AB intersects with the line segment CD.
 * This method does not determine the point of intersection, for that use lineLineIntersection.
 * @param {Point} a                   The first endpoint of segment AB
 * @param {Point} b                   The second endpoint of segment AB
 * @param {Point} c                   The first endpoint of segment CD
 * @param {Point} d                   The second endpoint of segment CD
 * @returns {boolean}                 Do the line segments intersect?
 */
function lineSegmentIntersects(a, b, c, d) {

  // First test the orientation of A and B with respect to CD to reject collinear cases
  const xa = foundry.utils.orient2dFast(a, b, c);
  const xb = foundry.utils.orient2dFast(a, b, d);
  if ( !xa && !xb ) return false;
  const xab = (xa * xb) <= 0;

  // Also require an intersection of CD with respect to AB
  const xcd = (foundry.utils.orient2dFast(c, d, a) * foundry.utils.orient2dFast(c, d, b)) <= 0;
  return xab && xcd;
}

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

/**
 * An internal helper method for computing the intersection between two infinite-length lines.
 * Adapted from http://paulbourke.net/geometry/pointlineplane/.
 * @param {Point} a                   The first endpoint of segment AB
 * @param {Point} b                   The second endpoint of segment AB
 * @param {Point} c                   The first endpoint of segment CD
 * @param {Point} d                   The second endpoint of segment CD
 * @param {object} [options]          Options which affect the intersection test
 * @param {boolean} [options.t1=false]    Return the optional vector distance from C to D on CD
 * @returns {LineIntersection|null}   An intersection point, or null if no intersection occurred
 */
function lineLineIntersection(a, b, c, d, {t1=false}={}) {

  // If either line is length 0, they cannot intersect
  if (((a.x === b.x) && (a.y === b.y)) || ((c.x === d.x) && (c.y === d.y))) return null;

  // Check denominator - avoid parallel lines where d = 0
  const dnm = ((d.y - c.y) * (b.x - a.x) - (d.x - c.x) * (b.y - a.y));
  if (dnm === 0) return null;

  // Vector distances
  const t0 = ((d.x - c.x) * (a.y - c.y) - (d.y - c.y) * (a.x - c.x)) / dnm;
  t1 = t1 ? ((b.x - a.x) * (a.y - c.y) - (b.y - a.y) * (a.x - c.x)) / dnm : undefined;

  // Return the point of intersection
  return {
    x: a.x + t0 * (b.x - a.x),
    y: a.y + t0 * (b.y - a.y),
    t0: t0,
    t1: t1
  }
}

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

/**
 * An internal helper method for computing the intersection between two finite line segments.
 * Adapted from http://paulbourke.net/geometry/pointlineplane/
 * @param {Point} a                   The first endpoint of segment AB
 * @param {Point} b                   The second endpoint of segment AB
 * @param {Point} c                   The first endpoint of segment CD
 * @param {Point} d                   The second endpoint of segment CD
 * @param {number} [epsilon]          A small epsilon which defines a tolerance for near-equality
 * @returns {LineIntersection|null}   An intersection point, or null if no intersection occurred
 */
function lineSegmentIntersection(a, b, c, d, epsilon=1e-8) {

  // If either line is length 0, they cannot intersect
  if (((a.x === b.x) && (a.y === b.y)) || ((c.x === d.x) && (c.y === d.y))) return null;

  // Check denominator - avoid parallel lines where d = 0
  const dnm = ((d.y - c.y) * (b.x - a.x) - (d.x - c.x) * (b.y - a.y));
  if (dnm === 0) return null;

  // Vector distance from a
  const t0 = ((d.x - c.x) * (a.y - c.y) - (d.y - c.y) * (a.x - c.x)) / dnm;
  if ( !Number.between(t0, 0-epsilon, 1+epsilon) ) return null;

  // Vector distance from c
  const t1 = ((b.x - a.x) * (a.y - c.y) - (b.y - a.y) * (a.x - c.x)) / dnm;
  if ( !Number.between(t1, 0-epsilon, 1+epsilon) ) return null;

  // Return the point of intersection and the vector distance from both line origins
  return {
    x: a.x + t0 * (b.x - a.x),
    y: a.y + t0 * (b.y - a.y),
    t0: Math.clamp(t0, 0, 1),
    t1: Math.clamp(t1, 0, 1)
  }
}

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

/**
 * Determine the intersection between a line segment and a circle.
 * @param {Point} a                   The first vertex of the segment
 * @param {Point} b                   The second vertex of the segment
 * @param {Point} center              The center of the circle
 * @param {number} radius             The radius of the circle
 * @param {number} epsilon            A small tolerance for floating point precision
 * @returns {LineCircleIntersection}  The intersection of the segment AB with the circle
 */
function lineCircleIntersection(a, b, center, radius, epsilon=1e-8) {
  const r2 = Math.pow(radius, 2);
  let intersections = [];

  // Test whether endpoint A is contained
  const ar2 = Math.pow(a.x - center.x, 2) + Math.pow(a.y - center.y, 2);
  const aInside = ar2 < r2 - epsilon;

  // Test whether endpoint B is contained
  const br2 = Math.pow(b.x - center.x, 2) + Math.pow(b.y - center.y, 2);
  const bInside = br2 < r2 - epsilon;

  // Find quadratic intersection points
  const contained = aInside && bInside;
  if ( !contained ) intersections = quadraticIntersection(a, b, center, radius, epsilon);

  // Return the intersection data
  return {
    aInside,
    bInside,
    contained,
    outside: !contained && !intersections.length,
    tangent: !aInside && !bInside && intersections.length === 1,
    intersections
  };
}

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

/**
 * Identify the point closest to C on segment AB
 * @param {Point} c     The reference point C
 * @param {Point} a     Point A on segment AB
 * @param {Point} b     Point B on segment AB
 * @returns {Point}     The closest point to C on segment AB
 */
function closestPointToSegment(c, a, b) {
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  if (( dx === 0 ) && ( dy === 0 )) {
    throw new Error("Zero-length segment AB not supported");
  }
  const u = (((c.x - a.x) * dx) + ((c.y - a.y) * dy)) / (dx * dx + dy * dy);
  if ( u < 0 ) return a;
  if ( u > 1 ) return b;
  else return {
    x: a.x + (u * dx),
    y: a.y + (u * dy)
  }
}

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

/**
 * Determine the points of intersection between a line segment (p0,p1) and a circle.
 * There will be zero, one, or two intersections
 * See https://math.stackexchange.com/a/311956.
 * @param {Point} p0            The initial point of the line segment
 * @param {Point} p1            The terminal point of the line segment
 * @param {Point} center        The center of the circle
 * @param {number} radius       The radius of the circle
 * @param {number} [epsilon=0]  A small tolerance for floating point precision
 */
function quadraticIntersection(p0, p1, center, radius, epsilon=0) {
  const dx = p1.x - p0.x;
  const dy = p1.y - p0.y;

  // Quadratic terms where at^2 + bt + c = 0
  const a = Math.pow(dx, 2) + Math.pow(dy, 2);
  const b = (2 * dx * (p0.x - center.x)) + (2 * dy * (p0.y - center.y));
  const c = Math.pow(p0.x - center.x, 2) + Math.pow(p0.y - center.y, 2) - Math.pow(radius, 2);

  // Discriminant
  let disc2 = Math.pow(b, 2) - (4 * a * c);
  if ( disc2.almostEqual(0) ) disc2 = 0; // segment endpoint touches the circle; 1 intersection
  else if ( disc2 < 0 ) return []; // no intersections

  // Roots
  const disc = Math.sqrt(disc2);
  const t1 = (-b - disc) / (2 * a);

  // If t1 hits (between 0 and 1) it indicates an "entry"
  const intersections = [];
  if ( t1.between(0-epsilon, 1+epsilon) ) {
    intersections.push({
      x: p0.x + (dx * t1),
      y: p0.y + (dy * t1)
    });
  }
  if ( !disc2 ) return intersections; // 1 intersection

  // If t2 hits (between 0 and 1) it indicates an "exit"
  const t2 = (-b + disc) / (2 * a);
  if ( t2.between(0-epsilon, 1+epsilon) ) {
    intersections.push({
      x: p0.x + (dx * t2),
      y: p0.y + (dy * t2)
    });
  }
  return intersections;
}

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

/**
 * Calculate the centroid non-self-intersecting closed polygon.
 * See https://en.wikipedia.org/wiki/Centroid#Of_a_polygon.
 * @param {Point[]|number[]} points    The points of the polygon
 * @returns {Point}                    The centroid of the polygon
 */
function polygonCentroid(points) {
  const n = points.length;
  if ( n === 0 ) return {x: 0, y: 0};
  let x = 0;
  let y = 0;
  let a = 0;
  if ( typeof points[0] === "number" ) {
    let x0 = points[n - 2];
    let y0 = points[n - 1];
    for ( let i = 0; i < n; i += 2 ) {
      const x1 = points[i];
      const y1 = points[i + 1];
      const z = (x0 * y1) - (x1 * y0);
      x += (x0 + x1) * z;
      y += (y0 + y1) * z;
      x0 = x1;
      y0 = y1;
      a += z;
    }
  } else {
    let {x: x0, y: y0} = points[n - 1];
    for ( let i = 0; i < n; i++ ) {
      const {x: x1, y: y1} = points[i];
      const z = (x0 * y1) - (x1 * y0);
      x += (x0 + x1) * z;
      y += (y0 + y1) * z;
      x0 = x1;
      y0 = y1;
      a += z;
    }
  }
  a *= 3;
  x /= a;
  y /= a;
  return {x, y};
}

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

/**
 * Test whether the circle given by the center and radius intersects the path (open or closed).
 * @param {Point[]|number[]} points    The points of the path
 * @param {boolean} close              If true, the edge from the last to the first point is tested
 * @param {Point} center               The center of the circle
 * @param {number} radius              The radius of the circle
 * @returns {boolean}                  Does the circle intersect the path?
 */
function pathCircleIntersects(points, close, center, radius) {
  const n = points.length;
  if ( n === 0 ) return false;
  const {x: cx, y: cy} = center;
  const rr = radius * radius;
  let i;
  let x0;
  let y0;
  if ( typeof points[0] === "number" ) {
    if ( close ) {
      i = 0;
      x0 = points[n - 2];
      y0 = points[n - 1];
    } else {
      i = 2;
      x0 = points[0];
      y0 = points[1];
    }
    for ( ; i < n; i += 2 ) {
      const x1 = points[i];
      const y1 = points[i + 1];
      let dx = cx - x0;
      let dy = cy - y0;
      const nx = x1 - x0;
      const ny = y1 - y0;
      const t = Math.clamp(((dx * nx) + (dy * ny)) / ((nx * nx) + (ny * ny)), 0, 1);
      dx = (t * nx) - dx;
      dy = (t * ny) - dy;
      if ( (dx * dx) + (dy * dy) <= rr ) return true;
      x0 = x1;
      y0 = y1;
    }
  } else {
    if ( close ) {
      i = 0;
      ({x: x0, y: y0} = points[n - 1]);
    } else {
      i = 1;
      ({x: x0, y: y0} = points[0]);
    }
    for ( ; i < n; i++ ) {
      const {x: x1, y: y1} = points[i];
      let dx = cx - x0;
      let dy = cy - y0;
      const nx = x1 - x0;
      const ny = y1 - y0;
      const t = Math.clamp(((dx * nx) + (dy * ny)) / ((nx * nx) + (ny * ny)), 0, 1);
      dx = (t * nx) - dx;
      dy = (t * ny) - dy;
      if ( (dx * dx) + (dy * dy) <= rr ) return true;
      x0 = x1;
      y0 = y1;
    }
  }
  return false;
}

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

/**
 * Test whether two circles (with position and radius) intersect.
 * @param {number} x0    x center coordinate of circle A.
 * @param {number} y0    y center coordinate of circle A.
 * @param {number} r0    radius of circle A.
 * @param {number} x1    x center coordinate of circle B.
 * @param {number} y1    y center coordinate of circle B.
 * @param {number} r1    radius of circle B.
 * @returns {boolean}    True if the two circles intersect, false otherwise.
 */
function circleCircleIntersects(x0, y0, r0, x1, y1, r1) {
  return Math.hypot(x0 - x1, y0 - y1) <= (r0 + r1);
}

/**
 * A wrapper method around `fetch` that attaches an AbortController signal to the `fetch` call for clean timeouts
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal#aborting_a_fetch_with_timeout_or_explicit_abort}
 * @param {string} url            The URL to make the Request to
 * @param {RequestInit} data      The data of the Request
 * @param {object} [options]                 Additional options
 * @param {number|null} [options.timeoutMs]  How long to wait for a Response before cleanly aborting.
 *                                           If null, no timeout is applied. Default: `30000`.
 * @param {Function} [options.onTimeout]     A method to invoke if and when the timeout is reached
 * @returns {Promise<Response>}
 * @throws {HttpError}
 */
async function fetchWithTimeout(url, data={}, {timeoutMs=30000, onTimeout=() => {}}={}) {
  const controller = new AbortController();
  data.signal = controller.signal;
  let timedOut = false;
  const enforceTimeout = timeoutMs !== null;

  // Enforce a timeout
  let timeout;
  if ( enforceTimeout ) {
    timeout = setTimeout(() => {
      timedOut = true;
      controller.abort();
      onTimeout();
    }, timeoutMs);
  }

  // Attempt the request
  let response;
  try {
    response = await fetch(url, data);
  } catch(err) {
    if ( timedOut ) {
      const timeoutS = Math.round(timeoutMs / 1000);
      const msg = game.i18n
        ? game.i18n.format("SETUP.ErrorTimeout", { url, timeout: timeoutS })
        : `The request to ${url} timed out after ${timeoutS}s.`;
      throw new HttpError("Timed Out", 408, msg);
    }
    throw err;
  } finally {
    if ( enforceTimeout ) clearTimeout(timeout);
  }

  // Return the response
  if ( !response.ok && (response.type !== "opaqueredirect") ) {
    const responseBody = response.body ? await response.text() : "";
    throw new HttpError(response.statusText, response.status, responseBody);
  }
  return response;
}

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

/**
 * A small wrapper that automatically asks for JSON with a Timeout
 * @param {string} url          The URL to make the Request to
 * @param {Object} data         The data of the Request
 * @param {object} [options]                 Additional options
 * @param {number|null} [options.timeoutMs]  How long to wait for a Response before cleanly aborting.
 *                                           If null, no timeout is applied. Default: `30000`.
 * @param {Function} [options.onTimeout]     A method to invoke if and when the timeout is reached
 * @returns {Promise<*>}
 */
async function fetchJsonWithTimeout(url, data = {}, {timeoutMs=30000, onTimeout = () => {}} = {}) {
  const response = await fetchWithTimeout(url, data, {timeoutMs, onTimeout: onTimeout});
  return response.json();
}

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

/**
 * 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$1(src) {
  return foundry.utils.fetchWithTimeout(src, { method: "HEAD" }).then(resp => {
    return resp.status < 400;
  }).catch(() => false);
}

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

/**
 * Represents an HTTP Error when a non-OK response is returned by Fetch
 * @extends {Error}
 */
class HttpError extends Error {
  constructor(statusText, code, displayMessage="") {
    super(statusText);
    this.code = code;
    this.displayMessage = displayMessage;
  }

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

  /** @override */
  toString() {
    return this.displayMessage;
  }
}

/**
 * The messages that have been logged already and should not be logged again.
 * @type {Set<string>}
 */
const loggedCompatibilityWarnings = new Set();

/**
 * Log a compatibility warning which is filtered based on the client's defined compatibility settings.
 * @param {string} message            The original warning or error message
 * @param {object} [options={}]       Additional options which customize logging
 * @param {number} [options.mode]          A logging level in COMPATIBILITY_MODES which overrides the configured default
 * @param {number|string} [options.since]  A version identifier since which a change was made
 * @param {number|string} [options.until]  A version identifier until which a change remains supported
 * @param {string} [options.details]       Additional details to append to the logged message
 * @param {boolean} [options.stack=true]   Include the message stack trace
 * @param {boolean} [options.once=false]   Log this the message only once?
 * @throws                            An Error if the mode is ERROR
 */
function logCompatibilityWarning(message, {mode, since, until, details, stack=true, once=false}={}) {

  // Determine the logging mode
  const modes = COMPATIBILITY_MODES;
  const compatibility = globalThis.CONFIG?.compatibility || {
    mode: modes.WARNING,
    includePatterns: [],
    excludePatterns: []
  };
  mode ??= compatibility.mode;
  if ( mode === modes.SILENT ) return;

  // Compose the message
  since = since ? `Deprecated since Version ${since}` : null;
  until = until ? `Backwards-compatible support will be removed in Version ${until}`: null;
  message = [message, since, until, details].filterJoin("\n");

  // Filter the message by its stack trace
  const error = new Error(message);
  if ( compatibility.includePatterns.length ) {
    if ( !compatibility.includePatterns.some(rgx => rgx.test(error.message) || rgx.test(error.stack)) ) return;
  }
  if ( compatibility.excludePatterns.length ) {
    if ( compatibility.excludePatterns.some(rgx => rgx.test(error.message) || rgx.test(error.stack)) ) return;
  }

  // Log the message
  const log = !(once && loggedCompatibilityWarnings.has(error.stack));
  switch ( mode ) {
    case modes.WARNING:
      if ( log ) globalThis.logger.warn(stack ? error : error.message);
      break;
    case modes.ERROR:
      if ( log ) globalThis.logger.error(stack ? error : error.message);
      break;
    case modes.FAILURE:
      throw error;
  }
  if ( log && once ) loggedCompatibilityWarnings.add(error.stack);
}

/**
 * @import ApplicationV2 from "../../client/applications/api/application.mjs"
 * @import {Constructor} from "../_types.mjs"
 * @import {EmittedEventListener} from "./_types.mjs"
 */

/**
 * Augment a base class with EventEmitter behavior.
 * @template {Function} TBaseClass
 * @param {TBaseClass} [BaseClass] Some base class to be augmented with event emitter functionality: defaults to an
 *                                 anonymous empty class.
 */
function EventEmitterMixin(BaseClass=class {}) {
  /**
   * A mixin class which implements the behavior of EventTarget.
   * This is useful in cases where a class wants EventTarget-like behavior but needs to extend some other class.
   * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget}
   */
  class EventEmitter extends BaseClass {

    /**
     * An array of event types which are valid for this class.
     * @type {string[]}
     */
    static emittedEvents = [];

    /**
     * A mapping of registered events.
     * @type {Record<string, Map<EmittedEventListener, {fn: EmittedEventListener, once: boolean}>>}
     */
    #events = {};

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

    /**
     * Add a new event listener for a certain type of event.
     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener}
     * @param {string} type                     The type of event being registered for
     * @param {EmittedEventListener} listener   The listener function called when the event occurs
     * @param {object} [options={}]             Options which configure the event listener
     * @param {boolean} [options.once=false]      Should the event only be responded to once and then removed
     */
    addEventListener(type, listener, {once = false} = {}) {
      if ( !this.constructor.emittedEvents.includes(type) ) {
        throw new Error(`"${type}" is not a supported event of the ${this.constructor.name} class`);
      }
      this.#events[type] ||= new Map();
      this.#events[type].set(listener, {fn: listener, once});
    }

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

    /**
     * Remove an event listener for a certain type of event.
     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener}
     * @param {string} type                     The type of event being removed
     * @param {EmittedEventListener} listener   The listener function being removed
     */
    removeEventListener(type, listener) {
      this.#events[type]?.delete(listener);
    }

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

    /**
     * Dispatch an event on this target.
     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent}
     * @param {Event} event                     The Event to dispatch
     * @returns {boolean}                       Was default behavior for the event prevented?
     */
    dispatchEvent(event) {
      if ( !(event instanceof Event) ) {
        throw new Error("EventEmitter#dispatchEvent must be provided an Event instance");
      }
      if ( !this.constructor.emittedEvents.includes(event?.type) ) {
        throw new Error(`"${event.type}" is not a supported event of the ${this.constructor.name} class`);
      }
      const listeners = this.#events[event.type];
      if ( !listeners ) return true;

      // Extend and configure the Event
      Object.defineProperties(event, {
        target: {value: this},
        stopPropagation: {value: function() {
          event.propagationStopped = true;
          Event.prototype.stopPropagation.call(this);
        }},
        stopImmediatePropagation: {value: function() {
          event.propagationStopped = true;
          Event.prototype.stopImmediatePropagation.call(this);
        }}
      });

      // Call registered listeners
      for ( const listener of listeners.values() ) {
        listener.fn(event);
        if ( listener.once ) this.removeEventListener(event.type, listener.fn);
        if ( event.propagationStopped ) break;
      }
      return event.defaultPrevented;
    }
  }
  return EventEmitter;
}

/**
 * @import {IterableWeakMapHeldValue, IterableWeakMapValue} from "./_types.mjs";
 */

/**
 * Stores a map of objects with weak references to the keys, allowing them to be garbage collected. Both keys and values
 * can be iterated over, unlike a WeakMap.
 */
class IterableWeakMap extends WeakMap {

  /**
   * A set of weak refs to the map's keys, allowing enumeration.
   * @type {Set<WeakRef<any>>}
   */
  #refs = new Set();

  /**
   * A FinalizationRegistry instance to clean up the ref set when objects are garbage collected.
   * @type {FinalizationRegistry<IterableWeakMapHeldValue>}
   */
  #finalizer = new FinalizationRegistry(IterableWeakMap.#cleanup);

  /**
   * @param {Iterable<[any, any]>} [entries]  The initial entries.
   */
  constructor(entries=[]) {
    super();
    for ( const [key, value] of entries ) this.set(key, value);
  }

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

  /**
   * Clean up the corresponding ref in the set when its value is garbage collected.
   * @param {IterableWeakMapHeldValue} heldValue  The value held by the finalizer.
   */
  static #cleanup({ set, ref }) {
    set.delete(ref);
  }

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

  /**
   * Remove a key from the map.
   * @param {any} key  The key to remove.
   * @returns {boolean}
   */
  delete(key) {
    const entry = super.get(key);
    if ( !entry ) return false;
    super.delete(key);
    this.#refs.delete(entry.ref);
    this.#finalizer.unregister(key);
    return true;
  }

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

  /**
   * Retrieve a value from the map.
   * @param {any} key  The value's key.
   * @returns {any}
   */
  get(key) {
    /** @type {IterableWeakMapValue|undefined} */
    const entry = super.get(key);
    return entry && entry.value;
  }

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

  /**
   * Place a value in the map.
   * @param {any} key    The key.
   * @param {any} value  The value.
   * @returns {IterableWeakMap}
   */
  set(key, value) {
    const entry = super.get(key);
    if ( entry ) this.#refs.delete(entry.ref);
    const ref = new WeakRef(key);
    super.set(key, /** @type {IterableWeakMapValue} */ { value, ref });
    this.#refs.add(ref);
    this.#finalizer.register(key, { ref, set: this.#refs }, key);
    return this;
  }

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

  /**
   * Clear all values from the map.
   */
  clear() {
    for ( const ref of this.#refs ) {
      const key = ref.deref();
      if ( key ) this.delete(key);
      else this.#refs.delete(ref);
    }
  }

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

  /**
   * Enumerate the entries.
   * @returns {Generator<[any, any], void, any>}
   */
  *[Symbol.iterator]() {
    for ( const ref of this.#refs ) {
      const key = ref.deref();
      if ( !key ) continue;
      const { value } = super.get(key);
      yield [key, value];
    }
  }

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

  /**
   * Enumerate the entries.
   * @returns {Generator<[any, any], void, any>}
   */
  entries() {
    return this[Symbol.iterator]();
  }

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

  /**
   * Enumerate the keys.
   * @returns {Generator<any, void, any>}
   */
  *keys() {
    for ( const [key] of this ) yield key;
  }

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

  /**
   * Enumerate the values.
   * @returns {Generator<any, void, any>}
   */
  *values() {
    for ( const [, value] of this ) yield value;
  }
}

/**
 * Stores a set of objects with weak references to them, allowing them to be garbage collected. Can be iterated over,
 * unlike a WeakSet.
 */
class IterableWeakSet extends WeakSet {
  /**
   * The backing iterable weak map.
   * @type {IterableWeakMap<any, any>}
   */
  #map = new IterableWeakMap();

  /**
   * @param {Iterable<any>} [entries]  The initial entries.
   */
  constructor(entries=[]) {
    super();
    for ( const entry of entries ) this.add(entry);
  }

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

  /**
   * Enumerate the values.
   * @returns {Generator<any, void, any>}
   */
  [Symbol.iterator]() {
    return this.values();
  }

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

  /**
   * Add a value to the set.
   * @param {any} value  The value to add.
   * @returns {IterableWeakSet}
   */
  add(value) {
    this.#map.set(value, value);
    return this;
  }

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

  /**
   * Delete a value from the set.
   * @param {any} value  The value to delete.
   * @returns {boolean}
   */
  delete(value) {
    return this.#map.delete(value);
  }

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

  /**
   * Whether this set contains the given value.
   * @param {any} value  The value to test.
   * @returns {boolean}
   */
  has(value) {
    return this.#map.has(value);
  }

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

  /**
   * Enumerate the collection.
   * @returns {Generator<any, void, any>}
   */
  values() {
    return this.#map.values();
  }

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

  /**
   * Clear all values from the set.
   */
  clear() {
    this.#map.clear();
  }
}

/**
 * A simple Semaphore implementation which provides a limited queue for ensuring proper concurrency.
 * @param {number} [max=1]    The maximum number of tasks which are allowed concurrently.
 *
 * @example Using a Semaphore
 * ```js
 * // Some async function that takes time to execute
 * function fn(x) {
 *   return new Promise(resolve => {
 *     setTimeout(() => {
 *       console.log(x);
 *       resolve(x);
 *     }, 1000);
 *   });
 * }
 *
 * // Create a Semaphore and add many concurrent tasks
 * const semaphore = new Semaphore(1);
 * for ( let i of Array.fromRange(100) ) {
 *   semaphore.add(fn, i);
 * }
 * ```
 */
class Semaphore {
  constructor(max=1) {

    /**
     * The maximum number of tasks which can be simultaneously attempted.
     * @type {number}
     */
    this.max = max;
  }

  /**
   * A queue of pending function signatures
   * @type {Array<[fn: Function, args: any[], resolve: (result: any) => void, reject: (error: Error) => void]>}
   */
  #queue = [];

  /**
   * The number of tasks which are currently underway
   * @type {number}
   */
  #active = 0;

  /**
   * The number of pending tasks remaining in the queue
   * @type {number}
   */
  get remaining() {
    return this.#queue.length;
  }

  /**
   * The number of actively executing tasks
   * @type {number}
   */
  get active() {
    return this.#active;
  }

  /**
   * Add a new tasks to the managed queue
   * @param {Function} fn     A callable function
   * @param {...*} [args]     Function arguments
   * @returns {Promise}       A promise that resolves once the added function is executed
   */
  add(fn, ...args) {
    return new Promise((resolve, reject) => {
      this.#queue.push([fn, args, resolve, reject]);
      this.#try();
    });
  }

  /**
   * Abandon any tasks which have not yet concluded
   */
  clear() {
    this.#queue = [];
  }

  /**
   * Attempt to perform a task from the queue.
   * If all workers are busy, do nothing.
   * If successful, try again.
   */
  async #try() {
    if ( (this.active === this.max) || !this.remaining ) return false;

    // Obtain the next task from the queue
    const next = this.#queue.shift();
    if ( !next ) return;
    this.#active += 1;

    // Try and execute it, resolving its promise
    const [fn, args, resolve, reject] = next;
    try {
      const r = await fn(...args);
      resolve(r);
    }
    catch(err) {
      reject(err);
    }

    // Try the next function in the queue
    this.#active -= 1;
    return this.#try();
  }
}

/**
 * Create a new BitMask instance.
 * @param {Record<string, boolean>} [states=null] An object containing valid states and their corresponding initial boolean values (default is null).
 */
class BitMask extends Number {
  constructor(states=null) {
    super();
    this.#generateValidStates(states);
    this.#generateEnum();
    this.#value = this.#computeValue(states);
  }

  /**
   * The real value behind the bitmask instance.
   * @type {number}
   */
  #value;

  /**
   * The structure of valid states and their associated values.
   * @type {Map<string, number>}
   */
  #validStates;

  /**
   * The enum associated with this structure.
   * @type {Record<string, string>}
   * @readonly
   */
  states;

  /* -------------------------------------------- */
  /*  Internals                                   */
  /* -------------------------------------------- */

  /**
   * Generates the valid states and their associated values.
   * @param {Record<string, boolean>} [states=null] The structure defining the valid states and their associated values.
   */
  #generateValidStates(states) {
    this.#validStates = new Map();
    let bitIndex = 0;
    for ( const state of Object.keys(states || {}) ) {
      if ( bitIndex >= 32 ) throw new Error("A bitmask can't handle more than 32 states");
      this.#validStates.set(state, 1 << bitIndex++);
    }
  }

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

  /**
   * Generates an enum based on the provided valid states.
   */
  #generateEnum() {
    this.states = {};
    for ( const state of this.#validStates.keys() ) this.states[state] = state;
    Object.freeze(this.states);
  }

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

  /**
   * Calculate the default value of the bitmask based on the initial states
   * @param {Record<string, boolean>} [initialStates={}] The structure defining the valid states and their associated values.
   * @returns {number}
   */
  #computeValue(initialStates={}) {
    let defaultValue = 0;
    for ( const state in initialStates ) {
      if ( !initialStates.hasOwnProperty(state) ) continue;
      this.#checkState(state);
      if ( initialStates[state] ) defaultValue |= this.#validStates.get(state);
    }
    return defaultValue;
  }

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

  /**
   * Checks a state and throws an error if it doesn't exist.
   * @param {string} state   Name of the state to check.
   */
  #checkState(state) {
    if ( !this.#validStates.has(state) ) {
      throw new Error(`${state} is an invalid state for this BitMask instance: ${this.toJSON()}`);
    }
  }

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

  /**
   * True if this bitmask is empty (no active states).
   * @type {boolean}
   */
  get isEmpty() {
    return this.#value === 0;
  }

  /* -------------------------------------------- */
  /*  Methods for Handling states                 */
  /* -------------------------------------------- */

  /**
   * Check if a specific state is active.
   * @param {string} state The state to check.
   * @returns {boolean} True if the state is active, false otherwise.
   */
  hasState(state) {
    return (this.#value & this.#validStates.get(state)) !== 0;
  }

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

  /**
   * Add a state to the bitmask.
   * @param {string} state The state to add.
   * @throws {Error} Throws an error if the provided state is not valid.
   */
  addState(state) {
    this.#checkState(state);
    this.#value |= this.#validStates.get(state);
  }

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

  /**
   * Remove a state from the bitmask.
   * @param {string} state The state to remove.
   * @throws {Error} Throws an error if the provided state is not valid.
   */
  removeState(state) {
    this.#checkState(state);
    this.#value &= ~this.#validStates.get(state);
  }

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

  /**
   * Toggle the state of a specific state in the bitmask.
   * @param {string} state The state to toggle.
   * @param {boolean} [enabled] Toggle on (true) or off (false)? If undefined, the state is switched automatically.
   * @throws {Error} Throws an error if the provided state is not valid.
   */
  toggleState(state, enabled) {
    this.#checkState(state);
    if ( enabled === undefined ) return (this.#value ^= this.#validStates.get(state));
    if ( enabled ) this.addState(state);
    else this.removeState(state);
  }

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

  /**
   * Clear the bitmask, setting all states to inactive.
   */
  clear() {
    this.#value = 0;
  }

  /* -------------------------------------------- */
  /*  bitmask representations                     */
  /* -------------------------------------------- */

  /**
   * Get the current value of the bitmask.
   * @returns {number} The current value of the bitmask.
   */
  valueOf() {
    return this.#value;
  }

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

  /**
   * Get a string representation of the bitmask in binary format.
   * @returns {string} The string representation of the bitmask.
   */
  toString() {
    return String(this.#value.toString(2)).padStart(this.#validStates.size, '0');
  }

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

  /**
   * Checks if two bitmasks structures are compatible (the same valid states).
   * @param {BitMask} otherBitMask The bitmask structure to compare with.
   * @returns {boolean} True if the two bitmasks have the same structure, false otherwise.
   */
  isCompatible(otherBitMask) {
    const states1 = Array.from(this.#validStates.keys()).sort().join(',');
    const states2 = Array.from(otherBitMask.#validStates.keys()).sort().join(',');
    return states1 === states2;
  }

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

  /**
   * Serializes the bitmask to a JSON string.
   * @returns {string} The JSON string representing the bitmask.
   */
  toJSON() {
    return JSON.stringify(this.toObject());
  }

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

  /**
   * Creates a new BitMask instance from a JSON string.
   * @param {string} jsonString The JSON string representing the bitmask.
   * @returns {BitMask} A new BitMask instance created from the JSON string.
   */
  static fromJSON(jsonString) {
    const data = JSON.parse(jsonString);
    return new BitMask(data);
  }

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

  /**
   * Convert value of this BitMask to object representation according to structure.
   * @returns {Object} The data represented by the bitmask.
   */
  toObject() {
    const result = {};
    for ( const [validState, value] of this.#validStates ) result[validState] = ((this.#value & value) !== 0);
    return result;
  }

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

  /**
   * Creates a clone of this BitMask instance.
   * @returns {BitMask} A new BitMask instance with the same value and valid states as this instance.
   */
  clone() {
    return new BitMask(this.toObject());
  }

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

  /**
   * Generates shader constants based on the provided states.
   * @param {string[]} states An array containing valid states.
   * @returns {string} Shader bit mask constants generated from the states.
   */
  static generateShaderBitMaskConstants(states) {
    let shaderConstants = '';
    let bitIndex = 0;
    for ( const state of states ) {
      shaderConstants += `const uint ${state.toUpperCase()} = 0x${(1 << bitIndex).toString(16).toUpperCase()}U;\n`;
      bitIndex++;
    }
    return shaderConstants;
  }
}

/**
 * @import {StringTreeEntryFilter, StringTreeNode} from "./_types.mjs";
 */

/**
 * A data structure representing a tree of string nodes with arbitrary object leaves.
 */
class StringTree {
  /**
   * The key symbol that stores the leaves of any given node.
   * @type {symbol}
   */
  static get leaves() {
    return StringTree.#leaves;
  }

  static #leaves = Symbol();

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

  /**
   * The tree's root.
   * @type {StringTreeNode}
   */
  #root = this.#createNode();

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

  /**
   * Create a new node.
   * @returns {StringTreeNode}
   */
  #createNode() {
    return { [StringTree.leaves]: [] };
  }

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

  /**
   * Insert an entry into the tree.
   * @param {string[]} strings  The string parents for the entry.
   * @param {any} entry         The entry to store.
   * @returns {StringTreeNode}  The node the entry was added to.
   */
  addLeaf(strings, entry) {
    let node = this.#root;
    for ( const string of strings ) {
      node[string] ??= this.#createNode();
      node = node[string];
    }

    // Once we've traversed the tree, we add our entry.
    node[StringTree.leaves].push(entry);
    return node;
  }

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

  /**
   * Traverse the tree along the given string path and return any entries reachable from the node.
   * @param {string[]} strings                               The string path to the desired node.
   * @param {object} [options]
   * @param {number} [options.limit]                         The maximum number of items to retrieve.
   * @param {StringTreeEntryFilter} [options.filterEntries]  A filter function to apply to each candidate entry.
   * @returns {any[]}
   */
  lookup(strings, { limit, filterEntries }={}) {
    const entries = [];
    const node = this.nodeAtPrefix(strings);
    if ( !node ) return []; // No matching entries.
    const queue = [node];
    while ( queue.length ) {
      if ( limit && (entries.length >= limit) ) break;
      this._breadthFirstSearch(queue.shift(), entries, queue, { limit, filterEntries });
    }
    return entries;
  }

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

  /**
   * Returns the node at the given path through the tree.
   * @param {string[]} strings                    The string path to the desired node.
   * @param {object} [options]
   * @param {boolean} [options.hasLeaves=false]   Only return the most recently visited node that has leaves, otherwise
   *                                              return the exact node at the prefix, if it exists.
   * @returns {StringTreeNode|void}
   */
  nodeAtPrefix(strings, { hasLeaves=false }={}) {
    let node = this.#root;
    let withLeaves = node;
    for ( const string of strings ) {
      if ( !(string in node) ) return hasLeaves ? withLeaves : undefined;
      node = node[string];
      if ( node[StringTree.leaves].length ) withLeaves = node;
    }
    return hasLeaves ? withLeaves : node;
  }

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

  /**
   * Perform a breadth-first search starting from the given node and retrieving any entries reachable from that node,
   * until we reach the limit.
   * @param {StringTreeNode} node                            The starting node.
   * @param {any[]} entries                                  The accumulated entries.
   * @param {StringTreeNode[]} queue                         The working queue of nodes to search.
   * @param {object} [options]
   * @param {number} [options.limit]                         The maximum number of entries to retrieve before stopping.
   * @param {StringTreeEntryFilter} [options.filterEntries]  A filter function to apply to each candidate entry.
   * @protected
   */
  _breadthFirstSearch(node, entries, queue, { limit, filterEntries }={}) {
    // Retrieve the entries at this node.
    let leaves = node[StringTree.leaves];
    if ( filterEntries instanceof Function ) leaves = leaves.filter(filterEntries);
    entries.push(...leaves);
    if ( limit && (entries.length >= limit) ) return;
    // Push this node's children onto the end of the queue.
    for ( const key of Object.keys(node) ) {
      if ( typeof key === "string" ) queue.push(node[key]);
    }
  }
}

/**
 * @import {StringTreeEntryFilter, StringTreeNode, WordTreeEntry} from "./_types.mjs";
 */

/**
 * A data structure for quickly retrieving objects by a string prefix.
 * Note that this works well for languages with alphabets (latin, cyrillic, korean, etc.), but may need more nuanced
 * handling for languages that compose characters and letters.
 * @extends {StringTree}
 */
class WordTree extends StringTree {
  /**
   * Insert an entry into the tree.
   * @param {string} string        The string key for the entry.
   * @param {WordTreeEntry} entry  The entry to store.
   * @returns {StringTreeNode}     The node the entry was added to.
   */
  addLeaf(string, entry) {
    string = string.toLocaleLowerCase(game.i18n.lang);
    return super.addLeaf(Array.from(string), entry);
  }

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

  /**
   * Return entries that match the given string prefix.
   * @param {string} prefix              The prefix.
   * @param {object} [options]           Additional options to configure behaviour.
   * @param {number} [options.limit=10]  The maximum number of items 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.
   * @returns {WordTreeEntry[]}          A number of entries that have the given prefix.
   */
  lookup(prefix, { limit=10, filterEntries }={}) {
    return super.lookup(prefix, { limit, filterEntries });
  }

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

  /**
   * Returns the node at the given prefix.
   * @param {string} prefix  The prefix.
   * @returns {StringTreeNode}
   */
  nodeAtPrefix(prefix) {
    prefix = prefix.toLocaleLowerCase(game.i18n.lang);
    return super.nodeAtPrefix(Array.from(prefix));
  }
}

/**
 * The constructor of an async function.
 * @type {typeof AsyncFunction}
 */
const AsyncFunction = (async function() {}).constructor;

/**
 * This module contains data field classes which are used to define a data schema.
 * A data field is responsible for cleaning, validation, and initialization of the value assigned to it.
 * Each data field extends the {@link foundry.data.fields.DataField} class to implement logic specific to its
 * contained data type.
 * @module fields
 */


/**
 * @import {EffectChangeData} from "../documents/_types.mjs";
 * @import {
 *   ArrayFieldOptions,
 *   ChoiceInputConfig,
 *   CodeMirrorInputConfig,
 *   DataFieldContext,
 *   DataFieldOptions,
 *   DataFieldValidationOptions,
 *   DocumentStats,
 *   DocumentUUIDFieldOptions,
 *   FilePathFieldOptions,
 *   FormGroupConfig,
 *   FormInputConfig,
 *   JavaScriptFieldOptions,
 *   NumberFieldOptions,
 *   StringFieldInputConfig,
 *   StringFieldOptions
 * } from "./_types.mjs";
 * @import {Document, DataModel} from "../abstract/_module.mjs";
 * @import {DataSchema, DataModelUpdateOptions} from "../abstract/_types.mjs";
 * @import {FormSelectOption} from "../../client/applications/forms/fields.mjs"
 */

/* ---------------------------------------- */
/*  Abstract Data Field                     */
/* ---------------------------------------- */

/**
 * An abstract class that defines the base pattern for a data field within a data schema.
 * @property {string} name                The name of this data field within the schema that contains it.
 * @mixes DataFieldOptions
 */
class DataField {
  /**
   * @param {DataFieldOptions} [options]    Options which configure the behavior of the field
   * @param {DataFieldContext} [context]    Additional context which describes the field
   */
  constructor(options={}, {name, parent}={}) {
    this.name = name;
    this.parent = parent;
    this.options = options;
    for ( const k in this.constructor._defaults ) {
      this[k] = k in this.options ? this.options[k] : this.constructor._defaults[k];
    }
  }

  /**
   * The field name of this DataField instance.
   * This is assigned by SchemaField#initialize.
   * @internal
   */
  name;

  /**
   * A reference to the parent schema to which this DataField belongs.
   * This is assigned by SchemaField#initialize.
   * @internal
   */
  parent;

  /**
   * The initially provided options which configure the data field
   * @type {DataFieldOptions}
   */
  options;

  /**
   * Whether this field defines part of a Document/Embedded Document hierarchy.
   * @type {boolean}
   */
  static hierarchical = false;

  /**
   * Does this field type contain other fields in a recursive structure?
   * Examples of recursive fields are SchemaField, ArrayField, or TypeDataField
   * Examples of non-recursive fields are StringField, NumberField, or ObjectField
   * @type {boolean}
   */
  static recursive = false;

  /**
   * Default parameters for this field type
   * @returns {DataFieldOptions}
   * @protected
   */
  static get _defaults() {
    return {
      required: false,
      nullable: false,
      initial: undefined,
      readonly: false,
      gmOnly: false,
      label: "",
      hint: "",
      validationError: "is not a valid value"
    };
  }

  /**
   * A dot-separated string representation of the field path within the parent schema.
   * @type {string}
   */
  get fieldPath() {
    return [this.parent?.fieldPath, this.name].filterJoin(".");
  }

  /**
   * Apply a function to this DataField which propagates through recursively to any contained data schema.
   * @param {string|Function} fn          The function to apply
   * @param {*} value                     The current value of this field
   * @param {object} [options={}]         Additional options passed to the applied function
   * @returns {object}                    The results object
   */
  apply(fn, value, options={}) {
    if ( typeof fn === "string" ) fn = this[fn];
    return fn.call(this, value, options);
  }

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

  /**
   * Add types of the source to the data if they are missing.
   * @param {*} source                           The source data
   * @param {*} changes                          The partial data
   * @param {object} [options]                   Additional options
   * @param {object} [options.source]            The root data model source
   * @param {object} [options.changes]           The root data model changes
   * @internal
   */
  _addTypes(source, changes, options) {}

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

  /**
   * Recursively traverse a schema and retrieve a field specification by a given path
   * @param {string[]} path             The field path as an array of strings
   * @returns {DataField|undefined}     The corresponding DataField definition for that field, or undefined
   * @internal
   */
  _getField(path) {
    return path.length ? undefined : this;
  }

  /* -------------------------------------------- */
  /*  Field Cleaning                              */
  /* -------------------------------------------- */

  /**
   * Coerce source data to ensure that it conforms to the correct data type for the field.
   * Data coercion operations should be simple and synchronous as these are applied whenever a DataModel is constructed.
   * For one-off cleaning of user-provided input the sanitize method should be used.
   * @param {*} value           An initial requested value
   * @param {object} [options]  Additional options for how the field is cleaned
   * @param {boolean} [options.partial]   Whether to perform partial cleaning?
   * @param {object} [options.source]     The root data model being cleaned
   * @returns {*}               The cast value
   */
  clean(value, options={}) {

    // Get an initial value for the field
    if ( value === undefined ) return this.getInitialValue(options.source);

    // Keep allowed special values
    try {
      const isValid = this._validateSpecial(value);
      if ( isValid === true ) return value;
    } catch(err) {
      return this.getInitialValue(options.source);
    }

    // Cast a provided value to the correct type
    value = this._cast(value);

    // Cleaning logic specific to the DataField.
    return this._cleanType(value, options);
  }

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

  /**
   * Apply any cleaning logic specific to this DataField type.
   * @param {*} value           The appropriately coerced value.
   * @param {object} [options]  Additional options for how the field is cleaned.
   * @returns {*}               The cleaned value.
   * @protected
   */
  _cleanType(value, options) {
    return value;
  }

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

  /**
   * Cast a non-default value to ensure it is the correct type for the field
   * @param {*} value       The provided non-default value
   * @returns {*}           The standardized value
   * @protected
   */
  _cast(value) {
    return value;
  }

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

  /**
   * Attempt to retrieve a valid initial value for the DataField.
   * @param {object} data   The source data object for which an initial value is required
   * @returns {*}           A proposed initial value
   */
  getInitialValue(data) {
    if ( this.initial instanceof Function ) return this.initial(data);  // Explicit function
    else if ( this.initial !== undefined ) return this.initial;         // Explicit value
    if ( !this.required ) return undefined;                             // Prefer undefined if non-required
    if ( this.nullable ) return null;                                   // Prefer explicit null
    return undefined;                                                   // Otherwise undefined
  }

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

  /**
   * Export the current value of the field into a serializable object.
   * @param {*} value                   The initialized value of the field
   * @returns {*}                       An exported representation of the field
   */
  toObject(value) {
    return value;
  }

  /* -------------------------------------------- */
  /*  Field Validation                            */
  /* -------------------------------------------- */

  /**
   * Validate a candidate input for this field, ensuring it meets the field requirements.
   * A validation failure can be provided as a raised Error (with a string message), by returning false, or by returning
   * a DataModelValidationFailure instance.
   * A validator which returns true denotes that the result is certainly valid and further validations are unnecessary.
   * @param {*} value                                  The initial value
   * @param {DataFieldValidationOptions} [options={}]  Options which affect validation behavior
   * @returns {DataModelValidationFailure|void}        Returns a DataModelValidationFailure if a validation failure
   *                                                   occurred.
   */
  validate(value, options={}) {
    const validators = [this._validateSpecial, this._validateType];
    if ( this.options.validate ) validators.push(this.options.validate);
    try {
      for ( const validator of validators ) {
        const isValid = validator.call(this, value, options);
        if ( isValid === true ) return undefined;
        if ( isValid === false ) {
          return new DataModelValidationFailure({
            invalidValue: value,
            message: this.validationError,
            unresolved: true
          });
        }
        if ( isValid instanceof DataModelValidationFailure ) return isValid;
      }
    } catch(err) {
      return new DataModelValidationFailure({invalidValue: value, message: err.message, unresolved: true});
    }
  }

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

  /**
   * Special validation rules which supersede regular field validation.
   * This validator screens for certain values which are otherwise incompatible with this field like null or undefined.
   * @param {*} value               The candidate value
   * @returns {boolean|void}        A boolean to indicate with certainty whether the value is valid.
   *                                Otherwise, return void.
   * @throws {Error}                May throw a specific error if the value is not valid
   * @protected
   */
  _validateSpecial(value) {

    // Allow null values for explicitly nullable fields
    if ( value === null ) {
      if ( this.nullable ) return true;
      else throw new Error("may not be null");
    }

    // Allow undefined if the field is not required
    if ( value === undefined ) {
      if ( this.required ) throw new Error("may not be undefined");
      else return true;
    }
  }

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

  /**
   * A default type-specific validator that can be overridden by child classes
   * @param {*} value                                    The candidate value
   * @param {DataFieldValidationOptions} [options={}]    Options which affect validation behavior
   * @returns {boolean|DataModelValidationFailure|void}  A boolean to indicate with certainty whether the value is
   *                                                     valid, or specific DataModelValidationFailure information,
   *                                                     otherwise void.
   * @throws                                             May throw a specific error if the value is not valid
   * @protected
   */
  _validateType(value, options={}) {}

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

  /**
   * Certain fields may declare joint data validation criteria.
   * This method will only be called if the field is designated as recursive.
   * @param {object} data       Candidate data for joint model validation
   * @param {object} options    Options which modify joint model validation
   * @throws  An error if joint model validation fails
   * @internal
   */
  _validateModel(data, options={}) {}

  /* -------------------------------------------- */
  /*  Initialization and Updates                  */
  /* -------------------------------------------- */

  /**
   * Initialize the original source data into a mutable copy for the DataModel instance.
   * @param {*} value                   The source value of the field
   * @param {Object} model              The DataModel instance that this field belongs to
   * @param {object} [options]          Initialization options
   * @returns {*}                       An initialized copy of the source data
   */
  initialize(value, model, options={}) {
    return value;
  }

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

  /**
   * Update the source data for a DataModel which includes this DataField.
   * This method is responsible for modifying the provided source data as well as updating the tracked diff included
   * in provided metadata.
   * @param {object} source               Source data of the DataModel which should be updated. This object is always
   *                                      a partial node of source data, relative to which this field belongs.
   * @param {string} key                  The name of this field within the context of the source data.
   * @param {any} value                   The candidate value that should be applied as an update.
   * @param {object} difference           The accumulated diff that is recursively populated as the model traverses
   *                                      through its schema fields.
   * @param {DataModelUpdateOptions} options Options which modify how this update workflow is performed.
   * @throws {Error}                      An error if the requested update cannot be performed.
   * @internal
   */
  _updateDiff(source, key, value, difference, options) {
    const current = source[key];
    if ( value === current ) return;
    difference[key] = value;
    source[key] = value;
  }

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

  /**
   * Commit a prepared update to DataModel#_source.
   * @param {object} source               The parent source object within which the `key` field exists
   * @param {string} key                  The named field in source to commit
   * @param {object} value                The new value of the field which should be committed to source
   * @param {object} diff                 The reported change to the field
   * @param {DataModelUpdateOptions} options Options which modify how this update workflow is performed.
   * @internal
   */
  _updateCommit(source, key, value, diff, options) {
    source[key] = value;
  }

  /* -------------------------------------------- */
  /*  Form Field Integration                      */
  /* -------------------------------------------- */

  /**
   * Does this form field class have defined form support?
   * @type {boolean}
   */
  static get hasFormSupport() {
    return this.prototype._toInput !== DataField.prototype._toInput;
  }

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

  /**
   * Render this DataField as an HTML element.
   * @param {FormInputConfig} config        Form element configuration parameters
   * @throws {Error}                        An Error if this DataField subclass does not support input rendering
   * @returns {HTMLElement|HTMLCollection}  A rendered HTMLElement for the field
   */
  toInput(config={}) {
    const inputConfig = {name: this.fieldPath, ...config};
    if ( inputConfig.input instanceof Function ) return config.input(this, inputConfig);
    return this._toInput(inputConfig);
  }

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

  // eslint-disable-next-line jsdoc/require-returns-check
  /**
   * Render this DataField as an HTML element.
   * Subclasses should implement this method rather than the public toInput method which wraps it.
   * @param {FormInputConfig} config        Form element configuration parameters
   * @throws {Error}                        An Error if this DataField subclass does not support input rendering
   * @returns {HTMLElement|HTMLCollection}  A rendered HTMLElement for the field
   * @protected
   */
  _toInput(config) {
    throw new Error(`The ${this.constructor.name} class does not implement the _toInput method`);
  }

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

  /**
   * Render this DataField as a standardized form-group element.
   * @param {FormGroupConfig} groupConfig   Configuration options passed to the wrapping form-group
   * @param {FormInputConfig} inputConfig   Input element configuration options passed to DataField#toInput
   * @returns {HTMLDivElement}              The rendered form group element
   */
  toFormGroup(groupConfig={}, inputConfig={}) {
    if ( groupConfig.widget instanceof Function ) return groupConfig.widget(this, groupConfig, inputConfig);
    groupConfig.label ??= this.label ?? this.fieldPath;
    groupConfig.hint ??= this.hint;
    groupConfig.input ??= this.toInput(inputConfig);
    return foundry.applications.fields.createFormGroup(groupConfig);
  }

  /* -------------------------------------------- */
  /*  Active Effect Integration                   */
  /* -------------------------------------------- */

  /**
   * Apply an ActiveEffectChange to this field.
   * @param {*} value                  The field's current value.
   * @param {DataModel} model          The model instance.
   * @param {EffectChangeData} change  The change to apply.
   * @returns {*}                      The updated value.
   */
  applyChange(value, model, change) {
    const delta = this._castChangeDelta(change.value);
    switch ( change.mode ) {
      case CONST.ACTIVE_EFFECT_MODES.ADD: return this._applyChangeAdd(value, delta, model, change);
      case CONST.ACTIVE_EFFECT_MODES.MULTIPLY: return this._applyChangeMultiply(value, delta, model, change);
      case CONST.ACTIVE_EFFECT_MODES.OVERRIDE: return this._applyChangeOverride(value, delta, model, change);
      case CONST.ACTIVE_EFFECT_MODES.UPGRADE: return this._applyChangeUpgrade(value, delta, model, change);
      case CONST.ACTIVE_EFFECT_MODES.DOWNGRADE: return this._applyChangeDowngrade(value, delta, model, change);
    }
    return this._applyChangeCustom(value, delta, model, change);
  }

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

  /**
   * Cast a change delta into an appropriate type to be applied to this field.
   * @param {*} delta  The change delta.
   * @returns {*}
   * @internal
   */
  _castChangeDelta(delta) {
    return this._cast(delta);
  }

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

  /**
   * Apply an ADD change to this field.
   * @param {*} value                  The field's current value.
   * @param {*} delta                  The change delta.
   * @param {DataModel} model          The model instance.
   * @param {EffectChangeData} change  The original change data.
   * @returns {*}                      The updated value.
   * @protected
   */
  _applyChangeAdd(value, delta, model, change) {
    return value + delta;
  }

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

  /**
   * Apply a MULTIPLY change to this field.
   * @param {*} value                  The field's current value.
   * @param {*} delta                  The change delta.
   * @param {DataModel} model          The model instance.
   * @param {EffectChangeData} change  The original change data.
   * @returns {*}                      The updated value.
   * @protected
   */
  _applyChangeMultiply(value, delta, model, change) {}

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

  /**
   * Apply an OVERRIDE change to this field.
   * @param {*} value                  The field's current value.
   * @param {*} delta                  The change delta.
   * @param {DataModel} model          The model instance.
   * @param {EffectChangeData} change  The original change data.
   * @returns {*}                      The updated value.
   * @protected
   */
  _applyChangeOverride(value, delta, model, change) {
    return delta;
  }

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

  /**
   * Apply an UPGRADE change to this field.
   * @param {*} value                  The field's current value.
   * @param {*} delta                  The change delta.
   * @param {DataModel} model          The model instance.
   * @param {EffectChangeData} change  The original change data.
   * @returns {*}                      The updated value.
   * @protected
   */
  _applyChangeUpgrade(value, delta, model, change) {}

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

  /**
   * Apply a DOWNGRADE change to this field.
   * @param {*} value                  The field's current value.
   * @param {*} delta                  The change delta.
   * @param {DataModel} model          The model instance.
   * @param {EffectChangeData} change  The original change data.
   * @returns {*}                      The updated value.
   * @protected
   */
  _applyChangeDowngrade(value, delta, model, change) {}

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

  /**
   * Apply a CUSTOM change to this field.
   * @param {*} value                  The field's current value.
   * @param {*} delta                  The change delta.
   * @param {DataModel} model          The model instance.
   * @param {EffectChangeData} change  The original change data.
   * @returns {*}                      The updated value.
   * @protected
   */
  _applyChangeCustom(value, delta, model, change) {
    const preHook = foundry.utils.getProperty(model, change.key);
    Hooks.call("applyActiveEffect", model, change, value, delta, {});
    const postHook = foundry.utils.getProperty(model, change.key);
    if ( postHook !== preHook ) return postHook;
  }
}

/* -------------------------------------------- */
/*  Data Schema Field                           */
/* -------------------------------------------- */

/**
 * A special class of {@link foundry.data.fields.DataField} which defines a data schema.
 */
class SchemaField extends DataField {
  /**
   * @param {DataSchema} fields                 The contained field definitions
   * @param {DataFieldOptions} [options]        Options which configure the behavior of the field
   * @param {DataFieldContext} [context]        Additional context which describes the field
   */
  constructor(fields, options, context={}) {
    super(options, context);
    this.fields = this._initialize(fields);
  }

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

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {required: true, nullable: false});
  }

  /** @override */
  static recursive = true;

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

  /**
   * The contained field definitions.
   * @type {DataSchema}
   */
  fields;

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

  /**
   * Initialize and validate the structure of the provided field definitions.
   * @param {DataSchema} fields     The provided field definitions
   * @returns {DataSchema}          The validated schema
   * @protected
   */
  _initialize(fields) {
    if ( getType(fields) !== "Object" ) {
      throw new Error("A DataSchema must be an object with string keys and DataField values.");
    }
    fields = {...fields};
    for ( const [name, field] of Object.entries(fields) ) {
      if ( name === "_source" ) throw new Error('"_source" is not a valid name for a field of a SchemaField.');
      if ( !(field instanceof DataField) ) {
        throw new Error(`The "${name}" field is not an instance of the DataField class.`);
      }
      if ( field.parent !== undefined ) {
        throw new Error(`The "${field.fieldPath}" field already belongs to some other parent and may not be reused.`);
      }
      field.name = name;
      field.parent = this;
    }
    return fields;
  }

  /* -------------------------------------------- */
  /*  Schema Iteration                            */
  /* -------------------------------------------- */

  /**
   * Iterate over a SchemaField by iterating over its fields.
   * @type {Iterable<DataField>}
   */
  *[Symbol.iterator]() {
    for ( const field of Object.values(this.fields) ) {
      yield field;
    }
  }

  /**
   * An array of field names which are present in the schema.
   * @returns {string[]}
   */
  keys() {
    return Object.keys(this.fields);
  }

  /**
   * An array of DataField instances which are present in the schema.
   * @returns {DataField[]}
   */
  values() {
    return Object.values(this.fields);
  }

  /**
   * An array of [name, DataField] tuples which define the schema.
   * @returns {Array<[string, DataField]>}
   */
  entries() {
    return Object.entries(this.fields);
  }

  /**
   * Test whether a certain field name belongs to this schema definition.
   * @param {string} fieldName    The field name
   * @returns {boolean}           Does the named field exist in this schema?
   */
  has(fieldName) {
    return Object.hasOwn(this.fields, fieldName);
  }

  /**
   * Get a DataField instance from the schema by name.
   * @param {string} fieldName    The field name
   * @returns {DataField|void}    The DataField instance or undefined
   */
  get(fieldName) {
    if ( !this.has(fieldName) ) return;
    return this.fields[fieldName];
  }

  /**
   * Traverse the schema, obtaining the DataField definition for a particular field.
   * @param {string[]|string} fieldName       A field path like ["abilities", "strength"] or "abilities.strength"
   * @returns {DataField|undefined}           The corresponding DataField definition for that field, or undefined
   */
  getField(fieldName) {
    let path;
    if ( typeof fieldName === "string" ) path = fieldName.split(".");
    else if ( Array.isArray(fieldName) ) path = fieldName.slice();
    else throw new Error("A field path must be an array of strings or a dot-delimited string");
    return this._getField(path);
  }

  /** @override */
  _getField(path) {
    if ( !path.length ) return this;
    const field = this.get(path.shift());
    return field?._getField(path);
  }

  /* -------------------------------------------- */
  /*  Data Field Methods                          */
  /* -------------------------------------------- */

  /** @override */
  getInitialValue(data) {
    const initial = super.getInitialValue(data);
    if ( this.required && (initial === undefined) ) return this._cleanType({});
    return initial;
  }

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

  /** @override */
  _cast(value) {
    return getType(value) === "Object" ? value : {};
  }

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

  /** @inheritdoc */
  _cleanType(data, options={}) {
    options.source = options.source || data;

    // Clean each field which belongs to the schema
    for ( const [name, field] of this.entries() ) {
      const k = `==${name}`;
      if ( k in data ) {
        data[k] = field.clean(applySpecialKeys(data[k]), {...options, partial: false});
      } else if ( !options.partial || (name in data) ) {
        data[name] = field.clean(data[name], options);
      }
    }

    // Delete any keys which do not belong to the schema
    for ( const k in data ) {
      if ( this.has(k) ) continue;
      if ( isDeletionKey(k) ) {
        const key = k.slice(2);
        if ( this.has(key) ) continue;
      }
      delete data[k];
    }
    return data;
  }

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

  /** @override */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    const data = {};
    for ( const [name, field] of this.entries() ) {
      const v = field.initialize(value[name], model, options);

      // Readonly fields
      if ( field.readonly ) {
        Object.defineProperty(data, name, {value: v, writable: false});
      }

      // Getter fields
      else if ( (typeof v === "function") && !v.prototype ) {
        Object.defineProperty(data, name, {get: v, set() {}, configurable: true});
      }

      // Writable fields
      else data[name] = v;
    }
    return data;
  }

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

  /**
   * The SchemaField#update method plays a special role of recursively dispatching DataField#update operations to the
   * constituent fields within the schema.
   * @override
   */
  _updateDiff(source, key, value, difference, options) {

    // * -> undefined, or * -> null
    if ( (value === undefined) || (value === null) || ((options.recursive === false) && (key !== "_source")) ) {
      value = applySpecialKeys(value);
      if ( options.recursive === false ) value = this.clean(value);
      super._updateDiff(source, key, value, difference, options);
      return;
    }

    // Pass type to fields
    const hasTypeData = this.fields.system instanceof TypeDataField;
    if ( hasTypeData && (("==type" in value) || ("-=type" in value)) ) {
      throw new Error("The type of a Document cannot be updated with ==type or -=type");
    }

    // {} -> {}, undefined -> {}, or null -> {}
    source[key] ||= {};
    source = source[key];
    const schemaDiff = difference[key] = {};
    for ( const [k, v] of Object.entries(value) ) {
      let name = k;
      const specialKey = isDeletionKey(k);
      if ( specialKey ) name = k.slice(2);

      // Require the changed field to exist
      const field = this.get(name);
      if ( !field ) continue;

      // Special operations for deletion or forced replacement
      if ( specialKey ) {
        if ( k[0] === "-" ) {
          if ( v !== null ) throw new Error("Removing a key using the -= deletion syntax requires the value of that"
            + " deletion key to be null, for example {-=key: null}");
          if ( name in source ) {
            schemaDiff[k] = v;
            delete source[name];
          }
        }
        else if ( k[0] === "=" ) schemaDiff[k] = source[name] = applySpecialKeys(v);
        continue;
      }

      // Perform field-specific update
      field._updateDiff(source, k, v, schemaDiff, options);
    }

    if ( hasTypeData && ("type" in schemaDiff) && !(("==system" in value) || (("system" in value) && (options.recursive === false))) ) {
      throw new Error("The type of a Document can be changed only if the system field is force-replaced (==) or updated with {recursive: false}");
    }

    // No updates applied
    if ( isEmpty(schemaDiff) ) delete difference[key];
  }

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

  /** @override */
  _updateCommit(source, key, value, diff, options) {
    const s = source[key];

    // Special Cases: * -> undefined, * -> null, undefined -> *, null -> *
    if ( !s || !value ) {
      source[key] = value;
      return;
    }

    // Clear system field if the type changed
    const hasTypeData = this.fields.system instanceof TypeDataField;
    if ( hasTypeData && ("type" in diff) ) s.system = undefined;

    // Update fields in source which changed in the diff
    for ( let [k, d] of Object.entries(diff) ) {
      k = isDeletionKey(k) ? k.slice(2) : k;
      const field = this.get(k);
      if ( !field ) continue;
      field._updateCommit(s, k, value[k], d, options);
    }
  }

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

  /** @override */
  _validateType(data, options={}) {
    if ( !(data instanceof Object) ) throw new Error("must be an object");
    options.source = options.source || data;
    const schemaFailure = new DataModelValidationFailure();
    for ( const [name, field] of this.entries() ) {
      for ( const prefix of ["", "-=", "=="] ) {
        const key = prefix + name;
        if ( (prefix || options.partial) && !(key in data) ) continue;

        // Validate the field's current value
        let value = data[key];
        if ( prefix === "-=" ) {
          if ( value !== null ) throw new Error("Removing a key using the -= deletion syntax requires the value of that"
            + " deletion key to be null, for example {-=key: null}");
          value = undefined;
        }
        const failure = field.validate(value, options);

        // Failure may be permitted if fallback replacement is allowed
        if ( failure ) {
          schemaFailure.fields[key] = failure;

          // If the field internally applied fallback logic
          if ( !failure.unresolved ) continue;

          // If fallback is allowed at the schema level
          if ( options.fallback && !prefix ) {
            const initial = field.getInitialValue(options.source);
            const fallbackFailure = field.validate(initial, {fallback: false, source: options.source});
            if ( fallbackFailure ) failure.unresolved = schemaFailure.unresolved = true;
            else {
              data[name] = failure.fallback = initial;
              failure.unresolved = false;
            }
          }

          // Otherwise the field-level failure is unresolved
          else failure.unresolved = schemaFailure.unresolved = true;
        }
      }
    }
    if ( !isEmpty(schemaFailure.fields) ) return schemaFailure;
  }

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

  /** @override */
  _validateModel(changes, options={}) {
    options.source = options.source || changes;
    if ( !changes ) return;
    for ( const [name, field] of this.entries() ) {
      const change = changes[name];  // May be nullish
      if ( change && field.constructor.recursive ) field._validateModel(change, options);
    }
  }

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

  /** @override */
  toObject(value) {
    if ( (value === undefined) || (value === null) ) return value;
    const data = {};
    for ( const [name, field] of this.entries() ) {
      data[name] = field.toObject(value[name]);
    }
    return data;
  }

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

  /** @override */
  apply(fn, data={}, options={}) {

    // Apply to this SchemaField
    const thisFn = typeof fn === "string" ? this[fn] : fn;
    thisFn?.call(this, data, options);
    if ( !data || (typeof data !== "object") ) return data; // Do not recurse for non-object types or null

    // Recursively apply to inner fields
    const results = {};
    for ( const [key, field] of this.entries() ) {
      if ( options.partial && !(key in data) ) continue;
      const r = field.apply(fn, data[key], options);
      if ( !options.filter || !isEmpty(r) ) results[key] = r;
    }
    return results;
  }

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

  /** @override */
  _addTypes(source, changes, options={}) {
    if ( getType(source) !== "Object" ) return;
    if ( getType(changes) !== "Object" ) return;
    options.source ??= source;
    options.changes ??= changes;
    const hasTypeData = this.fields.system instanceof TypeDataField;
    if ( hasTypeData ) {
      if ( "type" in changes ) changes.type ??= this.fields.type.getInitialValue(source);
      else changes.type = source.type;
    }
    for ( const key in changes ) {
      const field = this.get(key);
      field?._addTypes(source[key], changes[key], options);
    }
  }

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

  /**
   * Migrate this field's candidate source data.
   * @param {object} sourceData   Candidate source data of the root model
   * @param {any} fieldData       The value of this field within the source data
   */
  migrateSource(sourceData, fieldData) {
    if ( getType(fieldData) !== "Object" ) return;
    for ( const key in fieldData ) {
      const isDeletion = isDeletionKey(key);
      if ( isDeletion && (key[0] === "-") ) continue;
      const field = this.get(isDeletion ? key.slice(2) : key);
      if ( !field || !(field.migrateSource instanceof Function) ) continue;
      field.migrateSource(sourceData, fieldData[key]);
    }
  }
}

/* -------------------------------------------- */
/*  Basic Field Types                           */
/* -------------------------------------------- */

/**
 * A subclass of {@link foundry.data.fields.DataField} which deals with boolean-typed data.
 */
class BooleanField extends DataField {

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {required: true, nullable: false, initial: false});
  }

  /** @override */
  _cast(value) {
    if ( typeof value === "string" ) return value === "true";
    if ( typeof value === "object" ) return false;
    return Boolean(value);
  }

  /** @override */
  _validateType(value) {
    if (typeof value !== "boolean") throw new Error("must be a boolean");
  }

  /** @override */
  _toInput(config) {
    config.value ??= this.initial;
    return foundry.applications.fields.createCheckboxInput(config);
  }

  /* -------------------------------------------- */
  /*  Active Effect Integration                   */
  /* -------------------------------------------- */

  /** @override */
  _applyChangeAdd(value, delta, model, change) {
    return value || delta;
  }

  /** @override */
  _applyChangeMultiply(value, delta, model, change) {
    return value && delta;
  }

  /** @override */
  _applyChangeUpgrade(value, delta, model, change) {
    return delta > value ? delta : value;
  }

  /** @override */
  _applyChangeDowngrade(value, delta, model, change) {
    return delta < value ? delta : value;
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.DataField} which deals with number-typed data.
 *
 * @property {number} min                 A minimum allowed value
 * @property {number} max                 A maximum allowed value
 * @property {number} step                A permitted step size
 * @property {boolean} integer=false      Must the number be an integer?
 * @property {boolean} positive=false     Must the number be positive?
 * @property {number[]|object|Function} [choices] An array of values or an object of values/labels which represent
 *                                        allowed choices for the field. A function may be provided which dynamically
 *                                        returns the array of choices.
 */
class NumberField extends DataField {
  /**
   * @param {NumberFieldOptions} options  Options which configure the behavior of the field
   * @param {DataFieldContext} [context]  Additional context which describes the field
   */
  constructor(options={}, context={}) {
    super(options, context);
    // If choices are provided, the field should not be null by default
    if ( this.choices ) {
      this.nullable = options.nullable ?? false;
    }
    if ( Number.isFinite(this.min) && Number.isFinite(this.max) && (this.min > this.max) ) {
      throw new Error("NumberField minimum constraint cannot exceed its maximum constraint");
    }
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {
      nullable: true,
      min: undefined,
      max: undefined,
      step: undefined,
      integer: false,
      positive: false,
      choices: undefined
    });
  }

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

  /** @override */
  _cast(value) {
    return this.nullable && (value === "") ? null : Number(value);
  }

  /** @inheritdoc */
  _cleanType(value, options) {
    value = super._cleanType(value, options);
    if ( typeof value !== "number" ) return value;
    if ( this.integer ) value = Math.round(value);
    if ( Number.isFinite(this.step) ) {
      let base = 0;
      if ( Number.isFinite(this.min) ) value = Math.max(value, base = this.min);
      value = value.toNearest(this.step, "round", base);
      if ( Number.isFinite(this.max) ) value = Math.min(value, this.max.toNearest(this.step, "floor", base));
    } else {
      if ( Number.isFinite(this.min) ) value = Math.max(value, this.min);
      if ( Number.isFinite(this.max) ) value = Math.min(value, this.max);
    }
    return value;
  }

  /** @override */
  _validateType(value) {
    if ( typeof value !== "number" ) throw new Error("must be a number");
    if ( this.positive && (value <= 0) ) throw new Error("must be a positive number");
    if ( Number.isFinite(this.min) && (value < this.min) ) throw new Error(`must be at least ${this.min}`);
    if ( Number.isFinite(this.max) && (value > this.max) ) throw new Error(`must be at most ${this.max}`);
    if ( Number.isFinite(this.step) && (value.toNearest(this.step, "round", Number.isFinite(this.min) ? this.min : 0) !== value) ) {
      if ( Number.isFinite(this.min) && (this.min !== 0) ) throw new Error(`must be an increment of ${this.step} after subtracting ${this.min}`);
      else throw new Error(`must be an increment of ${this.step}`);
    }
    if ( this.choices && !this.#isValidChoice(value) ) throw new Error(`${value} is not a valid choice`);
    if ( this.integer ) {
      if ( !Number.isInteger(value) ) throw new Error("must be an integer");
    }
    else if ( !Number.isFinite(value) ) throw new Error("must be a finite number");
  }

  /**
   * Test whether a provided value is a valid choice from the allowed choice set
   * @param {number} value      The provided value
   * @returns {boolean}         Is the choice valid?
   */
  #isValidChoice(value) {
    let choices = this.choices;
    if ( choices instanceof Function ) choices = choices();
    if ( choices instanceof Array ) return choices.includes(value);
    return String(value) in choices;
  }

  /* -------------------------------------------- */
  /*  Form Field Integration                      */
  /* -------------------------------------------- */

  /** @override */
  _toInput(config) {
    config.min ??= this.min;
    config.max ??= this.max;
    config.step ??= this.step;
    if ( config.value === undefined ) config.value = this.getInitialValue({});
    if ( this.integer ) {
      if ( Number.isNumeric(config.value) ) config.value = Math.round(config.value);
      config.step ??= 1;
    }
    if ( this.positive && Number.isFinite(config.step) ) config.min ??= config.step;

    // Number Select
    config.choices ??= this.choices;
    StringField._prepareChoiceConfig(config);
    if ( config.options ) {
      config.dataset ||= {};
      config.dataset.dtype = "Number";
      return foundry.applications.fields.createSelectInput(config);
    }

    // Range Slider
    if ( ["min", "max", "step"].every(k => config[k] !== undefined) && (config.type !== "number") ) {
      return foundry.applications.elements.HTMLRangePickerElement.create(config);
    }

    // Number Input
    return foundry.applications.fields.createNumberInput(config);
  }

  /* -------------------------------------------- */
  /*  Active Effect Integration                   */
  /* -------------------------------------------- */

  /** @override */
  _applyChangeMultiply(value, delta, model, change) {
    return value * delta;
  }

  /** @override */
  _applyChangeUpgrade(value, delta, model, change) {
    return delta > value ? delta : value;
  }

  /** @override */
  _applyChangeDowngrade(value, delta, model, change) {
    return delta < value ? delta : value;
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.DataField} which deals with string-typed data.
 */
class StringField extends DataField {
  /**
   * @param {StringFieldOptions} [options]  Options which configure the behavior of the field
   * @param {DataFieldContext} [context]    Additional context which describes the field
   */
  constructor(options={}, context={}) {
    super(options, context);

    // If choices are provided, the field should not be null or blank by default
    if ( this.choices ) {
      this.nullable = options.nullable ?? false;
      this.blank = options.blank ?? false;
    }
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {blank: true, trim: true, choices: undefined, textSearch: false});
  }

  /**
   * Is the string allowed to be blank (empty)?
   * @type {boolean}
   */
  blank = this.blank;

  /**
   * Should any provided string be trimmed as part of cleaning?
   * @type {boolean}
   */
  trim = this.trim;

  /**
   * An array of values or an object of values/labels which represent
   * allowed choices for the field. A function may be provided which dynamically
   * returns the array of choices.
   * @type {string[]|object|Function}
   */
  choices = this.choices;

  /**
   * Is this string field a target for text search?
   * @type {boolean}
   */
  textSearch = this.textSearch;

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

  /** @inheritdoc */
  clean(value, options) {
    if ( (typeof value === "string") && this.trim ) value = value.trim(); // Trim input strings
    return super.clean(value, options);
  }

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

  /** @override */
  getInitialValue(data) {
    const initial = super.getInitialValue(data);
    if ( this.blank && this.required && !initial ) return "";  // Prefer blank to null for required fields
    return initial;
  }

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

  /** @override */
  _cast(value) {
    return String(value);
  }

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

  /** @inheritdoc */
  _validateSpecial(value) {
    if ( value === "" ) {
      if ( this.blank ) return true;
      else throw new Error("may not be a blank string");
    }
    return super._validateSpecial(value);
  }

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

  /** @override */
  _validateType(value) {
    if ( typeof value !== "string" ) throw new Error("must be a string");
    else if ( this.choices ) {
      if ( this._isValidChoice(value) ) return true;
      else throw new Error(`${value} is not a valid choice`);
    }
  }

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

  /**
   * Test whether a provided value is a valid choice from the allowed choice set
   * @param {string} value      The provided value
   * @returns {boolean}         Is the choice valid?
   * @protected
   */
  _isValidChoice(value) {
    let choices = this.choices;
    if ( choices instanceof Function ) choices = choices();
    if ( choices instanceof Array ) return choices.includes(value);
    return String(value) in choices;
  }

  /* -------------------------------------------- */
  /*  Form Field Integration                      */
  /* -------------------------------------------- */

  /**
   * Prepare form input configuration to accept a limited choice set of options.
   * @param {FormInputConfig & Partial<ChoiceInputConfig>} [config]
   * @internal
   */
  static _prepareChoiceConfig(config) {
    if ( config.options || !("choices" in config) ) return;
    let choices;
    try {
      choices = typeof config.choices === "function" ? config.choices() : config.choices;
    } catch(error) {
      logger.error(error);
    }

    // Prepare options array - only accept arrays or records
    if ( (typeof choices === "object") && (choices !== null) ) {
      config.options = [];
      for ( const [value, entry] of Object.entries(choices) ) {
        const choice = {value, ...StringField.#getChoiceFromEntry(entry, config)};
        config.options.push(choice);
      }
    }

    // Remove consumed options
    delete config.choices;
    delete config.valueAttr;
    delete config.labelAttr;
  }

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

  /**
   * Convert a choice entry into a standardized FormSelectOption
   * @param {string|object} entry
   * @param {{labelAttr?: string; valueAttr?: string; localize?: boolean}} options
   * @returns {FormSelectOption}
   */
  static #getChoiceFromEntry(entry, {labelAttr="label", valueAttr, localize}) {
    const choice = {};
    if ( foundry.utils.getType(entry) === "Object" ) {
      if ( valueAttr && (valueAttr in entry) ) choice.value = entry[valueAttr];
      if ( labelAttr && (labelAttr in entry) ) choice.label = entry[labelAttr];
      for ( const k of ["group", "disabled", "rule"] ) {
        if ( k in entry ) choice[k] = entry[k];
      }
    }
    else choice.label = String(entry);
    if ( localize && choice.label ) choice.label = game.i18n.localize(choice.label);
    return choice;
  }

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

  /**
   * @param {FormInputConfig & StringFieldInputConfig} config
   * @override
   */
  _toInput(config) {
    if ( config.value === undefined ) config.value = this.getInitialValue({});
    config.choices ??= this.choices;

    // Choice Select
    StringField._prepareChoiceConfig(config);
    if ( config.options ) {
      if ( this.blank || this.nullable || !this.required ) config.blank ??= "";
      return foundry.applications.fields.createSelectInput(config);
    }

    // One of several options for element type
    switch ( config.elementType ?? "input" ) {
      case "input":
        return foundry.applications.fields.createTextInput(config);
      case "textarea":
        return foundry.applications.fields.createTextareaInput(config);
      case "file-picker":
        return foundry.applications.elements.HTMLFilePickerElement.create(config);
      case "prose-mirror":
        return foundry.applications.elements.HTMLProseMirrorElement.create(config);
      case "code-mirror":
        return foundry.applications.elements.HTMLCodeMirrorElement.create(config);
      default:
        throw new Error(`Unrecognized element type for StringField input: ${config.elementType}`);
    }
  }
}

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

/**
 * A subclass of DataField which deals with object-typed data.
 */
class ObjectField extends DataField {

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {required: true, nullable: false});
  }

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

  /** @override */
  getInitialValue(data) {
    const initial = super.getInitialValue(data);
    if ( this.required && (initial === undefined) ) return {};
    return initial;
  }

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

  /** @override */
  _cast(value) {
    if ( value.toObject instanceof Function ) value = value.toObject();
    return getType(value) === "Object" ? value : {};
  }

  /** @override */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    return deepClone(value);
  }

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

  /** @override */
  _updateDiff(source, key, value, difference, options) {

    // {} -> {}, null -> {}, undefined -> {}
    if ( (getType(value) === "Object") && (options.recursive !== false) ) {
      if ( getType(source[key]) !== "Object" ) source[key] = {};
      const diff = diffObject(source[key], value, {deletionKeys: true});
      if ( isEmpty(diff) ) return;
      difference[key] = diff;
      mergeObject(source[key], value, {insertKeys: true, insertValues: true, performDeletions: true});
    }

    // {} -> null or {} -> undefined
    else super._updateDiff(source, key, applySpecialKeys(value), difference, options);
  }

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

  /** @inheritDoc */
  _updateCommit(source, key, value, diff, options) {
    const s = source[key];

    // Special Cases: * -> undefined, * -> null, undefined -> *, null -> *
    if ( !s || !value || Object.isSealed(s) ) {
      source[key] = value;
      return;
    }

    for ( const k of Object.keys(s) ) {
      if ( !(k in value) ) delete s[k];
    }
    Object.assign(s, value);
  }

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

  /** @override */
  toObject(value) {
    return deepClone(value);
  }

  /** @override */
  _validateType(value, options={}) {
    if ( getType(value) !== "Object" ) throw new Error("must be an object");
  }
}

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

/**
 * A subclass of ObjectField that represents a mapping of keys to the provided DataField type.
 */
class TypedObjectField extends ObjectField {
  /**
   * @param {DataField} element             The value type of each entry in this object.
   * @param {DataFieldOptions} [options]    Options which configure the behavior of the field.
   * @param {DataFieldContext} [context]    Additional context which describes the field
   */
  constructor(element, options, context) {
    super(options, context);
    if ( !(element instanceof DataField) ) throw new Error("The element must be a DataField");
    if ( element.parent !== undefined ) throw new Error("The element DataField already has a parent");
    element.name ||= "element";
    element.parent = this;
    this.element = element;
  }

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

  /**
   * The value type of each entry in this object.
   * @type {DataField}
   */
  element;

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

  /** @override */
  static recursive = true;

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

  /** @inheritDoc */
  static get _defaults() {
    return mergeObject(super._defaults, {validateKey: undefined});
  }

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

  /** @override */
  _cleanType(data, options) {
    options.source = options.source || data;
    for ( const key in data ) {
      const isDeletion = isDeletionKey(key);
      const k = isDeletion ? key.slice(2) : key;
      let valid;
      try {
        valid = this.validateKey?.(k);
      } catch {
        valid = false;
      }
      if ( valid === false ) {
        delete data[key];
        continue;
      }
      if ( isDeletion && (key[0] === "-") ) continue;
      data[key] = this.element.clean(data[key], options);
    }
    return data;
  }

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

  /** @override */
  _validateType(data, options={}) {
    if ( foundry.utils.getType(data) !== "Object" ) throw new Error("must be an object");
    options.source = options.source || data;
    const mappingFailure = new DataModelValidationFailure();
    for ( const key in data ) {
      if ( key.startsWith("-=") ) continue;

      // Validate the field's current value
      const value = data[key];
      const failure = this.element.validate(value, options);

      // Failure may be permitted if fallback replacement is allowed
      if ( failure ) {
        mappingFailure.fields[key] = failure;

        // If the field internally applied fallback logic
        if ( !failure.unresolved ) continue;

        // If fallback is allowed at the object level
        if ( options.fallback && !key.startsWith("==") ) {
          const initial = this.element.getInitialValue(options.source);
          if ( this.element.validate(initial, {source: options.source}) === undefined ) {  // Ensure initial is valid
            data[key] = initial;
            failure.fallback = initial;
            failure.unresolved = false;
          }
          else failure.unresolved = mappingFailure.unresolved = true;
        }

        // Otherwise the field-level failure is unresolved
        else failure.unresolved = mappingFailure.unresolved = true;
      }
    }
    if ( !foundry.utils.isEmpty(mappingFailure.fields) ) return mappingFailure;
  }

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

  /** @override */
  _validateModel(changes, options={}) {
    options.source = options.source || changes;
    if ( !changes ) return;
    for ( const key in changes ) {
      const change = changes[key];  // May be nullish
      if ( change && this.element.constructor.recursive ) this.element._validateModel(change, options);
    }
  }

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

  /** @override */
  initialize(value, model, options={}) {
    const object = {};
    for ( const key in value ) object[key] = this.element.initialize(value[key], model, options);
    return object;
  }

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

  /** @override */
  _updateDiff(source, key, value, difference, options) {

    // * -> undefined, or * -> null
    if ( (value === undefined) || (value === null) || (options.recursive === false) ) {
      super._updateDiff(source, key, value, difference, options);
      return;
    }

    // {} -> {}, undefined -> {}, or null -> {}
    source[key] ||= {};
    value ||= {};
    source = source[key];
    const schemaDiff = difference[key] = {};
    for ( const [k, v] of Object.entries(value) ) {
      let name = k;
      const specialKey = isDeletionKey(k);
      if ( specialKey ) name = k.slice(2);

      // Special operations for deletion or forced replacement
      if ( specialKey ) {
        if ( k[0] === "-" ) {
          if ( v !== null ) throw new Error("Removing a key using the -= deletion syntax requires the value of that"
            + " deletion key to be null, for example {-=key: null}");
          if ( name in source ) {
            schemaDiff[k] = v;
            delete source[name];
          }
        }
        else if ( k[0] === "=" ) schemaDiff[k] = source[name] = applySpecialKeys(v);
        continue;
      }

      // Perform type-specific update
      this.element._updateDiff(source, k, v, schemaDiff, options);
    }

    // No updates applied
    if ( isEmpty(schemaDiff) ) delete difference[key];
  }

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

  /** @override */
  _updateCommit(source, key, value, diff, options) {
    const s = source[key];

    // Special Cases: * -> undefined, * -> null, undefined -> *, null -> *
    if ( !s || !value || Object.isSealed(s) ) {
      source[key] = value;
      return;
    }

    // Remove keys which no longer exist in the new value
    for ( const k of Object.keys(s) ) {
      if ( !(k in value) ) delete s[k];
    }

    // Update fields in source which changed in the diff
    for ( let [k, d] of Object.entries(diff) ) {
      if ( isDeletionKey(k) ) {
        if ( k[0] === "-" ) continue;
        k = k.slice(2);
      }
      this.element._updateCommit(s, k, value[k], d, options);
    }
  }

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

  /** @override */
  toObject(value) {
    if ( (value === undefined) || (value === null) ) return value;
    const object = {};
    for ( const key in value ) object[key] = this.element.toObject(value[key]);
    return object;
  }

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

  /** @override */
  apply(fn, data={}, options={}) {

    // Apply to this TypedObjectField
    const thisFn = typeof fn === "string" ? this[fn] : fn;
    thisFn?.call(this, data, options);

    // Recursively apply to inner fields
    const results = {};
    for ( const key in data ) {
      const r = this.element.apply(fn, data[key], options);
      if ( !options.filter || !isEmpty(r) ) results[key] = r;
    }
    return results;
  }

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

  /** @override */
  _addTypes(source, changes, options={}) {
    if ( (getType(source) !== "Object") || (getType(changes) !== "Object") ) return;
    for ( const key in changes ) this.element._addTypes(source[key], changes[key], options);
  }

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

  /** @override */
  _getField(path) {
    if ( path.length === 0 ) return this;
    if ( path.shift() !== this.element.name ) return undefined;
    return this.element._getField(path);
  }

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

  /**
   * Migrate this field's candidate source data.
   * @param {object} sourceData   Candidate source data of the root model
   * @param {any} fieldData       The value of this field within the source data
   */
  migrateSource(sourceData, fieldData) {
    if ( !(this.element.migrateSource instanceof Function) ) return;
    if ( getType(fieldData) !== "Object" ) return;
    for ( const key in fieldData ) {
      if ( key.startsWith("-=") ) continue;
      this.element.migrateSource(sourceData, fieldData[key]);
    }
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.DataField} which deals with array-typed data.
 * @template [ElementType=DataField]
 * @property {number} min     The minimum number of elements.
 * @property {number} max     The maximum number of elements.
 */
class ArrayField extends DataField {
  /**
   * @param {ElementType} element          The type of element contained in the Array
   * @param {ArrayFieldOptions} [options]  Options which configure the behavior of the field
   * @param {DataFieldContext} [context]   Additional context which describes the field
   */
  constructor(element, options={}, context={}) {
    super(options, context);
    this.element = this.constructor._validateElementType(element);
    if ( this.element instanceof DataField ) {
      this.element.name ||= "element";
      this.element.parent = this;
    }
    if ( this.min > this.max ) throw new Error("ArrayField minimum length cannot exceed maximum length");
  }

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

  /**
   * The data type of each element in this array
   * @type {ElementType}
   */
  element;

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

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {
      required: true,
      nullable: false,
      empty: true,
      exact: undefined,
      min: 0,
      max: Infinity
    });
  }

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

  /** @override */
  static recursive = true;

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

  /**
   * Validate the contained element type of the ArrayField
   * @param {*} element        The type of Array element
   * @returns {ElementType}    The validated element type
   * @throws                   An error if the element is not a valid type
   * @protected
   */
  static _validateElementType(element) {
    if ( !(element instanceof DataField) ) {
      throw new Error(`${this.name} must have a DataField as its contained element`);
    }
    if ( element.parent !== undefined ) throw new Error("The element DataField already has a parent");
    return element;
  }

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

  /** @override */
  getInitialValue(data) {
    const initial = super.getInitialValue(data);
    if ( this.required && (initial === undefined) ) return [];
    return initial;
  }

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

  /** @override */
  _validateModel(changes, options) {
    if ( !this.element.constructor.recursive ) return;
    for ( const element of changes ) {
      this.element._validateModel(element, options);
    }
  }

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

  /** @override */
  _cast(value) {
    const t = getType(value);
    if ( t === "Object" ) {
      const arr = [];
      for ( const [k, v] of Object.entries(value) ) {
        const i = Number(k);
        if ( Number.isInteger(i) && (i >= 0) ) arr[i] = v;
      }
      return arr;
    }
    else if ( t === "Set" ) return Array.from(value);
    return value instanceof Array ? value : [value];
  }

  /** @override */
  _cleanType(value, options) {
    // Force partial as false for array cleaning. Arrays are updated by replacing the entire array, so partial data
    // must be initialized.
    return value.map(v => this.element.clean(v, { ...options, partial: false }));
  }

  /** @override */
  _validateType(value, options={}) {
    if ( !(value instanceof Array) ) throw new Error("must be an Array");
    if ( value.length < this.min ) throw new Error(`cannot have fewer than ${this.min} elements`);
    if ( value.length > this.max ) throw new Error(`cannot have more than ${this.max} elements`);
    return this._validateElements(value, options);
  }

  /**
   * Validate every element of the ArrayField
   * @param {Array} value                         The array to validate
   * @param {DataFieldValidationOptions} options  Validation options
   * @returns {DataModelValidationFailure|void}   A validation failure if any of the elements failed validation,
   *                                              otherwise void.
   * @protected
   */
  _validateElements(value, options) {
    const arrayFailure = new DataModelValidationFailure();
    for ( let i=0; i<value.length; i++ ) {
      // Force partial as false for array validation. Arrays are updated by replacing the entire array, so there cannot
      // be partial data in the elements.
      const failure = this._validateElement(value[i], { ...options, partial: false });
      if ( failure ) {
        arrayFailure.elements.push({id: i, failure});
        arrayFailure.unresolved ||= failure.unresolved;
      }
    }
    if ( arrayFailure.elements.length ) return arrayFailure;
  }

  /**
   * Validate a single element of the ArrayField.
   * @param {*} value                       The value of the array element
   * @param {DataFieldValidationOptions} options  Validation options
   * @returns {DataModelValidationFailure}  A validation failure if the element failed validation
   * @protected
   */
  _validateElement(value, options) {
    return this.element.validate(value, options);
  }

  /** @override */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    return value.map(v => this.element.initialize(v, model, options));
  }

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

  /** @override */
  _updateDiff(source, key, value, difference, options) {
    const current = source[key];
    value = applySpecialKeys(value);
    if ( (value === current) || value?.equals(current) ) return;
    source[key] = value;
    difference[key] = deepClone(value);
  }

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

  /**
   * Commit array field changes by replacing array contents while preserving the array reference itself.
   * @override
   */
  _updateCommit(source, key, value, diff, options) {
    const s = source[key];

    // Special Cases: * -> undefined, * -> null, undefined -> *, null -> *
    if ( !s || !value ) {
      source[key] = value;
      return;
    }

    s.length = 0;
    s.push(...value);
  }

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

  /** @override */
  toObject(value) {
    if ( !value ) return value;
    return value.map(v => this.element.toObject(v));
  }

  /** @override */
  apply(fn, value=[], options={}) {

    // Apply to this ArrayField
    const thisFn = typeof fn === "string" ? this[fn] : fn;
    thisFn?.call(this, value, options);
    if ( !Array.isArray(value) ) return value; // Do not recurse for non-array types

    // Recursively apply to array elements
    const results = [];
    if ( !value.length && options.initializeArrays ) value = [undefined];
    for ( const v of value ) {
      const r = this.element.apply(fn, v, options);
      if ( !options.filter || !isEmpty(r) ) results.push(r);
    }
    return results;
  }

  /** @override */
  _getField(path) {
    if ( path.length === 0 ) return this;
    if ( path.shift() !== this.element.name ) return undefined;
    return this.element._getField(path);
  }

  /**
   * Migrate this field's candidate source data.
   * @param {object} sourceData   Candidate source data of the root model
   * @param {any} fieldData       The value of this field within the source data
   */
  migrateSource(sourceData, fieldData) {
    if ( !(this.element.migrateSource instanceof Function) ) return;
    if ( getType(fieldData) !== "Array" ) return;
    for ( const entry of fieldData ) this.element.migrateSource(sourceData, entry);
  }

  /* -------------------------------------------- */
  /*  Active Effect Integration                   */
  /* -------------------------------------------- */

  /** @override */
  _castChangeDelta(raw) {
    let delta;
    try {
      delta = JSON.parse(raw);
      delta = Array.isArray(delta) ? delta : [delta];
    } catch(_err) {
      delta = [raw];
    }
    return delta.map(value => this.element._castChangeDelta(value));
  }

  /** @override */
  _applyChangeAdd(value, delta, model, change) {
    value.push(...delta);
    return value;
  }
}

/* -------------------------------------------- */
/*  Specialized Field Types                     */
/* -------------------------------------------- */

/**
 * A subclass of {@link foundry.data.fields.ArrayField} which supports a set of contained elements.
 * Elements in this set are treated as fungible and may be represented in any order or discarded if invalid.
 */
class SetField extends ArrayField {

  /** @override */
  _validateElements(value, options) {
    const setFailure = new DataModelValidationFailure();
    for ( let i=value.length-1; i>=0; i-- ) {  // Iterate backwards so we can splice as we go
      const failure = this._validateElement(value[i], options);
      if ( failure ) {
        setFailure.elements.unshift({id: i, failure});

        // The failure may have been internally resolved by fallback logic
        if ( !failure.unresolved && failure.fallback ) continue;

        // If fallback is allowed, remove invalid elements from the set
        if ( options.fallback ) {
          value.splice(i, 1);
          failure.dropped = true;
        }

        // Otherwise the set failure is unresolved
        else setFailure.unresolved = true;
      }
    }

    // Return a record of any failed set elements
    if ( setFailure.elements.length ) {
      if ( options.fallback && !setFailure.unresolved ) setFailure.fallback = value;
      return setFailure;
    }
  }

  /** @override */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    return new Set(super.initialize(value, model, options));
  }

  /** @override */
  toObject(value) {
    if ( !value ) return value;
    return Array.from(value).map(v => this.element.toObject(v));
  }

  /* -------------------------------------------- */
  /*  Form Field Integration                      */
  /* -------------------------------------------- */

  /** @override */
  _toInput(config) {
    const element = this.element;

    // Document UUIDs
    if ( element instanceof DocumentUUIDField ) {
      Object.assign(config, {type: element.type, single: false});
      return foundry.applications.elements.HTMLDocumentTagsElement.create(config);
    }

    // Multi-Select Input
    if ( element.choices && !config.options ) {
      config.choices ??= element.choices;
      StringField._prepareChoiceConfig(config);
    }
    if ( config.options ) {
      if ( element instanceof NumberField ) mergeObject(config, {dataset: {dtype: "Number"}});
      return foundry.applications.fields.createMultiSelectInput(config);
    }

    // Arbitrary String Tags
    if ( element instanceof StringField ) return foundry.applications.elements.HTMLStringTagsElement.create(config);
    throw new Error(`SetField#toInput is not supported for a ${element.constructor.name} element type`);
  }

  /* -------------------------------------------- */
  /*  Active Effect Integration                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _castChangeDelta(raw) {
    return new Set(super._castChangeDelta(raw));
  }

  /** @override */
  _applyChangeAdd(value, delta, model, change) {
    for ( const element of delta ) value.add(element);
    return value;
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.SchemaField} which embeds some other DataModel definition as an inner
 * object.
 */
class EmbeddedDataField extends SchemaField {
  /**
   * @param {typeof DataModel} model          The class of DataModel which should be embedded in this field
   * @param {DataFieldOptions} [options]      Options which configure the behavior of the field
   * @param {DataFieldContext} [context]      Additional context which describes the field
   */
  constructor(model, options={}, context={}) {
    if ( !isSubclass(model, foundry.abstract.DataModel) ) {
      throw new Error("An EmbeddedDataField must specify a DataModel class as its type");
    }

    // Create an independent copy of the model schema
    const fields = model.defineSchema();
    super(fields, options, context);

    /**
     * The base DataModel definition which is contained in this field.
     * @type {typeof DataModel}
     */
    this.model = model;
  }

  /** @inheritdoc */
  clean(value, options) {
    return super.clean(value, {...options, source: value});
  }

  /** @override */
  _cast(value) {
    if ( value.toObject instanceof Function ) value = value.toObject();
    return getType(value) === "Object" ? value : {};
  }

  /** @inheritdoc */
  validate(value, options) {
    return super.validate(value, {...options, source: value});
  }

  /** @override */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    // FIXME it should be unnecessary to construct a new instance of the model every time we initialize.
    const m = new this.model(value, {parent: model, ...options});
    Object.defineProperty(m, "schema", {value: this});
    return m;
  }

  /** @override */
  toObject(value) {
    if ( !value ) return value;
    return value.toObject(false);
  }

  /** @override */
  migrateSource(sourceData, fieldData) {
    if ( getType(fieldData) !== "Object" ) return;
    this.model.migrateDataSafe(fieldData);
  }

  /** @override */
  _validateModel(changes, options) {
    this.model.validateJoint(changes);
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.ArrayField} which supports an embedded Document collection.
 * Invalid elements will be dropped from the collection during validation rather than failing for the field entirely.
 * @extends {ArrayField<typeof Document>}
 */
class EmbeddedCollectionField extends ArrayField {
  /**
   * @param {typeof Document} element     The type of Document which belongs to this embedded collection
   * @param {DataFieldOptions} [options]  Options which configure the behavior of the field
   * @param {DataFieldContext} [context]  Additional context which describes the field
   */
  constructor(element, options={}, context={}) {
    super(element, options, context);
    this.readonly = true; // Embedded collections are always immutable
  }

  /** @override */
  static _validateElementType(element) {
    if ( isSubclass(element, foundry.abstract.Document) ) return element;
    throw new Error("An EmbeddedCollectionField must specify a Document subclass as its type");
  }

  /**
   * The Collection implementation to use when initializing the collection.
   * @type {typeof EmbeddedCollection}
   */
  static get implementation() {
    return EmbeddedCollection;
  }

  /** @override */
  static hierarchical = true;

  /**
   * A reference to the DataModel subclass of the embedded document element
   * @type {typeof Document}
   */
  get model() {
    return this.element.implementation;
  }

  /**
   * The DataSchema of the contained Document model.
   * @type {SchemaField}
   */
  get schema() {
    return this.model.schema;
  }

  /** @inheritDoc */
  _cast(value) {
    if ( getType(value) !== "Map" ) return super._cast(value);
    const arr = [];
    for ( const [id, v] of value.entries() ) {
      if ( !("_id" in v) ) v._id = id;
      arr.push(v);
    }
    return super._cast(arr);
  }

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

  /** @override */
  _cleanType(value, options={}) {
    if ( options.recursive === false ) options = {...options, partial: false};
    return value.map(v => this._cleanElement(v, options));
  }

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

  /**
   * Clean data for an individual element in the collection.
   * @param {object} value      Unclean data for the candidate embedded record
   * @param {object} options    Options which control how data is cleaned
   * @returns {object}          Cleaned data for the candidate embedded record
   * @protected
   */
  _cleanElement(value, options={}) {
    if ( !options.partial ) value._id ||= randomID(16); // Should this be left to the server side?
    return this.schema.clean(value, {...options, source: value});
  }

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

  /** @override */
  _validateElements(value, options) {
    const collectionFailure = new DataModelValidationFailure();
    for ( const v of value ) {
      const failure = this.schema.validate(v, {...options, source: v});
      if ( failure && !options.dropInvalidEmbedded ) {
        collectionFailure.elements.push({id: v._id, name: v.name, failure});
        collectionFailure.unresolved ||= failure.unresolved;
      }
    }
    if ( collectionFailure.elements.length ) return collectionFailure;
  }

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

  /** @override */
  initialize(value, model, options={}) {
    const collection = model.collections[this.name];
    collection.initialize(options);
    return collection;
  }

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

  /**
   * Dry-run an update of an EmbeddedCollection, modifying the contents of the safe copy of the source data.
   * @override
   */
  _updateDiff(source, key, value, difference, options) {
    if ( !Array.isArray(value) ) return;

    // Non-recursive updates replace the entire array
    if ( options.recursive === false ) {
      value = applySpecialKeys(value);
      source[key] = value;
      difference[key] = deepClone(value);
      return;
    }

    // Otherwise create or diff individual array members
    const sourceIdMap = {};
    for ( const obj of source[key] ?? [] ) sourceIdMap[obj._id] = obj;
    const diffArray = difference[key] = [];
    for ( const v of value ) {

      // Get the diff for each existing record
      const existing = sourceIdMap[v._id];
      if ( existing ) {
        const elementDiff = {};
        const typeChanged = "type" in v;
        this.schema._addTypes(existing, v);
        this.schema._updateDiff({_source: existing}, "_source", v, elementDiff, options);
        const d = elementDiff._source || {};
        if ( !isEmpty(d) ) {
          d._id = v._id;
          diffArray.push(d);
        }
        if ( !typeChanged ) delete v.type;
      }

      // Create new records using cleaned data
      else {
        const created = this._cleanElement(applySpecialKeys(v), {partial: false});
        source[key].push(created);
        diffArray.push(created);
      }
    }
    if ( !diffArray.length ) delete difference[key];
  }

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

  /** @override */
  _updateCommit(source, key, value, diff, options) {
    const src = source[key];

    // Special Cases: * -> undefined, * -> null, undefined -> *, null -> *
    if ( !src || !value ) {
      source[key] = value;
      return;
    }

    // Map the existing source objects
    const existing = {};
    for ( const obj of src ) existing[obj._id] = obj;
    const changed = {};
    for ( const obj of diff ) changed[obj._id] = obj;

    // Reconstruct the source array, retaining object references
    src.length = 0;
    for ( const obj of value ) {
      const prior = existing[obj._id];
      if ( prior ) {
        const d = changed[obj._id];
        if ( d ) this.schema._updateCommit({_source: prior}, "_source", obj, d, options);
        src.push(prior);
      }
      else src.push(obj);
    }
  }

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

  /** @override */
  toObject(value) {
    return value.toObject(false);
  }

  /** @override */
  apply(fn, value=[], options={}) {

    // Include this field in the options since it's not introspectable from the SchemaField
    options = {...options, collection: this};

    // Apply to this EmbeddedCollectionField
    const thisFn = typeof fn === "string" ? this[fn] : fn;
    thisFn?.call(this, value, options);

    // Recursively apply to inner fields
    const results = [];
    if ( !value.length && options.initializeArrays ) value = [undefined];
    for ( const v of value ) {
      const r = this.schema.apply(fn, v, options);
      if ( !options.filter || !isEmpty(r) ) results.push(r);
    }
    return results;
  }

  /**
   * Migrate this field's candidate source data.
   * @param {object} sourceData   Candidate source data of the root model
   * @param {any} fieldData       The value of this field within the source data
   */
  migrateSource(sourceData, fieldData) {
    if ( !Array.isArray(fieldData) ) return;
    for ( const entry of fieldData ) {
      if ( getType(entry) !== "Object" ) continue;
      this.model.migrateDataSafe(entry);
    }
  }

  /* -------------------------------------------- */
  /*  Embedded Document Operations                */
  /* -------------------------------------------- */

  /**
   * Return the embedded document(s) as a Collection.
   * @param {Document} parent  The parent document.
   * @returns {DocumentCollection}
   */
  getCollection(parent) {
    return parent[this.name];
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.EmbeddedCollectionField} which manages a collection of delta objects
 * relative to another collection.
 */
class EmbeddedCollectionDeltaField extends EmbeddedCollectionField {
  /** @override */
  static get implementation() {
    return EmbeddedCollectionDelta;
  }

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

  /** @override */
  _cleanElement(value, options={}) {
    const schema = value._tombstone ? foundry.data.TombstoneData.schema : this.schema;
    if ( !value._tombstone && !options.partial ) value._id ||= randomID(16); // Should this be left to the server side?
    return schema.clean(value, {...options, source: value});
  }

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

  /** @override */
  _validateElements(value, options) {
    const collectionFailure = new DataModelValidationFailure();
    for ( const v of value ) {
      const validationOptions = {...options, source: v};
      const schema = v._tombstone ? foundry.data.TombstoneData.schema : this.schema;
      const failure = schema.validate(v, validationOptions);
      if ( failure && !options.dropInvalidEmbedded ) {
        collectionFailure.elements.push({id: v._id, name: v.name, failure});
        collectionFailure.unresolved ||= failure.unresolved;
      }
    }
    if ( collectionFailure.elements.length ) return collectionFailure;
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.EmbeddedDataField} which supports a single embedded Document.
 */
class EmbeddedDocumentField extends EmbeddedDataField {
  /**
   * @param {typeof Document} model       The type of Document which is embedded.
   * @param {DataFieldOptions} [options]  Options which configure the behavior of the field.
   * @param {DataFieldContext} [context]  Additional context which describes the field
   */
  constructor(model, options={}, context={}) {
    if ( !isSubclass(model, foundry.abstract.Document) ) {
      throw new Error("An EmbeddedDocumentField must specify a Document subclass as its type.");
    }
    super(model.implementation, options, context);
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {nullable: true});
  }

  /** @override */
  static hierarchical = true;

  /** @override */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    if ( model[this.name] ) {
      model[this.name]._initialize(options);
      return model[this.name];
    }
    const m = new this.model(value, {...options, parent: model, parentCollection: this.name});
    Object.defineProperty(m, "schema", {value: this});
    return m;
  }

  /* -------------------------------------------- */
  /*  Embedded Document Operations                */
  /* -------------------------------------------- */

  /**
   * Return the embedded document(s) as a Collection.
   * @param {Document} parent  The parent document.
   * @returns {Collection<string, Document>}
   */
  getCollection(parent) {
    const collection = new SingletonEmbeddedCollection(this.name, parent, []);
    const doc = parent[this.name];
    if ( !doc ) return collection;
    collection.set(doc.id, doc);
    return collection;
  }
}

/* -------------------------------------------- */
/*  Special Field Types                         */
/* -------------------------------------------- */

/**
 * A subclass of {@link foundry.data.fields.StringField} which provides the primary _id for a Document.
 * The field may be initially null, but it must be non-null when it is saved to the database.
 */
class DocumentIdField extends StringField {

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {
      required: true,
      blank: false,
      nullable: true,
      readonly: true,
      validationError: "is not a valid Document ID string"
    });
  }

  /** @override */
  _cast(value) {
    if ( value instanceof foundry.abstract.Document ) return value._id;
    else return String(value);
  }

  /** @override */
  _validateType(value, options) {
    if ( !isValidId(value) ) throw new Error("must be a valid 16-character alphanumeric ID");
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.StringField} which supports referencing some other Document by its UUID.
 * This field may not be blank, but may be null to indicate that no UUID is referenced.
 */
class DocumentUUIDField extends StringField {
  /**
   * @param {DocumentUUIDFieldOptions} [options] Options which configure the behavior of the field
   * @param {DataFieldContext} [context]    Additional context which describes the field
   */
  constructor(options, context) {
    super(options, context);
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {
      required: true,
      blank: false,
      nullable: true,
      initial: null,
      type: undefined,
      embedded: undefined
    });
  }

  /** @override */
  _validateType(value) {
    const p = parseUuid(value);
    if ( this.type ) {
      if ( p.type !== this.type ) throw new Error(`Invalid document type "${p.type}" which must be a "${this.type}"`);
    }
    else if ( p.type && !ALL_DOCUMENT_TYPES.includes(p.type) ) throw new Error(`Invalid document type "${p.type}"`);
    if ( (this.embedded === true) && !p.embedded.length ) throw new Error("must be an embedded document");
    if ( (this.embedded === false) && p.embedded.length ) throw new Error("may not be an embedded document");
    if ( !isValidId(p.documentId) ) throw new Error(`Invalid document ID "${p.documentId}"`);
  }

  /* -------------------------------------------- */
  /*  Form Field Integration                      */
  /* -------------------------------------------- */

  /** @override */
  _toInput(config) {
    Object.assign(config, {type: this.type, single: true});
    return foundry.applications.elements.HTMLDocumentTagsElement.create(config);
  }
}

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

/**
 * A special class of {@link foundry.data.fields.StringField} field which references another DataModel by its id.
 * This field may also be null to indicate that no foreign model is linked.
 */
class ForeignDocumentField extends DocumentIdField {
  /**
   * @param {typeof Document} model  The foreign DataModel class definition which this field links to
   * @param {StringFieldOptions} [options]    Options which configure the behavior of the field
   * @param {DataFieldContext} [context]      Additional context which describes the field
   */
  constructor(model, options={}, context={}) {
    super(options, context);
    if ( !isSubclass(model, foundry.abstract.DataModel) ) {
      throw new Error("A ForeignDocumentField must specify a DataModel subclass as its type");
    }
    /**
     * A reference to the model class which is stored in this field
     * @type {typeof Document}
     */
    this.model = model;
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {nullable: true, readonly: false, idOnly: false});
  }

  /** @override */
  _cast(value) {
    if ( typeof value === "string" ) return value;
    if ( (value instanceof this.model) ) return value._id;
    throw new Error(`The value provided to a ForeignDocumentField must be a ${this.model.name} instance.`);
  }

  /** @inheritdoc */
  initialize(value, model, options={}) {
    if ( this.idOnly ) return value;
    if ( model?.pack && !foundry.utils.isSubclass(this.model, foundry.documents.BaseFolder) ) return null;
    if ( !game.collections ) return value; // Server-side
    return () => this.model?.get(value, {pack: model?.pack, ...options}) ?? null;
  }

  /** @inheritdoc */
  toObject(value) {
    return value?._id ?? value;
  }

  /* -------------------------------------------- */
  /*  Form Field Integration                      */
  /* -------------------------------------------- */

  /** @override */
  _toInput(config) {
    config.choices ??= this.choices;

    // Prepare passed choices
    StringField._prepareChoiceConfig(config);

    // Prepare visible Document instances as options
    const collection = game.collections.get(this.model.documentName);
    if ( collection && !config.options ) {
      const current = collection.get(config.value);
      let hasCurrent = false;
      const options = collection.reduce((arr, doc) => {
        if ( !doc.visible ) return arr;
        if ( doc === current ) hasCurrent = true;
        arr.push({value: doc.id, label: doc.name});
        return arr;
      }, []);
      if ( current && !hasCurrent ) options.unshift({value: config.value, label: current.name});
      config.options = options;
    }

    // Allow blank
    if ( !this.required || this.nullable ) config.blank ??= "";

    // Create select input
    return foundry.applications.fields.createSelectInput(config);
  }
}

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

/**
 * A special {@link foundry.data.fields.StringField} which records a standardized CSS color string.
 */
class ColorField extends StringField {

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {nullable: true, initial: null, blank: false});
  }

  /** @override */
  initialize(value, model, options={}) {
    if ( (value === null) || (value === undefined) ) return value;
    return Color.from(value);
  }

  /** @override */
  _cast(value) {
    return Color.from(value).css;
  }

  /** @inheritdoc */
  _validateType(value, options) {
    if ( !isColorString(value) ) throw new Error("must be a valid color string");
    return super._validateType(value, options);
  }

  /* -------------------------------------------- */
  /*  Form Field Integration                      */
  /* -------------------------------------------- */

  /** @override */
  _toInput(config) {
    if ( (config.placeholder === undefined) && !this.nullable && !(this.initial instanceof Function) ) {
      config.placeholder = this.initial;
    }
    return foundry.applications.elements.HTMLColorPickerElement.create(config);
  }
}

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

/**
 * A special {@link foundry.data.fields.StringField} which records a file path or inline base64 data.
 *
 * When using the `FilePathField` in a data model that is persisted to the database, for example a Document sub-type, it is essential to declare this field in the package manifest so that it receives proper server-side validation of its contents.
 * See {@link foundry.packages.types.ServerSanitizationFields} for information about this structure.
 *
 * @property {string[]} categories      A set of categories in CONST.FILE_CATEGORIES which this field supports
 * @property {boolean} base64=false     Is embedded base64 data supported in lieu of a file path?
 * @property {boolean} texture=false    Does the file path field allow specifying a virtual file path which must begin
 *                                      with the "#" character?
 * @property {boolean} wildcard=false   Does this file path field allow wildcard characters?
 */
class FilePathField extends StringField {
  /**
   * @param {FilePathFieldOptions} [options]  Options which configure the behavior of the field
   * @param {DataFieldContext} [context]      Additional context which describes the field
   */
  constructor(options={}, context={}) {
    super(options, context);
    if ( this.categories.includes("MEDIA") ) {
      foundry.utils.logCompatibilityWarning('The "MEDIA" file category is deprecated. '
        + "Use CONST.MEDIA_FILE_CATEGORIES instead.", {since: 13, until: 15, once: true});
      this.categories = Array.from(new Set(this.categories.filter(c => c !== "MEDIA").concat(CONST.MEDIA_FILE_CATEGORIES)));
      if ( "categories" in this.options ) this.options.categories = this.categories;
    }
    if ( !this.categories.length || this.categories.some(c => !(c in FILE_CATEGORIES)) ) {
      throw new Error("The categories of a FilePathField must be keys in CONST.FILE_CATEGORIES");
    }
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {
      categories: [],
      base64: false,
      wildcard: false,
      virtual: false,
      nullable: true,
      blank: false,
      initial: null
    });
  }

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

  /** @inheritdoc */
  _validateType(value) {

    // Wildcard or virtual paths
    if ( this.virtual && (value[0] === "#") && value.length > 1 ) return true;
    if ( this.wildcard && value.includes("*") ) return true;

    // Allowed extension or base64
    const isValid = this.categories.some(c => {
      const category = FILE_CATEGORIES[c];
      if ( hasFileExtension(value, Object.keys(category)) ) return true;
      return isBase64Data(value, Object.values(category));
    });

    // Throw an error for invalid paths
    if ( !isValid ) {
      let err = "does not have a valid file extension";
      if ( this.base64 ) err += " or provide valid base64 data";
      throw new Error(err);
    }
  }

  /* -------------------------------------------- */
  /*  Form Field Integration                      */
  /* -------------------------------------------- */

  /** @override */
  _toInput(config) {
    // FIXME: This logic is fragile and would require a mapping between CONST.FILE_CATEGORIES and FilePicker.TYPES
    config.type = this.categories.length === 1 ? this.categories[0].toLowerCase() : "any";
    return foundry.applications.elements.HTMLFilePickerElement.create(config);
  }
}

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

/**
 * A special {@link foundry.data.fields.NumberField} which represents an angle of rotation in degrees between 0 and 360.
 * @property {boolean} normalize Whether the angle should be normalized to [0,360) before being clamped to [0,360]. The
 *                               default is true.
 */
class AngleField extends NumberField {
  constructor(options={}, context={}) {
    super(options, context);
    if ( "base" in this.options ) this.base = this.options.base;
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {
      required: true,
      nullable: false,
      initial: 0,
      normalize: true,
      min: 0,
      max: 360,
      validationError: "is not a number between 0 and 360"
    });
  }

  /** @inheritdoc */
  _cast(value) {
    value = super._cast(value);
    if ( !this.normalize ) return value;
    value = Math.normalizeDegrees(value);
    /** @deprecated since v12 */
    if ( (this.#base === 360) && (value === 0) ) value = 360;
    return value;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get base() {
    const msg = "The AngleField#base is deprecated in favor of AngleField#normalize.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.#base;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  set base(v) {
    const msg = "The AngleField#base is deprecated in favor of AngleField#normalize.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    this.#base = v;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  #base = 0;
}

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

/**
 * A special {@link foundry.data.fields.NumberField} represents a number between 0 and 1.
 */
class AlphaField extends NumberField {
  static get _defaults() {
    return Object.assign(super._defaults, {
      required: true,
      nullable: false,
      initial: 1,
      min: 0,
      max: 1,
      validationError: "is not a number between 0 and 1"
    });
  }
}

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

/**
 * A special {@link foundry.data.fields.NumberField} represents a number between 0 (inclusive) and 1 (exclusive).
 * Its values are normalized (modulo 1) to the range [0, 1) instead of being clamped.
 */
class HueField extends NumberField {
  static get _defaults() {
    return Object.assign(super._defaults, {
      required: true,
      nullable: false,
      initial: 0,
      min: 0,
      max: 1,
      validationError: "is not a number between 0 (inclusive) and 1 (exclusive)"
    });
  }

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

  /** @inheritdoc */
  _cast(value) {
    value = super._cast(value) % 1;
    if ( value < 0 ) value += 1;
    return value;
  }

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

  /** @override */
  _toInput(config) {
    return foundry.applications.elements.HTMLHueSelectorSlider.create(config);
  }
}

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

/**
 * A special {@link foundry.data.fields.ForeignDocumentField} which defines the original author of a document.
 * This can only be changed later by GM users.
 */
class DocumentAuthorField extends ForeignDocumentField {

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {
      nullable: false,
      gmOnly: true,
      label: "Author",
      initial: () => game.user?.id
    });
  }
}

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

/**
 * A special {@link foundry.data.fields.ObjectField} which captures a mapping of User IDs to Document permission levels.
 */
class DocumentOwnershipField extends ObjectField {

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {
      initial: {default: DOCUMENT_OWNERSHIP_LEVELS.NONE},
      validationError: "is not a mapping of user IDs and document permission levels",
      gmOnly: true
    });
  }

  /** @override */
  _validateType(value) {
    for ( const [k, v] of Object.entries(value) ) {
      if ( k.startsWith("-=") ) return isValidId(k.slice(2)) && (v === null);   // Allow removals
      if ( (k !== "default") && !isValidId(k) ) return false;
      if ( !Object.values(DOCUMENT_OWNERSHIP_LEVELS).includes(v) ) return false;
    }
  }
}

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

/**
 * A special {@link foundry.data.fields.StringField} which contains serialized JSON data.
 */
class JSONField extends StringField {
  constructor(options, context) {
    super(options, context);
    this.blank = false;
    this.trim = false;
    this.choices = undefined;
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {
      blank: false,
      trim: false,
      initial: undefined,
      validationError: "is not a valid JSON string"
    });
  }

  /** @inheritdoc */
  clean(value, options) {
    if ( value === "" ) return '""';  // Special case for JSON fields
    return super.clean(value, options);
  }

  /** @override */
  _cast(value) {
    if ( (typeof value !== "string") || !isJSON(value) ) return JSON.stringify(value);
    return value;
  }

  /** @override */
  _validateType(value, options) {
    if ( (typeof value !== "string") || !isJSON(value) ) throw new Error("must be a serialized JSON string");
  }

  /** @override */
  initialize(value, model, options={}) {
    if ( (value === undefined) || (value === null) ) return value;
    return JSON.parse(value);
  }

  /** @override */
  toObject(value) {
    if ( (value === undefined) || (this.nullable && (value === null)) ) return value;
    return JSON.stringify(value);
  }

  /* -------------------------------------------- */
  /*  Form Field Integration                      */
  /* -------------------------------------------- */

  /**
   * @param {FormInputConfig & CodeMirrorInputConfig} config
   * @override
   */
  _toInput(config) {
    config.language = "json";
    config.indent ??= 2;
    config.value = foundry.data.validators.isJSON(config.value)
      ? JSON.stringify(JSON.parse(config.value), null, config.indent)
      : JSON.stringify(config.value, null, config.indent);
    return foundry.applications.elements.HTMLCodeMirrorElement.create(config);
  }
}

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

/**
 * A special subclass of {@link foundry.data.fields.DataField} which can contain any value of any type.
 * Any input is accepted and is treated as valid.
 * It is not recommended to use this class except for very specific circumstances.
 */
class AnyField extends DataField {

  /** @override */
  _validateType(value) {
    return true;
  }
}


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

/**
 * A subclass of {@link foundry.data.fields.StringField} which contains a sanitized HTML string.
 * This class does not override any StringField behaviors, but is used by the server-side to identify fields which
 * require sanitization of user input.
 *
 * When using the `HTMLField` in a data model that is persisted to the database, for example a Document sub-type, it is essential to declare this field in the package manifest so that it receives proper server-side validation of its contents.
 * See {@link foundry.packages.types.ServerSanitizationFields} for information about this structure.
 */
class HTMLField extends StringField {

  /** @inheritDoc */
  static get _defaults() {
    return Object.assign(super._defaults, {required: true, blank: true});
  }

  /** @inheritDoc */
  toFormGroup(groupConfig={}, inputConfig={}) {
    groupConfig.stacked ??= inputConfig.elementType !== "input";
    return super.toFormGroup(groupConfig, inputConfig);
  }

  /** @inheritDoc */
  _toInput(config) {
    config.elementType ??= "prose-mirror";
    return super._toInput(config);
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.NumberField} which is used for storing integer sort keys.
 */
class IntegerSortField extends NumberField {
  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {
      required: true,
      nullable: false,
      integer: true,
      initial: 0
    });
  }
}
/* ---------------------------------------- */

/**
 * A subclass of {@link foundry.data.fields.TypedObjectField} that is used specifically for the Document "flags" field.
 */
class DocumentFlagsField extends TypedObjectField {
  /**
   * @param {DataFieldOptions} [options]    Options which configure the behavior of the field
   * @param {DataFieldContext} [context]    Additional context which describes the field
   */
  constructor(options, context) {
    super(new ObjectField(), options, context);
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {
      validateKey: k => {
        try {
          foundry.packages.BasePackage.validateId(k);
        } catch {
          return false;
        }
        return true;
      }
    });
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.SchemaField} which stores document metadata in the _stats field.
 * @mixes DocumentStats
 */
class DocumentStatsField extends SchemaField {
  /**
   * @param {DataFieldOptions} [options]        Options which configure the behavior of the field
   * @param {DataFieldContext} [context]        Additional context which describes the field
   */
  constructor(options={}, context={}) {
    super({
      coreVersion: new StringField({required: true, blank: false, nullable: true, initial: () => game.release.version}),
      systemId: new StringField({required: true, blank: false, nullable: true, initial: () => game.system?.id ?? null}),
      systemVersion: new StringField({
        required: true,
        blank: false,
        nullable: true,
        initial: () => game.system?.version ?? null
      }),
      createdTime: new NumberField(),
      modifiedTime: new NumberField(),
      lastModifiedBy: new ForeignDocumentField(foundry.documents.BaseUser, {idOnly: true}),
      compendiumSource: new DocumentUUIDField(),
      duplicateSource: new DocumentUUIDField(),
      exportSource: new SchemaField({
        worldId: new StringField({required: true, blank: false, nullable: true}),
        uuid: new DocumentUUIDField({initial: undefined}),
        coreVersion: new StringField({required: true, blank: false, nullable: true}),
        systemId: new StringField({required: true, blank: false, nullable: true}),
        systemVersion: new StringField({required: true, blank: false, nullable: true})
      }, {nullable: true})
    }, options, context);
  }

  /**
   * All Document stats.
   * @type {string[]}
   */
  static fields = [
    "coreVersion", "systemId", "systemVersion", "createdTime", "modifiedTime", "lastModifiedBy", "compendiumSource",
    "duplicateSource", "exportSource"
  ];

  /**
   * These fields are managed by the server and are ignored if they appear in creation or update data.
   * @type {string[]}
   */
  static managedFields = ["coreVersion", "systemId", "systemVersion", "createdTime", "modifiedTime", "lastModifiedBy"];

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

  /**
   * Migrate deprecated core flags to `_stats` properties.
   * @param {typeof Document} document
   * @param {object} source
   * @internal
   */
  static _migrateData(document, source) {

    /**
     * Migrate flags.core.sourceId.
     * @deprecated since v12
     */
    document._addDataFieldMigration(source, "flags.core.sourceId", "_stats.compendiumSource");

    /**
     * Migrate flags.exportSource.
     * @deprecated since v13
     */
    if ( source.flags ) {
      document._addDataFieldMigration(source, "flags.exportSource", "_stats.exportSource", d => {
        const exportSource = foundry.utils.getProperty(d, "flags.exportSource");
        if ( !exportSource ) return null;
        return {
          worldId: exportSource.world ?? null,
          uuid: null,
          coreVersion: exportSource.coreVersion ?? null,
          systemId: exportSource.system ?? null,
          systemVersion: exportSource.systemVersion ?? null
        };
      });
    }
  }

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

  /**
   * Shim the deprecated core flag `exportSource` on Document source data.
   * @param {typeof Document} document
   * @param {object} source
   * @param {object} [options]
   * @internal
   */
  static _shimData(document, source, options) {
    if ( source.flags ) {
      /**
       * Shim flags.exportSource.
       * @deprecated since v13
       */
      Object.defineProperty(source.flags, "exportSource", {
        get: () => {
          document._logDataFieldMigration("flags.exportSource", "_stats.exportSource", {since: 13, until: 15});
          const exportSource = source._stats.exportSource;
          if ( !exportSource ) return undefined;
          return {
            world: exportSource.worldId,
            coreVersion: exportSource.coreVersion,
            system: exportSource.systemId,
            systemVersion: exportSource.systemVersion
          };
        },
        configurable: true,
        enumerable: false
      });
    }
  }

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

  /**
   * Shim the deprecated core flag `exportSource` on Documents.
   * @param {typeof Document} document
   * @internal
   */
  static _shimDocument(document) {
    /**
     * Shim flags.exportSource.
     * @deprecated since v13
     */
    Object.defineProperty(document.flags, "exportSource", {
      get: () => {
        document.constructor._logDataFieldMigration("flags.exportSource", "_stats.exportSource", {since: 13, until: 15});
        const exportSource = document._stats.exportSource;
        if ( !exportSource ) return undefined;
        return {
          world: exportSource.worldId,
          coreVersion: exportSource.coreVersion,
          system: exportSource.systemId,
          systemVersion: exportSource.systemVersion
        };
      },
      configurable: true,
      enumerable: false
    });
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.StringField} that is used specifically for the Document "type" field.
 */
class DocumentTypeField extends StringField {
  /**
   * @param {typeof Document} documentClass  The base document class which belongs in this field
   * @param {StringFieldOptions} [options]  Options which configure the behavior of the field
   * @param {DataFieldContext} [context]    Additional context which describes the field
   */
  constructor(documentClass, options={}, context={}) {
    options.choices = () => documentClass.TYPES;
    options.validationError = `is not a valid type for the ${documentClass.documentName} Document class`;
    super(options, context);
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {required: true, nullable: false, blank: false});
  }

  /** @override */
  _validateType(value, options) {
    if ( (typeof value !== "string") || !value ) throw new Error("must be a non-blank string");
    if ( this._isValidChoice(value) ) return true;
    // Allow unrecognized types if we are allowed to fallback (non-strict validation)
    if (options.fallback ) return true;
    throw new Error(`"${value}" ${this.options.validationError}`);
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.ObjectField} which supports a type-specific data object.
 */
class TypeDataField extends ObjectField {
  /**
   * @param {typeof Document} document      The base document class which belongs in this field
   * @param {DataFieldOptions} [options]    Options which configure the behavior of the field
   * @param {DataFieldContext} [context]    Additional context which describes the field
   */
  constructor(document, options={}, context={}) {
    super(options, context);
    /**
     * The canonical document name of the document type which belongs in this field
     * @type {typeof Document}
     */
    this.document = document;
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {required: true});
  }

  /** @override */
  static recursive = true;

  /**
   * Return the package that provides the sub-type for the given model.
   * @param {DataModel} model       The model instance created for this sub-type.
   * @returns {System|Module|null}
   */
  static getModelProvider(model) {
    const document = model.parent;
    if ( !document ) return null;
    const documentClass = document.constructor;
    const documentName = documentClass.documentName;
    const type = document.type;

    // Unrecognized type
    if ( !documentClass.TYPES.includes(type) ) return null;

    // Core-defined sub-type
    const coreTypes = documentClass.metadata.coreTypes;
    if ( coreTypes.includes(type) ) return null;

    // System-defined sub-type
    const systemTypes = game.system.documentTypes[documentName];
    if ( systemTypes && (type in systemTypes) ) return game.system;

    // Module-defined sub-type
    const moduleId = type.substring(0, type.indexOf("."));
    return game.modules.get(moduleId) ?? null;
  }

  /**
   * A convenience accessor for the name of the document type associated with this TypeDataField
   * @type {string}
   */
  get documentName() {
    return this.document.documentName;
  }

  /**
   * Get the DataModel definition that should be used for this type of document.
   * @param {string} type              The Document instance type
   * @returns {typeof DataModel|null}  The DataModel class or null
   */
  getModelForType(type) {
    if ( !type ) return null;
    return globalThis.CONFIG?.[this.documentName]?.dataModels?.[type] ?? null;
  }

  /** @override */
  getInitialValue(data) {
    const initial = super.getInitialValue(data); // ObjectField could return this.initial, undefined, null, or {}
    if ( getType(initial) === "Object" ) return this._cleanType(initial, {partial: false, source: data});
    return initial;
  }

  /** @override */
  _cleanType(value, options) {
    if ( !(typeof value === "object") ) value = {};

    // Use a defined DataModel
    const type = options.source?.type;
    const cls = this.getModelForType(type);
    if ( cls ) return cls.cleanData(value, {...options, source: value});
    if ( options.partial ) return value;

    // Use the defined template.json
    const template = game?.model[this.documentName]?.[type] || {};
    const insertKeys = (type === BASE_DOCUMENT_TYPE) || !game?.system?.strictDataCleaning;
    return mergeObject(template, value, {insertKeys, inplace: false});
  }

  /** @override */
  initialize(value, model, options={}) {
    const cls = this.getModelForType(model._source.type);
    if ( cls ) {
      const instance = new cls(value, {parent: model, ...options});
      if ( !("modelProvider" in instance) ) Object.defineProperty(instance, "modelProvider", {
        value: this.constructor.getModelProvider(instance),
        writable: false
      });
      return instance;
    }
    return deepClone(value);
  }

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

  /** @inheritDoc */
  _updateDiff(source, key, value, difference, options) {
    const cls = this.getModelForType(source.type);
    if ( cls ) cls.schema._updateDiff(source, key, value, difference, options);
    else super._updateDiff(source, key, value, difference, options);
  }

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

  /** @inheritDoc */
  _updateCommit(source, key, value, diff, options) {
    const cls = this.getModelForType(source.type);
    if ( cls ) cls.schema._updateCommit(source, key, value, diff, options);
    else super._updateCommit(source, key, value, diff, options);
  }

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

  /** @inheritdoc */
  _validateType(data, options={}) {
    const result = super._validateType(data, options);
    if ( result !== undefined ) return result;
    const cls = this.getModelForType(options.source?.type);
    const schema = cls?.schema;
    return schema?.validate(data, {...options, source: data});
  }

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

  /** @override */
  _validateModel(changes, options={}) {
    const cls = this.getModelForType(options.source?.type);
    return cls?.validateJoint(changes);
  }

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

  /** @override */
  toObject(value) {
    return value.toObject instanceof Function ? value.toObject(false) : deepClone(value);
  }

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

  /** @override */
  _addTypes(source, changes, options={}) {
    const cls = this.getModelForType(options.changes?.type ?? options.source?.type);
    cls?.schema._addTypes(source, changes, options);
  }

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

  /**
   * Migrate this field's candidate source data.
   * @param {object} sourceData   Candidate source data of the root model
   * @param {any} fieldData       The value of this field within the source data
   */
  migrateSource(sourceData, fieldData) {
    if ( getType(fieldData) !== "Object" ) return;
    const cls = this.getModelForType(sourceData.type);
    if ( cls ) cls.migrateDataSafe(fieldData);
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.DataField} that defines a union of schema-constrained objects discriminable
 * via a `type` property.
 */
class TypedSchemaField extends DataField {
  /**
   * @param {Record<string, DataSchema|SchemaField|typeof DataModel>} types The different types this field can represent
   * @param {DataFieldOptions} [options]                                    Options for configuring the field
   * @param {DataFieldContext} [context]                                    Additional context describing the field
   */
  constructor(types, options, context) {
    super(options, context);
    this.types = this.#configureTypes(types);
  }

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

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {required: true});
  }

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

  /** @override */
  static recursive = true;

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

  /**
   * The types of this field.
   * @type {{[type: string]: SchemaField}}
   */
  types;

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

  /**
   * Initialize and validate the structure of the provided type definitions.
   * @param {{[type: string]: DataSchema|SchemaField|typeof DataModel}} types The provided field definitions
   * @returns {{[type: string]: SchemaField}}                                 The validated fields
   */
  #configureTypes(types) {
    if ( (typeof types !== "object") ) {
      throw new Error("A DataFields must be an object with string keys and DataField values.");
    }
    types = {...types};
    for ( let [type, field] of Object.entries(types) ) {
      if ( isSubclass(field, foundry.abstract.DataModel) ) field = new EmbeddedDataField(field, {name: type});
      if ( getType(field) === "Object" ) {
        const schema = {...field};
        if ( !("type" in schema) ) {
          schema.type = new StringField({required: true, blank: false, initial: type,
            validate: value => value === type, validationError: `must be equal to "${type}"`});
        }
        field = new SchemaField(schema, {name: type});
      }
      if ( !(field instanceof SchemaField) ) {
        throw new Error(`The "${type}" field is not an instance of the SchemaField class or a subclass of DataModel.`);
      }
      field.name ??= type;
      if ( field.name !== type ) throw new Error(`The name of the "${this.fieldPath}.${type}" field must be "${type}".`);
      if ( field.parent !== undefined ) {
        throw new Error(`The "${field.fieldPath}" field already belongs to some other parent and may not be reused.`);
      }
      types[type] = field;
      field.parent = this;
      if ( !field.required ) throw new Error(`The "${field.fieldPath}" field must be required.`);
      if ( field.nullable ) throw new Error(`The "${field.fieldPath}" field must not be nullable.`);
      const typeField = field.fields.type;
      if ( !(typeField instanceof StringField) ) throw new Error(`The "${field.fieldPath}" field must have a "type" StringField.`);
      if ( !typeField.required ) throw new Error(`The "${typeField.fieldPath}" field must be required.`);
      if ( typeField.nullable ) throw new Error(`The "${typeField.fieldPath}" field must not be nullable.`);
      if ( typeField.blank ) throw new Error(`The "${typeField.fieldPath}" field must not be blank.`);
      if ( typeField.validate(type, {fallback: false}) !== undefined ) throw new Error(`"${type}" must be a valid type of "${typeField.fieldPath}".`);
    }
    return types;
  }

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

  /**
   * Get the schema for the given type.
   * @param {*} type
   * @returns {SchemaField|void}
   */
  #getTypeSchema(type) {
    if ( typeof type !== "string" ) return;
    if ( !Object.hasOwn(this.types, type) ) return;
    return this.types[type];
  }

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

  /** @override */
  _getField(path) {
    if ( !path.length ) return this;
    return this.#getTypeSchema(path.shift())?._getField(path);
  }

  /* -------------------------------------------- */
  /*  Data Field Methods                          */
  /* -------------------------------------------- */

  /** @override */
  _cleanType(value, options) {
    const schema = this.#getTypeSchema(value?.type);
    if ( !schema ) return value;
    return schema.clean(value, options);
  }

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

  /** @override */
  _cast(value) {
    if ( value.toObject instanceof Function ) value = value.toObject();
    return getType(value) === "Object" ? value : {};
  }

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

  /** @override */
  _validateSpecial(value) {
    const result = super._validateSpecial(value);
    if ( result !== undefined ) return result;
    const schema = this.#getTypeSchema(value?.type);
    if ( !schema ) throw new Error("does not have a valid type");
  }

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

  /** @override */
  _validateType(value, options) {
    return this.types[value.type].validate(value, options);
  }

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

  /** @override */
  initialize(value, model, options) {
    const schema = this.#getTypeSchema(value?.type);
    if ( !schema ) return value;
    return schema.initialize(value, model, options);
  }

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

  /** @inheritDoc */
  _updateDiff(source, key, value, difference, options) {
    const sourceType = source[key]?.type;
    const valueType = value?.type;
    if ( value && (("==type" in value) || ("-=type" in value)) ) throw new Error("The type of a TypedSchemaField cannot be updated with ==type or -=type");
    if ( sourceType && valueType && (sourceType !== valueType) && (options.recursive !== false) ) {
      throw new Error("The type of a TypedSchemaField can be changed only by forced replacement (==) of the entire field value or with {recursive: false}");
    }
    const schema = this.#getTypeSchema(valueType);
    if ( schema ) schema._updateDiff(source, key, value, difference, options);
    else super._updateDiff(source, key, applySpecialKeys(value), difference, options);
  }

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

  /** @inheritDoc */
  _updateCommit(source, key, value, diff, options) {
    const schema = this.#getTypeSchema(value?.type);
    if ( schema ) {
      if ( "type" in diff ) source[key] = undefined;
      schema._updateCommit(source, key, value, diff, options);
    }
    else super._updateCommit(source, key, value, diff, options);
  }

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

  /** @override */
  toObject(value) {
    if ( !value ) return value;
    return this.#getTypeSchema(value.type)?.toObject(value) ?? value;
  }

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

  /** @override */
  apply(fn, data={}, options={}) {

    // Apply to this TypedSchemaField
    const thisFn = typeof fn === "string" ? this[fn] : fn;
    thisFn?.call(this, data, options);

    // Apply to the schema of the type
    const schema = this.#getTypeSchema(data.type);
    return schema?.apply(fn, data, options) ?? {};
  }

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

  /** @override */
  _addTypes(source, changes, options={}) {
    if ( getType(source) !== "Object" ) return;
    if ( getType(changes) !== "Object" ) return;
    const type = changes.type ??= source.type;
    this.#getTypeSchema(type)?._addTypes(source, changes, options);
  }

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

  /**
   * Migrate this field's candidate source data.
   * @param {object} sourceData   Candidate source data of the root model
   * @param {any} fieldData       The value of this field within the source data
   */
  migrateSource(sourceData, fieldData) {
    if ( getType(fieldData) !== "Object" ) return;
    const schema = this.#getTypeSchema(fieldData.type);
    const canMigrate = schema?.migrateSource instanceof Function;
    if ( canMigrate ) schema.migrateSource(sourceData, fieldData);
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.StringField} which contains JavaScript code.
 */
class JavaScriptField extends StringField {
  /**
   * @param {JavaScriptFieldOptions} [options] Options which configure the behavior of the field
   * @param {DataFieldContext} [context]    Additional context which describes the field
   */
  constructor(options, context) {
    super(options, context);
    this.choices = undefined;
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {required: true, blank: true, nullable: false, async: false});
  }

  /** @inheritdoc */
  _validateType(value, options) {
    const result = super._validateType(value, options);
    if ( result !== undefined ) return result;
    try {
      new (this.async ? AsyncFunction : Function)(value);
    } catch(err) {
      const scope = this.async ? "an asynchronous" : "a synchronous";
      err.message = `must be valid JavaScript for ${scope} scope:\n${err.message}`;
      throw new Error(err);
    }
  }

  /* -------------------------------------------- */
  /*  Form Field Integration                      */
  /* -------------------------------------------- */

  /** @override */
  toFormGroup(groupConfig={}, inputConfig={}) {
    groupConfig.stacked ??= true;
    return super.toFormGroup(groupConfig, inputConfig);
  }

  /**
   * @param {FormInputConfig & CodeMirrorInputConfig} config
   * @override
   */
  _toInput(config) {
    config.language = "javascript";
    config.indent ??= 2;
    return foundry.applications.elements.HTMLCodeMirrorElement.create(config);
  }
}

/**
 * @import {DataField} from "../data/fields.mjs";
 * @import {DataModelConstructionContext, DataModelFromSourceOptions, DataModelUpdateOptions,
 *   DataModelValidationOptions, DataSchema} from "./_types.mjs";
 */

/**
 * An abstract class which is a fundamental building block of numerous structures and concepts in Foundry Virtual
 * Tabletop. Data models perform several essential roles:
 *
 * * A static schema definition that all instances of that model adhere to.
 * * A workflow for data migration, cleaning, validation, and initialization such that provided input data is structured
 *   according to the rules of the model's declared schema.
 * * A workflow for transacting differential updates to the instance data and serializing its data into format suitable
 *   for storage or transport.
 *
 * DataModel subclasses can be used for a wide variety of purposes ranging from simple game settings to high complexity
 * objects like `Scene` documents. Data models are often nested; see the {@link DataModel.parent} property for more.
 *
 * @abstract
 * @template {object} [ModelData=object]
 * @template {DataModelConstructionContext} [ModelContext=DataModelConstructionContext]
 */
class DataModel {
  /**
   * @param {Partial<ModelData>} [data={}] Initial data used to construct the data object. The provided object will be
   *                                       owned by the constructed model instance and may be mutated.
   * @param {ModelContext} [options={}]    Context and data validation options which affects initial model construction.
   */
  constructor(data={}, {parent=null, strict=true, ...options}={}) {

    // Parent model
    Object.defineProperty(this, "parent", {
      value: (() => {
        if ( parent === null ) return null;
        if ( parent instanceof DataModel ) return parent;
        throw new Error("The provided parent must be a DataModel instance");
      })(),
      writable: false,
      enumerable: false
    });

    // Source data
    Object.defineProperty(this, "_source", {
      value: this._initializeSource(data, {strict, ...options}),
      writable: false,
      enumerable: false
    });
    Object.seal(this._source);

    // Additional subclass configurations
    this._configure(options);

    // Data validation and initialization
    const fallback = options.fallback ?? !strict;
    const dropInvalidEmbedded = options.dropInvalidEmbedded ?? !strict;
    this.validate({strict, fallback, dropInvalidEmbedded, fields: true, joint: true});
    this._initialize({strict, ...options});
  }

  /**
   * Configure the data model instance before validation and initialization workflows are performed.
   * @param {object} [options] Additional options modifying the configuration
   * @protected
   */
  _configure(options={}) {}

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

  /**
   * The source data object for this DataModel instance.
   * Once constructed, the source object is sealed such that no keys may be added nor removed.
   * @type {ModelData}
   * @public
   */
  _source;

  /**
   * The defined and cached Data Schema for all instances of this DataModel.
   * @type {SchemaField}
   * @internal
   */
  static _schema;

  /**
   * An immutable reverse-reference to a parent DataModel to which this model belongs.
   * @type {DataModel|null}
   */
  parent;

  /* ---------------------------------------- */
  /*  Data Schema                             */
  /* ---------------------------------------- */

  /**
   * Define the data schema for models of this type.
   * The schema is populated the first time it is accessed and cached for future reuse.
   *
   * The schema, through its fields, provide the essential cleaning, validation, and initialization methods to turn the
   * {@link _source} values into direct properties of the data model. The schema is a static property of the model and
   * is reused by all instances to perform validation.
   *
   * The schemas defined by the core software in classes like {@link foundry.documents.BaseActor} are validated by the
   * server, where user code does not run. However, almost all documents have a `flags` field to store data, and many
   * have a `system` field that can be configured to be a {@link foundry.abstract.TypeDataModel} instance. Those models
   * are *not* constructed on the server and rely purely on client-side code, which means certain extra-sensitive fields
   * must be also be registered through your package manifest. {@link foundry.packages.types.ServerSanitizationFields}
   *
   * @returns {DataSchema}
   * @abstract
   *
   * @example
   * ```js
   * class SomeModel extends foundry.abstract.DataModel {
   *   static defineSchema() {
   *     return {
   *       foo: new foundry.data.fields.StringField()
   *     }
   *   }
   * }
   *
   * class AnotherModel extends SomeModel {
   *   static defineSchema() {
   *     // Inheritance and object oriented principles apply to schema definition
   *     const schema = super.defineSchema()
   *
   *     schema.bar = new foundry.data.fields.NumberField()
   *
   *     return schema;
   *   }
   * }
   * ```
   */
  static defineSchema() {
    throw new Error(`The ${this.name} subclass of DataModel must define its Document schema`);
  }

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

  /**
   * The Data Schema for all instances of this DataModel.
   * @type {SchemaField}
   */
  static get schema() {
    if ( this.hasOwnProperty("_schema") ) return this._schema;
    const schema = new SchemaField(Object.freeze(this.defineSchema()));
    Object.defineProperty(this, "_schema", {value: schema, writable: false});
    return schema;
  }

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

  /**
   * Define the data schema for this document instance.
   * @type {SchemaField}
   */
  get schema() {
    return this.constructor.schema;
  }

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

  /**
   * Is the current state of this DataModel invalid?
   * The model is invalid if there is any unresolved failure.
   * @type {boolean}
   */
  get invalid() {
    return Object.values(this.#validationFailures).some(f => f?.unresolved);
  }

  /**
   * An array of validation failure instances which may have occurred when this instance was last validated.
   * @type {{fields: DataModelValidationFailure|null, joint: DataModelValidationFailure|null}}
   */
  get validationFailures() {
    return this.#validationFailures;
  }

  #validationFailures = Object.seal({fields: null, joint: null });

  /**
   * A set of localization prefix paths which are used by this DataModel. This provides an alternative to defining the
   * `label` and `hint` property of each field by having foundry map the labels to a structure inside the path
   * provided by the prefix.
   *
   * @type {string[]}
   *
   * @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", () => {
   *   // Foundry will attempt to automatically localize models registered for a document subtype, so this step is only
   *   // needed for other data model usage, e.g. for a Setting.
   *   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 LOCALIZATION_PREFIXES = [];

  /* ---------------------------------------- */
  /*  Data Cleaning Methods                   */
  /* ---------------------------------------- */

  /**
   * Initialize the source data for a new DataModel instance.
   * One-time migrations and initial cleaning operations are applied to the source data.
   * @param {object|DataModel} data   The candidate source data from which the model will be constructed
   * @param {object} [options]        Options provided to the model constructor
   * @returns {object}                Migrated and cleaned source data which will be stored to the model instance,
   *                                  which is the same object as the `data` argument
   * @protected
   */
  _initializeSource(data, options={}) {
    if ( data instanceof DataModel ) data = data.toObject();

    // Migrate old data to the new format
    data = this.constructor.migrateDataSafe(data);
    const dt = getType(data);
    if ( dt !== "Object" ) {
      throw new Error(`${this.constructor.name} was incorrectly constructed with a ${dt} instead of an object.`);
    }

    // Clean data and apply shims for backwards compatibility
    data = this.constructor.cleanData(data);
    return this.constructor.shimData(data);
  }

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

  /**
   * Clean a data source object to conform to a specific provided schema.
   * @param {object} [source]         The source data object
   * @param {object} [options={}]     Additional options which are passed to field cleaning methods
   * @returns {object}                The cleaned source data, which is the same object as the `source` argument
   */
  static cleanData(source={}, options={}) {
    return this.schema.clean(source, options);
  }

  /* ---------------------------------------- */
  /*  Data Initialization                     */
  /* ---------------------------------------- */

  /**
   * A generator that orders the DataFields in the DataSchema into an expected initialization order.
   * @returns {Generator<[string,DataField]>}
   * @yields {DataField}
   * @protected
   */
  static *_initializationOrder() {
    for ( const entry of this.schema.entries() ) yield entry;
  }

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

  /**
   * Initialize the instance by copying data from the source object to instance attributes.
   * This mirrors the workflow of SchemaField#initialize but with some added functionality.
   * @param {object} [options]        Options provided to the model constructor
   * @protected
   */
  _initialize(options={}) {
    for ( const [name, field] of this.constructor._initializationOrder() ) {
      const sourceValue = this._source[name];

      // Field initialization
      const value = field.initialize(sourceValue, this, options);

      // Special handling for Document IDs.
      if ( (name === "_id") && (!Object.getOwnPropertyDescriptor(this, "_id") || (this._id === null)) ) {
        Object.defineProperty(this, name, {value, writable: false, configurable: true});
      }

      // Readonly fields
      else if ( field.readonly ) {
        if ( this[name] !== undefined ) continue;
        Object.defineProperty(this, name, {value, writable: false});
      }

      // Getter fields
      else if ( value instanceof Function ) {
        Object.defineProperty(this, name, {get: value, set() {}, configurable: true});
      }

      // Writable fields
      else this[name] = value;
    }
  }

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

  /**
   * Reset the state of this data instance back to mirror the contained source data, erasing any changes.
   */
  reset() {
    this._initialize();
  }

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

  /**
   * Clone a model, creating a new data model by combining current data with provided overrides.
   * @param {object} [data={}]             Additional data which overrides current document data at the time of creation
   * @param {DataModelConstructionContext} [context={}]          Context options passed to the data model constructor
   * @returns {DataModel|Promise<DataModel>} The cloned instance
   */
  clone(data={}, context={}) {
    data = mergeObject(this.toObject(), data, {insertKeys: false, performDeletions: true, inplace: true});
    return new this.constructor(data, {parent: this.parent, ...context});
  }

  /* ---------------------------------------- */
  /*  Data Validation Methods                 */
  /* ---------------------------------------- */

  /**
   * Validate the data contained in the document to check for type and content.
   * If changes are provided, missing types are added to it before cleaning and validation.
   * This mutates the provided changes. This function throws an error if data within the document is not valid.
   * @param {DataModelValidationOptions} options    Options which modify how the model is validated
   * @returns {boolean}                             Whether the data source or proposed change is reported as valid.
   *                                                A boolean is always returned if validation is non-strict.
   * @throws {Error}                                An error thrown if validation is strict and a failure occurs.
   */
  validate({changes, clean=false, fallback=false, dropInvalidEmbedded=false, strict=true,
    fields=true, joint}={}) {
    let source = changes ?? this._source;

    // Determine whether we are performing partial or joint validation
    joint = joint ?? !changes;
    const partial = !joint;

    // Add types where missing in a set of partial changes
    if ( partial ) {
      if ( !clean ) source = deepClone(source);
      this.schema._addTypes(this._source, source);
    }

    // Optionally clean the data before validating
    if ( clean ) this.constructor.cleanData(source, {partial});

    // Validate individual fields in the data or in a specific change-set, throwing errors if validation fails
    if ( fields ) {
      this.#validationFailures.fields = null;
      const failure = this.schema.validate(source, {partial, fallback, dropInvalidEmbedded});
      if ( failure ) {
        const id = this._source._id ? `[${this._source._id}] ` : "";
        failure.message = `${this.constructor.name} ${id}validation errors:`;
        this.#validationFailures.fields = failure;
        if ( strict && failure.unresolved ) throw failure.asError();
        else logger.warn(failure.asError());
      }
    }

    // Perform joint document-level validations which consider all fields together
    if ( joint ) {
      this.#validationFailures.joint = null;
      try {
        this.schema._validateModel(source);     // Validate inner models
        this.constructor.validateJoint(source); // Validate this model
      } catch(err) {
        const id = this._source._id ? `[${this._source._id}] ` : "";
        const message = [this.constructor.name, id, `Joint Validation Error:\n${err.message}`].filterJoin(" ");
        const failure = new DataModelValidationFailure({message, unresolved: true});
        this.#validationFailures.joint = failure;
        if ( strict ) throw failure.asError();
        else logger.warn(failure.asError());
      }
    }
    return !this.invalid;
  }

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

  /**
   * Evaluate joint validation rules which apply validation conditions across multiple fields of the model.
   * Field-specific validation rules should be defined as part of the DataSchema for the model.
   * This method allows for testing aggregate rules which impose requirements on the overall model.
   * @param {object} data     Candidate data for the model
   * @throws {Error}          An error if a validation failure is detected
   */
  static validateJoint(data) {}

  /* ---------------------------------------- */
  /*  Data Management                         */
  /* ---------------------------------------- */

  /**
   * Update the DataModel locally by applying an object of changes to its source data.
   * The provided changes are expanded, cleaned, validated, and stored to the source data object for this model.
   * The provided changes argument is mutated in this process.
   * The source data is then re-initialized to apply those changes to the prepared data.
   * The method returns an object of differential changes which modified the original data.
   *
   * @param {object} changes                  New values which should be applied to the data model
   * @param {DataModelUpdateOptions} options  Options which determine how the new data is merged
   * @returns {object}                        An object containing differential keys and values that were changed
   * @throws {DataModelValidationError}       An error if the requested data model changes were invalid
   */
  updateSource(changes={}, options={}) {
    const rootKey = "_source";
    const rootDiff = {[rootKey]: {}};

    // Expand the object, if dot-notation keys are provided
    if ( Object.keys(changes).some(k => /\./.test(k)) ) {
      const expandedChanges = expandObject(changes);
      for ( const key in changes ) delete changes[key];
      Object.assign(changes, expandedChanges);
    }

    // Clean proposed changes
    this.schema._addTypes(this._source, changes);
    this.constructor.cleanData(changes, {partial: true});

    // Perform updates on the safe copy of source data
    const copy = this.#prepareSafeSource(changes);
    this.schema._updateDiff({_source: copy}, rootKey, changes, rootDiff, options);
    const diff = rootDiff[rootKey] || {};
    if ( isEmpty(diff) ) return diff;

    // Validate final field-level changes only on the subset of fields which changed
    const typeChanged = "type" in diff;
    this.validate({changes: diff, fields: true, joint: false, strict: true});
    if ( !typeChanged ) delete diff.type;

    // Validate the final model on the safe copy of changes
    this.validate({changes: copy, fields: false, joint: true, strict: true});

    // If this is not a dry run, enact the final changes
    if ( !options.dryRun ) {
      this.schema._updateCommit(this, rootKey, copy, diff, options);
      this._initialize();
    }

    // Return the diff of enacted changes
    return diff;
  }

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

  /**
   * Prepare a mutation-safe version of the source data.
   * The resulting object contains a copy of the source data for any field present in the proposed set of model changes.
   * For fields not present in the proposed changes, the resulting object directly references the true source.
   * This approach is used because of superior performance for complex data structures.
   * @param {object} changes
   * @returns {object}
   */
  #prepareSafeSource(changes) {
    const copy = {};
    for ( const k of Object.keys(changes) ) {
      const key = isDeletionKey(k) ? k.slice(2) : k;
      if ( key in this._source ) copy[key] = deepClone(this._source[key]); // Copy for changed fields
    }
    for ( const k of Object.keys(this._source) ) {
      if ( !(k in copy) ) copy[k] = this._source[k]; // Direct reference for unchanged fields
    }
    return copy;
  }

  /* ---------------------------------------- */
  /*  Serialization and Storage               */
  /* ---------------------------------------- */

  /**
   * Copy and transform the DataModel into a plain object.
   * Draw the values of the extracted object from the data source (by default) otherwise from its transformed values.
   * @param {boolean} [source=true]     Draw values from the underlying data source rather than transformed values
   * @returns {object}                  The extracted primitive object
   */
  toObject(source=true) {
    if ( source ) return deepClone(this._source);

    // We have use the schema of the class instead of the schema of the instance to prevent an infinite recursion:
    // the EmbeddedDataField replaces the schema of its model instance with itself
    // and EmbeddedDataField#toObject calls DataModel#toObject.
    return this.constructor.schema.toObject(this);
  }

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

  /**
   * Extract the source data for the DataModel into a simple object format that can be serialized.
   * @returns {object}          The document source data expressed as a plain object
   */
  toJSON() {
    return this.toObject(true);
  }

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

  /**
   * Create a new instance of this DataModel from a source record.
   * The source is presumed to be trustworthy and is not strictly validated.
   * @param {object} source    Initial document data which comes from a trusted source.
   * @param {Omit<DataModelConstructionContext, "strict"> & DataModelFromSourceOptions} [context]
   *                           Model construction context
   * @returns {DataModel}
   */
  static fromSource(source, {strict=false, ...context}={}) {
    return new this(source, {strict, ...context});
  }

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

  /**
   * Create a DataModel instance using a provided serialized JSON string.
   * @param {string} json       Serialized document data in string format
   * @returns {DataModel}       A constructed data model instance
   */
  static fromJSON(json) {
    return this.fromSource(JSON.parse(json));
  }

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

  /**
   * Migrate candidate source data for this DataModel which may require initial cleaning or transformations.
   * @param {object} source           The candidate source data from which the model will be constructed
   * @returns {object}                Migrated source data, which is the same object as the `source` argument
   */
  static migrateData(source) {
    this.schema.migrateSource(source, source);
    return source;
  }

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

  /**
   * Wrap data migration in a try/catch which attempts it safely
   * @param {object} source           The candidate source data from which the model will be constructed
   * @returns {object}                Migrated source data, which is the same object as the `source` argument
   */
  static migrateDataSafe(source) {
    try {
      this.migrateData(source);
    } catch(err) {
      err.message = `Failed data migration for ${this.name}: ${err.message}`;
      logger.warn(err);
    }
    return source;
  }

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

  /**
   * Take data which conforms to the current data schema and add backwards-compatible accessors to it in order to
   * support older code which uses this data.
   * @param {object} data         Data which matches the current schema
   * @param {object} [options={}] Additional shimming options
   * @param {boolean} [options.embedded=true] Apply shims to embedded models?
   * @returns {object}            Data with added backwards-compatible properties, which is the same object as
   *                              the `data` argument
   */
  static shimData(data, {embedded=true}={}) {
    if ( Object.isSealed(data) ) return data;
    const schema = this.schema;
    if ( embedded ) {
      for ( const [name, value] of Object.entries(data) ) {
        const field = schema.get(name);
        if ( (field instanceof EmbeddedDataField) && !Object.isSealed(value) ) {
          data[name] = field.model.shimData(value || {});
        }
        else if ( field instanceof EmbeddedCollectionField ) {
          for ( const d of (value || []) ) {
            if ( !Object.isSealed(d) ) field.model.shimData(d);
          }
        }
      }
    }
    return data;
  }
}

/**
 * @import DatabaseBackend from "./backend.mjs";
 * @import BaseUser from "../documents/user.mjs";
 * @import {
 *   DatabaseCreateOperation,
 *   DatabaseGetOperation,
 *   DatabaseUpdateOperation,
 *   DatabaseDeleteOperation,
 *   DocumentCloneOptions,
 *   DocumentClassMetadata
 * } from "./_types.mjs";
 * @import {DocumentConstructionContext} from "./_types.mjs";
 * @import {DocumentOwnershipLevel, DocumentOwnershipNumber} from "../constants.mjs";
 * @import {DocumentFlags, DocumentStats} from "../data/_types.mjs";
 */

/**
 * An extension of the base DataModel which defines a Document.
 * Documents are special in that they are persisted to the database and referenced by _id.
 * @abstract
 *
 * @template {object} [DocumentData=object] Initial data from which to construct the Document
 * @template {DocumentConstructionContext} [DocumentContext=DocumentConstructionContext] Construction context options
 *
 * @property {string|null} _id                    The document identifier, unique within its Collection, or null if the
 *                                                Document has not yet been assigned an identifier
 * @property {string} [name]                      Documents typically have a human-readable name
 * @property {DataModel} [system]                 Certain document types may have a system data model which contains
 *                                                subtype-specific data defined by the game system or a module
 * @property {DocumentStats} [_stats]             Primary document types have a _stats object which provides metadata
 *                                                about their status
 * @property {DocumentFlags} flags                Documents each have an object of arbitrary flags which are used by
 *                                                systems or modules to store additional Document-specific data
 * @extends {DataModel<DocumentData, DocumentContext>}
 */
class Document extends DataModel {

  /** @override */
  _configure({pack=null, parentCollection=null}={}) {
    /**
     * An immutable reverse-reference to the name of the collection that this Document exists in on its parent, if any.
     * @type {string|null}
     */
    Object.defineProperty(this, "parentCollection", {
      value: this._getParentCollection(parentCollection),
      writable: false
    });

    /**
     * An immutable reference to a containing Compendium collection to which this Document belongs.
     * @type {string|null}
     */
    Object.defineProperty(this, "pack", {
      value: (() => {
        if ( typeof pack === "string" ) return pack;
        if ( this.parent?.pack ) return this.parent.pack;
        if ( pack === null ) return null;
        throw new Error("The provided compendium pack ID must be a string");
      })(),
      writable: false
    });

    // Construct Embedded Collections
    const collections = {};
    for ( const [fieldName, field] of Object.entries(this.constructor.hierarchy) ) {
      if ( !field.constructor.implementation ) continue;
      const data = this._source[fieldName];
      const c = collections[fieldName] = new field.constructor.implementation(fieldName, this, data);
      Object.defineProperty(this, fieldName, {value: c, writable: false});
    }

    /**
     * A mapping of embedded Document collections which exist in this model.
     * @type {Record<string, EmbeddedCollection>}
     */
    Object.defineProperty(this, "collections", {value: Object.seal(collections), writable: false});
  }

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

  /**
   * Ensure that all Document classes share the same schema of their base declaration.
   * @type {SchemaField}
   * @override
   */
  static get schema() {
    if ( this._schema ) return this._schema;
    const base = this.baseDocument;
    if ( !base.hasOwnProperty("_schema") ) {
      const schema = new SchemaField(Object.freeze(base.defineSchema()));
      Object.defineProperty(base, "_schema", {value: schema, writable: false});
    }
    Object.defineProperty(this, "_schema", {value: base._schema, writable: false});
    return base._schema;
  }

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

  /** @override */
  static *_initializationOrder() {
    const hierarchy = this.hierarchy;

    // Initialize non-hierarchical fields first
    for ( const [name, field] of this.schema.entries() ) {
      if ( name in hierarchy ) continue;
      yield [name, field];
    }

    // Initialize hierarchical fields last
    for ( const [name, field] of Object.entries(hierarchy) ) {
      yield [name, field];
    }
  }

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /**
   * Default metadata which applies to each instance of this Document type.
   * @type {Readonly<DocumentClassMetadata>}
   */
  static metadata = Object.freeze({
    name: "Document",
    label: "DOCUMENT.Document",
    coreTypes: [BASE_DOCUMENT_TYPE],
    collection: "documents",
    embedded: {},
    hasTypeData: false,
    indexed: false,
    compendiumIndexFields: [],
    permissions: {
      view: "LIMITED",      // At least limited permission is required to view the Document
      create: "ASSISTANT",  // Assistants or Gamemasters can create Documents
      update: "OWNER",      // Document owners can update Documents (this includes GM users)
      delete: "ASSISTANT"   // Assistants or Gamemasters can delete Documents
    },
    preserveOnImport: ["_id", "sort", "ownership", "folder"],
    /*
     * The metadata has to include the version of this Document schema, which needs to be increased
     * whenever the schema is changed such that Document data created before this version
     * would come out different if `fromSource(data).toObject()` was applied to it so that
     * we always vend data to client that is in the schema of the current core version.
     * The schema version needs to be bumped if
     *   - a field was added or removed,
     *   - the class/type of any field was changed,
     *   - the casting or cleaning behavior of any field class was changed,
     *   - the data model of an embedded data field was changed,
     *   - certain field properties are changed (e.g. required, nullable, blank, ...), or
     *   - there have been changes to cleanData or migrateData of the Document.
     *
     * Moreover, the schema version needs to be bumped if the sanitization behavior
     * of any field in the schema was changed.
     */
    schemaVersion: undefined
  });

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT"];

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

  /**
   * The database backend used to execute operations and handle results.
   * @type {DatabaseBackend}
   */
  static get database() {
    return globalThis.CONFIG.DatabaseBackend;
  }

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

  /**
   * Return a reference to the configured subclass of this base Document type.
   * @type {typeof Document}
   */
  static get implementation() {
    return globalThis.CONFIG[this.documentName]?.documentClass || this;
  }

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

  /**
   * The base document definition that this document class extends from.
   * @type {typeof Document}
   */
  static get baseDocument() {
    let cls;
    let parent = this;
    while ( parent ) {
      cls = parent;
      parent = Object.getPrototypeOf(cls);
      if ( parent === Document ) return cls;
    }
    throw new Error(`Base Document class identification failed for "${this.documentName}"`);
  }

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

  /**
   * The named collection to which this Document belongs.
   * @type {string}
   */
  static get collectionName() {
    return this.metadata.collection;
  }

  get collectionName() {
    return this.constructor.collectionName;
  }

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

  /**
   * The canonical name of this Document type, for example "Actor".
   * @type {string}
   */
  static get documentName() {
    return this.metadata.name;
  }

  get documentName() {
    return this.constructor.documentName;
  }

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

  /**
   * The allowed types which may exist for this Document class.
   * @type {string[]}
   */
  static get TYPES() {
    return Object.keys(game.model[this.metadata.name]);
  }

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

  /**
   * Does this Document support additional subtypes?
   * @type {boolean}
   */
  static get hasTypeData() {
    return this.metadata.hasTypeData;
  }

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

  /**
   * The Embedded Document hierarchy for this Document.
   * @returns {Readonly<Record<string, EmbeddedCollectionField|EmbeddedDocumentField>>}
   */
  static get hierarchy() {
    const hierarchy = {};
    for ( const [fieldName, field] of this.schema.entries() ) {
      if ( field.constructor.hierarchical ) hierarchy[fieldName] = field;
    }
    Object.defineProperty(this, "hierarchy", {value: Object.freeze(hierarchy), writable: false});
    return hierarchy;
  }

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

  /**
   * Identify the collection in a parent Document that this Document belongs to, if any.
   * @param {string|null} [parentCollection]  An explicitly provided parent collection name.
   * @returns {string|null}
   * @internal
   */
  _getParentCollection(parentCollection) {
    if ( !this.parent ) return null;
    if ( parentCollection ) return parentCollection;
    return this.parent.constructor.getCollectionName(this.documentName);
  }

  /**
   * The canonical identifier for this Document.
   * @type {string|null}
   */
  get id() {
    return this._id;
  }

  /**
   * A reference to the Compendium Collection containing this Document, if any, and otherwise null.
   * @returns {CompendiumCollection|null}
   * @abstract
   */
  get compendium() {
    throw new Error("A subclass of Document must implement this getter.");
  }

  /**
   * Is this document embedded within a parent document?
   * @returns {boolean}
   */
  get isEmbedded() {
    return !!(this.parent && this.parentCollection);
  }

  /**
   * Is this document in a compendium?
   * @returns {boolean}
   */
  get inCompendium() {
    return !!this.pack;
  }

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

  /**
   * A Universally Unique Identifier (uuid) for this Document instance.
   * @type {string}
   */
  get uuid() {
    return foundry.utils.buildUuid(this);
  }

  /* ---------------------------------------- */
  /*  Model Permissions                       */
  /* ---------------------------------------- */

  /**
   * Test whether a given User has sufficient permissions to create Documents of this type in general. This does not
   * guarantee that the User is able to create all Documents of this type, as certain document-specific requirements
   * may also be present.
   *
   * Generally speaking, this method is used to verify whether a User should be presented with the option to create
   * Documents of this type in the UI.
   *
   * @param {BaseUser} user       The User being tested
   * @returns {boolean}           Does the User have a sufficient role to create?
   */
  static canUserCreate(user) {
    const perm = this.metadata.permissions.create;

    // Require a custom User permission
    if ( perm in USER_PERMISSIONS ) return user.hasPermission(perm);

    // Require a specific User role
    if ( perm in USER_ROLES ) return user.hasRole(perm);

    // Construct a sample Document
    let doc;
    try {
      doc = this.fromSource(this.cleanData(), {strict: true});
    } catch(err) {
      return false;
    }

    // Use a specialized permission test function
    if ( perm instanceof Function ) return doc.canUserModify(user, "create");

    // Require Document ownership
    if ( perm in DOCUMENT_OWNERSHIP_LEVELS ) return doc.testUserPermission(user, perm);
    return false;
  }

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

  /**
   * Get the explicit permission level that a User has over this Document, a value in CONST.DOCUMENT_OWNERSHIP_LEVELS.
   * Compendium content ignores the ownership field in favor of User role-based ownership. Otherwise, Documents use
   * granular per-User ownership definitions and Embedded Documents defer to their parent ownership.
   *
   * This method returns the value recorded in Document ownership, regardless of the User's role, for example a
   * GAMEMASTER user might still return a result of NONE if they are not explicitly denoted as having a level.
   *
   * To test whether a user has a certain capability over the document, testUserPermission should be used.
   *
   * @param {BaseUser} [user=game.user] The User being tested
   * @returns {DocumentOwnershipNumber} A numeric permission level from {@link CONST.DOCUMENT_OWNERSHIP_LEVELS}
   */
  getUserLevel(user) {
    user ||= game.user;
    if ( this.pack ) return this.compendium.getUserLevel(user);               // Compendium User role
    if ( this.schema.has("ownership") ) {
      const level = this.ownership[user.id] ?? this.ownership.default ?? DOCUMENT_OWNERSHIP_LEVELS.NONE;
      if ( level !== DOCUMENT_OWNERSHIP_LEVELS.INHERIT ) return level;        // Defer inherited for Embedded
    }
    if ( this.parent ) return this.parent.getUserLevel(user);                 // Embedded Documents
    return DOCUMENT_OWNERSHIP_LEVELS.NONE;                                    // Otherwise, NONE
  }

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

  /**
   * Test whether a certain User has a requested permission level (or greater) over the Document
   * @param {BaseUser} user                 The User being tested
   * @param {DocumentOwnershipLevel} 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 Document?
   */
  testUserPermission(user, permission, {exact=false}={}) {
    const perms = DOCUMENT_OWNERSHIP_LEVELS;
    let level;
    if ( user.isGM ) level = perms.OWNER;
    else if ( user.isBanned ) level = perms.NONE;
    else level = this.getUserLevel(user);
    const target = (typeof permission === "string") ? (perms[permission] ?? perms.OWNER) : permission;
    return exact ? level === target : level >= target;
  }

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

  /**
   * Test whether a given User has permission to perform some action on this Document
   * @param {BaseUser} user             The User attempting modification
   * @param {string} action             The attempted action
   * @param {object} [data]             Data involved in the attempted action
   * @returns {boolean}                 Does the User have permission?
   */
  canUserModify(user, action, data={}) {
    const permissions = this.constructor.metadata.permissions;
    const perm = permissions[action];

    // Use a specialized permission test function
    if ( perm instanceof Function ) return perm(user, this, data);

    // Require a custom User permission
    if ( perm in USER_PERMISSIONS ) return user.hasPermission(perm);

    // Require a specific User role
    if ( perm in USER_ROLES ) return user.hasRole(perm);

    // Require Document ownership
    if ( perm in DOCUMENT_OWNERSHIP_LEVELS ) return this.testUserPermission(user, perm);
    return false;
  }

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

  /**
   * Clone a document, creating a new document by combining current data with provided overrides.
   * The cloned document is ephemeral and not yet saved to the database.
   * @param {object} [data={}]    Additional data which overrides current document data at the time of creation
   * @param {DocumentConstructionContext & DocumentCloneOptions} [context]
   *                                          Additional context options passed to the create method
   * @returns {Document|Promise<Document>}    The cloned Document instance
   */
  clone(data={}, context={}) {
    const {save=false, keepId=false, addSource=false, ...remaining} = context;
    context = remaining;
    context.parent = this.parent;
    context.pack = this.pack;
    context.strict = false;
    data = mergeObject(this.toObject(), data, {insertKeys: false, performDeletions: true, inplace: true});
    if ( !keepId ) delete data._id;
    if ( addSource ) {
      data._stats.duplicateSource = this.uuid;
      data._stats.exportSource = null;
    }
    return save ? this.constructor.create(data, context) : new this.constructor(data, context);
  }

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

  /**
   * For Documents which include game system data, migrate the system data object to conform to its latest data model.
   * The data model is defined by the template.json specification included by the game system.
   * @returns {object}              The migrated system data object
   */
  migrateSystemData() {
    if ( !this.constructor.hasTypeData ) {
      throw new Error(`The ${this.documentName} Document does not include a TypeDataField.`);
    }
    if ( (this.system instanceof DataModel) && !(this.system.modelProvider instanceof foundry.packages.BaseSystem) ) {
      throw new Error(`The ${this.documentName} Document does not have system-provided package data.`);
    }
    const model = game.model[this.documentName]?.[this.type] ?? {};
    return mergeObject(model, this.system, {
      insertKeys: false,
      insertValues: true,
      enforceTypes: false,
      overwrite: true,
      inplace: false
    });
  }

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

  /** @inheritDoc */
  toObject(source=true) {
    const data = super.toObject(source);
    return this.constructor.shimData(data);
  }

  /* -------------------------------------------- */
  /*  Database Operations                         */
  /* -------------------------------------------- */

  /**
   * Create multiple Documents using provided input data.
   * Data is provided as an array of objects where each individual object becomes one new Document.
   *
   * @param {Array<object|Document>} data  An array of data objects or existing Documents to persist.
   * @param {Partial<Omit<DatabaseCreateOperation, "data">>} [operation={}]  Parameters of the requested creation
   *                                  operation
   * @returns {Promise<Document[]>}        An array of created Document instances
   *
   * @example Create a single Document
   * ```js
   * const data = [{name: "New Actor", type: "character", img: "path/to/profile.jpg"}];
   * const created = await Actor.implementation.createDocuments(data);
   * ```
   *
   * @example Create multiple Documents
   * ```js
   * const data = [{name: "Tim", type: "npc"], [{name: "Tom", type: "npc"}];
   * const created = await Actor.implementation.createDocuments(data);
   * ```
   *
   * @example Create multiple embedded Documents within a parent
   * ```js
   * const actor = game.actors.getName("Tim");
   * const data = [{name: "Sword", type: "weapon"}, {name: "Breastplate", type: "equipment"}];
   * const created = await Item.implementation.createDocuments(data, {parent: actor});
   * ```
   *
   * @example Create a Document within a Compendium pack
   * ```js
   * const data = [{name: "Compendium Actor", type: "character", img: "path/to/profile.jpg"}];
   * const created = await Actor.implementation.createDocuments(data, {pack: "mymodule.mypack"});
   * ```
   */
  static async createDocuments(data=[], operation={}) {
    if ( operation.parent?.pack ) operation.pack = operation.parent.pack;
    operation.data = data;
    const created = await this.database.create(this.implementation, operation);

    /** @deprecated since v12 */
    if ( getDefiningClass(this, "_onCreateDocuments") !== Document ) {
      foundry.utils.logCompatibilityWarning("The Document._onCreateDocuments static method is deprecated in favor of "
        + "Document._onCreateOperation", {since: 12, until: 14});
      await this._onCreateDocuments(created, operation);
    }
    return created;
  }

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

  /**
   * Update multiple Document instances using provided differential data.
   * Data is provided as an array of objects where each individual object updates one existing Document.
   *
   * @param {object[]} updates          An array of differential data objects, each used to update a single Document
   * @param {Partial<Omit<DatabaseUpdateOperation, "updates">>} [operation={}] Parameters of the database update
   *                                    operation
   * @returns {Promise<Document[]>}     An array of updated Document instances
   *
   * @example Update a single Document
   * ```js
   * const updates = [{_id: "12ekjf43kj2312ds", name: "Timothy"}];
   * const updated = await Actor.implementation.updateDocuments(updates);
   * ```
   *
   * @example Update multiple Documents
   * ```js
   * const updates = [{_id: "12ekjf43kj2312ds", name: "Timothy"}, {_id: "kj549dk48k34jk34", name: "Thomas"}]};
   * const updated = await Actor.implementation.updateDocuments(updates);
   * ```
   *
   * @example Update multiple embedded Documents within a parent
   * ```js
   * const actor = game.actors.getName("Timothy");
   * const updates = [{_id: sword.id, name: "Magic Sword"}, {_id: shield.id, name: "Magic Shield"}];
   * const updated = await Item.implementation.updateDocuments(updates, {parent: actor});
   * ```
   *
   * @example Update Documents within a Compendium pack
   * ```js
   * const actor = await pack.getDocument(documentId);
   * const updated = await Actor.implementation.updateDocuments([{_id: actor.id, name: "New Name"}],
   *   {pack: "mymodule.mypack"});
   * ```
   */
  static async updateDocuments(updates=[], operation={}) {
    if ( operation.parent?.pack ) operation.pack = operation.parent.pack;
    operation.updates = updates;
    const updated = await this.database.update(this.implementation, operation);

    /** @deprecated since v12 */
    if ( getDefiningClass(this, "_onUpdateDocuments") !== Document ) {
      foundry.utils.logCompatibilityWarning("The Document._onUpdateDocuments static method is deprecated in favor of "
        + "Document._onUpdateOperation", {since: 12, until: 14});
      await this._onUpdateDocuments(updated, operation);
    }
    return updated;
  }

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

  /**
   * Delete one or multiple existing Documents using an array of provided ids.
   * Data is provided as an array of string ids for the documents to delete.
   *
   * @param {string[]} ids              An array of string ids for the documents to be deleted
   * @param {Partial<Omit<DatabaseDeleteOperation, "ids">>} [operation={}]  Parameters of the database deletion
   *                                    operation
   * @returns {Promise<Document[]>}     An array of deleted Document instances
   *
   * @example Delete a single Document
   * ```js
   * const tim = game.actors.getName("Tim");
   * const deleted = await Actor.implementation.deleteDocuments([tim.id]);
   * ```
   *
   * @example Delete multiple Documents
   * ```js
   * const tim = game.actors.getName("Tim");
   * const tom = game.actors.getName("Tom");
   * const deleted = await Actor.implementation.deleteDocuments([tim.id, tom.id]);
   * ```
   *
   * @example Delete multiple embedded Documents within a parent
   * ```js
   * const tim = game.actors.getName("Tim");
   * const sword = tim.items.getName("Sword");
   * const shield = tim.items.getName("Shield");
   * const deleted = await Item.implementation.deleteDocuments([sword.id, shield.id], parent: actor});
   * ```
   *
   * @example Delete Documents within a Compendium pack
   * ```js
   * const actor = await pack.getDocument(documentId);
   * const deleted = await Actor.implementation.deleteDocuments([actor.id], {pack: "mymodule.mypack"});
   * ```
   */
  static async deleteDocuments(ids=[], operation={}) {
    if ( operation.parent?.pack ) operation.pack = operation.parent.pack;
    operation.ids = ids;
    const deleted = await this.database.delete(this.implementation, operation);

    /** @deprecated since v12 */
    if ( getDefiningClass(this, "_onDeleteDocuments") !== Document ) {
      foundry.utils.logCompatibilityWarning("The Document._onDeleteDocuments static method is deprecated in favor of "
        + "Document._onDeleteOperation", {since: 12, until: 14});
      await this._onDeleteDocuments(deleted, operation);
    }
    return deleted;
  }

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

  /**
   * Create a new Document using provided input data, saving it to the database.
   * @see {@link Document.createDocuments}
   * @param {object|Document|(object|Document)[]} [data={}] Initial data used to create this Document, or a Document
   *                                                        instance to persist.
   * @param {Partial<Omit<DatabaseCreateOperation, "data">>} [operation={}]  Parameters of the creation operation
   * @returns {Promise<Document | Document[] | undefined>}        The created Document instance(s)
   *
   * @example Create a World-level Item
   * ```js
   * const data = [{name: "Special Sword", type: "weapon"}];
   * const created = await Item.implementation.create(data);
   * ```
   *
   * @example Create an Actor-owned Item
   * ```js
   * const data = [{name: "Special Sword", type: "weapon"}];
   * const actor = game.actors.getName("My Hero");
   * const created = await Item.implementation.create(data, {parent: actor});
   * ```
   *
   * @example Create an Item in a Compendium pack
   * ```js
   * const data = [{name: "Special Sword", type: "weapon"}];
   * const created = await Item.implementation.create(data, {pack: "mymodule.mypack"});
   * ```
   */
  static async create(data={}, operation={}) {
    const isArray = Array.isArray(data);
    const createData = isArray ? data : [data];
    const created = await this.createDocuments(createData, operation);
    return isArray ? created : created.shift();
  }

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

  /**
   * Update this Document using incremental data, saving it to the database.
   * @see {@link Document.updateDocuments}
   * @param {object} [data={}]          Differential update data which modifies the existing values of this document
   * @param {Partial<Omit<DatabaseUpdateOperation, "updates">>} [operation={}]  Parameters of the update operation
   * @returns {Promise<Document|undefined>}       The updated Document instance, or undefined not updated
   */
  async update(data={}, operation={}) {
    data._id = this.id;
    operation.parent = this.parent;
    operation.pack = this.pack;
    const updates = await this.constructor.updateDocuments([data], operation);
    return updates.shift();
  }

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

  /**
   * Delete this Document, removing it from the database.
   * @see {@link Document.deleteDocuments}
   * @param {Partial<Omit<DatabaseDeleteOperation, "ids">>} [operation={}]  Parameters of the deletion operation
   * @returns {Promise<Document|undefined>}       The deleted Document instance, or undefined if not deleted
   */
  async delete(operation={}) {
    operation.parent = this.parent;
    operation.pack = this.pack;
    const deleted = await this.constructor.deleteDocuments([this.id], operation);
    return deleted.shift();
  }

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

  /**
   * Get a World-level Document of this type by its id.
   * @param {string} documentId         The Document ID
   * @param {DatabaseGetOperation} [operation={}] Parameters of the get operation
   * @returns {Document|null}  The retrieved Document, or null
   */
  static get(documentId, operation={}) {
    if ( !documentId ) return null;
    if ( operation.pack ) {
      const pack = game.packs.get(operation.pack);
      return pack?.index.get(documentId) || null;
    }
    else {
      const collection = game.collections?.get(this.documentName);
      return collection?.get(documentId) || null;
    }
  }

  /* -------------------------------------------- */
  /*  Embedded Operations                         */
  /* -------------------------------------------- */

  /**
   * A compatibility method that returns the appropriate name of an embedded collection within this Document.
   * @param {string} name    An existing collection name or a document name.
   * @returns {string|null}  The provided collection name if it exists, the first available collection for the
   *                         document name provided, or null if no appropriate embedded collection could be found.
   * @example Passing an existing collection name.
   * ```js
   * Actor.implementation.getCollectionName("items");
   * // returns "items"
   * ```
   *
   * @example Passing a document name.
   * ```js
   * Actor.implementation.getCollectionName("Item");
   * // returns "items"
   * ```
   */
  static getCollectionName(name) {
    if ( name in this.hierarchy ) return name;
    for ( const [collectionName, field] of Object.entries(this.hierarchy) ) {
      if ( field.model.documentName === name ) return collectionName;
    }
    return null;
  }

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

  /**
   * Obtain a reference to the Array of source data within the data object for a certain embedded Document name
   * @param {string} embeddedName   The name of the embedded Document type
   * @returns {DocumentCollection}  The Collection instance of embedded Documents of the requested type
   */
  getEmbeddedCollection(embeddedName) {
    const collectionName = this.constructor.getCollectionName(embeddedName);
    if ( !collectionName ) {
      throw new Error(`${embeddedName} is not a valid embedded Document within the ${this.documentName} Document`);
    }
    const field = this.constructor.hierarchy[collectionName];
    return field.getCollection(this);
  }

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

  /**
   * Get an embedded document by its id from a named collection in the parent document.
   * @param {string} embeddedName              The name of the embedded Document type
   * @param {string} id                        The id of the child document to retrieve
   * @param {object} [options]                 Additional options which modify how embedded documents are retrieved
   * @param {boolean} [options.strict=false]   Throw an Error if the requested id does not exist. See Collection#get
   * @param {boolean} [options.invalid=false]  Allow retrieving an invalid Embedded Document.
   * @returns {Document}                       The retrieved embedded Document instance, or undefined
   * @throws If the embedded collection does not exist, or if strict is true and the Embedded Document could not be
   *         found.
   */
  getEmbeddedDocument(embeddedName, id, {invalid=false, strict=false}={}) {
    const collection = this.getEmbeddedCollection(embeddedName);
    return collection.get(id, {invalid, strict});
  }

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

  /**
   * Create multiple embedded Document instances within this parent Document using provided input data.
   * @see {@link Document.createDocuments}
   * @param {string} embeddedName                     The name of the embedded Document type
   * @param {object[]} data                           An array of data objects used to create multiple documents
   * @param {DatabaseCreateOperation} [operation={}]  Parameters of the database creation workflow
   * @returns {Promise<Document[]>}                   An array of created Document instances
   */
  async createEmbeddedDocuments(embeddedName, data=[], operation={}) {
    this.getEmbeddedCollection(embeddedName); // Validation only
    operation.parent = this;
    operation.pack = this.pack;
    const cls = getDocumentClass(embeddedName);
    return cls.createDocuments(data, operation);
  }

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

  /**
   * Update multiple embedded Document instances within a parent Document using provided differential data.
   * @see {@link Document.updateDocuments}
   * @param {string} embeddedName                     The name of the embedded Document type
   * @param {object[]} updates                        An array of differential data objects, each used to update a
   *                                                  single Document
   * @param {DatabaseUpdateOperation} [operation={}]  Parameters of the database update workflow
   * @returns {Promise<Document[]>}                   An array of updated Document instances
   */
  async updateEmbeddedDocuments(embeddedName, updates=[], operation={}) {
    this.getEmbeddedCollection(embeddedName); // Validation only
    operation.parent = this;
    operation.pack = this.pack;
    const cls = getDocumentClass(embeddedName);
    return cls.updateDocuments(updates, operation);
  }

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

  /**
   * Delete multiple embedded Document instances within a parent Document using provided string ids.
   * @see {@link Document.deleteDocuments}
   * @param {string} embeddedName                     The name of the embedded Document type
   * @param {string[]} ids                            An array of string ids for each Document to be deleted
   * @param {DatabaseDeleteOperation} [operation={}]  Parameters of the database deletion workflow
   * @returns {Promise<Document[]>}                   An array of deleted Document instances
   */
  async deleteEmbeddedDocuments(embeddedName, ids, operation={}) {
    this.getEmbeddedCollection(embeddedName); // Validation only
    operation.parent = this;
    operation.pack = this.pack;
    const cls = getDocumentClass(embeddedName);
    return cls.deleteDocuments(ids, operation);
  }

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

  /**
   * Iterate over all embedded Documents that are hierarchical children of this Document.
   * @param {string} [_parentPath]                      A parent field path already traversed
   * @yields {[string, Document]}
   */
  * traverseEmbeddedDocuments(_parentPath) {
    for ( const [fieldName, field] of Object.entries(this.constructor.hierarchy) ) {
      const fieldPath = _parentPath ? `${_parentPath}.${fieldName}` : fieldName;

      // Singleton embedded document
      if ( field instanceof foundry.data.fields.EmbeddedDocumentField ) {
        const document = this[fieldName];
        if ( document ) {
          yield [fieldPath, document];
          yield* document.traverseEmbeddedDocuments(fieldPath);
        }
      }

      // Embedded document collection
      else if ( field instanceof foundry.data.fields.EmbeddedCollectionField ) {
        const collection = this[fieldName];
        const isDelta = field instanceof foundry.data.fields.EmbeddedCollectionDeltaField;
        for ( const document of collection.values() ) {
          if ( isDelta && !collection.manages(document.id) ) continue;
          yield [fieldPath, document];
          yield* document.traverseEmbeddedDocuments(fieldPath);
        }
      }
    }
  }

  /* -------------------------------------------- */
  /*  Flag Operations                             */
  /* -------------------------------------------- */

  /**
   * Get the value of a "flag" for this document
   * See the setFlag method for more details on flags
   *
   * @param {string} scope        The flag scope which namespaces the key
   * @param {string} key          The flag key
   * @returns {*}                 The flag value
   */
  getFlag(scope, key) {
    const scopes = this.constructor.database.getFlagScopes();
    if ( !scopes.includes(scope) ) throw new Error(`Flag scope "${scope}" is not valid or not currently active`);

    /** @deprecated since v12 */
    if ( (scope === "core") && (key === "sourceId") ) {
      foundry.utils.logCompatibilityWarning("The core.sourceId flag has been deprecated. "
        + "Please use the _stats.compendiumSource property instead.", { since: 12, until: 14 });
      return this._stats?.compendiumSource;
    }

    if ( !this.flags || !(scope in this.flags) ) return undefined;
    return getProperty(this.flags?.[scope], key);
  }

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

  /**
   * Assign a "flag" to this document.
   * Flags represent key-value type data which can be used to store flexible or arbitrary data required by either
   * the core software, game systems, or user-created modules.
   *
   * Each flag should be set using a scope which provides a namespace for the flag to help prevent collisions.
   *
   * Flags set by the core software use the "core" scope.
   * Flags set by game systems or modules should use the canonical name attribute for the module
   * Flags set by an individual world should "world" as the scope.
   *
   * Flag values can assume almost any data type. Setting a flag value to null will delete that flag.
   *
   * @param {string} scope        The flag scope which namespaces the key
   * @param {string} key          The flag key
   * @param {*} value             The flag value
   * @returns {Promise<Document>} A Promise resolving to the updated document
   */
  async setFlag(scope, key, value) {
    const scopes = this.constructor.database.getFlagScopes();
    if ( !scopes.includes(scope) ) throw new Error(`Flag scope "${scope}" is not valid or not currently active`);
    return this.update({
      flags: {
        [scope]: {
          [key]: value
        }
      }
    });
  }

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

  /**
   * Remove a flag assigned to the document
   * @param {string} scope        The flag scope which namespaces the key
   * @param {string} key          The flag key
   * @returns {Promise<Document>} The updated document instance
   */
  async unsetFlag(scope, key) {
    const scopes = this.constructor.database.getFlagScopes();
    if ( !scopes.includes(scope) ) throw new Error(`Flag scope "${scope}" is not valid or not currently active`);
    const head = key.split(".");
    const tail = `-=${head.pop()}`;
    key = ["flags", scope, ...head, tail].join(".");
    return this.update({[key]: null});
  }

  /* -------------------------------------------- */
  /*  Database Creation Operations                */
  /* -------------------------------------------- */

  /**
   * Pre-process a creation operation for a single Document instance. Pre-operation events only occur for the client
   * which requested the operation.
   *
   * Modifications to the pending Document instance must be performed using {@link updateSource}.
   *
   * @param {object} data               The initial data object provided to the document creation request
   * @param {object} options            Additional options which modify the creation request
   * @param {BaseUser} user             The User requesting the document creation
   * @returns {Promise<boolean|void>}   Return false to exclude this Document from the creation operation
   * @protected
   */
  async _preCreate(data, options, user) {}

  /**
   * Post-process a creation operation for a single Document instance. Post-operation events occur for all connected
   * clients.
   *
   * @param {object} data                         The initial data object provided to the document creation request
   * @param {object} options                      Additional options which modify the creation request
   * @param {string} userId                       The id of the User requesting the document update
   * @protected
   */
  _onCreate(data, options, userId) {}

  /**
   * Pre-process a creation operation, potentially altering its instructions or input data. Pre-operation events only
   * occur for the client which requested the operation.
   *
   * This batch-wise workflow occurs after individual {@link _preCreate} workflows and provides a final pre-flight check
   * before a database operation occurs.
   *
   * Modifications to pending documents must mutate the documents array or alter individual document instances using
   * {@link updateSource}.
   *
   * @param {Document[]} documents                Pending document instances to be created
   * @param {DatabaseCreateOperation} operation   Parameters of the database creation operation
   * @param {BaseUser} user                       The User requesting the creation operation
   * @returns {Promise<boolean|void>}             Return false to cancel the creation operation entirely
   * @protected
   */
  static async _preCreateOperation(documents, operation, user) {}

  /**
   * Post-process a creation operation, reacting to database changes which have occurred. Post-operation events occur
   * for all connected clients.
   *
   * This batch-wise workflow occurs after individual {@link _onCreate} workflows.
   *
   * @param {Document[]} documents                The Document instances which were created
   * @param {DatabaseCreateOperation} operation   Parameters of the database creation operation
   * @param {BaseUser} user                       The User who performed the creation operation
   * @returns {Promise<void>}
   * @protected
   */
  static async _onCreateOperation(documents, operation, user) {}

  /* -------------------------------------------- */
  /*  Database Update Operations                  */
  /* -------------------------------------------- */

  /**
   * Pre-process an update operation for a single Document instance. Pre-operation events only occur for the client
   * which requested the operation.
   *
   * @param {object} changes            The candidate changes to the Document
   * @param {object} options            Additional options which modify the update request
   * @param {BaseUser} user             The User requesting the document update
   * @returns {Promise<boolean|void>}   A return value of false indicates the update operation should be cancelled.
   * @protected
   */
  async _preUpdate(changes, options, user) {}

  /**
   * Post-process an update operation for a single Document instance. Post-operation events occur for all connected
   * clients.
   *
   * @param {object} changed            The differential data that was changed relative to the documents prior values
   * @param {object} options            Additional options which modify the update request
   * @param {string} userId             The id of the User requesting the document update
   * @protected
   */
  _onUpdate(changed, options, userId) {}

  /**
   * Pre-process an update operation, potentially altering its instructions or input data. Pre-operation events only
   * occur for the client which requested the operation.
   *
   * This batch-wise workflow occurs after individual {@link _preUpdate} workflows and provides a final pre-flight check
   * before a database operation occurs.
   *
   * Modifications to the requested updates are performed by mutating the data array of the operation.
   *
   * @param {Document[]} documents                Document instances to be updated
   * @param {DatabaseUpdateOperation} operation   Parameters of the database update operation
   * @param {BaseUser} user                       The User requesting the update operation
   * @returns {Promise<boolean|void>}             Return false to cancel the update operation entirely
   * @protected
   */
  static async _preUpdateOperation(documents, operation, user) {}

  /**
   * Post-process an update operation, reacting to database changes which have occurred. Post-operation events occur
   * for all connected clients.
   *
   * This batch-wise workflow occurs after individual {@link _onUpdate} workflows.
   *
   * @param {Document[]} documents                The Document instances which were updated
   * @param {DatabaseUpdateOperation} operation   Parameters of the database update operation
   * @param {BaseUser} user                       The User who performed the update operation
   * @returns {Promise<void>}
   * @protected
   */
  static async _onUpdateOperation(documents, operation, user) {}

  /* -------------------------------------------- */
  /*  Database Delete Operations                  */
  /* -------------------------------------------- */

  /**
   * Pre-process a deletion operation for a single Document instance. Pre-operation events only occur for the client
   * which requested the operation.
   *
   * @param {object} options            Additional options which modify the deletion request
   * @param {BaseUser} user             The User requesting the document deletion
   * @returns {Promise<boolean|void>}   A return value of false indicates the deletion operation should be cancelled.
   * @protected
   */
  async _preDelete(options, user) {}

  /**
   * Post-process a deletion operation for a single Document instance. Post-operation events occur for all connected
   * clients.
   *
   * @param {object} options            Additional options which modify the deletion request
   * @param {string} userId             The id of the User requesting the document update
   * @protected
   */
  _onDelete(options, userId) {}

  /**
   * Pre-process a deletion operation, potentially altering its instructions or input data. Pre-operation events only
   * occur for the client which requested the operation.
   *
   * This batch-wise workflow occurs after individual {@link _preDelete} workflows and provides a final pre-flight check
   * before a database operation occurs.
   *
   * Modifications to the requested deletions are performed by mutating the operation object.
   * {@link updateSource}.
   *
   * @param {Document[]} documents                Document instances to be deleted
   * @param {DatabaseDeleteOperation} operation   Parameters of the database update operation
   * @param {BaseUser} user                       The User requesting the deletion operation
   * @returns {Promise<boolean|void>}             Return false to cancel the deletion operation entirely
   * @protected
   */
  static async _preDeleteOperation(documents, operation, user) {}

  /**
   * Post-process a deletion operation, reacting to database changes which have occurred. Post-operation events occur
   * for all connected clients.
   *
   * This batch-wise workflow occurs after individual {@link _onDelete} workflows.
   *
   * @param {Document[]} documents                The Document instances which were deleted
   * @param {DatabaseDeleteOperation} operation   Parameters of the database deletion operation
   * @param {BaseUser} user                       The User who performed the deletion operation
   * @returns {Promise<void>}
   * @protected
   */
  static async _onDeleteOperation(documents, operation, user) {}

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

  /**
   * A reusable helper for adding migration shims.
   * @param {object} data                       The data object being shimmed
   * @param {{[oldKey: string]: string}} shims  The mapping of old keys to new keys
   * @param {object} [options]                  Options passed to {@link foundry.utils.logCompatibilityWarning}
   * @param {string} [options.warning]          The deprecation message
   * @param {any} [options.value]               The value of the shim
   * @internal
   */
  static _addDataFieldShims(data, shims, options) {
    for ( const [oldKey, newKey] of Object.entries(shims) ) {
      this._addDataFieldShim(data, oldKey, newKey, options);
    }
  }

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

  /**
   * A reusable helper for adding a migration shim
   * The value of the data can be transformed during the migration by an optional application function.
   * @param {object} data               The data object being shimmed
   * @param {string} oldKey             The old field name
   * @param {string} newKey             The new field name
   * @param {object} [options]          Options passed to {@link foundry.utils.logCompatibilityWarning}
   * @param {string} [options.warning]  The deprecation message
   * @param {any} [options.value]       The value of the shim
   * @internal
   */
  static _addDataFieldShim(data, oldKey, newKey, options={}) {
    if ( hasProperty(data, oldKey) ) return;
    let oldTarget = data;
    let oldTargetKey = oldKey;
    if ( oldKey.includes(".") ) {
      const parts = oldKey.split(".");
      oldTarget = getProperty(data, parts.slice(0, -1).join("."));
      oldTargetKey = parts.at(-1);
    }
    Object.defineProperty(oldTarget, oldTargetKey, {
      get: () => {
        if ( options.warning ) logCompatibilityWarning(options.warning, options);
        else this._logDataFieldMigration(oldKey, newKey, options);
        return ("value" in options) ? options.value : getProperty(data, newKey);
      },
      set: value => {
        if ( newKey ) setProperty(data, newKey, value);
      },
      configurable: true
    });
  }

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

  /**
   * Define a simple migration from one field name to another.
   * The value of the data can be transformed during the migration by an optional application function.
   * @param {object} data     The data object being migrated
   * @param {string} oldKey   The old field name
   * @param {string} newKey   The new field name
   * @param {(data: object) => any} [apply]  An application function, otherwise the old value is applied
   * @returns {boolean}       Whether a migration was applied.
   * @internal
   */
  static _addDataFieldMigration(data, oldKey, newKey, apply) {
    if ( !hasProperty(data, newKey) && hasProperty(data, oldKey) ) {
      let oldTarget = data;
      let oldTargetKey = oldKey;
      if ( oldKey.includes(".") ) {
        const parts = oldKey.split(".");
        oldTarget = getProperty(data, parts.slice(0, -1).join("."));
        oldTargetKey = parts.at(-1);
      }
      const oldProp = Object.getOwnPropertyDescriptor(oldTarget, oldTargetKey);
      if ( oldProp && !oldProp.writable ) return false;
      setProperty(data, newKey, apply ? apply(data) : getProperty(data, oldKey));
      deleteProperty(data, oldKey);
      return true;
    }
    return false;
  }

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

  /**
   * Log a compatbility warning for the data field migration.
   * @param {string} oldKey       The old field name
   * @param {string} newKey       The new field name
   * @param {object} [options]    Options passed to {@link foundry.utils.logCompatibilityWarning}
   * @internal
   */
  static _logDataFieldMigration(oldKey, newKey, options={}) {
    const msg = `You are accessing ${this.name}#${oldKey} which has been migrated to ${this.name}#${newKey}`;
    logCompatibilityWarning(msg, options);
  }

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

  /**
   * @callback RecursiveFieldClearCallback
   * @param {object} data       The (partial) Document data.
   * @param {string} fieldName  The name of the field to clear.
   */

  /**
   * Clear the fields from the given Document data recursively.
   * @param {object} data                                     The (partial) Document data
   * @param {string[]} fieldNames                             The fields that are cleared
   * @param {object} [options]
   * @param {RecursiveFieldClearCallback} [options.callback]  A callback that is invoked on each field in order to clear
   *                                                          it.
   * @internal
   */
  static _clearFieldsRecursively(data, fieldNames, options={}) {
    if ( fieldNames.length === 0 ) return;
    const { callback } = options;
    for ( const fieldName of fieldNames ) {
      if ( typeof callback === "function" ) callback(data, fieldName);
      else deleteProperty(data, fieldName);
    }
    for ( const [collectionName, field] of Object.entries(this.hierarchy) ) {
      const collection = data[collectionName];
      if ( !collection ) continue;
      if ( field instanceof foundry.data.fields.EmbeddedDocumentField ) {
        field.model._clearFieldsRecursively(collection, fieldNames, options);
        continue;
      }
      for ( const embeddedData of collection ) {
        if ( embeddedData._tombstone ) continue;
        field.model._clearFieldsRecursively(embeddedData, fieldNames, options);
      }
    }
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static async _onCreateDocuments(documents, operation) {}

  /**
   * @deprecated since v12
   * @ignore
   */
  static async _onUpdateDocuments(documents, operation) {}

  /**
   * @deprecated since v12
   * @ignore
   */
  static async _onDeleteDocuments(documents, operation) {}
}

/**
 * @import {ActiveEffectData} from "./_types.mjs";
 */

/**
 * The ActiveEffect Document.
 * Defines the DataSchema and common behaviors for an ActiveEffect which are shared between both client and server.
 * @extends {Document<ActiveEffectData>}
 * @mixes ActiveEffectData
 * @category Documents
 */
class BaseActiveEffect extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "ActiveEffect",
    collection: "effects",
    hasTypeData: true,
    label: "DOCUMENT.ActiveEffect",
    labelPlural: "DOCUMENT.ActiveEffects",
    schemaVersion: "13.341",
    permissions: {
      create: "OWNER",
      delete: "OWNER"
    }
  }, {inplace: false}));

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

  /** @inheritdoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      img: new FilePathField({categories: ["IMAGE"]}),
      type: new DocumentTypeField(this, {initial: BASE_DOCUMENT_TYPE}),
      system: new TypeDataField(this),
      changes: new ArrayField(new SchemaField({
        key: new StringField({required: true}),
        value: new StringField({required: true}),
        mode: new NumberField({required: true, nullable: false, integer: true,
          initial: ACTIVE_EFFECT_MODES.ADD}),
        priority: new NumberField()
      })),
      disabled: new BooleanField(),
      duration: new SchemaField({
        startTime: new NumberField({initial: null}),
        seconds: new NumberField({integer: true, min: 0}),
        combat: new ForeignDocumentField(foundry.documents.BaseCombat),
        rounds: new NumberField({integer: true, min: 0}),
        turns: new NumberField({integer: true, min: 0}),
        startRound: new NumberField({integer: true, min: 0}),
        startTurn: new NumberField({integer: true, min: 0})
      }),
      description: new HTMLField({textSearch: true}),
      origin: new StringField({nullable: true, blank: false, initial: null}),
      tint: new ColorField({nullable: false, initial: "#ffffff"}),
      transfer: new BooleanField({initial: true}),
      statuses: new SetField(new StringField({required: true, blank: false})),
      sort: new IntegerSortField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "EFFECT"];

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

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;
    if ( this.parent instanceof foundry.documents.BaseActor ) {
      this.updateSource({transfer: false});
    }
  }

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

  /** @inheritDoc */
  static migrateData(data) {
    /**
     * icon -> img
     * @deprecated since v12
     */
    this._addDataFieldMigration(data, "icon", "img");
    return super.migrateData(data);
  }

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

  /** @inheritdoc */
  static shimData(data, options) {
    this._addDataFieldShim(data, "icon", "img", {since: 12, until: 14});
    return super.shimData(data, options);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get icon() {
    this.constructor._logDataFieldMigration("icon", "img", {since: 12, until: 14, once: true});
    return this.img;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  set icon(value) {
    this.constructor._logDataFieldMigration("icon", "img", {since: 12, until: 14, once: true});
    this.img = value;
  }
}

/**
 * @import {ActorDeltaData} from "./_types.mjs";
 * @import BaseActor from "./actor.mjs";
 * @import {DataModelUpdateOptions} from "@common/abstract/_types.mjs";
 */

/**
 * The ActorDelta Document.
 * Defines the DataSchema and common behaviors for an ActorDelta which are shared between both client and server.
 * ActorDeltas store a delta that can be applied to a particular Actor in order to produce a new Actor.
 * @extends {Document<ActorDeltaData>}
 * @mixes ActorDeltaData
 * @category Documents
 */
class BaseActorDelta extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "ActorDelta",
    collection: "delta",
    label: "DOCUMENT.ActorDelta",
    labelPlural: "DOCUMENT.ActorDeltas",
    isEmbedded: true,
    embedded: {
      Item: "items",
      ActiveEffect: "effects"
    },
    permissions: {
      create: "OWNER",
      delete: "OWNER"
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @override */
  static defineSchema() {
    const {BaseItem, BaseActiveEffect} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: false, nullable: true, initial: null}),
      type: new StringField({required: false, nullable: true, initial: null}),
      img: new FilePathField({categories: ["IMAGE"], nullable: true, initial: null, required: false}),
      system: new ObjectField(),
      items: new EmbeddedCollectionDeltaField(BaseItem),
      effects: new EmbeddedCollectionDeltaField(BaseActiveEffect),
      ownership: new DocumentOwnershipField({required: false, nullable: true, initial: null}),
      flags: new DocumentFlagsField()
    };
  }

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

  /** @override */
  getUserLevel(user) {
    user ||= game.user;
    const {INHERIT, NONE} = CONST.DOCUMENT_OWNERSHIP_LEVELS;
    if ( this.ownership ) {
      const level = this.ownership[user.id] ?? this.ownership.default ?? NONE;
      if ( level !== INHERIT ) return level;                                  // Defer inherited for Embedded
    }
    if ( this.parent ) return this.parent.getUserLevel(user);                 // Embedded Documents
    return NONE;                                                              // Otherwise, NONE
  }

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

  /**
   * Retrieve the base actor's collection, if it exists.
   * @param {string} collectionName  The collection name.
   * @returns {Collection}
   */
  getBaseCollection(collectionName) {
    const baseActor = this.parent?.baseActor;
    return baseActor?.getEmbeddedCollection(collectionName);
  }

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

  /**
   * Apply an ActorDelta to an Actor and return the resultant synthetic Actor.
   * @param {ActorDelta} delta    The ActorDelta.
   * @param {BaseActor} baseActor The base Actor.
   * @param {object} [context]    Context to supply to synthetic Actor instantiation.
   * @returns {BaseActor|null}
   */
  static applyDelta(delta, baseActor, context={}) {
    if ( !baseActor ) return null;
    if ( delta.parent?.isLinked ) return baseActor;

    // Get base actor data.
    const cls = getDocumentClass("Actor");
    const actorData = baseActor.toObject();
    const deltaData = delta.toObject();
    delete deltaData._id;

    // Merge embedded collections.
    BaseActorDelta.#mergeEmbeddedCollections(cls, actorData, deltaData);

    // Merge the rest of the delta.
    mergeObject(actorData, deltaData);
    return new cls(actorData, {parent: delta.parent, ...context});
  }

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

  /**
   * Merge delta Document embedded collections with the base Document.
   * @param {typeof Document} documentClass  The parent Document class.
   * @param {object} baseData                The base Document data.
   * @param {object} deltaData               The delta Document data.
   */
  static #mergeEmbeddedCollections(documentClass, baseData, deltaData) {
    for ( const collectionName of Object.keys(documentClass.hierarchy) ) {
      const baseCollection = baseData[collectionName];
      const deltaCollection = deltaData[collectionName];
      baseData[collectionName] = BaseActorDelta.#mergeEmbeddedCollection(baseCollection, deltaCollection);
      delete deltaData[collectionName];
    }
  }

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

  /**
   * Apply an embedded collection delta.
   * @param {object[]} base   The base embedded collection.
   * @param {object[]} delta  The delta embedded collection.
   * @returns {object[]}
   */
  static #mergeEmbeddedCollection(base=[], delta=[]) {
    const deltaIds = new Set();
    const records = [];
    for ( const record of delta ) {
      if ( !record._tombstone ) records.push(record);
      deltaIds.add(record._id);
    }
    for ( const record of base ) {
      if ( !deltaIds.has(record._id) ) records.push(record);
    }
    return records;
  }

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

  /** @override */
  static migrateData(source) {
    return foundry.documents.BaseActor.migrateData(source);
  }

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

  /**
   * Prepare changes to a descendent delta collection.
   * @param {object} changes                  Candidate source changes.
   * @param {DataModelUpdateOptions} options  Options which determine how the new data is merged.
   * @internal
   */
  _prepareDeltaUpdate(changes={}, options={}) {
    for ( const collectionName of Object.keys(this.constructor.hierarchy) ) {
      if ( collectionName in changes ) {
        this.getEmbeddedCollection(collectionName)._prepareDeltaUpdate(changes[collectionName], options);
      }
    }
  }

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

  /** @inheritDoc */
  updateSource(changes={}, options={}) {
    this._prepareDeltaUpdate(changes, options);
    return super.updateSource(changes, options);
  }

  /* -------------------------------------------- */
  /*  Serialization                               */
  /* -------------------------------------------- */

  /** @override */
  toObject(source=true) {
    const data = {};
    const value = source ? this._source : this;
    for ( const [name, field] of this.schema.entries() ) {
      const v = value[name];
      if ( !field.required && ((v === undefined) || (v === null)) ) continue; // Drop optional fields
      data[name] = source ? deepClone(value[name]) : field.toObject(value[name]);
    }
    return data;
  }
}

/**
 * The collection of data schema and document definitions for primary documents which are shared between the both the
 * client and the server.
 * @module data
 */


/**
 * @import {EmbeddedCollectionDelta} from "../abstract/_module.mjs";
 * @import BaseActor from "../documents/actor.mjs";
 * @import {PrototypeTokenData} from "@common/documents/_types.mjs";
 * @import {TextureDataFitMode} from "../constants.mjs";
 * @import {DataFieldOptions, FilePathFieldOptions, LightAnimationData} from "./_types.mjs";
 */

/**
 * A reusable document structure for the internal data used to render the appearance of a light source.
 * This is re-used by both the AmbientLightData and TokenData classes.
 *
 * @property {boolean} negative           Is this light source a negative source? (i.e. darkness source)
 * @property {number} priority            The priority of this source
 * @property {number} alpha               An opacity for the emitted light, if any
 * @property {number} angle               The angle of emission for this point source
 * @property {number} bright              The allowed radius of bright vision or illumination
 * @property {number} color               A tint color for the emitted light, if any
 * @property {number} coloration          The coloration technique applied in the shader
 * @property {number} contrast            The amount of contrast this light applies to the background texture
 * @property {number} dim                 The allowed radius of dim vision or illumination
 * @property {number} attenuation         Fade the difference between bright, dim, and dark gradually?
 * @property {number} luminosity          The luminosity applied in the shader
 * @property {number} saturation          The amount of color saturation this light applies to the background texture
 * @property {number} shadows             The depth of shadows this light applies to the background texture
 * @property {LightAnimationData} animation  An animation configuration for the source
 * @property {{min: number, max: number}} darkness  A darkness range (min and max) for which the source should be active
 */
class LightData extends DataModel {
  static defineSchema() {
    return {
      negative: new BooleanField(),
      priority: new NumberField({required: true, nullable: false, integer: true, initial: 0, min: 0}),
      alpha: new AlphaField({initial: 0.5}),
      angle: new AngleField({initial: 360, normalize: false}),
      bright: new NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01}),
      color: new ColorField({}),
      coloration: new NumberField({required: true, integer: true, initial: 1}),
      dim: new NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01}),
      attenuation: new AlphaField({initial: 0.5}),
      luminosity: new NumberField({required: true, nullable: false, initial: 0.5, min: 0, max: 1}),
      saturation: new NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1}),
      contrast: new NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1}),
      shadows: new NumberField({required: true, nullable: false, initial: 0, min: 0, max: 1}),
      animation: new SchemaField({
        type: new StringField({nullable: true, blank: false, initial: null}),
        speed: new NumberField({required: true, nullable: false, integer: true, initial: 5, min: 0, max: 10,
          validationError: "Light animation speed must be an integer between 0 and 10"}),
        intensity: new NumberField({required: true, nullable: false, integer: true, initial: 5, min: 1, max: 10,
          validationError: "Light animation intensity must be an integer between 1 and 10"}),
        reverse: new BooleanField()
      }),
      darkness: new SchemaField({
        min: new AlphaField({initial: 0}),
        max: new AlphaField({initial: 1})
      }, {
        validate: d => (d.min ?? 0) <= (d.max ?? 1),
        validationError: "darkness.max may not be less than darkness.min"
      })
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["LIGHT"];

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

  /** @inheritDoc */
  static migrateData(data) {
    /**
     * Migration of negative luminosity
     * @deprecated since v12
     */
    const luminosity = data.luminosity;
    if ( luminosity < 0) {
      data.luminosity = 1 - luminosity;
      data.negative = true;
    }
    return super.migrateData(data);
  }
}

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

/**
 * A data model intended to be used as an inner EmbeddedDataField which defines a geometric shape.
 *
 * @property {string} type                The type of shape, a value in ShapeData.TYPES.
 *                                        For rectangles, the x/y coordinates are the top-left corner.
 *                                        For circles, the x/y coordinates are the center of the circle.
 *                                        For polygons, the x/y coordinates are the first point of the polygon.
 * @property {number|null} width          For rectangles, the pixel width of the shape.
 * @property {number|null} height         For rectangles, the pixel width of the shape.
 * @property {number|null} radius         For circles, the pixel radius of the shape.
 * @property {number[]} [points]          For polygons, the array of polygon coordinates which comprise the shape.
 */
class ShapeData extends DataModel {
  static defineSchema() {
    return {
      type: new StringField({required: true, blank: false, choices: Object.values(this.TYPES), initial: "r"}),
      width: new NumberField({required: true, integer: true, min: 0, label: "Width"}),
      height: new NumberField({required: true, integer: true, min: 0, label: "Height"}),
      radius: new NumberField({required: true, integer: true, positive: true}),
      points: new ArrayField(new NumberField({required: true, nullable: false}))
    };
  }

  /**
   * The primitive shape types which are supported
   * @enum {string}
   */
  static TYPES = {
    RECTANGLE: "r",
    CIRCLE: "c",
    ELLIPSE: "e",
    POLYGON: "p"
  };
}

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

/**
 * A data model intended to be used as an inner EmbeddedDataField which defines a geometric shape.
 * @abstract
 *
 * @property {string} type                                          The type of shape, a value in BaseShapeData.TYPES
 * @property {boolean} [hole=false]                                 Is this shape a hole?
 */
class BaseShapeData extends DataModel {

  /**
   * The possible shape types.
   * @type {Readonly<{
   *   rectangle: RectangleShapeData,
   *   circle: CircleShapeData,
   *   ellipse: EllipseShapeData,
   *   polygon: PolygonShapeData
   * }>}
   */
  static get TYPES() {
    return BaseShapeData.#TYPES ??= Object.freeze({
      [RectangleShapeData.TYPE]: RectangleShapeData,
      [CircleShapeData.TYPE]: CircleShapeData,
      [EllipseShapeData.TYPE]: EllipseShapeData,
      [PolygonShapeData.TYPE]: PolygonShapeData
    });
  }

  static #TYPES;

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

  /**
   * The type of this shape.
   * @type {string}
   */
  static TYPE = "";

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

  /** @override */
  static defineSchema() {
    return {
      type: new StringField({required: true, blank: false, initial: this.TYPE,
        validate: value => value === this.TYPE, validationError: `must be equal to "${this.TYPE}"`}),
      hole: new BooleanField()
    };
  }
}

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

/**
 * The data model for a rectangular shape.
 *
 * @property {number} x               The top-left x-coordinate in pixels before rotation.
 * @property {number} y               The top-left y-coordinate in pixels before rotation.
 * @property {number} width           The width of the rectangle in pixels.
 * @property {number} height          The height of the rectangle in pixels.
 * @property {number} rotation        The rotation around the center of the rectangle in degrees.
 */
class RectangleShapeData extends BaseShapeData {

  static {
    Object.defineProperty(this, "TYPE", {value: "rectangle"});
  }

  /** @inheritdoc */
  static defineSchema() {
    return Object.assign(super.defineSchema(), {
      x: new NumberField({required: true, nullable: false, initial: undefined}),
      y: new NumberField({required: true, nullable: false, initial: undefined}),
      width: new NumberField({required: true, nullable: false, initial: undefined, positive: true}),
      height: new NumberField({required: true, nullable: false, initial: undefined, positive: true}),
      rotation: new AngleField()
    });
  }
}

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

/**
 * The data model for a circle shape.
 *
 * @property {number} x         The x-coordinate of the center point in pixels.
 * @property {number} y         The y-coordinate of the center point in pixels.
 * @property {number} radius    The radius of the circle in pixels.
 */
class CircleShapeData extends BaseShapeData {

  static {
    Object.defineProperty(this, "TYPE", {value: "circle"});
  }

  /** @inheritdoc */
  static defineSchema() {
    return Object.assign(super.defineSchema(), {
      x: new NumberField({required: true, nullable: false, initial: undefined}),
      y: new NumberField({required: true, nullable: false, initial: undefined}),
      radius: new NumberField({required: true, nullable: false, initial: undefined, positive: true})
    });
  }
}

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

/**
 * The data model for an ellipse shape.
 *
 * @property {number} x               The x-coordinate of the center point in pixels.
 * @property {number} y               The y-coordinate of the center point in pixels.
 * @property {number} radiusX         The x-radius of the circle in pixels.
 * @property {number} radiusY         The y-radius of the circle in pixels.
 * @property {number} rotation        The rotation around the center of the rectangle in degrees.
 */
class EllipseShapeData extends BaseShapeData {

  static {
    Object.defineProperty(this, "TYPE", {value: "ellipse"});
  }

  /** @inheritdoc */
  static defineSchema() {
    return Object.assign(super.defineSchema(), {
      x: new NumberField({required: true, nullable: false, initial: undefined}),
      y: new NumberField({required: true, nullable: false, initial: undefined}),
      radiusX: new NumberField({required: true, nullable: false, initial: undefined, positive: true}),
      radiusY: new NumberField({required: true, nullable: false, initial: undefined, positive: true}),
      rotation: new AngleField()
    });
  }
}

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

/**
 * The data model for a polygon shape.
 *
 * @property {number[]} points      The points of the polygon ([x0, y0, x1, y1, ...]).
 *                                  The polygon must not be self-intersecting.
 */
class PolygonShapeData extends BaseShapeData {

  static {
    Object.defineProperty(this, "TYPE", {value: "polygon"});
  }

  /** @inheritdoc */
  static defineSchema() {
    return Object.assign(super.defineSchema(), {
      points: new ArrayField(new NumberField({required: true, nullable: false, initial: undefined}),
        {validate: value => {
          if ( value.length % 2 !== 0 ) throw new Error("must have an even length");
          if ( value.length < 6 ) throw new Error("must have at least 3 points");
        }})
    });
  }
}

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

/**
 * A {@link foundry.data.fields.SchemaField} subclass used to represent texture data.
 * @property {string|null} src            The URL of the texture source.
 * @property {number} anchorX             The X coordinate of the texture anchor.
 * @property {number} anchorY             The Y coordinate of the texture anchor.
 * @property {number} scaleX              The scale of the texture in the X dimension.
 * @property {number} scaleY              The scale of the texture in the Y dimension.
 * @property {number} offsetX             The X offset of the texture with (0,0) in the top left.
 * @property {number} offsetY             The Y offset of the texture with (0,0) in the top left.
 * @property {TextureDataFitMode} fit     The texture fit mode.
 * @property {number} rotation            An angle of rotation by which this texture is rotated around its center.
 * @property {string} tint                The tint applied to the texture.
 * @property {number} alphaThreshold      Only pixels with an alpha value at or above this value are consider solid
 *                                        w.r.t. to occlusion testing and light/weather blocking.
 */
class TextureData extends SchemaField {
  /**
   * @param {DataFieldOptions} options        Options which are forwarded to the SchemaField constructor
   * @param {Pick<FilePathFieldOptions, "categories"|"initial"|"wildcard"|"label">} srcOptions
   *                                          Additional options for the src field
   */
  constructor(options={}, {categories=["IMAGE", "VIDEO"], initial={}, wildcard=false, label=""}={}) {
    /** @deprecated since v12 */
    if ( typeof initial === "string" ) {
      const msg = "Passing the initial value of the src field as a string is deprecated. Pass {src} instead.";
      logCompatibilityWarning(msg, {since: 12, until: 14});
      initial = {src: initial};
    }
    super({
      src: new FilePathField({required: true, categories, initial: initial.src ?? null, label,
        virtual: !wildcard, wildcard}),
      anchorX: new NumberField({required: true, nullable: false, initial: initial.anchorX ?? 0}),
      anchorY: new NumberField({required: true, nullable: false, initial: initial.anchorY ?? 0}),
      offsetX: new NumberField({required: true, nullable: false, integer: true, initial: initial.offsetX ?? 0}),
      offsetY: new NumberField({required: true, nullable: false, integer: true, initial: initial.offsetY ?? 0}),
      fit: new StringField({required: true, initial: initial.fit ?? "fill", choices: CONST.TEXTURE_DATA_FIT_MODES}),
      scaleX: new NumberField({required: true, nullable: false, initial: initial.scaleX ?? 1}),
      scaleY: new NumberField({required: true, nullable: false, initial: initial.scaleY ?? 1}),
      rotation: new AngleField({initial: initial.rotation ?? 0}),
      tint: new ColorField({required: true, nullable: false, initial: initial.tint ?? "#ffffff"}),
      alphaThreshold: new AlphaField({nullable: false, initial: initial.alphaThreshold ?? 0})
    }, options);
  }
}

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

/**
 * Extend the base TokenData to define a PrototypeToken which exists within a parent Actor.
 * @extends DataModel<PrototypeTokenData>
 * @property {boolean} randomImg      Does the prototype token use a random wildcard image?
 */
class PrototypeToken extends DataModel {
  constructor(data={}, options={}) {
    super(data, options);
    Object.defineProperty(this, "apps", {value: {}});
  }

  /** @override */
  static defineSchema() {
    const schema = foundry.documents.BaseToken.defineSchema();
    const excluded = ["_id", "actorId", "delta", "x", "y", "elevation", "shape", "sort", "hidden", "locked",
      "_movementHistory", "_regions"];
    for ( const fieldName of excluded ) {
      delete schema[fieldName];
    }
    schema.name.textSearch = schema.name.options.textSearch = false;
    schema.randomImg = new BooleanField();
    schema.appendNumber = new BooleanField();
    schema.prependAdjective = new BooleanField();
    return schema;
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "TOKEN"];

  /**
   * The Actor which owns this Prototype Token
   * @type {BaseActor}
   */
  get actor() {
    return this.parent;
  }

  /** @inheritdoc */
  toObject(source=true) {
    const data = super.toObject(source);
    data.actorId = this.document?.id;
    return data;
  }

  /** @ignore */
  static get database() {
    return globalThis.CONFIG.DatabaseBackend;
  }

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

  /** @inheritDoc */
  _initializeSource(data, options={}) {
    if ( data instanceof PrototypeToken ) data = data.toObject();
    PrototypeTokenOverrides.applyOverrides(data, this.parent?.type);
    return super._initializeSource(data, options);
  }

  /* -------------------------------------------- */
  /*  Document Compatibility Methods              */
  /* -------------------------------------------- */

  /**
   * @see {@link foundry.abstract.Document#update}
   * @ignore
   */
  update(data, options) {
    return this.actor.update({prototypeToken: data}, options);
  }

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

  /**
   * @see {@link foundry.abstract.Document#getFlag}
   * @ignore
   */
  getFlag(...args) {
    return foundry.abstract.Document.prototype.getFlag.call(this, ...args);
  }

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

  /**
   * @see {@link foundry.abstract.Document#getFlag}
   * @ignore
   */
  setFlag(...args) {
    return foundry.abstract.Document.prototype.setFlag.call(this, ...args);
  }

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

  /**
   * @see {@link foundry.abstract.Document#unsetFlag}
   * @ignore
   */
  async unsetFlag(...args) {
    return foundry.abstract.Document.prototype.unsetFlag.call(this, ...args);
  }

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

  /**
   * @see {@link foundry.abstract.Document#testUserPermission}
   * @ignore
   */
  testUserPermission(user, permission, {exact=false}={}) {
    return this.actor.testUserPermission(user, permission, {exact});
  }

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

  /**
   * @see {@link foundry.documents.BaseActor#isOwner}
   * @ignore
   */
  get isOwner() {
    return this.actor.isOwner;
  }
}

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

/**
 * The data model for the the core.prototypeTokenOverrides setting.
 */
class PrototypeTokenOverrides extends DataModel {
  /** @override */
  static defineSchema() {
    const labelFor = n => `TOKEN.FIELDS.${n}.label`;
    return getDocumentClass("Actor").TYPES.reduce((types, type) => {
      const prototypeSchema = PrototypeToken.defineSchema();

      // Select a subset of the prototype schema, make all non-recursive fields nullable, and set an initial of null.
      const subSchema = {
        sight: new SchemaField({
          enabled: new BooleanField({required: false, initial: undefined, label: labelFor("sight.enabled")})
        }),
        ring: new SchemaField({
          enabled: new BooleanField({required: false, initial: undefined, label: labelFor("ring.enabled")})
        }),
        turnMarker: prototypeSchema.turnMarker
      };
      for ( const fieldName of ["displayName", "displayBars", "disposition", "lockRotation"] ) {
        const field = prototypeSchema[fieldName];
        field.label = labelFor(fieldName);
        field.required = false;
        field.initial = undefined;
        subSchema[fieldName] = field;
      }
      for (const field of Object.values(subSchema.turnMarker.fields)) {
        field.label = labelFor(`turnMarker.${field.name}`);
        field.required = false;
        field.initial = undefined;
      }
      types[type] = new SchemaField(subSchema);
      return types;
    }, {});
  }

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

  /**
   * Localize all non-recursive data fields on first load of the application.
   * @param {fields.DataField[]} [fields] Subfields of a recursive field
   * @param {Record<string, string>} [cache] A running cache of localization results
   */
  static localizeFields(fields, cache={}) {
    const Cls = PrototypeTokenOverrides;
    if ( Cls.#localized ) return;
    fields ??= Object.values(Cls.schema.fields).flatMap(f => Object.values(f.fields));
    for ( const field of fields ) {
      if ( field instanceof foundry.data.fields.SchemaField ) {
        Cls.localizeFields(Object.values(field.fields, cache));
        continue;
      }
      field.label = (cache[field.label] ??= game.i18n.localize(field.label));
    }
    if ( !fields ) Cls.#localized = true;
  }

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["TOKEN"];

  /**
   * The named of the world setting that stores the prototype token overrides
   * @type {"prototypeTokenOverrides"}
   */
  static SETTING = "prototypeTokenOverrides";

  /**
   * A cached copy of the currently-configured overrides
   * @returns {PrototypeTokenOverrides}
   */
  static get overrides() {
    return this.#overrides ??= game.settings.get("core", PrototypeTokenOverrides.SETTING);
  }

  /**
   * Set or clear the cached overrides.
   * @param {PrototypeTokenOverrides|null} value
   */
  static set overrides(value) {
    this.#overrides = value;
  }

  /**
   * @type {PrototypeTokenOverrides|null}
   */
  static #overrides = null;

  /** Have the fields been localized? Done later since this model may be initialized before i18n is ready. */
  static #localized = false;

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

  /**
   * Apply configured overrides to prototype token data.
   * @param {object} source The prototype token source data on which to operate
   * @param {string} [actorType] The prototype parent's actor type: used to retrieve type-specific overrides
   */
  static applyOverrides(source, actorType) {
    if ( !game.settings || !game.model ) return; // Server-side or Setup
    const setting = PrototypeTokenOverrides.overrides;
    const global = flattenObject(setting.base);
    const typed = flattenObject(setting[actorType] ?? {});
    for ( const [path, baseValue] of Object.entries(global) ) {
      const override = typed[path] ?? baseValue;
      if ( override !== undefined ) setProperty(source, path, override);
    }
  }

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

  /**
   * Apply configured overrides to all Actor documents within the World.
   */
  static applyAll() {
    PrototypeTokenOverrides.#overrides = null;
    for ( const actor of game.actors ) {
      this.applyOverrides(actor.prototypeToken._source, actor.type);
      actor.prototypeToken.reset();
      actor.render();
    }
  }
}

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

/**
 * A minimal data model used to represent a tombstone entry inside an {@link foundry.abstract.EmbeddedCollectionDelta}.
 *
 * @property {string} _id              The _id of the base Document that this tombstone represents.
 * @property {boolean} _tombstone      A property that identifies this entry as a tombstone.
 */
class TombstoneData extends DataModel {
  /** @override */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      _tombstone: new BooleanField({initial: true, validate: v => v === true, validationError: "must be true"})
    };
  }
}

/**
 * @import {ActorData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The Actor Document.
 * Defines the DataSchema and common behaviors for an Actor which are shared between both client and server.
 * @extends {Document<ActorData>}
 * @mixes ActorData
 * @category Documents
 */
class BaseActor extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Actor",
    collection: "actors",
    indexed: true,
    compendiumIndexFields: ["_id", "name", "img", "type", "sort", "folder"],
    embedded: {ActiveEffect: "effects", Item: "items"},
    hasTypeData: true,
    label: "DOCUMENT.Actor",
    labelPlural: "DOCUMENT.Actors",
    permissions: {
      create: this.#canCreate,
      update: this.#canUpdate
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

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

  /** @inheritdoc */
  static defineSchema() {
    const {BaseItem, BaseActiveEffect, BaseFolder} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      img: new FilePathField({categories: ["IMAGE"], initial: data => {
        return this.implementation.getDefaultArtwork(data).img;
      }}),
      type: new DocumentTypeField(this),
      system: new TypeDataField(this),
      prototypeToken: new EmbeddedDataField(PrototypeToken),
      items: new EmbeddedCollectionField(BaseItem),
      effects: new EmbeddedCollectionField(BaseActiveEffect),
      folder: new ForeignDocumentField(BaseFolder),
      sort: new IntegerSortField(),
      ownership: new DocumentOwnershipField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /**
   * The default icon used for newly created Actor documents.
   * @type {string}
   */
  static DEFAULT_ICON = DEFAULT_TOKEN;

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

  /**
   * Determine default artwork based on the provided actor data.
   * @param {ActorData} actorData                      The source actor data.
   * @returns {{img: string, texture: {src: string}}}  Candidate actor image and prototype token artwork.
   */
  static getDefaultArtwork(actorData) {
    return {
      img: this.DEFAULT_ICON,
      texture: {
        src: this.DEFAULT_ICON
      }
    };
  }

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

  /** @inheritdoc */
  _initializeSource(source, options) {
    source = super._initializeSource(source, options);
    source.prototypeToken.name = source.prototypeToken.name || source.name;
    source.prototypeToken.texture.src = source.prototypeToken.texture.src || source.img;
    return source;
  }

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

  /** @inheritDoc */
  _initialize(options) {
    super._initialize(options);
    DocumentStatsField._shimDocument(this);
  }

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

  /** @override */
  static canUserCreate(user) {
    return user.hasPermission("ACTOR_CREATE");
  }

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

  /**
   * Is a user able to create this actor?
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc) {
    if ( !user.hasPermission("ACTOR_CREATE") ) return false;      // User cannot create actors at all
    if ( doc._source.prototypeToken.randomImg && !user.hasPermission("FILES_BROWSE") ) return false;
    return true;
  }

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

  /**
   * Is a user able to update an existing actor?
   * @type {DocumentPermissionTest}
   */
  static #canUpdate(user, doc, data) {
    if ( !doc.testUserPermission(user, "OWNER") ) return false; // Ownership is required.

    // Users can only enable token wildcard images if they have FILES_BROWSE permission.
    const tokenChange = data?.prototypeToken || {};
    const enablingRandomImage = tokenChange.randomImg === true;
    if ( enablingRandomImage ) return user.hasPermission("FILES_BROWSE");

    // Users can only change a token wildcard path if they have FILES_BROWSE permission.
    const randomImageEnabled = doc._source.prototypeToken.randomImg && (tokenChange.randomImg !== false);
    const changingRandomImage = ("img" in tokenChange) && randomImageEnabled;
    if ( changingRandomImage ) return user.hasPermission("FILES_BROWSE");
    return true;
  }

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

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;
    if ( !this.prototypeToken.name ) this.prototypeToken.updateSource({name: this.name});
    if ( !this.prototypeToken.texture.src || (this.prototypeToken.texture.src === DEFAULT_TOKEN)) {
      const { texture } = this.constructor.getDefaultArtwork(this.toObject());
      this.prototypeToken.updateSource("img" in data ? { texture: { src: this.img } } : { texture });
    }
  }

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

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    const allowed = await super._preUpdate(changed, options, user);
    if ( allowed === false ) return false;
    if ( changed.img && !getProperty(changed, "prototypeToken.texture.src") ) {
      const { texture } = this.constructor.getDefaultArtwork(foundry.utils.mergeObject(this.toObject(), changed));
      if ( !this.prototypeToken.texture.src || (this.prototypeToken.texture.src === texture?.src) ) {
        setProperty(changed, "prototypeToken.texture.src", changed.img);
      }
    }
  }

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

  /** @inheritDoc */
  static migrateData(source) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source);
  }

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

  /** @inheritDoc */
  static shimData(source, options) {
    DocumentStatsField._shimData(this, source, options);
    return super.shimData(source, options);
  }
}

/**
 * @import {AdventureData} from "./_types.mjs";
 */

/**
 * The Adventure Document.
 * Defines the DataSchema and common behaviors for an Adventure which are shared between both client and server.
 * @extends {Document<AdventureData>}
 * @mixes AdventureData
 * @category Documents
 */
class BaseAdventure extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Adventure",
    collection: "adventures",
    compendiumIndexFields: ["_id", "name", "description", "img", "sort", "folder"],
    label: "DOCUMENT.Adventure",
    labelPlural: "DOCUMENT.Adventures",
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const documents = foundry.documents;
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      img: new FilePathField({categories: ["IMAGE"]}),
      caption: new HTMLField(),
      description: new HTMLField({textSearch: true}),
      actors: new SetField(new EmbeddedDataField(documents.BaseActor)),
      combats: new SetField(new EmbeddedDataField(documents.BaseCombat)),
      items: new SetField(new EmbeddedDataField(documents.BaseItem)),
      journal: new SetField(new EmbeddedDataField(documents.BaseJournalEntry)),
      scenes: new SetField(new EmbeddedDataField(documents.BaseScene)),
      tables: new SetField(new EmbeddedDataField(documents.BaseRollTable)),
      macros: new SetField(new EmbeddedDataField(documents.BaseMacro)),
      cards: new SetField(new EmbeddedDataField(documents.BaseCards)),
      playlists: new SetField(new EmbeddedDataField(documents.BasePlaylist)),
      folders: new SetField(new EmbeddedDataField(documents.BaseFolder)),
      folder: new ForeignDocumentField(documents.BaseFolder),
      sort: new IntegerSortField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "ADVENTURE"];

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

  /**
   * An array of the fields which provide imported content from the Adventure.
   * @type {Record<string, typeof Document>}
   */
  static get contentFields() {
    const content = {};
    for ( const field of this.schema ) {
      if ( field instanceof SetField ) content[field.name] = field.element.model.implementation;
    }
    return content;
  }

  /**
   * Provide a thumbnail image path used to represent the Adventure document.
   * @type {string}
   */
  get thumbnail() {
    return this.img;
  }
}

/**
 * @import {AmbientLightData} from "./_types.mjs";
 */

/**
 * The AmbientLight Document.
 * Defines the DataSchema and common behaviors for an AmbientLight which are shared between both client and server.
 * @extends {Document<AmbientLightData>}
 * @mixes AmbientLightData
 * @category Documents
 */
class BaseAmbientLight extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "AmbientLight",
    collection: "lights",
    label: "DOCUMENT.AmbientLight",
    labelPlural: "DOCUMENT.AmbientLights",
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      x: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      y: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      elevation: new NumberField({required: true, nullable: false, initial: 0}),
      rotation: new AngleField(),
      walls: new BooleanField({initial: true}),
      vision: new BooleanField(),
      config: new EmbeddedDataField(LightData),
      hidden: new BooleanField(),
      flags: new DocumentFlagsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "AMBIENT_LIGHT"];
}

/**
 * @import {AmbientSoundData} from "./_types.mjs";
 */

/**
 * The AmbientSound Document.
 * Defines the DataSchema and common behaviors for an AmbientSound which are shared between both client and server.
 * @extends {Document<AmbientSoundData>}
 * @mixes AmbientSoundData
 * @category Documents
 */
class BaseAmbientSound extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "AmbientSound",
    collection: "sounds",
    label: "DOCUMENT.AmbientSound",
    labelPlural: "DOCUMENT.AmbientSounds",
    isEmbedded: true,
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const oneToTen = {required: true, nullable: false, integer: true, initial: 5, min: 1, max: 10, step: 1};
    return {
      _id: new DocumentIdField(),
      x: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      y: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      elevation: new NumberField({required: true, nullable: false, initial: 0}),
      radius: new NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01}),
      path: new FilePathField({categories: ["AUDIO"]}),
      repeat: new BooleanField(),
      volume: new AlphaField({initial: 0.5, step: 0.01}),
      walls: new BooleanField({initial: true}),
      easing: new BooleanField({initial: true}),
      hidden: new BooleanField(),
      darkness: new SchemaField({
        min: new AlphaField({initial: 0}),
        max: new AlphaField({initial: 1})
      }),
      effects: new SchemaField({
        base: new SchemaField({
          type: new StringField(),
          intensity: new NumberField({...oneToTen})
        }),
        muffled: new SchemaField({
          type: new StringField(),
          intensity: new NumberField({...oneToTen})
        })
      }),
      flags: new DocumentFlagsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "AMBIENT_SOUND"];
}

/**
 * @import {CardData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The Card Document.
 * Defines the DataSchema and common behaviors for a Card which are shared between both client and server.
 * @extends {Document<CardData>}
 * @mixes CardData
 * @category Documents
 */
class BaseCard extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Card",
    collection: "cards",
    hasTypeData: true,
    indexed: true,
    label: "DOCUMENT.Card",
    labelPlural: "DOCUMENT.CardPlural",
    permissions: {
      create: this.#canCreate,
      update: this.#canUpdate,
      delete: "OWNER"
    },
    compendiumIndexFields: ["name", "type", "suit", "sort"],
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      description: new HTMLField(),
      type: new DocumentTypeField(this, {initial: BASE_DOCUMENT_TYPE}),
      system: new TypeDataField(this),
      suit: new StringField({required: true}),
      value: new NumberField({required: true}),
      back: new SchemaField({
        name: new StringField(),
        text: new HTMLField(),
        img: new FilePathField({categories: ["IMAGE", "VIDEO"]})
      }),
      faces: new ArrayField(new SchemaField({
        name: new StringField(),
        text: new HTMLField(),
        img: new FilePathField({categories: ["IMAGE", "VIDEO"], initial: () => this.DEFAULT_ICON})
      })),
      face: new NumberField({required: true, initial: null, integer: true, min: 0}),
      drawn: new BooleanField(),
      origin: new ForeignDocumentField(foundry.documents.BaseCards),
      width: new NumberField({integer: true, positive: true}),
      height: new NumberField({integer: true, positive: true}),
      rotation: new AngleField(),
      sort: new IntegerSortField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

  /**
   * The default icon used for a Card face that does not have a custom image set
   * @type {string}
   */
  static DEFAULT_ICON = "icons/svg/card-joker.svg";

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "CARD"];

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

  /**
   * Is a User able to create a new Card within this parent?
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc, data) {
    if ( user.isGM ) return true;                             // GM users can always create
    if ( doc.parent.type !== "deck" ) return true;            // Users can pass cards to card hands or piles
    return doc.parent.testUserPermission(user, "OWNER");      // Otherwise require owner permission of the parent document
  }

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

  /**
   * Is a user able to update an existing Card?
   * @type {DocumentPermissionTest}
   */
  static #canUpdate(user, doc, data) {
    if ( user.isGM ) return true;                               // GM users can always update
    const wasDrawn = new Set(["drawn", "_id"]);                 // Users can draw cards from a deck
    if ( new Set(Object.keys(data)).equals(wasDrawn) ) return true;
    return doc.parent.testUserPermission(user, "OWNER");        // Otherwise require owner permission of the parent document
  }
}

/**
 * @import {CardsData} from "./_types.mjs";
 */

/**
 * The Cards Document.
 * Defines the DataSchema and common behaviors for a Cards Document which are shared between both client and server.
 * @extends {Document<CardsData>}
 * @mixes CardsData
 * @category Documents
 */
class BaseCards extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Cards",
    collection: "cards",
    indexed: true,
    compendiumIndexFields: ["_id", "name", "description", "img", "type", "sort", "folder"],
    embedded: {Card: "cards"},
    hasTypeData: true,
    label: "DOCUMENT.Cards",
    labelPlural: "DOCUMENT.CardsPlural",
    permissions: {
      create: "CARDS_CREATE",
      delete: "OWNER"
    },
    coreTypes: ["deck", "hand", "pile"],
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const {BaseCard, BaseFolder} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      type: new DocumentTypeField(this),
      description: new HTMLField({textSearch: true}),
      img: new FilePathField({categories: ["IMAGE", "VIDEO"], initial: () => this.DEFAULT_ICON}),
      system: new TypeDataField(this),
      cards: new EmbeddedCollectionField(BaseCard),
      width: new NumberField({integer: true, positive: true}),
      height: new NumberField({integer: true, positive: true}),
      rotation: new AngleField(),
      displayCount: new BooleanField(),
      folder: new ForeignDocumentField(BaseFolder),
      sort: new IntegerSortField(),
      ownership: new DocumentOwnershipField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "CARDS"];

  /**
   * The default icon used for a cards stack that does not have a custom image set
   * @type {string}
   */
  static DEFAULT_ICON = "icons/svg/card-hand.svg";

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

  /** @inheritDoc */
  _initialize(options) {
    super._initialize(options);
    DocumentStatsField._shimDocument(this);
  }

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

  /** @inheritDoc */
  static migrateData(source) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source);
  }

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

  /** @inheritDoc */
  static shimData(source, options) {
    DocumentStatsField._shimData(this, source, options);
    return super.shimData(source, options);
  }
}

/**
 * @import {ChatMessageData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The ChatMessage Document.
 * Defines the DataSchema and common behaviors for a ChatMessage which are shared between both client and server.
 * @extends {Document<ChatMessageData>}
 * @mixes ChatMessageData
 * @category Documents
 */
class BaseChatMessage extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "ChatMessage",
    collection: "messages",
    label: "DOCUMENT.ChatMessage",
    labelPlural: "DOCUMENT.ChatMessages",
    hasTypeData: true,
    isPrimary: true,
    permissions: {
      create: this.#canCreate,
      delete: "OWNER"
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const documents = foundry.documents;
    return {
      _id: new DocumentIdField(),
      type: new DocumentTypeField(this, {initial: BASE_DOCUMENT_TYPE}),
      system: new TypeDataField(this),
      style: new NumberField({required: true, choices: Object.values(CHAT_MESSAGE_STYLES),
        initial: CHAT_MESSAGE_STYLES.OTHER, validationError: "must be a value in CONST.CHAT_MESSAGE_STYLES"}),
      author: new DocumentAuthorField(documents.BaseUser),
      timestamp: new NumberField({required: true, nullable: false, initial: Date.now}),
      flavor: new HTMLField(),
      title: new StringField(),
      content: new HTMLField({textSearch: true}),
      speaker: new SchemaField({
        scene: new ForeignDocumentField(documents.BaseScene, {idOnly: true}),
        actor: new ForeignDocumentField(documents.BaseActor, {idOnly: true}),
        token: new ForeignDocumentField(documents.BaseToken, {idOnly: true}),
        alias: new StringField()
      }),
      whisper: new ArrayField(new ForeignDocumentField(documents.BaseUser, {idOnly: true})),
      blind: new BooleanField(),
      rolls: new ArrayField(new JSONField({validate: BaseChatMessage.#validateRoll})),
      sound: new FilePathField({categories: ["AUDIO"]}),
      emote: new BooleanField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

  /**
   * Is a user able to create a new chat message?
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc) {
    if ( user.isGM ) return true;
    if ( user.id !== doc._source.author ) return false; // You cannot impersonate a different user
    return user.hasRole("PLAYER");                      // Any player can create messages
  }

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

  /**
   * Validate that Rolls belonging to the ChatMessage document are valid
   * @param {string} rollJSON     The serialized Roll data
   */
  static #validateRoll(rollJSON) {
    const roll = JSON.parse(rollJSON);
    if ( !roll.evaluated ) throw new Error("Roll objects added to ChatMessage documents must be evaluated");
  }

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

  /** @inheritDoc */
  getUserLevel(user) {
    user ||= game.user;
    if ( user.id === this._source.author ) return DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return super.getUserLevel(user);
  }

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

  /** @inheritdoc */
  static migrateData(data) {
    /**
     * V12 migration from user to author
     * @deprecated since v12
     */
    this._addDataFieldMigration(data, "user", "author");
    BaseChatMessage.#migrateTypeToStyle(data);
    return super.migrateData(data);
  }

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

  /**
   * Migrate the type field to the style field in order to allow the type field to be used for system sub-types.
   * @param {Partial<ChatMessageData>} data
   */
  static #migrateTypeToStyle(data) {
    if ( (typeof data.type !== "number") || ("style" in data) ) return;
    // WHISPER, ROLL, and any other invalid style are redirected to OTHER
    data.style = Object.values(CHAT_MESSAGE_STYLES).includes(data.type) ? data.type : 0;
    data.type = BASE_DOCUMENT_TYPE;
  }

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

  /** @inheritdoc */
  static shimData(data, options) {
    this._addDataFieldShim(data, "user", "author", {since: 12, until: 14});
    return super.shimData(data, options);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get user() {
    this.constructor._logDataFieldMigration("user", "author", {since: 12, until: 14});
    return this.author;
  }
}

/**
 * @import {CombatData, CombatantData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The Combat Document.
 * Defines the DataSchema and common behaviors for a Combat which are shared between both client and server.
 * @extends {Document<CombatData>}
 * @mixes CombatData
 * @category Documents
 */
class BaseCombat extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Combat",
    collection: "combats",
    label: "DOCUMENT.Combat",
    labelPlural: "DOCUMENT.Combats",
    embedded: {
      Combatant: "combatants",
      CombatantGroup: "groups"
    },
    hasTypeData: true,
    permissions: {
      update: this.#canUpdate
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "COMBAT"];

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

  /** @inheritdoc */
  static defineSchema() {
    const {BaseScene, BaseCombatant, BaseCombatantGroup} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      type: new DocumentTypeField(this, {initial: BASE_DOCUMENT_TYPE}),
      system: new TypeDataField(this),
      scene: new ForeignDocumentField(BaseScene),
      groups: new EmbeddedCollectionField(BaseCombatantGroup),
      combatants: new EmbeddedCollectionField(BaseCombatant),
      active: new BooleanField(),
      round: new NumberField({required: true, nullable: false, integer: true, min: 0, initial: 0}),
      turn: new NumberField({required: true, integer: true, min: 0, initial: null}),
      sort: new IntegerSortField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /**
   * Is a user able to update an existing Combat?
   * @type {DocumentPermissionTest}
   */
  static #canUpdate(user, doc, data) {
    if ( user.isGM ) return true;                             // GM users can do anything
    const turnOnly = ["_id", "round", "turn", "combatants"];  // Players may only modify a subset of fields
    if ( Object.keys(data).some(k => !turnOnly.includes(k)) ) return false;
    if ( ("round" in data) && !doc._canChangeRound(user) ) return false;
    if ( ("turn" in data) && !doc._canChangeTurn(user) ) return false;
    if ( ("combatants" in data) && !doc.#canModifyCombatants(user, data.combatants) ) return false;
    return true;
  }

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

  /**
   * Can a certain User change the Combat round?
   * @param {User} user     The user attempting to change the round
   * @returns {boolean}     Is the user allowed to change the round?
   * @protected
   */
  _canChangeRound(user) {
    return true;
  }

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

  /**
   * Can a certain User change the Combat turn?
   * @param {documents.User} user The user attempting to change the turn
   * @returns {boolean} Is the user allowed to change the turn?
   * @protected
   */
  _canChangeTurn(user) {
    return true;
  }

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

  /**
   * Can a certain user make modifications to the array of Combatants?
   * @param {documents.User} user                 The user attempting to modify combatants
   * @param {Partial<CombatantData>[]} combatants Proposed combatant changes
   * @returns {boolean} Is the user allowed to make this change?
   */
  #canModifyCombatants(user, combatants) {
    for ( const {_id, ...change} of combatants ) {
      const c = this.combatants.get(_id);
      if ( !c ) return false;
      if ( !c.canUserModify(user, "update", change) ) return false;
    }
    return true;
  }

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

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    const allowed = await super._preUpdate(changed, options, user);
    if ( allowed === false ) return false;
    // Don't allow linking to a Scene that doesn't contain all its Combatants
    if ( !("scene" in changed) ) return;
    const sceneId = this.schema.fields.scene.clean(changed.scene);
    if ( (sceneId !== null) && isValidId(sceneId)
      && this.combatants.some(c => c.sceneId && (c.sceneId !== sceneId)) ) {
      throw new Error("You cannot link the Combat to a Scene that doesn't contain all its Combatants.");
    }
  }
}

/**
 * @import {CombatantData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The Combatant Document.
 * Defines the DataSchema and common behaviors for a Combatant which are shared between both client and server.
 * @extends {Document<CombatantData>}
 * @mixes CombatantData
 * @category Documents
 */
class BaseCombatant extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Combatant",
    collection: "combatants",
    label: "DOCUMENT.Combatant",
    labelPlural: "DOCUMENT.Combatants",
    isEmbedded: true,
    hasTypeData: true,
    permissions: {
      create: "OWNER",
      update: this.#canUpdate,
      delete: "OWNER"
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

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

  /** @inheritdoc */
  static defineSchema() {
    const {BaseActor, BaseToken, BaseScene} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      type: new DocumentTypeField(this, {initial: BASE_DOCUMENT_TYPE}),
      system: new TypeDataField(this),
      actorId: new ForeignDocumentField(BaseActor, {label: "COMBAT.CombatantActor", idOnly: true}),
      tokenId: new ForeignDocumentField(BaseToken, {label: "COMBAT.CombatantToken", idOnly: true}),
      sceneId: new ForeignDocumentField(BaseScene, {label: "COMBAT.CombatantScene", idOnly: true}),
      name: new StringField({label: "COMBAT.CombatantName", textSearch: true}),
      img: new FilePathField({categories: ["IMAGE"], label: "COMBAT.CombatantImage"}),
      initiative: new NumberField({required: true, label: "COMBAT.CombatantInitiative"}),
      hidden: new BooleanField({label: "COMBAT.CombatantHidden"}),
      defeated: new BooleanField({label: "COMBAT.CombatantDefeated"}),
      group: new DocumentIdField({readonly: false}),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /**
   * Is a user able to update an existing Combatant?
   * @type {DocumentPermissionTest}
   */
  static #canUpdate(user, doc, data) {
    if ( user.isGM ) return true; // GM users can do anything
    if ( !doc.testUserPermission(user, "OWNER") ) return false;

    // Players may only update a subset of fields
    const updateKeys = Object.keys(data);
    const allowedKeys = ["_id", "initiative", "flags", "defeated", "system"];
    return updateKeys.every(k => allowedKeys.includes(k));
  }

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

  /** @inheritDoc */
  getUserLevel(user) {
    if ( this.actor ) return this.actor.getUserLevel(user);
    return super.getUserLevel(user);
  }
}

/**
 * @import {CombatantGroupData} from "./_types.mjs";
 */

/**
 * A Document that represents a grouping of individual Combatants in a Combat.
 * Defines the DataSchema and common behaviors for a CombatantGroup which are shared between both client and server.
 * @extends {Document<CombatantGroupData>}
 * @mixes CombatantGroupData
 * @category Documents
 */
class BaseCombatantGroup extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "CombatantGroup",
    collection: "groups",
    label: "DOCUMENT.CombatantGroup",
    labelPlural: "DOCUMENT.CombatantGroups",
    isEmbedded: true,
    hasTypeData: true,
    schemaVersion: "13.341"
  }, { inplace: false }));

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

  /** @inheritDoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      type: new DocumentTypeField(this, { initial: BASE_DOCUMENT_TYPE }),
      system: new TypeDataField(this),
      name: new StringField({ textSearch: true }),
      img: new FilePathField({ categories: ["IMAGE"] }),
      initiative: new NumberField({ required: true }),
      ownership: new DocumentOwnershipField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }
}

/**
 * @import {DrawingData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The Drawing Document.
 * Defines the DataSchema and common behaviors for a Drawing which are shared between both client and server.
 * @extends {Document<DrawingData>}
 * @mixes DrawingData
 * @category Documents
 */
class BaseDrawing extends Document {

  /* ---------------------------------------- */
  /*  Model Configuration                     */
  /* ---------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Drawing",
    collection: "drawings",
    label: "DOCUMENT.Drawing",
    labelPlural: "DOCUMENT.Drawings",
    isEmbedded: true,
    permissions: {
      create: this.#canCreate,
      delete: "OWNER"
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "DRAWING"];

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

  /** @inheritDoc */
  static defineSchema() {
    const fillTypeChoices = Object.entries(DRAWING_FILL_TYPES).reduce((types, [key, value]) => {
      types[value] = `DRAWING.FillType${key.titleCase()}`;
      return types;
    }, {});
    return {
      _id: new DocumentIdField(),
      author: new DocumentAuthorField(foundry.documents.BaseUser),
      shape: new EmbeddedDataField(ShapeData),
      x: new NumberField({required: true, nullable: false, initial: 0}),
      y: new NumberField({required: true, nullable: false, initial: 0}),
      elevation: new NumberField({required: true, nullable: false, initial: 0}),
      sort: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      rotation: new AngleField(),
      bezierFactor: new AlphaField({initial: 0, label: "DRAWING.SmoothingFactor", max: 0.5,
        hint: "DRAWING.SmoothingFactorHint"}),
      fillType: new NumberField({
        required: true,
        nullable: false,
        initial: DRAWING_FILL_TYPES.NONE,
        choices: fillTypeChoices,
        label: "DRAWING.FillTypes",
        validationError: "must be a value in CONST.DRAWING_FILL_TYPES"
      }),
      fillColor: new ColorField({nullable: false, initial: () => game.user?.color.css || "#ffffff", label: "DRAWING.FillColor"}),
      fillAlpha: new AlphaField({initial: 0.5, label: "DRAWING.FillOpacity"}),
      strokeWidth: new NumberField({nullable: false, integer: true, initial: 8, min: 0, label: "DRAWING.LineWidth"}),
      strokeColor: new ColorField({nullable: false, initial: () => game.user?.color.css || "#ffffff", label: "DRAWING.StrokeColor"}),
      strokeAlpha: new AlphaField({initial: 1, label: "DRAWING.LineOpacity"}),
      texture: new FilePathField({categories: ["IMAGE"], label: "DRAWING.FillTexture"}),
      text: new StringField({label: "DRAWING.TextLabel"}),
      fontFamily: new StringField({blank: false, label: "DRAWING.FontFamily",
        initial: () => globalThis.CONFIG?.defaultFontFamily || "Signika"}),
      fontSize: new NumberField({nullable: false, integer: true, min: 8, max: 256, initial: 48, label: "DRAWING.FontSize",
        validationError: "must be an integer between 8 and 256"}),
      textColor: new ColorField({nullable: false, initial: "#ffffff", label: "DRAWING.TextColor"}),
      textAlpha: new AlphaField({label: "DRAWING.TextOpacity"}),
      hidden: new BooleanField(),
      locked: new BooleanField(),
      interface: new BooleanField(),
      flags: new DocumentFlagsField()
    };
  }

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

  /**
   * Validate whether the drawing has some visible content (as required by validation).
   * @param {Partial<Pick<DrawingData, "shape">> & Pick<DrawingData, "text"|"textAlpha"|"fillType"|"fillAlpha"
   *   |"strokeWidth"|"strokeAlpha">} data
   * @returns {boolean}
   * @internal
   */
  static _validateVisibleContent(data) {
    let isEmpty;
    switch ( data.shape?.type ) {
      case "r":
      case "e": isEmpty = (data.shape.width <= data.strokeWidth) || (data.shape.height <= data.strokeWidth); break;
      case "p": isEmpty = ((data.shape.width === 0) && (data.shape.height === 0)) || (data.shape.points.length < 4); break;
      case "c": isEmpty = (data.shape.radius <= (data.strokeWidth / 2)); break;
    }
    if ( isEmpty ) return false;
    const hasText = (data.text !== "") && (data.textAlpha > 0);
    const hasFill = (data.fillType !== DRAWING_FILL_TYPES.NONE) && (data.fillAlpha > 0);
    const hasLine = (data.strokeWidth > 0) && (data.strokeAlpha > 0);
    return hasText || hasFill || hasLine;
  }

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

  /** @inheritdoc */
  static validateJoint(data) {
    if ( !BaseDrawing._validateVisibleContent(data) ) {
      throw new Error(game.i18n.localize("DRAWING.JointValidationError"));
    }
  }

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

  /** @override */
  static canUserCreate(user) {
    return user.hasPermission("DRAWING_CREATE");
  }

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

  /**
   * Is a user able to create a new Drawing?
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc) {
    if ( !user.isGM && (doc._source.author !== user.id) ) return false;
    return user.hasPermission("DRAWING_CREATE");
  }

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

  /** @inheritDoc */
  getUserLevel(user) {
    user ||= game.user;
    if ( user.id === this._source.author ) return DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return super.getUserLevel(user);
  }

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

  /** @inheritdoc */
  static migrateData(data) {
    /**
     * V12 migration to elevation and sort fields
     * @deprecated since v12
     */
    this._addDataFieldMigration(data, "z", "elevation");
    return super.migrateData(data);
  }

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

  /** @inheritdoc */
  static shimData(data, options) {
    this._addDataFieldShim(data, "z", "elevation", {since: 12, until: 14});
    return super.shimData(data, options);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get z() {
    this.constructor._logDataFieldMigration("z", "elevation", {since: 12, until: 14});
    return this.elevation;
  }
}

/**
 * @import {FogExplorationData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The FogExploration Document.
 * Defines the DataSchema and common behaviors for a FogExploration which are shared between both client and server.
 * @extends {Document<FogExplorationData>}
 * @mixes FogExplorationData
 * @category Documents
 */
class BaseFogExploration extends Document {

  /* ---------------------------------------- */
  /*  Model Configuration                     */
  /* ---------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "FogExploration",
    collection: "fog",
    label: "DOCUMENT.FogExploration",
    labelPlural: "DOCUMENT.FogExplorations",
    isPrimary: true,
    permissions: {
      create: "PLAYER",
      update: this.#canModify,
      delete: this.#canModify
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const {BaseScene, BaseUser} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      scene: new ForeignDocumentField(BaseScene, {initial: () => canvas?.scene?.id}),
      user: new ForeignDocumentField(BaseUser, {initial: () => game?.user?.id}),
      explored: new FilePathField({categories: ["IMAGE"], required: true, base64: true}),
      positions: new ObjectField(),
      timestamp: new NumberField({nullable: false, initial: Date.now}),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

  /**
   * Test whether a User can modify a FogExploration document.
   * @type {DocumentPermissionTest}
   */
  static #canModify(user, doc) {
    return (user.id === doc._source.user) || user.hasRole("ASSISTANT");
  }

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

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    const allowed = await super._preUpdate(changed, options, user);
    if ( allowed === false ) return false;
    changed.timestamp = Date.now();
  }
}

/**
 * @import {FolderData} from "./_types.mjs";
 */

/**
 * The Folder Document.
 * Defines the DataSchema and common behaviors for a Folder which are shared between both client and server.
 * @extends {Document<FolderData>}
 * @mixes FolderData
 * @category Documents
 */
class BaseFolder extends Document {

  /* ---------------------------------------- */
  /*  Model Configuration                     */
  /* ---------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Folder",
    collection: "folders",
    label: "DOCUMENT.Folder",
    labelPlural: "DOCUMENT.Folders",
    coreTypes: FOLDER_DOCUMENT_TYPES,
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      type: new DocumentTypeField(this),
      description: new HTMLField({textSearch: true}),
      folder: new ForeignDocumentField(BaseFolder),
      sorting: new StringField({required: true, initial: "a", choices: this.SORTING_MODES}),
      sort: new IntegerSortField(),
      color: new ColorField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "FOLDER"];

  /** @inheritdoc */
  static validateJoint(data) {
    if ( (data.folder !== null) && (data.folder === data._id) ) {
      throw new Error("A Folder may not contain itself");
    }
  }

  /**
   * Allow folder sorting modes
   * @type {string[]}
   */
  static SORTING_MODES = ["a", "m"];

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

  /** @override */
  static get(documentId, options={}) {
    if ( !documentId ) return null;
    if ( !options.pack ) return super.get(documentId, options);
    const pack = game.packs.get(options.pack);
    if ( !pack ) {
      console.error(`The ${this.name} model references a non-existent pack ${options.pack}.`);
      return null;
    }
    return pack.folders.get(documentId);
  }
}

/**
 * @import {ItemData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The Item Document.
 * Defines the DataSchema and common behaviors for a Item which are shared between both client and server.
 * @extends {Document<ItemData>}
 * @mixes ItemData
 * @category Documents
 */
class BaseItem extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Item",
    collection: "items",
    hasTypeData: true,
    indexed: true,
    compendiumIndexFields: ["_id", "name", "img", "type", "sort", "folder"],
    embedded: {ActiveEffect: "effects"},
    label: "DOCUMENT.Item",
    labelPlural: "DOCUMENT.Items",
    permissions: {
      create: BaseItem.#canCreate,
      delete: "OWNER"
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

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

  /** @inheritdoc */
  static defineSchema() {
    const {BaseActiveEffect, BaseFolder} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      type: new DocumentTypeField(this),
      img: new FilePathField({categories: ["IMAGE"], initial: data => {
        return this.implementation.getDefaultArtwork(data).img;
      }}),
      system: new TypeDataField(this),
      effects: new EmbeddedCollectionField(BaseActiveEffect),
      folder: new ForeignDocumentField(BaseFolder),
      sort: new IntegerSortField(),
      ownership: new DocumentOwnershipField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /**
   * The default icon used for newly created Item documents
   * @type {string}
   */
  static DEFAULT_ICON = "icons/svg/item-bag.svg";

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

  /**
   * Determine default artwork based on the provided item data.
   * @param {ItemData} itemData  The source item data.
   * @returns {{img: string}}    Candidate item image.
   */
  static getDefaultArtwork(itemData) {
    return { img: this.DEFAULT_ICON };
  }

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

  /** @inheritDoc */
  _initialize(options) {
    super._initialize(options);
    DocumentStatsField._shimDocument(this);
  }

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

  /** @inheritDoc */
  getUserLevel(user) {
    // Embedded Items require a special exception because they ignore their own ownership field.
    if ( this.parent ) return this.parent.getUserLevel(user);
    return super.getUserLevel(user);
  }

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

  /** @override */
  static canUserCreate(user) {
    return user.hasPermission("ITEM_CREATE");
  }

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

  /**
   * Is a User able to create a new Item?
   * Embedded Items depend on Actor ownership.
   * Otherwise, the ITEM_CREATE permission is required.
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc) {
    if ( doc.parent ) return doc.parent.testUserPermission(user, "OWNER");
    return user.hasPermission("ITEM_CREATE");
  }

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


  /** @inheritDoc */
  static migrateData(source) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source);
  }

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

  /** @inheritDoc */
  static shimData(source, options) {
    DocumentStatsField._shimData(this, source, options);
    return super.shimData(source, options);
  }
}

/**
 * @import {JournalEntryData} from "./_types.mjs";
 */

/**
 * The JournalEntry Document.
 * Defines the DataSchema and common behaviors for a JournalEntry which are shared between both client and server.
 * @extends {Document<JournalEntryData>}
 * @mixes JournalEntryData
 * @category Documents
 */
class BaseJournalEntry extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "JournalEntry",
    collection: "journal",
    indexed: true,
    compendiumIndexFields: ["_id", "name", "sort", "folder"],
    embedded: {
      JournalEntryCategory: "categories",
      JournalEntryPage: "pages"
    },
    label: "DOCUMENT.JournalEntry",
    labelPlural: "DOCUMENT.JournalEntries",
    permissions: {
      create: "JOURNAL_CREATE",
      delete: "OWNER"
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const {BaseJournalEntryPage, BaseJournalEntryCategory, BaseFolder} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      pages: new EmbeddedCollectionField(BaseJournalEntryPage),
      folder: new ForeignDocumentField(BaseFolder),
      categories: new EmbeddedCollectionField(BaseJournalEntryCategory),
      sort: new IntegerSortField(),
      ownership: new DocumentOwnershipField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /** @inheritDoc */
  _initialize(options) {
    super._initialize(options);
    DocumentStatsField._shimDocument(this);
  }

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

  /** @inheritDoc */
  static migrateData(source) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source);
  }

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

  /** @inheritDoc */
  static shimData(source, options) {
    DocumentStatsField._shimData(this, source, options);
    return super.shimData(source, options);
  }
}

/**
 * @import {JournalEntryCategoryData} from "./_types.mjs";
 */

/**
 * An embedded Document that represents a category in a JournalEntry.
 * Defines the DataSchema and common behaviors for a JournalEntryCategory which are shared between both client and
 * server.
 * @extends {Document<JournalEntryCategoryData>}
 * @mixes JournalEntryCategoryData
 * @category Documents
 */
class BaseJournalEntryCategory extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "JournalEntryCategory",
    collection: "categories",
    label: "DOCUMENT.JournalEntryCategory",
    labelPlural: "DOCUMENT.JournalEntryCategories",
    isEmbedded: true,
    schemaVersion: "13.341"
  }, { inplace: false }));

  /** @override */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      name: new StringField({ required: true, blank: true, textSearch: true }),
      sort: new IntegerSortField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }
}

/**
 * @import {JournalEntryPageData} from "./_types.mjs";
 */

/**
 * The JournalEntryPage Document.
 * Defines the DataSchema and common behaviors for a JournalEntryPage which are shared between both client and server.
 * @extends {Document<JournalEntryPageData>}
 * @mixes JournalEntryPageData
 * @category Documents
 */
class BaseJournalEntryPage extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "JournalEntryPage",
    collection: "pages",
    hasTypeData: true,
    indexed: true,
    label: "DOCUMENT.JournalEntryPage",
    labelPlural: "DOCUMENT.JournalEntryPages",
    coreTypes: ["text", "image", "pdf", "video"],
    compendiumIndexFields: ["name", "type", "sort"],
    permissions: {
      create: "OWNER",
      delete: "OWNER"
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, label: "JOURNALENTRYPAGE.PageTitle", textSearch: true}),
      type: new DocumentTypeField(this, {initial: "text"}),
      system: new TypeDataField(this),
      title: new SchemaField({
        show: new BooleanField({initial: true}),
        level: new NumberField({required: true, initial: 1, min: 1, max: 6, integer: true, nullable: false})
      }),
      image: new SchemaField({
        caption: new StringField({required: false, initial: undefined})
      }),
      text: new SchemaField({
        content: new HTMLField({required: false, initial: undefined, textSearch: true}),
        markdown: new StringField({required: false, initial: undefined}),
        format: new NumberField({label: "JOURNALENTRYPAGE.Format",
          initial: JOURNAL_ENTRY_PAGE_FORMATS.HTML, choices: Object.values(JOURNAL_ENTRY_PAGE_FORMATS)})
      }),
      video: new SchemaField({
        controls: new BooleanField({initial: true}),
        loop: new BooleanField({required: false, initial: undefined}),
        autoplay: new BooleanField({required: false, initial: undefined}),
        volume: new AlphaField({required: true, step: 0.01, initial: .5}),
        timestamp: new NumberField({required: false, min: 0, initial: undefined}),
        width: new NumberField({required: false, positive: true, integer: true, initial: undefined}),
        height: new NumberField({required: false, positive: true, integer: true, initial: undefined})
      }),
      src: new StringField({required: false, blank: false, nullable: true, initial: null,
        label: "JOURNALENTRYPAGE.Source"}),
      category: new DocumentIdField({readonly: false}),
      sort: new IntegerSortField(),
      ownership: new DocumentOwnershipField({initial: {default: DOCUMENT_OWNERSHIP_LEVELS.INHERIT}}),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }
}

/**
 * @import {MacroData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The Macro Document.
 * Defines the DataSchema and common behaviors for a Macro which are shared between both client and server.
 * @extends {Document<MacroData>}
 * @mixes MacroData
 * @category Documents
 */
class BaseMacro extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Macro",
    collection: "macros",
    indexed: true,
    compendiumIndexFields: ["_id", "name", "img", "sort", "folder"],
    label: "DOCUMENT.Macro",
    labelPlural: "DOCUMENT.Macros",
    coreTypes: Object.values(MACRO_TYPES),
    permissions: {
      create: this.#canCreate,
      update: this.#canUpdate,
      delete: "OWNER"
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const {BaseUser, BaseFolder} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      type: new DocumentTypeField(this, {initial: MACRO_TYPES.CHAT}),
      author: new DocumentAuthorField(BaseUser),
      img: new FilePathField({categories: ["IMAGE"], initial: () => this.DEFAULT_ICON}),
      scope: new StringField({required: true, choices: MACRO_SCOPES, initial: MACRO_SCOPES[0],
        validationError: "must be a value in CONST.MACRO_SCOPES"}),
      command: new StringField({required: true, blank: true}),
      folder: new ForeignDocumentField(BaseFolder),
      sort: new IntegerSortField(),
      ownership: new DocumentOwnershipField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "MACRO"];

  /**
   * The default icon used for newly created Macro documents.
   * @type {string}
   */
  static DEFAULT_ICON = "icons/svg/dice-target.svg";

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

  /** @inheritDoc */
  _initialize(options) {
    super._initialize(options);
    DocumentStatsField._shimDocument(this);
  }

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

  /** @inheritDoc */
  static migrateData(source) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source);
  }

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

  /** @inheritDoc */
  static shimData(source, options) {
    DocumentStatsField._shimData(this, source, options);
    return super.shimData(source, options);
  }

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

  /** @override */
  static validateJoint(data) {
    if ( data.type !== MACRO_TYPES.SCRIPT ) return;
    const field = new JavaScriptField({ async: true });
    const failure = field.validate(data.command);
    if ( failure ) throw failure.asError();
  }

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

  /** @override */
  static canUserCreate(user) {
    return user.hasRole("PLAYER");
  }

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

  /**
   * Is a user able to create the Macro document?
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc) {
    if ( !user.isGM && (doc._source.author !== user.id) ) return false;
    if ( (doc._source.type === "script") && !user.hasPermission("MACRO_SCRIPT") ) return false;
    return user.hasRole("PLAYER");
  }

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

  /**
   * Is a user able to update the Macro document?
   * @type {DocumentPermissionTest}
   */
  static #canUpdate(user, doc, data) {
    if ( !user.hasPermission("MACRO_SCRIPT") ) {
      if ( (data.type === "script") || (data["==type"] === "script") ) return false;
      if ( (doc._source.type === "script") && ("command" in data) ) return false;
    }
    return doc.testUserPermission(user, "OWNER");
  }

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

  /** @inheritDoc */
  getUserLevel(user) {
    user ||= game.user;
    if ( user.id === this._source.author ) return DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return super.getUserLevel(user);
  }

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

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;
    this.updateSource({author: user.id});
  }
}

/**
 * @import {MeasuredTemplateData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The MeasuredTemplate Document.
 * Defines the DataSchema and common behaviors for a MeasuredTemplate which are shared between both client and server.
 * @extends {Document<MeasuredTemplateData>}
 * @mixes MeasuredTemplateData
 * @category Documents
 */
class BaseMeasuredTemplate extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = mergeObject(super.metadata, {
    name: "MeasuredTemplate",
    collection: "templates",
    label: "DOCUMENT.MeasuredTemplate",
    labelPlural: "DOCUMENT.MeasuredTemplates",
    isEmbedded: true,
    permissions: {
      create: this.#canCreate,
      delete: "OWNER"
    },
    schemaVersion: "13.341"
  }, {inplace: false});

  /** @inheritdoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      author: new DocumentAuthorField(foundry.documents.BaseUser),
      t: new StringField({required: true, choices: Object.values(MEASURED_TEMPLATE_TYPES),
        initial: MEASURED_TEMPLATE_TYPES.CIRCLE,
        validationError: "must be a value in CONST.MEASURED_TEMPLATE_TYPES"
      }),
      x: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      y: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      elevation: new NumberField({required: true, nullable: false, initial: 0}),
      sort: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      distance: new NumberField({required: true, nullable: false, initial: 0, min: 0}),
      direction: new AngleField(),
      angle: new AngleField({normalize: false}),
      width: new NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01}),
      borderColor: new ColorField({nullable: false, initial: "#000000"}),
      fillColor: new ColorField({nullable: false, initial: () => game.user?.color.css || "#ffffff"}),
      texture: new FilePathField({categories: ["IMAGE", "VIDEO"]}),
      hidden: new BooleanField(),
      flags: new DocumentFlagsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "TEMPLATE"];

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

  /**
   * Is a user able to create a new MeasuredTemplate?
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc) {
    if ( !user.isGM && (doc._source.author !== user.id) ) return false;
    return user.hasPermission("TEMPLATE_CREATE");
  }

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

  /** @inheritDoc */
  getUserLevel(user) {
    user ||= game.user;
    if ( user.id === this._source.author ) return DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return super.getUserLevel(user);
  }

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

  /** @inheritdoc */
  static migrateData(data) {
    /**
     * V12 migration from user to author
     * @deprecated since v12
     */
    this._addDataFieldMigration(data, "user", "author");
    return super.migrateData(data);
  }

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

  /** @inheritdoc */
  static shimData(data, options) {
    this._addDataFieldShim(data, "user", "author", {since: 12, until: 14});
    return super.shimData(data, options);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get user() {
    this.constructor._logDataFieldMigration("user", "author", {since: 12, until: 14});
    return this.author;
  }
}

/**
 * @import {NoteData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The Note Document.
 * Defines the DataSchema and common behaviors for a Note which are shared between both client and server.
 * @extends {Document<NoteData>}
 * @mixes NoteData
 * @category Documents
 */
class BaseNote extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Note",
    collection: "notes",
    label: "DOCUMENT.Note",
    labelPlural: "DOCUMENT.Notes",
    permissions: {
      create: BaseNote.#canCreate,
      delete: "OWNER"
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const {BaseJournalEntry, BaseJournalEntryPage} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      entryId: new ForeignDocumentField(BaseJournalEntry, {idOnly: true}),
      pageId: new ForeignDocumentField(BaseJournalEntryPage, {idOnly: true}),
      x: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      y: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      elevation: new NumberField({required: true, nullable: false, initial: 0}),
      sort: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      texture: new TextureData({}, {categories: ["IMAGE"],
        initial: {src: () => this.DEFAULT_ICON, anchorX: 0.5, anchorY: 0.5, fit: "contain"}}),
      iconSize: new NumberField({required: true, nullable: false, integer: true, min: 32, initial: 40,
        validationError: "must be an integer greater than 32"}),
      text: new StringField({textSearch: true}),
      fontFamily: new StringField({required: true,
        initial: () => globalThis.CONFIG?.defaultFontFamily || "Signika"}),
      fontSize: new NumberField({required: true, nullable: false, integer: true, min: 8, max: 128, initial: 32,
        validationError: "must be an integer between 8 and 128"}),
      textAnchor: new NumberField({required: true, choices: Object.values(TEXT_ANCHOR_POINTS),
        initial: TEXT_ANCHOR_POINTS.BOTTOM, validationError: "must be a value in CONST.TEXT_ANCHOR_POINTS"}),
      textColor: new ColorField({required: true, nullable: false, initial: "#ffffff"}),
      global: new BooleanField(),
      flags: new DocumentFlagsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "NOTE"];

  /**
   * The default icon used for newly created Note documents.
   * @type {string}
   */
  static DEFAULT_ICON = "icons/svg/book.svg";

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

  /** @override */
  getUserLevel(user) {
    if ( this.page ) return this.page.getUserLevel(user);
    if ( this.entry ) return this.entry.getUserLevel(user);
    if ( user.isGM || user.hasPermission("NOTE_CREATE") ) return DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return DOCUMENT_OWNERSHIP_LEVELS.NONE;
  }

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

  /** @override */
  static canUserCreate(user) {
    return user.hasPermission("NOTE_CREATE");
  }

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

  /**
   * To create a Note document, the player must have both the NOTE_CREATE permission and at least OBSERVER
   * permission over the referenced JournalEntry.
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc) {
    if ( !user.hasPermission("NOTE_CREATE") ) return false;
    if ( doc._source.entryId ) return doc.entry.testUserPermission(user, "OBSERVER");
    return true;
  }
}

/**
 * @import {PlaylistData} from "./_types.mjs";
 */

/**
 * The Playlist Document.
 * Defines the DataSchema and common behaviors for a Playlist which are shared between both client and server.
 * @extends {Document<PlaylistData>}
 * @mixes PlaylistData
 * @category Documents
 */
class BasePlaylist extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Playlist",
    collection: "playlists",
    indexed: true,
    compendiumIndexFields: ["_id", "name", "description", "sort", "folder"],
    embedded: {PlaylistSound: "sounds"},
    label: "DOCUMENT.Playlist",
    labelPlural: "DOCUMENT.Playlists",
    permissions: {
      create: "PLAYLIST_CREATE",
      delete: "OWNER"
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const {BasePlaylistSound, BaseFolder} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      description: new StringField({textSearch: true}),
      sounds: new EmbeddedCollectionField(BasePlaylistSound),
      channel: new StringField({required: true, choices: AUDIO_CHANNELS, initial: "music", blank: false}),
      mode: new NumberField({required: true, choices: Object.values(PLAYLIST_MODES),
        initial: PLAYLIST_MODES.SEQUENTIAL, validationError: "must be a value in CONST.PLAYLIST_MODES"}),
      playing: new BooleanField(),
      fade: new NumberField({integer: true, positive: true}),
      folder: new ForeignDocumentField(BaseFolder),
      sorting: new StringField({required: true, choices: Object.values(PLAYLIST_SORT_MODES),
        initial: PLAYLIST_SORT_MODES.ALPHABETICAL,
        validationError: "must be a value in CONST.PLAYLIST_SORTING_MODES"}),
      seed: new NumberField({integer: true, min: 0}),
      sort: new IntegerSortField(),
      ownership: new DocumentOwnershipField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "PLAYLIST"];

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

  /** @inheritDoc */
  _initialize(options) {
    super._initialize(options);
    DocumentStatsField._shimDocument(this);
  }

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

  /** @inheritDoc */
  static migrateData(source) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source);
  }

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

  /** @inheritDoc */
  static shimData(source, options) {
    DocumentStatsField._shimData(this, source, options);
    return super.shimData(source, options);
  }
}

/**
 * @import {PlaylistSoundData} from "./_types.mjs";
 */

/**
 * The PlaylistSound Document.
 * Defines the DataSchema and common behaviors for a PlaylistSound which are shared between both client and server.
 * @extends {Document<PlaylistSoundData>}
 * @mixes PlaylistSoundData
 * @category Documents
 */
class BasePlaylistSound extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "PlaylistSound",
    collection: "sounds",
    indexed: true,
    label: "DOCUMENT.PlaylistSound",
    labelPlural: "DOCUMENT.PlaylistSounds",
    compendiumIndexFields: ["name", "sort"],
    schemaVersion: "13.341",
    permissions: {
      ...super.metadata.permissions,
      create: "OWNER",
      update: "OWNER",
      delete: "OWNER"
    }
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      description: new StringField(),
      path: new FilePathField({categories: ["AUDIO"]}),
      channel: new StringField({required: true, choices: AUDIO_CHANNELS, initial: "", blank: true}),
      playing: new BooleanField(),
      pausedTime: new NumberField({min: 0}),
      repeat: new BooleanField(),
      volume: new AlphaField({initial: 0.5, step: 0.01}),
      fade: new NumberField({integer: true, min: 0}),
      sort: new IntegerSortField(),
      flags: new DocumentFlagsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "PLAYLIST_SOUND"];
}

/**
 * @import {RollTableData} from "./_types.mjs";
 */

/**
 * The RollTable Document.
 * Defines the DataSchema and common behaviors for a RollTable which are shared between both client and server.
 * @extends {Document<RollTableData>}
 * @mixes RollTableData
 * @category Documents
 */
class BaseRollTable extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "RollTable",
    collection: "tables",
    indexed: true,
    compendiumIndexFields: ["_id", "name", "description", "img", "sort", "folder"],
    embedded: {TableResult: "results"},
    label: "DOCUMENT.RollTable",
    labelPlural: "DOCUMENT.RollTables",
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "TABLE"];

  /**
   * The default icon used for newly created Macro documents
   * @type {string}
   */
  static DEFAULT_ICON = "icons/svg/d20-grey.svg";

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

  /** @inheritDoc */
  static defineSchema() {
    const {BaseTableResult, BaseFolder} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      img: new FilePathField({categories: ["IMAGE"], initial: () => this.DEFAULT_ICON}),
      description: new HTMLField({textSearch: true}),
      results: new EmbeddedCollectionField(BaseTableResult),
      formula: new StringField(),
      replacement: new BooleanField({initial: true}),
      displayRoll: new BooleanField({initial: true}),
      folder: new ForeignDocumentField(BaseFolder),
      sort: new IntegerSortField(),
      ownership: new DocumentOwnershipField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /** @inheritDoc */
  _initialize(options) {
    super._initialize(options);
    DocumentStatsField._shimDocument(this);
  }

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

  /** @inheritDoc */
  static migrateData(source) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source);
  }

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

  /** @inheritDoc */
  static shimData(source, options) {
    DocumentStatsField._shimData(this, source, options);
    return super.shimData(source, options);
  }
}

/**
 * @import {GridConfiguration, GridOffset2D, GridOffset3D, GridCoordinates2D, GridCoordinates3D, GridSnappingBehavior,
 *   GridMeasurePathWaypointData2D, GridMeasurePathWaypointData3D, GridMeasurePathResult, GridMeasurePathCostFunction2D,
 *   GridMeasurePathCostFunction3D} from "./_types.mjs";
 * @import {Point, ElevatedPoint, Rectangle} from "../_types.mjs";
 * @import {GridType, MOVEMENT_DIRECTIONS} from "../constants.mjs";
 */

/**
 * The base grid class.
 * @template [Coordinates2D=GridCoordinates2D]
 * @template [Coordinates3D=GridCoordinates3D]
 * @abstract
 */
class BaseGrid {
  /**
   * The base grid constructor.
   * @param {GridConfiguration} config    The grid configuration
   */
  constructor(config) {
    let {size, distance=1, units="", style="solidLines", thickness=1, color, alpha=1} = config;
    /** @deprecated since v12 */
    if ( "dimensions" in config ) {
      const msg = "The constructor BaseGrid({dimensions, color, alpha}) is deprecated "
        + "in favor of BaseGrid({size, distance, units, style, thickness, color, alpha}).";
      logCompatibilityWarning(msg, {since: 12, until: 14});
      const dimensions = config.dimensions;
      size = dimensions.size;
      distance = dimensions.distance || 1;
    }

    if ( size === undefined ) throw new Error(`${this.constructor.name} cannot be constructed without a size`);
    if ( !(size > 0) ) throw new Error("The size must be positive");
    if ( !(distance > 0) ) throw new Error("The distance must be positive");

    // Convert the color to a Color
    if ( color ) color = Color.from(color);
    if ( !color?.valid ) color = new Color(0);

    /**
     * The size of a grid space in pixels.
     * @type {number}
     * @readonly
     */
    this.size = size;

    /**
     * The width of a grid space in pixels.
     * @type {number}
     * @readonly
     */
    this.sizeX = size;

    /**
     * The height of a grid space in pixels.
     * @type {number}
     * @readonly
     */
    this.sizeY = size;

    /**
     * The distance of a grid space in units.
     * @type {number}
     * @readonly
     */
    this.distance = distance;

    /**
     * The distance units used in this grid.
     * @type {string}
     * @readonly
     */
    this.units = units;

    /**
     * The style of the grid.
     * @type {string}
     * @readonly
     */
    this.style = style;

    /**
     * The thickness of the grid.
     * @type {number}
     * @readonly
     */
    this.thickness = thickness;

    /**
     * The color of the grid.
     * @type {Color}
     * @readonly
     */
    this.color = color;

    /**
     * The opacity of the grid.
     * @type {number}
     * @readonly
     */
    this.alpha = alpha;
  }

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

  /**
   * The grid type (see {@link CONST.GRID_TYPES}).
   * @type {GridType}
   * @readonly
   */
  type;

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

  /**
   * Is this a gridless grid?
   * @type {boolean}
   */
  get isGridless() {
    return this.type === GRID_TYPES.GRIDLESS;
  }

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

  /**
   * Is this a square grid?
   * @type {boolean}
   */
  get isSquare() {
    return this.type === GRID_TYPES.SQUARE;
  }

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

  /**
   * Is this a hexagonal grid?
   * @type {boolean}
   */
  get isHexagonal() {
    return (this.type >= GRID_TYPES.HEXODDR) && (this.type <= GRID_TYPES.HEXEVENQ);
  }

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

  /**
   * Calculate the total size of the canvas with padding applied, as well as the top-left coordinates of the inner
   * rectangle that houses the scene.
   * @param {number} sceneWidth         The width of the scene.
   * @param {number} sceneHeight        The height of the scene.
   * @param {number} padding            The percentage of padding.
   * @returns {{width: number, height: number, x: number, y: number, rows: number, columns: number}}
   * @abstract
   */
  calculateDimensions(sceneWidth, sceneHeight, padding) {
    throw new Error("A subclass of the BaseGrid must implement the calculateDimensions method");
  }

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

  /**
   * Returns the offset of the grid space corresponding to the given coordinates.
   * @overload
   * @param {Coordinates2D} coords    The coordinates
   * @returns {GridOffset2D}          The offset
   */
  /**
   * @overload
   * @param {Coordinates3D} coords    The coordinates
   * @returns {GridOffset3D}          The offset
   * @abstract
   */
  getOffset(coords) {
    throw new Error("A subclass of the BaseGrid must implement the getOffset method");
  }

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

  /**
   * Returns the smallest possible range containing the offsets of all grid spaces that intersect the given bounds.
   * If the bounds are empty (nonpositive width or height), then the offset range is empty.
   * @example
   * ```js
   * 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};
   *     // ...
   *   }
   * }
   * ```
   * @param {Rectangle} bounds                                      The bounds
   * @returns {[i0: number, j0: number, i1: number, j1: number]}    The offset range
   * @abstract
   */
  getOffsetRange({x, y, width, height}) {
    throw new Error("A subclass of the BaseGrid must implement the getOffsetRange method");
  }

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

  /**
   * Returns the offsets of the grid spaces adjacent to the one corresponding to the given coordinates.
   * Returns always an empty array in gridless grids.
   * @overload
   * @param {Coordinates2D} coords    The coordinates
   * @returns {GridOffset2D[]}        The adjacent offsets
   */
  /**
   * @overload
   * @param {Coordinates3D} coords    The coordinates
   * @returns {GridOffset3D[]}        The adjacent offsets
   * @abstract
   */
  getAdjacentOffsets(coords) {
    throw new Error("A subclass of the BaseGrid must implement the getAdjacentOffsets method");
  }

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

  /**
   * Returns true if the grid spaces corresponding to the given coordinates are adjacent to each other.
   * In square and hexagonal grids with illegal diagonals the diagonally neighboring grid spaces are not adjacent.
   * Returns always false in gridless grids.
   * @overload
   * @param {Coordinates2D} coords1    The first coordinates
   * @param {Coordinates2D} coords2    The second coordinates
   * @returns {boolean}
   */
  /**
   * @overload
   * @param {Coordinates3D} coords1    The first coordinates
   * @param {Coordinates3D} coords2    The second coordinates
   * @returns {boolean}
   * @abstract
   */
  testAdjacency(coords1, coords2) {
    throw new Error("A subclass of the BaseGrid must implement the testAdjacency method");
  }

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

  /**
   * Returns the offset of the grid space corresponding to the given coordinates
   * shifted by one grid space in the given direction. The k-coordinate is not changed.
   * In square and hexagonal grids with illegal diagonals the offset of the given coordinates is returned
   * if the direction is diagonal.
   * In gridless grids the point is by the grid size.
   * @overload
   * @param {Coordinates2D} coords             The coordinates
   * @param {MOVEMENT_DIRECTIONS} direction    The direction (see {@link CONST.MOVEMENT_DIRECTIONS})
   * @returns {GridOffset2D}                   The offset
   */
  /**
   * @overload
   * @param {Coordinates3D} coords             The coordinates
   * @param {MOVEMENT_DIRECTIONS} direction    The direction (see {@link CONST.MOVEMENT_DIRECTIONS})
   * @returns {GridOffset3D}                   The offset
   * @abstract
   */
  getShiftedOffset(coords, direction) {
    throw new Error("A subclass of the BaseGrid must implement the getShiftedOffset method");
  }

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

  /**
   * Returns the point shifted by the difference between the grid space corresponding to the given coordinates
   * and the shifted grid space in the given direction. The z-coordinate is not changed.
   * In square and hexagonal grids with illegal diagonals the point is not shifted if the direction is diagonal.
   * In gridless grids the point coordinates are shifted by the grid size.
   * @overload
   * @param {Point} point                      The point that is to be shifted
   * @param {MOVEMENT_DIRECTIONS} direction    The direction (see {@link CONST.MOVEMENT_DIRECTIONS})
   * @returns {Point}                          The shifted point
   */
  /**
   * @overload
   * @param {ElevatedPoint} point              The point that is to be shifted
   * @param {MOVEMENT_DIRECTIONS} direction    The direction (see {@link CONST.MOVEMENT_DIRECTIONS})
   * @returns {ElevatedPoint}                  The shifted point
   * @abstract
   */
  getShiftedPoint(point, direction) {
    throw new Error("A subclass of the BaseGrid must implement the getShiftedPoint method");
  }

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

  /**
   * Returns the top-left point of the grid space bounds corresponding to the given coordinates.
   * If given a point, the top-left point of the grid space bounds that contains it is returned.
   * The top-left point lies in the plane of the bottom face of the 3D grid space.
   * In gridless grids a point with the same coordinates as the given point is returned.
   * @overload
   * @param {Coordinates2D} coords    The coordinates
   * @returns {Point}                 The top-left point
   */
  /**
   * @overload
   * @param {Coordinates3D} coords    The coordinates
   * @returns {ElevatedPoint}         The top-left point
   * @abstract
   */
  getTopLeftPoint(coords) {
    throw new Error("A subclass of the BaseGrid must implement the getTopLeftPoint method");
  }

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

  /**
   * Returns the center point of the grid space corresponding to the given coordinates.
   * If given a point, the center point of the grid space that contains it is returned.
   * The center point lies in the plane of the bottom face of the 3D grid space.
   * In gridless grids a point with the same coordinates as the given point is returned.
   * @overload
   * @param {Coordinates2D} coords    The coordinates
   * @returns {Point}                 The center point
   */
  /**
   * @overload
   * @param {Coordinates3D} coords    The coordinates
   * @returns {ElevatedPoint}         The center point
   * @abstract
   */
  getCenterPoint(coords) {
    throw new Error("A subclass of the BaseGrid must implement the getCenterPoint method");
  }

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

  /**
   * Returns the points of the grid space shape relative to the center point.
   * The points are returned in the same order as in {@link BaseGrid#getVertices}.
   * In gridless grids an empty array is returned.
   * @returns {Point[]}    The points of the polygon
   * @abstract
   */
  getShape() {
    throw new Error("A subclass of the BaseGrid must implement the getShape method");
  }

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

  /**
   * Returns the vertices of the grid space corresponding to the given coordinates.
   * The vertices are returned ordered in positive orientation with the first vertex
   * being the top-left vertex in square grids, the top vertex in row-oriented
   * hexagonal grids, and the left vertex in column-oriented hexagonal grids.
   * In gridless grids an empty array is returned.
   * @param {Coordinates2D} coords      The coordinates
   * @returns {Point[]}                 The vertices
   * @abstract
   */
  getVertices(coords) {
    throw new Error("A subclass of the BaseGrid must implement the getVertices method");
  }

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

  /**
   * Snaps the given point to the grid.
   * In square and hexagonal grids the z-coordinate of the point is rounded to the nearest multiple of the grid size.
   * In gridless grids a point with the same coordinates as the given point is returned regardless of the
   * snapping behavior.
   * @overload
   * @param {Point} point                      The point that is to be snapped
   * @param {GridSnappingBehavior} behavior    The snapping behavior
   * @returns {Point}                          The snapped point
   */
  /**
   * @overload
   * @param {ElevatedPoint} point              The point that is to be snapped
   * @param {GridSnappingBehavior} behavior    The snapping behavior
   * @returns {ElevatedPoint}                  The snapped point
   * @abstract
   */
  getSnappedPoint(point, behavior) {
    throw new Error("A subclass of the BaseGrid must implement the getSnappedPoint method");
  }

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

  /**
   * Measure a shortest, direct path through the given waypoints.
   * @template {{[K in "i"|"j"|"k"|"q"|"r"|"s"|"x"|"y"|"elevation"|"cost"]: never}} [SegmentData={}]
   * @overload
   * @param {(Coordinates2D & Partial<GridMeasurePathWaypointData2D> & SegmentData)[]} waypoints
   *   The waypoints the path must pass through
   * @param {object} [options]                                             Additional measurement options
   * @param {GridMeasurePathCostFunction2D<SegmentData>} [options.cost]    The function that returns the cost
   *   for a given move between grid spaces (default is the distance travelled along the direct path)
   * @returns {GridMeasurePathResult}    The measurements a shortest, direct path through the given waypoints
   */
  /**
   * @overload
   * @param {(Coordinates3D & Partial<GridMeasurePathWaypointData3D> & SegmentData)[]} waypoints
   *   The waypoints the path must pass through
   * @param {object} [options]                                             Additional measurement options
   * @param {GridMeasurePathCostFunction3D<SegmentData>} [options.cost]    The function that returns the cost
   *   for a given move between grid spaces (default is the distance travelled along the direct path)
   * @returns {GridMeasurePathResult}    The measurements a shortest, direct path through the given waypoints
   */
  measurePath(waypoints, options={}) {
    const result = {
      waypoints: [],
      segments: [],
      distance: 0,
      cost: 0,
      spaces: 0,
      diagonals: 0,
      euclidean: 0
    };
    if ( waypoints.length === 0 ) return result;

    // Create result waypoints and segments
    let from = {backward: null, forward: null, distance: 0, cost: 0, spaces: 0, diagonals: 0, euclidean: 0};
    result.waypoints.push(from);
    for ( let i = 1; i < waypoints.length; i++ ) {
      const to = {backward: null, forward: null, distance: 0, cost: 0, spaces: 0, diagonals: 0, euclidean: 0};
      const segment = {from, to, distance: 0, cost: 0, spaces: 0, diagonals: 0, euclidean: 0};
      from.forward = to.backward = segment;
      result.waypoints.push(to);
      result.segments.push(segment);
      from = to;
    }

    // Measure segments
    this._measurePath(waypoints, options, result);

    // Accumulate segment measurements
    for ( let i = 1; i < waypoints.length; i++ ) {
      const waypoint = result.waypoints[i];
      const segment = waypoint.backward;

      // Accumulate measurements
      result.distance += segment.distance;
      result.cost += segment.cost;
      result.spaces += segment.spaces;
      result.diagonals += segment.diagonals;
      result.euclidean += segment.euclidean;

      // Set waypoint measurements
      waypoint.distance = result.distance;
      waypoint.cost = result.cost;
      waypoint.spaces = result.spaces;
      waypoint.diagonals = result.diagonals;
      waypoint.euclidean = result.euclidean;
    }
    return result;
  }

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

  /**
   * Measures the path and writes the segments measurements into the result.
   * The waypoint measurements are filled in by {@link BaseGrid#measurePath}.
   * Called by {@link BaseGrid#measurePath}.
   * @template {{[K in "i"|"j"|"k"|"q"|"r"|"s"|"x"|"y"|"elevation"|"cost"|"measure"]: never}} SegmentData
   * @overload
   * @param {(Coordinates2D & Partial<GridMeasurePathWaypointData2D> & SegmentData)[]} waypoints
   *   The waypoints the path must pass through
   * @param {object} [options]                                             Additional measurement options
   * @param {GridMeasurePathCostFunction2D<SegmentData>} [options.cost]    The function that returns the cost
   *   for a given move between grid spaces (default is the distance travelled)
   * @param {GridMeasurePathResult} result    The measurement result that the measurements need to be written to
   */
  /**
   * @overload
   * @param {(Coordinates3D & Partial<GridMeasurePathWaypointData3D> & SegmentData)[]} waypoints
   *   The waypoints the path must pass through
   * @param {object} [options]                                             Additional measurement options
   * @param {GridMeasurePathCostFunction3D<SegmentData>} [options.cost]    The function that returns the cost
   *   for a given move between grid spaces (default is the distance travelled)
   * @param {GridMeasurePathResult} result    The measurement result that the measurements need to be written to
   * @protected
   * @abstract
   */
  _measurePath(waypoints, options, result) {
    throw new Error("A subclass of the BaseGrid must implement the _measurePath method");
  }

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

  /**
   * Returns the sequence of grid offsets of a shortest, direct path passing through the given waypoints.
   * @overload
   * @param {Coordinates2D[]} waypoints    The waypoints the path must pass through
   * @returns {GridOffset2D[]}             The sequence of grid offsets of a shortest, direct path
   */
  /**
   * @overload
   * @param {Coordinates3D[]} waypoints    The waypoints the path must pass through
   * @returns {GridOffset3D[]}             The sequence of grid offsets of a shortest, direct path
   * @abstract
   */
  getDirectPath(waypoints) {
    throw new Error("A subclass of the BaseGrid must implement the getDirectPath method");
  }

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

  /**
   * Get the point translated in a direction by a distance.
   * The z-coordinate is not changed.
   * @overload
   * @param {Point} point            The point that is to be translated
   * @param {number} direction       The angle of direction in degrees
   * @param {number} distance        The distance in grid units
   * @returns {Point}                The translated point
   */
  /**
   * @overload
   * @param {ElevatedPoint} point    The point that is to be translated
   * @param {number} direction       The angle of direction in degrees
   * @param {number} distance        The distance in grid units
   * @returns {ElevatedPoint}        The translated point
   * @abstract
   */
  getTranslatedPoint(point, direction, distance) {
    throw new Error("A subclass of the BaseGrid must implement the getTranslatedPoint method");
  }

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

  /**
   * Get the circle polygon given the radius in grid units for this grid.
   * The points of the polygon are returned ordered in positive orientation.
   * In gridless grids an approximation of the true circle with a deviation of less than 0.25 pixels is returned.
   * @param {Point} center      The center point of the circle.
   * @param {number} radius     The radius in grid units.
   * @returns {Point[]}         The points of the circle polygon.
   * @abstract
   */
  getCircle(center, radius) {
    throw new Error("A subclass of the BaseGrid must implement the getCircle method");
  }

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

  /**
   * Get the cone polygon given the radius in grid units and the angle in degrees for this grid.
   * The points of the polygon are returned ordered in positive orientation.
   * In gridless grids an approximation of the true cone with a deviation of less than 0.25 pixels is returned.
   * @param {Point} origin        The origin point of the cone
   * @param {number} radius       The radius in grid units
   * @param {number} direction    The direction in degrees
   * @param {number} angle        The angle in degrees
   * @returns {Point[]}           The points of the cone polygon
   */
  getCone(origin, radius, direction, angle) {
    if ( (radius <= 0) || (angle <= 0) ) return [];
    const circle = this.getCircle(origin, radius);
    if ( angle >= 360 ) return circle;
    const n = circle.length;
    const aMin = Math.normalizeRadians(Math.toRadians(direction - (angle / 2)));
    const aMax = aMin + Math.toRadians(angle);
    const pMin = {x: origin.x + (Math.cos(aMin) * this.size), y: origin.y + (Math.sin(aMin) * this.size)};
    const pMax = {x: origin.x + (Math.cos(aMax) * this.size), y: origin.y + (Math.sin(aMax) * this.size)};
    const angles = circle.map(p => {
      const a = Math.atan2(p.y - origin.y, p.x - origin.x);
      return a >= aMin ? a : a + (2 * Math.PI);
    });
    const points = [{x: origin.x, y: origin.y}];
    for ( let i = 0, c0 = circle[n - 1], a0 = angles[n - 1]; i < n; i++ ) {
      let c1 = circle[i];
      let a1 = angles[i];
      if ( a0 > a1 ) {
        const {x: x1, y: y1} = lineLineIntersection(c0, c1, origin, pMin);
        points.push({x: x1, y: y1});
        while ( a1 < aMax ) {
          points.push(c1);
          i = (i + 1) % n;
          c0 = c1;
          c1 = circle[i];
          a0 = a1;
          a1 = angles[i];
          if ( a0 > a1 ) break;
        }
        const {x: x2, y: y2} = lineLineIntersection(c0, c1, origin, pMax);
        points.push({x: x2, y: y2});
        break;
      }
      c0 = c1;
      a0 = a1;
    }
    return points;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getRect(w, h) {
    const msg = "BaseGrid#getRect is deprecated. If you need the size of a Token, use Token#getSize instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return new PIXI.Rectangle(0, 0, w * this.sizeX, h * this.sizeY);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static calculatePadding(gridType, width, height, size, padding, options) {
    const msg = "BaseGrid.calculatePadding is deprecated in favor of BaseGrid#calculateDimensions.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    let grid;
    if ( gridType === GRID_TYPES.GRIDLESS ) {
      grid = new foundry.grid.GridlessGrid({size});
    } else if ( gridType === GRID_TYPES.SQUARE ) {
      grid = new foundry.grid.SquareGrid({size});
    } else if ( gridType.between(GRID_TYPES.HEXODDR, GRID_TYPES.HEXEVENQ) ) {
      const columns = (gridType === GRID_TYPES.HEXODDQ) || (gridType === GRID_TYPES.HEXEVENQ);
      const HexagonalGrid = foundry.grid.HexagonalGrid;
      if ( options?.legacy ) return HexagonalGrid._calculatePreV10Dimensions(columns, size, width, height, padding);
      grid = new HexagonalGrid({
        columns,
        even: (gridType === GRID_TYPES.HEXEVENR) || (gridType === GRID_TYPES.HEXEVENQ),
        size
      });
    } else {
      throw new Error("Invalid grid type");
    }
    return grid.calculateDimensions(width, height, padding);
  }

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

  /**
   * @deprecated
   * @ignore
   */
  get w() {
    const msg = "BaseGrid#w is deprecated in favor of BaseGrid#sizeX.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.sizeX;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  set w(value) {
    const msg = "BaseGrid#w is deprecated in favor of BaseGrid#sizeX.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    this.sizeX = value;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get h() {
    const msg = "BaseGrid#h is deprecated in favor of BaseGrid#sizeY.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.sizeY;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  set h(value) {
    const msg = "BaseGrid#h is deprecated in favor of BaseGrid#sizeY.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    this.sizeY = value;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getTopLeft(x, y) {
    const msg = "BaseGrid#getTopLeft is deprecated. Use BaseGrid#getTopLeftPoint instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const [row, col] = this.getGridPositionFromPixels(x, y);
    return this.getPixelsFromGridPosition(row, col);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getCenter(x, y) {
    const msg = "BaseGrid#getCenter is deprecated. Use BaseGrid#getCenterPoint instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return [x, y];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getNeighbors(row, col) {
    const msg = "BaseGrid#getNeighbors is deprecated. Use BaseGrid#getAdjacentOffsets instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.getAdjacentOffsets({i: row, j: col}).map(({i, j}) => [i, j]);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getGridPositionFromPixels(x, y) {
    const msg = "BaseGrid#getGridPositionFromPixels is deprecated. Use BaseGrid#getOffset instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return [y, x].map(Math.round);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getPixelsFromGridPosition(row, col) {
    const msg = "BaseGrid#getPixelsFromGridPosition is deprecated. Use BaseGrid#getTopLeftPoint instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return [col, row].map(Math.round);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  shiftPosition(x, y, dx, dy, options={}) {
    const msg = "BaseGrid#shiftPosition is deprecated. Use BaseGrid#getShiftedPoint instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return [x + (dx * this.size), y + (dy * this.size)];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  measureDistances(segments, options={}) {
    const msg = "BaseGrid#measureDistances is deprecated. Use BaseGrid#measurePath instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return segments.map(s => {
      return (s.ray.distance / this.size) * this.distance;
    });
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getSnappedPosition(x, y, interval=null, options={}) {
    const msg = "BaseGrid#getSnappedPosition is deprecated. Use BaseGrid#getSnappedPoint instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    if ( interval === 0 ) return {x: Math.round(x), y: Math.round(y)};
    interval = interval ?? 1;
    return {
      x: Math.round(x.toNearest(this.sizeX / interval)),
      y: Math.round(y.toNearest(this.sizeY / interval))
    };
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  highlightGridPosition(layer, options) {
    const msg = "BaseGrid#highlightGridPosition is deprecated. Use GridLayer#highlightPosition instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    canvas.interface.grid.highlightPosition(layer.name, options);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get grid() {
    const msg = "canvas.grid.grid is deprecated. Use canvas.grid instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  isNeighbor(r0, c0, r1, c1) {
    const msg = "canvas.grid.isNeighbor is deprecated. Use canvas.grid.testAdjacency instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.testAdjacency({i: r0, j: c0}, {i: r1, j: c1});
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get isHex() {
    const msg = "canvas.grid.isHex is deprecated. Use of canvas.grid.isHexagonal instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.isHexagonal;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  measureDistance(origin, target, options={}) {
    const msg = "canvas.grid.measureDistance is deprecated. "
      + "Use canvas.grid.measurePath instead, which returns grid distance (gridSpaces: true) and Euclidean distance (gridSpaces: false).";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const ray = new foundry.canvas.geometry.Ray(origin, target);
    const segments = [{ray}];
    return this.measureDistances(segments, options)[0];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get highlight() {
    const msg = "canvas.grid.highlight is deprecated. Use canvas.interface.grid.highlight instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return canvas.interface.grid.highlight;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get highlightLayers() {
    const msg = "canvas.grid.highlightLayers is deprecated. Use canvas.interface.grid.highlightLayers instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return canvas.interface.grid.highlightLayers;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  addHighlightLayer(name) {
    const msg = "canvas.grid.addHighlightLayer is deprecated. Use canvas.interface.grid.addHighlightLayer instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return canvas.interface.grid.addHighlightLayer(name);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  clearHighlightLayer(name) {
    const msg = "canvas.grid.clearHighlightLayer is deprecated. Use canvas.interface.grid.clearHighlightLayer instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    canvas.interface.grid.clearHighlightLayer(name);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  destroyHighlightLayer(name) {
    const msg = "canvas.grid.destroyHighlightLayer is deprecated. Use canvas.interface.grid.destroyHighlightLayer instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    canvas.interface.grid.destroyHighlightLayer(name);
  }

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


  /**
   * @deprecated since v12
   * @ignore
   */
  getHighlightLayer(name) {
    const msg = "canvas.grid.getHighlightLayer is deprecated. Use canvas.interface.grid.getHighlightLayer instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return canvas.interface.grid.getHighlightLayer(name);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  highlightPosition(name, options) {
    const msg = "canvas.grid.highlightPosition is deprecated. Use canvas.interface.grid.highlightPosition instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    canvas.interface.grid.highlightPosition(name, options);
  }
}

/**
 * The gridless grid class.
 */
class GridlessGrid extends BaseGrid {

  /**
   * @override
   * @readonly
   */
  type = GRID_TYPES.GRIDLESS;

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

  /** @override */
  calculateDimensions(sceneWidth, sceneHeight, padding) {
    // Note: Do not replace `* (1 / this.size)` by `/ this.size`!
    // It could change the result and therefore break certain scenes.
    const x = Math.ceil((padding * sceneWidth) * (1 / this.size)) * this.size;
    const y = Math.ceil((padding * sceneHeight) * (1 / this.size)) * this.size;
    const width = sceneWidth + (2 * x);
    const height = sceneHeight + (2 * y);
    return {width, height, x, y, rows: Math.ceil(height), columns: Math.ceil(width)};
  }

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

  /** @override */
  getOffset(coords) {
    let i = coords.i;
    if ( i !== undefined ) {
      const {j, k} = coords;
      return k !== undefined ? {i, j, k} : {i, j};
    }
    const {x, y, elevation} = coords;
    i = Math.floor(y) | 0;
    const j = Math.floor(x) | 0;
    if ( elevation === undefined ) return {i, j};
    const k = Math.floor((elevation / this.distance * this.size) + 1e-8) | 0;
    return {i, j, k};
  }

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

  /** @override */
  getOffsetRange({x, y, width, height}) {
    const i0 = Math.floor(y) | 0;
    const j0 = Math.floor(x) | 0;
    if ( !((width > 0) && (height > 0)) ) return [i0, j0, i0, j0];
    return [i0, j0, Math.ceil(y + height) | 0, Math.ceil(x + width) | 0];
  }

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

  /** @override */
  getAdjacentOffsets(coords) {
    return [];
  }

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

  /** @override */
  testAdjacency(coords1, coords2) {
    return false;
  }

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

  /** @override */
  getShiftedOffset(coords, direction) {
    if ( coords.x === undefined ) {
      const {i, j, k} = coords;
      coords = k !== undefined ? {x: j, y: i, elevation: k / this.size * this.distance} : {x: j, y: i};
    }
    return this.getOffset(this.getShiftedPoint(coords, direction));
  }

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

  /** @override */
  getShiftedPoint(point, direction) {
    let di = 0;
    let dj = 0;
    let dk = 0;
    if ( direction & MOVEMENT_DIRECTIONS.UP ) di--;
    if ( direction & MOVEMENT_DIRECTIONS.DOWN ) di++;
    if ( direction & MOVEMENT_DIRECTIONS.LEFT ) dj--;
    if ( direction & MOVEMENT_DIRECTIONS.RIGHT ) dj++;
    if ( direction & MOVEMENT_DIRECTIONS.DESCEND ) dk--;
    if ( direction & MOVEMENT_DIRECTIONS.ASCEND ) dk++;
    const x = point.x + (dj * this.size);
    const y = point.y + (di * this.size);
    const elevation = point.elevation;
    return elevation !== undefined ? {x, y, elevation: elevation + (dk * this.distance)} : {x, y};
  }

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

  /** @override */
  getTopLeftPoint(coords) {
    const i = coords.i;
    if ( i !== undefined ) {
      const {j, k} = coords;
      return k !== undefined ? {x: j, y: i, elevation: k / this.size * this.distance} : {x: j, y: i};
    }
    const {x, y, elevation} = coords;
    return elevation !== undefined ? {x, y, elevation} : {x, y};
  }

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

  /** @override */
  getCenterPoint(coords) {
    const i = coords.i;
    if ( i !== undefined ) {
      const {j, k} = coords;
      return k !== undefined ? {x: j, y: i, elevation: k / this.size * this.distance} : {x: j, y: i};
    }
    const {x, y, elevation} = coords;
    return elevation !== undefined ? {x, y, elevation} : {x, y};
  }

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

  /** @override */
  getShape() {
    return [];
  }

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

  /** @override */
  getVertices(coords) {
    return [];
  }

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

  /** @override */
  getSnappedPoint({x, y, elevation}, behavior) {
    return elevation !== undefined ? {x, y, elevation} : {x, y};
  }

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

  /** @override */
  _measurePath(waypoints, {cost}, result) {

    // Prepare data for the starting point
    const w0 = waypoints[0];
    let o0 = this.getOffset(w0);
    let p0 = this.getCenterPoint(w0);

    // Iterate over additional path points
    const is3D = o0.k !== undefined;
    for ( let i = 1; i < waypoints.length; i++ ) {
      const w1 = waypoints[i];
      const o1 = this.getOffset(w1);
      const p1 = this.getCenterPoint(w1);
      const cost1 = w1.cost ?? cost;

      // Measure segment
      if ( w1.measure !== false ) {
        const segment = result.waypoints[i].backward;
        segment.distance = Math.hypot(p0.x - p1.x, p0.y - p1.y, is3D ? (p0.elevation - p1.elevation) / this.distance
          * this.size : 0) / this.size * this.distance;
        segment.euclidean = segment.distance;
        const offsetDistance = Math.hypot(o0.i - o1.i, o0.j - o1.j, is3D ? o0.k - o1.k : 0) / this.size * this.distance;
        if ( (cost1 === undefined) || (offsetDistance === 0) ) segment.cost = w1.teleport ? 0 : offsetDistance;
        else if ( typeof cost1 === "function" ) segment.cost = cost1(o0, o1, offsetDistance, w1);
        else segment.cost = Number(cost1);
      }

      o0 = o1;
      p0 = p1;
    }
  }

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

  /** @override */
  getDirectPath(waypoints) {
    if ( waypoints.length === 0 ) return [];
    let o0 = this.getOffset(waypoints[0]);
    const path = [o0];
    for ( let i = 1; i < waypoints.length; i++ ) {
      const o1 = this.getOffset(waypoints[i]);
      if ( (o0.i === o1.i) && (o0.j === o1.j) && (o0.k === o1.k) ) continue;
      path.push(o1);
      o0 = o1;
    }
    return path;
  }

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

  /** @override */
  getTranslatedPoint(point, direction, distance) {
    direction = Math.toRadians(direction);
    const dx = Math.cos(direction);
    const dy = Math.sin(direction);
    const s = distance / this.distance * this.size;
    const x = point.x + (dx * s);
    const y = point.y + (dy * s);
    const elevation = point.elevation;
    return elevation !== undefined ? {x, y, elevation} : {x, y};
  }

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

  /** @override */
  getCircle({x, y}, radius) {
    if ( radius <= 0 ) return [];
    const r = radius / this.distance * this.size;
    const n = Math.max(Math.ceil(Math.PI / Math.acos(Math.max(r - 0.25, 0) / r)), 4);
    const points = new Array(n);
    for ( let i = 0; i < n; i++ ) {
      const a = 2 * Math.PI * (i / n);
      points[i] = {x: x + (Math.cos(a) * r), y: y + (Math.sin(a) * r)};
    }
    return points;
  }

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

  /** @override */
  getCone(origin, radius, direction, angle) {
    if ( (radius <= 0) || (angle <= 0) ) return [];
    if ( angle >= 360 ) return this.getCircle(origin, radius);
    const r = radius / this.distance * this.size;
    const n = Math.max(Math.ceil(Math.PI / Math.acos(Math.max(r - 0.25, 0) / r) * (angle / 360)), 4);
    const a0 = Math.toRadians(direction - (angle / 2));
    const a1 = Math.toRadians(direction + (angle / 2));
    const points = new Array(n + 1);
    const {x, y} = origin;
    points[0] = {x, y};
    for ( let i = 0; i <= n; i++ ) {
      const a = Math.mix(a0, a1, i / n);
      points[i + 1] = {x: x + (Math.cos(a) * r), y: y + (Math.sin(a) * r)};
    }
    return points;
  }
}

/**
 * @import {SquareGridConfiguration, GridOffset2D, GridOffset3D, GridCoordinates2D,
 *   GridCoordinates3D} from "./_types.mjs"
 * @import {Point} from "../_types.mjs"
 * @import {GridDiagonalRule} from "../constants.mjs"
 */

/**
 * The square grid class.
 */
class SquareGrid extends BaseGrid {
  /**
   * The square grid constructor.
   * @param {SquareGridConfiguration} config   The grid configuration
   */
  constructor(config) {
    super(config);

    /**
     * The rule for diagonal measurement (see {@link CONST.GRID_DIAGONALS}).
     * @type {GridDiagonalRule}
     * @readonly
     */
    this.diagonals = config.diagonals ?? GRID_DIAGONALS.EQUIDISTANT;
  }

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

  /**
   * @override
   * @readonly
   */
  type = GRID_TYPES.SQUARE;

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

  /** @override */
  getOffset(coords) {
    let i = coords.i;
    let j;
    let k;
    if ( i !== undefined ) {
      j = coords.j;
      k = coords.k;
    } else {
      j = Math.floor(coords.x / this.size);
      i = Math.floor(coords.y / this.size);
      if ( coords.elevation !== undefined ) k = Math.floor((coords.elevation / this.distance) + 1e-8);
    }
    return k !== undefined ? {i, j, k} : {i, j};
  }

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

  /** @override */
  getOffsetRange({x, y, width, height}) {
    const i0 = Math.floor(y / this.size);
    const j0 = Math.floor(x / this.size);
    if ( !((width > 0) && (height > 0)) ) return [i0, j0, i0, j0];
    return [i0, j0, Math.ceil((y + height) / this.size) | 0, Math.ceil((x + width) / this.size) | 0];
  }

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

  /** @override */
  getAdjacentOffsets(coords) {
    const {i, j, k} = this.getOffset(coords);

    // 2D
    if ( k === undefined ) {

      // Non-diagonals
      if ( this.diagonals === GRID_DIAGONALS.ILLEGAL ) return [
        {i: i - 1, j},
        {i, j: j - 1},
        {i, j: j + 1},
        {i: i + 1, j}
      ];

      // Diagonals
      return [
        {i: i - 1, j: j - 1},
        {i: i - 1, j},
        {i: i - 1, j: j + 1},
        {i, j: j - 1},
        {i, j: j + 1},
        {i: i + 1, j: j - 1},
        {i: i + 1, j},
        {i: i + 1, j: j + 1}
      ];
    }

    // 3D
    else {

      // Non-diagonals
      if ( this.diagonals === GRID_DIAGONALS.ILLEGAL ) return [
        {i: i - 1, j, k},
        {i, j: j - 1, k},
        {i, j, k: k - 1},
        {i, j, k: k + 1},
        {i, j: j + 1, k},
        {i: i + 1, j, k}
      ];

      // Diagonals
      const offsets = [];
      for ( let di = -1; di <= 1; di++ ) {
        for ( let dj = -1; dj <= 1; dj++ ) {
          for ( let dk = -1; dk <= 1; dk++ ) {
            if ( (di === 0) && (dj === 0) && (dk === 0) ) continue;
            offsets.push({i: i + di, j: j + dj, k: k + dk});
          }
        }
      }
      return offsets;
    }
  }

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

  /** @override */
  testAdjacency(coords1, coords2) {
    const {i: i1, j: j1, k: k1} = this.getOffset(coords1);
    const {i: i2, j: j2, k: k2} = this.getOffset(coords2);
    const di = Math.abs(i1 - i2);
    const dj = Math.abs(j1 - j2);
    const dk = k1 !== undefined ? Math.abs(k1 - k2) : 0;
    const diagonals = this.diagonals !== GRID_DIAGONALS.ILLEGAL;
    return diagonals ? Math.max(di, dj, dk) === 1 : (di + dj + dk) === 1;
  }

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

  /** @override */
  getShiftedOffset(coords, direction) {
    let di = 0;
    let dj = 0;
    let dk = 0;
    if ( direction & MOVEMENT_DIRECTIONS.UP ) di--;
    if ( direction & MOVEMENT_DIRECTIONS.DOWN ) di++;
    if ( direction & MOVEMENT_DIRECTIONS.LEFT ) dj--;
    if ( direction & MOVEMENT_DIRECTIONS.RIGHT ) dj++;
    if ( direction & MOVEMENT_DIRECTIONS.DESCEND ) dk--;
    if ( direction & MOVEMENT_DIRECTIONS.ASCEND ) dk++;
    if ( ((Math.abs(di) + Math.abs(dj) + Math.abs(dk)) > 1) && (this.diagonals === GRID_DIAGONALS.ILLEGAL) ) {
      // Diagonal movement is not allowed
      di = 0;
      dj = 0;
      dk = 0;
    }
    const offset = this.getOffset(coords);
    offset.i += di;
    offset.j += dj;
    if ( offset.k !== undefined ) offset.k += dk;
    return offset;
  }

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

  /** @override */
  getShiftedPoint(point, direction) {
    const topLeft = this.getTopLeftPoint(point);
    const shifted = this.getTopLeftPoint(this.getShiftedOffset(topLeft, direction));
    shifted.x = point.x + (shifted.x - topLeft.x);
    shifted.y = point.y + (shifted.y - topLeft.y);
    if ( shifted.elevation !== undefined ) {
      shifted.elevation = point.elevation + (shifted.elevation - topLeft.elevation);
    }
    return shifted;
  }

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

  /** @override */
  getTopLeftPoint(coords) {
    let i = coords.i;
    let j;
    let k;
    if ( i !== undefined ) {
      j = coords.j;
      k = coords.k;
    } else {
      const {x, y, elevation} = coords;
      j = Math.floor(x / this.size);
      i = Math.floor(y / this.size);
      if ( elevation !== undefined ) k = Math.floor((elevation / this.distance) + 1e-8);
    }
    const x = j * this.size;
    const y = i * this.size;
    if ( k === undefined ) return {x, y};
    const elevation = k * this.distance;
    return {x, y, elevation};
  }

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

  /** @override */
  getCenterPoint(coords) {
    let i = coords.i;
    let j;
    let k;
    if ( i !== undefined ) {
      j = coords.j;
      k = coords.k;
    } else {
      const {x, y, elevation} = coords;
      j = Math.floor(x / this.size);
      i = Math.floor(y / this.size);
      if ( elevation !== undefined ) k = Math.floor((elevation / this.distance) + 1e-8);
    }
    const x = (j + 0.5) * this.size;
    const y = (i + 0.5) * this.size;
    if ( k === undefined ) return {x, y};
    const elevation = (k + 0.5) * this.distance;
    return {x, y, elevation};
  }

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

  /** @override */
  getShape() {
    const s = this.size / 2;
    return [{x: -s, y: -s}, {x: s, y: -s}, {x: s, y: s}, {x: -s, y: s}];
  }

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

  /** @override */
  getVertices(coords) {
    const {i, j} = this.getOffset(coords);
    const x0 = j * this.size;
    const x1 = (j + 1) * this.size;
    const y0 = i * this.size;
    const y1 = (i + 1) * this.size;
    return [{x: x0, y: y0}, {x: x1, y: y0}, {x: x1, y: y1}, {x: x0, y: y1}];
  }

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

  /** @override */
  getSnappedPoint(point, {mode, resolution=1}) {
    if ( mode & -65524 ) throw new Error("Invalid snapping mode");
    if ( mode === 0 ) {
      return point.elevation !== undefined ? {x: point.x, y: point.y, elevation: point.elevation}
        : {x: point.x, y: point.y};
    }

    let nearest;
    let distance;
    const keepNearest = candidate => {
      if ( !nearest ) return nearest = candidate;
      const {x, y} = point;
      distance ??= ((nearest.x - x) ** 2) + ((nearest.y - y) ** 2);
      const d = ((candidate.x - x) ** 2) + ((candidate.y - y) ** 2);
      if ( d < distance ) {
        nearest = candidate;
        distance = d;
      }
      return nearest;
    };

    // Any edge = Any side
    if ( !(mode & 0x2) ) {
      // Horizontal (Top/Bottom) side + Vertical (Left/Right) side = Any edge
      if ( (mode & 0x3000) && (mode & 0xC000) ) mode |= 0x2;
      // Horizontal (Top/Bottom) side
      else if ( mode & 0x3000 ) keepNearest(this.#snapToTopOrBottom(point, resolution));
      // Vertical (Left/Right) side
      else if ( mode & 0xC000 ) keepNearest(this.#snapToLeftOrRight(point, resolution));
    }

    // With vertices (= corners)
    if ( mode & 0xFF0 ) {
      switch ( mode & -65521 ) {
        case 0x0: keepNearest(this.#snapToVertex(point, resolution)); break;
        case 0x1: keepNearest(this.#snapToVertexOrCenter(point, resolution)); break;
        case 0x2: keepNearest(this.#snapToEdgeOrVertex(point, resolution)); break;
        case 0x3: keepNearest(this.#snapToEdgeOrVertexOrCenter(point, resolution)); break;
      }
    }
    // Without vertices
    else {
      switch ( mode & -65521 ) {
        case 0x1: keepNearest(this.#snapToCenter(point, resolution)); break;
        case 0x2: keepNearest(this.#snapToEdge(point, resolution)); break;
        case 0x3: keepNearest(this.#snapToEdgeOrCenter(point, resolution)); break;
      }
    }

    return point.elevation === undefined ? nearest
      : {x: nearest.x, y: nearest.y, elevation: Math.round((point.elevation / this.distance) + 1e-8) * this.distance};
  }

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

  /**
   * Snap the point to the nearest center of a square.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToCenter({x, y}, resolution) {
    const s = this.size / resolution;
    const t = this.size / 2;
    return {
      x: (Math.round((x - t) / s) * s) + t,
      y: (Math.round((y - t) / s) * s) + t
    };
  }

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

  /**
   * Snap the point to the nearest vertex of a square.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToVertex({x, y}, resolution) {
    const s = this.size / resolution;
    const t = this.size / 2;
    return {
      x: ((Math.floor((x - t) / s) + 0.5) * s) + t,
      y: ((Math.floor((y - t) / s) + 0.5) * s) + t
    };
  }

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

  /**
   * Snap the point to the nearest vertex or center of a square.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToVertexOrCenter({x, y}, resolution) {
    const s = this.size / resolution;
    const t = this.size / 2;
    const c0 = (x - t) / s;
    const r0 = (y - t) / s;
    const c1 = Math.round(c0 + r0);
    const r1 = Math.round(r0 - c0);
    return {
      x: ((c1 - r1) * s / 2) + t,
      y: ((c1 + r1) * s / 2) + t
    };
  }

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

  /**
   * Snap the point to the nearest edge of a square.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToEdge({x, y}, resolution) {
    const s = this.size / resolution;
    const t = this.size / 2;
    const c0 = (x - t) / s;
    const r0 = (y - t) / s;
    const c1 = Math.floor(c0 + r0);
    const r1 = Math.floor(r0 - c0);
    return {
      x: ((c1 - r1) * s / 2) + t,
      y: ((c1 + r1 + 1) * s / 2) + t
    };
  }

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

  /**
   * Snap the point to the nearest edge or center of a square.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToEdgeOrCenter({x, y}, resolution) {
    const s = this.size / resolution;
    const t = this.size / 2;
    const c0 = (x - t) / s;
    const r0 = (y - t) / s;
    const x0 = (Math.round(c0) * s) + t;
    const y0 = (Math.round(r0) * s) + t;
    if ( Math.max(Math.abs(x - x0), Math.abs(y - y0)) <= s / 4 ) {
      return {x: x0, y: y0};
    }
    const c1 = Math.floor(c0 + r0);
    const r1 = Math.floor(r0 - c0);
    return {
      x: ((c1 - r1) * s / 2) + t,
      y: ((c1 + r1 + 1) * s / 2) + t
    };
  }

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

  /**
   * Snap the point to the nearest edge or vertex of a square.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToEdgeOrVertex({x, y}, resolution) {
    const s = this.size / resolution;
    const t = this.size / 2;
    const c0 = (x - t) / s;
    const r0 = (y - t) / s;
    const x0 = ((Math.floor(c0) + 0.5) * s) + t;
    const y0 = ((Math.floor(r0) + 0.5) * s) + t;
    if ( Math.max(Math.abs(x - x0), Math.abs(y - y0)) <= s / 4 ) {
      return {x: x0, y: y0};
    }
    const c1 = Math.floor(c0 + r0);
    const r1 = Math.floor(r0 - c0);
    return {
      x: ((c1 - r1) * s / 2) + t,
      y: ((c1 + r1 + 1) * s / 2) + t
    };
  }

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

  /**
   * Snap the point to the nearest edge, vertex, or center of a square.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToEdgeOrVertexOrCenter({x, y}, resolution) {
    const s = this.size / (resolution * 2);
    return {
      x: Math.round(x / s) * s,
      y: Math.round(y / s) * s
    };
  }

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

  /**
   * Snap the point to the nearest top/bottom side of a square.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToTopOrBottom({x, y}, resolution) {
    const s = this.size / resolution;
    const t = this.size / 2;
    return {
      x: (Math.round((x - t) / s) * s) + t,
      y: ((Math.floor((y - t) / s) + 0.5) * s) + t
    };
  }

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

  /**
   * Snap the point to the nearest left/right side of a square.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToLeftOrRight({x, y}, resolution) {
    const s = this.size / resolution;
    const t = this.size / 2;
    return {
      x: ((Math.floor((x - t) / s) + 0.5) * s) + t,
      y: (Math.round((y - t) / s) * s) + t
    };
  }

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

  /** @override */
  _measurePath(waypoints, {cost}, result) {

    // Convert to point coordiantes
    const toPoint = coords => {
      if ( coords.x !== undefined ) return coords;
      return this.getCenterPoint(coords);
    };

    // Prepare data for the starting point
    const w0 = waypoints[0];
    let o0 = this.getOffset(w0);
    let p0 = toPoint(w0);

    // Iterate over additional path points
    const is3D = o0.k !== undefined;
    const diagonals = this.diagonals;
    let l0 = diagonals === GRID_DIAGONALS.ALTERNATING_2 ? 1.0 : 0.0;
    let dx0 = l0;
    let dy0 = l0;
    let dz0 = l0;
    let nd = l0 * 1.5;
    for ( let i = 1; i < waypoints.length; i++ ) {
      const w1 = waypoints[i];
      const o1 = this.getOffset(w1);
      const p1 = toPoint(w1);
      const cost1 = w1.cost ?? cost;

      // Determine the number of moves total, number of diagonal moves, and cost of the moves
      if ( w1.measure !== false ) {
        let di = Math.abs(o0.i - o1.i);
        let dj = Math.abs(o0.j - o1.j);
        if ( di < dj ) [di, dj] = [dj, di];
        let dk = 0;
        if ( is3D ) {
          dk = Math.abs(o0.k - o1.k);
          if ( dj < dk ) [dj, dk] = [dk, dj];
          if ( di < dj ) [di, dj] = [dj, di];
        }
        let n = di; // The number of moves total
        let d = dj; // The number of diagonal moves
        let c; // The cost of the moves
        const nd0 = nd;
        switch ( diagonals ) {
          case GRID_DIAGONALS.EQUIDISTANT: c = di; break;
          case GRID_DIAGONALS.EXACT: c = di + (((Math.SQRT2 - 1) * (dj - dk)) + ((Math.SQRT3 - 1) * dk)); break;
          case GRID_DIAGONALS.APPROXIMATE: c = di + ((0.5 * (dj - dk)) + (0.75 * dk)); break;
          case GRID_DIAGONALS.RECTILINEAR: c = di + (dj + dk); break;
          case GRID_DIAGONALS.ALTERNATING_1:
          case GRID_DIAGONALS.ALTERNATING_2:
            nd += (dj + (0.5 * dk));
            c = di + (Math.floor(nd / 2) - Math.floor(nd0 / 2));
            break;
          case GRID_DIAGONALS.ILLEGAL:
            n = di + (dj + dk);
            d = 0;
            c = n;
            break;
        }

        // Determine the distance of the segment
        let dx = Math.abs(p0.x - p1.x) / this.size;
        let dy = Math.abs(p0.y - p1.y) / this.size;
        if ( dx < dy ) [dx, dy] = [dy, dx];
        let dz = 0;
        if ( is3D ) {
          dz = Math.abs(p0.elevation - p1.elevation) / this.distance;
          if ( dy < dz ) [dy, dz] = [dz, dy];
          if ( dx < dy ) [dx, dy] = [dy, dx];
        }
        let l; // The distance of the segment
        switch ( diagonals ) {
          case GRID_DIAGONALS.EQUIDISTANT: l = dx; break;
          case GRID_DIAGONALS.EXACT: l = dx + (((Math.SQRT2 - 1) * (dy - dz)) + ((Math.SQRT3 - 1) * dz)); break;
          case GRID_DIAGONALS.APPROXIMATE: l = dx + ((0.5 * (dy - dz)) + (0.75 * dz)); break;
          case GRID_DIAGONALS.RECTILINEAR: l = dx + (dy + dz); break;
          case GRID_DIAGONALS.ALTERNATING_1:
          case GRID_DIAGONALS.ALTERNATING_2: {
            dx0 += dx;
            dy0 += dy;
            dz0 += dz;
            const fx = Math.floor(dx0);
            const fy = Math.floor(dy0);
            const fz = Math.floor(dz0);
            const a = fx + (0.5 * fy) + (0.25 * fz);
            const a0 = Math.floor(a);
            const a1 = Math.floor(a + 1);
            const a2 = Math.floor(a + 1.5);
            const a3 = Math.floor(a + 1.75);
            const mx = dx0 - fx;
            const my = dy0 - fy;
            const mz = dz0 - fz;
            const l1 = (a0 * (1 - mx)) + (a1 * (mx - my)) + (a2 * (my - mz)) + (a3 * mz);
            l = l1 - l0;
            l0 = l1;
            break;
          }
          case GRID_DIAGONALS.ILLEGAL: l = dx + (dy + dz); break;
        }
        if ( l.almostEqual(c) ) l = c;

        const segment = result.waypoints[i].backward;
        segment.distance = l * this.distance;
        if ( (cost1 === undefined) || (c === 0) ) segment.cost = w1.teleport ? 0 : c * this.distance;
        else if ( typeof cost1 === "function" ) segment.cost = w1.teleport ? cost1(o0, o1, c * this.distance, w1)
          : this.#calculateCost(o0, o1, cost1, nd0, w1);
        else segment.cost = Number(cost1);
        segment.spaces = n;
        segment.diagonals = d;
        segment.euclidean = Math.hypot(p0.x - p1.x, p0.y - p1.y, is3D ? (p0.elevation - p1.elevation) / this.distance
          * this.size : 0) / this.size * this.distance;
      }

      o0 = o1;
      p0 = p1;
    }
  }

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

  /**
   * Calculate the cost of the direct path segment.
   * @template SegmentData
   * @overload
   * @param {GridOffset2D} from      The coordinates the segment starts from
   * @param {GridOffset2D} to        The coordinates the segment goes to
   * @param {GridMeasurePathCostFunction2D<SegmentData>} cost    The cost function
   * @param {number} diagonals       The number of diagonal moves that have been performed already
   * @param {SegmentData} segment    The segment data
   * @returns {number}               The cost of the path segment
   */
  /**
   * @overload
   * @param {GridOffset3D} from      The coordinates the segment starts from
   * @param {GridOffset3D} to        The coordinates the segment goes to
   * @param {GridMeasurePathCostFunction3D<SegmentData>} cost    The cost function
   * @param {number} diagonals       The number of diagonal moves that have been performed already
   * @param {SegmentData} segment    The segment data
   * @returns {number}               The cost of the path segment
   */
  #calculateCost(from, to, cost, diagonals, segment) {
    const path = this.getDirectPath([from, to]);
    if ( path.length <= 1 ) return 0;

    // Prepare data for the starting point
    let o0 = path[0];
    let c = 0;

    // Iterate over additional path points
    for ( let i = 1; i < path.length; i++ ) {
      const o1 = path[i];

      // Determine the normalized distance
      let k;
      const m = (o0.i === o1.i) + (o0.j === o1.j) + (o0.k === o1.k);
      if ( m === 2 ) k = 1;
      else if ( m === 1 ) {
        switch ( this.diagonals ) {
          case GRID_DIAGONALS.EQUIDISTANT: k = 1; break;
          case GRID_DIAGONALS.EXACT: k = Math.SQRT2; break;
          case GRID_DIAGONALS.APPROXIMATE: k = 1.5; break;
          case GRID_DIAGONALS.RECTILINEAR: k = 2; break;
          case GRID_DIAGONALS.ALTERNATING_1:
          case GRID_DIAGONALS.ALTERNATING_2:
            k = 1 + (Math.floor((diagonals + 1) / 2) - Math.floor(diagonals / 2));
            break;
        }
        diagonals += 1;
      } else {
        switch ( this.diagonals ) {
          case GRID_DIAGONALS.EQUIDISTANT: k = 1; break;
          case GRID_DIAGONALS.EXACT: k = Math.SQRT3; break;
          case GRID_DIAGONALS.APPROXIMATE: k = 1.75; break;
          case GRID_DIAGONALS.RECTILINEAR: k = 3; break;
          case GRID_DIAGONALS.ALTERNATING_1:
          case GRID_DIAGONALS.ALTERNATING_2:
            k = 1 + (Math.floor((diagonals + 1.5) / 2) - Math.floor(diagonals / 2));
            break;
        }
        diagonals += 1.5;
      }

      // Calculate and accumulate the cost
      c += cost(o0, o1, k * this.distance, segment);

      o0 = o1;
    }

    return c;
  }

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

  /** @override */
  getDirectPath(waypoints) {
    if ( waypoints.length === 0 ) return [];
    const w0 = waypoints[0];
    if ( (w0.k !== undefined) || (w0.elevation !== undefined) ) return this.#getDirectPath3D(waypoints);
    else return this.#getDirectPath2D(waypoints);
  }

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

  /**
   * Returns the sequence of grid offsets of a shortest, direct path passing through the given waypoints.
   * @see {@link https://en.wikipedia.org/wiki/Bresenham's_line_algorithm}
   * @see {@link https://playtechs.blogspot.com/2007/03/raytracing-on-grid.html}
   * @param {GridCoordinates2D[]} waypoints    The waypoints the path must pass through
   * @returns {GridOffset2D[]}                 The sequence of grid offsets of a shortest, direct path
   */
  #getDirectPath2D(waypoints) {

    // Prepare data for the starting point
    const o0 = this.getOffset(waypoints[0]);
    let {i: i0, j: j0} = o0;
    const path = [o0];

    // Iterate over additional path points
    const diagonals = this.diagonals !== GRID_DIAGONALS.ILLEGAL;
    for ( let i = 1; i < waypoints.length; i++ ) {
      const o1 = this.getOffset(waypoints[i]);
      const {i: i1, j: j1} = o1;
      if ( (i0 === i1) && (j0 === j1) ) continue;

      // Walk from (i0, j0) to (i1, j1)
      const di = Math.abs(i0 - i1);
      const dj = 0 - Math.abs(j0 - j1);
      const si = i0 < i1 ? 1 : -1;
      const sj = j0 < j1 ? 1 : -1;
      let e = di + dj;
      if ( diagonals ) {
        for ( ;; ) {
          const e2 = e * 2;
          if ( e2 >= dj ) {
            e += dj;
            i0 += si;
          }
          if ( e2 <= di ) {
            e += di;
            j0 += sj;
          }
          if ( (i0 === i1) && (j0 === j1) ) break;
          path.push({i: i0, j: j0});
        }
      } else {
        const di2 = 2 * di;
        const dj2 = 2 * dj;
        for ( ;; ) {
          if ( e > 0 ) {
            e += dj2;
            i0 += si;
          } else {
            e += di2;
            j0 += sj;
          }
          if ( (i0 === i1) && (j0 === j1) ) break;
          path.push({i: i0, j: j0});
        }
      }
      path.push(o1);

      i0 = i1;
      j0 = j1;
    }

    return path;
  }

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

  /**
   * Returns the sequence of grid offsets of a shortest, direct path passing through the given waypoints.
   * @see {@link https://www.geeksforgeeks.org/bresenhams-algorithm-for-3-d-line-drawing}
   * @see {@link http://www.cse.yorku.ca/~amana/research/grid.pdf}
   * @param {GridCoordinates3D[]} waypoints    The waypoints the path must pass through
   * @returns {GridOffset3D[]}                 The sequence of grid offsets of a shortest, direct path
   */
  #getDirectPath3D(waypoints) {

    // Prepare data for the starting point
    const o0 = this.getOffset(waypoints[0]);
    let {i: i0, j: j0, k: k0} = o0;
    const path = [o0];

    // Iterate over additional path points
    const diagonals = this.diagonals !== GRID_DIAGONALS.ILLEGAL;
    for ( let i = 1; i < waypoints.length; i++ ) {
      const o1 = this.getOffset(waypoints[i]);
      const {i: i1, j: j1, k: k1} = o1;
      if ( (i0 === i1) && (j0 === j1) && (k0 === k1) ) continue;

      // Walk from (i0, j0, k0) to (i1, j1, k1)
      const di = Math.abs(i0 - i1);
      const dj = Math.abs(j0 - j1);
      const dk = Math.abs(k0 - k1);
      const si = i0 < i1 ? 1 : -1;
      const sj = j0 < j1 ? 1 : -1;
      const sk = k0 < k1 ? 1 : -1;
      if ( diagonals ) {
        const di2 = 2 * di;
        const dj2 = 2 * dj;
        const dk2 = 2 * dk;
        if ( (di >= dj) && (di >= dk) ) {
          let ej = 0 - di;
          let ek = ej;
          for ( ;; ) {
            ej += dj2;
            ek += dk2;
            i0 += si;
            if ( ej >= 0 ) {
              ej -= di2;
              j0 += sj;
            }
            if ( ek >= 0 ) {
              ek -= di2;
              k0 += sk;
            }
            if ( i0 === i1 ) break;
            path.push({i: i0, j: j0, k: k0});
          }
        } else if ( (dj >= di) && (dj >= dk) ) {
          let ei = 0 - dj;
          let ek = ei;
          for ( ;; ) {
            ei += di2;
            ek += dk2;
            j0 += sj;
            if ( ei >= 0 ) {
              ei -= dj2;
              i0 += si;
            }
            if ( ek >= 0 ) {
              ek -= dj2;
              k0 += sk;
            }
            if ( j0 === j1 ) break;
            path.push({i: i0, j: j0, k: k0});
          }
        } else {
          let ei = 0 - dk;
          let ej = ei;
          for ( ;; ) {
            ei += di2;
            ej += dj2;
            k0 += sk;
            if ( ei >= 0 ) {
              ei -= dk2;
              i0 += si;
            }
            if ( ej >= 0 ) {
              ej -= dk2;
              j0 += sj;
            }
            if ( k0 === k1 ) break;
            path.push({i: i0, j: j0, k: k0});
          }
        }
      } else {
        const di1 = di || 1;
        const dj1 = dj || 1;
        const dk1 = dk || 1;
        const tdi = dj1 * dk1;
        const tdj = di1 * dk1;
        const tdk = di1 * dj1;
        const tm = (di1 * dj1 * dk1) + 1;
        let ti = di > 0 ? tdi : tm;
        let tj = dj > 0 ? tdj : tm;
        let tk = dk > 0 ? tdk : tm;
        for ( ;; ) {
          if ( ti < tj ) {
            if ( ti <= tk ) {
              ti += tdi;
              i0 += si;
            } else {
              tk += tdk;
              k0 += sk;
            }
          } else {
            if ( tj <= tk ) {
              tj += tdj;
              j0 += sj;
            } else {
              tk += tdk;
              k0 += sk;
            }
          }
          if ( (i0 === i1) && (j0 === j1) && (k0 === k1) ) break;
          path.push({i: i0, j: j0, k: k0});
        }
      }
      path.push(o1);

      i0 = i1;
      j0 = j1;
      k0 = k1;
    }

    return path;
  }

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

  /** @override */
  getTranslatedPoint(point, direction, distance) {
    direction = Math.toRadians(direction);
    const dx = Math.cos(direction);
    const dy = Math.sin(direction);
    const adx = Math.abs(dx);
    const ady = Math.abs(dy);
    let s = distance / this.distance;
    switch ( this.diagonals ) {
      case GRID_DIAGONALS.EQUIDISTANT: s /= Math.max(adx, ady); break;
      case GRID_DIAGONALS.EXACT: s /= (Math.max(adx, ady) + ((Math.SQRT2 - 1) * Math.min(adx, ady))); break;
      case GRID_DIAGONALS.APPROXIMATE: s /= (Math.max(adx, ady) + (0.5 * Math.min(adx, ady))); break;
      case GRID_DIAGONALS.ALTERNATING_1: {
        let a = Math.max(adx, ady);
        const b = Math.min(adx, ady);
        const t = (2 * a) + b;
        let k = Math.floor(s * b / t);
        if ( (s * b) - (k * t) > a ) {
          a += b;
          k = -1 - k;
        }
        s = (s - k) / a;
        break;
      }
      case GRID_DIAGONALS.ALTERNATING_2: {
        let a = Math.max(adx, ady);
        const b = Math.min(adx, ady);
        const t = (2 * a) + b;
        let k = Math.floor(s * b / t);
        if ( (s * b) - (k * t) > a + b ) {
          k += 1;
        } else {
          a += b;
          k = -k;
        }
        s = (s - k) / a;
        break;
      }
      case GRID_DIAGONALS.RECTILINEAR:
      case GRID_DIAGONALS.ILLEGAL: s /= (adx + ady); break;
    }
    s *= this.size;
    const x = point.x + (dx * s);
    const y = point.y + (dy * s);
    const elevation = point.elevation;
    return elevation !== undefined ? {x, y, elevation} : {x, y};
  }

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

  /** @override */
  getCircle(center, radius) {
    if ( radius <= 0 ) return [];
    switch ( this.diagonals ) {
      case GRID_DIAGONALS.EQUIDISTANT: return this.#getCircleEquidistant(center, radius);
      case GRID_DIAGONALS.EXACT: return this.#getCircleExact(center, radius);
      case GRID_DIAGONALS.APPROXIMATE: return this.#getCircleApproximate(center, radius);
      case GRID_DIAGONALS.ALTERNATING_1: return this.#getCircleAlternating(center, radius, false);
      case GRID_DIAGONALS.ALTERNATING_2: return this.#getCircleAlternating(center, radius, true);
      case GRID_DIAGONALS.RECTILINEAR:
      case GRID_DIAGONALS.ILLEGAL: return this.#getCircleRectilinear(center, radius);
    }
  }

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

  /**
   * Get the circle polygon given the radius in grid units (EQUIDISTANT).
   * @param {Point} center      The center point of the circle.
   * @param {number} radius     The radius in grid units (positive).
   * @returns {Point[]}         The points of the circle polygon.
   */
  #getCircleEquidistant({x, y}, radius) {
    const r = radius / this.distance * this.size;
    const x0 = x + r;
    const x1 = x - r;
    const y0 = y + r;
    const y1 = y - r;
    return [{x: x0, y: y0}, {x: x1, y: y0}, {x: x1, y: y1}, {x: x0, y: y1}];
  }

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

  /**
   * Get the circle polygon given the radius in grid units (EXACT).
   * @param {Point} center      The center point of the circle.
   * @param {number} radius     The radius in grid units (positive).
   * @returns {Point[]}         The points of the circle polygon.
   */
  #getCircleExact({x, y}, radius) {
    const r = radius / this.distance * this.size;
    const s = r / Math.SQRT2;
    return [
      {x: x + r, y},
      {x: x + s, y: y + s},
      {x: x, y: y + r },
      {x: x - s, y: y + s},
      {x: x - r, y},
      {x: x - s, y: y - s},
      {x: x, y: y - r},
      {x: x + s, y: y - s}
    ];
  }

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

  /**
   * Get the circle polygon given the radius in grid units (APPROXIMATE).
   * @param {Point} center      The center point of the circle.
   * @param {number} radius     The radius in grid units (positive).
   * @returns {Point[]}         The points of the circle polygon.
   */
  #getCircleApproximate({x, y}, radius) {
    const r = radius / this.distance * this.size;
    const s = r / 1.5;
    return [
      {x: x + r, y},
      {x: x + s, y: y + s},
      {x: x, y: y + r },
      {x: x - s, y: y + s},
      {x: x - r, y},
      {x: x - s, y: y - s},
      {x: x, y: y - r},
      {x: x + s, y: y - s}
    ];
  }

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

  /**
   * Get the circle polygon given the radius in grid units (ALTERNATING_1/2).
   * @param {Point} center           The center point of the circle.
   * @param {number} radius          The radius in grid units (positive).
   * @param {boolean} firstDouble    2/1/2 instead of 1/2/1?
   * @returns {Point[]}              The points of the circle polygon.
   */
  #getCircleAlternating(center, radius, firstDouble) {
    const r = radius / this.distance;
    const points = [];
    let dx = 0;
    let dy = 0;

    // Generate points of the first quarter
    if ( firstDouble ) {
      points.push({x: r - dx, y: dy});
      dx++;
      dy++;
    }
    for ( ;; ) {
      if ( r - dx <= dy ) {
        [dx, dy] = [dy - 1, dx - 1];
        break;
      }
      points.push({x: r - dx, y: dy});
      dy++;
      if ( r - dx <= dy ) {
        points.push({x: r - dx, y: r - dx});
        if ( dx !== 0 ) {
          points.push({x: dy - 1, y: r - dx});
          [dx, dy] = [dy - 2, dx - 1];
        }
        break;
      }
      points.push({x: r - dx, y: dy});
      dx++;
      dy++;
    }
    for ( ;; ) {
      if ( dx === 0 ) break;
      points.push({x: dx, y: r - dy});
      dx--;
      if ( dx === 0 ) break;
      points.push({x: dx, y: r - dy});
      dx--;
      dy--;
    }

    // Generate the points of the other three quarters by mirroring the first
    const n = points.length;
    for ( let i = 0; i < n; i++ ) {
      const p = points[i];
      points.push({x: -p.y, y: p.x});
    }
    for ( let i = 0; i < n; i++ ) {
      const p = points[i];
      points.push({x: -p.x, y: -p.y});
    }
    for ( let i = 0; i < n; i++ ) {
      const p = points[i];
      points.push({x: p.y, y: -p.x});
    }

    // Scale and center the polygon points
    for ( let i = 0; i < 4 * n; i++ ) {
      const p = points[i];
      p.x = (p.x * this.size) + center.x;
      p.y = (p.y * this.size) + center.y;
    }
    return points;
  }

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

  /**
   * Get the circle polygon given the radius in grid units (RECTILINEAR/ILLEGAL).
   * @param {Point} center      The center point of the circle.
   * @param {number} radius     The radius in grid units (positive).
   * @returns {Point[]}         The points of the circle polygon.
   */
  #getCircleRectilinear({x, y}, radius) {
    const r = radius / this.distance * this.size;
    return [{x: x + r, y}, {x, y: y + r}, {x: x - r, y}, {x, y: y - r}];
  }

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

  /** @override */
  calculateDimensions(sceneWidth, sceneHeight, padding) {
    // Note: Do not replace `* (1 / this.size)` by `/ this.size`!
    // It could change the result and therefore break certain scenes.
    const x = Math.ceil((padding * sceneWidth) * (1 / this.size)) * this.size;
    const y = Math.ceil((padding * sceneHeight) * (1 / this.size)) * this.size;
    const width = sceneWidth + (2 * x);
    const height = sceneHeight + (2 * y);
    const rows = Math.ceil((height / this.size) - 1e-6);
    const columns = Math.ceil((width / this.size) - 1e-6);
    return {width, height, x, y, rows, columns};
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getCenter(x, y) {
    const msg = "SquareGrid#getCenter is deprecated. Use SquareGrid#getCenterPoint instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.getTopLeft(x, y).map(c => c + (this.size / 2));
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getSnappedPosition(x, y, interval=1, options={}) {
    const msg = "SquareGrid#getSnappedPosition is deprecated. "
      + "Use BaseGrid#getSnappedPoint instead for non-Euclidean measurements.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    if ( interval === 0 ) return {x: Math.round(x), y: Math.round(y)};
    const [x0, y0] = this.#getNearestVertex(x, y);
    let dx = 0;
    let dy = 0;
    if ( interval !== 1 ) {
      const delta = this.size / interval;
      dx = Math.round((x - x0) / delta) * delta;
      dy = Math.round((y - y0) / delta) * delta;
    }
    return {
      x: Math.round(x0 + dx),
      y: Math.round(y0 + dy)
    };
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  #getNearestVertex(x, y) {
    return [x.toNearest(this.size), y.toNearest(this.size)];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getGridPositionFromPixels(x, y) {
    const msg = "BaseGrid#getGridPositionFromPixels is deprecated. Use BaseGrid#getOffset instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return [Math.floor(y / this.size), Math.floor(x / this.size)];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getPixelsFromGridPosition(row, col) {
    const msg = "BaseGrid#getPixelsFromGridPosition is deprecated. Use BaseGrid#getTopLeftPoint instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return [col * this.size, row * this.size];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  shiftPosition(x, y, dx, dy, options={}) {
    const msg = "BaseGrid#shiftPosition is deprecated. Use BaseGrid#getShiftedPoint instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const [row, col] = this.getGridPositionFromPixels(x, y);
    return this.getPixelsFromGridPosition(row+dy, col+dx);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  measureDistances(segments, options={}) {
    const msg = "SquareGrid#measureDistances is deprecated. "
      + "Use SquareGrid#measurePath instead, which returns grid distance (gridSpaces: true) and Euclidean distance (gridSpaces: false).";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    if ( !options.gridSpaces ) return super.measureDistances(segments, options);
    return segments.map(s => {
      const r = s.ray;
      const nx = Math.abs(Math.ceil(r.dx / this.size));
      const ny = Math.abs(Math.ceil(r.dy / this.size));

      // Determine the number of straight and diagonal moves
      const nd = Math.min(nx, ny);
      const ns = Math.abs(ny - nx);

      // Linear distance for all moves
      return (nd + ns) * this.distance;
    });
  }
}

/**
 * @import {HexagonalGridConfiguration, GridOffset2D, GridOffset3D, HexagonalGridCube2D, HexagonalGridCube3D,
 *   HexagonalGridCoordinates2D, HexagonalGridCoordinates3D} from "./_types.mjs"
 * @import {Point, ElevatedPoint} from "../_types.mjs"
 * @import {GridDiagonalRule} from "../constants.mjs"
 */

/**
 * The hexagonal grid class.
 * @extends {BaseGrid<HexagonalGridCoordinates2D, HexagonalGridCoordinates3D>}
 */
class HexagonalGrid extends BaseGrid {
  /**
   * The hexagonal grid constructor.
   * @param {HexagonalGridConfiguration} config   The grid configuration
   */
  constructor(config) {
    super(config);
    const {columns, even} = config;

    // Set the type and size of the grid
    let type;
    if ( columns ) {
      if ( even ) type = GRID_TYPES.HEXEVENQ;
      else type = GRID_TYPES.HEXODDQ;
      this.sizeX *= (2 * Math.SQRT1_3);
    } else {
      if ( even ) type = GRID_TYPES.HEXEVENR;
      else type = GRID_TYPES.HEXODDR;
      this.sizeY *= (2 * Math.SQRT1_3);
    }

    /**
     * @override
     * @readonly
     */
    this.type = type;

    /**
     * Is this grid column-based (flat-topped) or row-based (pointy-topped)?
     * @type {boolean}
     * @readonly
     */
    this.columns = !!columns;

    /**
     * Is this grid even or odd?
     * @type {boolean}
     * @readonly
     */
    this.even = !!even;

    /**
     * The rule for diagonal measurement (see {@link CONST.GRID_DIAGONALS}).
     * @type {GridDiagonalRule}
     * @readonly
     */
    this.diagonals = config.diagonals ?? GRID_DIAGONALS.EQUIDISTANT;
  }

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

  /** @override */
  getOffset(coords) {
    if ( coords.i !== undefined ) {
      const {i, j, k} = coords;
      return k !== undefined ? {i, j, k}: {i, j};
    }
    const cube = coords.q !== undefined ? coords : this.pointToCube(coords);
    return this.cubeToOffset(HexagonalGrid.cubeRound(cube));
  }

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

  /** @override */
  getOffsetRange({x, y, width, height}) {
    const x0 = x;
    const y0 = y;
    const {i: i00, j: j00} = this.getOffset({x: x0, y: y0});
    if ( !((width > 0) && (height > 0)) ) return [i00, j00, i00, j00];
    const x1 = x + width;
    const y1 = y + height;
    const {i: i01, j: j01} = this.getOffset({x: x1, y: y0});
    const {i: i10, j: j10} = this.getOffset({x: x0, y: y1});
    const {i: i11, j: j11} = this.getOffset({x: x1, y: y1});
    let i0 = Math.min(i00, i01, i10, i11);
    let j0 = Math.min(j00, j01, j10, j11);
    let i1 = Math.max(i00, i01, i10, i11) + 1;
    let j1 = Math.max(j00, j01, j10, j11) + 1;
    // While the corners of the rectangle are included in this range, the edges of the rectangle might
    // intersect rows or columns outside of the range. So we need to expand the range if necessary.
    if ( this.columns ) {
      if ( (i00 === i01) && (j00 < j01) && (!(j00 % 2) !== this.even) && (y0 < i00 * this.sizeY) ) i0--;
      if ( (i10 === i11) && (j10 < j11) && (!(j00 % 2) === this.even) && (y1 > (i10 + 0.5) * this.sizeY) ) i1++;
      if ( (j00 === j10) && (i00 < i10) && (x0 < ((j00 * 0.75) + 0.25) * this.sizeX) ) j0--;
      if ( (j01 === j11) && (i01 < i11) && (x1 > ((j01 * 0.75) + 0.75) * this.sizeX) ) j1++;
    } else {
      if ( (j00 === j10) && (i00 < i10) && (!(i00 % 2) !== this.even) && (x0 < j00 * this.sizeX) ) j0--;
      if ( (j01 === j11) && (i01 < i11) && (!(i00 % 2) === this.even) && (x1 > (j01 + 0.5) * this.sizeX) ) j1++;
      if ( (i00 === i01) && (j00 < j01) && (y0 < ((i00 * 0.75) + 0.25) * this.sizeY) ) i0--;
      if ( (i10 === i11) && (j10 < j11) && (y1 > ((i10 * 0.75) + 0.75) * this.sizeY) ) i1++;
    }
    return [i0, j0, i1, j1];
  }

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

  /** @override */
  getAdjacentOffsets(coords) {
    return this.getAdjacentCubes(coords).map(cube => this.getOffset(cube));
  }

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

  /** @override */
  testAdjacency(coords1, coords2) {
    const c1 = this.getCube(coords1);
    const c2 = this.getCube(coords2);
    const d0 = HexagonalGrid.cubeDistance(c1, c2);
    if ( c1.k === undefined ) return d0 === 1;
    if ( d0 > 1 ) return false;
    const d1 = Math.abs(c1.k - c2.k);
    if ( d1 > 1 ) return false;
    if ( this.diagonals === GRID_DIAGONALS.ILLEGAL ) return d0 + d1 === 1;
    return d0 + d1 !== 0;
  }

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

  /** @override */
  getShiftedOffset(coords, direction) {
    const offset = this.getOffset(coords);
    if ( this.columns ) {
      if ( !(direction & MOVEMENT_DIRECTIONS.LEFT) !== !(direction & MOVEMENT_DIRECTIONS.RIGHT) ) {
        const even = (offset.j % 2 === 0) === this.even;
        if ( (even && (direction & MOVEMENT_DIRECTIONS.UP)) || (!even && (direction & MOVEMENT_DIRECTIONS.DOWN)) ) {
          direction &= ~(MOVEMENT_DIRECTIONS.UP | MOVEMENT_DIRECTIONS.DOWN);
        }
      }
    } else {
      if ( !(direction & MOVEMENT_DIRECTIONS.UP) !== !(direction & MOVEMENT_DIRECTIONS.DOWN) ) {
        const even = (offset.i % 2 === 0) === this.even;
        if ( (even && (direction & MOVEMENT_DIRECTIONS.LEFT)) || (!even && (direction & MOVEMENT_DIRECTIONS.RIGHT)) ) {
          direction &= ~(MOVEMENT_DIRECTIONS.LEFT | MOVEMENT_DIRECTIONS.RIGHT);
        }
      }
    }
    let di = 0;
    let dj = 0;
    let dk = 0;
    if ( direction & MOVEMENT_DIRECTIONS.UP ) di--;
    if ( direction & MOVEMENT_DIRECTIONS.DOWN ) di++;
    if ( direction & MOVEMENT_DIRECTIONS.LEFT ) dj--;
    if ( direction & MOVEMENT_DIRECTIONS.RIGHT ) dj++;
    if ( direction & MOVEMENT_DIRECTIONS.DESCEND ) dk--;
    if ( direction & MOVEMENT_DIRECTIONS.ASCEND ) dk++;
    if ( (((Math.abs(di) | Math.abs(dj)) + Math.abs(dk)) > 1) && (this.diagonals === GRID_DIAGONALS.ILLEGAL) ) {
      // Diagonal movement is not allowed
      di = 0;
      dj = 0;
      dk = 0;
    }
    offset.i += di;
    offset.j += dj;
    if ( offset.k !== undefined ) offset.k += dk;
    return offset;
  }

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

  /** @override */
  getShiftedPoint(point, direction) {
    const center = this.getCenterPoint(point);
    const shifted = this.getCenterPoint(this.getShiftedOffset(center, direction));
    shifted.x = point.x + (shifted.x - center.x);
    shifted.y = point.y + (shifted.y - center.y);
    if ( point.elevation !== undefined ) {
      shifted.elevation = point.elevation + (shifted.elevation - center.elevation);
    }
    return shifted;
  }

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

  /**
   * Returns the cube coordinates of the grid space corresponding to the given coordinates.
   * @overload
   * @param {HexagonalGridCoordinates2D} coords    The coordinates
   * @returns {HexagonalGridCube2D}                The cube coordinates
   */
  /**
   * @overload
   * @param {HexagonalGridCoordinates3D} coords    The coordinates
   * @returns {HexagonalGridCube3D}                The cube coordinates
   */
  getCube(coords) {
    if ( coords.i !== undefined ) return this.offsetToCube(coords);
    const cube = coords.q !== undefined ? coords : this.pointToCube(coords);
    return HexagonalGrid.cubeRound(cube);
  }

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

  /**
   * Returns the cube coordinates of grid spaces adjacent to the one corresponding to the given coordinates.
   * @overload
   * @param {HexagonalGridCoordinates2D} coords   The coordinates
   * @returns {HexagonalGridCube2D[]}             The adjacent cube coordinates
   */
  /**
   * @overload
   * @param {HexagonalGridCoordinates3D} coords   The coordinates
   * @returns {HexagonalGridCube3D[]}             The adjacent cube coordinates
   */
  getAdjacentCubes(coords) {
    const {q, r, s, k} = this.getCube(coords);
    const cubes = [
      {q: q - 1, r, s: s + 1},
      {q: q - 1, r: r + 1, s},
      {q, r: r - 1, s: s + 1},
      {q, r: r + 1, s: s - 1},
      {q: q + 1, r: r - 1, s},
      {q: q + 1, r, s: s - 1}
    ];

    // 2D case
    if ( k === undefined ) return cubes;

    // 3D case
    for ( const cube of cubes ) cube.k = k;

    // Add diagonals unless illegal
    if ( this.diagonals !== GRID_DIAGONALS.ILLEGAL ) {
      for ( let i = 0; i < 6; i++ ) {
        const {q, r, s, k} = cubes[i];
        cubes.push({q, r, s, k: k - 1}, {q, r, s, k: k + 1});
      }
    }

    // Add cubes directly above and below
    cubes.push({q, r, s, k: k - 1}, {q, r, s, k: k + 1});
    return cubes;
  }

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

  /**
   * Returns the cube coordinates of the grid space corresponding to the given coordinates
   * shifted by one grid space in the given direction.
   * @overload
   * @param {HexagonalGridCoordinates2D} coords    The coordinates
   * @param {number} direction                     The direction (see {@link CONST.MOVEMENT_DIRECTIONS})
   * @returns {HexagonalGridCube2D}                The cube coordinates
   */
  /**
   * @overload
   * @param {HexagonalGridCoordinates3D} coords    The coordinates
   * @param {number} direction                     The direction (see {@link CONST.MOVEMENT_DIRECTIONS})
   * @returns {HexagonalGridCube3D}                The cube coordinates
   */
  getShiftedCube(coords, direction) {
    return this.getCube(this.getShiftedOffset(coords, direction));
  }

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

  /** @override */
  getTopLeftPoint(coords) {
    if ( coords.i !== undefined ) {
      const {i, j, k} = coords;
      let x;
      let y;
      const size = this.size;
      if ( this.columns ) {
        x = (2 * Math.SQRT1_3) * ((0.75 * j) * size);
        const even = (j + 1) % 2 === 0;
        y = (i - (this.even === even ? 0.5 : 0)) * size;
      } else {
        y = (2 * Math.SQRT1_3) * ((0.75 * i) * size);
        const even = (i + 1) % 2 === 0;
        x = (j - (this.even === even ? 0.5 : 0)) * size;
      }
      return k !== undefined ? {x, y, elevation: k * this.distance} : {x, y};
    }
    const {q, r, k} = HexagonalGrid.cubeRound(coords.q !== undefined ? coords : this.pointToCube(coords));
    let x;
    let y;
    const size = this.size;
    if ( this.columns ) {
      x = (Math.SQRT3 / 2) * (q * size);
      y = ((0.5 * (q - (this.even ? 0 : 1))) + r) * size;
    } else {
      y = (Math.SQRT3 / 2) * (r * size);
      x = ((0.5 * (r - (this.even ? 0 : 1))) + q) * size;
    }
    return k !== undefined ? {x, y, elevation: k * this.distance} : {x, y};
  }

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

  /** @override */
  getCenterPoint(coords) {
    if ( coords.i !== undefined ) {
      const {i, j, k} = coords;
      let x;
      let y;
      const size = this.size;
      if ( this.columns ) {
        x = (2 * Math.SQRT1_3) * (((0.75 * j) + 0.5) * size);
        const even = (j + 1) % 2 === 0;
        y = (i + (this.even === even ? 0 : 0.5)) * size;
      } else {
        y = (2 * Math.SQRT1_3) * (((0.75 * i) + 0.5) * size);
        const even = (i + 1) % 2 === 0;
        x = (j + (this.even === even ? 0 : 0.5)) * size;
      }
      return k !== undefined ? {x, y, elevation: (k + 0.5) * this.distance} : {x, y};
    }
    const cube = HexagonalGrid.cubeRound(coords.q !== undefined ? coords : this.pointToCube(coords));
    if ( cube.k !== undefined ) cube.k += 0.5;
    return this.cubeToPoint(cube);
  }

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

  /** @override */
  getShape() {
    const scaleX = this.sizeX / 4;
    const scaleY = this.sizeY / 4;
    if ( this.columns ) {
      const x0 = -2 * scaleX;
      const x1 = -scaleX;
      const x2 = scaleX;
      const x3 = 2 * scaleX;
      const y0 = -2 * scaleY;
      const y1 = 2 * scaleY;
      return [{x: x0, y: 0}, {x: x1, y: y0}, {x: x2, y: y0}, {x: x3, y: 0}, {x: x2, y: y1}, {x: x1, y: y1}];
    } else {
      const y0 = -2 * scaleY;
      const y1 = -scaleY;
      const y2 = scaleY;
      const y3 = 2 * scaleY;
      const x0 = -2 * scaleX;
      const x1 = 2 * scaleX;
      return [{x: 0, y: y0}, {x: x1, y: y1}, {x: x1, y: y2}, {x: 0, y: y3}, {x: x0, y: y2}, {x: x0, y: y1}];
    }
  }

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

  /** @override */
  getVertices(coords) {
    const {i, j} = this.getOffset(coords);
    const scaleX = this.sizeX / 4;
    const scaleY = this.sizeY / 4;
    if ( this.columns ) {
      const x = 3 * j;
      const x0 = x * scaleX;
      const x1 = (x + 1) * scaleX;
      const x2 = (x + 3) * scaleX;
      const x3 = (x + 4) * scaleX;
      const even = (j + 1) % 2 === 0;
      const y = (4 * i) - (this.even === even ? 2 : 0);
      const y0 = y * scaleY;
      const y1 = (y + 2) * scaleY;
      const y2 = (y + 4) * scaleY;
      return [{x: x0, y: y1}, {x: x1, y: y0}, {x: x2, y: y0}, {x: x3, y: y1}, {x: x2, y: y2}, {x: x1, y: y2}];
    } else {
      const y = 3 * i;
      const y0 = y * scaleY;
      const y1 = (y + 1) * scaleY;
      const y2 = (y + 3) * scaleY;
      const y3 = (y + 4) * scaleY;
      const even = (i + 1) % 2 === 0;
      const x = (4 * j) - (this.even === even ? 2 : 0);
      const x0 = x * scaleX;
      const x1 = (x + 2) * scaleX;
      const x2 = (x + 4) * scaleX;
      return [{x: x1, y: y0}, {x: x2, y: y1}, {x: x2, y: y2}, {x: x1, y: y3}, {x: x0, y: y2}, {x: x0, y: y1}];
    }
  }

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

  /** @override */
  getSnappedPoint(point, {mode, resolution=1}) {
    if ( mode & -65524 ) throw new Error("Invalid snapping mode");
    if ( mode === 0 ) {
      return point.elevation !== undefined ? {x: point.x, y: point.y, elevation: point.elevation}
        : {x: point.x, y: point.y};
    }

    let nearest;
    let distance;
    const keepNearest = candidate => {
      if ( !nearest ) return nearest = candidate;
      const {x, y} = point;
      distance ??= ((nearest.x - x) ** 2) + ((nearest.y - y) ** 2);
      const d = ((candidate.x - x) ** 2) + ((candidate.y - y) ** 2);
      if ( d < distance ) {
        nearest = candidate;
        distance = d;
      }
      return nearest;
    };

    // Symmetries and identities
    if ( this.columns ) {
      // Top-Left = Bottom-Left
      if ( mode & 0x50 ) mode |= 0x50; // Vertex
      if ( mode & 0x500 ) mode |= 0x500; // Corner
      // Top-Right = Bottom-Right
      if ( mode & 0xA0 ) mode |= 0xA0; // Vertex
      if ( mode & 0xA00 ) mode |= 0xA00; // Corner
      // Left Side = Right Vertex
      if ( mode & 0x4000 ) mode |= 0xA0;
      // Right Side = Left Vertex
      if ( mode & 0x8000 ) mode |= 0x50;
    } else {
      // Top-Left = Top-Right
      if ( mode & 0x30 ) mode |= 0x30; // Vertex
      if ( mode & 0x300 ) mode |= 0x300; // Corner
      // Bottom-Left = Bottom-Right
      if ( mode & 0xC0 ) mode |= 0xC0; // Vertex
      if ( mode & 0xC00 ) mode |= 0xC00; // Corner
      // Top Side = Bottom Vertex
      if ( mode & 0x1000 ) mode |= 0xC0;
      // Bottom Side = Top Vertex
      if ( mode & 0x2000 ) mode |= 0x30;
    }

    // Only top/bottom or left/right edges
    if ( !(mode & 0x2) ) {
      if ( this.columns ) {
        // Top/Left side (= edge)
        if ( mode & 0x3000 ) keepNearest(this.#snapToTopOrBottom(point, resolution));
      } else {
        // Left/Right side (= edge)
        if ( mode & 0xC000 ) keepNearest(this.#snapToLeftOrRight(point, resolution));
      }
    }

    // Any vertex (plus edge/center)
    if ( (mode & 0xF0) === 0xF0 ) {
      switch ( mode & 0x3 ) {
        case 0x0: keepNearest(this.#snapToVertex(point, resolution)); break;
        case 0x1: keepNearest(this.#snapToVertexOrCenter(point, resolution)); break;
        case 0x2: keepNearest(this.#snapToEdgeOrVertex(point, resolution)); break;
        case 0x3: keepNearest(this.#snapToEdgeOrVertexOrCenter(point, resolution)); break;
      }
    }
    // A specific vertex
    else if ( mode & 0xF0 ) {
      // Center
      if ( (mode & 0x3) === 0x1 ) {
        keepNearest(this.#snapToSpecificVertexOrCenter(point, !(mode & 0x10), resolution));
      } else {
        // Edge and/or center
        switch ( mode & 0x3 ) {
          case 0x2: keepNearest(this.#snapToEdge(point, resolution)); break;
          case 0x3: keepNearest(this.#snapToEdgeOrCenter(point, resolution)); break;
        }

        // A combination of specific vertices and corners that results in a rectangular grid
        if ( ((mode & 0xF0) ^ ((mode & 0xF00) >> 4)) === 0xF0 ) {
          return keepNearest(this.#snapToRectangularGrid(point, !(mode & 0x100), resolution));
        }

        keepNearest(this.#snapToSpecificVertex(point, !(mode & 0x10), resolution));
      }
    }
    // Edges and/or centers
    else {
      switch ( mode & 0x3 ) {
        case 0x1: keepNearest(this.#snapToCenter(point, resolution)); break;
        case 0x2: keepNearest(this.#snapToEdge(point, resolution)); break;
        case 0x3: keepNearest(this.#snapToEdgeOrCenter(point, resolution)); break;
      }
    }

    // Any corner
    if ( (mode & 0xF00) === 0xF00 ) {
      keepNearest(this.#snapToCorner(point, resolution));
    }
    // A specific corner
    else if ( mode & 0xF00 ) {
      keepNearest(this.#snapToSpecificCorner(point, !(mode & 0x100), resolution));
    }

    return point.elevation === undefined ? nearest
      : {x: nearest.x, y: nearest.y, elevation: Math.round((point.elevation / this.distance) + 1e-8) * this.distance};
  }

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

  /**
   * Snap the point to the nearest center of a hexagon.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @param {number} [dx=0]        The x-translation of the grid
   * @param {number} [dy=0]        The y-translation of the grid
   * @param {boolean} [columns]    Flat-top instead of pointy-top?
   * @param {boolean} [even]       Start at a full grid space?
   * @param {number} [size]        The size of a grid space
   * @returns {Point}              The snapped point
   */
  #snapToCenter({x, y}, resolution, dx=0, dy=0, columns=this.columns, even=this.even, size=this.size) {

    // Subdivide the hex grid
    const grid = HexagonalGrid.#TEMP_GRID;
    grid.columns = columns;
    grid.size = size / resolution;
    if ( columns ) {
      grid.type = GRID_TYPES.HEXODDQ;
      grid.sizeX = grid.size * (2 * Math.SQRT1_3);
      grid.sizeY = grid.size;
    } else {
      grid.type = GRID_TYPES.HEXODDR;
      grid.sizeX = grid.size;
      grid.sizeY = grid.size * (2 * Math.SQRT1_3);
    }

    // Align the subdivided grid with this hex grid
    if ( columns ) {
      dx += ((size - grid.size) * Math.SQRT1_3);
      if ( even ) dy += (size / 2);
    } else {
      if ( even ) dx += (size / 2);
      dy += ((size - grid.size) * Math.SQRT1_3);
    }

    // Get the snapped center point for the subdivision
    const point = HexagonalGrid.#TEMP_POINT;
    point.x = x - dx;
    point.y = y - dy;
    const snapped = grid.getCenterPoint(point);
    snapped.x += dx;
    snapped.y += dy;
    return snapped;
  }

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

  /**
   * Snap the point to the nearest vertex of a hexagon.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @param {number} [dx=0]        The x-offset of the grid
   * @param {number} [dy=0]        The y-offset of the grid
   * @returns {Point}              The snapped point
   */
  #snapToVertex(point, resolution, dx, dy) {
    const center = this.#snapToCenter(point, resolution, dx, dy);
    const {x: x0, y: y0} = center;
    let angle = Math.atan2(point.y - y0, point.x - x0);
    if ( this.columns ) angle = Math.round(angle / (Math.PI / 3)) * (Math.PI / 3);
    else angle = (Math.floor(angle / (Math.PI / 3)) + 0.5) * (Math.PI / 3);
    const radius = Math.max(this.sizeX, this.sizeY) / (2 * resolution);
    const vertex = center; // Reuse the object
    vertex.x = x0 + (Math.cos(angle) * radius);
    vertex.y = y0 + (Math.sin(angle) * radius);
    return vertex;
  }

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

  /**
   * Snap the point to the nearest vertex or center of a hexagon.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToVertexOrCenter(point, resolution) {
    let size;
    let dx = 0;
    let dy = 0;
    if ( this.columns ) {
      size = this.sizeX / 2;
      dy = size * (Math.SQRT1_3 / 2);
    } else {
      size = this.sizeY / 2;
      dx = size * (Math.SQRT1_3 / 2);
    }
    return this.#snapToCenter(point, resolution, dx, dy, !this.columns, !this.even, size);
  }

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

  /**
   * Snap the point to the nearest edge of a hexagon.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToEdge(point, resolution) {
    const center = this.#snapToCenter(point, resolution);
    const {x: x0, y: y0} = center;
    let angle = Math.atan2(point.y - y0, point.x - x0);
    if ( this.columns ) angle = (Math.floor(angle / (Math.PI / 3)) + 0.5) * (Math.PI / 3);
    else angle = Math.round(angle / (Math.PI / 3)) * (Math.PI / 3);
    const radius = Math.min(this.sizeX, this.sizeY) / (2 * resolution);
    const vertex = center; // Reuse the object
    vertex.x = x0 + (Math.cos(angle) * radius);
    vertex.y = y0 + (Math.sin(angle) * radius);
    return vertex;
  }

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

  /**
   * Snap the point to the nearest edge or center of a hexagon.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToEdgeOrCenter(point, resolution) {
    let size;
    let dx = 0;
    let dy = 0;
    if ( this.columns ) {
      size = this.sizeY / 2;
      dx = size * Math.SQRT1_3;
    } else {
      size = this.sizeX / 2;
      dy = size * Math.SQRT1_3;
    }
    return this.#snapToCenter(point, resolution, dx, dy, this.columns, false, size);
  }

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

  /**
   * Snap the point to the nearest edge or vertex of a hexagon.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToEdgeOrVertex(point, resolution) {
    const {x, y} = point;
    point = this.#snapToCenter(point, resolution);
    const {x: x0, y: y0} = point;
    const dx = x - x0;
    const dy = y - y0;
    let angle = Math.atan2(dy, dx);
    if ( this.columns ) angle = (Math.floor(angle / (Math.PI / 3)) + 0.5) * (Math.PI / 3);
    else angle = Math.round(angle / (Math.PI / 3)) * (Math.PI / 3);
    const s = 2 * resolution;
    let radius1 = this.sizeX / s;
    let radius2 = this.sizeY / s;
    if ( radius1 > radius2 ) [radius1, radius2] = [radius2, radius1];
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    const d = (cos * dy) - (sin * dx);
    if ( Math.abs(d) <= radius2 / 4 ) {
      point.x = x0 + (cos * radius1);
      point.y = y0 + (sin * radius1);
    } else {
      angle += ((Math.PI / 6) * Math.sign(d));
      point.x = x0 + (Math.cos(angle) * radius2);
      point.y = y0 + (Math.sin(angle) * radius2);
    }
    return point;
  }

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

  /**
   * Snap the point to the nearest edge, vertex, center of a hexagon.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToEdgeOrVertexOrCenter(point, resolution) {
    const {x, y} = point;
    point = this.#snapToCenter(point, resolution);
    const {x: x0, y: y0} = point;
    const dx = x - x0;
    const dy = y - y0;
    let angle = Math.atan2(dy, dx);
    if ( this.columns ) angle = (Math.floor(angle / (Math.PI / 3)) + 0.5) * (Math.PI / 3);
    else angle = Math.round(angle / (Math.PI / 3)) * (Math.PI / 3);
    const s = 2 * resolution;
    let radius1 = this.sizeX / s;
    let radius2 = this.sizeY / s;
    if ( radius1 > radius2 ) [radius1, radius2] = [radius2, radius1];
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    const d1 = (cos * dx) + (sin * dy);
    if ( d1 <= radius1 / 2 ) return point;
    const d2 = (cos * dy) - (sin * dx);
    if ( Math.abs(d2) <= radius2 / 4 ) {
      point.x = x0 + (cos * radius1);
      point.y = y0 + (sin * radius1);
    } else {
      angle += ((Math.PI / 6) * Math.sign(d2));
      point.x = x0 + (Math.cos(angle) * radius2);
      point.y = y0 + (Math.sin(angle) * radius2);
    }
    return point;
  }

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

  /**
   * Snap the point to the nearest corner of a hexagon.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToCorner(point, resolution) {
    let dx = 0;
    let dy = 0;
    const s = 2 * resolution;
    if ( this.columns ) dy = this.sizeY / s;
    else dx = this.sizeX / s;
    return this.#snapToVertex(point, resolution, dx, dy);
  }

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

  /**
   * Snap the point to the nearest top/bottom-left/right vertex of a hexagon.
   * @param {Point} point          The point
   * @param {boolean} other        Bottom-right instead of top-left vertex?
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToSpecificVertex(point, other, resolution) {
    let dx = 0;
    let dy = 0;
    const s = (other ? -2 : 2) * resolution;
    if ( this.columns ) dx = this.sizeX / s;
    else dy = this.sizeY / s;
    return this.#snapToCenter(point, resolution, dx, dy);
  }

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

  /**
   * Snap the point to the nearest top/bottom-left/right vertex or center of a hexagon.
   * @param {Point} point          The point
   * @param {boolean} other        Bottom-right instead of top-left vertex?
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToSpecificVertexOrCenter(point, other, resolution) {
    let dx = 0;
    let dy = 0;
    const s = (other ? 2 : -2) * resolution;
    if ( this.columns ) dx = this.sizeX / s;
    else dy = this.sizeY / s;
    return this.#snapToVertex(point, resolution, dx, dy);
  }

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

  /**
   * Snap the point to the nearest top/bottom-left/right corner of a hexagon.
   * @param {Point} point          The point
   * @param {boolean} other        Bottom-right instead of top-left corner?
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToSpecificCorner(point, other, resolution) {
    let dx = 0;
    let dy = 0;
    const s = (other ? -4 : 4) * resolution;
    if ( this.columns ) dx = this.sizeX / s;
    else dy = this.sizeY / s;
    return this.#snapToCenter(point, resolution, dx, dy);
  }

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

  /**
   * Snap the point to the nearest grid intersection of the rectanglar grid.
   * @param {Point} point          The point
   * @param {boolean} other        Align rectangles with top-left vertices instead of top-left corners?
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToRectangularGrid(point, other, resolution) {
    const tx = this.sizeX / 2;
    const ty = this.sizeY / 2;
    let sx = tx;
    let sy = ty;
    let dx = 0;
    let dy = 0;
    const d = other ? 1 / 3 : 2 / 3;
    if ( this.columns ) {
      sx *= 1.5;
      dx = d;
    } else {
      sy *= 1.5;
      dy = d;
    }
    sx /= resolution;
    sy /= resolution;
    return {
      x: ((Math.round(((point.x - tx) / sx) + dx) - dx) * sx) + tx,
      y: ((Math.round(((point.y - ty) / sy) + dy) - dy) * sy) + ty
    };
  }

  /**
   * Snap the point to the nearest top/bottom side of the bounds of a hexagon.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToTopOrBottom(point, resolution) {
    return this.#snapToCenter(point, resolution, 0, this.sizeY / (2 * resolution));
  }

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

  /**
   * Snap the point to the nearest left/right side of the bounds of a hexagon.
   * @param {Point} point          The point
   * @param {number} resolution    The grid resolution
   * @returns {Point}              The snapped point
   */
  #snapToLeftOrRight(point, resolution) {
    return this.#snapToCenter(point, resolution, this.sizeX / (2 * resolution), 0);
  }

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

  /** @inheritdoc */
  calculateDimensions(sceneWidth, sceneHeight, padding) {
    const {columns, size} = this;
    const sizeX = columns ? (2 * size) / Math.SQRT3 : size;
    const sizeY = columns ? size : (2 * size) / Math.SQRT3;
    const strideX = columns ? 0.75 * sizeX : sizeX;
    const strideY = columns ? sizeY : 0.75 * sizeY;

    // Skip padding computation for Scenes which do not include padding
    if ( !padding ) {
      const cols = Math.ceil(((sceneWidth + (columns ? -sizeX / 4 : sizeX / 2)) / strideX) - 1e-6);
      const rows = Math.ceil(((sceneHeight + (columns ? sizeY / 2 : -sizeY / 4)) / strideY) - 1e-6);
      return {width: sceneWidth, height: sceneHeight, x: 0, y: 0, rows, columns: cols};
    }

    // The grid size is equal to the short diagonal of the hexagon, so padding in that axis will divide evenly by the
    // grid size. In the cross-axis, however, the hexagons do not stack but instead interleave. Multiplying the long
    // diagonal by 75% gives us the amount of space each hexagon takes up in that axis without overlapping.
    // Note: Do not replace `* (1 / strideX)` by `/ strideX` and `* (1 / strideY)` by `/ strideY`!
    // It could change the result and therefore break certain scenes.
    let x = Math.ceil((padding * sceneWidth) * (1 / strideX)) * strideX;
    let y = Math.ceil((padding * sceneHeight) * (1 / strideY)) * strideY;
    // Note: The width and height calculation needs rounded x/y. If we were to remove the rounding here,
    // the result of the rounding of the width and height below would change in certain scenes.
    let width = sceneWidth + (2 * Math.round(Math.ceil((padding * sceneWidth) * (1 / strideX)) / (1 / strideX)));
    let height = sceneHeight + (2 * Math.round(Math.ceil((padding * sceneHeight) * (1 / strideY)) / (1 / strideY)));

    // Ensure that the top-left hexagon of the scene rectangle is always a full hexagon for even grids and always a
    // half hexagon for odd grids, by shifting the padding in the main axis by half a hex if the number of hexagons in
    // the cross-axis is odd.
    const crossEven = Math.round(columns ? x / strideX : y / strideY) % 2 === 0;
    if ( !crossEven ) {
      if ( columns ) {
        y += (sizeY / 2);
        height += sizeY;
      } else {
        x += (sizeX / 2);
        width += sizeX;
      }
    }

    // The height (if column orientation) or width (if row orientation) must be a multiple of the grid size, and
    // the last column (if column orientation) or row (if row orientation) must be fully within the bounds.
    // Note: Do not replace `* (1 / strideX)` by `/ strideX` and `* (1 / strideY)` by `/ strideY`!
    // It could change the result and therefore break certain scenes.
    let cols = Math.round(width * (1 / strideX));
    let rows = Math.round(height * (1 / strideY));
    width = cols * strideX;
    height = rows * strideY;
    if ( columns ) {
      rows++;
      width += (sizeX / 4);
    } else {
      cols++;
      height += (sizeY / 4);
    }
    return {width, height, x, y, rows, columns: cols};
  }

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

  /**
   * Calculate the total size of the canvas with padding applied, as well as the top-left coordinates of the inner
   * rectangle that houses the scene. (Legacy)
   * @param {number} columns            Column or row orientation?
   * @param {number} legacySize         The legacy size of the grid.
   * @param {number} sceneWidth         The width of the scene.
   * @param {number} sceneHeight        The height of the scene.
   * @param {number} padding            The percentage of padding.
   * @returns {{width: number, height: number, x: number, y: number, rows: number, columns: number}}
   * @internal
   * @ignore
   */
  static _calculatePreV10Dimensions(columns, legacySize, sceneWidth, sceneHeight, padding) {
    // Note: Do not replace `* (1 / legacySize)` by `/ legacySize`!
    // It could change the result and therefore break certain scenes.
    const x = Math.ceil((padding * sceneWidth) * (1 / legacySize)) * legacySize;
    const y = Math.ceil((padding * sceneHeight) * (1 / legacySize)) * legacySize;
    const width = sceneWidth + (2 * x);
    const height = sceneHeight + (2 * y);
    const size = legacySize * (Math.SQRT3 / 2);
    const sizeX = columns ? legacySize : size;
    const sizeY = columns ? size : legacySize;
    const strideX = columns ? 0.75 * sizeX : sizeX;
    const strideY = columns ? sizeY : 0.75 * sizeY;
    const cols = Math.floor(((width + (columns ? sizeX / 4 : sizeX)) / strideX) + 1e-6);
    const rows = Math.floor(((height + (columns ? sizeY : sizeY / 4)) / strideY) + 1e-6);
    return {width, height, x, y, rows, columns: cols};
  }

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

  /** @override */
  _measurePath(waypoints, {cost}, result) {

    // Convert to (fractional) cube coordinates
    const toCube = coords => {
      if ( coords.x !== undefined ) return this.pointToCube(coords);
      if ( coords.i !== undefined ) return this.offsetToCube(coords);
      return coords;
    };

    // Prepare data for the starting point
    const w0 = waypoints[0];
    let o0 = this.getOffset(w0);
    let c0 = this.offsetToCube(o0);
    let d0 = toCube(w0);
    let p0 = w0.x !== undefined ? w0 : this.cubeToPoint(d0);

    // Iterate over additional path points
    const is3D = o0.k !== undefined;
    const diagonals = this.diagonals;
    let nd = diagonals === GRID_DIAGONALS.ALTERNATING_2 ? 1 : 0;
    let ld = nd;
    for ( let i = 1; i < waypoints.length; i++ ) {
      const w1 = waypoints[i];
      const o1 = this.getOffset(w1);
      const c1 = this.offsetToCube(o1);
      const d1 = toCube(w1);
      const p1 = w1.x !== undefined ? w1 : this.cubeToPoint(d1);
      const cost1 = w1.cost ?? cost;

      // Determine the number of moves total, number of diagonal moves, and cost of the moves
      if ( w1.measure !== false ) {
        let n = HexagonalGrid.cubeDistance(c0, c1);
        let d = 0;
        if ( is3D ) {
          d = Math.abs(c0.k - c1.k);
          if ( n < d ) [n, d] = [d, n];
        }
        let c;
        const nd0 = nd;
        switch ( diagonals ) {
          case GRID_DIAGONALS.EQUIDISTANT: c = n; break;
          case GRID_DIAGONALS.EXACT: c = n + ((Math.SQRT2 - 1) * d); break;
          case GRID_DIAGONALS.APPROXIMATE: c = n + (0.5 * d); break;
          case GRID_DIAGONALS.RECTILINEAR: c = n + d; break;
          case GRID_DIAGONALS.ALTERNATING_1:
          case GRID_DIAGONALS.ALTERNATING_2:
            nd += d;
            c = n + (Math.floor(nd / 2) - Math.floor(nd0 / 2));
            break;
          case GRID_DIAGONALS.ILLEGAL:
            n = n + d;
            d = 0;
            c = n;
            break;
        }

        // Determine the distance of the segment
        let a = HexagonalGrid.cubeDistance(d0, d1);
        let b = 0;
        if ( is3D ) {
          b = Math.abs(d0.k - d1.k);
          if ( a < b ) [a, b] = [b, a];
        }
        let l;
        switch ( diagonals ) {
          case GRID_DIAGONALS.EQUIDISTANT: l = a; break;
          case GRID_DIAGONALS.EXACT: l = a + ((Math.SQRT2 - 1) * b); break;
          case GRID_DIAGONALS.APPROXIMATE: l = a + (0.5 * b); break;
          case GRID_DIAGONALS.ILLEGAL: l = a + b; break;
          case GRID_DIAGONALS.ALTERNATING_1:
          case GRID_DIAGONALS.ALTERNATING_2: {
            const ld0 = ld;
            ld += b;
            l = a + ((Math.abs(((ld - 1) / 2) - Math.floor(ld / 2)) + ((ld - 1) / 2))
              - (Math.abs(((ld0 - 1) / 2) - Math.floor(ld0 / 2)) + ((ld0 - 1) / 2)));
            break;
          }
          case GRID_DIAGONALS.RECTILINEAR: l = a + b; break;
        }
        if ( l.almostEqual(c) ) l = c;

        const segment = result.segments[i - 1];
        segment.distance = l * this.distance;
        if ( (cost1 === undefined) || (c === 0) ) segment.cost = w1.teleport ? 0 : c * this.distance;
        else if ( typeof cost1 === "function" ) segment.cost = w1.teleport ? cost1(o0, o1, c * this.distance, w1)
          : this.#calculateCost(o0, o1, cost1, nd0, w1);
        else segment.cost = Number(cost1);
        segment.spaces = n;
        segment.diagonals = d;
        segment.euclidean = Math.hypot(p0.x - p1.x, p0.y - p1.y, is3D ? (p0.elevation - p1.elevation) / this.distance
          * this.size : 0) / this.size * this.distance;
      }

      o0 = o1;
      c0 = c1;
      d0 = d1;
      p0 = p1;
    }
  }

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

  /**
   * Calculate the cost of the direct path segment.
   * @template SegmentData
   * @overload
   * @param {GridOffset2D} from      The coordinates the segment starts from
   * @param {GridOffset2D} to        The coordinates the segment goes to
   * @param {GridMeasurePathCostFunction2D<SegmentData>} cost    The cost function
   * @param {number} diagonals       The number of diagonal moves that have been performed already
   * @param {SegmentData} segment    The segment data
   * @returns {number}               The cost of the path segment
   */
  /**
   * @overload
   * @param {GridOffset3D} from      The coordinates the segment starts from
   * @param {GridOffset3D} to        The coordinates the segment goes to
   * @param {GridMeasurePathCostFunction3D<SegmentData>} cost    The cost function
   * @param {number} diagonals       The number of diagonal moves that have been performed already
   * @param {SegmentData} segment    The segment data
   * @returns {number}               The cost of the path segment
   */
  #calculateCost(from, to, cost, diagonals, segment) {
    const path = this.getDirectPath([from, to]);
    if ( path.length <= 1 ) return 0;

    // Prepare data for the starting point
    let o0 = path[0];
    let c = 0;

    // Iterate over additional path points
    for ( let i = 1; i < path.length; i++ ) {
      const o1 = path[i];

      // Determine the normalized distance
      let d;
      if ( (o0.k === o1.k) || ((o0.i === o1.i) && (o0.j === o1.j)) ) d = 1;
      else {
        switch ( this.diagonals ) {
          case GRID_DIAGONALS.EQUIDISTANT: d = 1; break;
          case GRID_DIAGONALS.EXACT: d = Math.SQRT2; break;
          case GRID_DIAGONALS.APPROXIMATE: d = 1.5; break;
          case GRID_DIAGONALS.RECTILINEAR: d = 2; break;
          case GRID_DIAGONALS.ALTERNATING_1:
          case GRID_DIAGONALS.ALTERNATING_2:
            d = 1 + (Math.floor((diagonals + 1) / 2) - Math.floor(diagonals / 2));
            break;
        }
        diagonals++;
      }

      // Calculate and accumulate the cost
      c += cost(o0, o1, d * this.distance, segment);

      o0 = o1;
    }

    return c;
  }

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

  /**
   * @see {@link https://www.redblobgames.com/grids/hexagons/#line-drawing}
   * @override
   */
  getDirectPath(waypoints) {
    if ( waypoints.length === 0 ) return [];

    // Prepare data for the starting point
    let c0 = this.getCube(waypoints[0]);
    let {q: q0, r: r0, k: k0} = c0;
    const is3D = k0 !== undefined;
    const path = [this.getOffset(c0)];

    // Iterate over additional path points
    const diagonals = this.diagonals !== GRID_DIAGONALS.ILLEGAL;
    for ( let i = 1; i < waypoints.length; i++ ) {
      const c1 = this.getCube(waypoints[i]);
      const {q: q1, r: r1, s: s0, k: k1} = c1;
      if ( (q0 === q1) && (r0 === r1) && (k0 === k1) ) continue;

      // Walk from (q0, r0, s0, k0) to (q1, r1, s1, k1)
      const dq = q0 - q1;
      const dr = r0 - r1;
      // If the path segment is collinear with some hexagon edge, we need to nudge
      // the cube coordinates in the right direction so that we get a consistent, clean path.
      const EPS = 1e-6;
      let eq = 0;
      let er = 0;
      if ( this.columns ) {
        // Collinear with SE-NW edges
        if ( dq === dr ) {
          // Prefer movement such that we have rotational symmetry with the E-W case at (0, 0, 0)
          er = !((q0 + r0) & 1) === this.even ? EPS : -1e-6;
          eq = -er;
        }
        // Collinear with SW-NE edges
        else if ( -2 * dq === dr ) {
          // Prefer movement such that we have rotational symmetry with the E-W case at (0, 0, 0)
          eq = !(r0 & 1) === this.even ? EPS : -1e-6;
        }
        // Collinear with E-W edges
        else if ( dq === -2 * dr ) {
          // Move such we don't leave the row that we're in
          er = !(q0 & 1) === this.even ? -1e-6 : EPS;
        }
      } else {
        // Collinear with SE-NW edges
        if ( dq === dr ) {
          // Prefer movement such that we have rotational symmetry with the S-N case at (0, 0, 0)
          eq = !((q0 + r0) & 1) === this.even ? EPS : -1e-6;
          er = -eq;
        }
        // Collinear with SW-NE edges
        else if ( dq === -2 * dr ) {
          // Prefer movement such that we have rotational symmetry with the S-N case at (0, 0, 0)
          er = !(q0 & 1) === this.even ? EPS : -1e-6;
        }
        // Collinear with S-N edges
        else if ( -2 * dq === dr ) {
          // Move such we don't leave the column that we're in
          eq = !(r0 & 1) === this.even ? -1e-6 : EPS;
        }
      }
      const n = HexagonalGrid.cubeDistance(c0, c1);
      if ( is3D ) {
        if ( n !== 0 ) {
          let q = q0;
          let r = r0;
          let s = s0;
          let k = k0;
          let j = 0;
          const sk = k0 < k1 ? 1 : -1;
          if ( diagonals ) {
            const dk = 0 - Math.abs(k0 - k1);
            let e = n + dk;
            for ( ;; ) {
              const e2 = e * 2;
              if ( e2 >= dk ) {
                e += dk;
                j++;

                // Break tries on E-W (if columns) / S-N (if rows) edges
                const t = (j + EPS) / n;
                q = Math.mix(q0, q1, t) + eq;
                r = Math.mix(r0, r1, t) + er;
                s = 0 - q - r;
              }
              if ( e2 <= n ) {
                e += n;
                k += sk;
              }
              if ( (j === n) && (k === k1) ) break;
              path.push(this.getOffset({q, r, s, k}));
            }
          } else {
            const dk1 = Math.abs(k0 - k1) || 1;
            let tc = dk1;
            let tk = n;
            for ( ;; ) {
              if ( tc <= tk ) {
                tc += dk1;
                j++;

                // Break tries on E-W (if columns) / S-N (if rows) edges
                const t = (j + EPS) / n;
                q = Math.mix(q0, q1, t) + eq;
                r = Math.mix(r0, r1, t) + er;
                s = 0 - q - r;
              } else {
                tk += n;
                k += sk;
              }
              if ( (j === n) && (k === k1) ) break;
              path.push(this.getOffset({q, r, s, k}));
            }
          }
          path.push(this.getOffset(c1));
        } else {
          const {i, j} = path.at(-1);
          let k = k0;
          const sk = k0 < k1 ? 1 : -1;
          while ( k !== k1 ) {
            k += sk;
            path.push({i, j, k});
          }
        }
      } else {
        for ( let j = 1; j < n; j++ ) {
          // Break tries on E-W (if columns) / S-N (if rows) edges
          const t = (j + EPS) / n;
          const q = Math.mix(q0, q1, t) + eq;
          const r = Math.mix(r0, r1, t) + er;
          const s = 0 - q - r;
          path.push(this.getOffset({q, r, s}));
        }
        path.push(this.getOffset(c1));
      }

      c0 = c1;
      q0 = q1;
      r0 = r1;
      k0 = k1;
    }

    return path;
  }

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

  /** @override */
  getTranslatedPoint(point, direction, distance) {
    direction = Math.toRadians(direction);
    const dx = Math.cos(direction);
    const dy = Math.sin(direction);
    let q;
    let r;
    if ( this.columns ) {
      q = (2 * Math.SQRT1_3) * dx;
      r = (-0.5 * q) + dy;
    } else {
      r = (2 * Math.SQRT1_3) * dy;
      q = (-0.5 * r) + dx;
    }
    const s = distance / this.distance * this.size / ((Math.abs(r) + Math.abs(q) + Math.abs(q + r)) / 2);
    const x = point.x + (dx * s);
    const y = point.y + (dy * s);
    const elevation = point.elevation;
    return elevation !== undefined ? {x, y, elevation} : {x, y};
  }

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

  /** @override */
  getCircle({x, y}, radius) {
    if ( radius <= 0 ) return [];
    const r = radius / this.distance * this.size;
    if ( this.columns ) {
      const x0 = r * (Math.SQRT3 / 2);
      const x1 = -x0;
      const y0 = r;
      const y1 = y0 / 2;
      const y2 = -y1;
      const y3 = -y0;
      return [{x: x, y: y + y0}, {x: x + x1, y: y + y1}, {x: x + x1, y: y + y2},
        {x: x, y: y + y3}, {x: x + x0, y: y + y2}, {x: x + x0, y: y + y1}];
    } else {
      const y0 = r * (Math.SQRT3 / 2);
      const y1 = -y0;
      const x0 = r;
      const x1 = x0 / 2;
      const x2 = -x1;
      const x3 = -x0;
      return [{x: x + x0, y: y}, {x: x + x1, y: y + y0}, {x: x + x2, y: y + y0},
        {x: x + x3, y: y}, {x: x + x2, y: y + y1}, {x: x + x1, y: y + y1}];
    }
  }

  /* -------------------------------------------- */
  /*  Conversion Functions                        */
  /* -------------------------------------------- */

  /**
   * Round the fractional cube coordinates (q, r, s) / (q, r, s, k).
   * The k-coordinate is floored.
   * @see {@link https://www.redblobgames.com/grids/hexagons/}
   * @overload
   * @param {HexagonalGridCube2D} cube    The fractional cube coordinates
   * @returns {HexagonalGridCube2D}       The rounded integer cube coordinates
   */
  /**
   * @overload
   * @param {HexagonalGridCube3D} cube    The fractional cube coordinates
   * @returns {HexagonalGridCube3D}       The rounded integer cube coordinates
   */
  static cubeRound({q, r, s, k}) {
    let iq = Math.round(q);
    let ir = Math.round(r);
    let is = Math.round(s);
    const dq = Math.abs(iq - q);
    const dr = Math.abs(ir - r);
    const ds = Math.abs(is - s);

    if ( (dq > dr) && (dq > ds) ) {
      iq = -ir - is;
    } else if ( dr > ds ) {
      ir = -iq - is;
    } else {
      is = -iq - ir;
    }

    q = iq | 0;
    r = ir | 0;
    s = is | 0;
    return k !== undefined ? {q, r, s, k: Math.floor(k + 1e-8) | 0} : {q, r, s};
  }

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

  /**
   * Convert point coordinates (x, y) / (x, y, elevation) into cube coordinates (q, r, s) / (q, r, s, k).
   * Inverse of {@link HexagonalGrid#cubeToPoint}.
   * @see {@link https://www.redblobgames.com/grids/hexagons/}
   * @overload
   * @param {Point} point              The point
   * @returns {HexagonalGridCube2D}    The (fractional) cube coordinates
   */
  /**
   * @overload
   * @param {ElevatedPoint} point      The point
   * @returns {HexagonalGridCube3D}    The (fractional) cube coordinates
   */
  pointToCube({x, y, elevation}) {
    let q;
    let r;

    const size = this.size;
    x /= size;
    y /= size;

    if ( this.columns ) {
      q = ((2 * Math.SQRT1_3) * x) - (2 / 3);
      r = (-0.5 * (q + (this.even ? 1 : 0))) + y;
    } else {
      r = ((2 * Math.SQRT1_3) * y) - (2 / 3);
      q = (-0.5 * (r + (this.even ? 1 : 0))) + x;
    }

    const s = 0 - q - r;
    return elevation !== undefined ? {q, r, s, k: elevation / this.distance} : {q, r, s};
  }

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

  /**
   * Convert cube coordinates (q, r, s) / (q, r, s, k) into point coordinates (x, y) / (x, y, elevation).
   * Inverse of {@link HexagonalGrid#pointToCube}.
   * @see {@link https://www.redblobgames.com/grids/hexagons/}
   * @overload
   * @param {HexagonalGridCube2D} cube    The cube coordinates
   * @returns {Point}                     The point coordinates
   */
  /**
   * @overload
   * @param {HexagonalGridCube3D} cube    The cube coordinates
   * @returns {ElevatedPoint}             The point coordinates
   */
  cubeToPoint({q, r, k}) {
    let x;
    let y;

    const size = this.size;
    if ( this.columns ) {
      x = (0.5 * Math.SQRT1_3) * (((3 * q) + 2) * size);
      y = ((0.5 * (q + (this.even ? 1 : 0))) + r) * size;
    } else {
      y = (0.5 * Math.SQRT1_3) * (((3 * r) + 2) * size);
      x = ((0.5 * (r + (this.even ? 1 : 0))) + q) * size;
    }

    return k !== undefined ? {x, y, elevation: k * this.distance} : {x, y};
  }

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

  /**
   * Convert offset coordinates (i, j) / (i, j, k) into integer cube coordinates (q, r, s) / (q, r, s, k).
   * Inverse of {@link HexagonalGrid#cubeToOffset}.
   * @see {@link https://www.redblobgames.com/grids/hexagons/}
   * @overload
   * @param {GridOffset2D} offset      The offset coordinates
   * @returns {HexagonalGridCube2D}    The integer cube coordinates
   */
  /**
   * @overload
   * @param {GridOffset3D} offset      The offset coordinates
   * @returns {HexagonalGridCube3D}    The integer cube coordinates
   */
  offsetToCube({i, j, k}) {
    let q;
    let r;
    if ( this.columns ) {
      q = j;
      r = i - ((j + ((this.even ? 1 : -1) * (j & 1))) >> 1);
    } else {
      q = j - ((i + ((this.even ? 1 : -1) * (i & 1))) >> 1);
      r = i;
    }
    const s = 0 - q - r;
    return k !== undefined ? {q, r, s, k} : {q, r, s};
  }

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

  /**
   * Convert integer cube coordinates (q, r, s) / (q, r, s, k) into offset coordinates (i, j) / (i, j, k).
   * Inverse of {@link HexagonalGrid#offsetToCube}.
   * @see {@link https://www.redblobgames.com/grids/hexagons/}
   * @overload
   * @param {HexagonalGridCube2D} cube    The cube coordinates
   * @returns {GridOffset2D}              The offset coordinates
   */
  /**
   * @overload
   * @param {HexagonalGridCube3D} cube    The cube coordinates
   * @returns {GridOffset3D}              The offset coordinates
   */
  cubeToOffset({q, r, k}) {
    let i;
    let j;
    if ( this.columns ) {
      j = q;
      i = r + ((q + ((this.even ? 1 : -1) * (q & 1))) >> 1);
    } else {
      i = r;
      j = q + ((r + ((this.even ? 1 : -1) * (r & 1))) >> 1);
    }
    return k !== undefined ? {i, j, k} : {i, j};
  }

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

  /**
   * Measure the distance in hexagons between two cube coordinates.
   * @see {@link https://www.redblobgames.com/grids/hexagons/}
   * @param {HexagonalGridCube2D} a    The first cube coordinates
   * @param {HexagonalGridCube2D} b    The second cube coordinates
   * @returns {number}                 The distance between the two cube coordinates in hexagons
   */
  static cubeDistance(a, b) {
    const dq = a.q - b.q;
    const dr = a.r - b.r;
    return (Math.abs(dq) + Math.abs(dr) + Math.abs(dq + dr)) / 2;
  }

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

  /**
   * Used by {@link HexagonalGrid#snapToCenter}.
   * @type {Point}
   */
  static #TEMP_POINT = {x: 0, y: 0};

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

  /**
   * Used by {@link HexagonalGrid#snapToCenter}.
   * Always an odd grid!
   * @type {HexagonalGrid}
   */
  static #TEMP_GRID = new HexagonalGrid({size: 1});

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static get POINTY_HEX_BORDERS() {
    const msg = "HexagonalGrid.POINTY_HEX_BORDERS is deprecated without replacement.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.#POINTY_HEX_BORDERS;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  static #POINTY_HEX_BORDERS = {
    0.5: [[0, 0.25], [0.5, 0], [1, 0.25], [1, 0.75], [0.5, 1], [0, 0.75]],
    1: [[0, 0.25], [0.5, 0], [1, 0.25], [1, 0.75], [0.5, 1], [0, 0.75]],
    2: [
      [.5, 0], [.75, 1/7], [.75, 3/7], [1, 4/7], [1, 6/7], [.75, 1], [.5, 6/7], [.25, 1], [0, 6/7], [0, 4/7],
      [.25, 3/7], [.25, 1/7]
    ],
    3: [
      [.5, .1], [2/3, 0], [5/6, .1], [5/6, .3], [1, .4], [1, .6], [5/6, .7], [5/6, .9], [2/3, 1], [.5, .9], [1/3, 1],
      [1/6, .9], [1/6, .7], [0, .6], [0, .4], [1/6, .3], [1/6, .1], [1/3, 0]
    ],
    4: [
      [.5, 0], [5/8, 1/13], [.75, 0], [7/8, 1/13], [7/8, 3/13], [1, 4/13], [1, 6/13], [7/8, 7/13], [7/8, 9/13],
      [.75, 10/13], [.75, 12/13], [5/8, 1], [.5, 12/13], [3/8, 1], [.25, 12/13], [.25, 10/13], [1/8, 9/13],
      [1/8, 7/13], [0, 6/13], [0, 4/13], [1/8, 3/13], [1/8, 1/13], [.25, 0], [3/8, 1/13]
    ]
  };

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static get FLAT_HEX_BORDERS() {
    const msg = "HexagonalGrid.FLAT_HEX_BORDERS is deprecated without replacement.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.#FLAT_HEX_BORDERS;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  static #FLAT_HEX_BORDERS = {
    0.5: [[0, 0.5], [0.25, 0], [0.75, 0], [1, 0.5], [0.75, 1], [0.25, 1]],
    1: [[0, 0.5], [0.25, 0], [0.75, 0], [1, 0.5], [0.75, 1], [0.25, 1]],
    2: [
      [3/7, .25], [4/7, 0], [6/7, 0], [1, .25], [6/7, .5], [1, .75], [6/7, 1], [4/7, 1], [3/7, .75], [1/7, .75],
      [0, .5], [1/7, .25]
    ],
    3: [
      [.4, 0], [.6, 0], [.7, 1/6], [.9, 1/6], [1, 1/3], [.9, .5], [1, 2/3], [.9, 5/6], [.7, 5/6], [.6, 1], [.4, 1],
      [.3, 5/6], [.1, 5/6], [0, 2/3], [.1, .5], [0, 1/3], [.1, 1/6], [.3, 1/6]
    ],
    4: [
      [6/13, 0], [7/13, 1/8], [9/13, 1/8], [10/13, .25], [12/13, .25], [1, 3/8], [12/13, .5], [1, 5/8], [12/13, .75],
      [10/13, .75], [9/13, 7/8], [7/13, 7/8], [6/13, 1], [4/13, 1], [3/13, 7/8], [1/13, 7/8], [0, .75], [1/13, 5/8],
      [0, .5], [1/13, 3/8], [0, .25], [1/13, 1/8], [3/13, 1/8], [4/13, 0]
    ]
  };

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static get pointyHexPoints() {
    const msg = "HexagonalGrid.pointyHexPoints is deprecated without replacement.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.#POINTY_HEX_BORDERS[1];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static get flatHexPoints() {
    const msg = "HexagonalGrid.flatHexPoints is deprecated without replacement.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.#FLAT_HEX_BORDERS[1];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get hexPoints() {
    const msg = "HexagonalGrid#hexPoints is deprecated without replacement.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.columns ? this.constructor.flatHexPoints : this.constructor.pointyHexPoints;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getPolygon(x, y, w, h, points) {
    const msg = "HexagonalGrid#getPolygon is deprecated. You can get the shape of the hex with HexagonalGrid#getShape "
      + "and the polygon of any hex with HexagonalGrid#getVertices.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    w = w ?? this.sizeX;
    h = h ?? this.sizeY;
    points ??= this.hexPoints;
    const poly = [];
    for ( let i=0; i < points.length; i++ ) {
      poly.push(x + (w * points[i][0]), y + (h * points[i][1]));
    }
    return poly;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getBorderPolygon(w, h, p) {
    const msg = "HexagonalGrid#getBorderPolygon is deprecated. "
      + "If you need the shape of a Token, use Token#shape/getShape instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const points = this.columns ? this.constructor.FLAT_HEX_BORDERS[w] : this.constructor.POINTY_HEX_BORDERS[w];
    if ( (w !== h) || !points ) return null;
    const p2 = p / 2;
    const p4 = p / 4;
    const r = this.getRect(w, h);
    return this.getPolygon(-p4, -p4, r.width + p2, r.height + p2, points);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getRect(w, h) {
    const msg = "HexagonalGrid#getRect is deprecated. If you need the size of a Token, use Token#getSize instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    if ( !this.columns || (w < 1) ) w *= this.sizeX;
    else w = (this.sizeX * .75 * (w - 1)) + this.sizeX;
    if ( this.columns || (h < 1) ) h *= this.sizeY;
    else h = (this.sizeY * .75 * (h - 1)) + this.sizeY;
    return new PIXI.Rectangle(0, 0, w, h);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  _adjustSnapForTokenSize(x, y, token) {
    const msg = "HexagonalGrid#_adjustSnapForTokenSize is deprecated.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    if ( (token.document.width <= 1) && (token.document.height <= 1) ) {
      const [row, col] = this.getGridPositionFromPixels(x, y);
      const [x0, y0] = this.getPixelsFromGridPosition(row, col);
      return [x0 + (this.sizeX / 2) - (token.w / 2), y0 + (this.sizeY / 2) - (token.h / 2)];
    }

    if ( this.columns && (token.document.height > 1) ) y -= this.sizeY / 2;
    if ( !this.columns && (token.document.width > 1) ) x -= this.sizeX / 2;
    return [x, y];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static computeDimensions({columns, size, legacy}) {
    const msg = "HexagonalGrid.computeDimensions is deprecated without replacement.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});

    // Legacy dimensions (deprecated)
    if ( legacy ) {
      if ( columns ) return { width: size, height: (Math.SQRT3 / 2) * size };
      return { width: (Math.SQRT3 / 2) * size, height: size };
    }

    // Columnar orientation
    if ( columns ) return { width: (2 * size) / Math.SQRT3, height: size };

    // Row orientation
    return { width: size, height: (2 * size) / Math.SQRT3 };
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get columnar() {
    const msg = "HexagonalGrid#columnar is deprecated in favor of HexagonalGrid#columns.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.columns;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  set columnar(value) {
    const msg = "HexagonalGrid#columnar is deprecated in favor of HexagonalGrid#columns.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    this.columns = value;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getCenter(x, y) {
    const msg = "HexagonalGrid#getCenter is deprecated. Use HexagonalGrid#getCenterPoint instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const [x0, y0] = this.getTopLeft(x, y);
    return [x0 + (this.sizeX / 2), y0 + (this.sizeY / 2)];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getSnappedPosition(x, y, interval=1, {token}={}) {
    const msg = "HexagonalGrid#getSnappedPosition is deprecated. Use HexagonalGrid#getSnappedPoint instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    if ( interval === 0 ) return {x: Math.round(x), y: Math.round(y)};

    // At precision 5, return the center or nearest vertex
    if ( interval === 5) {
      const w4 = this.w / 4;
      const h4 = this.h / 4;

      // Distance relative to center
      const [xc, yc] = this.getCenter(x, y);
      const dx = x - xc;
      const dy = y - yc;
      let ox = dx.between(-w4, w4) ? 0 : Math.sign(dx);
      let oy = dy.between(-h4, h4) ? 0 : Math.sign(dy);

      // Closest to the center
      if ( (ox === 0) && (oy === 0) ) return {x: xc, y: yc};

      // Closest vertex based on offset
      if ( this.columns && (ox === 0) ) ox = Math.sign(dx) ?? -1;
      if ( !this.columns && (oy === 0) ) oy = Math.sign(dy) ?? -1;
      const {x: x0, y: y0 } = this.#getClosestVertex(xc, yc, ox, oy);
      return {x: Math.round(x0), y: Math.round(y0)};
    }

    // Start with the closest top-left grid position
    if ( token ) {
      if ( this.columns && (token.document.height > 1) ) y += this.sizeY / 2;
      if ( !this.columns && (token.document.width > 1) ) x += this.sizeX / 2;
    }
    const options = {
      columns: this.columns,
      even: this.even,
      size: this.size,
      width: this.sizeX,
      height: this.sizeY
    };
    const offset = HexagonalGrid.pixelsToOffset({x, y}, options, "round");
    const point = HexagonalGrid.offsetToPixels(offset, options);

    // Adjust pixel coordinate for token size
    let x0 = point.x;
    let y0 = point.y;
    if ( token ) [x0, y0] = this._adjustSnapForTokenSize(x0, y0, token);

    // Snap directly at interval 1
    if ( interval === 1 ) return {x: x0, y: y0};

    // Round the remainder
    const dx = (x - x0).toNearest(this.w / interval);
    const dy = (y - y0).toNearest(this.h / interval);
    return {x: Math.round(x0 + dx), y: Math.round(y0 + dy)};
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  #getClosestVertex(xc, yc, ox, oy) {
    const b = ox + (oy << 2); // Bit shift to make a unique reference
    const vertices = this.columns
      ? {"-1": 0, "-5": 1, "-3": 2, 1: 3, 5: 4, 3: 5}   // Flat hex vertices
      : {"-5": 0, "-4": 1, "-3": 2, 5: 3, 4: 4, 3: 5};  // Pointy hex vertices
    const idx = vertices[b];
    const pt = this.hexPoints[idx];
    return {
      x: (xc - (this.sizeX / 2)) + (pt[0] * this.sizeX),
      y: (yc - (this.sizeY / 2)) + (pt[1] * this.sizeY)
    };
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  #measureDistance(p0, p1) {
    const [i0, j0] = this.getGridPositionFromPixels(p0.x, p0.y);
    const [i1, j1] = this.getGridPositionFromPixels(p1.x, p1.y);
    const c0 = this.getCube({i: i0, j: j0});
    const c1 = this.getCube({i: i1, j: j1});
    return HexagonalGrid.cubeDistance(c0, c1);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getGridPositionFromPixels(x, y) {
    const msg = "HexagonalGrid#getGridPositionFromPixels is deprecated. This function is based on the \"brick wall\" grid. "
    + " For getting the offset coordinates of the hex containing the given point use HexagonalGrid#getOffset.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const {row, col} = HexagonalGrid.pixelsToOffset({x, y}, {
      columns: this.columns,
      even: this.even,
      size: this.size,
      width: this.sizeX,
      height: this.sizeY
    });
    return [row, col];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getPixelsFromGridPosition(row, col) {
    const msg = "HexagonalGrid#getPixelsFromGridPosition is deprecated. This function is based on the \"brick wall\" grid. "
    + " For getting the top-left coordinates of the hex at the given offset coordinates use HexagonalGrid#getTopLeftPoint.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const {x, y} = HexagonalGrid.offsetToPixels({row, col}, {
      columns: this.columns,
      even: this.even,
      size: this.size,
      width: this.sizeX,
      height: this.sizeY
    });
    return [Math.ceil(x), Math.ceil(y)];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  shiftPosition(x, y, dx, dy, {token}={}) {
    const msg = "BaseGrid#shiftPosition is deprecated. Use BaseGrid#getShiftedPoint instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const [row, col] = this.getGridPositionFromPixels(x, y);

    // Adjust diagonal moves for offset
    const isDiagonal = (dx !== 0) && (dy !== 0);
    if ( isDiagonal ) {

      // Column orientation
      if ( this.columns ) {
        const isEven = ((col+1) % 2 === 0) === this.even;
        if ( isEven && (dy > 0)) dy--;
        else if ( !isEven && (dy < 0)) dy++;
      }

      // Row orientation
      else {
        const isEven = ((row + 1) % 2 === 0) === this.even;
        if ( isEven && (dx > 0) ) dx--;
        else if ( !isEven && (dx < 0 ) ) dx++;
      }
    }
    const [shiftX, shiftY] = this.getPixelsFromGridPosition(row+dy, col+dx);
    if ( token ) return this._adjustSnapForTokenSize(shiftX, shiftY, token);
    return [shiftX, shiftY];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  measureDistance(origin, target, options) {
    const msg = "HexagonalGrid#measureDistance now returns the same result as GridLayer#measureDistance instead of the cube distance "
      + " (breaking). Use HexagonalGrid#measurePath instead to get the number of steps (cube distance) between the origin and target.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return super.measureDistance(origin, target, options);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  measureDistances(segments, options={}) {
    const msg = "HexagonalGrid#measureDistances is deprecated. "
      + "Use HexagonalGrid#measurePath instead, which returns grid distance (gridSpaces: true) and Euclidean distance (gridSpaces: false).";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    if ( !options.gridSpaces ) return super.measureDistances(segments, options);
    return segments.map(s => {
      const r = s.ray;
      return this.#measureDistance(r.A, r.B) * this.distance;
    });
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  _adjustPositionForTokenSize(row, col, token) {
    const msg = "HexagonalGrid#_adjustPositionForTokenSize is deprecated.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    if ( this.columns && (token.document.height > 1) ) row++;
    if ( !this.columns && (token.document.width > 1) ) col++;
    return [row, col];
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static getConfig(type, size) {
    const msg = "HexagonalGrid.getConfig is deprecated without replacement.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const config = {
      columns: [GRID_TYPES.HEXODDQ, GRID_TYPES.HEXEVENQ].includes(type),
      even: [GRID_TYPES.HEXEVENR, GRID_TYPES.HEXEVENQ].includes(type),
      size: size
    };
    const {width, height} = HexagonalGrid.computeDimensions(config);
    config.width = width;
    config.height = height;
    return config;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static offsetToCube({row, col}={}, {columns=true, even=false}={}) {
    const msg = "HexagonalGrid.offsetToCube is deprecated. Use HexagonalGrid#offsetToCube instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return new HexagonalGrid({size: 100, columns, even}).offsetToCube({i: row, j: col});
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static cubeToOffset(cube={}, {columns=true, even=false}={}) {
    const msg = "HexagonalGrid.cubeToOffset is deprecated. Use HexagonalGrid#cubeToOffset instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const {i: row, j: col} = new HexagonalGrid({size: 100, columns, even}).cubeToOffset(cube);
    return {row, col};
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static pixelToCube(point, config) {
    const msg = "HexagonalGrid.pixelToCube is deprecated. Use HexagonalGrid#pointToCube instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const {x, y} = point ?? {};
    const {size} = config;
    const cx = x / (size / 2);
    const cy = y / (size / 2);

    // Fractional hex coordinates, might not satisfy (fx + fy + fz = 0) due to rounding
    const fr = (2/3) * cx;
    const fq = ((-1/3) * cx) + ((1 / Math.sqrt(3)) * cy);
    const fs = ((-1/3) * cx) - ((1 / Math.sqrt(3)) * cy);

    // Convert to integer triangle coordinates
    const a = Math.ceil(fr - fq);
    const b = Math.ceil(fq - fs);
    const c = Math.ceil(fs - fr);

    // Convert back to cube coordinates
    return {
      q: Math.round((a - c) / 3),
      r: Math.round((c - b) / 3),
      s: Math.round((b - a) / 3)
    };
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static offsetToPixels({row, col}, {columns, even, width, height}) {
    const msg = "HexagonalGrid.offsetToPixels is deprecated. Use HexagonalGrid#getTopLeftPoint instead.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    let x;
    let y;

    // Flat-topped hexes
    if ( columns ) {
      x = Math.ceil(col * (width * 0.75));
      const isEven = (col + 1) % 2 === 0;
      y = Math.ceil((row - (even === isEven ? 0.5 : 0)) * height);
    }

    // Pointy-topped hexes
    else {
      y = Math.ceil(row * (height * 0.75));
      const isEven = (row + 1) % 2 === 0;
      x = Math.ceil((col - (even === isEven ? 0.5 : 0)) * width);
    }

    // Return the pixel coordinate
    return {x, y};
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static pixelsToOffset({x, y}, config, method="floor") {
    const msg = "HexagonalGrid.pixelsToOffset is deprecated without replacement. This function is based on the \"brick wall\" grid. "
      + " For getting the offset coordinates of the hex containing the given point use HexagonalGrid#getOffset.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const {columns, even, width, height} = config;
    const fn = Math[method];
    let row;
    let col;

    // Columnar orientation
    if ( columns ) {
      col = fn(x / (width * 0.75));
      const isEven = (col + 1) % 2 === 0;
      row = fn((y / height) + (even === isEven ? 0.5 : 0));
    }

    // Row orientation
    else {
      row = fn(y / (height * 0.75));
      const isEven = (row + 1) % 2 === 0;
      col = fn((x / width) + (even === isEven ? 0.5 : 0));
    }
    return {row, col};
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getAStarPath(start, goal, options) {
    const msg = "HexagonalGrid#getAStarPath is deprecated without replacement.";
    logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const costs = new Map();

    // Create a prioritized frontier sorted by increasing cost
    const frontier = [];
    const explore = (hex, from, cost) => {
      const idx = frontier.findIndex(l => l.cost > cost);
      if ( idx === -1 ) frontier.push({hex, cost, from});
      else frontier.splice(idx, 0, {hex, cost, from});
      costs.set(hex, cost);
    };
    explore(start, null, 0);

    // Expand the frontier, exploring towards the goal
    let current;
    let solution;
    while ( frontier.length ) {
      current = frontier.shift();
      if ( current.cost === Infinity ) break;
      if ( current.hex.equals(goal) ) {
        solution = current;
        break;
      }
      for ( const next of current.hex.getNeighbors() ) {
        const deltaCost = next.getTravelCost instanceof Function ? next.getTravelCost(current.hex, options) : 1;
        const newCost = current.cost + deltaCost;     // Total cost of reaching this hex
        if ( costs.get(next) <= newCost ) continue;   // We already made it here in the lowest-cost way
        explore(next, current, newCost);
      }
    }

    // Ensure a path was achieved
    if ( !solution ) {
      throw new Error("No valid path between these positions exists");
    }

    // Return the optimal path and cost
    const path = [];
    let c = solution;
    while ( c.from ) {
      path.unshift(c.hex);
      c = c.from;
    }
    return {from: start, to: goal, cost: solution.cost, path};
  }
}

/**
 * @import {SceneData} from "./_types.mjs";
 * @import BaseGrid from "../grid/base.mjs";
 */

/**
 * The Scene Document.
 * Defines the DataSchema and common behaviors for a Scene which are shared between both client and server.
 * @extends {Document<SceneData>}
 * @mixes SceneData
 * @category Documents
 */
class BaseScene extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Scene",
    collection: "scenes",
    indexed: true,
    compendiumIndexFields: ["_id", "name", "thumb", "sort", "folder"],
    embedded: {
      AmbientLight: "lights",
      AmbientSound: "sounds",
      Drawing: "drawings",
      MeasuredTemplate: "templates",
      Note: "notes",
      Region: "regions",
      Tile: "tiles",
      Token: "tokens",
      Wall: "walls"
    },
    label: "DOCUMENT.Scene",
    labelPlural: "DOCUMENT.Scenes",
    preserveOnImport: [...super.metadata.preserveOnImport, "active"],
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritDoc */
  static defineSchema() {
    const documents = foundry.documents;
    // Define reusable ambience schema for environment
    const environmentData = init => new SchemaField({
      hue: new HueField({required: true, initial: init.hue}),
      intensity: new AlphaField({required: true, nullable: false, initial: init.intensity}),
      luminosity: new NumberField({required: true, nullable: false, initial: init.luminosity, min: -1, max: 1}),
      saturation: new NumberField({required: true, nullable: false, initial: init.saturation, min: -1, max: 1}),
      shadows: new NumberField({required: true, nullable: false, initial: init.shadows, min: 0, max: 1})
    });
    // Reuse parts of the LightData schema for the global light
    const lightDataSchema = foundry.data.LightData.defineSchema();

    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),

      // Navigation
      active: new BooleanField(),
      navigation: new BooleanField({initial: true}),
      navOrder: new NumberField({required: true, nullable: false, integer: true, initial: 0}),
      navName: new StringField({textSearch: true}),

      // Canvas Dimensions
      background: new TextureData(),
      foreground: new FilePathField({categories: ["IMAGE", "VIDEO"], virtual: true}),
      foregroundElevation: new NumberField({required: true, positive: true, integer: true}),
      thumb: new FilePathField({categories: ["IMAGE"]}),
      width: new NumberField({integer: true, positive: true, initial: 4000}),
      height: new NumberField({integer: true, positive: true, initial: 3000}),
      padding: new NumberField({required: true, nullable: false, min: 0, max: 0.5, step: 0.05, initial: 0.25}),
      initial: new SchemaField({
        x: new NumberField({integer: true, required: true}),
        y: new NumberField({integer: true, required: true}),
        scale: new NumberField({required: true, positive: true})
      }),
      backgroundColor: new ColorField({nullable: false, initial: "#999999"}),

      // Grid Configuration
      grid: new SchemaField({
        type: new NumberField({required: true, choices: Object.values(GRID_TYPES),
          initial: () => game.system.grid.type, validationError: "must be a value in CONST.GRID_TYPES"}),
        size: new NumberField({required: true, nullable: false, integer: true, min: GRID_MIN_SIZE,
          initial: 100, validationError: `must be an integer number of pixels, ${GRID_MIN_SIZE} or greater`}),
        style: new StringField({required: true, blank: false, initial: "solidLines"}),
        thickness: new NumberField({required: true, nullable: false, positive: true, integer: true, initial: 1}),
        color: new ColorField({required: true, nullable: false, initial: "#000000"}),
        alpha: new AlphaField({initial: 0.2}),
        distance: new NumberField({required: true, nullable: false, positive: true,
          initial: () => game.system.grid.distance}),
        units: new StringField({required: true, initial: () => game.system.grid.units})
      }),

      // Vision Configuration
      tokenVision: new BooleanField({initial: true}),
      fog: new SchemaField({
        exploration: new BooleanField({initial: true}),
        reset: new NumberField({required: false, initial: undefined}),
        overlay: new FilePathField({categories: ["IMAGE", "VIDEO"], virtual: true}),
        colors: new SchemaField({
          explored: new ColorField(),
          unexplored: new ColorField()
        })
      }),

      // Environment Configuration
      environment: new SchemaField({
        darknessLevel: new AlphaField({initial: 0}),
        darknessLock: new BooleanField({initial: false}),
        globalLight: new SchemaField({
          enabled: new BooleanField({required: true, initial: false}),
          alpha: lightDataSchema.alpha,
          bright: new BooleanField({required: true, initial: false}),
          color: lightDataSchema.color,
          coloration: lightDataSchema.coloration,
          luminosity: new NumberField({required: true, nullable: false, initial: 0, min: 0, max: 1}),
          saturation: lightDataSchema.saturation,
          contrast: lightDataSchema.contrast,
          shadows: lightDataSchema.shadows,
          darkness: lightDataSchema.darkness
        }),
        cycle: new BooleanField({initial: true}),
        base: environmentData({hue: 0, intensity: 0, luminosity: 0, saturation: 0, shadows: 0}),
        dark: environmentData({hue: 257/360, intensity: 0, luminosity: -0.25, saturation: 0, shadows: 0})
      }),

      // Embedded Collections
      drawings: new EmbeddedCollectionField(documents.BaseDrawing),
      tokens: new EmbeddedCollectionField(documents.BaseToken),
      lights: new EmbeddedCollectionField(documents.BaseAmbientLight),
      notes: new EmbeddedCollectionField(documents.BaseNote),
      sounds: new EmbeddedCollectionField(documents.BaseAmbientSound),
      regions: new EmbeddedCollectionField(documents.BaseRegion),
      templates: new EmbeddedCollectionField(documents.BaseMeasuredTemplate),
      tiles: new EmbeddedCollectionField(documents.BaseTile),
      walls: new EmbeddedCollectionField(documents.BaseWall),

      // Linked Documents
      playlist: new ForeignDocumentField(documents.BasePlaylist),
      playlistSound: new ForeignDocumentField(documents.BasePlaylistSound, {idOnly: true}),
      journal: new ForeignDocumentField(documents.BaseJournalEntry),
      journalEntryPage: new ForeignDocumentField(documents.BaseJournalEntryPage, {idOnly: true}),
      weather: new StringField({required: true}),

      // Permissions
      folder: new ForeignDocumentField(documents.BaseFolder),
      sort: new IntegerSortField(),
      ownership: new DocumentOwnershipField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "SCENE"];

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

  /**
   * The default grid defined by the system.
   * @type {BaseGrid}
   */
  static get defaultGrid() {
    if ( BaseScene.#defaultGrid ) return BaseScene.#defaultGrid;

    const T = GRID_TYPES;
    const {type, ...config} = game.system.grid;
    config.size = 100;

    // Gridless grid
    if ( type === T.GRIDLESS ) BaseScene.#defaultGrid = new GridlessGrid(config);

    // Square grid
    if ( type === T.SQUARE ) BaseScene.#defaultGrid = new 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);
      BaseScene.#defaultGrid = new HexagonalGrid(config);
    }

    return BaseScene.#defaultGrid;
  }

  static #defaultGrid;

  /* -------------------------------------------- */
  /*  Data Management                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initialize(options) {
    super._initialize(options);
    DocumentStatsField._shimDocument(this);
  }

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

  /** @inheritDoc */
  updateSource(changes={}, options={}) {
    if ( "tokens" in changes ) {
      for ( const tokenChange of changes.tokens ) {
        this.tokens.get(tokenChange._id)?._prepareDeltaUpdate(tokenChange, options);
      }
    }
    return super.updateSource(changes, options);
  }

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

  /**
   * Static Initializer Block for deprecated properties.
   */
  static {
    const migrations = {
      fogExploration: "fog.exploration",
      fogReset: "fog.reset",
      fogOverlay: "fog.overlay",
      fogExploredColor: "fog.colors.explored",
      fogUnexploredColor: "fog.colors.unexplored",
      globalLight: "environment.globalLight.enabled",
      globalLightThreshold: "environment.globalLight.darkness.max",
      darkness: "environment.darknessLevel"
    };
    Object.defineProperties(this.prototype, Object.fromEntries(
      Object.entries(migrations).map(([o, n]) => [o, {
        get() {
          this.constructor._logDataFieldMigration(o, n, {since: 12, until: 14});
          return foundry.utils.getProperty(this, n);
        },
        set(v) {
          this.constructor._logDataFieldMigration(o, n, {since: 12, until: 14});
          return foundry.utils.setProperty(this, n, v);
        },
        configurable: true
      }])));
  }

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

  /** @inheritdoc */
  static migrateData(source) {
    /**
     * Migration to fog schema fields. Can be safely removed in V14+
     * @deprecated since v12
     */
    for ( const [oldKey, newKey] of Object.entries({
      fogExploration: "fog.exploration",
      fogReset: "fog.reset",
      fogOverlay: "fog.overlay",
      fogExploredColor: "fog.colors.explored",
      fogUnexploredColor: "fog.colors.unexplored"
    }) ) this._addDataFieldMigration(source, oldKey, newKey);

    /**
     * Migration to global light embedded fields. Can be safely removed in V14+
     * @deprecated since v12
     */
    this._addDataFieldMigration(source, "globalLight", "environment.globalLight.enabled");
    this._addDataFieldMigration(source, "globalLightThreshold", "environment.globalLight.darkness.max",
      d => d.globalLightThreshold ?? 1);

    /**
     * Migration to environment darkness level. Can be safely removed in V14+
     * @deprecated since v12
     */
    this._addDataFieldMigration(source, "darkness", "environment.darknessLevel");

    DocumentStatsField._migrateData(this, source);

    return super.migrateData(source);
  }

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

  /** @inheritdoc */
  static shimData(source, options) {

    /** @deprecated since v12 */
    this._addDataFieldShims(source, {
      fogExploration: "fog.exploration",
      fogReset: "fog.reset",
      fogOverlay: "fog.overlay",
      fogExploredColor: "fog.colors.explored",
      fogUnexploredColor: "fog.colors.unexplored",
      globalLight: "environment.globalLight.enabled",
      globalLightThreshold: "environment.globalLight.darkness.max",
      darkness: "environment.darknessLevel"
    }, {since: 12, until: 14});

    DocumentStatsField._shimData(this, source, options);

    return super.shimData(source, options);
  }
}

/**
 * @import {RegionData} from "./_types.mjs";
 */

/**
 * The Region Document.
 * Defines the DataSchema and common behaviors for a Region which are shared between both client and server.
 * @extends {Document<RegionData>}
 * @mixes RegionData
 * @category Documents
 */
class BaseRegion extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Region",
    collection: "regions",
    label: "DOCUMENT.Region",
    labelPlural: "DOCUMENT.Regions",
    isEmbedded: true,
    embedded: {
      RegionBehavior: "behaviors"
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const {BaseRegionBehavior} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      color: new ColorField({required: true, nullable: false,
        initial: () => Color.fromHSV([Math.random(), 0.8, 0.8]).css}),
      shapes: new ArrayField(new TypedSchemaField(BaseShapeData.TYPES)),
      elevation: new SchemaField({
        bottom: new NumberField({required: true}), // Treat null as -Infinity
        top: new NumberField({required: true}) // Treat null as +Infinity
      }, {
        validate: d => (d.bottom ?? -Infinity) <= (d.top ?? Infinity),
        validationError: "elevation.top may not be less than elevation.bottom"
      }),
      behaviors: new EmbeddedCollectionField(BaseRegionBehavior),
      visibility: new NumberField({required: true,
        initial: CONST.REGION_VISIBILITY.LAYER,
        choices: Object.values(CONST.REGION_VISIBILITY)}),
      locked: new BooleanField(),
      flags: new DocumentFlagsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "REGION"];
}

/**
 * @import {RegionBehaviorData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The RegionBehavior Document.
 * Defines the DataSchema and common behaviors for a RegionBehavior which are shared between both client and server.
 * @extends {Document<RegionBehaviorData>}
 * @mixes RegionBehaviorData
 * @category Documents
 */
class BaseRegionBehavior extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "RegionBehavior",
    collection: "behaviors",
    label: "DOCUMENT.RegionBehavior",
    labelPlural: "DOCUMENT.RegionBehaviors",
    coreTypes: ["adjustDarknessLevel", "displayScrollingText", "executeMacro", "executeScript", "modifyMovementCost", "pauseGame", "suppressWeather", "teleportToken", "toggleBehavior"],
    hasTypeData: true,
    isEmbedded: true,
    permissions: {
      create: this.#canCreate,
      update: this.#canUpdate
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: true, textSearch: true}),
      type: new DocumentTypeField(this),
      system: new TypeDataField(this),
      disabled: new BooleanField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "BEHAVIOR"];

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

  /** @override */
  static canUserCreate(user) {
    return user.isGM;
  }

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

  /**
   * Is a user able to create the RegionBehavior document?
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc) {
    if ( (doc._source.type === "executeScript") && !user.hasPermission("MACRO_SCRIPT") ) return false;
    return user.isGM;
  }

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

  /**
   * Is a user able to update the RegionBehavior document?
   * @type {DocumentPermissionTest}
   */
  static #canUpdate(user, doc, data) {
    if ( (((doc._source.type === "executeScript") && ("system" in data) && ("source" in data.system))
      || (data.type === "executeScript")) && !user.hasPermission("MACRO_SCRIPT") ) return false;
    return user.isGM;
  }
}

/**
 * @import {UserData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The User Document.
 * Defines the DataSchema and common behaviors for a User which are shared between both client and server.
 * @extends {Document<UserData>}
 * @mixes UserData
 * @category Documents
 */
class BaseUser extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "User",
    collection: "users",
    label: "DOCUMENT.User",
    labelPlural: "DOCUMENT.Users",
    permissions: {
      create: this.#canCreate,
      update: this.#canUpdate,
      delete: this.#canDelete
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "USER"];

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

  /** @inheritdoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      role: new NumberField({required: true, choices: Object.values(USER_ROLES),
        initial: USER_ROLES.PLAYER, readonly: true}),
      password: new StringField({required: true, blank: true}),
      passwordSalt: new StringField(),
      avatar: new FilePathField({categories: ["IMAGE"]}),
      character: new ForeignDocumentField(BaseActor),
      color: new ColorField({required: true, nullable: false,
        initial: () => Color.fromHSV([Math.random(), 0.8, 0.8]).css
      }),
      pronouns: new StringField({required: true}),
      hotbar: new ObjectField({required: true, validate: BaseUser.#validateHotbar,
        validationError: "must be a mapping of slots to macro identifiers"}),
      permissions: new ObjectField({required: true, validate: BaseUser.#validatePermissions,
        validationError: "must be a mapping of permission names to booleans"}),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /**
   * Validate the structure of the User hotbar object
   * @param {object} bar      The attempted hotbar data
   * @returns {boolean}
   */
  static #validateHotbar(bar) {
    if ( typeof bar !== "object" ) return false;
    for ( const [k, v] of Object.entries(bar) ) {
      const slot = parseInt(k);
      if ( !slot || slot < 1 || slot > 50 ) return false;
      if ( !isValidId(v) ) return false;
    }
    return true;
  }

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

  /**
   * Validate the structure of the User permissions object
   * @param {object} perms      The attempted permissions data
   * @returns {boolean}
   */
  static #validatePermissions(perms) {
    for ( const [k, v] of Object.entries(perms) ) {
      if ( typeof k !== "string" ) return false;
      if ( k.startsWith("-=") ) {
        if ( v !== null ) return false;
      } else {
        if ( typeof v !== "boolean" ) return false;
      }
    }
    return true;
  }

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

  /**
   * A convenience test for whether this User has the NONE role.
   * @type {boolean}
   */
  get isBanned() {
    return this.role === USER_ROLES.NONE;

  }

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

  /**
   * Test whether the User has a GAMEMASTER or ASSISTANT role in this World?
   * @type {boolean}
   */
  get isGM() {
    return this.hasRole(USER_ROLES.ASSISTANT);
  }

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

  /**
   * Test whether the User is able to perform a certain permission action.
   * The provided permission string may pertain to an explicit permission setting or a named user role.
   *
   * @param {string} action The action to test
   * @returns {boolean} Does the user have the ability to perform this action?
   */
  can(action) {
    if ( action in USER_PERMISSIONS ) return this.hasPermission(action);
    return this.hasRole(action);
  }

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

  /** @inheritdoc */
  getUserLevel(user) {
    return DOCUMENT_OWNERSHIP_LEVELS[user.id === this.id ? "OWNER" : "NONE"];
  }

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

  /**
   * Test whether the User has at least a specific permission
   * @param {string} permission The permission name from USER_PERMISSIONS to test
   * @returns {boolean} Does the user have at least this permission
   */
  hasPermission(permission) {
    if ( this.isBanned ) return false;

    // CASE 1: The user has the permission set explicitly
    const explicit = this.permissions[permission];
    if (explicit !== undefined) return explicit;

    // CASE 2: Permission defined by the user's role
    const rolePerms = game.permissions[permission];
    return rolePerms ? rolePerms.includes(this.role) : false;
  }

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

  /**
   * Test whether the User has at least the permission level of a certain role
   * @param {string|number} role    The role name from USER_ROLES to test
   * @param {boolean} [exact]       Require the role match to be exact
   * @returns {boolean}             Does the user have at this role level (or greater)?
   */
  hasRole(role, {exact = false} = {}) {
    const level = typeof role === "string" ? USER_ROLES[role] : role;
    if (level === undefined) return false;
    return exact ? this.role === level : this.role >= level;
  }

  /* ---------------------------------------- */
  /*  Model Permissions                       */
  /* ---------------------------------------- */

  /**
   * Is a user able to create an existing User?
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc, data) {
    if ( !user.isGM ) return false; // Only Assistants and above can create users.
    // Do not allow Assistants to create a new user with special permissions which might be greater than their own.
    if ( !isEmpty(doc.permissions) ) return user.hasRole(USER_ROLES.GAMEMASTER);
    return user.hasRole(doc.role);
  }

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

  /**
   * Is a user able to update an existing User?
   * @type {DocumentPermissionTest}
   */
  static #canUpdate(user, doc, changes) {
    const roles = USER_ROLES;
    if ( user.role === roles.GAMEMASTER ) return true; // Full GMs can do everything
    if ( user.role === roles.NONE ) return false; // Banned users can do nothing

    // Non-GMs cannot update certain fields.
    const restricted = ["permissions", "passwordSalt"];
    if ( user.role < roles.ASSISTANT ) restricted.push("name", "role");
    if ( doc.role === roles.GAMEMASTER ) restricted.push("password");
    if ( restricted.some(k => k in changes) ) return false;

    // Role changes may not escalate
    if ( ("role" in changes) && !user.hasRole(changes.role) ) return false;

    // Assistant GMs may modify other users. Players may only modify themselves
    return user.isGM || (user.id === doc.id);
  }

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

  /**
   * Is a user able to delete an existing User?
   * @type {DocumentPermissionTest}
   */
  static #canDelete(user, doc) {
    const role = Math.max(USER_ROLES.ASSISTANT, doc.role);
    return user.hasRole(role);
  }
}

/**
 * @import {SettingData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The Setting Document.
 * Defines the DataSchema and common behaviors for a Setting which are shared between both client and server.
 * @extends {Document<SettingData>}
 * @mixes SettingData
 * @category Documents
 */
class BaseSetting extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Setting",
    collection: "settings",
    label: "DOCUMENT.Setting",
    labelPlural: "DOCUMENT.Settings",
    permissions: {
      create: this.#canModify,
      update: this.#canModify,
      delete: this.#canModify
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      key: new StringField({required: true, nullable: false, blank: false,
        validate: k => k.split(".").length >= 2,
        validationError: "must have the format {scope}.{field}"}),
      value: new JSONField({required: true, nullable: true, initial: null}),
      user: new ForeignDocumentField(BaseUser, {idOnly: true}),
      _stats: new DocumentStatsField()
    };
  }

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

  /**
   * The settings that only full GMs can modify.
   * @type {string[]}
   */
  static #GAMEMASTER_ONLY_KEYS = ["core.permissions"];

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

  /**
   * The settings that assistant GMs can modify regardless of their permission.
   * @type {string[]}
   */
  static #ALLOWED_ASSISTANT_KEYS = ["core.time", "core.combatTrackerConfig", "core.sheetClasses", "core.scrollingStatusText",
    "core.tokenDragPreview", "core.adventureImports", "core.gridDiagonals", "core.gridTemplates", "core.coneTemplateType"];

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

  /** @override */
  static canUserCreate(user) {
    return user.hasPermission("SETTINGS_MODIFY");
  }

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

  /**
   * Define special rules which allow certain settings to be updated.
   * @type {DocumentPermissionTest}
   */
  static #canModify(user, doc, data) {
    if ( BaseSetting.#GAMEMASTER_ONLY_KEYS.includes(doc._source.key)
      && (!("key" in data) || BaseSetting.#GAMEMASTER_ONLY_KEYS.includes(data.key)) ) return user.hasRole("GAMEMASTER");
    const sourceUser = doc._source.user;
    const targetUser = data?.user;
    const sourceMatch = !sourceUser || (sourceUser === user.id);
    const targetMatch = !targetUser || (targetUser === user.id);
    if ( sourceUser || targetUser ) return user.isGM || (sourceMatch && targetMatch);
    if ( user.hasPermission("SETTINGS_MODIFY") ) return true;
    if ( !user.isGM ) return false;
    return BaseSetting.#ALLOWED_ASSISTANT_KEYS.includes(doc._source.key)
      && (!("key" in data) || BaseSetting.#ALLOWED_ASSISTANT_KEYS.includes(data.key));
  }
}

/**
 * @import {TableResultData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The TableResult Document.
 * Defines the DataSchema and common behaviors for a TableResult which are shared between both client and server.
 * @extends {Document<TableResultData>}
 * @mixes TableResultData
 * @category Documents
 */
class BaseTableResult extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "TableResult",
    collection: "results",
    label: "DOCUMENT.TableResult",
    labelPlural: "DOCUMENT.TableResults",
    coreTypes: Object.values(TABLE_RESULT_TYPES),
    permissions: {
      create: "OWNER",
      update: this.#canUpdate,
      delete: "OWNER"
    },
    compendiumIndexFields: ["type"],
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @override */
  static LOCALIZATION_PREFIXES = ["TABLE_RESULT"];

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

  /** @inheritDoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      type: new DocumentTypeField(this, {initial: TABLE_RESULT_TYPES.TEXT}),
      name: new StringField({required: true, nullable: false, blank: true, initial: "", textSearch: true}),
      img: new FilePathField({categories: ["IMAGE"]}),
      description: new HTMLField({textSearch: true}),
      documentUuid: new DocumentUUIDField({required: false, nullable: true, initial: undefined}),
      weight: new NumberField({required: true, integer: true, positive: true, nullable: false, initial: 1}),
      range: new ArrayField(new NumberField({integer: true}), {
        min: 2,
        max: 2,
        validate: r => r[1] >= r[0],
        validationError: "must be a length-2 array of ascending integers"
      }),
      drawn: new BooleanField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /**
   * Is a user able to update an existing TableResult?
   * @type {DocumentPermissionTest}
   */
  static #canUpdate(user, doc, data) {
    if ( user.isGM ) return true;                               // GM users can do anything
    if ( !doc.testUserPermission(user, "OWNER") ) return false;
    const wasDrawn = new Set(["drawn", "_id"]);                 // Users can update the drawn status of a result
    if ( new Set(Object.keys(data)).equals(wasDrawn) ) return true;
    return doc.parent.testUserPermission(user, "OWNER");        // Otherwise, go by parent document permission
  }

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

  /**
   * @deprecated since V13
   * @ignore
   */
  get text() {
    const cls = this.constructor.name;
    const message = `${cls}#text is deprecated. Use ${cls}#name or ${cls}#description instead.`;
    foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15, once: true});
    return this.type === "text" ? this.description : this.name;
  }

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

  /**
   * @deprecated since V13
   * @ignore
   */
  get documentId() {
    const cls = this.constructor.name;
    const message = `${cls}#documentId is deprecated. Consult ${cls}#uuid instead.`;
    foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15, once: true});
    return parseUuid(this.documentUuid)?.id ?? null;
  }

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

  /**
   * @deprecated since V13
   * @ignore
   */
  get documentCollection() {
    const cls = this.constructor.name;
    const message = `${cls}#documentCollection is deprecated. Consult ${cls}#uuid instead.`;
    foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15, once: true});
    const parsedUuid = parseUuid(this.documentUuid);
    const collection = parsedUuid?.collection;
    if ( collection instanceof foundry.documents.collections.CompendiumCollection ) return collection.metadata.id;
    return parsedUuid?.type ?? "";
  }

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

  /** @inheritDoc */
  static migrateData(data) {
    const TYPES = CONST.TABLE_RESULT_TYPES;

    /**
     * V12 migration of type from number to string.
     * @deprecated since v12
     */
    if ( typeof data.type === "number" ) {
      data.type = data.type === 0 ? TYPES.TEXT : TYPES.DOCUMENT;
    }

    // Since V13, the "compendium" type has been dropped.
    if ( data.type === "pack" ) data.type = TYPES.DOCUMENT;
    BaseTableResult.#migrateDocumentUuid(data);

    return super.migrateData(data);
  }

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

  /**
   * The documentId and documentCollection fields have been replaced with a single uuid field.
   * @param {object} data
   * @deprecated since V13
   */
  static #migrateDocumentUuid(data) {
    const hasRealProperty = p => Object.hasOwn(data, p) && !Object.getOwnPropertyDescriptor(data, p).get;
    if ( ["documentId", "documentCollection"].every(p => hasRealProperty(p)) ) {
      if ( data.type === CONST.TABLE_RESULT_TYPES.DOCUMENT ) {
        data.name = data.text;
        data.text = "";
        const [documentName, pack] = CONST.COMPENDIUM_DOCUMENT_TYPES.includes(data.documentCollection)
          ? [data.documentCollection, undefined]
          : [null, data.documentCollection];
        data.documentUuid = buildUuid({id: data.documentId, documentName, pack});
      }
      delete data.documentId;
      delete data.documentCollection;
    }
    this._addDataFieldMigration(data, "text", "description");
  }

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

  /** @inheritDoc */
  static shimData(data, options) {
    if ( Object.isSealed(data) || Object.hasOwn(data, "documentId") ) {
      return super.shimData(data, options);
    }
    BaseTableResult.#shimDocumentUuid(data);
    this._addDataFieldShim(data, "text", "description", {since: 13, until: 15});
    return super.shimData(data, options);
  }

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

  /**
   * Provide accessors for documentId and documentCollection, attempting to preserve a well-formed uuid on set.
   * @param {object} data
   */
  static #shimDocumentUuid(data) {
    const obj = "TableResultData";
    Object.defineProperties(data, {
      documentId: {
        get() {
          const message = `${obj}#documentId is deprecated. Consult ${obj}#documentUuid instead.`;
          foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15, once: true});
          return parseUuid(data.documentUuid)?.id ?? null;
        },
        set(id) {
          const message = `${obj}#documentId is deprecated. Update ${obj}#documentUuid instead.`;
          foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15, once: true});
          const [documentName, pack] = CONST.WORLD_DOCUMENT_TYPES.includes(data.documentCollection)
            ? [data.documentCollection, undefined]
            : [null, data.documentCollection];
          data.documentUuid = buildUuid({id, documentName, pack});
        }
      },
      documentCollection: {
        get() {
          const message = `${obj}#documentCollection is deprecated. Consult ${obj}#documentUuid instead.`;
          foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15});
          const parsedUuid = parseUuid(data.documentUuid);
          return parsedUuid?.collection?.metadata?.id ?? parsedUuid.type ?? "";
        },
        set(value) {
          const message = `${obj}#documentCollection is deprecated. Update ${obj}#documentUuid instead.`;
          foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15, once: true});
          data.documentUuid = CONST.WORLD_DOCUMENT_TYPES.includes(value)
            ? buildUuid({id: data.documentId, documentName: value})
            : buildUuid({id: data.documentId, pack: value});
        }
      }
    });
  }
}

/**
 * @import {TileData} from "./_types.mjs";
 */

/**
 * The Tile Document.
 * Defines the DataSchema and common behaviors for a Tile which are shared between both client and server.
 * @extends {Document<TileData>}
 * @mixes TileData
 * @category Documents
 */
class BaseTile extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Tile",
    collection: "tiles",
    label: "DOCUMENT.Tile",
    labelPlural: "DOCUMENT.Tiles",
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const occlusionModes = Object.entries(OCCLUSION_MODES).reduce((modes, entry) => {
      modes[entry[1]] = `TILE.OcclusionMode${entry[0].titleCase()}`;
      return modes;
    }, {});
    return {
      _id: new DocumentIdField(),
      texture: new TextureData({}, {initial: {anchorX: 0.5, anchorY: 0.5, alphaThreshold: 0.75}}),
      width: new NumberField({required: true, min: 0, integer: true, nullable: false}),
      height: new NumberField({required: true, min: 0, integer: true, nullable: false}),
      x: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      y: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      elevation: new NumberField({required: true, nullable: false, initial: 0}),
      sort: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      rotation: new AngleField(),
      alpha: new AlphaField(),
      hidden: new BooleanField(),
      locked: new BooleanField(),
      restrictions: new SchemaField({
        light: new BooleanField(),
        weather: new BooleanField()
      }),
      occlusion: new SchemaField({
        mode: new NumberField({choices: occlusionModes, initial: OCCLUSION_MODES.NONE,
          validationError: "must be a value in CONST.TILE_OCCLUSION_MODES"}),
        alpha: new AlphaField({initial: 0})
      }),
      video: new SchemaField({
        loop: new BooleanField({initial: true}),
        autoplay: new BooleanField({initial: true}),
        volume: new AlphaField({initial: 0, step: 0.01})
      }),
      flags: new DocumentFlagsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "TILE"];

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

  /** @inheritdoc */
  static migrateData(data) {
    /**
     * V12 migration to elevation and sort
     * @deprecated since v12
     */
    this._addDataFieldMigration(data, "z", "sort");

    /**
     * V12 migration from roof to restrictions.light and restrictions.weather
     * @deprecated since v12
     */
    if ( foundry.utils.hasProperty(data, "roof") ) {
      const value = foundry.utils.getProperty(data, "roof");
      if ( !foundry.utils.hasProperty(data, "restrictions.light") ) foundry.utils.setProperty(data, "restrictions.light", value);
      if ( !foundry.utils.hasProperty(data, "restrictions.weather") ) foundry.utils.setProperty(data, "restrictions.weather", value);
      delete data.roof;
    }

    return super.migrateData(data);
  }

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

  /** @inheritdoc */
  static shimData(data, options) {
    this._addDataFieldShim(data, "z", "sort", {since: 12, until: 14});
    return super.shimData(data, options);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  set roof(enabled) {
    this.constructor._logDataFieldMigration("roof", "restrictions.{light|weather}", {since: 12, until: 14});
    this.restrictions.light = enabled;
    this.restrictions.weather = enabled;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  get roof() {
    this.constructor._logDataFieldMigration("roof", "restrictions.{light|weather}", {since: 12, until: 14});
    return this.restrictions.light && this.restrictions.weather;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get z() {
    this.constructor._logDataFieldMigration("z", "sort", {since: 12, until: 14});
    return this.sort;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get overhead() {
    foundry.utils.logCompatibilityWarning(`${this.constructor.name}#overhead is deprecated.`, {since: 12, until: 14});
    return this.elevation >= this.parent?.foregroundElevation;
  }
}

/**
 * @import {Point, ElevatedPoint, DeepReadonly} from "../_types.mjs";
 * @import {TokenShapeType} from "../constants.mjs";
 * @import {TokenHexagonalOffsetsData, TokenHexagonalShapeData, TokenDimensions, TokenPosition} from "./_types.mjs";
 * @import {GridOffset2D, GridOffset3D} from "../grid/_types.mjs";
 * @import {TokenData} from "./_types.mjs";
 * @import {SquareGrid} from "../grid/_module.mjs";
 * @import {DataModelUpdateOptions, DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The Token Document.
 * Defines the DataSchema and common behaviors for a Token which are shared between both client and server.
 * @extends {Document<TokenData>}
 * @mixes TokenData
 * @category Documents
 */
class BaseToken extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Token",
    collection: "tokens",
    label: "DOCUMENT.Token",
    labelPlural: "DOCUMENT.Tokens",
    isEmbedded: true,
    embedded: {
      ActorDelta: "delta"
    },
    permissions: {
      create: "TOKEN_CREATE",
      update: this.#canUpdate,
      delete: "TOKEN_DELETE"
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

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

  /** @inheritdoc */
  static defineSchema() {
    const documents = foundry.documents;
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: true, textSearch: true}),
      displayName: new NumberField({required: true, initial: TOKEN_DISPLAY_MODES.NONE,
        choices: Object.values(TOKEN_DISPLAY_MODES),
        validationError: "must be a value in CONST.TOKEN_DISPLAY_MODES"
      }),
      actorId: new ForeignDocumentField(documents.BaseActor, {idOnly: true}),
      actorLink: new BooleanField(),
      delta: new ActorDeltaField(documents.BaseActorDelta),
      width: new NumberField({required: true, nullable: false, positive: true, initial: 1}),
      height: new NumberField({required: true, nullable: false, positive: true, initial: 1}),
      texture: new TextureData({}, {initial: {src: () => this.DEFAULT_ICON, anchorX: 0.5, anchorY: 0.5, fit: "contain",
        alphaThreshold: 0.75}, wildcard: true}),
      shape: new NumberField({initial: TOKEN_SHAPES.RECTANGLE_1,
        choices: Object.values(TOKEN_SHAPES)}),
      x: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      y: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      elevation: new NumberField({required: true, nullable: false, initial: 0}),
      sort: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      locked: new BooleanField(),
      lockRotation: new BooleanField(),
      rotation: new AngleField(),
      alpha: new AlphaField(),
      hidden: new BooleanField(),
      disposition: new NumberField({required: true, choices: Object.values(TOKEN_DISPOSITIONS),
        initial: TOKEN_DISPOSITIONS.HOSTILE,
        validationError: "must be a value in CONST.TOKEN_DISPOSITIONS"
      }),
      displayBars: new NumberField({required: true, choices: Object.values(TOKEN_DISPLAY_MODES),
        initial: TOKEN_DISPLAY_MODES.NONE,
        validationError: "must be a value in CONST.TOKEN_DISPLAY_MODES"
      }),
      bar1: new SchemaField({
        attribute: new StringField({required: true, nullable: true, blank: false,
          initial: () => game?.system.primaryTokenAttribute || null})
      }),
      bar2: new SchemaField({
        attribute: new StringField({required: true, nullable: true, blank: false,
          initial: () => game?.system.secondaryTokenAttribute || null})
      }),
      light: new EmbeddedDataField(LightData),
      sight: new SchemaField({
        enabled: new BooleanField({initial: data => Number(data?.sight?.range) > 0}),
        range: new NumberField({required: true, nullable: true, min: 0, step: 0.01, initial: 0}),
        angle: new AngleField({initial: 360, normalize: false}),
        visionMode: new StringField({required: true, blank: false, initial: "basic"}),
        color: new ColorField(),
        attenuation: new AlphaField({initial: 0.1}),
        brightness: new NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1}),
        saturation: new NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1}),
        contrast: new NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1})
      }),
      detectionModes: new ArrayField(new SchemaField({
        id: new StringField(),
        enabled: new BooleanField({initial: true}),
        range: new NumberField({required: true, min: 0, step: 0.01})
      }), {
        validate: BaseToken.#validateDetectionModes
      }),
      occludable: new SchemaField({
        radius: new NumberField({required: true, nullable: false, min: 0, step: 0.01, initial: 0})
      }),
      ring: new SchemaField({
        enabled: new BooleanField(),
        colors: new SchemaField({
          ring: new ColorField(),
          background: new ColorField()
        }),
        effects: new NumberField({required: true, nullable: false, integer: true, initial: 1, min: 0,
          max: 8388607}),
        subject: new SchemaField({
          scale: new NumberField({required: true, nullable: false, initial: 1, min: 0.5}),
          texture: new FilePathField({categories: ["IMAGE"]})
        })
      }),
      turnMarker: new SchemaField({
        mode: new NumberField({required: true, choices: Object.values(TOKEN_TURN_MARKER_MODES),
          initial: TOKEN_TURN_MARKER_MODES.DEFAULT,
          validationError: "must be a value in CONST.TOKEN_TURN_MARKER_MODES"
        }),
        animation: new StringField({required: true, blank: false, nullable: true}),
        src: new FilePathField({categories: ["IMAGE", "VIDEO"]}),
        disposition: new BooleanField()
      }),
      movementAction: new StringField({required: true, blank: false, nullable: true, initial: null,
        choices: CONFIG.Token.movement?.actions}),
      /** @internal */
      _movementHistory: new ArrayField(new SchemaField({
        x: new NumberField({required: true, nullable: false, integer: true, initial: undefined}),
        y: new NumberField({required: true, nullable: false, integer: true, initial: undefined}),
        elevation: new NumberField({required: true, nullable: false, initial: undefined}),
        width: new NumberField({required: true, nullable: false, positive: true, initial: undefined}),
        height: new NumberField({required: true, nullable: false, positive: true, initial: undefined}),
        shape: new NumberField({required: true, initial: undefined, choices: Object.values(TOKEN_SHAPES)}),
        action: new StringField({required: true, blank: false, initial: undefined}),
        terrain: CONFIG.Token.movement?.TerrainData ? new EmbeddedDataField(CONFIG.Token.movement.TerrainData,
          {nullable: true, initial: undefined}) : new ObjectField({nullable: true, initial: undefined}),
        snapped: new BooleanField({initial: undefined}),
        explicit: new BooleanField({initial: undefined}),
        checkpoint: new BooleanField({initial: undefined}),
        intermediate: new BooleanField({initial: undefined}),
        userId: new ForeignDocumentField(documents.BaseUser, {idOnly: true, required: true, initial: undefined}),
        movementId: new StringField({required: true, blank: false, initial: undefined,
          validate: value => {
            if ( !foundry.data.validators.isValidId(value) ) throw new Error("must be a valid 16-character alphanumeric ID");
          }
        }),
        cost: new NumberField({required: true, nullable: true, min: 0, initial: undefined})
      })),
      /** @internal */
      _regions: new ArrayField(new ForeignDocumentField(documents.BaseRegion, {idOnly: true})),
      flags: new DocumentFlagsField()
    };
  }

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "TOKEN"];

  /**
   * The fields of the data model for which changes count as a movement action.
   * @type {Readonly<["x", "y", "elevation", "width", "height", "shape"]>}
   * @readonly
   */
  static MOVEMENT_FIELDS = Object.freeze(["x", "y", "elevation", "width", "height", "shape"]);

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

  /**
   * Are the given positions equal?
   * @param {TokenPosition} position1
   * @param {TokenPosition} position2
   * @returns {boolean}
   */
  static arePositionsEqual(position1, position2) {
    return (position1 === position2) || this.MOVEMENT_FIELDS.every(k => position1[k] === position2[k]);
  }

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

  /**
   * Validate the structure of the detection modes array
   * @param {object[]} modes    Configured detection modes
   * @throws                    An error if the array is invalid
   */
  static #validateDetectionModes(modes) {
    const seen = new Set();
    for ( const mode of modes ) {
      if ( mode.id === "" ) continue;
      if ( seen.has(mode.id) ) {
        throw new Error(`may not have more than one configured detection mode of type "${mode.id}"`);
      }
      seen.add(mode.id);
    }
  }

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

  /**
   * The default icon used for newly created Token documents
   * @type {string}
   */
  static DEFAULT_ICON = DEFAULT_TOKEN;

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

  /**
   * Is a User able to update an existing Token? One can update a Token embedded in a World Scene if they own the
   * corresponding Actor.
   * @type {DocumentPermissionTest}
   */
  static #canUpdate(user, doc, data) {
    if ( user.isGM ) return true;                     // GM users can do anything
    if ( doc.inCompendium ) return doc.testUserPermission(user, "OWNER");
    if ( doc.actor ) {                                // You can update Tokens for Actors you control
      return doc.actor.testUserPermission(user, "OWNER");
    }
    return !doc.actorId;                              // Actor-less Tokens can be updated by anyone
  }

  /* -------------------------------------------- */
  /*  Data Management                             */
  /* -------------------------------------------- */

  /**
   * Prepare changes to a descendent delta collection.
   * @param {object} changes                  Candidate source changes.
   * @param {DataModelUpdateOptions} options  Options which determine how the new data is merged.
   * @internal
   */
  _prepareDeltaUpdate(changes={}, options={}) {
    if ( changes.delta && this.delta ) this.delta._prepareDeltaUpdate(changes.delta, options);
  }

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

  /** @inheritDoc */
  updateSource(changes={}, options={}) {
    this._prepareDeltaUpdate(changes, options);
    return super.updateSource(changes, options);
  }

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

  /** @inheritDoc */
  clone(data={}, context={}) {
    const clone = super.clone(data, context);
    if ( (clone instanceof Promise) || clone.actorLink ) return clone;
    // Extra care needs to be taken when temporarily cloning an unlinked TokenDocument.
    // Preparation of the clone's synthetic Actor using the embedded ActorDelta can easily enter an infinite loop.
    // In this case we need to eagerly evaluate the clone ActorDelta instance so it is available immediately.
    clone.delta; // Resolve lazy getter
    return clone;
  }

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

  /**
   * Get the snapped position of the Token.
   * @param {Partial<ElevatedPoint & TokenDimensions>} [data] The position and dimensions
   * @returns {ElevatedPoint}                                 The snapped position
   */
  getSnappedPosition(data={}) {
    const grid = this.parent?.grid ?? BaseScene.defaultGrid;
    const x = data.x ?? this.x;
    const y = data.y ?? this.y;
    let elevation = data.elevation ?? this.elevation;
    const unsnapped = {x, y, elevation};

    // Gridless grid
    if ( grid.isGridless ) return unsnapped;

    // Get position and elevation
    elevation = Math.round(elevation / grid.distance) * grid.distance;

    // Round width and height to nearest multiple of 0.5
    const width = Math.round((data.width ?? this.width) * 2) / 2;
    const height = Math.round((data.height ?? this.height) * 2) / 2;
    const shape = data.shape ?? this.shape;

    // Square grid
    let snapped;
    if ( grid.isSquare ) snapped = BaseToken.#getSnappedPositionInSquareGrid(grid, unsnapped, width, height);

    // Hexagonal grid
    else snapped = BaseToken.#getSnappedPositionInHexagonalGrid(grid, unsnapped, width, height, shape);
    return {x: snapped.x, y: snapped.y, elevation};
  }

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

  /**
   * Get the snapped position on a square grid.
   * @param {SquareGrid} grid     The square grid
   * @param {Point} position      The position that is snapped or grid offset
   * @param {number} width        The width in grid spaces (positive)
   * @param {number} height       The height in grid spaces (positive)
   * @returns {Point}             The snapped position
   */
  static #getSnappedPositionInSquareGrid(grid, position, width, height) {

    // Small tokens snap to any vertex of the subgrid with resolution 4
    // where the token is fully contained within the grid space
    const isSmall = ((width === 0.5) && (height <= 1)) || ((width <= 1) && (height === 0.5));
    if ( isSmall ) {
      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 M = GRID_SNAPPING_MODES;
    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 on a hexagonal grid.
   * @param {SquareGrid} grid       The hexagonal grid
   * @param {Point} position        The position that is snapped or grid offset
   * @param {number} width          The width in grid spaces (positive)
   * @param {number} height         The height in grid spaces (positive)
   * @param {TokenShapeType} shape  The shape (one of {@link CONST.TOKEN_SHAPES})
   * @returns {Point}               The snapped position
   */
  static #getSnappedPositionInHexagonalGrid(grid, position, width, height, shape) {

    // Hexagonal shape
    const hexagonalShape = BaseToken.#getHexagonalShape(width, height, shape, grid.columns);
    if ( hexagonalShape ) {
      const offsetX = hexagonalShape.anchor.x * grid.sizeX;
      const offsetY = hexagonalShape.anchor.y * grid.sizeY;
      position = grid.getCenterPoint({x: position.x + offsetX, y: position.y + offsetY});
      position.x -= offsetX;
      position.y -= offsetY;
      return position;
    }

    // Rectagular shape
    const M = GRID_SNAPPING_MODES;
    return grid.getSnappedPoint(position, {mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT});
  }

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

  /**
   * Get the top-left grid offset of the Token.
   * @param {Partial<ElevatedPoint & TokenDimensions>} [data]      The position and dimensions
   * @returns {GridOffset3D}                                       The top-left grid offset
   * @internal
   */
  _positionToGridOffset(data={}) {
    let x = Math.round(data.x ?? this.x);
    let y = Math.round(data.y ?? this.y);
    const elevation = data.elevation ?? this.elevation;

    // Gridless grid
    const grid = this.parent?.grid ?? BaseScene.defaultGrid;
    if ( grid.isGridless ) return grid.getOffset({x, y, elevation});

    // Round width and height to nearest multiple of 0.5
    const width = Math.round((data.width ?? this.width) * 2) / 2;
    const height = Math.round((data.height ?? this.height) * 2) / 2;

    // Square grid
    if ( grid.isSquare ) {
      x += (grid.size * (Number.isInteger(width) ? 0.5 : 0.25));
      y += (grid.size * (Number.isInteger(height) ? 0.5 : 0.25));
    }

    // Hexagonal grid
    else {
      const {anchor} = BaseToken._getHexagonalOffsets(width, height, data.shape ?? this.shape, grid.columns);
      x += (grid.sizeX * anchor.x);
      y += (grid.sizeY * anchor.y);
    }

    return grid.getOffset({x, y, elevation});
  }

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

  /**
   * Get the position of the Token from the top-left grid offset.
   * @param {GridOffset3D } offset                 The top-left grid offset
   * @param {Partial<TokenDimensions>} [data]      The dimensions that override the current dimensions
   * @returns {ElevatedPoint}                      The snapped position
   * @internal
   */
  _gridOffsetToPosition(offset, data={}) {
    const grid = this.parent?.grid ?? BaseScene.defaultGrid;

    // Gridless grid
    if ( grid.isGridless ) return {x: offset.j, y: offset.i, elevation: offset.k / grid.size * grid.distance};

    // Round width and height to nearest multiple of 0.5
    const width = Math.round((data.width ?? this.width) * 2) / 2;
    const height = Math.round((data.height ?? this.height) * 2) / 2;

    let x;
    let y;

    // Square grid
    if ( grid.isSquare ) {
      x = offset.j;
      y = offset.i;
      const isSmall = ((width === 0.5) && (height <= 1)) || ((width <= 1) && (height === 0.5));
      if ( isSmall ) {
        if ( width === 0.5 ) x += 0.25;
        if ( height === 0.5 ) y += 0.25;
      }
      x *= grid.size;
      y *= grid.size;
    }

    // Hexagonal grid
    else {
      const {anchor} = BaseToken._getHexagonalOffsets(width, height, data.shape ?? this.shape, grid.columns);
      const position = grid.getCenterPoint(offset);
      x = position.x - (anchor.x * grid.sizeX);
      y = position.y - (anchor.y * grid.sizeY);
    }

    return {x, y, elevation: offset.k * grid.distance};
  }

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

  /**
   * Get the width and height of the Token in pixels.
   * @param {Partial<{width: number; height: number}>} [data] The width and/or height in grid units (must be positive)
   * @returns {{width: number; height: number}} The width and height in pixels
   */
  getSize(data={}) {
    let width = data.width ?? this.width;
    let height = data.height ?? this.height;

    const grid = this.parent?.grid ?? BaseScene.defaultGrid;
    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 center point of the Token.
   * @param {Partial<ElevatedPoint & TokenDimensions>} [data] The position and dimensions
   * @returns {ElevatedPoint}                                 The center point
   */
  getCenterPoint(data={}) {
    const x = data.x ?? this.x;
    const y = data.y ?? this.y;
    const elevation = data.elevation ?? this.elevation;

    // Hexagonal shape
    const grid = this.parent?.grid ?? BaseScene.defaultGrid;
    if ( grid.isHexagonal ) {
      const width = data.width ?? this.width;
      const height = data.height ?? this.height;
      const hexagonalShape = BaseToken.#getHexagonalShape(width, height, data.shape ?? this.shape, grid.columns);
      if ( hexagonalShape ) {
        const center = hexagonalShape.center;
        return {x: x + (center.x * grid.sizeX), y: y + (center.y * grid.sizeY), elevation};
      }

      // No hexagonal shape for this combination of shape type, width, and height.
      // Fallback to the center of the rectangle.
    }

    // Rectangular shape
    const {width, height} = this.getSize(data);
    return {x: x + (width / 2), y: y + (height / 2), elevation};
  }

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

  /**
   * Get the grid space polygon of the Token.
   * Returns undefined in gridless grids because there are no grid spaces.
   * @param {Partial<TokenDimensions>} [data] The dimensions
   * @returns {Point[]|void}                  The grid space polygon or undefined if gridless
   */
  getGridSpacePolygon(data={}) {
    const grid = this.parent?.grid ?? BaseScene.defaultGrid;

    // Gridless grid
    if ( grid.isGridless ) return;

    // Hexagonal shape
    if ( grid.isHexagonal ) {
      const width = data.width ?? this.width;
      const height = data.height ?? this.height;
      const hexagonalShape = BaseToken.#getHexagonalShape(width, height, data.shape ?? this.shape, grid.columns);
      if ( hexagonalShape ) {
        const points = [];
        for ( let i = 0; i < hexagonalShape.points.length; i += 2 ) {
          points.push({x: hexagonalShape.points[i] * grid.sizeX, y: hexagonalShape.points[i + 1] * grid.sizeY});
        }
        return points;
      }

      // No hexagonal shape for this combination of shape type, width, and height.
      // Fallback to rectangular shape.
    }

    // Rectangular shape
    const {width, height} = this.getSize(data);
    return [{x: 0, y: 0}, {x: width, y: 0}, {x: width, y: height}, {x: 0, y: height}];
  }

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

  /**
   * Get the offsets of grid spaces that are occupied by this Token at the current or given position.
   * The grid spaces the Token occupies are those that are covered by the Token's shape in the snapped position.
   * Returns an empty array in gridless grids.
   * @param {Partial<Point & TokenDimensions>} [data] The position and dimensions
   * @returns {GridOffset2D[]}                        The offsets of occupied grid spaces
   */
  getOccupiedGridSpaceOffsets(data={}) {
    const offsets = [];
    const grid = this.parent?.grid ?? BaseScene.defaultGrid;

    // No grid spaces in gridless grids
    if ( grid.isGridless ) return offsets;

    // Get the top-left grid offset
    const {i: i0, j: j0} = this._positionToGridOffset(data);

    // Round width and height to nearest multiple of 0.5
    const width = Math.round((data.width ?? this.width) * 2) / 2;
    const height = Math.round((data.height ?? this.height) * 2) / 2;

    // Square grid
    if ( grid.isSquare ) {
      const i1 = i0 + Math.ceil(height);
      const j1 = j0 + Math.ceil(width);
      for ( let i = i0; i < i1; i++ ) {
        for ( let j = j0; j < j1; j++ ) {
          offsets.push({i, j});
        }
      }
    }

    // Hexagonal grid
    else {
      const {even: offsetsEven, odd: offsetsOdd} = BaseToken._getHexagonalOffsets(
        width, height, data.shape ?? this.shape, grid.columns);
      const isEven = ((grid.columns ? j0 : i0) % 2 === 0) === grid.even;
      for ( const {i: di, j: dj} of (isEven ? offsetsEven : offsetsOdd) ) {
        offsets.push({i: i0 + di, j: j0 + dj});
      }
    }

    return offsets;
  }

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

  /**
   * The cache of hexagonal offsets.
   * @type {Map<string, DeepReadonly<TokenHexagonalOffsetsData>>}
   */
  static #hexagonalOffsets = new Map();

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

  /**
   * Get the hexagonal offsets given the type, width, and height.
   * @param {number} width                                 The width of the Token (positive)
   * @param {number} height                                The height of the Token (positive)
   * @param {TokenShapeType} shape                         The shape (one of {@link CONST.TOKEN_SHAPES})
   * @param {boolean} columns                              Column-based instead of row-based hexagonal grid?
   * @returns {DeepReadonly<TokenHexagonalOffsetsData>}    The hexagonal offsets
   * @internal
   */
  static _getHexagonalOffsets(width, height, shape, columns) {
    // TODO: can we set a max of 2^13 on width and height so that we may use an integer key?
    const key = `${width},${height},${shape}${columns ? "C" : "R"}`;
    let offsets = BaseToken.#hexagonalOffsets.get(key);
    if ( offsets ) return offsets;

    let anchor;
    let data = BaseToken.#getHexagonalShape(width, height, shape, columns);

    // Hexagonal shape
    if ( data ) anchor = data.anchor;

    // Fallback for non-hexagonal shapes
    else {
      if ( columns ) {
        height += 0.5;
        width = Math.round(width);
        if ( width === 1 ) height = Math.floor(height);
        else if ( height === 1 ) height += 0.5;
      } else {
        width += 0.5;
        height = Math.round(height);
        if ( height === 1 ) width = Math.floor(width);
        else if ( width === 1 ) width += 0.5;
      }
      data = BaseToken.#getHexagonalShape(width, height, TOKEN_SHAPES.RECTANGLE_1, columns);
      anchor = {x: data.anchor.x - 0.25, y: data.anchor.y - 0.25};
    }

    // Cache the offsets
    offsets = foundry.utils.deepFreeze({
      even: data.offsets.even,
      odd: data.offsets.odd,
      anchor
    });
    BaseToken.#hexagonalOffsets.set(key, offsets);
    return offsets;
  }

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

  /**
   * The cache of hexagonal shapes.
   * @type {Map<string, DeepReadonly<TokenHexagonalShapeData>>}
   */
  static #hexagonalShapes = new Map();

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

  /**
   * Get the hexagonal shape given the type, width, and height.
   * @param {number} width                                    The width of the Token (positive)
   * @param {number} height                                   The height of the Token (positive)
   * @param {TokenShapeType} shape                            The shape (one of {@link CONST.TOKEN_SHAPES})
   * @param {boolean} columns                                 Column-based instead of row-based hexagonal grid?
   * @returns {DeepReadonly<TokenHexagonalShapeData>|null}    The hexagonal shape or null if there is no shape
   *                                                          for the given combination of arguments
   */
  static #getHexagonalShape(width, height, shape, columns) {
    if ( !Number.isInteger(width * 2) || !Number.isInteger(height * 2) ) return null;

    // TODO: can we set a max of 2^13 on width and height so that we may use an integer key?
    const key = `${width},${height},${shape}${columns ? "C" : "R"}`;
    let data = BaseToken.#hexagonalShapes.get(key);
    if ( data ) return data;

    // Hexagon symmetry
    if ( columns ) {
      const rowData = BaseToken.#getHexagonalShape(height, width, shape, false);
      if ( !rowData ) return null;

      // Transpose the offsets/points of the shape in row orientation
      const offsets = {even: [], odd: []};
      for ( const {i, j} of rowData.offsets.even ) offsets.even.push({i: j, j: i});
      for ( const {i, j} of rowData.offsets.odd ) offsets.odd.push({i: j, j: i});
      offsets.even.sort(({i: i0, j: j0}, {i: i1, j: j1}) => (j0 - j1) || (i0 - i1));
      offsets.odd.sort(({i: i0, j: j0}, {i: i1, j: j1}) => (j0 - j1) || (i0 - i1));
      const points = [];
      for ( let i = rowData.points.length; i > 0; i -= 2 ) {
        points.push(rowData.points[i - 1], rowData.points[i - 2]);
      }
      data = {
        offsets,
        points,
        center: {x: rowData.center.y, y: rowData.center.x},
        anchor: {x: rowData.anchor.y, y: rowData.anchor.x}
      };
    }

    // Small hexagon
    else if ( (width === 0.5) && (height === 0.5) ) {
      data = {
        offsets: {even: [{i: 0, j: 0}], odd: [{i: 0, j: 0}]},
        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},
        anchor: {x: 0.25, y: 0.25}
      };
    }

    // Normal hexagon
    else if ( (width === 1) && (height === 1) ) {
      data = {
        offsets: {even: [{i: 0, j: 0}], odd: [{i: 0, j: 0}]},
        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},
        anchor: {x: 0.5, y: 0.5}
      };
    }

    // Hexagonal ellipse or trapezoid
    else if ( shape <= TOKEN_SHAPES.TRAPEZOID_2 ) {
      data = BaseToken.#createHexagonalEllipseOrTrapezoid(width, height, shape);
    }

    // Hexagonal rectangle
    else if ( shape <= TOKEN_SHAPES.RECTANGLE_2 ) {
      data = BaseToken.#createHexagonalRectangle(width, height, shape);
    }

    // Cache the shape
    if ( data ) {
      foundry.utils.deepFreeze(data);
      BaseToken.#hexagonalShapes.set(key, data);
    }
    return data;
  }

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

  /**
   * Create the row-based hexagonal ellipse/trapezoid given the type, width, and height.
   * @param {number} width                   The width of the Token (positive)
   * @param {number} height                  The height of the Token (positive)
   * @param {number} shape                   The shape type (must be ELLIPSE_1, ELLIPSE_1, TRAPEZOID_1, or TRAPEZOID_2)
   * @returns {TokenHexagonalShapeData|null} The hexagonal shape or null if there is no shape for the given combination
   *                                         of arguments
   */
  static #createHexagonalEllipseOrTrapezoid(width, height, shape) {
    if ( !Number.isInteger(width) || !Number.isInteger(height) ) return null;
    const points = [];
    let top;
    let bottom;
    switch ( shape ) {
      case TOKEN_SHAPES.ELLIPSE_1:
        if ( height >= 2 * width ) return null;
        top = Math.floor(height / 2);
        bottom = Math.floor((height - 1) / 2);
        break;
      case TOKEN_SHAPES.ELLIPSE_2:
        if ( height >= 2 * width ) return null;
        top = Math.floor((height - 1) / 2);
        bottom = Math.floor(height / 2);
        break;
      case TOKEN_SHAPES.TRAPEZOID_1:
        if ( height > width ) return null;
        top = height - 1;
        bottom = 0;
        break;
      case TOKEN_SHAPES.TRAPEZOID_2:
        if ( height > width ) return null;
        top = 0;
        bottom = height - 1;
        break;
    }
    const offsets = {even: [], odd: []};
    for ( let i = bottom; i > 0; i-- ) {
      for ( let j = 0; j < width - i; j++ ) {
        offsets.even.push({i: bottom - i, j: j + (((bottom & 1) + i + 1) >> 1)});
        offsets.odd.push({i: bottom - i, j: j + (((bottom & 1) + i) >> 1)});
      }
    }
    for ( let i = 0; i <= top; i++ ) {
      for ( let j = 0; j < width - i; j++ ) {
        offsets.even.push({i: bottom + i, j: j + (((bottom & 1) + i + 1) >> 1)});
        offsets.odd.push({i: bottom + i, j: j + (((bottom & 1) + i) >> 1)});
      }
    }
    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 {
      offsets,
      points,
      // We use the centroid of the polygon for ellipse and trapzoid shapes
      center: foundry.utils.polygonCentroid(points),
      anchor: bottom % 2 ? {x: 0.0, y: 0.5} : {x: 0.5, y: 0.5}
    };
  }

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

  /**
   * Create the row-based hexagonal rectangle given the type, width, and height.
   * @param {number} width                      The width of the Token (positive)
   * @param {number} height                     The height of the Token (positive)
   * @param {TokenShapeType} shape              The shape type (must be RECTANGLE_1 or RECTANGLE_2)
   * @returns {TokenHexagonalShapeData|null}    The hexagonal shape or null if there is no shape
   *                                            for the given combination of arguments
   */
  static #createHexagonalRectangle(width, height, shape) {
    if ( (width < 1) || !Number.isInteger(height) ) return null;
    if ( (width === 1) && (height > 1) ) return null;
    if ( !Number.isInteger(width) && (height === 1) ) return null;
    const even = (shape === TOKEN_SHAPES.RECTANGLE_1) || (height === 1);
    const offsets = {even: [], odd: []};
    for ( let i = 0; i < height; i++) {
      const j0 = even ? 0 : (i + 1) & 1;
      const j1 = ((width + ((i & 1) * 0.5)) | 0) - (even ? (i & 1) : 0);
      for ( let j = j0; j < j1; j++ ) {
        offsets.even.push({i, j: j + (i & 1)});
        offsets.odd.push({i, j});
      }
    }
    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 {
      offsets,
      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
      },
      anchor: even ? {x: 0.5, y: 0.5} : {x: 0.0, y: 0.5}
    };
  }

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

  /** @inheritDoc */
  getUserLevel(user) {
    if ( this.actor ) return this.actor.getUserLevel(user);
    return super.getUserLevel(user);
  }

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

  /** @inheritdoc */
  toObject(source=true) {
    const obj = super.toObject(source);
    obj.delta = obj.actorLink ? null : this.delta.toObject(source);
    return obj;
  }

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

  /** @inheritDoc */
  static migrateData(data) {
    // Remember that any migrations defined here may also be required for the PrototypeToken model.
    /**
     * Migration of hexagonalShape to shape.
     * @deprecated since v13
     */
    if ( ("hexagonalShape" in data) && !("shape" in data) ) {
      data.shape = data.hexagonalShape;
      delete data.hexagonalShape;
    }
    return super.migrateData(data);
  }

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

  /** @inheritdoc */
  static shimData(data, options) {
    // Remember that any shims defined here may also be required for the PrototypeToken model.
    this._addDataFieldShim(data, "effects", undefined, {value: [], since: 12, until: 14,
      warning: "TokenDocument#effects is deprecated in favor of using ActiveEffect documents on the associated Actor"});
    this._addDataFieldShim(data, "overlayEffect", undefined, {value: "", since: 12, until: 14,
      warning: "TokenDocument#overlayEffect is deprecated in favor of using"
        + " ActiveEffect documents on the associated Actor"});
    this._addDataFieldShim(data, "hexagonalShape", "shape", {since: 13, until: 15,
      warning: "TokenDocument#hexagonalShape is deprecated in favor of TokenDocument#shape."});
    return super.shimData(data, options);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get effects() {
    foundry.utils.logCompatibilityWarning("TokenDocument#effects is deprecated in favor of using ActiveEffect"
      + " documents on the associated Actor", {since: 12, until: 14, once: true});
    return [];
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  get overlayEffect() {
    foundry.utils.logCompatibilityWarning("TokenDocument#overlayEffect is deprecated in favor of using"
      + " ActiveEffect documents on the associated Actor", {since: 12, until: 14, once: true});
    return "";
  }

  /**
   * @deprecated since v13
   * @ignore
   */
  get hexagonalShape() {
    foundry.utils.logCompatibilityWarning("TokenDocument#hexagonalShape is deprecated in favor of TokenDocument#shape.",
      {since: 13, until: 15, once: true});
    return this.shape;
  }
}

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

/**
 * A special subclass of EmbeddedDocumentField which allows construction of the ActorDelta to be lazily evaluated.
 */
class ActorDeltaField extends EmbeddedDocumentField {
  /** @inheritdoc */
  initialize(value, model, options = {}) {
    if ( !value ) return value;
    const descriptor = Object.getOwnPropertyDescriptor(model, this.name);
    if ( (descriptor === undefined) || (!descriptor.get && !descriptor.value) ) {
      return () => {
        const m = new this.model(value, {...options, parent: model, parentCollection: this.name});
        Object.defineProperty(m, "schema", {value: this});
        Object.defineProperty(model, this.name, {
          value: m,
          configurable: true,
          writable: true
        });
        return m;
      };
    }
    else if ( descriptor.get instanceof Function ) return descriptor.get;
    model[this.name]._initialize(options);
    return model[this.name];
  }

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

  /** @inheritDoc */
  _updateCommit(source, key, value, diff, options) {
    super._updateCommit(source, key, value, diff, options);
    options._deltaModel?.updateSyntheticActor?.();
  }
}

/**
 * @import {WallData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 */

/**
 * The Wall Document.
 * Defines the DataSchema and common behaviors for a Wall which are shared between both client and server.
 * @extends {Document<WallData>}
 * @mixes WallData
 * @category Documents
 */
class BaseWall extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Wall",
    collection: "walls",
    label: "DOCUMENT.Wall",
    labelPlural: "DOCUMENT.Walls",
    permissions: {
      update: this.#canUpdate
    },
    schemaVersion: "13.341"
  }, {inplace: false}));

  /** @inheritDoc */
  static defineSchema() {
    const choices = [
      ["senseTypes", WALL_SENSE_TYPES, "SenseTypes"],
      ["moveTypes", WALL_MOVEMENT_TYPES, "SenseTypes"],
      ["directions", WALL_DIRECTIONS, "Directions"],
      ["doorTypes", WALL_DOOR_TYPES, "DoorTypes"],
      ["doorStates", WALL_DOOR_STATES, "DoorStates"]
    ].reduce((outer, [key, record, labelObj]) => {
      outer[key] = Object.entries(record).reduce((inner, [labelKey, value]) => {
        inner[value] = `WALL.${labelObj}.${labelKey}`;
        return inner;
      }, {});
      return outer;
    }, {});
    return {
      _id: new DocumentIdField(),
      c: new ArrayField(new NumberField({required: true, integer: true, nullable: false}), {
        validate: c => (c.length === 4),
        validationError: "must be a length-4 array of integer coordinates"}),
      light: new NumberField({required: true, choices: choices.senseTypes,
        initial: WALL_SENSE_TYPES.NORMAL,
        validationError: "must be a value in CONST.WALL_SENSE_TYPES"}),
      move: new NumberField({required: true, choices: choices.moveTypes,
        initial: WALL_MOVEMENT_TYPES.NORMAL,
        validationError: "must be a value in CONST.WALL_MOVEMENT_TYPES"}),
      sight: new NumberField({required: true, choices: choices.senseTypes,
        initial: WALL_SENSE_TYPES.NORMAL,
        validationError: "must be a value in CONST.WALL_SENSE_TYPES"}),
      sound: new NumberField({required: true, choices: choices.senseTypes,
        initial: WALL_SENSE_TYPES.NORMAL,
        validationError: "must be a value in CONST.WALL_SENSE_TYPES"}),
      dir: new NumberField({required: true, choices: choices.directions,
        initial: WALL_DIRECTIONS.BOTH,
        validationError: "must be a value in CONST.WALL_DIRECTIONS"}),
      door: new NumberField({required: true, choices: choices.doorTypes,
        initial: WALL_DOOR_TYPES.NONE,
        validationError: "must be a value in CONST.WALL_DOOR_TYPES"}),
      ds: new NumberField({required: true, choices: choices.doorStates,
        initial: WALL_DOOR_STATES.CLOSED,
        validationError: "must be a value in CONST.WALL_DOOR_STATES"}),
      doorSound: new StringField({required: false, blank: true, initial: undefined}),
      threshold: new SchemaField({
        light: new NumberField({required: true, nullable: true, initial: null, positive: true}),
        sight: new NumberField({required: true, nullable: true, initial: null, positive: true}),
        sound: new NumberField({required: true, nullable: true, initial: null, positive: true}),
        attenuation: new BooleanField()
      }),
      animation: new SchemaField({
        direction: new NumberField({choices: [-1, 1], initial: 1}),
        double: new BooleanField({initial: false}),
        duration: new NumberField({positive: true, integer: true, initial: 750}),
        flip: new BooleanField({initial: false}),
        strength: new NumberField({initial: 1.0, min: 0, max: 2.0, step: 0.05}),
        texture: new FilePathField({categories: ["IMAGE"], virtual: true}),
        type: new StringField({initial: "swing", blank: true}) // Allow any value to be persisted
      }, {required: true, nullable: true, initial: null}),
      flags: new DocumentFlagsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "WALL"];

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

  /**
   * Is a user able to update an existing Wall?
   * @type {DocumentPermissionTest}
   */
  static #canUpdate(user, doc, data) {
    if ( user.isGM ) return true;                     // GM users can do anything
    const dsOnly = Object.keys(data).every(k => ["_id", "ds"].includes(k));
    if ( dsOnly && (doc.ds !== WALL_DOOR_STATES.LOCKED) && (data.ds !== WALL_DOOR_STATES.LOCKED) ) {
      return user.hasRole("PLAYER");                  // Players may open and close unlocked doors
    }
    return false;
  }
}

/**
 * @import {ElevatedPoint, REGION_MOVEMENT_SEGMENTS, RegionMovementSegmentType, TOKEN_SHAPES,
 *   TokenShapeType} from "@common/constants.mjs";
 * @import {EffectDurationData,  TokenPosition, TokenMovementWaypoint} from "@client/documents/_types.mjs";
 * @import Roll from "@client/dice/roll.mjs";
 * @import {GridMeasurePathCostFunction3D, GridOffset3D} from "@common/grid/_types.mjs";
 * @import {DataModel, Document} from "@common/abstract/_module.mjs";
 * @import {DeepReadonly, TokenConstrainMovementPathOptions, TokenMovementActionConfig} from "../_types.mjs";
 * @import {Combat, Combatant, Folder, RegionDocument, TableResult, TokenDocument, User} from "./_module.mjs";
 */

/**
 * @typedef AdventureImportData
 * The data that is planned to be imported for the adventure, categorized into new documents that will be created and
 * existing documents that will be updated.
 * @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
 */

/**
 * @callback AdventurePreImportCallback
 * A callback function that is invoked and awaited during import data preparation before the adventure import proceeds.
 * This can be used to perform custom pre-processing on the import data.
 * @param {AdventureImportData} data
 * @param {AdventureImportOptions} options
 * @returns {Promise<void>}
 */

/**
 * @typedef AdventureImportOptions
 * Options which customize how the adventure import process is orchestrated.
 * Modules can use the preImportAdventure hook to extend these options by adding preImport or postImport callbacks.
 * @property {boolean} [dialog=true]                Display a warning dialog if existing documents would be overwritten
 * @property {string[]} [importFields]              A subset of adventure fields to import
 * @property {AdventurePreImportCallback[]} [preImport]   An array of awaited pre-import callbacks
 * @property {AdventurePostImportCallback[]} [postImport] An array of awaited post-import callbacks
 */

/**
 * @typedef AdventureImportResult
 * A report of the world Document instances that were created or updated during the import process.
 * @property {Record<string, Document[]>} created Documents created as a result of the import, grouped by document name
 * @property {Record<string, Document[]>} updated Documents updated as a result of the import, grouped by document name
 */

/**
 * @callback AdventurePostImportCallback
 * A callback function that is invoked and awaited after import but before the overall import workflow concludes.
 * This can be used to perform additional custom adventure setup steps.
 * @param {AdventureImportResult} result
 * @param {AdventureImportOptions} options
 * @returns {Promise<void>}
 */

/**
 * @typedef _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
 */

/**
 * @typedef {EffectDurationData & _ActiveEffectDuration} ActiveEffectDuration
 */

/**
 * @typedef FolderChildNode
 * A node of a Folder-content tree
 * @property {boolean} root               Whether this is the root node of a tree
 * @property {Folder} folder              The Folder document represented by this node
 * @property {number} depth               This node's depth number in the tree
 * @property {boolean} visible            Whether the Folder is visible to the current User
 * @property {FolderChildNode[]} children Child nodes of this node
 * @property {Document[]|CompendiumCollection[]} entries Loose contents in this node
 */

/**
 * @typedef CombatHistoryData
 * @property {number} round
 * @property {number|null} turn
 * @property {string|null} tokenId
 * @property {string|null} combatantId
 */

/**
 * @typedef CombatTurnEventContext
 * @property {number} round       The round
 * @property {number} turn        The turn
 * @property {boolean} skipped    Was skipped?
 */

/**
 * @typedef {Omit<CombatTurnEventContext, "turn">} CombatRoundEventContext
 */

/**
 * @template [Data=object]
 * @typedef 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 {RegionEvent<{}>} RegionRegionBoundaryEvent
 * @typedef {RegionEvent<{}>} RegionBehaviorActivatedEvent
 * @typedef {RegionEvent<{}>} RegionBehaviorDeactivatedEvent
 * @typedef {RegionEvent<{}>} RegionBehaviorViewedEvent
 * @typedef {RegionEvent<{}>} RegionBehaviorUnviewedEvent
 */

/**
 * @typedef RegionTokenEnterExitEventData
 * @property {TokenDocument} token                  The Token that entered/exited the Region
 * @property {TokenMovementOperation|null} movement The movement if the Token entered/exited by moving out of the Region
 *
 * @typedef {RegionEvent<RegionTokenEnterExitEventData>} RegionTokenEnterExitEvent
 * @typedef {RegionTokenEnterExitEvent} RegionTokenEnterEvent
 * @typedef {RegionTokenEnterExitEvent} RegionTokenExitEvent
 */

/**
 * @typedef RegionTokenMoveEventData
 * @property {TokenDocument} token                The Token that moved into/out of/within the Region
 * @property {TokenMovementOperation} movement    The movement
 *
 * @typedef {RegionEvent<RegionTokenMoveEventData>} RegionTokenMoveEvent
 * @typedef {RegionTokenMoveEvent} RegionTokenMoveInEvent
 * @typedef {RegionTokenMoveEvent} RegionTokenMoveOutEvent
 * @typedef {RegionTokenMoveEvent} RegionTokenMoveWithinEvent
 */

/**
 * @typedef RegionTokenAnimateEventData
 * @property {TokenDocument} token       The Token that animated into/out of the Region
 * @property {TokenPosition} position    The position of the Token when it moved into/out of the Region
 *
 * @typedef {RegionEvent<RegionTokenAnimateEventData>} RegionTokenAnimateEvent
 * @typedef {RegionTokenAnimateEvent} RegionTokenAnimateInEvent
 * @typedef {RegionTokenAnimateEvent} RegionTokenAnimateOutEvent
 */

/**
 * @typedef RegionTokenTurnEventData
 * @property {TokenDocument} token    The Token that started/ended its Combat turn
 * @property {Combatant} combatant    The Combatant of the Token that started/ended its Combat turn
 * @property {Combat} combat          The Combat
 * @property {number} round           The round of this turn
 * @property {number} turn            The turn that started/ended
 * @property {boolean} skipped        Was the turn skipped?
 *
 * @typedef {RegionEvent<RegionTokenTurnEventData>} RegionTokenTurnEvent
 * @typedef {RegionTokenTurnEvent} RegionTokenTurnStartEvent
 * @typedef {RegionTokenTurnEvent} RegionTokenTurnEndEvent
 */

/**
 * @typedef RegionTokenRoundEventData
 * @property {TokenDocument} token    The Token
 * @property {Combatant} combatant    The Combatant of the Token
 * @property {Combat} combat          The Combat
 * @property {number} round           The round that started/ended
 * @property {boolean} skipped        Was the round skipped?
 *
 * @typedef {RegionEvent<RegionTokenRoundEventData>} RegionTokenRoundEvent
 * @typedef {RegionTokenRoundEvent} RegionTokenRoundStartEvent
 * @typedef {RegionTokenRoundEvent} RegionTokenRoundEndEvent
 */

/**
 * @typedef RegionMovementSegment
 * @property {RegionMovementSegmentType} type   The type of this segment (see {@link CONST.REGION_MOVEMENT_SEGMENTS}).
 * @property {ElevatedPoint} from               The waypoint that this segment starts from.
 * @property {ElevatedPoint} to                 The waypoint that this segment goes to.
 * @property {boolean} teleport                 Teleport between the waypoints?
 */

/**
 * @typedef RegionSegmentizeMovementPathWaypoint
 * @property {number} x                         The x-coordinate in pixels (integer).
 * @property {number} y                         The y-coordinate in pixels (integer).
 * @property {number} elevation                 The elevation in grid units.
 * @property {boolean} [teleport=false]         Teleport from the previous to this waypoint? Default: `false`.
 */

/**
 * @typedef 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
 */

/**
 * @typedef SceneDimensions
 * @property {number} width        The width of the canvas.
 * @property {number} height       The height of the canvas.
 * @property {number} size         The grid size.
 * @property {PIXI.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 {PIXI.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.
 */

/**
 * @typedef 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.
 */

/**
 * @typedef TokenMeasuredMovementWaypoint
 * @property {number} x                  The top-left x-coordinate in pixels (integer).
 * @property {number} y                  The top-left y-coordinate in pixels (integer).
 * @property {number} elevation          The elevation in grid units.
 * @property {number} width              The width in grid spaces (positive).
 * @property {number} height             The height in grid spaces (positive).
 * @property {TokenShapeType} shape      The shape type (see {@link CONST.TOKEN_SHAPES}).
 * @property {string} action             The movement action from the previous to this waypoint.
 * @property {DataModel|null} terrain    The terrain data from the previous to this waypoint.
 * @property {boolean} snapped           Was this waypoint snapped to the grid?
 * @property {boolean} explicit          Was this waypoint explicitly placed by the user?
 * @property {boolean} checkpoint        Is this waypoint a checkpoint?
 * @property {boolean} intermediate      Is this waypoint intermediate?
 * @property {string} userId             The ID of the user that moved the token to from the previous to this waypoint.
 * @property {string} movementId         The ID of the movement from the previous to this waypoint.
 * @property {number} cost               The movement cost from the previous to this waypoint (nonnegative).
 */

/**
 * @typedef {Omit<TokenMeasuredMovementWaypoint, "terrain"|"intermediate"|"userId"|"movementId"
 *   |"cost">} TokenMovementWaypoint
 */

/**
 * @typedef {Pick<TokenMeasuredMovementWaypoint, "width"|"height"|"shape"|"action"|"terrain">
 *   & {actionConfig: TokenMovementActionConfig, teleport: boolean}} TokenMovementSegmentData
 */

/**
 * @typedef TokenMeasureMovementPathWaypoint
 * @property {number} [x]                                 The top-left x-coordinate in pixels (integer).
 *                                                        Default: the previous or source x-coordinate.
 * @property {number} [y]                                 The top-left y-coordinate in pixels (integer).
 *                                                        Default: the previous or source y-coordinate.
 * @property {number} [elevation]                         The elevation in grid units.
 *                                                        Default: the previous or source elevation.
 * @property {number} [width]                             The width in grid spaces (positive).
 *                                                        Default: the previous or source width.
 * @property {number} [height]                            The height in grid spaces (positive).
 *                                                        Default: the previous or source height.
 * @property {TokenShapeType} [shape]                     The shape type (see {@link CONST.TOKEN_SHAPES}).
 *                                                        Default: the previous or source shape.
 * @property {string} [action]                            The movement action from the previous to this waypoint.
 *                                                        Default: the previous or prepared movement action.
 * @property {DataModel|null} [terrain=null]              The terrain data of this segment. Default: `null`.
 * @property {number|TokenMovementCostFunction} [cost]    A predetermined cost (nonnegative) or cost function
 *                                                        to be used instead of `options.cost`.
 */

/**
 * @typedef TokenMeasureMovementPathOptions
 * @property {boolean} [preview=false]    Measure a preview path? Default: `false`.
 */

/**
 * @typedef {GridMeasurePathCostFunction3D<TokenMovementSegmentData>} TokenMovementCostFunction
 */

/**
 * @callback TokenMovementCostAggregator
 * @param {Array<DeepReadonly<{from: GridOffset3D, to: GridOffset3D, cost: number}>>} results
 *                                                           The results of the cost function calls.
 *                                                           The array may be sorted but otherwise not be mutated.
 * @param {number} distance                                  The distance between the grid spaces.
 * @param {DeepReadonly<TokenMovementSegmentData>} segment   The properties of the segment.
 * @returns {number}                                         The aggregated cost.
 */

/**
 * @typedef TokenGetCompleteMovementPathWaypoint
 * @property {number} [x]                       The top-left x-coordinate in pixels (integer).
 *                                              Default: the previous or source x-coordinate.
 * @property {number} [y]                       The top-left y-coordinate in pixels (integer).
 *                                              Default: the previous or source y-coordinate.
 * @property {number} [elevation]               The elevation in grid units.
 *                                              Default: the previous or source elevation.
 * @property {number} [width]                   The width in grid spaces (positive).
 *                                              Default: the previous or source width.
 * @property {number} [height]                  The height in grid spaces (positive).
 *                                              Default: the previous or source height.
 * @property {TokenShapeType} [shape]           The shape type (see {@link CONST.TOKEN_SHAPES}).
 *                                              Default: the previous or source shape.
 * @property {string} [action]                  The movement action from the previous to this waypoint.
 *                                              Default: the previous or prepared movement action.
 * @property {DataModel|null} [terrain=null]    The terrain data of this segment. Default: `null`.
 * @property {boolean} [snapped=false]          Was this waypoint snapped to the grid? Default: `false`.
 * @property {boolean} [explicit=false]         Was this waypoint explicitly placed by the user? Default: `false`.
 * @property {boolean} [checkpoint=false]       Is this waypoint a checkpoint? Default: `false`.
 * @property {boolean} [intermediate=false]     Is this waypoint intermediate? Default: `false`.
 */

/**
 * @typedef {Omit<TokenMeasuredMovementWaypoint, "userId"|"movementId"|"cost">} TokenCompleteMovementWaypoint
 */

/**
 * @typedef TokenSegmentizeMovementWaypoint
 * @property {number} [x]                       The x-coordinate in pixels (integer).
 *                                              Default: the previous or source x-coordinate.
 * @property {number} [y]                       The y-coordinate in pixels (integer).
 *                                              Default: the previous or source y-coordinate.
 * @property {number} [elevation]               The elevation in grid units.
 *                                              Default: the previous or source elevation.
 * @property {number} [width]                   The width in grid spaces (positive).
 *                                              Default: the previous or source width.
 * @property {number} [height]                  The height in grid spaces (positive).
 *                                              Default: the previous or source height.
 * @property {TokenShapeType} [shape]           The shape type (see {@link CONST.TOKEN_SHAPES}).
 *                                              Default: the previous or source shape.
 * @property {string} [action]                  The movement action from the previous to this waypoint.
 *                                              Default: the previous or prepared movement action.
 * @property {DataModel|null} [terrain=null]    The terrain data of this segment. Default: `null`.
 * @property {boolean} [snapped=false]          Was this waypoint snapped to the grid? Default: `false`.
 */

/**
 * @typedef {TokenPosition} TokenRegionMovementWaypoint
 */

/**
 * @typedef TokenRegionMovementSegment
 * @property {RegionMovementSegmentType} type     The type of this segment (see {@link CONST.REGION_MOVEMENT_SEGMENTS}).
 * @property {TokenRegionMovementWaypoint} from   The waypoint that this segment starts from.
 * @property {TokenRegionMovementWaypoint} to     The waypoint that this segment goes to.
 * @property {string} action                      The movement action between the waypoints.
 * @property {DataModel|null} terrain             The terrain data of this segment.
 * @property {boolean} snapped                    Is the destination snapped to the grid?
 */


/**
 * @typedef TokenMovementSectionData
 * @property {TokenMeasuredMovementWaypoint[]} waypoints    The waypoints of the movement path
 * @property {number} distance                              The distance of the movement path
 * @property {number} cost                                  The cost of the movement path
 * @property {number} spaces                                The number of spaces moved along the path
 * @property {number} diagonals                             The number of diagonals moved along the path
 */

/**
 * @typedef TokenMovementHistoryData
 * @property {TokenMovementSectionData} recorded            The recorded waypoints of the movement path
 * @property {TokenMovementSectionData} unrecorded          The unrecored waypoints of the movement path
 * @property {number} distance                              The distance of the combined movement path
 * @property {number} cost                                  The cost of the combined movement path
 * @property {number} spaces                                The number of spaces moved along the combined path
 * @property {number} diagonals                             The number of diagonals moved along the combined path
 */

/**
 * @typedef {"api"|"config"|"dragging"|"keyboard"|"paste"|"undo"} TokenMovementMethod
 */

/**
 * @typedef {"completed"|"paused"|"pending"|"stopped"} TokenMovementState
 */

/**
 * @typedef TokenMovementData
 * @property {string} id         The ID of the movement
 * @property {string[]} chain    The chain of prior movement IDs that this movement is a continuation of
 * @property {TokenPosition} origin                The origin of movement
 * @property {TokenPosition} destination           The destination of movement
 * @property {TokenMovementSectionData} passed     The waypoints and measurements of the passed path
 * @property {TokenMovementSectionData} pending    The waypoints and measurements of the pending path
 * @property {TokenMovementHistoryData} history    The waypoints and measurements of the history path
 * @property {boolean} constrained                 Was the movement constrained?
 * @property {boolean} recorded                    Was the movement recorded in the movement history?
 * @property {TokenMovementMethod} method          The method of movement
 * @property {Omit<TokenConstrainMovementPathOptions, "preview"|"history">} constrainOptions
 *                                         The options to constrain movement
 * @property {boolean} autoRotate          Automatically rotate the token in the direction of movement?
 * @property {boolean} showRuler           Show the ruler during the movement animation of the token?
 * @property {User} user                   The user that moved the token
 * @property {TokenMovementState} state    The state of the movement
 * @property {object} updateOptions        The update options of the movement operation
 */

/**
 * @typedef {Omit<TokenMovementData, "user"|"state"|"updateOptions">} TokenMovementOperation
 */

/**
 * @typedef TokenMovementContinuationData
 * @property {string} movementId                        The movement ID
 * @property {number} continueCounter                   The number of continuations
 * @property {boolean} continued                        Was continued?
 * @property {Promise<boolean>|null} continuePromise    The continuation promise
 * @property {Promise<void>} waitPromise                The promise to wait for before continuing movement
 * @property {() => {}|undefined} resolveWaitPromise    Resolve function of the wait promise
 * @property {Promise<void>} postWorkflowPromise        The promise that resolves after the update workflow
 * @property {{[movementId: string]: {handles: Map<string|symbol, TokenMovementContinuationHandle>;
 *   callbacks: Array<(continued: boolean) => void>; pending: Set<string>}}} states    The movement continuation states
 */

/**
 * @typedef TokenMovementContinuationHandle
 * @property {string} movementId                             The movement ID
 * @property {Promise<boolean>|undefined} continuePromise    The continuation promise
 */

/**
 * @callback TokenResumeMovementCallback
 * @returns {Promise<boolean>}    A promise that resolves to true if the movement was resumed.
 *                                If it wasn't resumed, it resolves to false.
 */

var _types$i = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * @typedef 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.
 */
let Hooks$1 = 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[]>} */
  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(`${CONST.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];
      if ( !event ) return;
      const entry = event.findSplice(h => h.fn === fn);
      if ( !entry ) return;
      this.#ids.delete(entry.id);
    }
    console.debug(`${CONST.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
   */
  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;
    for ( const entry of Array.from(this.#events[hook]) ) {
      this.#call(entry, args);
    }
  }

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

  /**
   * 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]) ) {
      const 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
   */
  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(`${CONST.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]?.(foundry.utils.escapeHTML(msg || error.message));
    Hooks.callAll("error", location, error, data);
  }
};

/**
 * @import {Application, ApplicationV2} from "@client/applications/api/_module.mjs";
 * @import {SearchableField} from "@client/_types.mjs";
 * @import Document from "@common/abstract/document.mjs";
 * @import {DatabaseAction, DatabaseOperation} from "@common/abstract/_types.mjs";
 * @import User from "../user.mjs";
 */

/**
 * An abstract subclass of the Collection container which defines a collection of Document instances.
 * @abstract
 * @category Collections
 * @template {Document} TDocument
 * @extends Collection<string, TDocument>
 */
class DocumentCollection extends Collection {
  /**
   * @param {object[]} data      An array of data objects from which to create document instances
   */
  constructor(data=[]) {
    super();

    /**
     * The source data array from which the Documents in the WorldCollection are created
     * @type {object[]}
     * @internal
     */
    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|ApplicationV2)[]}
     */
    this.apps = [];

    // Initialize data
    this._initialize();
  }

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

  /**
   * Initialize the DocumentCollection by constructing any initially provided Document instances
   * @protected
   */
  _initialize() {
    this.clear();
    for ( const 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$1.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 Document}
   */
  get documentClass() {
    return foundry.utils.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 {TDocument}
   */
  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 {TDocument|void}               An in-memory instance for the invalid Document
   * @throws {Error}                         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 {TDocument}
   * @throws {Error}                           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.
   * @param {boolean} [force=false]     Force rendering
   * @param {object} [options={}]       Optional options
   */
  render(force=false, options={}) {
    for ( const app of this.apps ) {
      const opts = foundry.utils.deepClone(options);
      if ( app instanceof foundry.applications.api.ApplicationV2 ) {
        opts.force = force;
        app.render(opts);
      }
      else app.render(force, opts);
    }
  }

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

  /**
   * The cache of search fields for each data model
   * @type {Map<string, Record<string, SearchableField>>}
   */
  static #dataModelSearchFieldsCache = new Map();

  /**
   * Get the searchable fields for a given document or index, based on its data model
   * @param {string} documentName         The document name
   * @param {string} [type]               A document subtype
   * @returns {Record<string, SearchableField>} A record of searchable DataField definitions
   */
  static getSearchableFields(documentName, type) {
    const searchFields = DocumentCollection.#getSearchableFields(documentName);
    if ( type ) {
      const systemFields = DocumentCollection.#getSearchableFields(documentName, type);
      if ( !foundry.utils.isEmpty(systemFields) ) Object.assign(searchFields, systemFields);
    }
    return searchFields;
  }

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

  /**
   * Identify and cache the searchable fields for a DataModel.
   * @param {string} documentName
   * @param {string} [type]
   * @returns {Record<string, SearchableField>}
   */
  static #getSearchableFields(documentName, type) {
    const isSubtype = !!type;
    const cacheName = isSubtype ? `${documentName}.${type}` : documentName;

    // If this already exists in the cache, return it
    const cached = DocumentCollection.#dataModelSearchFieldsCache.get(cacheName);
    if ( cached ) return cached;

    // Reference the Document model
    const docConfig = CONFIG[documentName];
    if ( !docConfig ) throw new Error(`Could not find configuration for ${documentName}`);
    const model = isSubtype ? docConfig.dataModels?.[type] : docConfig.documentClass;
    if ( !model ) return {};

    // Get fields for the base model
    let searchFields = {};
    model.schema.apply(function() {
      if ( (this instanceof foundry.data.fields.StringField) && this.textSearch ) searchFields[this.fieldPath] = this;
    });
    searchFields = foundry.utils.expandObject(searchFields);
    DocumentCollection.#dataModelSearchFieldsCache.set(cacheName, searchFields);
    return searchFields;
  }

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

  /**
   * 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 {TDocument[]|object[]}
   */
  search({query= "", filters=[], exclude=[]}) {
    query = foundry.applications.ux.SearchFilter.cleanQuery(query);
    const regex = new RegExp(RegExp.escape(query), "i");

    // Iterate over all index members or documents
    const results = [];
    for ( const doc of this.index ?? this.contents ) {
      if ( exclude.includes(doc._id) ) continue; // Explicitly exclude this document
      let matched = !query;

      // Do a full-text search against any searchable fields based on metadata
      if ( query ) {
        const searchFields = DocumentCollection.getSearchableFields(this.documentName, doc.type);
        const match = DocumentCollection.#searchTextFields(doc, searchFields, regex);
        if ( !match ) continue; // Query did not match, no need to continue
        matched = true;
      }

      // Apply filters
      for ( const filter of filters ) {
        const match = foundry.applications.ux.SearchFilter.evaluateFilter(doc, filter);
        if ( !match ) {
          matched = false;
          break; // Filter did not match, no need to continue
        }
      }
      if ( matched ) results.push(doc);
    }
    return results;
  }

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

  /**
   * Recursively search text fields.
   * @param {object} data
   * @param {Record<string, SearchableField>} searchFields
   * @param {RegExp} rgx
   * @param {DOMParser} [domParser]
   */
  static #searchTextFields(data, searchFields, rgx, domParser) {
    for ( const [k, field] of Object.entries(searchFields) ) {
      let v = data[k];
      if ( !v ) continue;
      if ( typeof v === "string" ) {
        if ( field instanceof foundry.data.fields.HTMLField ) {
          domParser ??= new DOMParser();
          // TODO: Ideally we would search the text content of enriched HTML
          v = domParser.parseFromString(v, "text/html").body.textContent;
        }
        if ( foundry.applications.ux.SearchFilter.testQuery(rgx, v) ) return true;
      }
      else if ( Array.isArray(v) ) {
        if ( v.some(x => foundry.applications.ux.SearchFilter.testQuery(rgx, x)) ) return true;
      }
      else if ( typeof v === "object" ) {
        const m = DocumentCollection.#searchTextFields(v, field, rgx, domParser);
        if ( m ) return true;
      }
    }
    return false;
  }

  /* -------------------------------------------- */
  /*  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<TDocument[]>}            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 ( const 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 {TDocument[]} 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 && !operation.parent && documents.length ) {
      this.render(false, {renderContext: `${action}${documents[0].documentName}`, renderData: result});
    }
  }
}

/**
 * @import Collection from "@common/utils/collection.mjs";
 * @import Folder from "../folder.mjs";
 */

/**
 * A mixin which adds directory functionality to a DocumentCollection, such as folders, tree structures, and sorting.
 * @category Mixins
 * @param {typeof Collection} BaseCollection      The base collection class to extend
 */
function DirectoryCollectionMixin(BaseCollection) {

  /**
   * An extension of the Collection class which adds behaviors specific to tree-based collections of entries and
   * folders.
   */
  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() {
      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 = this.sortingMode === "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<*>}
     * @protected
     */
    _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
      const 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) {
      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) {
      return (a.sort ?? 0) - (b.sort ?? 0);
    }

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

    /** @inheritDoc */
    _onModifyContents(action, documents, result, operation, user) {
      if ( !operation.parent ) this.initializeTree();
      super._onModifyContents(action, documents, result, operation, user);
    }
  };
}

/**
 * @import Folder from "../folder.mjs";
 * @import CompendiumCollection from "./compendium-collection.mjs";
 */

/**
 * A Collection of Folder documents within a Compendium pack.
 * @extends {DocumentCollection<Folder>}
 * @category Collections
 */
class CompendiumFolderCollection extends DocumentCollection {
  constructor(pack, data=[]) {
    super(data);
    this.pack = pack;
  }

  /**
   * The CompendiumCollection instance that contains this CompendiumFolderCollection
   * @type {CompendiumCollection}
   */
  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.pack.collection;
    return super.updateAll(transformation, condition, options);
  }

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

  /** @override */
  _onModifyContents(action, documents, result, operation, user) {
    this.pack._onModifyContents(action, documents, result, operation, user);
  }
}

/**
 * @import Folder from "../folder.mjs";
 * @import {ManageCompendiumResponse, WorldCompendiumConfiguration,
 *   WorldCompendiumPackConfiguration} from "@client/_types.mjs";
 * @import WorldCollection from "../abstract/world-collection.mjs";
 * @import Document from "@common/abstract/document.mjs";
 */

/**
 * 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.
 *
 * ### Hook Events
 * - {@link hookEvents.updateCompendium}
 *
 * @template {Document} TDocument
 * @extends {DocumentCollection<TDocument>}
 * @category Collections
 *
 * @see {@link foundry.Game#packs}
 */
class CompendiumCollection extends DirectoryCollectionMixin(DocumentCollection) {
  /**
   * @param {object} metadata   The compendium metadata, an object provided by game.data
   */
  constructor(metadata) {
    super();

    // Cache the world setting if not already populated
    CompendiumCollection.#config ??= foundry.utils.deepClone(
      game.settings.get("core", CompendiumCollection.CONFIG_SETTING)
    );

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

    // Initialize a provided Compendium index
    this.#indexedFields = new Set(this.documentClass.metadata.compendiumIndexFields);
    for ( const i of metadata.index ) {
      i.uuid = this.getUuid(i._id);
      this.index.set(i._id, i);
    }
    delete metadata.index;
    const Folder = foundry.utils.getDocumentClass("Folder");
    for ( const f of metadata.folders.sort((a, b) => a.sort - b.sort) ) {
      this.#folders.set(f._id, new Folder(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 DataField definition for the configuration Setting
   * @type {foundry.data.fields.TypedObjectField}
   */
  static get CONFIG_FIELD() {
    if ( CompendiumCollection.#CONFIG_FIELD ) return CompendiumCollection.#CONFIG_FIELD;
    const ownershipChoices = Object.keys(CONST.DOCUMENT_OWNERSHIP_LEVELS);
    return CompendiumCollection.#CONFIG_FIELD = new TypedObjectField(new SchemaField({
      folder: new StringField({required: true, blank: false, nullable: true,
        validate: f => isValidId(f)}),
      sort: new NumberField({required: false, nullable: false, integer: true, min: 0, initial: undefined}),
      locked: new BooleanField({required: false, initial: undefined}),
      ownership: new SchemaField({
        GAMEMASTER: new StringField({required: true, choices: ["OWNER"], initial: "OWNER"}),
        ASSISTANT: new StringField({required: true, choices: ownershipChoices, initial: "OWNER"}),
        TRUSTED: new StringField({required: true, choices: ownershipChoices, initial: "INHERIT"}),
        PLAYER: new StringField({required: true, choices: ownershipChoices, initial: "INHERIT"})
      }, {required: false, initial: undefined})
    }));
  }

  static #CONFIG_FIELD;

  /**
   * The cached value of the compendiumConfiguration setting.
   * @type {WorldCompendiumConfiguration}
   */
  static #config;

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

  /**
   * 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 foundry.appv1.api.Application|typeof foundry.applications.api.ApplicationV2}
   */
  applicationClass = foundry.applications.sidebar.apps.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 foundry.documents.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;
  }

  /**
   * Access the compendium configuration data for this pack
   * @type {object}
   */
  get config() {
    return CompendiumCollection.#config[this.collection] || {};
  }

  /** @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 {WorldCompendiumPackConfiguration["ownership"]}
   */
  get ownership() {
    return this.config.ownership ?? this.metadata.ownership ?? {
      ...foundry.packages.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>}
   */
  #indexedFields;

  /**
   * Has this compendium pack been fully indexed?
   * @type {boolean}
   */
  get indexed() {
    return this.indexFields.isSubsetOf(this.#indexedFields);
  }

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

  /**
   * A debounced function which will clear the contents of the Compendium pack if it is not accessed frequently.
   * @type {Function}
   */
  #flush = foundry.utils.debounce(this.clear.bind(this), this.constructor.CACHE_LIFETIME_SECONDS * 1000);

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

  /** @inheritDoc */
  get(key, options) {
    this.#flush();
    return super.get(key, options);
  }

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

  /** @inheritDoc */
  set(id, document) {
    if ( document instanceof foundry.documents.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.isSubsetOf(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
    const restoreArt = this.documentName === "Actor";
    for ( const 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);
      // Restore compendium art if previously assigned
      if ( restoreArt ) {
        const img = game.compendiumArt.get(indexed.uuid)?.actor ?? indexed.img;
        indexed.img = img;
      }
      this.index.set(i._id, indexed);
    }

    // Record that the pack has been indexed
    console.log(`${CONST.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<TDocument>|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. The available query options are
   * shown below.
   * @param {object} query            A database query used to retrieve documents from the underlying database
   * @returns {Promise<TDocument[]>}   The retrieved Document instances
   *
   * @example Get Documents that match the given value only.
   * ```js
   * await pack.getDocuments({ type: "weapon" });
   * ```
   *
   * @example Get all Documents that do not have the given value.
   * ```js
   * await pack.getDocuments({ type__ne: "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 ( const 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 ClientDocumentMixin#toCompendium.
   * @returns {Promise<TDocument>}   The imported Document instance
   */
  async importDocument(document, options={}) {
    if ( !(document instanceof this.documentClass) && !(document instanceof foundry.documents.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 foundry.documents.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 foundry.documents.Folder.implementation.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 foundry.documents.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 foundry.documents.Folder.implementation.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 foundry.documents.abstract.WorldCollection#fromCompendium} and
   *                                  {@link foundry.abstract.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<TDocument[]>}   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 foundry.documents.Folder.implementation.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("COMPENDIUM.ImportAll.Start", {format: {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 foundry.documents.Folder.implementation.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("COMPENDIUM.ImportAll.Finish", {format: {number: created.length, folderNumber: folders.size,
      folder: parentFolder.name, type: game.i18n.localize(this.documentClass.metadata.label)}});
    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 DialogV2.confirm method
   * @returns {Promise<TDocument[]|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 foundry.applications.handlebars.renderTemplate("templates/sidebar/apps/compendium-import.hbs", {
      folderName: this.title,
      keepId: options.keepId ?? false,
      folders: collection?._formatFolderSelectOptions() ?? []
    });
    const content = document.createElement("div");
    content.innerHTML = html;

    // Present the Dialog
    return foundry.applications.api.DialogV2.confirm(foundry.utils.mergeObject({
      window: {
        title: game.i18n.format("COMPENDIUM.ImportAll.Title", {compendium: this.title}), // FIXME: double localization
        icon: "fa-solid fa-download"
      },
      content,
      render: event => {
        const form = event.target.element.querySelector("form");
        form.elements.folder.addEventListener("change", event => {
          form.elements.folderName.disabled = !!event.currentTarget.value;
        }, {passive: true});
      },
      yes: {
        label: "COMPENDIUM.ImportAll.Submit",
        callback: event => {
          const form = event.currentTarget.querySelector("form");
          return this.importAll({
            folderId: form.elements.folder.value,
            folderName: form.folderName.value,
            keepId: form.keepId.checked
          });
        }
      },
      no: {label: "Cancel"}
    }, options));
  }

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

  /**
   * Add a Document to the index, capturing its relevant index attributes
   * @param {TDocument} 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 foundry.applications.api.DialogV2.wait({
      id: "compendium-ownership",
      window: {
        title: game.i18n.format("OWNERSHIP.Title", {object: game.i18n.localize(this.metadata.label)}), // FIXME: double localization
        icon: "fa-solid fa-user-lock"
      },
      position: {width: 480},
      content: await foundry.applications.handlebars.renderTemplate("templates/sidebar/apps/compendium-ownership.hbs", {roles}),
      buttons: [
        {
          action: "reset",
          label: "COMPENDIUM.OwnershipReset",
          icon: "fa-solid fa-arrow-rotate-left",
          callback: () => this.configure({ ownership: undefined })
        },
        {
          action: "ok",
          label: "OWNERSHIP.Configure",
          icon: "fa-solid fa-check",
          callback: async event => {
            const fd = new foundry.applications.ux.FormDataExtended(event.currentTarget.querySelector("form"));
            const ownership = Object.entries(fd.object).reduce((obj, [r, l]) => {
              if ( l ) obj[r] = l;
              return obj;
            }, {});
            ownership.GAMEMASTER = "OWNER";
            await this.configure({ownership});
          },
          default: true
        }
      ]
    });
    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 foundry.helpers.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) {
    const {documentName, collection: pack} = this;
    return foundry.utils.buildUuid({id, documentName, pack});
  }

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

  /**
   * Assign configuration metadata settings to the compendium pack
   * @param {object} configuration  The object of compendium settings to define
   * @returns {Promise<void>}       A Promise which resolves once the setting is updated
   */
  async configure(configuration={}) {
    const settings = foundry.utils.deepClone(CompendiumCollection.#config);
    settings[this.collection] ||= {};
    const config = settings[this.collection];
    for ( const [k, v] of Object.entries(configuration) ) {
      if ( v === undefined ) delete config[k];
      else config[k] = v;
    }
    await 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 foundry.helpers.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
   * @param {object} [options]
   * @param {boolean} [options.requireUnlocked=true] Throw if the compendium is locked.
   * @returns {boolean}
   */
  #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.
   * @param {object} [options]
   * @param {boolean} [options.notify=true]  Display notifications
   * @returns {Promise<CompendiumCollection>}
   */
  async migrate({ notify=true }={}) {
    this.#assertUserCanManage();
    if ( notify ) {
      ui.notifications.info("COMPENDIUM.Migration.Begin", { format: { collection: this.collection } });
    }
    await foundry.helpers.SocketInterface.dispatch("manageCompendium", {
      type: this.collection,
      action: "migrate",
      data: this.collection,
      options: { broadcast: false }
    });
    if ( notify ) ui.notifications.info("COMPENDIUM.Migration.Complete", { format: { collection: this.collection } });
    return this;
  }

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

  /** @inheritDoc */
  async updateAll(transformation, condition=null, options={}) {
    await this.getDocuments();
    options.pack = this.collection;
    return super.updateAll(transformation, condition, options);
  }

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

  /** @inheritDoc */
  render(force, options) {
    super.render(force, options);
    if ( options?.renderContext === "updateConfiguration" ) {
      for ( const document of this.contents ) document.render(false);
    }
  }

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

  /** @inheritDoc */
  _onModifyContents(action, documents, result, operation, user) {
    super._onModifyContents(action, documents, result, operation, user);
    Hooks$1.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.initializeTree();
    pack.apps.push(new pack.applicationClass({collection: pack}));
    game.packs.initializeTree();
    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;
  }

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

  /**
   * Handle changes to the world compendium configuration setting.
   * @param {WorldCompendiumConfiguration} config
   */
  static _onConfigure(config) {
    const prior = CompendiumCollection.#config;
    CompendiumCollection.#config = foundry.utils.deepClone(config);
    const diff = foundry.utils.diffObject(prior, config);
    let folderChanged = false;
    let sortChanged = false;
    for ( const [id, delta] of Object.entries(diff) ) {
      const pack = game.packs.get(id);
      if ( !pack ) continue;
      if ( "sort" in delta ) sortChanged = true;
      if ( "folder" in delta ) folderChanged = true;
      if ( "ownership" in delta ) pack.initializeTree();
      if ( "locked" in delta ) pack.render(false, { renderContext: "updateConfiguration", renderData: delta });
    }
    if ( folderChanged || sortChanged ) game.packs.initializeTree();
    ui.compendium.render();
  }
}

/**
 * @typedef 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 htmlEditor = this.view.dom.closest(".editor")?.querySelector(":scope > code-mirror[language=html]");
    if ( !htmlEditor ) return;
    htmlEditor.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.tooltipText = 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.collaborate=false]    Whether collaborative editing enabled.
   * @param {boolean} [options.relativeLinks=false]  Whether to generate relative UUID links to Documents that are
   *                                                 dropped on the editor.
   * @param {object} [options.props]                 Additional ProseMirror editor properties.
   * @returns {Promise<ProseMirrorEditor>}
   */
  static async create(target, content="", {uuid, document, fieldName, plugins={}, collaborate=false,
    relativeLinks=false, props={}}={}) {

    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$1.callAll("createProseMirrorEditor", uuid, plugins, options);

    const view = collaborate
      ? await this._createCollaborativeEditorView(uuid, target, options.state, Object.values(plugins), props)
      : this._createLocalEditorView(target, options.state, Object.values(plugins), props);
    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 ProseMirror editor plugins to load.
   * @param {object} props        Additional ProseMirror editor properties.
   * @returns {Promise<EditorView>}
   * @protected
   */
  static async _createCollaborativeEditorView(uuid, target, state, plugins, props) {
    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}, {
      ...props,
      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 ProseMirror editor plugins to load.
   * @param {object} props        Additional ProseMirror editor properties.
   * @returns {EditorView}
   * @protected
   */
  static _createLocalEditorView(target, state, plugins, props) {
    return new ProseMirror.EditorView({mount: target}, {
      ...props, 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 foundry.utils.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));
  }
}

/**
 * @import Application from "@client/appv1/api/application-v1.mjs"
 */

/**
 * @typedef 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 {ContextMenuJQueryCallback} callback       The function to call when the menu item is clicked.
 * @property {ContextMenuCondition|boolean} [condition] A function to call or boolean value to determine if this entry
 *                                                      appears in the menu.
 */

/**
 * @callback ContextMenuCondition
 * @param {jQuery|HTMLElement} html                     The 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.
 * @returns {unknown}
 */

/**
 * @callback ContextMenuJQueryCallback
 * @param {HTMLElement|jQuery} target                   The element that the context menu has been triggered for. Will
 *                                                      either be a jQuery object or an HTMLElement instance, depending
 *                                                      on how the ContextMenu was configured.
 * @returns {unknown}
 */

/**
 * @typedef ContextMenuOptions
 * @property {string} [eventName="contextmenu"] Optionally override the triggering event which can spawn the menu. If
 *                                              the menu is using fixed positioning, this event must be a MouseEvent.
 * @property {ContextMenuCallback} [onOpen]     A function to call when the context menu is opened.
 * @property {ContextMenuCallback} [onClose]    A function to call when the context menu is closed.
 * @property {boolean} [fixed=false]            If true, the context menu is given a fixed position rather than being
 *                                              injected into the target.
 * @property {boolean} [jQuery=true]            If true, callbacks will be passed jQuery objects instead of HTMLElement
 *                                              instances.
 */

/**
 * @typedef ContextMenuRenderOptions
 * @property {Event} [event]           The event that triggered the context menu opening.
 * @property {boolean} [animate=true]  Animate the context menu opening.
 */

/**
 * 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} container              The HTML element that contains the context menu targets.
   * @param {string} selector                           A CSS selector which activates the context menu.
   * @param {ContextMenuEntry[]} menuItems              An Array of entries to display in the menu
   * @param {ContextMenuOptions} [options]              Additional options to configure the context menu.
   */
  constructor(container, selector, menuItems, {eventName="contextmenu", onOpen, onClose, jQuery, fixed=false}={}) {
    if ( jQuery === undefined ) {
      foundry.utils.logCompatibilityWarning("ContextMenu is changing to no longer transact jQuery objects for menu"
        + " item callbacks. Because the jQuery option provided to the ContextMenu constructor was undefined, your"
        + " callbacks will receive jQuery objects. You may opt-out and receive HTMLElement references instead by"
        + " passing jQuery: false to the constructor. This parameter will be false by default in v14 and deprecated"
        + " entirely in v15 at which point only HTMLElement references will be used.",
      { since: 13, until: 15, once: true });
      jQuery = true;
    }

    // Accept HTMLElement or jQuery (for now)
    if ( !(container instanceof HTMLElement) ) {
      foundry.utils.logCompatibilityWarning("ContextMenu is changing to no longer transact jQuery objects."
        + " You must begin passing an HTMLElement instead.", { since: 13, until: 15, once: true });
      container = container[0];
    }

    // Assign attributes
    this.#container = container;
    this.#selector = selector || container.id;
    this.#eventName = eventName;
    this.menuItems = menuItems;
    this.onOpen = onOpen;
    this.onClose = onClose;
    this.#fixed = fixed;
    /** @deprecated since v13 until v15 */
    this.#jQuery = jQuery;

    // Bind to the container.
    this.#container.addEventListener(this.eventName, this._onActivate.bind(this));
  }

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

  /**
   * Create a ContextMenu for this Application and dispatch hooks.
   * @param {Application} 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}
   * @deprecated since v13
   */
  static create(app, html, selector, menuItems, {hookName="EntryContext", ...options}={}) {
    if ( app instanceof foundry.applications.api.ApplicationV2 ) {
      throw new Error("ContextMenu.create is deprecated and only supports Application (v1) instances.");
    }
    app._callHooks?.(className => `get${className}${hookName}`, menuItems);
    return new this(html, selector, menuItems, options);
  }

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

  /**
   * The HTML element that contains the context menu targets.
   * @type {HTMLElement}
   */
  #container;

  /**
   * The menu element.
   * @type {HTMLElement}
   */
  get element() {
    return this.#element;
  }

  #element;

  /**
   * A CSS selector to identify context menu targets.
   * @type {string}
   */
  get selector() {
    return this.#selector;
  }

  #selector;

  /**
   * The event name to listen for.
   * @type {string}
   */
  get eventName() {
    return this.#eventName;
  }

  #eventName;

  /**
   * The array of menu items to render.
   * @type {Array<ContextMenuEntry & {element: HTMLElement}>}
   */
  menuItems;

  /**
   * A function to call when the context menu is opened.
   * @type {ContextMenuCallback}
   */
  onOpen;

  /**
   * A function to call when the context menu is closed.
   * @type {ContextMenuCallback}
   */
  onClose;

  /**
   * Check which direction the menu is expanded in.
   * @type {boolean}
   */
  get expandUp() {
    return this.#expandUp;
  }

  #expandUp = false;

  /**
   * Whether to position the context menu as a fixed element, or inject it into the target.
   * @type {boolean}
   */
  get fixed() {
    return this.#fixed;
  }

  #fixed;

  /**
   * Whether to pass jQuery objects or HTMLElement instances to callback.
   * @type {boolean}
   */
  #jQuery;

  /**
   * The parent HTML element to which the context menu is attached
   * @type {HTMLElement}
   */
  get target() {
    return this.#target;
  }

  #target;

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

  /**
   * Animate the context menu's height when opening or closing.
   * @param {boolean} open      Whether the menu is opening or closing.
   * @returns {Promise<void>}   A Promise that resolves when the animation completes.
   * @protected
   */
  async _animate(open=false) {
    const { height } = this.#element.getBoundingClientRect();
    const from = open ? "0" : `${height}px`;
    const to = open ? `${height}px` : "0";
    Object.assign(this.#element.style, { height: from, padding: "0", overflow: "hidden" });
    await this.#element.animate({ height: [from, to] }, { duration: 200, easing: "ease", fill: "forwards" }).finished;
    if ( open ) Object.assign(this.#element.style, { height: "", padding: "", overflow: "" });
  }

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

  /**
   * 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.
   * @param {HTMLElement} [options.target]    The target element to close on.
   * @returns {Promise<void>}
   */
  async close({animate=true, target}={}) {
    if ( animate ) await this._animate(false);
    this._close({ target });
  }

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

  /**
   * Close the menu and remove it from the DOM.
   * @param {object} [options]
   * @param {HTMLElement} [options.target]  The target element to close on.
   * @protected
   */
  _close({ target }={}) {
    for ( const item of this.menuItems ) delete item.element;
    this.#element.remove();
    document.querySelectorAll(".context").forEach(el => el.classList.remove("context"));
    if ( ui.context === this ) delete ui.context;
    this.onClose?.(target ?? this.#target);
  }

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

  /**
   * Called before the context menu begins rendering.
   * @param {HTMLElement} target  The context target.
   * @param {ContextMenuRenderOptions} [options]
   * @returns {Promise<void>}
   * @protected
   */
  async _preRender(target, options={}) {}

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

  /**
   * 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 {HTMLElement} target  The target element to which the context menu is attached.
   * @param {ContextMenuRenderOptions} [options]
   * @returns {Promise<void>}     A Promise that resolves when the open animation has completed.
   */
  async render(target, options={}) {
    await this._preRender(target, options);
    this.#element?.remove();
    const html = this.#element = document.createElement("nav");
    html.id = "context-menu";
    const menu = document.createElement("menu");
    menu.classList.add("context-items");
    html.replaceChildren(menu);

    if ( !this.menuItems.length ) return;

    /** @type {Record<string, ContextMenuEntry>} */
    const groups = this.menuItems.reduce((acc, entry) => {
      const group = entry.group ?? "_none";
      acc[group] ??= [];
      // Determine menu item visibility (display unless false)
      let display = true;
      if ( entry.condition !== undefined ) {
        if ( entry.condition instanceof Function ) display = entry.condition(this.#jQuery ? $(target) : target);
        else display = entry.condition;
      }
      if ( display ) acc[group].push(entry);
      return acc;
    }, {});

    for ( const [group, entries] of Object.entries(groups) ) {
      let parent = menu;
      if ( (group !== "_none") && entries.length ) {
        const item = document.createElement("li");
        item.classList.add("context-group");
        item.dataset.groupId = group;
        const list = document.createElement("ol");
        item.append(list);
        menu.append(item);
        parent = list;
      }
      for ( const item of entries ) {
        // Construct and add the menu item
        const name = game.i18n.localize(item.name);
        const classes = ["context-item", item.classes].filterJoin(" ");
        const entry = document.createElement("li");
        entry.className = classes;
        if ( item.icon ) {
          entry.insertAdjacentHTML("afterbegin", item.icon);
          entry.querySelector("i")?.classList.add("fa-fw");
        }
        const span = document.createElement("span");
        span.append(name);
        entry.append(span);
        parent.append(entry);

        // Record a reference to the element.
        item.element = entry;
      }
    }

    // Bail out if there are no children
    if ( !menu.children.length ) return;

    // Append to target
    this._setPosition(html, target, options);

    // Apply interactivity
    this.activateListeners(html);

    // Deactivate global tooltip
    game.tooltip.deactivate();

    // Animate open the menu
    if ( options.animate !== false ) await this._animate(true);
    return this._onRender(options);
  }

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

  /**
   * Called after the context menu has finished rendering and animating open.
   * @param {ContextMenuRenderOptions} [options]
   * @returns {Promise<void>}
   * @protected
   */
  async _onRender(options={}) {}

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

  /**
   * Set the position of the context menu, taking into consideration whether the menu should expand upward or downward
   * @param {HTMLElement} menu       The context menu element.
   * @param {HTMLElement} target     The element that the context menu was spawned on.
   * @param {object} [options]
   * @param {Event} [options.event]  The event that triggered the context menu opening.
   * @protected
   */
  _setPosition(menu, target, { event }={}) {
    if ( this.#fixed ) this._setFixedPosition(menu, target, { event });
    else this._injectMenu(menu, target);
  }

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

  /**
   * Inject the menu inside the target.
   * @param {HTMLElement} menu    The menu element.
   * @param {HTMLElement} target  The context target.
   * @protected
   */
  _injectMenu(menu, target) {
    const container = target.parentElement;

    // Append to target and get the context bounds
    target.style.position = "relative";
    menu.style.visibility = "hidden";
    target.append(menu);
    const menuRect = menu.getBoundingClientRect();
    const parentRect = target.getBoundingClientRect();
    const containerRect = container.getBoundingClientRect();

    // Determine whether to expand upwards
    const menuTop = parentRect.top - menuRect.height;
    const menuBottom = parentRect.bottom + menuRect.height;
    const canOverflowUp = (menuTop > containerRect.top) || (getComputedStyle(container).overflowY === "visible");

    // If it overflows the container bottom, but not the container top
    const containerUp = (menuBottom > containerRect.bottom) && (menuTop >= containerRect.top);
    const windowUp = (menuBottom > window.innerHeight) && (menuTop > 0) && canOverflowUp;
    this.#expandUp = containerUp || windowUp;

    // Display the menu
    menu.classList.toggle("expand-up", this.#expandUp);
    menu.classList.toggle("expand-down", !this.#expandUp);
    menu.style.visibility = "";
    target.classList.add("context");
  }

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

  /**
   * Set the context menu at a fixed position in the viewport.
   * @param {HTMLElement} menu       The menu element.
   * @param {HTMLElement} target     The context target.
   * @param {object} [options]
   * @param {Event} [options.event]  The event that triggered the context menu opening.
   * @protected
   */
  _setFixedPosition(menu, target, { event }={}) {
    let { clientX, clientY } = event ?? {};

    // Bail early if it won't be possible to position the menu.
    const needsCoords = [clientX, clientY].includes(undefined) || (!event.isTrusted && [clientX, clientY].includes(0));
    if ( needsCoords && !target.checkVisibility() ) return;

    menu.setAttribute("popover", "manual");
    document.body.appendChild(menu);
    menu.showPopover();
    const { clientWidth, clientHeight } = document.documentElement;
    const { width, height } = menu.getBoundingClientRect();

    if ( needsCoords ) {
      // If an event was either not provided or without meaningful clientX/clientY co-ordinates, set the co-ordinates to
      // the bottom-left of the target.
      ({ left: clientX, bottom: clientY } = target.getBoundingClientRect());
    }

    menu.style.left = `${(Math.min(clientX, clientWidth - width))}px`;
    this.#expandUp = (clientY + height) > clientHeight;
    if ( this.#expandUp ) menu.style.bottom = `${clientHeight - clientY}px`;
    else menu.style.top = `${clientY}px`;
    menu.classList.toggle("expand-up", this.#expandUp);
    menu.classList.toggle("expand-down", !this.#expandUp);
    target.classList.add("context");

    const nearestThemed = target.closest(".themed") ?? document.body;
    const [, theme] = nearestThemed.className.match(/(?:^|\s)(theme-\w+)/) ?? [];
    if ( theme ) menu.classList.add("themed", theme);
  }

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

  /**
   * Local listeners which apply to each ContextMenu instance which is created.
   * @param {HTMLElement} menu  The context menu element.
   */
  activateListeners(menu) {
    menu.addEventListener("click", this.#onClickItem.bind(this));
  }

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

  /**
   * Handle context menu activation.
   * @param {Event} event  The triggering event.
   * @protected
   */
  _onActivate(event) {
    const matching = event.target.closest(this.#selector);
    if ( !matching ) return;
    event.preventDefault();
    const priorTarget = this.#target;
    this.#target = matching;

    // Remove existing context UI.
    if ( this.#target.classList.contains("context") ) return this.close();

    // If the menu is already open, call its close handler on its original target.
    const closeOptions = { animate: ui.context !== this };
    if ( ui.context === this ) closeOptions.target = priorTarget;
    ui.context?.close(closeOptions);

    // Render a new context menu.
    event.stopImmediatePropagation();
    ui.context = this;
    this.onOpen?.(this.#target);
    return this.render(this.#target, { event });
  }

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

  /**
   * Handle click events on context menu items.
   * @param {PointerEvent} event      The click event
   */
  #onClickItem(event) {
    event.preventDefault();
    event.stopPropagation();
    const element = event.target.closest(".context-item");
    if ( !element ) return;
    const item = this.menuItems.find(i => i.element === element);
    item?.callback(this.#jQuery ? $(this.#target) : this.#target);
    this.close();
  }

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

  /**
   * Global listeners which apply once only to the document.
   */
  static eventListeners() {
    document.addEventListener("click", () => ui.context?.close(), { passive: true });
  }

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

  /**
   * Retrieve the configured DragDrop implementation.
   * @type {typeof ContextMenu}
   */
  static get implementation() {
    let Class = CONFIG.ux.ContextMenu;
    if ( !foundry.utils.isSubclass(Class, ContextMenu) ) {
      console.warn("Configured ContextMenu override must be a subclass of ContextMenu.");
      Class = ContextMenu;
    }
    return Class;
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13 until v15
   * @ignore
   */
  get _expandUp() {
    foundry.utils.logCompatibilityWarning("ContextMenu#_expandUp is deprecated. Please use ContextMenu#expandUp "
      + "instead.", { since: 13, until: 15, once: true });
    return this.#expandUp;
  }

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

  /**
   * @deprecated since v13 until v15
   * @ignore
   */
  get menu() {
    foundry.utils.logCompatibilityWarning("ContextMenu#menu is deprecated. "
      + "Please use ContextMenu#element instead.", { since: 13, until: 15, once: true });
    return $("#context-menu");
  }
}

/**
 * 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
   * @override
   */
  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
   * @override
   */
  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?
   */
  #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.matches(":disabled") ) continue;
      if ( !readonly && element.readOnly ) continue;

      // Extract and process the value of the field
      const field = form.elements.namedItem(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?
   */
  #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.
   */
  #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
   */
  #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
   */
  #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;
  }
}

/**
 * @import {
 *   ApplicationClosingOptions,
 *   ApplicationConfiguration,
 *   ApplicationHeaderControlsEntry,
 *   ApplicationPosition,
 *   ApplicationRenderContext,
 *   ApplicationRenderOptions,
 *   ApplicationTab,
 *   ApplicationTabsConfiguration
 * } from "../_types.mjs";
 */

/**
 * @typedef {typeof ApplicationV2.RENDER_STATES[keyof typeof ApplicationV2.RENDER_STATES]} ApplicationRenderState
 */

/**
 * The Application class is responsible for rendering an HTMLElement into the Foundry Virtual Tabletop user interface.
 * @template {ApplicationConfiguration} [Configuration=ApplicationConfiguration]
 * @template {ApplicationRenderOptions} [RenderOptions=ApplicationRenderOptions]
 */
class ApplicationV2 extends EventEmitterMixin() {

  /**
   * Applications are constructed by providing an object of configuration options.
   * @param {Partial<Configuration>} [options]    Options used to configure the Application instance
   */
  constructor(options={}) {
    super();

    // Configure Application Options
    this.options = Object.freeze(this._initializeApplicationOptions(options));
    this.#id = this.options.id.replace("{id}", this.options.uniqueId);
    Object.assign(this.#position, this.options.position);

    // Verify the Application class is renderable
    this.#renderable = (this._renderHTML !== ApplicationV2.prototype._renderHTML)
      && (this._replaceHTML !== ApplicationV2.prototype._replaceHTML);
  }

  /**
   * Designates which upstream Application class in this class' inheritance chain is the base application.
   * Any DEFAULT_OPTIONS of super-classes further upstream of the BASE_APPLICATION are ignored.
   * Hook events for super-classes further upstream of the BASE_APPLICATION are not dispatched.
   * @type {typeof ApplicationV2}
   */
  static BASE_APPLICATION = ApplicationV2;

  /**
   * The default configuration options which are assigned to every instance of this Application class.
   * @type {Partial<Configuration>}
   */
  static DEFAULT_OPTIONS = {
    id: "app-{id}",
    classes: [],
    tag: "div",
    window: {
      frame: true,
      positioned: true,
      title: "",
      icon: "",
      controls: [],
      minimizable: true,
      resizable: false,
      contentTag: "section",
      contentClasses: []
    },
    actions: {},
    form: {
      handler: undefined,
      submitOnChange: false,
      closeOnSubmit: false
    },
    position: {
      width: "auto",
      height: "auto"
    }
  };

  /**
   * Configuration of application tabs, with an entry per tab group.
   * @type {Record<string, ApplicationTabsConfiguration>}
   */
  static TABS = {};

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

  /**
   * An incrementing integer Application ID.
   * @type {number}
   * @internal
   */
  static _appId = 0;

  /**
   * The current maximum z-index of any displayed Application.
   * @type {number}
   * @internal
   */
  static _maxZ = Number(getComputedStyle(document.body).getPropertyValue("--z-index-window") ?? 100);

  /**
   * Which application is currently "in front" with the maximum z-index
   * @type {ApplicationV2}
   */
  static #frontApp;

  /** @override */
  static emittedEvents = Object.freeze(["render", "close", "position"]);

  /**
   * Initial values of the #window object.
   * @type {object}
   */
  static #INITIAL_WINDOW_VALUES = {
    header: undefined,
    title: undefined,
    icon: undefined,
    resize: undefined,
    close: undefined,
    content: undefined,
    controls: undefined,
    controlsDropdown: undefined,
    pointerStartPosition: undefined,
    pointerMoveThrottle: false
  };

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

  /**
   * Application instance configuration options.
   * @type {Readonly<Configuration>}
   */
  options;

  /**
   * @type {string}
   */
  #id;

  /**
   * Flag that this Application instance is renderable.
   * Applications are not renderable unless a subclass defines the _renderHTML and _replaceHTML methods.
   */
  #renderable = true;

  /**
   * The outermost HTMLElement of this rendered Application.
   * For window applications this is ApplicationV2##frame.
   * For non-window applications this ApplicationV2##content.
   * @type {HTMLElement}
   */
  #element;

  /**
   * The HTMLElement within which inner HTML is rendered.
   * For non-window applications this is the same as ApplicationV2##element.
   * @type {HTMLElement}
   */
  #content;

  /**
   * Data pertaining to the minimization status of the Application.
   * @type {{
   *  active: boolean,
   *  priorWidth?: number,
   *  priorHeight?: number,
   *  priorBoundingWidth?: number,
   *  priorBoundingHeight?: number
   * }}
   */
  #minimization = Object.seal({
    active: false,
    priorWidth: undefined,
    priorHeight: undefined,
    priorBoundingWidth: undefined,
    priorBoundingHeight: undefined
  });

  /**
   * The rendered position of the Application.
   * @type {ApplicationPosition}
   */
  #position = Object.seal({
    top: undefined,
    left: undefined,
    width: undefined,
    height: "auto",
    scale: 1,
    zIndex: ApplicationV2._maxZ
  });

  /**
   * @type {ApplicationRenderState}
   */
  #state = ApplicationV2.RENDER_STATES.NONE;

  /**
   * A Semaphore used to enqueue asynchronous operations.
   * @type {Semaphore}
   */
  #semaphore = new Semaphore(1);

  /**
   * Convenience references to window header elements.
   * @type {{
   *  header: HTMLElement,
   *  resize: HTMLElement,
   *  title: HTMLHeadingElement,
   *  icon: HTMLElement,
   *  close: HTMLButtonElement,
   *  controls: HTMLButtonElement,
   *  content: HTMLElement,
   *  controlsDropdown: HTMLDivElement,
   *  onDrag: Function,
   *  onResize: Function,
   *  pointerStartPosition: ApplicationPosition,
   *  pointerMoveThrottle: boolean
   * }}
   */
  get window() {
    return this.#window;
  }

  #window = {
    ...ApplicationV2.#INITIAL_WINDOW_VALUES,
    onDrag: this.#onWindowDragMove.bind(this),
    onResize: this.#onWindowResizeMove.bind(this)
  };

  /**
   * If this Application uses tabbed navigation groups, this mapping is updated whenever the changeTab method is called.
   * Reports the active tab for each group, with a value of `null` indicating no tab is active.
   * Subclasses may override this property to define default tabs for each group.
   * @type {Record<string, string|null>}
   */
  tabGroups = Object.entries(this.constructor.TABS).reduce((obj, [id, {initial}]) => {
    obj[id] = initial || null;
    return obj;
  }, {});

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

  /**
   * The CSS class list of this Application instance
   * @type {DOMTokenList}
   */
  get classList() {
    return this.#element?.classList;
  }

  /**
   * The HTML element ID of this Application instance.
   * This provides a readonly view into the internal ID used by this application.
   * This getter should not be overridden by subclasses, which should instead configure the ID in `DEFAULT_OPTIONS` or
   * by defining a `uniqueId` during `_initializeApplicationOptions`.
   * @type {string}
   */
  get id() {
    return this.#id;
  }

  /**
   * A convenience reference to the title of the Application window.
   * @type {string}
   */
  get title() {
    return game.i18n.localize(this.options.window.title);
  }

  /**
   * The HTMLElement which renders this Application into the DOM.
   * @type {HTMLElement}
   */
  get element() {
    return this.#element;
  }

  /**
   * Does this Application have a top-level form element?
   * @type {HTMLFormElement|null}
   */
  get form() {
    if ( this.options.tag === "form" ) return this.#element;
    if ( this.options.window?.contentTag === "form" ) return this.#content;
    return null;
  }

  /**
   * Is this Application instance currently minimized?
   * @type {boolean}
   */
  get minimized() {
    return this.rendered && this.#minimization.active;
  }

  /**
   * The current position of the application with respect to the window.document.body.
   * @type {ApplicationPosition}
   */
  position = new Proxy(this.#position, {
    set: (obj, prop, value) => {
      if ( prop in obj ) {
        obj[prop] = value;
        this._updatePosition(this.#position);
        return value;
      }
    }
  });

  /**
   * Is this Application instance currently rendered?
   * @type {boolean}
   */
  get rendered() {
    return this.#state === ApplicationV2.RENDER_STATES.RENDERED;
  }

  /**
   * The current render state of the Application.
   * @type {ApplicationRenderState}
   */
  get state() {
    return this.#state;
  }

  /**
   * Does this Application instance render within an outer window frame?
   * @type {boolean}
   */
  get hasFrame() {
    return this.options.window.frame;
  }

  /* -------------------------------------------- */
  /*  Initialization                              */
  /* -------------------------------------------- */

  /**
   * Iterate over the inheritance chain of this Application.
   * The chain includes this Application itself and all parents until the base application is encountered.
   * @see {@link ApplicationV2.BASE_APPLICATION}
   * @yields {typeof ApplicationV2}
   */
  static *inheritanceChain() {
    let cls = this;
    while ( cls ) {
      yield cls;
      if ( cls === this.BASE_APPLICATION ) return;
      cls = Object.getPrototypeOf(cls);
    }
  }

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

  /**
   * Initialize configuration options for the Application instance.
   * The default behavior of this method is to intelligently merge options for each class with those of their parents.
   * - Array-based options are concatenated
   * - Inner objects are merged
   * - Otherwise, properties in the subclass replace those defined by a parent
   * @param {Partial<ApplicationConfiguration>} options      Options provided directly to the constructor
   * @returns {ApplicationConfiguration}                     Configured options for the application instance
   * @protected
   */
  _initializeApplicationOptions(options) {

    // Options initialization order
    const order = [options];
    for ( const cls of this.constructor.inheritanceChain() ) {
      if ( cls.hasOwnProperty("DEFAULT_OPTIONS") ) order.unshift(cls.DEFAULT_OPTIONS);
    }

    // Intelligently merge with parent class options
    /** @type {ApplicationConfiguration} */
    const applicationOptions = {};
    for ( const opts of order ) {
      ApplicationV2.#mergeApplicationOptions(applicationOptions, opts);
    }

    // Unique application ID
    applicationOptions.uniqueId = String(++ApplicationV2._appId);

    // Constrain some options into mutual coherence
    if ( !applicationOptions.window.frame ) applicationOptions.window.minimizable = false;

    // Special handling for classes
    if ( applicationOptions.window.frame ) applicationOptions.classes.unshift("application");
    applicationOptions.classes = Array.from(new Set(applicationOptions.classes));
    return applicationOptions;
  }

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

  /**
   * Merge Application options with logic as described by ApplicationV2#_initializeApplicationOptions.
   * @param {object} options
   * @param {object} opts
   */
  static #mergeApplicationOptions(options, opts) {
    for ( const [k, v] of Object.entries(opts) ) {
      const v1 = foundry.utils.deepClone(v);
      if ( (k in options) ) {
        const v0 = options[k];
        if ( Array.isArray(v0) ) options[k].push(...v1);                          // Concatenate arrays
        else if ( foundry.utils.getType(v0) === "Object") {                       // Merge objects
          ApplicationV2.#mergeApplicationOptions(v0, v1);
        }
        else options[k] = v1;                                                     // Replace option
      }
      else options[k] = v1;                                                       // Define option
    }
  }

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

  /**
   * Render the Application, creating its HTMLElement and replacing its innerHTML.
   * Add it to the DOM if it is not currently rendered and rendering is forced. Otherwise, re-render its contents.
   * @param {boolean|RenderOptions} [options]            Options which configure application rendering behavior.
   *                                                      A boolean is interpreted as the "force" option.
   * @param {RenderOptions} [_options]                   Legacy options for backwards-compatibility with the original
   *                                                      ApplicationV1#render signature.
   * @returns {Promise<this>}            A Promise which resolves to the rendered Application instance
   */
  async render(options={}, _options={}) {
    if ( typeof options === "boolean" ) options = Object.assign(_options, {force: options});
    return this.#semaphore.add(this.#render.bind(this), options);
  }

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

  /**
   * Manage the rendering step of the Application life-cycle.
   * This private method delegates out to several protected methods which can be defined by the subclass.
   * @param {RenderOptions} [options]             Options which configure application rendering behavior
   * @returns {Promise<this>}            A Promise which resolves to the rendered Application instance
   */
  async #render(options) {
    const states = ApplicationV2.RENDER_STATES;
    if ( !this.#renderable ) throw new Error(`The ${this.constructor.name} Application class is not renderable because`
      + " it does not implement the abstract methods _renderHTML and _replaceHTML. Consider using a mixin such as"
      + " foundry.applications.api.HandlebarsApplicationMixin for this purpose.");

    // Verify that the Application is allowed to be rendered
    try {
      const canRender = this._canRender(options);
      if ( canRender === false ) return this;
    } catch(err) {
      ui.notifications.warn(err.message);
      return this;
    }
    options.isFirstRender = this.#state <= states.NONE;
    if ( options.isFirstRender && !options.force ) return this;

    // Prepare rendering context data
    this._configureRenderOptions(options);
    const context = await this._prepareContext(options);

    // Pre-render life-cycle events (awaited)
    const handlerArgs = [context, options];
    if ( options.isFirstRender ) {
      await this._doEvent(this._preFirstRender, {async: true, handlerArgs, debugText: "Before first render"});
    }
    await this._doEvent(this._preRender, {async: true, handlerArgs, debugText: "Before render"});

    // Render the Application frame
    this.#state = states.RENDERING;
    if ( options.isFirstRender ) {
      this.#element = await this._renderFrame(options);
      this.#content = this.hasFrame ? this.#element.querySelector(".window-content") : this.#element;
      this._attachFrameListeners();
    }

    // Render Application content
    try {
      const result = await this._renderHTML(context, options);
      this._replaceHTML(result, this.#content, options);
    }
    catch(err) {
      if ( this.#element ) {
        this.#element.remove();
        this.#element = null;
      }
      this.#state = states.ERROR;
      throw new Error(`Failed to render Application "${this.id}":\n${err.message}`, { cause: err });
    }

    // Register the rendered Application
    if ( options.isFirstRender ) {
      foundry.applications.instances.set(this.#id, this);
      this._insertElement(this.#element);
    }
    if ( this.hasFrame ) this._updateFrame(options);
    this.#state = states.RENDERED;

    // Post-render life-cycle events (not awaited)
    if ( options.isFirstRender ) {
      await this._doEvent(this._onFirstRender, {handlerArgs, debugText: "After first render", async: true});
    }
    await this._doEvent(this._onRender, {
      handlerArgs, debugText: "After render", eventName: "render", hookName: "render",
      hookArgs: [this.#element, ...handlerArgs], async: true
    });

    // Finalize render after hooks have run.
    await this._doEvent(this._postRender, {handlerArgs, debugText: "Render finalization", async: true});

    // Update application position
    if ( "position" in options ) this.setPosition(options.position);
    if ( options.force ) this.maximize().then(() => this.bringToFront());
    return this;
  }

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

  /**
   * Modify the provided options passed to a render request.
   * @param {RenderOptions} options                 Options which configure application rendering behavior
   * @protected
   */
  _configureRenderOptions(options) {
    const {window, position} = this.options;

    // Initial frame options
    if ( options.isFirstRender ) {
      if ( this.hasFrame ) {
        options.window ??= {};
        options.window.title = (options.window.title || this.title).replace(/\s+/g, " ").trim();
        options.window.icon ||= window.icon;
        options.window.controls = true;
        options.window.resizable = window.resizable;
      }
    }

    // Automatic repositioning
    if ( options.isFirstRender ) options.position = Object.assign(this.#position, options.position);
    else {
      if ( position.width === "auto" ) options.position = Object.assign({width: "auto"}, options.position);
      if ( position.height === "auto" ) options.position = Object.assign({height: "auto"}, options.position);
    }

    // Tabs
    if ( options.tab ) {
      const tabType = foundry.utils.getType(options.tab);
      if ( tabType === "string" ) this.tabGroups[Object.keys(this.constructor.TABS)[0]] = options.tab;
      else if ( tabType === "Object" ) {
        for ( const [tabGroup, tabId] of Object.entries(options.tab) ) this.tabGroups[tabGroup] = tabId;
      }
    }
  }

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

  /**
   * Prepare application rendering context data for a given render request. If exactly one tab group is configured for
   * this application, it will be prepared automatically.
   * @param {RenderOptions} options                 Options which configure application rendering behavior
   * @returns {Promise<ApplicationRenderContext>}   Context data for the render operation
   * @protected
   */
  async _prepareContext(options) {
    const tabGroupIds = Object.keys(this.constructor.TABS);
    return tabGroupIds.length === 1 ? {tabs: this._prepareTabs(tabGroupIds[0])} : {};
  }

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

  /**
   * Prepare application tab data for a single tab group.
   * @param {string} group The ID of the tab group to prepare
   * @returns {Record<string, ApplicationTab>}
   * @protected
   */
  _prepareTabs(group) {
    const {tabs, labelPrefix, initial=null} = this._getTabsConfig(group) ?? {tabs: []};
    this.tabGroups[group] ??= initial;
    return tabs.reduce((prepared, {id, cssClass, ...tabConfig}) => {
      const active = this.tabGroups[group] === id;
      if ( active ) cssClass = [cssClass, "active"].filterJoin(" ");
      const tab = {group, id, active, cssClass, ...tabConfig};
      if ( labelPrefix ) tab.label ??= `${labelPrefix}.${id}`;
      prepared[id] = tab;
      return prepared;
    }, {});
  }

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

  /**
   * Get the configuration for a tabs group.
   * @param {string} group The ID of a tabs group
   * @returns {ApplicationTabsConfiguration|null}
   * @protected
   */
  _getTabsConfig(group) {
    return this.constructor.TABS[group] ?? null;
  }

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

  /**
   * Configure the array of header control menu options
   * @returns {ApplicationHeaderControlsEntry[]}
   * @protected
   */
  _getHeaderControls() {
    return this.options.window.controls?.slice() ?? [];
  }

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

  /**
   * Iterate over header control buttons, filtering for controls which are visible for the current client.
   * @returns {Generator<ApplicationHeaderControlsEntry>}
   * @yields {ApplicationHeaderControlsEntry}
   * @protected
   */
  *_headerControlButtons() {
    const controls = this._doEvent(this._getHeaderControls, {
      async: false,
      debugText: "Header Control Buttons",
      hookName: "getHeaderControls",
      hookResponse: true
    });
    for ( const control of controls ) {
      const visible = typeof control.visible === "function" ? control.visible.call(this) : control.visible ?? true;
      if ( visible ) yield control;
    }
  }

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

  /**
   * Render an HTMLElement for the Application.
   * An Application subclass must implement this method in order for the Application to be renderable.
   * @param {ApplicationRenderContext} context      Context data for the render operation
   * @param {RenderOptions} options                 Options which configure application rendering behavior
   * @returns {Promise<any>}                        The result of HTML rendering may be implementation specific.
   *                                                Whatever value is returned here is passed to _replaceHTML
   * @abstract
   */
  async _renderHTML(context, options) {}

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

  /**
   * Replace the HTML of the application with the result provided by the rendering backend.
   * An Application subclass should implement this method in order for the Application to be renderable.
   * @param {any} result                            The result returned by the application rendering backend
   * @param {HTMLElement} content                   The content element into which the rendered result must be inserted
   * @param {RenderOptions} options                 Options which configure application rendering behavior
   * @protected
   */
  _replaceHTML(result, content, options) {}

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

  /**
   * Render the outer framing HTMLElement which wraps the inner HTML of the Application.
   * @param {RenderOptions} options                 Options which configure application rendering behavior
   * @returns {Promise<HTMLElement>}
   * @protected
   */
  async _renderFrame(options) {
    const frame = document.createElement(this.options.tag);
    frame.id = this.#id;
    if ( this.options.classes.length ) frame.className = this.options.classes.join(" ");
    if ( !this.hasFrame ) return frame;

    // Window applications
    const labels = {
      controls: game.i18n.localize("APPLICATION.TOOLS.ControlsMenu"),
      toggleControls: game.i18n.localize("APPLICATION.TOOLS.ToggleControls"),
      close: game.i18n.localize("APPLICATION.TOOLS.Close")
    };
    frame.innerHTML = `<header class="window-header">
      <i class="window-icon hidden" inert></i>
      <h1 class="window-title"></h1>
      <button type="button" class="header-control icon fa-solid fa-ellipsis-vertical"
              data-tooltip="${labels.toggleControls}" aria-label="${labels.toggleControls}"
              data-action="toggleControls"></button>
      <button type="button" class="header-control icon fa-solid fa-xmark"
              data-tooltip="${labels.close}" aria-label="${labels.close}" data-action="close"></button>
    </header>
    <menu class="controls-dropdown"></menu>`;
    const content = document.createElement(this.options.window.contentTag);
    content.classList.add("window-content", ...this.options.window.contentClasses);
    frame.appendChild(content);
    if ( this.options.window.resizable ) frame.insertAdjacentHTML("beforeend", '<div class="window-resize-handle"></div>');

    // Reference elements
    this.#window.header = frame.querySelector(".window-header");
    this.#window.title = frame.querySelector(".window-title");
    this.#window.icon = frame.querySelector(".window-icon");
    this.#window.resize = frame.querySelector(".window-resize-handle");
    this.#window.close = frame.querySelector("button[data-action=close]");
    this.#window.content = content;
    this.#window.controls = frame.querySelector("button[data-action=toggleControls]");
    this.#window.controlsDropdown = frame.querySelector(".controls-dropdown");
    return frame;
  }

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

  /**
   * Render a header control button.
   * @param {ApplicationHeaderControlsEntry} control
   * @returns {HTMLLIElement}
   * @protected
   */
  _renderHeaderControl(control) {
    const li = document.createElement("li");
    li.className = "header-control";
    li.dataset.action = control.action;
    const button = document.createElement("button");
    button.type = "button";
    button.className = "control";
    const i = document.createElement("i");
    i.className = `control-icon fa-fw ${control.icon}`;
    const span = document.createElement("span");
    span.className = "control-label";
    span.innerText = game.i18n.localize(control.label);
    button.append(i, span);
    li.append(button);
    if ( typeof control.onClick === "function" ) {
      button.addEventListener("click", event => {
        event.preventDefault();
        control.onClick(event);
      });
    }
    return li;
  }

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

  /**
   * When the Application is rendered, optionally update aspects of the window frame.
   * @param {RenderOptions} options               Options provided at render-time
   * @protected
   */
  _updateFrame(options) {
    const window = options.window;
    if ( !window ) return;
    if ( "title" in window ) this.#window.title.innerText = window.title;
    if ( "icon" in window ) this.#window.icon.className = `window-icon fa-fw ${window.icon || "hidden"}`;

    // Window header controls
    const controls = [];
    for ( const c of this._headerControlButtons() ) {
      controls.push(this._renderHeaderControl(c));
    }
    this.#window.controlsDropdown.replaceChildren(...controls);
    this.#window.controls.classList.toggle("hidden", !controls.length);
  }

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

  /**
   * Insert the application HTML element into the DOM.
   * Subclasses may override this method to customize how the application is inserted.
   * @param {HTMLElement} element                 The element to insert
   * @protected
   */
  _insertElement(element) {
    const existing = document.getElementById(element.id);
    if ( existing ) existing.replaceWith(element);
    else document.body.append(element);
  }

  /* -------------------------------------------- */
  /*  Closing                                     */
  /* -------------------------------------------- */

  /**
   * Close the Application, removing it from the DOM.
   * @param {Partial<ApplicationClosingOptions>} [options]  Options which modify how the application is closed.
   * @returns {Promise<this>}                               A Promise which resolves to the closed Application instance
   */
  async close(options={}) {
    return this.#semaphore.add(this.#close.bind(this), options);
  }

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

  /**
   * Manage the closing step of the Application life-cycle.
   * This private method delegates out to several protected methods which can be defined by the subclass.
   * @param {Partial<ApplicationClosingOptions>} options    Options which modify how the application is closed
   * @returns {Promise<this>}                               A Promise which resolves to the closed Application instance
   */
  async #close(options) {
    const states = ApplicationV2.RENDER_STATES;
    if ( !this.#element ) {
      this.#state = states.CLOSED;
      return this;
    }

    // Pre-close life-cycle events (awaited)
    await this._doEvent(this._preClose, {async: true, handlerArgs: [options], debugText: "Before close"});

    // Toggle controls
    await this.toggleControls(false, {animate: false});

    // Set explicit dimensions for the transition.
    if ( options.animate !== false ) {
      const { width, height } = this.#element.getBoundingClientRect();
      this.#applyPosition({ ...this.#position, width, height });
    }

    // Animate the window closing
    this.#element.classList.add("minimizing");
    this.#element.style.maxHeight = "0px";
    this.#state = states.CLOSING;
    if ( options.animate !== false ) await this._awaitTransition(this.#element, 1000);

    // Tearing down and removing the instance
    this._tearDown(options);
    this.#state = states.CLOSED;
    foundry.applications.instances.delete(this.#id);

    // Reset minimization state and restore original size
    if ( this.minimized ) {
      const {priorWidth, priorHeight} = this.#minimization;
      if ( priorWidth ) this.#position.width = priorWidth;
      if ( priorHeight ) this.#position.height = priorHeight;
      this.#minimization.active = false;
    }

    // Post-close life-cycle events (not awaited)
    this._doEvent(this._onClose, {handlerArgs: [options], debugText: "After close", eventName: "close",
      hookName: "close"});
    return this;
  }

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

  /**
   * Remove the application HTML element from the DOM.
   * Subclasses may override this method to customize how the application element is removed.
   * @param {HTMLElement} element                 The element to be removed
   * @protected
   */
  _removeElement(element) {
    element.remove();
  }

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

  /**
   * Remove elements from the DOM and trigger garbage collection as part of application closure.
   * @param {ApplicationClosingOptions} options
   * @protected
   */
  _tearDown(options) {
    this._removeElement(this.#element);
    this.#element = null;
    this.#content = null;
    Object.assign(this.#window, ApplicationV2.#INITIAL_WINDOW_VALUES);
  }

  /* -------------------------------------------- */
  /*  Positioning                                 */
  /* -------------------------------------------- */

  /**
   * Update the Application element position using provided data which is merged with the prior position.
   * @param {Partial<ApplicationPosition>} [position] New Application positioning data
   * @returns {ApplicationPosition|void}              The updated application position
   */
  setPosition(position) {
    if ( !this.options.window.positioned ) return;
    position = Object.assign(this.#position, position);
    this._doEvent(this._prePosition, {handlerArgs: [position], debugText: "Before reposition"});

    // Update resolved position
    const updated = this._updatePosition(position);
    Object.assign(this.#position, updated);

    // Assign CSS styles
    this.#applyPosition(updated);
    this._doEvent(this._onPosition, {handlerArgs: [position], debugText: "After reposition", eventName: "position"});
    return position;
  }

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

  /**
   * Translate a requested application position updated into a resolved allowed position for the Application.
   * Subclasses may override this method to implement more advanced positioning behavior.
   * @param {ApplicationPosition} position        Requested Application positioning data
   * @returns {ApplicationPosition}               Resolved Application positioning data
   * @protected
   */
  _updatePosition(position) {
    if ( !this.#element ) return position;
    const el = this.#element;
    let {width, height, left, top, scale} = position;
    scale ??= 1.0;
    const computedStyle = getComputedStyle(el);
    let minWidth = ApplicationV2.parseCSSDimension(computedStyle.minWidth, el.parentElement.offsetWidth) || 0;
    let maxWidth = ApplicationV2.parseCSSDimension(computedStyle.maxWidth, el.parentElement.offsetWidth) || Infinity;
    let minHeight = ApplicationV2.parseCSSDimension(computedStyle.minHeight, el.parentElement.offsetHeight) || 0;
    let maxHeight = ApplicationV2.parseCSSDimension(computedStyle.maxHeight, el.parentElement.offsetHeight) || Infinity;
    let bounds = el.getBoundingClientRect();
    const {clientWidth, clientHeight} = document.documentElement;

    // Explicit width
    const autoWidth = width === "auto";
    if ( !autoWidth ) {
      const targetWidth = Number(width || bounds.width);
      minWidth = parseInt(minWidth) || 0;
      maxWidth = parseInt(maxWidth) || (clientWidth / scale);
      width = Math.clamp(targetWidth, minWidth, maxWidth);
    }

    // Explicit height
    const autoHeight = height === "auto";
    if ( !autoHeight ) {
      const targetHeight = Number(height || bounds.height);
      minHeight = parseInt(minHeight) || 0;
      maxHeight = parseInt(maxHeight) || (clientHeight / scale);
      height = Math.clamp(targetHeight, minHeight, maxHeight);
    }

    // Implicit height
    if ( autoHeight ) {
      Object.assign(el.style, {width: `${width}px`, height: ""});
      bounds = el.getBoundingClientRect();
      height = bounds.height;
    }

    // Implicit width
    if ( autoWidth ) {
      Object.assign(el.style, {height: `${height}px`, width: ""});
      bounds = el.getBoundingClientRect();
      width = bounds.width;
    }

    // Left Offset
    const scaledWidth = width * scale;
    const targetLeft = left ?? ((clientWidth - scaledWidth) / 2);
    const maxLeft = Math.max(clientWidth - scaledWidth, 0);
    left = Math.clamp(targetLeft, 0, maxLeft);

    // Top Offset
    const scaledHeight = height * scale;
    const targetTop = top ?? ((clientHeight - scaledHeight) / 2);
    const maxTop = Math.max(clientHeight - scaledHeight, 0);
    top = Math.clamp(targetTop, 0, maxTop);

    // Scale
    scale ??= 1.0;
    return {width: autoWidth ? "auto" : width, height: autoHeight ? "auto" : height, left, top, scale};
  }

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

  /**
   * Apply validated position changes to the element.
   * @param {ApplicationPosition} position  The new position data to apply.
   */
  #applyPosition(position) {
    Object.assign(this.#element.style, {
      width: position.width === "auto" ? "" : `${position.width}px`,
      height: position.height === "auto" ? "" : `${position.height}px`,
      left: `${position.left}px`,
      top: `${position.top}px`,
      transform: position.scale === 1 ? "" : `scale(${position.scale})`
    });
  }

  /* -------------------------------------------- */
  /*  Other Public Methods                        */
  /* -------------------------------------------- */

  /**
   * Is the window control buttons menu currently expanded?
   * @type {boolean}
   */
  #controlsExpanded = false;

  /**
   * Toggle display of the Application controls menu.
   * Only applicable to window Applications.
   * @param {boolean} [expanded]      Set the controls visibility to a specific state.
   *                                  Otherwise, the visible state is toggled from its current value
   * @param {object} [options]                Options to configure the toggling behavior.
   * @param {boolean} [options.animate=true]  Animate the controls toggling.
   * @returns {Promise<void>}         A Promise which resolves once the control expansion animation is complete
   */
  async toggleControls(expanded, {animate=true}={}) {
    expanded ??= !this.#controlsExpanded;
    if ( expanded === this.#controlsExpanded ) return;
    const dropdown = this.#element.querySelector(".controls-dropdown");
    game.tooltip.deactivate();
    this.#controlsExpanded = expanded;
    dropdown.classList.remove("expanded");
    if ( animate ) {
      const transitionClass = expanded ? "expanding" : "collapsing";
      dropdown.classList.add(transitionClass);
      await this._awaitTransition(dropdown, 1000);
      dropdown.classList.remove(transitionClass);
    }
    if ( expanded ) dropdown.classList.add("expanded");
  }

  /* -------------------------------------------- */

  /**
   * Minimize the Application, collapsing it to a minimal header.
   * @returns {Promise<void>}
   */
  async minimize() {
    if ( this.minimized || !this.rendered || !this.options.window.minimizable ) return;
    this.#minimization.active = true;

    // Set explicit dimensions for the transition.
    const { width, height } = this.#element.getBoundingClientRect();
    this.#applyPosition({ ...this.#position, width, height });

    // Record pre-minimization data
    this.#minimization.priorWidth = this.#position.width;
    this.#minimization.priorHeight = this.#position.height;
    this.#minimization.priorBoundingWidth = width;
    this.#minimization.priorBoundingHeight = height;

    // Animate to collapsed size
    this.#element.classList.add("minimizing");
    this.#element.style.maxWidth = "var(--minimized-width)";
    this.#element.style.maxHeight = "var(--header-height)";
    await this._awaitTransition(this.#element, 1000);
    this.#element.classList.add("minimized");
    this.#element.classList.remove("minimizing");
  }

  /* -------------------------------------------- */

  /**
   * Restore the Application to its original dimensions.
   * @returns {Promise<void>}
   */
  async maximize() {
    if ( !this.minimized ) return;
    this.#minimization.active = false;

    // Animate back to full size
    this.#element.classList.remove("minimized");
    this.#element.classList.add("maximizing");
    this.#element.style.maxWidth = "";
    this.#element.style.maxHeight = "";
    await this._awaitTransition(this.#element, 1000);

    // Set position
    const {priorBoundingWidth: width, priorBoundingHeight: height} = this.#minimization;
    this.setPosition({width, height});
    this.#element.classList.remove("maximizing");
  }

  /* -------------------------------------------- */

  /**
   * Bring this Application window to the front of the rendering stack by increasing its z-index.
   * Once ApplicationV1 is deprecated we should switch from _maxZ to ApplicationV2#maxZ
   * We should also eliminate ui.activeWindow in favor of only ApplicationV2#frontApp
   */
  bringToFront() {
    if ( !this.options.window?.frame ) return;
    if ( !((ApplicationV2.#frontApp === this) && (ui.activeWindow === this)) ) {
      this.#position.zIndex = ++ApplicationV2._maxZ;
    }
    this.#element.style.zIndex = String(this.#position.zIndex);
    ApplicationV2.#frontApp = this;
    ui.activeWindow = this; // ApplicationV1 compatibility
  }

  /* -------------------------------------------- */

  /**
   * Change the active tab within a tab group in this Application instance.
   * @param {string} tab        The name of the tab which should become active
   * @param {string} group      The name of the tab group which defines the set of tabs
   * @param {object} [options]  Additional options which affect tab navigation
   * @param {Event} [options.event]                 An interaction event which caused the tab change, if any
   * @param {HTMLElement} [options.navElement]      An explicit navigation element being modified
   * @param {boolean} [options.force=false]         Force changing the tab even if the new tab is already active
   * @param {boolean} [options.updatePosition=true] Update application position after changing the tab?
   */
  changeTab(tab, group, {event, navElement, force=false, updatePosition=true}={}) {
    if ( !tab || !group ) throw new Error("You must pass both the tab and tab group identifier");
    if ( (this.tabGroups[group] === tab) && !force ) return;  // No change necessary
    const tabElement = this.#content.querySelector(`.tabs [data-group="${group}"][data-tab="${tab}"]`);
    if ( !tabElement ) throw new Error(`No matching tab element found for group "${group}" and tab "${tab}"`);

    // Update tab navigation
    for ( const t of this.#content.querySelectorAll(`.tabs [data-group="${group}"]`) ) {
      t.classList.toggle("active", t.dataset.tab === tab);
      if ( t instanceof HTMLButtonElement ) t.ariaPressed = `${t.dataset.tab === tab}`;
    }

    // Update tab contents
    for ( const section of this.#content.querySelectorAll(`.tab[data-group="${group}"]`) ) {
      section.classList.toggle("active", section.dataset.tab === tab);
    }
    this.tabGroups[group] = tab;

    // Update automatic width or height
    if ( !updatePosition ) return;
    const positionUpdate = {};
    if ( this.options.position.width === "auto" ) positionUpdate.width = "auto";
    if ( this.options.position.height === "auto" ) positionUpdate.height = "auto";
    if ( !foundry.utils.isEmpty(positionUpdate) ) this.setPosition(positionUpdate);
  }

  /* -------------------------------------------- */

  /**
   * Programmatically submit an ApplicationV2 instance which implements a single top-level form.
   * @param {object} [submitOptions]  Arbitrary options which are supported by and provided to the configured form
   *                                  submission handler.
   * @returns {Promise<*>}            A promise that resolves to the returned result of the form submission handler,
   *                                  if any.
   */
  async submit(submitOptions={}) {
    const formConfig = this.options.form;
    if ( !formConfig?.handler ) throw new Error(`The ${this.constructor.name} Application does not support a`
      + " single top-level form element.");
    const form = this.form;
    const event = new SubmitEvent("submit", {cancelable: true});
    const formData = new FormDataExtended(form);
    return formConfig.handler.call(this, event, form, formData, submitOptions);
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /**
   * Perform an event in the application life-cycle.
   * Await an internal life-cycle method defined by the class.
   * Optionally dispatch an event for any registered listeners.
   * @param {Function} handler        A handler function to call
   * @param {object} options          Options which configure event handling
   * @param {boolean} [options.async]         Await the result of the handler function?
   * @param {any[]} [options.handlerArgs]     Arguments passed to the handler function
   * @param {string} [options.debugText]      Debugging text to log for the event
   * @param {string} [options.eventName]      An event name to dispatch for registered listeners
   * @param {string} [options.hookName]       A hook name to dispatch for this and all parent classes
   * @param {any[]} [options.hookArgs]        Arguments passed to the requested hook function
   * @param {boolean} [options.hookResponse=false]  Add the handler response to hookArgs
   * @param {boolean} [options.parentClassHooks=true] Call hooks for parent classes in the inheritance chain?
   * @returns {Promise<void>|void}    A promise which resoles once the handler is complete if async is true
   * @internal
   */
  _doEvent(handler, {async=false, handlerArgs=[], debugText, eventName, hookName, hookArgs=[],
    hookResponse=false, parentClassHooks=true}={}) {

    // Debug logging
    if ( debugText && CONFIG.debug.applications ) {
      console.debug(`${this.constructor.name} | ${debugText}`);
    }

    // Async Events
    const response = handler.call(this, ...handlerArgs);
    if ( async && (response instanceof Promise) ) return response.then(r => {
      if ( hookResponse ) hookArgs = [...hookArgs, r];
      this.#dispatchEvent(eventName, hookName, hookArgs, parentClassHooks);
      return r;
    });

    // Sync Events
    else {
      if ( hookResponse ) hookArgs = [...hookArgs, response];
      this.#dispatchEvent(eventName, hookName, hookArgs, parentClassHooks);
      return response;
    }
  }

  /* -------------------------------------------- */

  /**
   * Dispatch downstream workflows after either an async or sync event.
   * @param {string} eventName
   * @param {string} hookName
   * @param {any[]} hookArgs
   * @param {boolean} parentClassHooks
   */
  #dispatchEvent(eventName, hookName, hookArgs, parentClassHooks) {

    // Dispatch event for this Application instance
    if ( eventName ) this.dispatchEvent(new Event(eventName, { bubbles: true, cancelable: true }));

    // Call hooks for this Application class
    if ( hookName ) this.#callHooks(hookName, hookArgs, parentClassHooks);
  }

  /* -------------------------------------------- */

  /**
   * Call hooks for this Application class.
   * @param {string} hookName             The hook name.
   * @param {any[]} hookArgs              Arguments passed to the hook.
   * @param {boolean} parentClassHooks    Call hooks for parent classes in the inheritance chain?
   */
  #callHooks(hookName, hookArgs, parentClassHooks) {
    if ( parentClassHooks && !hookName.includes("{}") ) hookName += "{}";
    const classes = parentClassHooks ? this.constructor.inheritanceChain() : [this.constructor];
    for ( const cls of classes ) {
      if ( !cls.name ) continue;
      Hooks$1.callAll(hookName.replace("{}", cls.name), this, ...hookArgs);
    }
  }

  /* -------------------------------------------- */
  /*  Rendering Life-Cycle Methods                */
  /* -------------------------------------------- */

  /**
   * Test whether this Application is allowed to be rendered.
   * @param {RenderOptions} options                 Provided render options
   * @returns {false|void}                          Return false to prevent rendering
   * @throws {Error}                                An Error to display a warning message
   * @protected
   */
  _canRender(options) {}

  /* -------------------------------------------- */

  /**
   * Actions performed before a first render of the Application.
   * @param {ApplicationRenderContext} context      Prepared context data
   * @param {RenderOptions} options                 Provided render options
   * @returns {Promise<void>}
   * @protected
   */
  async _preFirstRender(context, options) {}

  /* -------------------------------------------- */

  /**
   * Actions performed after a first render of the Application.
   * @param {ApplicationRenderContext} context      Prepared context data
   * @param {RenderOptions} options                 Provided render options
   * @returns {Promise<void>}
   * @protected
   */
  async _onFirstRender(context, options) {}

  /* -------------------------------------------- */

  /**
   * Actions performed before any render of the Application.
   * Pre-render steps are awaited by the render process.
   * @param {ApplicationRenderContext} context      Prepared context data
   * @param {RenderOptions} options                 Provided render options
   * @returns {Promise<void>}
   * @protected
   */
  async _preRender(context, options) {}

  /* -------------------------------------------- */

  /**
   * Actions performed after any render of the Application.
   * @param {ApplicationRenderContext} context      Prepared context data
   * @param {RenderOptions} options                 Provided render options
   * @returns {Promise<void>}
   * @protected
   */
  async _onRender(context, options) {}

  /* -------------------------------------------- */

  /**
   * Perform post-render finalization actions.
   * @param {ApplicationRenderContext} context  Prepared context data.
   * @param {RenderOptions} options             Provided render options.
   * @returns {Promise<void>}
   * @protected
   */
  async _postRender(context, options) {
    if ( options.isFirstRender ) this.#element.querySelector("[autofocus]")?.focus();
  }

  /* -------------------------------------------- */

  /**
   * Actions performed before closing the Application.
   * Pre-close steps are awaited by the close process.
   * @param {RenderOptions} options                 Provided render options
   * @returns {Promise<void>}
   * @protected
   */
  async _preClose(options) {}

  /* -------------------------------------------- */

  /**
   * Actions performed after closing the Application.
   * Post-close steps are not awaited by the close process.
   * @param {RenderOptions} options Provided render options
   * @protected
   */
  _onClose(options) {}

  /* -------------------------------------------- */

  /**
   * Actions performed before the Application is re-positioned.
   * Pre-position steps are not awaited because setPosition is synchronous.
   * @param {ApplicationPosition} position          The requested application position
   * @protected
   */
  _prePosition(position) {}

  /* -------------------------------------------- */

  /**
   * Actions performed after the Application is re-positioned.
   * @param {ApplicationPosition} position          The requested application position
   * @protected
   */
  _onPosition(position) {}

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Attach event listeners to the Application frame.
   * @protected
   */
  _attachFrameListeners() {

    // Application Click Events
    this.#element.addEventListener("pointerdown", this.#onPointerDown.bind(this), {capture: true});
    const click = this.#onClick.bind(this);
    this.#element.addEventListener("click", click);
    this.#element.addEventListener("contextmenu", click);

    if ( this.hasFrame ) {
      this.bringToFront();
      this.#window.header.addEventListener("pointerdown", this.#onWindowDragStart.bind(this));
      this.#window.header.addEventListener("dblclick", this.#onWindowDoubleClick.bind(this));
      this.#window.resize?.addEventListener("pointerdown", this.#onWindowResizeStart.bind(this));
    }

    // Form handlers
    const form = this.form;
    if ( form ) {
      form.autocomplete = "off";
      form.addEventListener("submit", this._onSubmitForm.bind(this, this.options.form));
      form.addEventListener("change", this._onChangeForm.bind(this, this.options.form));
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle initial pointerdown events inside a rendered Application.
   * @param {PointerEvent} event
   */
  async #onPointerDown(event) {
    if ( this.hasFrame ) this.bringToFront();
  }

  /* -------------------------------------------- */

  /**
   * Centralized handling of click events which occur on or within the Application frame.
   * @param {PointerEvent} event
   */
  async #onClick(event) {
    const target = event.target;
    const actionButton = target.closest("[data-action]");
    if ( actionButton ) return this.#onClickAction(event, actionButton);
    this.toggleControls(false);
  }

  /* -------------------------------------------- */

  /**
   * Handle a click event on an element which defines a [data-action] handler.
   * @param {PointerEvent} event      The originating click event
   * @param {HTMLElement} target      The capturing HTML element which defined a [data-action]
   */
  #onClickAction(event, target) {
    const action = target.dataset.action;
    switch ( action ) {
      case "close":
        event.stopPropagation();
        event.preventDefault();
        if ( event.button === 0 ) this.close();
        else this.toggleControls(false);
        break;
      case "tab":
        this.toggleControls(false);
        this._onClickTab(event);
        break;
      case "toggleControls":
        event.stopPropagation();
        event.preventDefault();
        this.toggleControls(event.button === 0 ? undefined : false);
        break;
      default: {
        let handler = this.options.actions[action];

        // Toggle controls dropdown
        this.toggleControls(false);

        // No defined handler
        if ( !handler ) {
          this._onClickAction(event, target);
          break;
        }

        // Defined handler
        let buttons = [0];
        if ( typeof handler === "object" ) {
          buttons = handler.buttons;
          handler = handler.handler;
        }
        if ( buttons.includes(event.button) ) handler?.call(this, event, target);
        break;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on a tab within the Application.
   * @param {PointerEvent} event
   * @protected
   */
  _onClickTab(event) {
    const button = event.target;
    const tab = button.dataset.tab;
    if ( !tab || button.classList.contains("active") || (event.button !== 0) ) return;
    const group = button.dataset.group;
    const navElement = button.closest(".tabs");
    this.changeTab(tab, group, {event, navElement});
  }

  /* -------------------------------------------- */

  /**
   * A generic event handler for action clicks which can be extended by subclasses.
   * Action handlers defined in DEFAULT_OPTIONS are called first. This method is only called for actions which have
   * no defined handler.
   * @param {PointerEvent} event      The originating click event
   * @param {HTMLElement} target      The capturing HTML element which defined a [data-action]
   * @protected
   */
  _onClickAction(event, target) {}

  /* -------------------------------------------- */

  /**
   * Begin capturing pointer events on the application frame.
   * @param {PointerEvent} event  The triggering event.
   * @param {Function} callback   The callback to attach to pointer move events.
   */
  #startPointerCapture(event, callback) {
    this.#window.pointerStartPosition = Object.assign(foundry.utils.deepClone(this.#position), {
      clientX: event.clientX, clientY: event.clientY
    });
    this.#element.addEventListener("pointermove", callback, { passive: true });
    this.#element.addEventListener("pointerup", event => this.#endPointerCapture(event, callback), {
      capture: true, once: true
    });
  }

  /* -------------------------------------------- */

  /**
   * End capturing pointer events on the application frame.
   * @param {PointerEvent} event  The triggering event.
   * @param {Function} callback   The callback to remove from pointer move events.
   */
  #endPointerCapture(event, callback) {
    this.#element.releasePointerCapture(event.pointerId);
    this.#element.removeEventListener("pointermove", callback);
    delete this.#window.pointerStartPosition;
    this.#window.pointerMoveThrottle = false;
  }

  /* -------------------------------------------- */

  /**
   * Handle a pointer move event while dragging or resizing the window frame.
   * @param {PointerEvent} event
   * @returns {{dx: number, dy: number}|void}  The amount the cursor has moved since the last frame, or undefined if
   *                                           the movement occurred between frames.
   */
  #onPointerMove(event) {
    if ( this.#window.pointerMoveThrottle ) return;
    this.#window.pointerMoveThrottle = true;
    const dx = event.clientX - this.#window.pointerStartPosition.clientX;
    const dy = event.clientY - this.#window.pointerStartPosition.clientY;
    requestAnimationFrame(() => this.#window.pointerMoveThrottle = false);
    return { dx, dy };
  }

  /* -------------------------------------------- */

  /**
   * Begin dragging the Application position.
   * @param {PointerEvent} event
   */
  #onWindowDragStart(event) {
    if ( event.target.closest(".header-control") ) return;
    this.#endPointerCapture(event, this.#window.onDrag);
    this.#startPointerCapture(event, this.#window.onDrag);
  }

  /* -------------------------------------------- */

  /**
   * Begin resizing the Application.
   * @param {PointerEvent} event
   */
  #onWindowResizeStart(event) {
    for ( const dim of ["width", "height"] ) {
      if ( this.#position[dim] === "auto" ) {
        this.#position[dim] = this.#element[`client${dim.titleCase()}`];
        this.options.position[dim] = this.#position[dim];
      }
    }
    this.#endPointerCapture(event, this.#window.onResize);
    this.#startPointerCapture(event, this.#window.onResize);
  }

  /* -------------------------------------------- */

  /**
   * Drag the Application position during mouse movement.
   * @param {PointerEvent} event
   */
  #onWindowDragMove(event) {
    if ( !this.#window.header.hasPointerCapture(event.pointerId) ) {
      this.#window.header.setPointerCapture(event.pointerId);
    }
    const delta = this.#onPointerMove(event);
    if ( !delta ) return;
    const { pointerStartPosition } = this.#window;
    let { top, left, height, width } = pointerStartPosition;
    left += delta.dx;
    top += delta.dy;
    this.setPosition({ top, left, height, width });
  }

  /* -------------------------------------------- */

  /**
   * Resize the Application during mouse movement.
   * @param {PointerEvent} event
   */
  #onWindowResizeMove(event) {
    if ( !this.#window.resize.hasPointerCapture(event.pointerId) ) {
      this.#window.resize.setPointerCapture(event.pointerId);
    }
    const delta = this.#onPointerMove(event);
    if ( !delta ) return;
    const { scale } = this.#position;
    const { pointerStartPosition } = this.#window;
    let { top, left, height, width } = pointerStartPosition;
    width += delta.dx / scale;
    height += delta.dy / scale;
    this.setPosition({ top, left, width, height });
  }

  /* -------------------------------------------- */

  /**
   * Double-click events on the window title are used to minimize or maximize the application.
   * @param {PointerEvent} event
   */
  #onWindowDoubleClick(event) {
    event.preventDefault();
    if ( event.target.dataset.action ) return; // Ignore double clicks on buttons which perform an action
    if ( !this.options.window.minimizable ) return;
    if ( this.minimized ) this.maximize();
    else this.minimize();
  }

  /* -------------------------------------------- */

  /**
   * Handle submission for an Application which uses the form element.
   * @param {ApplicationFormConfiguration} formConfig     The form configuration for which this handler is bound
   * @param {Event|SubmitEvent} event                     The form submission event
   * @returns {Promise<void>}
   * @protected
   */
  async _onSubmitForm(formConfig, event) {
    event.preventDefault();
    const form = event.currentTarget;
    const {handler, closeOnSubmit} = formConfig;
    const formData = new FormDataExtended(form);
    if ( handler instanceof Function ) {
      try {
        await handler.call(this, event, form, formData);
      } catch(err) {
        ui.notifications.error(err, {console: true});
        return; // Do not close
      }
    }
    if ( closeOnSubmit ) await this.close({submitted: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to an input element within the form.
   * @param {ApplicationFormConfiguration} formConfig     The form configuration for which this handler is bound
   * @param {Event} event                                 An input change event within the form
   * @protected
   */
  _onChangeForm(formConfig, event) {
    const { RENDERED, CLOSING } = ApplicationV2.RENDER_STATES;
    const open = (this.#state === RENDERED) || (this.#state === CLOSING);
    if ( open && formConfig.submitOnChange ) this._onSubmitForm(formConfig, event);
  }

  /* -------------------------------------------- */
  /*  Helper Methods                              */
  /* -------------------------------------------- */

  /**
   * Parse a CSS style rule into a number of pixels which apply to that dimension.
   * @param {string} style            The CSS style rule
   * @param {number} parentDimension  The relevant dimension of the parent element
   * @returns {number|void}           The parsed style dimension in pixels
   */
  static parseCSSDimension(style, parentDimension) {
    if ( style.includes("px") ) return parseInt(style.replace("px", ""));
    if ( style.includes("%") ) {
      const p = parseInt(style.replace("%", "")) / 100;
      return parentDimension * p;
    }
  }

  /* -------------------------------------------- */

  /**
   * Wait for a CSS transition to complete for an element.
   * @param {HTMLElement} element         The element which is transitioning
   * @param {number} timeout              A timeout in milliseconds in case the transitionend event does not occur
   * @returns {Promise<void>}
   * @internal
   */
  async _awaitTransition(element, timeout) {
    let listener;
    await Promise.race([
      new Promise(resolve => {
        listener = event => { if ( event.target === element ) resolve(); };
        element.addEventListener("transitionend", listener);
      }),
      new Promise(resolve => window.setTimeout(resolve, timeout))
    ]);
    element.removeEventListener("transitionend", listener);
  }

  /* -------------------------------------------- */

  /**
   * Create a ContextMenu instance used in this Application.
   * @param {() => ContextMenuEntry[]} handler  A handler function that provides initial context options
   * @param {string} selector                   A CSS selector to which the ContextMenu will be bound
   * @param {object} [options]                  Additional options which affect ContextMenu construction
   * @param {HTMLElement} [options.container]   A parent HTMLElement which contains the selector target
   * @param {string} [options.hookName]         The hook name
   * @param {boolean} [options.parentClassHooks=true]  Whether to call hooks for the parent classes in the inheritance
   *                                                   chain.
   * @returns {ContextMenu|null}                A created ContextMenu or null if no menu items were defined
   * @protected
   */
  _createContextMenu(handler, selector, {container, hookName, parentClassHooks, ...options}={}) {
    container ??= this.element;
    hookName ??= "get{}ContextOptions";
    const menuItems = this._doEvent(handler, {hookName, parentClassHooks, hookResponse: true});
    if ( !menuItems.length ) return null;
    return new ContextMenu.implementation(container, selector, menuItems, {jQuery: false, ...options});
  }

  /* -------------------------------------------- */

  /**
   * Wait for any images in the given element to load.
   * @param {HTMLElement} element  The element.
   * @returns {Promise<void>}
   */
  static async waitForImages(element) {
    const images = Array.from(element.querySelectorAll("img")).filter(img => !img.complete);
    if ( !images.length ) return;
    let loaded = 0;
    const { promise, resolve } = Promise.withResolvers();
    const onLoad = img => {
      loaded++;
      img.onload = img.onerror = null;
      if ( loaded >= images.length ) resolve();
    };
    for ( const img of images ) img.onload = img.onerror = onLoad.bind(img, img);
    return promise;
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  bringToTop() {
    foundry.utils.logCompatibilityWarning(`ApplicationV2#bringToTop is not a valid function and redirects to
      ApplicationV2#bringToFront. This shim will be removed in v14.`, {since: 12, until: 14});
    return this.bringToFront();
  }
}

/**
 * @import ApplicationV2 from "./application.mjs";
 * @import {Constructor} from "@common/_types.mjs";
 * @import {ApplicationConfiguration, ApplicationFormConfiguration} from "../_types.mjs";
 */

/**
 * @typedef HandlebarsRenderOptions
 * @property {string[]} parts                       An array of named template parts to render
 */

/**
 * @typedef HandlebarsTemplatePart
 * @property {string} template                      The template entry-point for the part
 * @property {string} [id]                          A CSS id to assign to the top-level element of the rendered part.
 *                                                  This id string is automatically prefixed by the application id.
 * @property {boolean} [root=false]                 Does this rendered contents of this template part replace the
 *                                                  children of the root element?
 * @property {string[]} [classes]                   An array of CSS classes to apply to the top-level element of the
 *                                                  rendered part.
 * @property {string[]} [templates]                 An array of additional templates that are required to render the
 *                                                  part. If omitted, only the entry-point is inferred as required.
 * @property {string[]} [scrollable]                An array of selectors within this part whose scroll positions should
 *                                                  be persisted during a re-render operation. A blank string is used
 *                                                  to denote that the root level of the part is scrollable.
 * @property {Record<string, ApplicationFormConfiguration>} [forms] A registry of forms selectors and submission
 *                                                                  handlers.
 */

/**
 * Augment an Application class with [Handlebars](https://handlebarsjs.com) template rendering behavior.
 * @param {Constructor<ApplicationV2>} BaseApplication
 */
function HandlebarsApplicationMixin(BaseApplication) {
  /**
   * The mixed application class augmented with [Handlebars](https://handlebarsjs.com) template rendering behavior.
   * @extends {ApplicationV2<ApplicationConfiguration, HandlebarsRenderOptions>}
   */
  class HandlebarsApplication extends BaseApplication {
    constructor(...args) {
      super(...args);
      Object.values(this.constructor.PARTS).forEach((part, i) => {
        if ( part.root && (i > 0) ) throw new Error("A root template must be first in the PARTS record.");
      });
    }

    /**
     * Configure a registry of template parts which are supported for this application for partial rendering.
     * @type {Record<string, HandlebarsTemplatePart>}
     */
    static PARTS = {};

    /**
     * A record of all rendered template parts.
     * @returns {Record<string, HTMLElement>}
     */
    get parts() {
      return this.#parts;
    }

    #parts = {};

    /**
     * Dynamically configured part descriptors.
     * @type {Readonly<Record<string, HandlebarsTemplatePart>>}
     */
    #partDescriptors;

    /* -------------------------------------------- */

    /** @inheritDoc */
    _configureRenderOptions(options) {
      super._configureRenderOptions(options);
      this.#partDescriptors = Object.freeze(this._configureRenderParts(options));
      // Render all parts if we are rendering the root element
      if ( options.parts && options.parts.some(id => this.#partDescriptors[id]?.root) ) options.parts = undefined;
      options.parts ??= Object.keys(this.#partDescriptors);
    }

    /* -------------------------------------------- */

    /**
     * Allow subclasses to dynamically configure render parts.
     * @param {HandlebarsRenderOptions} options
     * @returns {Record<string, HandlebarsTemplatePart>}
     * @protected
     */
    _configureRenderParts(options) {
      const parts = foundry.utils.deepClone(this.constructor.PARTS);
      Object.values(parts).forEach(p => p.templates ??= []);
      return parts;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _preRender(context, options) {
      await super._preRender(context, options);
      const allTemplates = new Set();
      for ( const part of Object.values(this.#partDescriptors) ) {
        allTemplates.add(part.template);
        part.templates ??= [];
        for ( const template of part.templates ) allTemplates.add(template);
      }
      await foundry.applications.handlebars.loadTemplates(Array.from(allTemplates));
    }

    /* -------------------------------------------- */

    /**
     * Render each configured application part using Handlebars templates.
     * @param {ApplicationRenderContext} context        Context data for the render operation
     * @param {HandlebarsRenderOptions} options         Options which configure application rendering behavior
     * @returns {Promise<Record<string, HTMLElement>>}  A single rendered HTMLElement for each requested part
     * @protected
     * @override
     */
    async _renderHTML(context, options) {
      const rendered = {};
      const partInfo = this.#partDescriptors;
      for ( const partId of options.parts ) {
        const part = partInfo[partId];
        if ( !part ) {
          ui.notifications.warn(`Part "${partId}" is not a supported template part for ${this.constructor.name}`);
          continue;
        }
        const partContext = await this._preparePartContext(partId, context, options);
        try {
          const htmlString = await foundry.applications.handlebars.renderTemplate(part.template, partContext);
          rendered[partId] = this.#parsePartHTML(partId, part, htmlString);
        } catch(err) {
          throw new Error(`Failed to render template part "${partId}":\n${err.message}`, {cause: err});
        }
      }
      return rendered;
    }

    /* -------------------------------------------- */

    /**
     * Prepare context that is specific to only a single rendered part.
     *
     * It is recommended to augment or mutate the shared context so that downstream methods like _onRender have
     * visibility into the data that was used for rendering. It is acceptable to return a different context object
     * rather than mutating the shared context at the expense of this transparency.
     *
     * @param {string} partId                         The part being rendered
     * @param {ApplicationRenderContext} context      Shared context provided by _prepareContext
     * @param {HandlebarsRenderOptions} options       Options which configure application rendering behavior
     * @returns {Promise<ApplicationRenderContext>}   Context data for a specific part
     * @protected
     */
    async _preparePartContext(partId, context, options) {
      context.partId = `${this.id}-${partId}`;
      return context;
    }

    /* -------------------------------------------- */

    /**
     * Parse the returned HTML string from template rendering into a uniquely identified HTMLElement for insertion.
     * @param {string} partId                   The id of the part being rendered
     * @param {HandlebarsTemplatePart} part     Configuration of the part being parsed
     * @param {string} htmlString               The string rendered for the part
     * @returns {HTMLElement}                   The parsed HTMLElement for the part
     */
    #parsePartHTML(partId, part, htmlString) {
      const tempEl = document.createElement("div");
      tempEl.innerHTML = htmlString;
      const isMultipartRoot = part.root && tempEl.children.length > 1;
      if ( (tempEl.children.length !== 1) && !isMultipartRoot ) {
        throw new Error(`Template part "${partId}" must render a single HTML element.`);
      }
      const element = isMultipartRoot ? tempEl : tempEl.firstElementChild;
      element.dataset.applicationPart = partId;
      if ( part.id ) element.id = `${this.id}-${part.id}`;
      if ( part.classes ) element.classList.add(...part.classes);
      return element;
    }

    /* -------------------------------------------- */

    /**
     * Replace the HTML of the application with the result provided by Handlebars rendering.
     * @param {Record<string, HTMLElement>} result  The result from Handlebars template rendering
     * @param {HTMLElement} content                 The content element into which the rendered result must be inserted
     * @param {HandlebarsRenderOptions} options     Options which configure application rendering behavior
     * @protected
     * @override
     */
    _replaceHTML(result, content, options) {
      const partInfo = this.#partDescriptors;
      for ( const [partId, htmlElement] of Object.entries(result) ) {
        const part = partInfo[partId];
        const priorElement = part.root ? content : content.querySelector(`[data-application-part="${partId}"]`);
        const state = {};
        if ( priorElement ) {
          this._preSyncPartState(partId, htmlElement, priorElement, state);
          if ( part.root ) priorElement.replaceChildren(...htmlElement.children);
          else priorElement.replaceWith(htmlElement);
          this._syncPartState(partId, htmlElement, priorElement, state);
        }
        else content.appendChild(htmlElement);
        this._attachPartListeners(partId, htmlElement, options);
        this.#parts[partId] = htmlElement;
      }
    }

    /* -------------------------------------------- */

    /**
     * Prepare data used to synchronize the state of a template part.
     * @param {string} partId                       The id of the part being rendered
     * @param {HTMLElement} newElement              The new rendered HTML element for the part
     * @param {HTMLElement} priorElement            The prior rendered HTML element for the part
     * @param {object} state                        A state object which is used to synchronize after replacement
     * @protected
     */
    _preSyncPartState(partId, newElement, priorElement, state) {
      const part = this.#partDescriptors[partId];

      // Focused element or field
      const focus = priorElement.querySelector(":focus");
      if ( focus?.id ) state.focus = `#${CSS.escape(focus.id)}`;
      else if ( focus?.name ) state.focus = `${focus.tagName}[name="${focus.name}"]`;
      else state.focus = undefined;

      // Scroll positions
      state.scrollPositions = [];
      for ( const selector of (part.scrollable || []) ) {
        const el0 = selector === "" ? priorElement : priorElement.querySelector(selector);
        if ( el0 ) {
          const el1 = selector === "" ? newElement : newElement.querySelector(selector);
          if ( el1 ) state.scrollPositions.push([el1, el0.scrollTop, el0.scrollLeft]);
        }
      }
    }

    /* -------------------------------------------- */

    /**
     * Synchronize the state of a template part after it has been rendered and replaced in the DOM.
     * @param {string} partId                       The id of the part being rendered
     * @param {HTMLElement} newElement              The new rendered HTML element for the part
     * @param {HTMLElement} priorElement            The prior rendered HTML element for the part
     * @param {object} state                        A state object which is used to synchronize after replacement
     * @protected
     */
    _syncPartState(partId, newElement, priorElement, state) {
      if ( state.focus ) {
        const newFocus = newElement.querySelector(state.focus);
        if ( newFocus ) newFocus.focus();
      }
      for ( const [el, scrollTop, scrollLeft] of state.scrollPositions ) Object.assign(el, {scrollTop, scrollLeft});
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _tearDown(options) {
      super._tearDown(options);
      this.#parts = {};
    }

    /* -------------------------------------------- */
    /*  Event Listeners and Handlers                */
    /* -------------------------------------------- */

    /**
     * Attach event listeners to rendered template parts.
     * @param {string} partId                       The id of the part being rendered
     * @param {HTMLElement} htmlElement             The rendered HTML element for the part
     * @param {ApplicationRenderOptions} options    Rendering options passed to the render method
     * @protected
     */
    _attachPartListeners(partId, htmlElement, options) {
      const part = this.#partDescriptors[partId];

      // Attach form submission handlers
      if ( part.forms ) {
        for ( const [selector, formConfig] of Object.entries(part.forms) ) {
          const form = htmlElement.matches(selector) ? htmlElement : htmlElement.querySelector(selector);
          form.addEventListener("submit", this._onSubmitForm.bind(this, formConfig));
          form.addEventListener("change", this._onChangeForm.bind(this, formConfig));
        }
      }
    }
  }
  return HandlebarsApplication;
}

/**
 * @callback SearchFilterCallback
 * @param {KeyboardEvent|null} event
 * @param {string} query
 * @param {RegExp} rgx
 * @param {HTMLElement} content
 * @returns {void}
 */

/**
 * @typedef SearchFilterConfiguration
 * Options which customize the behavior of the filter
 * @property {string} inputSelector    The CSS selector used to target the text input element.
 * @property {string} contentSelector  The CSS selector used to target the content container for these tabs.
 * @property {SearchFilterCallback} callback  A callback function which executes when the filter changes.
 * @property {string} [initial]        The initial value of the search query.
 * @property {number} [delay=200]      The number of milliseconds to wait for text input before processing.
 *                                     Default: `200`.
 */

/**
 * @typedef 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.
 */
class SearchFilter {
  /**
   * @param {SearchFilterConfiguration} config     Configuration object for initializing the SearchFilter.
   */
  constructor(config={}) {
    const {
      inputSelector,
      contentSelector,
      initial = "",
      callback,
      delay = 200
    } = config;

    /**
     * The value of the current query string
     * @type {string}
     */
    this.query = initial;

    /**
     * A callback function to trigger when the tab is changed
     * @type {SearchFilterCallback|null}
     */
    this.callback = callback;

    this.#inputSelector = inputSelector;
    this.#contentSelector = contentSelector;
    this.#filter = foundry.utils.debounce(this.callback, delay);
  }

  /* -------------------------------------------- */

  /**
   * The CSS selector used to target the tab navigation element
   * @type {string}
   */
  #inputSelector;

  /* -------------------------------------------- */

  /**
   * The CSS selector used to target the tab content element
   * @type {string}
   */
  #contentSelector;

  /* -------------------------------------------- */

  /**
   * A debounced function which applies the search filtering.
   * Average typing speed is 167 ms per character, per https://stackoverflow.com/a/4098779
   * @type {SearchFilterCallback}
   */
  #filter;

  /* -------------------------------------------- */

  /**
   * 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"
  });

  /* -------------------------------------------- */

  /**
   * The regular expression corresponding to the query that should be matched against
   * @type {RegExp}
   */
  rgx;

  /**
   * A reference to the HTML navigation element the tab controller is bound to
   * @type {HTMLElement|null}
   * @internal
   */
  _input = null;

  /**
   * A reference to the HTML container element of the tab content
   * @type {HTMLElement|null}
   */
  #content = null;

  /* -------------------------------------------- */

  /**
   * 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);
  }

  /* -------------------------------------------- */

  /**
   * Release all bound HTML elements and reset the query.
   */
  unbind() {
    this._input = null;
    this.#content = null;
    this.query = "";
  }

  /* -------------------------------------------- */

  /**
   * Perform a filtering of the content by invoking the callback function
   * @param {KeyboardEvent|null} event The triggering keyboard event
   * @param {string} query             The input search string
   */
  filter(event, query) {
    if ( this._input ) this._input.value = 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, "");
  }

  /* -------------------------------------------- */

  /**
   * A helper method to test a value against a precomposed regex pattern.
   * @param {RegExp} rgx        The regular expression to test
   * @param {string} value      The value to test against
   * @returns {boolean}         Does the query match?
   */
  static testQuery(rgx, value) {
    return rgx.test(foundry.applications.ux.SearchFilter.cleanQuery(value));
  }

  /* -------------------------------------------- */

  /**
   * 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;

    const 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:
          return Array.isArray(filterValue) ? filterValue.includes(docValue) : [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;
  }
}

/**
 * @import {ApplicationClickAction, ApplicationConfiguration, ApplicationFormSubmission} from "../_types.mjs";
 */

/**
 * @typedef FilePickerConfiguration
 * @property {"any"|"audio"|"folder"|"font"|"graphics"|"image"|"imagevideo"|"text"|"video"} [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.
 */

/**
 * @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
 */

/**
 * The FilePicker application renders contents of the server-side public directory.
 * This app allows for navigating and uploading files to the public path.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class FilePicker extends HandlebarsApplicationMixin(ApplicationV2) {
  /**
   * @param {DeepPartial<ApplicationConfiguration & FilePickerConfiguration>} [options={}] Options that configure the
   *                                                                                       behavior of the FilePicker
   */
  constructor(options={}) {
    super(options);

    /**
     * The full requested path given by the user
     * @type {string}
     */
    this.request = options.current ?? "";

    /**
     * A callback function to trigger once a file has been selected
     * @type {Function|null}
     */
    this.callback = options.callback ?? null;

    /**
     * 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|null}
     */
    this.field = options.field ?? null;

    /**
     * A button controlling the display of the picker UI
     * @type {HTMLElement|null}
     */
    this.button = options.button ?? null;

    /**
     * The display mode of the FilePicker UI
     * @type {string}
     */
    this.displayMode = options.displayMode ?? this.constructor.LAST_DISPLAY_MODE;

    /**
     * The file sources available for browsing
     * @type {Record<"data"|"public"|"s3", {target: string; bucket?: string; buckets?: string[]}|undefined>}>
     */
    this.sources = ["data", "public", "s3"]
      .filter(s => game.data.files.storages.includes(s))
      .reduce((sources, key) => {
        sources[key] = key === "s3" ? {target: "", bucket: "", buckets: []} : {target: ""};
        return sources;
      }, {});

    // Infer the source
    const [source, target] = this._inferSourceAndTarget(this.request);
    this.sources[source].target = target;

    /**
     * Track the active source tab which is being browsed
     * @type {"data"|"public"|"s3"}
     */
    this.activeSource = source;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "file-picker",
    tag: "form",
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-file-magnifying-glass"
    },
    position: {
      width: 560
    },
    tileSize: false,
    actions: {
      backTraverse: FilePicker.#onBackTraverse,
      makeDirectory: FilePicker.#onMakeDirectory,
      togglePrivacy: FilePicker.#onTogglePrivacy,
      changeDisplayMode: FilePicker.#onChangeDisplayMode,
      pickDirectory: FilePicker.#onPickDirectory,
      pickFile: FilePicker.#onPickFile,
      goToFavorite: FilePicker.#onGoToFavorite,
      setFavorite: FilePicker.#onSetFavorite,
      removeFavorite: FilePicker.#onRemoveFavorite
    },
    form: {
      handler: FilePicker.#onSubmit,
      submitOnChange: false,
      closeOnSubmit: false
    }
  };

  /** @override */
  static PARTS = {
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    subheader: {template: "templates/apps/file-picker/subheader.hbs"},
    body: {template: "templates/apps/file-picker/body.hbs"},
    subfooter: {template: "templates/apps/file-picker/subfooter.hbs"},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /** @override */
  static TABS = {
    sources: {
      tabs: [
        {id: "data", icon: "fa-solid fa-database"},
        {id: "public", icon: "fa-solid fa-server"},
        {id: "s3", icon: "fa-solid fa-cloud-arrow-up"}
      ],
      initial: "data",
      labelPrefix: "FILES.TABS"
    }
  };

  /**
   * The allowed values for the type of this FilePicker instance.
   * @type {string[]}
   */
  static FILE_TYPES = ["any", "audio", "folder", "font", "graphics", "image", "imagevideo", "text", "video"];

  /**
   * 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;

  /**
   * Return the upload URL to which the FilePicker should post uploaded files
   * @type {string}
   */
  static get uploadURL() {
    return foundry.utils.getRoute("upload");
  }

  /**
   * Retrieve the configured FilePicker implementation.
   * @type {typeof FilePicker}
   */
  static get implementation() {
    if ( !foundry.utils.isSubclass(CONFIG.ux.FilePicker, FilePicker) ) {
      console.warn("Configured FilePicker override must be a subclass of FilePicker.");
      CONFIG.ux.FilePicker = FilePicker;
    }
    return CONFIG.ux.FilePicker;
  }

  /* -------------------------------------------- */

  /**
   * Track whether we have loaded files.
   * @type {boolean}
   */
  #loaded = false;

  /**
   * The latest set of results browsed from the server
   * @type {object}
   */
  results = {};

  /**
   * The current set of file extensions which are being filtered upon
   * @type {string[]}
   */
  extensions = FilePicker.#getExtensions(this.options.type ?? "any");

  #search = new SearchFilter({
    inputSelector: "input[name=filter]",
    contentSelector: "section[data-files]",
    callback: this._onSearchFilter.bind(this)
  });

  /**
   * Get favorite folders for quick access
   * @type {Record<string, FavoriteFolder>}
   */
  get favorites() {
    return game.settings.get("core", "favoritePaths");
  }

  /** @override */
  get title() {
    const 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;
  }

  /**
   * Whether the current user is able to create folders.
   * @type {boolean}
   */
  get canCreateFolder() {
    if ( this.options.allowUpload === false ) return false;
    if ( !["data", "s3"].includes(this.activeSource) ) return false;
    const isData = this.activeSource === "data";
    // Prevent uploading into the root package directories.
    if ( isData && ["worlds", "systems", "modules"].includes(this.source.target) ) return false;
    // Prevent uploading into a world or system that is not this one.
    for ( const [pkg, path] of [[game.world, "worlds/"], [game.system, "systems/"]] ) {
      if ( pkg && isData && this.source.target.startsWith(path) ) {
        const [, id] = this.source.target.split("/");
        if ( id !== pkg.id ) return false;
      }
    }
    // Prevent uploading into a module or system directory unless the canUpload flag is present.
    if ( isData && (this.source.target.startsWith("systems/") || this.source.target.startsWith("modules/")) ) {
      const [type, id] = this.source.target.split("/");
      const pkg = type === "systems" ? (game.system ?? game.systems?.get(id)) : game.modules.get(id);
      if ( !pkg?.flags.canUpload ) return false;
    }
    return game.user?.can("FILES_UPLOAD") !== false;
  }

  /* -------------------------------------------- */

  /**
   * Whether the current use is able to upload file content.
   * @type {boolean}
   */
  get canUpload() {
    if ( !this.canCreateFolder ) return false;
    if ( this.type === "folder" ) return false;
    // Prevent uploading to the root of Data/.
    return (this.activeSource !== "data") || (this.source.target !== "");
  }

  /* -------------------------------------------- */

  /**
   * Get the valid file extensions for a given named file picker type
   * @param {string} type
   * @returns {string[]}
   */
  static #getExtensions(type) {
    const types = (() => {
      switch ( type ) {
        case "audio": return Object.keys(CONST.AUDIO_FILE_EXTENSIONS);
        case "folder": return [];
        case "font": return Object.keys(CONST.FONT_FILE_EXTENSIONS);
        case "graphics": return Object.keys(CONST.GRAPHICS_FILE_EXTENSIONS);
        case "image": return Object.keys(CONST.IMAGE_FILE_EXTENSIONS);
        case "imagevideo":
          return Object.keys(CONST.IMAGE_FILE_EXTENSIONS).concat(Object.keys(CONST.VIDEO_FILE_EXTENSIONS));
        case "text": return Object.keys(CONST.TEXT_FILE_EXTENSIONS);
        case "video": return Object.keys(CONST.VIDEO_FILE_EXTENSIONS);
        default: return Object.keys(CONST.UPLOADABLE_FILE_EXTENSIONS);
      }
    })();
    return types.map(t => `.${t}`);
  }

  /* -------------------------------------------- */

  /**
   * 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);
  }

  /* -------------------------------------------- */

  /**
   * 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<object>} A Promise that resolves to the directories and files contained in the location
   */
  static async browse(source, target, options={}) {
    const data = {action: "browseFiles", storage: source, 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 modifying 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={}, options={}) {
    if ( this !== CONFIG.ux.FilePicker ) return FilePicker.implementation.upload(source, path, file, body, options);
    const notify = options.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}={}) {
    const 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});
  }

  /* -------------------------------------------- */

  /**
   * 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 ID of the compendium the actor is in.
   * @returns {Promise<string[]>}
   */
  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);
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * Given a current file path, determine the directory to which it belongs.
   * @param {string} target   The currently requested target path
   * @returns {[source: string, revisedTarget: string]} A tuple of the inferred source and target directory path
   * @protected
   */
  _inferSourceAndTarget(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();
      if ( FILE_PICKER_PUBLIC_DIRS.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
    const parts = target.split("/");
    if ( parts[parts.length - 1].indexOf(".") !== -1 ) parts.pop();
    const dir = parts.join("/");
    return [source, dir];
  }

  /* -------------------------------------------- */

  /**
   * 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);
    }
  }

  /* -------------------------------------------- */

  /**
   * 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 labelText = game.i18n.localize("FILES.DirectoryName.Label");
    const placeholder = game.i18n.localize("FILES.DirectoryName.Placeholder");
    const content = `<div class="form-group">
    <label for="create-directory-name">${labelText}</label>
    <div class="form-fields">
    <input id="create-directory-name" type="text" name="dirname" placeholder="${foundry.utils.escapeHTML(placeholder)}" required autofocus>
    </div></div>`;
    return foundry.applications.api.DialogV2.confirm({
      id: "create-directory",
      window: {title: "FILES.CreateSubfolder", icon: "fa-solid fa-folder-plus"},
      content,
      yes: {
        label: "CONTROLS.CommonCreate",
        default: true,
        callback: async event => {
          const dirname = event.currentTarget.querySelector("input").value || placeholder;
          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);
        }
      },
      no: {label: "Cancel"}
    });
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * 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
   * @returns {Promise<this>}
   */
  async browse(target=this.target, options={}) {

    // If the user does not have permission to browse, do not proceed
    if ( game.user?.can("FILES_BROWSE") === false ) return this;

    // Configure browsing parameters
    options = Object.assign({
      type: this.type,
      extensions: this.extensions,
      wildcard: false,
      render: true
    }, options);

    // Determine the S3 buckets which may be used
    const source = this.activeSource;
    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
    const safeTarget = target === CONST.DEFAULT_TOKEN
      ? this.constructor.LAST_BROWSED_DIRECTORY
      : target.replace(/^\//, "");

    // Request files from the server
    const result = await this.constructor.browse(source, safeTarget, 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
    if ( options.render ) return this.render({force: !this.rendered});
    return this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async render(...args) {
    if ( game.user?.can("FILES_BROWSE") === false ) return this;
    if ( !this.#loaded ) return this.browse();
    return super.render(...args);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const result = this.result;
    const source = this.source;
    const isS3 = this.activeSource === "s3";

    // Sort directories alphabetically and store their paths
    const dirs = result.dirs.map(d => ({
      name: decodeURIComponent(d.split("/").pop()),
      path: d,
      private: result.private || result.privateDirs.includes(d)
    })).sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));

    // Sort files alphabetically and store their client URLs
    const files = result.files.map(path => {
      let img = path;
      if ( foundry.helpers.media.VideoHelper.hasVideoExtension(path) ) img = "icons/svg/video.svg";
      else if ( foundry.audio.AudioHelper.hasAudioExtension(path) ) img = "icons/svg/sound.svg";
      else if ( !foundry.helpers.media.ImageHelper.hasImageExtension(path) ) img = "icons/svg/book.svg";
      return {
        name: decodeURIComponent(path.split("/").pop()),
        url: path,
        img
      };
    }).sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));

    const canSelect = !this.options.tileSize;
    const isFolderPicker = this.type === "folder";
    const buttons = canSelect ? [{
      type: "submit",
      icon: "fa-solid fa-check",
      label: isFolderPicker ? "FILES.SelectFolder" : "FILES.SelectFile"
    }] : [];
    return Object.assign(context, {
      rootId: this.id,
      bucket: isS3 ? source.bucket : null,
      buckets: isS3 ? source.buckets.map(b => ({ value: b, label: b })) : null,
      canGoBack: this.activeSource !== "",
      canCreateFolder: this.canCreateFolder,
      canUpload: this.canUpload,
      canSelect,
      canTogglePrivacy: game.user?.isGM !== false,
      dirs: dirs,
      displayMode: this.displayMode,
      extensions: this.extensions,
      files,
      isFolderPicker,
      isS3: isS3,
      noResults: dirs.length + files.length === 0,
      selected: isFolderPicker ? source.target : this.request,
      source,
      sources: this.sources,
      target: decodeURIComponent(source.target),
      tileSize: this.options.tileSize ? (this.constructor.LAST_TILE_SIZE || canvas.dimensions.size) : null,
      user: game.user,
      favorites: this.favorites,
      buttons
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareTabs(group) {
    if ( group !== "sources" ) return super._prepareTabs(group);
    this.tabGroups.sources = this.activeSource;
    const tabs = super._prepareTabs(group);
    for ( const source in tabs ) {
      if ( !(source in this.sources) ) delete tabs[source];
    }
    return tabs;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  changeTab(tab, group, options) {
    if ( group === "sources" ) this.tabGroups.sources = this.activeSource = tab;
    this.browse(this.source.target);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _tearDown(options) {
    super._tearDown(options);
    this.#search.unbind();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    const form = this.element;

    form.classList.toggle("private", this.result.private);

    // Change the directory
    const targetInput = form.elements.target;
    targetInput.addEventListener("keydown", this.#onRequestTarget.bind(this));
    targetInput.focus();

    // Change the S3 bucket
    form.elements.bucket?.addEventListener("change", this.#onChangeBucket.bind(this));

    // Change the tile size.
    form.elements.tileSize?.addEventListener("change", this._onChangeTileSize.bind(this));

    // Upload new file
    if ( this.canUpload ) form.elements.upload?.addEventListener("change", this.#onUpload.bind(this));

    // Flag the current pick
    const li = form.querySelector(`.file[data-path="${encodeURIComponent(this.request)}"]`);
    if ( li ) li.classList.add("picked");

    // Search among shown directories and files
    this.#search.bind(form);

    // Drag & drop
    if ( options.parts.includes("body") ) {
      new foundry.applications.ux.DragDrop.implementation({
        dragSelector: "li[data-file]",
        dropSelector: "section[data-files]",
        permissions: {
          dragstart: () => !!game.user?.isGM && (canvas.activeLayer instanceof foundry.canvas.layers.TilesLayer),
          drop: () => this.canUpload
        },
        callbacks: {
          dragstart: this.#onDragStart.bind(this),
          drop: this.#onDrop.bind(this)
        }
      }).bind(this.element);
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle a click event to change the display mode of the File Picker
   * @this {FilePicker}
   * @type {ApplicationClickAction}
   */
  static async #onChangeDisplayMode(_event, button) {
    if ( !this.constructor.DISPLAY_MODES.includes(button.dataset.mode) ) {
      throw new Error("Invalid display mode requested");
    }
    if ( button.dataset.mode === this.displayMode ) return;
    this.constructor.LAST_DISPLAY_MODE = this.displayMode = button.dataset.mode;
    for ( const modeButton of button.closest("div").querySelectorAll("button") ) {
      modeButton.ariaPressed = String(modeButton.dataset.mode === this.displayMode);
    }
    await this.render({parts: ["body"]});
  }

  /* -------------------------------------------- */

  /**
   * Traverse back one directory level.
   * @this {FilePicker}
   * @type {ApplicationClickAction}
   */
  static async #onBackTraverse() {
    const path = this.target.replace(/\/$/, "").split("/").slice(0, -1).join("/");
    await this.browse(path);
  }

  /* -------------------------------------------- */

  /**
   * Create a new subdirectory in the current working directory.
   * @this {FilePicker}
   * @type {ApplicationClickAction}
   */
  static async #onMakeDirectory() {
    await this.#createDirectoryDialog(this.source);
  }

  /* -------------------------------------------- */

  /**
   * Toggle privacy mode.
   * @this {FilePicker}
   * @type {ApplicationClickAction}
   */
  static async #onTogglePrivacy() {
    const isPrivate = !this.result.private;
    const data = {private: isPrivate, bucket: this.result.bucket};
    const result = await this.constructor.configurePath(this.activeSource, this.target, data);
    this.result.private = result.private;
    await this.render({parts: ["subheader", "body"]});
  }

  /* -------------------------------------------- */

  /**
   * Navigate to a favorited directory.
   * @this {FilePicker}
   * @type {ApplicationClickAction}
   */
  static async #onGoToFavorite(_event, button) {
    const source = button.dataset.source || this.activeSource;
    this.activeSource = source;
    const path = button.dataset.path || this.target;
    await this.browse(path);
  }

  /* -------------------------------------------- */

  /**
   * Add the given path for the source to the favorites.
   * @this {FilePicker}
   * @type {ApplicationClickAction}
   */
  static async #onSetFavorite(_event, button) {
    const source = button.dataset.source || this.activeSource;

    // 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.
    const path = `${button.dataset.path || this.target}/`.replace(/\/+$/, "/");
    const favorites = foundry.utils.deepClone(this.favorites);
    if ( `${source}-${path}` in favorites ) {
      ui.notifications.info("FILES.AlreadyFavorited", {format: {path}});
      return;
    }
    const label = path === "/" ? "root" : path.split("/").at(-2); // Get the final part of the path for the label
    favorites[`${source}-${path}`] = {source, path, label};
    await game.settings.set("core", "favoritePaths", favorites);
    await this.render({parts: ["subheader"]});
  }

  /* -------------------------------------------- */

  /**
   * Remove the given path from the favorites.
   * @this {FilePicker}
   * @type {ApplicationClickAction}
   */
  static async #onRemoveFavorite(_event, button) {
    const source = button.dataset.source || this.activeSource;
    let path = button.dataset.path || this.target;
    path = path.endsWith("/") ? path : `${path}/`;
    const favorites = foundry.utils.deepClone(this.favorites);
    delete favorites[`${source}-${path}`];
    await game.settings.set("core", "favoritePaths", favorites);
    await this.render({parts: ["subheader"]});
  }

  /* -------------------------------------------- */

  /**
   * Handle a directory selection within the file picker
   * @this {FilePicker}
   * @type {ApplicationClickAction}
   */
  static async #onPickDirectory(_event, row) {
    await this.browse(row.dataset.path);
  }

  /* -------------------------------------------- */

  /**
   * Handle file selection within the file picker
   * @this {FilePicker}
   * @type {ApplicationClickAction}
   */
  static async #onPickFile(_event, pickedRow) {
    const form = this.element;
    for ( const row of pickedRow.closest("ul").children ) {
      row.classList.toggle("picked", row === pickedRow);
    }
    if ( form.elements.file ) form.elements.file.value = pickedRow.dataset.path;
  }

  /* -------------------------------------------- */

  /**
   * Handle user submission of the address bar to request an explicit target
   * @param {KeyboardEvent} event     The originating keydown event
   */
  async #onRequestTarget(event) {
    if ( event.key === "Enter" ) {
      event.preventDefault();
      await this.browse(event.target.value);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle the start of a drag.
   * @param {DragEvent} event
   */
  #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 = foundry.applications.ux.DragDrop.implementation.createDragImage(img, w, h);
    event.dataTransfer.setDragImage(preview, w/2, h/2);
  }

  /* -------------------------------------------- */

  /**
   * Handle a drop event.
   * @param {DragEvent} event
   */
  async #onDrop(event) {
    if ( !this.canUpload ) return;
    const form = this.element;
    form.disabled = true;
    const target = form.target.value;

    // Process the data transfer
    const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);
    const files = event.dataTransfer.files;
    if ( !files?.length || data.fromFilePicker ) return;

    // Iterate over dropped files
    for ( const 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;
    await this.browse(target);
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * Search among shown directories and files.
   * @param {KeyboardEvent} event The triggering event
   * @param {string} query The search input value
   * @param {RegExp} rgx
   * @param {HTMLElement} html
   * @protected
   */
  _onSearchFilter(event, query, rgx, html) {
    for ( const list of html.querySelectorAll("ul") ) {
      let matched = false;
      for ( const row of list.children ) {
        const match = foundry.applications.ux.SearchFilter.testQuery(rgx, row.dataset.name);
        if ( match ) matched = true;
        row.style.display = !match ? "none" : "";
      }
      list.style.display = matched ? "" : "none";
    }
    this.setPosition({height: "auto"});
  }

  /* -------------------------------------------- */

  /**
   * Handle file selection.
   * @this {FilePicker}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmit(event) {
    if ( this.options.tileSize ) return;
    const path = event.target.file?.value;
    if ( !path ) {
      ui.notifications.error("You must select a file to proceed.");
      return;
    }

    // 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);
    await this.close();
  }

  /* -------------------------------------------- */

  /**
   * Handle file upload
   * @param {Event} event The file upload event
   */
  async #onUpload(event) {
    const form = event.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");
    const type = button.getAttribute("data-type");
    const form = button.form;
    const field = form[button.dataset.target] || null;
    const current = field?.value || "";
    return new FilePicker.implementation({field, type, current, button});
  }
}

/**
 * @import {DiceTerm, RollTerm} from "./terms/_module.mjs";
 * @import {RollOptions, RollParseNode} from "./_types.mjs";
 * @import RollResolver from "../applications/dice/roll-resolver.mjs";
 */

/**
 * An interface and API for constructing and evaluating dice rolls.
 * The basic structure for a dice roll is a string formula and an object of data against which to parse it.
 *
 * @example Attack with advantage
 * ```js
 * // Construct the Roll instance
 * let r = new Roll("2d20kh + @prof + @strMod", {prof: 2, strMod: 4});
 *
 * // The parsed terms of the roll formula
 * console.log(r.terms);    // [Die, OperatorTerm, NumericTerm, OperatorTerm, NumericTerm]
 *
 * // Execute the roll
 * await r.evaluate();
 *
 * // The resulting equation after it was rolled
 * console.log(r.result);   // 16 + 2 + 4
 *
 * // The total resulting from the roll
 * console.log(r.total);    // 22
 * ```
 */
class Roll {
  /**
   * @param {string} formula    The string formula to parse
   * @param {object} data       The data object against which to parse attributes within the formula
   * @param {RollOptions} [options]  Options modifying or describing the Roll
   */
  constructor(formula="", data={}, options={}) {
    if ( typeof formula !== "string" ) throw new Error(game.i18n.format("DICE.ErrorNotParsable", {formula}));
    this.data = this._prepareData(data);
    this.options = options;
    this.terms = this.constructor.parse(formula, this.data);
    this._formula = this.resetFormula();
  }

  /**
   * Dice Configuration setting name.
   * @readonly
   */
  static DICE_CONFIGURATION_SETTING = "diceConfiguration";

  /**
   * The original provided data object which substitutes into attributes of the roll formula.
   * @type {object}
   */
  data;

  /**
   * Options modifying or describing the Roll
   * @type {RollOptions}
   */
  options;

  /**
   * The identified terms of the Roll
   * @type {RollTerm[]}
   */
  terms;

  /**
   * An array of inner DiceTerms that were evaluated as part of the Roll evaluation
   * @type {DiceTerm[]}
   * @internal
   */
  _dice = [];

  /**
   * Store the original cleaned formula for the Roll, prior to any internal evaluation or simplification
   * @type {string}
   * @internal
   */
  _formula;

  /**
   * Track whether this Roll instance has been evaluated or not. Once evaluated the Roll is immutable.
   * @type {boolean}
   * @internal
   */
  _evaluated = false;

  /**
   * Cache the numeric total generated through evaluation of the Roll.
   * @type {number}
   * @internal
   */
  _total;

  /**
   * A reference to the Roll at the root of the evaluation tree.
   * @type {Roll}
   * @internal
   */
  _root;

  /**
   * A reference to the RollResolver app being used to externally resolve this Roll.
   * @type {RollResolver}
   * @internal
   */
  _resolver;

  /**
   * A Proxy environment for safely evaluating a string using only available Math functions
   * @type {Math}
   */
  static MATH_PROXY = new Proxy(Math, {
    has: () => true, // Include everything
    get: (t, k) => k === Symbol.unscopables ? undefined : t[k],
    set: () => console.error("You may not set properties of the Roll.MATH_PROXY environment") // No-op
  });

  /**
   * The HTML template path used to render a complete Roll object to the chat log
   * @type {string}
   */
  static CHAT_TEMPLATE = "templates/dice/roll.hbs";

  /**
   * The HTML template used to render an expanded Roll tooltip to the chat log
   * @type {string}
   */
  static TOOLTIP_TEMPLATE = "templates/dice/tooltip.hbs";

  /**
   * A mapping of Roll instances to currently-active resolvers.
   * @type {Map<Roll, RollResolver>}
   */
  static RESOLVERS = new Map();

  /* -------------------------------------------- */

  /**
   * Prepare the data structure used for the Roll.
   * This is factored out to allow for custom Roll classes to do special data preparation using provided input.
   * @param {object} data   Provided roll data
   * @returns {object}      The prepared data object
   * @protected
   */
  _prepareData(data) {
    return data;
  }

  /* -------------------------------------------- */
  /*  Roll Attributes                             */
  /* -------------------------------------------- */

  /**
   * Return an Array of the individual DiceTerm instances contained within this Roll.
   * @type {DiceTerm[]}
   */
  get dice() {
    return this._dice.concat(this.terms.flatMap(t => {
      const dice = [];
      dice.push(...(t.dice ?? []));
      if ( t instanceof foundry.dice.terms.DiceTerm ) dice.push(t);
      return dice;
    }));
  }

  /* -------------------------------------------- */

  /**
   * Return a standardized representation for the displayed formula associated with this Roll.
   * @type {string}
   */
  get formula() {
    return this.constructor.getFormula(this.terms);
  }

  /* -------------------------------------------- */

  /**
   * The resulting arithmetic expression after rolls have been evaluated
   * @type {string}
   */
  get result() {
    return this.terms.map(t => t.total).join("");
  }

  /* -------------------------------------------- */

  /**
   * Return the total result of the Roll expression if it has been evaluated.
   * @type {number}
   */
  get total() {
    return Number(this._total) || 0;
  }

  /* -------------------------------------------- */

  /**
   * Return the arbitrary product of evaluating this Roll.
   * @returns {any}
   */
  get product() {
    return this._total;
  }

  /* -------------------------------------------- */

  /**
   * Whether this Roll contains entirely deterministic terms or whether there is some randomness.
   * @type {boolean}
   */
  get isDeterministic() {
    return this.terms.every(t => t.isDeterministic);
  }

  /* -------------------------------------------- */
  /*  Roll Instance Methods                       */
  /* -------------------------------------------- */

  /**
   * Alter the Roll expression by adding or multiplying the number of dice which are rolled
   * @param {number} multiply   A factor to multiply. Dice are multiplied before any additions.
   * @param {number} add        A number of dice to add. Dice are added after multiplication.
   * @param {boolean} [multiplyNumeric]  Apply multiplication factor to numeric scalar terms
   * @returns {Roll}            The altered Roll expression
   */
  alter(multiply, add, {multiplyNumeric=false}={}) {
    if ( this._evaluated ) throw new Error("You may not alter a Roll which has already been evaluated");

    // Alter dice and numeric terms
    this.terms = this.terms.map(term => {
      if ( term instanceof foundry.dice.terms.DiceTerm ) return term.alter(multiply, add);
      else if ( (term instanceof foundry.dice.terms.NumericTerm) && multiplyNumeric ) term.number *= multiply;
      return term;
    });

    // Update the altered formula and return the altered Roll
    this.resetFormula();
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Clone the Roll instance, returning a new Roll instance that has not yet been evaluated.
   * @returns {Roll}
   */
  clone() {
    return new this.constructor(this._formula, this.data, this.options);
  }

  /* -------------------------------------------- */

  /**
   * Execute the Roll asynchronously, replacing dice and evaluating the total result
   * @param {object} [options={}]                      Options which inform how the Roll is evaluated
   * @param {boolean} [options.minimize=false]         Minimize the result, obtaining the smallest possible value.
   * @param {boolean} [options.maximize=false]         Maximize the result, obtaining the largest possible value.
   * @param {boolean} [options.allowStrings=false]     If true, string terms will not cause an error to be thrown during
   *                                                   evaluation.
   * @param {boolean} [options.allowInteractive=true]  If false, force the use of non-interactive rolls and do not
   *                                                   prompt the user to make manual rolls.
   * @returns {Promise<Roll>}                          The evaluated Roll instance
   *
   * @example Evaluate a Roll expression
   * ```js
   * let r = new Roll("2d6 + 4 + 1d4");
   * await r.evaluate();
   * console.log(r.result); // 5 + 4 + 2
   * console.log(r.total);  // 11
   * ```
   */
  async evaluate({minimize=false, maximize=false, allowStrings=false, allowInteractive=true, ...options}={}) {
    if ( this._evaluated ) {
      throw new Error(`The ${this.constructor.name} has already been evaluated and is now immutable`);
    }
    this._evaluated = true;
    if ( CONFIG.debug.dice ) console.debug(`Evaluating roll with formula "${this.formula}"`);

    // Migration path for async rolls
    if ( "async" in options ) {
      foundry.utils.logCompatibilityWarning("The async option for Roll#evaluate has been removed. "
        + "Use Roll#evaluateSync for synchronous roll evaluation.");
    }
    return this._evaluate({minimize, maximize, allowStrings, allowInteractive});
  }

  /* -------------------------------------------- */

  /**
   * Execute the Roll synchronously, replacing dice and evaluating the total result.
   * @param {object} [options={}]
   * @param {boolean} [options.minimize=false]      Minimize the result, obtaining the smallest possible value.
   * @param {boolean} [options.maximize=false]      Maximize the result, obtaining the largest possible value.
   * @param {boolean} [options.strict=true]         Throw an Error if the Roll contains non-deterministic terms that
   *                                                cannot be evaluated synchronously. If this is set to false,
   *                                                non-deterministic terms will be ignored.
   * @param {boolean} [options.allowStrings=false]  If true, string terms will not cause an error to be thrown during
   *                                                evaluation.
   * @returns {Roll}                                The evaluated Roll instance.
   */
  evaluateSync({minimize=false, maximize=false, allowStrings=false, strict=true}={}) {
    if ( this._evaluated ) {
      throw new Error(`The ${this.constructor.name} has already been evaluated and is now immutable.`);
    }
    this._evaluated = true;
    if ( CONFIG.debug.dice ) console.debug(`Synchronously evaluating roll with formula "${this.formula}"`);
    return this._evaluateSync({minimize, maximize, allowStrings, strict});
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the roll asynchronously.
   * @param {object} [options]                    Options which inform how evaluation is performed
   * @param {boolean} [options.minimize]          Force the result to be minimized
   * @param {boolean} [options.maximize]          Force the result to be maximized
   * @param {boolean} [options.allowStrings]      If true, string terms will not cause an error to be thrown during
   *                                              evaluation.
   * @param {boolean} [options.allowInteractive]  If false, force the use of digital rolls and do not prompt the user to
   *                                              make manual rolls.
   * @returns {Promise<Roll>}
   * @protected
   */
  async _evaluate(options={}) {
    // If the user has configured alternative dice fulfillment methods, prompt for the first pass of fulfillment here.
    let resolver;
    const { allowInteractive, minimize, maximize } = options;
    if ( !this._root && (allowInteractive !== false) && (maximize !== true) && (minimize !== true) ) {
      resolver = new this.constructor.resolverImplementation(this);
      this._resolver = resolver;
      await resolver.awaitFulfillment();
    }

    const ast = CONFIG.Dice.parser.toAST(this.terms);
    this._total = await this._evaluateASTAsync(ast, options);
    resolver?.close();
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Evaluate an AST asynchronously.
   * @param {RollParseNode|RollTerm} node     The root node or term.
   * @param {object} [options]                Options which inform how evaluation is performed
   * @param {boolean} [options.minimize]      Force the result to be minimized
   * @param {boolean} [options.maximize]      Force the result to be maximized
   * @param {boolean} [options.allowStrings]  If true, string terms will not cause an error to be thrown during
   *                                          evaluation.
   * @returns {Promise<string|number>}
   * @protected
   */
  async _evaluateASTAsync(node, options={}) {
    if ( node.class !== "Node" ) {
      if ( !node._evaluated ) {
        node._root = this._root ?? this;
        await node.evaluate(options);
      }
      return node.total;
    }

    let [left, right] = node.operands;
    [left, right] = [await this._evaluateASTAsync(left, options), await this._evaluateASTAsync(right, options)];

    switch ( node.operator ) {
      case "-": return left - right;
      case "*": return left * right;
      case "/": return left / right;
      case "%": return left % right;

      // Treat an unknown operator as addition.
      default: return left + right;
    }
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the roll synchronously.
   * @param {object} [options]                Options which inform how evaluation is performed
   * @param {boolean} [options.minimize]      Force the result to be minimized
   * @param {boolean} [options.maximize]      Force the result to be maximized
   * @param {boolean} [options.strict]        Throw an error if encountering a term that cannot be synchronously
   *                                          evaluated.
   * @param {boolean} [options.allowStrings]  If true, string terms will not cause an error to be thrown during
   *                                          evaluation.
   * @returns {Roll}
   * @protected
   */
  _evaluateSync(options={}) {
    const ast = CONFIG.Dice.parser.toAST(this.terms);
    this._total = this._evaluateASTSync(ast, options);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Evaluate an AST synchronously.
   * @param {RollParseNode|RollTerm} node     The root node or term.
   * @param {object} [options]                Options which inform how evaluation is performed
   * @param {boolean} [options.minimize]      Force the result to be minimized
   * @param {boolean} [options.maximize]      Force the result to be maximized
   * @param {boolean} [options.strict]        Throw an error if encountering a term that cannot be synchronously
   *                                          evaluated.
   * @param {boolean} [options.allowStrings]  If true, string terms will not cause an error to be thrown during
   *                                          evaluation.
   * @returns {string|number}
   * @protected
   */
  _evaluateASTSync(node, options={}) {
    const { maximize, minimize, strict } = options;
    if ( node.class !== "Node" ) {
      if ( node._evaluated ) return node.total;
      if ( foundry.dice.terms.RollTerm.isDeterministic(node, { maximize, minimize }) ) {
        node.evaluate(options);
        return node.total;
      }
      if ( strict ) throw new Error("This Roll contains terms that cannot be synchronously evaluated.");
      return 0;
    }

    let [left, right] = node.operands;
    [left, right] = [this._evaluateASTSync(left, options), this._evaluateASTSync(right, options)];

    switch ( node.operator ) {
      case "-": return left - right;
      case "*": return left * right;
      case "/": return left / right;
      case "%": return left % right;

      // Treat an unknown operator as addition.
      default: return left + right;
    }
  }

  /* -------------------------------------------- */

  /**
   * Safely evaluate the final total result for the Roll using its component terms.
   * @returns {number}    The evaluated total
   * @protected
   */
  _evaluateTotal() {
    const expression = this.terms.map(t => t.total).join(" ");
    const total = this.constructor.safeEval(expression);
    if ( !Number.isNumeric(total) ) {
      throw new Error(game.i18n.format("DICE.ErrorNonNumeric", {formula: this.formula}));
    }
    return total;
  }

  /* -------------------------------------------- */

  /**
   * Alias for evaluate.
   * @see {Roll#evaluate}
   * @param {object} options    Options passed to Roll#evaluate
   * @returns {Promise<Roll>}
   */
  async roll(options={}) {
    return this.evaluate(options);
  }

  /* -------------------------------------------- */

  /**
   * Create a new Roll object using the original provided formula and data.
   * Each roll is immutable, so this method returns a new Roll instance using the same data.
   * @param {object} [options={}]  Evaluation options passed to Roll#evaluate
   * @returns {Promise<Roll>}      A new Roll object, rolled using the same formula and data
   */
  async reroll(options={}) {
    const r = this.clone();
    return r.evaluate(options);
  }

  /* -------------------------------------------- */

  /**
   * Recompile the formula string that represents this Roll instance from its component terms.
   * @returns {string}                The re-compiled formula
   */
  resetFormula() {
    return this._formula = this.constructor.getFormula(this.terms);
  }

  /* -------------------------------------------- */

  /**
   * Propagate flavor text across all terms that do not have any.
   * @param {string} flavor  The flavor text.
   */
  propagateFlavor(flavor) {
    if ( !flavor ) return;
    this.terms.forEach(t => t.options.flavor ??= flavor);
  }

  /* -------------------------------------------- */

  /** @override */
  toString() {
    return this._formula;
  }

  /* -------------------------------------------- */
  /*  Static Class Methods                        */
  /* -------------------------------------------- */

  /**
   * A factory method which constructs a Roll instance using the default configured Roll class.
   * @param {string} formula        The formula used to create the Roll instance
   * @param {object} [data={}]      The data object which provides component data for the formula
   * @param {object} [options={}]   Additional options which modify or describe this Roll
   * @returns {Roll}                The constructed Roll instance
   */
  static create(formula, data={}, options={}) {
    const cls = CONFIG.Dice.rolls[0];
    return new cls(formula, data, options);
  }

  /* -------------------------------------------- */

  /**
   * Get the default configured Roll class.
   * @returns {typeof Roll}
   */
  static get defaultImplementation() {
    return CONFIG.Dice.rolls[0];
  }

  /* -------------------------------------------- */

  /**
   * Retrieve the appropriate resolver implementation based on the user's configuration.
   * @returns {typeof RollResolver}
   */
  static get resolverImplementation() {
    const config = game.settings.get("core", Roll.DICE_CONFIGURATION_SETTING);
    const methods = new Set(Object.values(config).filter(method => {
      if ( !method || (method === "manual") ) return false;
      return CONFIG.Dice.fulfillment.methods[method]?.interactive;
    }));

    // If there is more than one interactive method configured, use the default resolver which has a combined, method-
    // agnostic interface.
    if ( methods.size !== 1 ) return foundry.applications.dice.RollResolver;

    // Otherwise use the specific resolver configured for that method, if any.
    const method = CONFIG.Dice.fulfillment.methods[methods.first()];
    return method.resolver ?? foundry.applications.dice.RollResolver;
  }

  /* -------------------------------------------- */

  /**
   * Transform an array of RollTerm objects into a cleaned string formula representation.
   * @param {RollTerm[]} terms      An array of terms to represent as a formula
   * @returns {string}              The string representation of the formula
   */
  static getFormula(terms) {
    return terms.map(t => t.formula).join("");
  }

  /* -------------------------------------------- */

  /**
   * A sandbox-safe evaluation function to execute user-input code with access to scoped Math methods.
   * @param {string} expression   The input string expression
   * @returns {number}            The numeric evaluated result
   */
  static safeEval(expression) {
    let result;
    try {
      // eslint-disable-next-line no-new-func
      const evl = new Function("sandbox", `with (sandbox) { return ${expression}}`);
      result = evl(this.MATH_PROXY);
    } catch(err) {
      result = undefined;
    }
    if ( !Number.isNumeric(result) ) {
      throw new Error(`Roll.safeEval produced a non-numeric result from expression "${expression}"`);
    }
    return result;
  }

  /* -------------------------------------------- */

  /**
   * After parenthetical and arithmetic terms have been resolved, we need to simplify the remaining expression.
   * Any remaining string terms need to be combined with adjacent non-operators in order to construct parsable terms.
   * @param {RollTerm[]} terms      An array of terms which is eligible for simplification
   * @returns {RollTerm[]}          An array of simplified terms
   */
  static simplifyTerms(terms) {
    const {OperatorTerm, StringTerm} = foundry.dice.terms;

    // Simplify terms by combining with pending strings
    let simplified = terms.reduce((terms, term) => {
      const prior = terms[terms.length - 1];
      const isOperator = term instanceof OperatorTerm;

      // Combine a non-operator term with prior StringTerm
      if ( !isOperator && (prior instanceof StringTerm) ) {
        prior.term += term.total;
        foundry.utils.mergeObject(prior.options, term.options);
        return terms;
      }

      // Combine StringTerm with a prior non-operator term
      const priorOperator = prior instanceof OperatorTerm;
      if ( prior && !priorOperator && (term instanceof StringTerm) ) {
        term.term = String(prior.total) + term.term;
        foundry.utils.mergeObject(term.options, prior.options);
        terms[terms.length - 1] = term;
        return terms;
      }

      // Otherwise continue
      terms.push(term);
      return terms;
    }, []);

    // Convert remaining String terms to a RollTerm which can be evaluated
    simplified = simplified.map(term => {
      if ( !(term instanceof StringTerm) ) return term;
      const t = this._classifyStringTerm(term.formula, {intermediate: false});
      t.options = foundry.utils.mergeObject(term.options, t.options, {inplace: false});
      return t;
    });

    // Eliminate leading or trailing arithmetic
    if ( (simplified[0] instanceof OperatorTerm) && (simplified[0].operator !== "-") ) simplified.shift();
    if ( simplified.at(-1) instanceof OperatorTerm ) simplified.pop();
    return simplified;
  }

  /* -------------------------------------------- */

  /**
   * Simulate a roll and evaluate the distribution of returned results
   * @param {string} formula      The Roll expression to simulate
   * @param {number} n            The number of simulations
   * @returns {Promise<number[]>} The rolled totals
   */
  static async simulate(formula, n=10000) {
    const results = await Promise.all([...Array(n)].map(async () => {
      const r = new this(formula);
      return (await r.evaluate({allowInteractive: false})).total;
    }, []));
    const summary = results.reduce((sum, v) => {
      sum.total = sum.total + v;
      if ( (sum.min === null) || (v < sum.min) ) sum.min = v;
      if ( (sum.max === null) || (v > sum.max) ) sum.max = v;
      return sum;
    }, {total: 0, min: null, max: null});
    summary.mean = summary.total / n;
    console.log(`Formula: ${formula} | Iterations: ${n} | Mean: ${summary.mean} | Min: ${summary.min} | Max: ${summary.max}`);
    return results;
  }

  /* -------------------------------------------- */

  /**
   * Register an externally-fulfilled result with an active RollResolver.
   * @param {string} method        The fulfillment method.
   * @param {string} denomination  The die denomination being fulfilled.
   * @param {number} result        The obtained result.
   * @returns {boolean|void}       Whether the result was consumed. Returns undefined if no resolver was available.
   */
  static registerResult(method, denomination, result) {
    // TODO: Currently this only takes the first Resolver, but the logic for which Resolver to use could be improved.
    for ( const app of foundry.applications.instances.values() ) {
      if ( (app instanceof foundry.applications.dice.RollResolver) && app.rendered ) {
        return app.registerResult(method, denomination, result);
      }
    }
  }

  /* -------------------------------------------- */
  /*  Roll Formula Parsing                        */
  /* -------------------------------------------- */

  /**
   * Parse a formula expression using the compiled peggy grammar.
   * @param {string} formula  The original string expression to parse.
   * @param {object} data     A data object used to substitute for attributes in the formula.
   * @returns {RollTerm[]}
   */
  static parse(formula="", data={}) {
    if ( typeof formula !== "string" ) throw new Error(game.i18n.format("DICE.ErrorNotParsable", {formula}));
    if ( !formula ) return [];

    // Step 1: Replace formula and remove all spaces.
    const replaced = this.replaceFormulaData(formula, data, { missing: "0" });

    // Step 2: Use configured RollParser to parse the formula into a parse tree.
    const tree = foundry.dice.RollGrammar.parse(replaced);

    // Step 3: Flatten the tree into infix notation and instantiate all the nodes as RollTerm instances.
    return this.instantiateAST(tree);
  }

  /* -------------------------------------------- */

  /**
   * Instantiate the nodes in an AST sub-tree into RollTerm instances.
   * @param {RollParseNode} ast  The root of the AST sub-tree.
   * @returns {RollTerm[]}
   */
  static instantiateAST(ast) {
    return CONFIG.Dice.parser.flattenTree(ast).map(node => {
      const cls = foundry.dice.terms[node.class] ?? foundry.dice.terms.RollTerm;
      return cls.fromParseNode(node);
    });
  }

  /* -------------------------------------------- */

  /**
   * Replace referenced data attributes in the roll formula with values from the provided data.
   * Data references in the formula use the \@attr syntax and would reference the corresponding attr key.
   *
   * @param {string} formula          The original formula within which to replace
   * @param {object} data             The data object which provides replacements
   * @param {object} [options]        Options which modify formula replacement
   * @param {string} [options.missing]      The value that should be assigned to any unmatched keys.
   *                                        If null, the unmatched key is left as-is.
   * @param {boolean} [options.warn=false]  Display a warning notification when encountering an un-matched key.
   */
  static replaceFormulaData(formula, data, {missing, warn=false}={}) {
    const dataRgx = new RegExp(/@([a-z.0-9_-]+)/gi);
    return formula.replace(dataRgx, (match, term) => {
      let value = foundry.utils.getProperty(data, term);
      if ( value == null ) {
        if ( warn && ui.notifications ) ui.notifications.warn("DICE.WarnMissingData", {format: {match}});
        return (missing !== undefined) ? String(missing) : match;
      }
      switch ( foundry.utils.getType(value) ) {
        case "string": return value.trim();
        case "number": case "boolean": return String(value);
        case "Unknown":
          if ( value.toString instanceof Function ) return value.toString();
          break;
        case "Set": value = Array.from(value); break;
        case "Map": value = Object.fromEntries(Array.from(value)); break;
      }
      return `ᚖ${JSON.stringify(value)}ᚖ`;
    });
  }

  /* -------------------------------------------- */

  /**
   * Validate that a provided roll formula can represent a valid
   * @param {string} formula    A candidate formula to validate
   * @returns {boolean}         Is the provided input a valid dice formula?
   */
  static validate(formula) {
    if ( typeof formula !== "string" ) return false;

    // Replace all data references with an arbitrary number
    formula = formula.replace(/@([a-z.0-9_-]+)/gi, "1");

    // Attempt to evaluate the roll
    try {
      const r = new this(formula);
      r.evaluateSync({ strict: false });
      return true;
    }

    // If we weren't able to evaluate, the formula is invalid
    catch(err) {
      return false;
    }
  }

  /* -------------------------------------------- */

  /**
   * Determine which of the given terms require external fulfillment.
   * @param {RollTerm[]} terms  The terms.
   * @returns {DiceTerm[]}
   */
  static identifyFulfillableTerms(terms) {
    const fulfillable = [];
    const config = game.settings.get("core", Roll.DICE_CONFIGURATION_SETTING);
    const allowManual = game.user.hasPermission("MANUAL_ROLLS");

    /**
     * Determine if a given term should be externally fulfilled.
     * @param {RollTerm} term  The term.
     */
    const identifyTerm = term => {
      if ( !(term instanceof foundry.dice.terms.DiceTerm) || !term.number || !term.faces ) return;
      const method = config[term.denomination] || CONFIG.Dice.fulfillment.defaultMethod;
      if ( (method === "manual") && !allowManual ) return;
      if ( CONFIG.Dice.fulfillment.methods[method]?.interactive ) fulfillable.push(term);
    };

    /**
     * Identify any DiceTerms in the provided list of terms.
     * @param {RollTerm[]} terms  The terms.
     */
    const identifyDice = (terms=[]) => {
      terms.forEach(term => {
        identifyTerm(term);
        if ( "dice" in term ) identifyDice(term.dice);
      });
    };

    identifyDice(terms);
    return fulfillable;
  }

  /* -------------------------------------------- */

  /**
   * Classify a remaining string term into a recognized RollTerm class
   * @param {string} term         A remaining un-classified string
   * @param {object} [options={}] Options which customize classification
   * @param {boolean} [options.intermediate=true]  Allow intermediate terms
   * @param {RollTerm|string} [options.prior]       The prior classified term
   * @param {RollTerm|string} [options.next]        The next term to classify
   * @returns {RollTerm}          A classified RollTerm instance
   * @internal
   */
  static _classifyStringTerm(term, {intermediate=true, prior, next}={}) {
    const {DiceTerm, NumericTerm, RollTerm, StringTerm} = foundry.dice.terms;

    // Terms already classified
    if ( term instanceof RollTerm ) return term;

    // Numeric terms
    const numericMatch = NumericTerm.matchTerm(term);
    if ( numericMatch ) return NumericTerm.fromMatch(numericMatch);

    // Dice terms
    const diceMatch = DiceTerm.matchTerm(term, {imputeNumber: !intermediate});
    if ( diceMatch ) {
      if ( intermediate && (prior?.isIntermediate || next?.isIntermediate) ) return new StringTerm({term});
      return DiceTerm.fromMatch(diceMatch);
    }

    // Remaining strings
    return new StringTerm({term});
  }

  /* -------------------------------------------- */
  /*  Chat Messages                               */
  /* -------------------------------------------- */

  /**
   * Render the tooltip HTML for a Roll instance
   * @returns {Promise<string>}     The rendered HTML tooltip as a string
   */
  async getTooltip() {
    const parts = this.dice.map(d => d.getTooltipData());
    return foundry.applications.handlebars.renderTemplate(this.constructor.TOOLTIP_TEMPLATE, {parts});
  }

  /* -------------------------------------------- */

  /**
   * Render a Roll instance to HTML
   * @param {object} [options={}]               Options which affect how the Roll is rendered
   * @param {string} [options.flavor]             Flavor text to include
   * @param {string} [options.template]           A custom HTML template path
   * @param {boolean} [options.isPrivate=false]   Is the Roll displayed privately?
   * @returns {Promise<string>}                 The rendered HTML template as a string
   */
  async render({flavor, template=this.constructor.CHAT_TEMPLATE, isPrivate=false, ...options}={}) {
    if ( !this._evaluated ) await this.evaluate({allowInteractive: !isPrivate});
    const chatData = await this._prepareChatRenderContext({flavor, isPrivate, ...options});
    return foundry.applications.handlebars.renderTemplate(template, chatData);
  }

  /* -------------------------------------------- */

  /**
   * Prepare context data used to render the CHAT_TEMPLATE for this roll.
   * @param {object} options
   * @param {string} [options.flavor]
   * @param {boolean} [options.isPrivate=false]
   * @returns {Promise<{object}>}
   * @protected
   */
  async _prepareChatRenderContext({flavor, isPrivate=false, ...options}={}) {
    return {
      formula: isPrivate ? "???" : this._formula,
      flavor: isPrivate ? null : flavor ?? this.options.flavor,
      user: game.user.id,
      tooltip: isPrivate ? "" : await this.getTooltip(),
      total: isPrivate ? "?" : Math.round(this.total * 100) / 100
    };
  }

  /* -------------------------------------------- */

  /**
   * Transform a Roll instance into a ChatMessage, displaying the roll result.
   * This function can either create the ChatMessage directly, or return the data object that will be used to create.
   *
   * @param {object} messageData          The data object to use when creating the message
   * @param {object} [options]            Additional options which modify the created message.
   * @param {string} [options.rollMode]   The template roll mode to use for the message from CONFIG.Dice.rollModes
   * @param {boolean} [options.create=true]   Whether to automatically create the chat message, or only return the
   *                                          prepared chatData object.
   * @returns {Promise<ChatMessage|object>} A promise which resolves to the created ChatMessage document if create is
   *                                        true, or the Object of prepared chatData otherwise.
   */
  async toMessage(messageData={}, {rollMode, create=true}={}) {
    if ( rollMode === "roll" ) rollMode = undefined;
    rollMode ||= game.settings.get("core", "rollMode");

    // Perform the roll, if it has not yet been rolled
    if ( !this._evaluated ) await this.evaluate({allowInteractive: rollMode !== CONST.DICE_ROLL_MODES.BLIND});

    // Prepare chat data
    messageData = foundry.utils.mergeObject({
      author: game.user.id,
      content: String(this.total),
      sound: CONFIG.sounds.dice
    }, messageData);
    messageData.rolls = [this];

    // Either create the message or just return the chat data
    const cls = foundry.utils.getDocumentClass("ChatMessage");
    const msg = new cls(messageData);
    msg.applyRollMode(rollMode);

    // Either create or return the data
    if ( create ) return cls.create(msg);
    return msg.toObject();
  }

  /* -------------------------------------------- */
  /*  Interface Helpers                           */
  /* -------------------------------------------- */

  /**
   * Expand an inline roll element to display its contained dice result as a tooltip.
   * @param {HTMLAnchorElement} a     The inline-roll button
   * @returns {Promise<void>}
   */
  static async expandInlineResult(a) {
    if ( !a.classList.contains("inline-roll") ) return;
    if ( a.classList.contains("expanded") ) return;

    // Create a new tooltip
    const roll = this.fromJSON(unescape(a.dataset.roll));
    const tip = document.createElement("div");
    tip.innerHTML = await roll.getTooltip();

    // Add the tooltip
    const tooltip = tip.querySelector(".dice-tooltip");
    if ( !tooltip ) return;
    a.appendChild(tooltip);
    a.classList.add("expanded");

    // Set the position
    const pa = a.getBoundingClientRect();
    const pt = tooltip.getBoundingClientRect();
    tooltip.style.left = `${Math.min(pa.x, window.innerWidth - (pt.width + 3))}px`;
    tooltip.style.top = `${Math.min(pa.y + pa.height + 3, window.innerHeight - (pt.height + 3))}px`;
    tooltip.popover = "manual";
    tooltip.showPopover();

    // Disable tooltip while expanded
    delete a.dataset.tooltip;
    game.tooltip.deactivate();
  }

  /* -------------------------------------------- */

  /**
   * Collapse an expanded inline roll to conceal its tooltip.
   * @param {HTMLAnchorElement} a     The inline-roll button
   */
  static collapseInlineResult(a) {
    if ( !a.classList.contains("inline-roll") ) return;
    if ( !a.classList.contains("expanded") ) return;
    const tooltip = a.querySelector(".dice-tooltip");
    if ( tooltip ) tooltip.remove();
    const roll = this.fromJSON(unescape(a.dataset.roll));
    a.dataset.tooltipText = roll.formula;
    return a.classList.remove("expanded");
  }

  /* -------------------------------------------- */

  /**
   * Construct an inline roll link for this Roll.
   * @param {object} [options]                  Additional options to configure how the link is constructed.
   * @param {string} [options.label]            A custom label for the total.
   * @param {Record<string, string>} [options.attrs]    Attributes to set on the link.
   * @param {Record<string, string>} [options.dataset]  Custom data attributes to set on the link.
   * @param {string[]} [options.classes]        Additional classes to add to the link. The classes `inline-roll`
   *                                            and `inline-result` are added by default.
   * @param {string} [options.icon]             A font-awesome icon class to use as the icon instead of a d20.
   * @returns {HTMLAnchorElement}
   */
  toAnchor({attrs={}, dataset={}, classes=[], label, icon}={}) {
    dataset = foundry.utils.mergeObject({roll: escape(JSON.stringify(this))}, dataset);
    const a = document.createElement("a");
    a.classList.add("inline-roll", "inline-result", ...classes);
    a.dataset.tooltipText = this.formula;
    Object.entries(attrs).forEach(([k, v]) => a.setAttribute(k, v));
    Object.entries(dataset).forEach(([k, v]) => a.dataset[k] = v);
    label = label ? `${label}: ${this.total}` : this.total;
    const i = document.createElement("i");
    i.className = icon ?? "fa-solid fa-dice-d20";
    i.inert = true;
    a.append(i, label);
    return a;
  }

  /* -------------------------------------------- */
  /*  Serialization and Loading                   */
  /* -------------------------------------------- */

  /**
   * Represent the data of the Roll as an object suitable for JSON serialization.
   * @returns {object}     Structured data which can be serialized into JSON
   */
  toJSON() {
    return {
      class: this.constructor.name,
      options: this.options,
      dice: this._dice,
      formula: this._formula,
      terms: this.terms.map(t => t.toJSON()),
      total: this._total,
      evaluated: this._evaluated
    };
  }

  /* -------------------------------------------- */

  /**
   * Recreate a Roll instance using a provided data object
   * @param {object} data   Unpacked data representing the Roll
   * @returns {Roll}         A reconstructed Roll instance
   */
  static fromData(data) {
    const {DiceTerm, RollTerm} = foundry.dice.terms;

    // Redirect to the proper Roll class definition
    if ( data.class && (data.class !== this.name) ) {
      const cls = CONFIG.Dice.rolls.find(cls => cls.name === data.class);
      if ( !cls ) throw new Error(`Unable to recreate ${data.class} instance from provided data`);
      return cls.fromData(data);
    }

    // Create the Roll instance
    const roll = new this(data.formula, data.data, data.options);

    // Expand terms
    roll.terms = data.terms.map(t => {
      if ( t.class ) {
        if ( t.class === "DicePool" ) t.class = "PoolTerm"; // Backwards compatibility
        if ( t.class === "MathTerm" ) t.class = "FunctionTerm";
        return RollTerm.fromData(t);
      }
      return t;
    });

    // Repopulate evaluated state
    if ( data.evaluated ?? true ) {
      roll._total = data.total;
      roll._dice = (data.dice || []).map(t => DiceTerm.fromData(t));
      roll._evaluated = true;
    }
    return roll;
  }

  /* -------------------------------------------- */

  /**
   * Recreate a Roll instance using a provided JSON string
   * @param {string} json   Serialized JSON data representing the Roll
   * @returns {Roll}        A reconstructed Roll instance
   */
  static fromJSON(json) {
    return this.fromData(JSON.parse(json));
  }

  /* -------------------------------------------- */

  /**
   * Manually construct a Roll object by providing an explicit set of input terms
   * @param {RollTerm[]} terms      The array of terms to use as the basis for the Roll
   * @param {object} [options={}]   Additional options passed to the Roll constructor
   * @returns {Roll}                The constructed Roll instance
   *
   * @example Construct a Roll instance from an array of component terms
   * ```js
   * const t1 = new Die({number: 4, faces: 8};
   * const plus = new OperatorTerm({operator: "+"});
   * const t2 = new NumericTerm({number: 8});
   * const roll = Roll.fromTerms([t1, plus, t2]);
   * roll.formula; // 4d8 + 8
   * ```
   */
  static fromTerms(terms, options={}) {
    const {OperatorTerm, RollTerm} = foundry.dice.terms;

    // Validate provided terms
    if ( !terms.every(t => t instanceof RollTerm ) ) {
      throw new Error("All provided terms must be RollTerm instances");
    }
    const allEvaluated = terms.every(t => t._evaluated);
    const noneEvaluated = terms.every(t => !t._evaluated || (t instanceof OperatorTerm));
    if ( !(allEvaluated || noneEvaluated) ) {
      throw new Error("You can only call Roll.fromTerms with an array of terms which are either all evaluated, or none evaluated");
    }

    // Construct the roll
    const formula = this.getFormula(terms);
    const roll = new this(formula, {}, options);
    roll.terms = terms;
    roll._evaluated = allEvaluated;
    if ( roll._evaluated ) roll._total = roll._evaluateTotal();
    return roll;
  }
}

/**
 * @import TinyMCE from "tinymce"
 */

/**
 * @typedef 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 {boolean} [custom=true]        Apply custom enrichers?
 * @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.
 */

/**
 * @typedef EnrichmentAnchorOptions
 * @property {Record<string, string>} [attrs]    Attributes to set on the anchor.
 * @property {Record<string, string>} [dataset]  Data- attributes to set on the anchor.
 * @property {string[]} [classes]                Classes to add to the anchor.
 * @property {string} [name]                     The anchor's content.
 * @property {string} [icon]                     A font-awesome icon class to use as the icon.
 */

/**
 * @typedef TextReplacementOptions
 * @property {boolean} [replaceParent=false]  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.
 */

/**
 * @typedef 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.
 */

/**
 * A collection of helper functions and utility methods related to the rich text editor.
 */
let TextEditor$1 = 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" ) {
      const warning = 'The editor engine option of "tinymce" is deprecated and will be removed in V14.'
        + 'Please use "prosemirror" instead.';
      foundry.utils.logCompatibilityWarning(warning, {since: 13, until: 14});
      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>}
   */
  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) {
      const filePicker = new FilePicker.implementation({
        type: "image",
        callback: path => {
          pickerCallback(path);
          // Reset our z-index for next open
          document.querySelector(".tox-tinymce-aux").style.zIndex = "";
        }
      });
      filePicker.render();
      // Set the TinyMCE dialog to be below the FilePicker
      document.querySelector(".tox-tinymce-aux").style.zIndex = Math.min(++foundry.applications.api.ApplicationV2._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 => TextEditor.#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;
  }

  /* -------------------------------------------- */

  /**
   * 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={}) {
    if ( this !== CONFIG.ux.TextEditor ) return TextEditor.implementation.enrichHTML(content, options);
    const {secrets=false, documents=true, links=true, embeds=true, rolls=true, custom=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 = {...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));
    if ( custom ) {
      for ( const config of CONFIG.TextEditor.enrichers ) {
        fns.push(this._applyCustomEnrichers.bind(this, config));
      }
    }

    // Perform enrichment
    let text = TextEditor.#getTextNodes(html);
    await this._primeCompendiums(text, options);

    let updateTextArray = false;
    for ( const fn of fns ) {
      if ( updateTextArray ) text = TextEditor.#getTextNodes(html);
      updateTextArray = await fn(text, options);
    }

    TextEditor.#wrapSecrets(html);
    await this._finalizeEnrichedHTML(html, 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.
   * @param {EnrichmentOptions} [options]   Options provided to customize text enrichment
   * @protected
   */
  static async _primeCompendiums(text, options={}) {
    // 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, options={}) {
    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, options));
  }

  /* -------------------------------------------- */

  /**
   * 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, match => this._createHyperlink(match, options));
  }

  /* -------------------------------------------- */

  /**
   * 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.
   * @param {EnrichmentOptions} [options] Options provided to customize text enrichment.
   * @returns {Promise<boolean>}          Whether any inline rolls were replaced and the text nodes need to be updated.
   * @protected
   */
  static async _enrichInlineRolls(rollData, text, options={}) {
    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, options));
  }

  /* -------------------------------------------- */

  /**
   * 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({id, pattern, enricher, onRender, replaceParent}, text, options) {
    const wrapElement = !!id && (onRender instanceof Function);
    const enrich = async match => {
      let result = await enricher(match, options);
      if ( result && wrapElement ) {
        const wrapCls = foundry.applications.elements.HTMLEnrichedContentElement;
        if ( !(result instanceof wrapCls) ) {
          const wrap = new wrapCls();
          wrap.appendChild(result);
          result = wrap;
        }
        result.setAttribute("enricher", id);
      }
      return result;
    };
    return this._replaceTextContent(text, pattern, enrich, {replaceParent});
  }

  /* -------------------------------------------- */

  /**
   * A method that can be extended by subclasses to perform final post-enrichment operations on an HTML fragment.
   * Final changes should be made in-place, mutating the provided HTML element.
   * Note: This API is experimental and may be removed in later versions without deprecation.
   * @param {HTMLDivElement} html             A div element containing the enriched HTML
   * @param {EnrichmentOptions} options       Provided enrichment options
   * @returns {Promise<void>}                 A promise which resolves once finalization has completed
   * @protected
   * @internal
   */
  static async _finalizeEnrichedHTML(html, options) {}

  /* -------------------------------------------- */

  /**
   * 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 ( TextEditor.#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;
  }

  /* -------------------------------------------- */

  /**
   * Wrap secrets in a custom element for interactivity handling.
   * @param {HTMLDivElement} html  The post-enriched HTML.
   */
  static #wrapSecrets(html) {
    for ( const secret of html.querySelectorAll("section.secret") ) {
      if ( secret.closest("secret-block, [data-content-embed]") ) continue;
      const block = document.createElement("secret-block");
      secret.replaceWith(block);
      block.append(secret);
    }
  }

  /* -------------------------------------------- */
  /*  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
   */
  static #getTextNodes(parent) {
    const text = [];
    const walk = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
    while ( walk.nextNode() ) text.push(walk.currentNode);
    return text;
  }

  /* -------------------------------------------- */

  /**
   * 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.
   * @protected
   */
  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$1.onError("TextEditor.enrichHTML", err, { log: "error" });
        }
        if ( result ) {
          TextEditor.#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.
   */
  static #replaceTextNode(text, match, replacement, {replaceParent=false}={}) {
    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 {EnrichmentOptions} [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 foundry.utils.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 = "fa-solid fa-link-slash";
      data.classes.push("broken");
    }
    return this.createAnchor(data);
  }

  /* -------------------------------------------- */

  /**
   * 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;
    }
    if ( icon ) {
      const i = document.createElement("i");
      i.className = icon;
      i.inert = true;
      a.append(i);
    }
    a.append(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 foundry.utils.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");
    const i = document.createElement("i");
    i.classList.add("fa-solid", "fa-circle-exclamation");
    i.inert = true;
    broken.append(i, game.i18n.format("EDITOR.EmbedFailed", { uuid: config.uuid }));
    return broken;
  }

  /* -------------------------------------------- */

  /**
   * 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.
   */
  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 ( id && 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
   * @param {EnrichmentOptions} [options]   Options provided to customize text enrichment
   * @returns {Promise<HTMLAnchorElement>}  An HTML element for the document link
   * @protected
   */
  static async _createHyperlink(match, options={}) {
    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
   * @param {EnrichmentOptions} [options]        Options provided to customize text enrichment.
   * @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, options={}) {
    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(err) { return null; }
    }

    // Otherwise verify that the tag contains a valid roll command
    const chatCommand = `${command}${formula}`;
    const ChatLog = foundry.applications.sidebar.tabs.ChatLog;
    let parsedCommand = null;
    try {
      parsedCommand = ChatLog.parse(chatCommand);
    }
    catch(err) { 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.tooltipText = formula;
    const i = document.createElement("i");
    i.classList.add("fa-solid", "fa-dice-d20");
    i.inert = true;
    a.append(i, label || matchedFormula);
    return a;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Activate interaction listeners for the interior content of the editor frame.
   */
  static activateListeners() {
    document.body.addEventListener("click", event => {
      if ( event.target?.closest("a[data-link]") ) TextEditor.#onClickContentLink(event);
      if ( event.target?.closest("a.inline-roll") ) this._onClickInlineRoll(event);
    });
    document.body.addEventListener("dragstart", event => {
      if ( event.target?.closest?.("a[data-link]") ) TextEditor.#onDragContentLink(event);
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on Document Links
   * @param {Event} event
   */
  static async #onClickContentLink(event) {
    event.preventDefault();
    const doc = await foundry.utils.fromUuid(event.target.closest("a[data-link]").dataset.uuid);
    return doc?._onClickDocumentLink(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle left-mouse clicks on an inline roll, dispatching the formula or displaying the tooltip
   * @param {MouseEvent} event    The initiating click event
   * @protected
   */
  static async _onClickInlineRoll(event) {
    event.preventDefault();
    const a = event.target.closest("a.inline-roll");

    // 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 = foundry.utils.getDocumentClass("ChatMessage");
    const speaker = cls.getSpeaker();
    const 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] ?? foundry.applications.instances.get(sheet.id);
      if ( ["Actor", "Item"].includes(app?.document?.documentName) ) rollData = app.document.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
   */
  static #onDragContentLink(event) {
    event.stopPropagation();
    const a = event.target.closest("a[data-link]");
    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 = foundry.utils.fromUuidSync(a.dataset.uuid);
      dragData = doc.toDragData();
    }

    event.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
   */
  static async #onDropEditorData(event, editor) {
    event.preventDefault();
    const eventData = TextEditor.implementation.getDragEventData(event);
    const link = await TextEditor.implementation.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 = foundry.utils.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|void>} 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;
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Retrieve the configured TextEditor implementation.
   * @type {typeof TextEditor}
   */
  static get implementation() {
    if ( !foundry.utils.isSubclass(CONFIG.ux.TextEditor, TextEditor) ) {
      console.warn("Configured TextEditor override must be a subclass of TextEditor.");
      CONFIG.ux.TextEditor = TextEditor;
    }
    return CONFIG.ux.TextEditor;
  }
};

/**
 * @import Document from "@common/abstract/document.mjs";
 * @import {Application, ApplicationV2} from "@client/applications/api/_module.mjs";
 * @import {DatabaseCreateOperation, DatabaseDeleteOperation} from "@common/abstract/_types.mjs";
 * @import Collection from "@common/utils/collection.mjs";
 * @import {DocumentConstructionContext} from "@common/abstract/_types.mjs";
 */

/**
 * 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.
 * @category Mixins
 * @param {typeof Document} Base    The base Document class to be mixed
 */
function ClientDocumentMixin(Base) {
  /**
   * The ClientDocument extends the base Document class by adding client-specific behaviors to all Document types.
   */
  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.
       * @type {Record<string,Application|ApplicationV2>}
       */
      Object.defineProperty(this, "apps", {
        value: {},
        writable: false,
        enumerable: false
      });

      /**
       * A cached reference to the Application instance used to configure this Document.
       * @type {Application|ApplicationV2|null}
       * @internal
       */
      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;
      if ( this.parent?.collections[this.parentCollection]?._initialized === false ) {
        return; // Skip documents in uninitialized embedded collections
      }
      this._safePrepareData();
    }

    /* -------------------------------------------- */
    /*  Properties                                  */
    /* -------------------------------------------- */

    /**
     * Return a reference to the parent Collection instance that contains this Document.
     * @this {ClientDocument}
     * @returns {Collection|null}
     */
    get collection() {
      if ( this.isEmbedded ) return this.parent[this.parentCollection];
      if ( this.inCompendium ) return game.packs.get(this.pack, {strict: true});
      return CONFIG[this.documentName].collection?.instance ?? null;
    }

    /* -------------------------------------------- */

    /** @override */
    get compendium() {
      return this.inCompendium ? game.packs.get(this.pack) : null;
    }

    /* -------------------------------------------- */

    /**
     * Is this document in a compendium? A stricter check than Document#inCompendium.
     * @returns {boolean}
     * @override
     */
    get inCompendium() {
      return this.parent?.inCompendium ?? (this.documentName === game.packs.get(this.pack)?.documentName);
    }

    /* -------------------------------------------- */

    /**
     * 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}
     */
    get isOwner() {
      return this.testUserPermission(game.user, "OWNER");
    }

    /* -------------------------------------------- */

    /**
     * Test whether this Document is owned by any non-Gamemaster User.
     * @type {boolean}
     */
    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}
     */
    get limited() {
      return this.testUserPermission(game.user, "LIMITED", {exact: true});
    }

    /* -------------------------------------------- */

    /**
     * Return a string which creates a dynamic link to this Document instance.
     * @returns {string}
     */
    get link() {
      return `@UUID[${this.uuid}]{${foundry.utils.escapeHTML(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}
     *
     * @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 DOCUMENT_OWNERSHIP_LEVELS.OWNER;
      if ( this.isEmbedded ) return this.parent.permission;
      return this.getUserLevel(game.user);
    }

    /* -------------------------------------------- */

    /**
     * Lazily obtain a Application instance used to configure this Document, or null if no sheet is available.
     * @type {Application|ApplicationV2|null}
     */
    get sheet() {
      if ( !this._sheet ) {
        const cls = this._getSheetClass();

        // Application V1 Document Sheets
        if ( foundry.utils.isSubclass(cls, foundry.appv1.api.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}
     */
    get visible() {
      if ( this.isEmbedded ) return this.parent.visible;
      return this.testUserPermission(game.user, "LIMITED");
    }

    /* -------------------------------------------- */
    /*  Methods                                     */

    /* -------------------------------------------- */

    /**
     * Obtain the Application class constructor which should be used to configure this Document.
     * @returns {Function|null}
     * @internal
     */
    _getSheetClass() {
      const cfg = CONFIG[this.documentName];
      const type = this.type ?? 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 foundry.applications.sheets.BaseSheet;
      return (classes.find(s => s.default) ?? classes.find(s => s.canBeDefault) ?? classes.pop()).cls;
    }

    /* -------------------------------------------- */

    /**
     * Safely prepare data for a Document, catching any errors.
     * @internal
     */
    _safePrepareData() {
      try {
        this.prepareData();
      } catch(err) {
        Hooks$1.onError("ClientDocumentMixin#_initialize", err, {
          msg: `Failed data preparation for ${this.uuid}`,
          log: "error",
          uuid: this.uuid
        });
      }
    }

    /* -------------------------------------------- */

    /**
     * Prepare data for the Document. This method provides an opportunity for Document classes to define special data
     * preparation logic to compute values that don't need to be stored in the database, such as a "bloodied" hp value
     * or the total carrying weight of items. The work done by this method should be idempotent per initialization.
     * There are situations in which prepareData may be called more than once.
     *
     * By default, foundry calls the following methods in order whenever the document is created or updated.
     * 1. {@link reset} (Inherited from DataModel)
     * 2. {@link _initialize} (Inherited from DataModel)
     * 3. {@link prepareData}
     * 4. {@link foundry.abstract.TypeDataModel.prepareBaseData | TypeDataModel#prepareBaseData}
     * 5. {@link prepareBaseData}
     * 6. {@link prepareEmbeddedDocuments}
     * 7. {@link foundry.abstract.TypeDataModel.prepareDerivedData | TypeDataModel#prepareBaseData}
     * 8. {@link prepareDerivedData}
     *
     * Do NOT invoke database operations like {@link update} or {@link setFlag} within data prep, as that can cause an
     * infinite loop by re-triggering the data initialization process.
     *
     * If possible you should extend {@link prepareBaseData} and {@link prepareDerivedData} instead of this function
     * directly, but some systems with more complicated calculations may want to override this function to add extra
     * steps, such as to calculate certain item values after actor data prep.
     */
    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.
     *
     * If possible when modifying the `system` object you should use
     * {@link foundry.abstract.TypeDataModel.prepareBaseData | TypeDataModel#prepareBaseData} on your data models
     * instead of this method directly on the document.
     */
    prepareBaseData() {}

    /* -------------------------------------------- */

    /**
     * Prepare all embedded Document instances which exist within this primary Document.
     */
    prepareEmbeddedDocuments() {
      for ( const collectionName of Object.keys(this.constructor.hierarchy || {}) ) {
        for ( const 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.
     *
     * If possible when modifying the `system` object you should use
     * {@link foundry.abstract.TypeDataModel.prepareDerivedData | TypeDataModel#prepareDerivedData} on your data models
     * instead of this method directly on the document.
     */
    prepareDerivedData() {}

    /* -------------------------------------------- */

    /**
     * Render all Application instances which are connected to this document by calling their respective
     * @see {@link foundry.applications.api.ApplicationV2#render}
     * @param {boolean} [force=false]     Force rendering
     * @param {object} [context={}]       Optional context
     */
    render(force=false, context={}) {
      for ( const 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} [options.updateData] Additional data changes applied to each sorted document
     * @param {object} [options.sortOptions] Options passed to the foundry.utils.performIntegerSort method
     * @returns {Promise<Document>}       The Document after it has been re-sorted
     */
    async sortRelative({updateData={}, ...sortOptions}={}) {
      const sorting = foundry.utils.performIntegerSort(this, sortOptions);
      const updates = [];
      for ( const 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) {
      const collection = this.collection;

      // The Documents are in two different compendia.
      if ( collection instanceof CompendiumCollection && (collection !== relative.collection) ) return this.uuid;

      // This Document is a sibling of the relative Document.
      if ( this.isEmbedded && (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 ??= foundry.utils.escapeHTML(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 {PointerEvent} event The triggering click event.
     * @returns {Application|Promise<ApplicationV2>|null}
     * @protected
     */
    _onClickDocumentLink(event) {
      return this.sheet?.render(true) ?? null;
    }

    /* -------------------------------------------- */
    /*  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.inCompendium && !this.isEmbedded ) {
        if ( this instanceof foundry.documents.Folder ) this.collection.folders.set(this.id, this);
        else this.collection.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.inCompendium && !this.isEmbedded ) {
        if ( this instanceof foundry.documents.Folder ) this.collection.folders.set(this.id, this);
        else this.collection.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);
      this.#closeApplications(this);

      // Update Compendium and global indices
      if ( this.inCompendium && !this.isEmbedded ) {
        if ( this instanceof foundry.documents.Folder ) this.collection.folders.delete(this.id);
        else this.collection.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);
      }
    }

    /**
     * Close open Applications for this Document and its children.
     * @param {ClientDocument} document
     * @param {object} [closingOptions]
     */
    #closeApplications(document, closingOptions) {
      if ( !closingOptions ) {
        closingOptions = {
          submit: false,
          renderContext: `delete${this.documentName}`,
          renderData: this
        };
        /** @deprecated since v12 */
        Object.defineProperties(closingOptions, {
          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(document.apps).forEach(a => a.close(closingOptions));
      for ( const collection of Object.values(document.collections) ) {
        for (const child of collection ) {
          this.#closeApplications(child, closingOptions);
        }
      }
    }

    /* -------------------------------------------- */
    /*  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);

      // 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).index;
      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                Document creation data
     * @param {DatabaseCreateOperation} [createOptions]  Document creation options.
     * @param {object} [options={}]        Options forwarded to DialogV2.prompt
     * @param {{id: string; name: string}[]} [options.folders] Available folders in which the new Document can be place
     * @param {string[]} [options.types]   A restriction of the selectable sub-types of the Dialog.
     * @param {string} [options.template]  A template to use for the dialog contents instead of the default.
     * @param {object} [options.context]   Additional render context to provide to the template.
     * @returns {Promise<Document|null>}   A Promise which resolves to the created Document, or null if the dialog was
     *                                     closed.
     */
    static async createDialog(data={}, createOptions={}, {folders, types, template, context, ...dialogOptions}={}) {
      const applicationOptions = {
        top: "position", left: "position", width: "position", height: "position", scale: "position", zIndex: "position",
        title: "window", id: "", classes: "", jQuery: ""
      };

      for ( const [k, v] of Object.entries(createOptions) ) {
        if ( k in applicationOptions ) {
          foundry.utils.logCompatibilityWarning("The ClientDocument.createDialog signature has changed. "
            + "It now accepts database operation options in its second parameter, "
            + "and options for DialogV2.prompt in its third parameter.", { since: 13, until: 15, once: true });
          const dialogOption = applicationOptions[k];
          if ( dialogOption ) foundry.utils.setProperty(dialogOptions, `${dialogOption}.${k}`, v);
          else dialogOptions[k] = v;
          delete createOptions[k];
        }
      }

      const { parent, pack } = createOptions;
      const cls = this.implementation;

      // Identify allowed types
      const 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 === 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
      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
      template ??= "templates/sidebar/document-create.html";
      const html = await foundry.applications.handlebars.renderTemplate(template, {
        folders, hasTypes, type,
        name: data.name || "",
        defaultName: cls.defaultName({type, parent, pack}),
        folder: data.folder,
        hasFolders: folders.length >= 1,
        types: documentTypes,
        ...context
      });
      const content = document.createElement("div");
      content.innerHTML = html;

      // Render the confirmation dialog window
      return foundry.applications.api.DialogV2.prompt(foundry.utils.mergeObject({
        content,
        window: {title}, // FIXME: double localization
        position: {width: 360},
        render: (event, dialog) => {
          if ( !hasTypes ) return;
          dialog.element.querySelector('[name="type"]').addEventListener("change", e => {
            const nameInput = dialog.element.querySelector('[name="name"]');
            nameInput.placeholder = cls.defaultName({type: e.target.value, parent, pack});
          });
        },
        ok: {
          label: title, // FIXME: double localization
          callback: (event, button) => {
            const fd = new foundry.applications.ux.FormDataExtended(button.form);
            foundry.utils.mergeObject(data, fd.object);
            if ( !data.folder ) delete data.folder;
            if ( !data.name?.trim() ) data.name = cls.defaultName({type: data.type, parent, pack});
            return cls.create(data, {renderSheet: true, ...createOptions});
          }
        }
      }, dialogOptions));
    }

    /* -------------------------------------------- */

    /**
     * Present a Dialog form to confirm deletion of this Document.
     * @param {object} [options] Additional options passed to `DialogV2.confirm`
     * @param {DatabaseDeleteOperation} [operation]  Document deletion options.
     * @returns {Promise<Document>} A Promise that resolves to the deleted Document
     */
    async deleteDialog(options={}, operation={}) {
      const positionKeys = ["top", "left", "width", "height", "scale", "zIndex"];
      if ( positionKeys.some(k => k in options) ) {
        foundry.utils.logCompatibilityWarning(
          "options is now an object containing entries supported by DialogV2.confirm.",
          {since: 13, until: 15}
        );
        options.position = positionKeys.reduce((position, key) => {
          if ( options[key] !== undefined ) position[key] = options[key];
          delete options[key];
          return position;
        }, {});
      }
      let content = options.content;
      const type = game.i18n.localize(this.constructor.metadata.label);
      if ( !content ) {
        const question = game.i18n.localize("AreYouSure");
        const warning = game.i18n.format("SIDEBAR.DeleteWarning", {type});
        content = `<p><strong>${question}</strong> ${warning}</p>`;
      }
      return foundry.applications.api.DialogV2.confirm(foundry.utils.mergeObject(
        {
          content,
          yes: {callback: () => this.delete(operation)},
          window: {
            icon: "fa-solid fa-trash",
            title: `${game.i18n.format("DOCUMENT.Delete", {type})}: ${this.name}` // FIXME: double localization
          }
        },
        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 ClientDocument#toCompendium} method
     */
    exportToJSON(options={}) {
      if ( !WORLD_DOCUMENT_TYPES.includes(this.documentName) ) {
        throw new Error("Only world Documents may be exported");
      }
      options.clearSource ??= false;
      const data = this.toCompendium(null, options);
      data._stats ??= {};
      data._stats.exportSource = {
        worldId: game.world.id,
        uuid: this.uuid,
        coreVersion: game.version,
        systemId: game.system.id,
        systemVersion: game.system.version
      };
      const filename = ["fvtt", this.documentName, this.name?.slugify(), this.id].filterJoin("-");
      foundry.utils.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
     *
     * @param {object} data           The data object extracted from a DataTransfer event
     * @returns {Promise<Document>}   The resolved Document
     * @throws If a Document could not be retrieved from the provided data.
     */
    static async fromDropData(data) {
      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 foundry.utils.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 foundry.abstract.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} [context] The model construction context passed to
     *                                                {@link foundry.abstract.Document.fromSource}. Strict validation is
     *                                                enabled by default.
     * @returns {Promise<Document>}
     */
    static async fromImport(source, context) {
      if ( !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 ( !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
      for ( const k of this.constructor.metadata.preserveOnImport ) delete data[k];

      // Commit the import as an update to this document
      await this.update(data, {diff: false, recursive: false, noHook: true});
      ui.notifications.info("DOCUMENT.Imported", {format: {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>}
     */
    async importFromJSONDialog() {
      await foundry.applications.api.DialogV2.wait({
        window: {title: `${game.i18n.localize("DOCUMENT.ImportData")}: ${this.name}`}, // FIXME: double localization
        position: {width: 400},
        content: await foundry.applications.handlebars.renderTemplate("templates/apps/import-data.hbs", {
          hint1: game.i18n.format("DOCUMENT.ImportDataHint1", {document: this.documentName}),
          hint2: game.i18n.format("DOCUMENT.ImportDataHint2", {name: this.name})
        }),
        buttons: [{
          action: "import",
          label: "Import",
          icon: "fa-solid fa-file-import",
          callback: (event, button) => {
            const form = button.form;
            if ( !form.data.files.length ) {
              return ui.notifications.error("DOCUMENT.ImportDataError", {localize: true});
            }
            foundry.utils.readTextFromFile(form.data.files[0]).then(json => this.importFromJSON(json));
          },
          default: true
        }, {
          action: "no",
          label: "Cancel",
          icon: "fa-solid fa-xmark"
        }]
      });
    }

    /* -------------------------------------------- */

    /**
     * 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 (recursive). Default ownership is
     *                                                  preserved.
     * @param {boolean} [options.clearState=true]       Clear fields which store document state (recursive)
     * @param {boolean} [options.keepId=false]          Retain the current Document id
     * @returns {object}                      A data object of cleaned data suitable for compendium import
     */
    toCompendium(pack, {clearSort=true, clearFolder=false, clearFlags=false, clearSource=true, clearOwnership=true,
      clearState=true, keepId=false} = {}) {
      const data = this.toObject();
      const fieldsToClearRecursively = [];
      if ( !keepId ) delete data._id;
      if ( clearSort ) delete data.sort;
      if ( clearFolder ) delete data.folder;
      if ( clearFlags ) delete data.flags;
      if ( clearSource ) fieldsToClearRecursively.push("_stats.compendiumSource", "_stats.duplicateSource", "_stats.exportSource");
      if ( clearState ) delete data.active;
      this.constructor._clearFieldsRecursively(data, fieldsToClearRecursively);
      if ( clearOwnership ) this.constructor._clearFieldsRecursively(data, ["ownership"], {
        callback: data => {
          if ( !("ownership" in data) ) return;
          const defaultOwnership = foundry.utils.getProperty(data, "ownership.default");
          foundry.utils.deleteProperty(data, "ownership");
          if ( typeof defaultOwnership === "number" ) data.ownership = { default: defaultOwnership };
        }
      });
      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.tooltipText ??= typeName
          ? game.i18n.format("DOCUMENT.TypePageFormat", {type: typeName, page: documentName})
          : documentName;
        anchorIcon = icon ?? documentConfig.typeIcons?.[this.type] ?? documentConfig.sidebarIcon;
      }

      name ??= this.name;
      return TextEditor$1.implementation.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<HTMLDocumentEmbedElement|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;

      // Structure the embed as inline or figure unless it is already explicitly a <document-embed> element
      const embedCls = foundry.applications.elements.HTMLDocumentEmbedElement;
      let embed;
      if ( content instanceof embedCls ) embed = content;
      else {
        if ( config.inline ) embed = await this._createInlineEmbed(content, config, options);
        else embed = await this._createFigureEmbed(content, config, options);
      }

      // Attach required attributes
      if ( embed ) {
        embed.setAttribute("uuid", this.uuid);
        embed.dataset.uuid = this.uuid;
        embed.dataset.contentEmbed = "";
        if ( config.classes ) embed.classList.add(...config.classes.split(" "));
      }
      return embed;
    }

    /* -------------------------------------------- */

    /**
     * Specific callback actions to take when the embedded HTML for this Document has been added to the DOM.
     * @param {HTMLDocumentEmbedElement} element      The embedded document HTML
     */
    onEmbed(element) {}

    /* -------------------------------------------- */

    /**
     * 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 embed = new foundry.applications.elements.HTMLDocumentEmbedElement();
      if ( content instanceof HTMLCollection ) embed.append(...content);
      else embed.append(content);
      return embed;
    }

    /* -------------------------------------------- */

    /**
     * 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 ) {
          const el = document.createElement("strong");
          el.classList.add("embed-caption");
          el.append(label || this.name);
          figcaption.append(el);
        }
        if ( cite ) {
          const el = document.createElement("cite");
          el.append(this.toAnchor());
          figcaption.append(el);
        }
        figure.insertAdjacentElement(captionPosition === "bottom" ? "beforeend" : "afterbegin", figcaption);
        if ( (captionPosition === "top") && cite ) figure.append(figcaption.querySelector(":scope > cite"));
      }
      figure.classList.add("content-embed"); // For backwards compatibility
      return this._createInlineEmbed(figure);
    }
  }
  return ClientDocument;
}

/**
 * @import Document from "@common/abstract/document.mjs";
 * @import PlaceableObject from "@client/canvas/placeables/placeable-object.mjs";
 * @import PlaceablesLayer from "@client/canvas/layers/base/placeables-layer.mjs";
 */

/**
 * 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 Document} Base        The base document class mixed with client and canvas features
 */
function CanvasDocumentMixin(Base) {
  /**
   * A ClientDocument class with additional facilities for utilizing the {@link foundry.canvas.Canvas} API
   */
  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}
     * @internal
     */
    _object = this._object ?? null;

    /**
     * Has this object been deliberately destroyed as part of the deletion workflow?
     * @type {boolean}
     * @internal
     */
    _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);

      // Remove from clipboard
      this.layer.clipboard.objects = this.layer.clipboard.objects.filter(o => o.document !== this);
    }
  }
  return CanvasDocument;
}

/**
 * @import Document from "@common/abstract/document.mjs";
 * @import PlaceableObject from "@client/canvas/placeables/placeable-object.mjs";
 */

/**
 * Clean a provided HTML fragment, closing unbalanced tags and stripping some undesirable properties
 * @param {string} raw      A raw HTML string
 * @returns {string}        The cleaned HTML content
 */
function cleanHTML(raw) {
  const document = domParser.parseFromString(raw, "text/html");
  const cleanedBody = document.createElement("body");
  for ( const childNode of document.body.childNodes ) cleanedBody.appendChild(cleanNode(childNode));
  return cleanedBody.innerHTML;
}

/**
 * The retry string used by {@link getCacheBustURL}
 * @type {string}
 */
const CACHE_BUST_RETRY_STRING = Date.now().toString();

/**
 * The DOMParser instance used by {@link cleanHTML}.
 * @type {DOMParser}
 */
const domParser = new DOMParser();

/**
 * The list of allowed HTML tags.
 * Used by {@link cleanNode}.
 * @type {Set<string>}
 */
const allowedHtmlTags = new Set(ALLOWED_HTML_TAGS.map(tag => tag.toUpperCase()));

/**
 * The list of allowed attributes in HTML elements.
 * Used by {@link cleanNode}.
 * @type {Map<string, RegExp>}
 */
const allowedHtmlAttributes = new Map(Object.entries(ALLOWED_HTML_ATTRIBUTES).map(
  ([tag, attributes]) => [tag.toUpperCase(), new RegExp(`^${attributes.join("|").replaceAll("*", ".*")}$`)]));

/**
 * The list of allowed URL schemes.
 * Used by {@link cleanNode}.
 * @type {Set<string>}
 */
const allowedUrlSchemes = new Set(ALLOWED_URL_SCHEMES.map(scheme => `${scheme}:`));

/**
 * The list of attributes validated as URLs.
 * Used by {@link cleanNode}.
 * @type {Set<string>}
 */
const allowedUrlSchemesAppliedToAttributes = new Set(ALLOWED_URL_SCHEMES_APPLIED_TO_ATTRIBUTES);

/**
 * Clean the provided HTML node.
 * @param {Node} node   A node
 * @returns {Node}      The cleaned node
 */
function cleanNode(node) {
  if ( node.nodeType === Node.TEXT_NODE ) return node.cloneNode(true);
  if ( (node.nodeType !== Node.ELEMENT_NODE) || !allowedHtmlTags.has(node.tagName) ) {
    return node.ownerDocument.createDocumentFragment();
  }
  const cleanedNode = node.ownerDocument.createElement(node.tagName);

  // Sanitize attributes
  const allowedAttributesGlobal = allowedHtmlAttributes.get("*");
  const allowedAttributesLocal = allowedHtmlAttributes.get(node.tagName);
  if ( allowedAttributesGlobal || allowedAttributesLocal ) {
    for ( const attribute of node.attributes ) {
      if ( (allowedAttributesGlobal?.test(attribute.name) !== true)
        && (allowedAttributesLocal?.test(attribute.name) !== true) ) continue;
      if ( allowedUrlSchemesAppliedToAttributes.has(attribute.name) ) {
        const url = URL.parseSafe(attribute.value);
        if ( url && !allowedUrlSchemes.has(url.protocol) ) continue;
      }
      cleanedNode.setAttribute(attribute.name, attribute.value);
    }
  }

  // Sanitize child nodes
  for ( const childNode of node.childNodes ) cleanedNode.appendChild(cleanNode(childNode));

  // Automatically add a sandbox attribute to iframes from untrusted domains
  if ( cleanedNode.tagName === "IFRAME" ) {
    const url = URL.parseSafe(cleanedNode.getAttribute("src"));
    const host = url?.hostname;
    const isTrusted = TRUSTED_IFRAME_DOMAINS.some(domain => (host === domain) || host?.endsWith(`.${domain}`));
    if ( isTrusted ) cleanedNode.removeAttribute("sandbox");
    else cleanedNode.setAttribute("sandbox", "allow-scripts allow-forms");
  }

  // Parse data-tooltip contents as HTML and sanitize it as well
  if ( cleanedNode.hasAttribute("data-tooltip") ) {
    cleanedNode.setAttribute("data-tooltip", cleanHTML(cleanedNode.getAttribute("data-tooltip")));
  }
  return cleanedNode;
}

/* -------------------------------------------- */

/**
 * 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
  const 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
 * @returns {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;
  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;
  const {relative, invalid=false, strict=true} = options;
  let {type, id, primaryId, collection, embedded, doc} = foundry.utils.parseUuid(uuid, {relative}) ?? {};
  if ( !id || !collection ) return null;
  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 ) 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.
 */
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 Document|undefined}       The configured Document class implementation
 */
function getDocumentClass$1(documentName) {
  return CONFIG[documentName]?.documentClass;
}

/* -------------------------------------------- */

/**
 * Return a reference to the PlaceableObject class implementation which is configured for use.
 * @param {string} documentName                  The canonical Document name, for example "Actor"
 * @returns {typeof PlaceableObject|undefined}   The configured PlaceableObject class implementation
 */
function getPlaceableObjectClass(documentName) {
  return CONFIG[documentName]?.objectClass;
}

/* -------------------------------------------- */

/**
 * 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
 */
function performIntegerSort(source, {target=null, siblings=[], sortKey="sort", sortBefore}={}) {

  // Automatically determine the sorting direction
  sortBefore ??= ((source[sortKey] || 0) > (target?.[sortKey] || 0));

  // Ensure the siblings are sorted
  siblings = Array.from(siblings).sort((a, b) => a[sortKey] - b[sortKey]);

  // Determine the index target for the sort
  const defaultIdx = sortBefore ? siblings.length : 0;
  const idx = target ? siblings.findIndex(sib => sib === target) : defaultIdx;

  // Determine the indices to sort between
  const [min, max] = sortBefore ? _sortBefore(siblings, idx, sortKey) : _sortAfter(siblings, idx, sortKey);

  // Easiest case - no siblings
  if ( siblings.length === 0 ) {
    return [{target: source, update: {[sortKey]: SORT_INTEGER_DENSITY}}];
  }

  // No minimum - sort to beginning
  else if ( Number.isFinite(max) && (min === null) ) {
    return [{target: source, update: {[sortKey]: max - SORT_INTEGER_DENSITY}}];
  }

  // No maximum - sort to end
  else if ( Number.isFinite(min) && (max === null) ) {
    return [{target: source, update: {[sortKey]: min + 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) => ({target: sib, update: {[sortKey]: (i+1) * SORT_INTEGER_DENSITY}}));
  }
}

/* -------------------------------------------- */

/**
 * Given an ordered Array of siblings and a target position, return the [min,max] indices to sort before the target.
 */
function _sortBefore(siblings, idx, sortKey) {
  const max = siblings[idx] ? siblings[idx][sortKey] : null;
  const 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.
 */
function _sortAfter(siblings, idx, sortKey) {
  const min = siblings[idx] ? siblings[idx][sortKey] : null;
  const max = siblings[idx+1] ? siblings[idx+1][sortKey] : null;
  return [min, max];
}

/* -------------------------------------------- */

/**
 * Express a timestamp as a relative string.
 * This helper internally uses GameTime#format using the relative formatter and the Earth calendar.
 * @param {Date|string} timeStamp   A timestamp string or Date object to be formatted as a relative time
 * @returns {string}                A string expression for the relative time
 */
function timeSince(timeStamp) {
  timeStamp = new Date(timeStamp);
  const now = new Date();
  const seconds = (now - timeStamp) / 1000;
  const components = game.time.earthCalendar.timeToComponents(seconds);
  return game.time.earthCalendar.format(components, "ago", {
    short: true,
    separator: " ",
    maxTerms: 2
  });
}

/* -------------------------------------------- */

/**
 * Parse an HTML string, returning a processed HTMLElement or HTMLCollection.
 * A single HTMLElement is returned if the provided string contains only a single top-level element.
 * An HTMLCollection is returned if the provided string contains multiple top-level elements.
 * @param {string} htmlString
 * @returns {HTMLCollection|HTMLElement}
 */
function parseHTML$1(htmlString) {
  const div = document.createElement("div");
  div.innerHTML = htmlString;
  const children = div.children;
  return children.length > 1 ? children : children[0];
}

/* -------------------------------------------- */

/**
 * 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.
 */
function getCacheBustURL(src) {
  const url = URL.parseSafe(src);
  if ( !url ) return false;
  if ( url.origin === window.location.origin ) return false;
  url.searchParams.append("cors-retry", CACHE_BUST_RETRY_STRING);
  return url.href;
}

/* -------------------------------------------- */

/**
 * 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
 */
async function fetchResource(src, {bustCache=false}={}) {
  const fail = `Failed to load texture ${src}`;
  const req = bustCache ? 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 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();
}

/**
 * @import Collection from "@common/utils/collection.mjs";
 * @import Document from "@common/abstract/document.mjs";
 * @import DocumentDirectory from "../../applications/sidebar/document-directory.mjs";
 * @import CompendiumCollection from "../collections/compendium-collection.mjs";
 * @import {FromCompendiumOptions} from "../../_types.mjs";
 * @import Folder from "../folder.mjs";
 */

/**
 * 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.
 * @template {Document} TDocument
 * @extends DocumentCollection<TDocument>
 * @abstract
 * @category Collections
 *
 * @see {@link foundry.Game#collections}
 */
class WorldCollection extends DirectoryCollectionMixin(DocumentCollection) {

  /**
   * Reference the set of Folders which contain documents in this collection
   * @type {Collection<string, Folder>}
   */
  get folders() {
    if ( !game.folders ) return new foundry.utils.Collection();
    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$1(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 foundry.documents.abstract.WorldCollection#fromCompendium} and
   *                                {@link foundry.abstract.Document.create} methods
   * @returns {Promise<TDocument>}  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(`${CONST.vtt} | Importing ${cls.documentName} ${document.name} from ${pack.collection}`);
    this.directory.activate();
    options.fromCompendium = true;
    return this.documentClass.create(createData, options);
  }

  /* -------------------------------------------- */

  /**
   * Apply data transformations when importing a Document from a Compendium pack
   * @param {TDocument|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, clearState=true, 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 ) {
      const hadOwnership = "ownership" in data;
      document.constructor._clearFieldsRecursively(data, ["ownership"]);
      if ( hadOwnership ) {
        data.ownership = {
          default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE,
          [game.user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER
        };
      }
    }
    if ( clearState ) delete data.active;
    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 foundry.applications.apps.DocumentSheetConfig.registerSheet} for details.
   * @param {Array<*>} args      Arguments forwarded to the DocumentSheetConfig.registerSheet method
   *
   * @example Register a new ActorSheet subclass for use with certain Actor types.
   * ```js
   * foundry.documents.collections.Actors.registerSheet("dnd5e", ActorSheet5eCharacter, {
   *   types: ["character],
   *   makeDefault: true
   * });
   * ```
   */
  static registerSheet(...args) {
    foundry.applications.apps.DocumentSheetConfig.registerSheet(getDocumentClass$1(this.documentName), ...args);
  }

  /* -------------------------------------------- */

  /**
   * Unregister a Document sheet class, removing it from the list of available sheet Applications to use.
   * See {@link foundry.applications.apps.DocumentSheetConfig.unregisterSheet} for detauls.
   * @param {Array<*>} args      Arguments forwarded to the DocumentSheetConfig.unregisterSheet method
   *
   * @example Deregister the default ActorSheet subclass to replace it with others.
   * ```js
   * foundry.documents.collections.Actors.unregisterSheet("core", ActorSheet);
   * ```
   */
  static unregisterSheet(...args) {
    foundry.applications.apps.DocumentSheetConfig.unregisterSheet(getDocumentClass$1(this.documentName), ...args);
  }

  /* -------------------------------------------- */

  /**
   * Return an array of currently registered sheet classes for this Document type.
   * @type {DocumentSheet[]}
   */
  static get registeredSheets() {
    const sheets = new Set();
    for ( const t of Object.values(CONFIG[this.documentName].sheetClasses) ) {
      for ( const s of Object.values(t) ) sheets.add(s.cls);
    }
    return Array.from(sheets);
  }
}

var _module$_ = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CanvasDocumentMixin: CanvasDocumentMixin,
  ClientDocumentMixin: ClientDocumentMixin,
  DirectoryCollectionMixin: DirectoryCollectionMixin,
  DocumentCollection: DocumentCollection,
  WorldCollection: WorldCollection
});

/** @import Actor from "../actor.mjs"; */

/**
 * 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<Actor>}
 * @category Collections
 *
 * @see {@link foundry.documents.Actor}: The Actor document
 * @see {@link foundry.applications.sidebar.tabs.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";

  /* -------------------------------------------- */

  /** @inheritDoc */
  fromCompendium(document, options) {
    const data = super.fromCompendium(document, options);
    // 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 ( const 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;
  }
}

/** @import Cards from "../cards.mjs"; */

/**
 * The collection of Cards documents which exist within the active World.
 * This Collection is accessible within the Game object as game.cards.
 * @extends {WorldCollection<Cards>}
 * @category Collections
 *
 * @see {@link foundry.documents.Cards}: The Cards document
 * @see {@link foundry.applications.sidebar.tabs.CardsDirectory}: The CardsDirectory sidebar directory
 */
class CardStacks extends WorldCollection {

  /** @override */
  static documentName = "Cards";
}

/** @import ChatMessage from "../chat-message.mjs"; */

/**
 * 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<ChatMessage>}
 * @category Collections
 *
 * @see {@link foundry.documents.ChatMessage}: The ChatMessage document
 * @see {@link foundry.applications.sidebar.tabs.ChatLog}: The ChatLog sidebar directory
 */
class ChatMessages extends WorldCollection {

  /** @override */
  static documentName = "ChatMessage";

  /* -------------------------------------------- */

  /** @override */
  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
   */
  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
   */
  export() {
    const log = this.contents.map(m => m.export()).join("\n---------------------------\n");
    const date = new Date().toDateString().replace(/\s/g, "-");
    const filename = `fvtt-log-${date}.txt`;
    foundry.utils.saveDataToFile(log, "text/plain", filename);
  }

  /* -------------------------------------------- */

  /**
   * Allow for bulk deletion of all chat messages, confirm first with a yes/no dialog.
   */
  async flush() {
    const question = game.i18n.localize("AreYouSure");
    const warning = game.i18n.localize("CHAT.FlushWarning");
    return foundry.applications.api.DialogV2.confirm({
      window: {title: "CHAT.FlushTitle"},
      content: `<p><strong>${question}</strong> ${warning}</p>`,
      position: {
        top: window.innerHeight - 150,
        left: window.innerWidth - 720
      },
      yes: {
        callback: async () => {
          await this.documentClass.deleteDocuments([], {deleteAll: true});
          const jumpToBottomElement = document.querySelector(".jump-to-bottom");
          jumpToBottomElement.hidden = true;
        }
      }
    });
  }
}

/**
 * @import Combat from "../combat.mjs";
 */

/**
 * 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<Combat>}
 * @category Collections
 *
 * @see {@link foundry.documents.Combat}: The Combat document
 * @see {@link foundry.applications.sidebar.tabs.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", foundry.documents.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 && (!c.scene || (c.scene === game.scenes.current)));
  }

  /* -------------------------------------------- */

  /**
   * The currently viewed Combat encounter
   * @type {Combat|null}
   */
  get viewed() {
    return ui.combat?.viewed ?? null;
  }
}

/** @import FogExploration from "../fog-exploration.mjs" */

/**
 * The singleton collection of FogExploration documents which exist within the active World.
 * @extends {WorldCollection<FogExploration>}
 * @category Collections
 *
 * @see {@link foundry.documents.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();
      }
    });
  }
}

/**
 * A custom HTML element used to wrap secret blocks in HTML content in order to provide additional interactivity.
 */
class HTMLSecretBlockElement extends HTMLElement {
  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "secret-block";

  /* -------------------------------------------- */

  /**
   * A reference to the reveal button, if it exists.
   * @type {HTMLButtonElement}
   */
  #button;

  /* -------------------------------------------- */

  /**
   * The wrapped secret block.
   * @type {HTMLElement}
   */
  get secret() {
    return this.querySelector(":scope > .secret");
  }

  /* -------------------------------------------- */

  /**
   * The revealed state of the secret block.
   * @type {boolean}
   */
  get revealed() {
    return this.secret.classList.contains("revealed");
  }

  /* -------------------------------------------- */

  /** @override */
  connectedCallback() {
    if ( !this.#button ) this.#addRevealButton();
  }

  /* -------------------------------------------- */

  /**
   * Toggle the secret revealed or hidden state in content that this secret block represents.
   * @param {string} content  The raw string content for this secret.
   * @returns {string}        The modified raw content.
   */
  toggleRevealed(content) {
    const id = this.secret.id;
    const regex = new RegExp(`<section[^i]+id="${id}"[^>]*>`);
    return content.replace(regex, () => `<section class="secret${this.revealed ? "" : " revealed"}" id="${id}">`);
  }

  /* -------------------------------------------- */

  /**
   * Add a button that can be used to reveal or hide the secret block.
   */
  #addRevealButton() {
    const button = this.#button = document.createElement("button");
    Object.assign(button, { type: "button", className: "reveal" });
    button.append(game.i18n.localize(`EDITOR.${this.revealed ? "Hide" : "Reveal"}`));
    button.addEventListener("click", this.#onReveal.bind(this));
    this.secret.insertAdjacentElement("afterbegin", button);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the hidden/revealed state of the secret block.
   */
  #onReveal() {
    this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
  }
}

/**
 * @import Document from "@common/abstract/document.mjs";
 * @import {DatabaseCreateOperation, DatabaseUpdateOperation} from "@common/abstract/_types.mjs";
 * @import {ApplicationClickAction, ApplicationConfiguration, ApplicationRenderOptions} from "../_types.mjs";
 * @import FormDataExtended from "../ux/form-data-extended.mjs";
 */

/**
 * @typedef DocumentSheetConfiguration
 * @property {Document} document          The Document instance associated with this sheet
 * @property {number} viewPermission      A permission level in CONST.DOCUMENT_OWNERSHIP_LEVELS
 * @property {number} editPermission      A permission level in CONST.DOCUMENT_OWNERSHIP_LEVELS
 * @property {boolean} canCreate          Can this sheet class be used to create a new Document?
 * @property {boolean} sheetConfig        Allow sheet configuration as a header button
 */

/**
 * @typedef DocumentSheetRenderOptions
 * @property {string} renderContext       A string with the format "{operation}{documentName}" providing context
 * @property {object} renderData          Data describing the document modification that occurred
 */

/**
 * The Application class is responsible for rendering an HTMLElement into the Foundry Virtual Tabletop user interface.
 * @extends {ApplicationV2<
 *  ApplicationConfiguration & DocumentSheetConfiguration,
 *  ApplicationRenderOptions & DocumentSheetRenderOptions
 * >}
 */
class DocumentSheetV2 extends ApplicationV2 {
  /** @inheritDoc */
  constructor(options, ...args) {
    options = new.target._migrateConstructorParams(options, args);
    super(options);
    this.#document = options.document;
  }

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "{id}",
    classes: ["sheet"],
    tag: "form",  // Document sheets are forms by default
    document: null,
    viewPermission: DOCUMENT_OWNERSHIP_LEVELS.LIMITED,
    editPermission: DOCUMENT_OWNERSHIP_LEVELS.OWNER,
    canCreate: false,
    sheetConfig: true,
    actions: {
      configureSheet: DocumentSheetV2.#onConfigureSheet,
      copyUuid: {handler: DocumentSheetV2.#onCopyUuid, buttons: [0, 2]},
      editImage: DocumentSheetV2.#onEditImage,
      importDocument: DocumentSheetV2.#onImportDocument
    },
    form: {
      handler: this.#onSubmitDocumentForm,
      submitOnChange: false,
      closeOnSubmit: false
    },
    window: {
      controls: [{
        icon: "fa-solid fa-gear",
        label: "SHEETS.ConfigureSheet",
        action: "configureSheet",
        visible: DocumentSheetV2.#canConfigureSheet
      }]
    }
  };

  /* -------------------------------------------- */

  /**
   * The Document instance associated with the application
   * @type {ClientDocument}
   */
  get document() {
    return this.#document;
  }

  #document;

  /* -------------------------------------------- */

  /** @override */
  get title() {
    const {constructor: cls, id, name, type} = this.document;
    const prefix = cls.hasTypeData && type !== "base" ? CONFIG[cls.documentName].typeLabels[type] : cls.metadata.label;
    return `${game.i18n.localize(prefix)}: ${name || id}`;
  }

  /* -------------------------------------------- */

  /**
   * Is this Document sheet visible to the current User?
   * This is governed by the viewPermission threshold configured for the class.
   * @type {boolean}
   */
  get isVisible() {
    return this.document.testUserPermission(game.user, this.options.viewPermission);
  }

  /* -------------------------------------------- */

  /**
   * Is this Document sheet editable by the current User?
   * This is governed by the editPermission threshold configured for the class.
   * @type {boolean}
   */
  get isEditable() {
    if ( this.document.pack ) {
      const pack = game.packs.get(this.document.pack);
      if ( pack.locked ) return false;
    }
    return this.document.testUserPermission(game.user, this.options.editPermission);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options = super._initializeApplicationOptions(options);
    const suffix = options.document.uuid ?? foundry.utils.randomID();
    options.uniqueId = `${this.constructor.name}-${suffix.replaceAll(".", "-")}`;
    const theme = foundry.applications.apps.DocumentSheetConfig.getSheetThemeForDocument(options.document);
    if ( theme && !options.classes.includes("themed") ) options.classes.push("themed", `theme-${theme}`);
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  *_headerControlButtons() {
    for ( const control of super._headerControlButtons() ) {
      if ( ("ownership" in control) && !this.document.testUserPermission(game.user, control.ownership) ) continue;
      yield control;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);

    // If the Document's name was changed, update the window title.
    if ( this.hasFrame && options.renderContext && options.renderData?.name ) {
      options.window = Object.assign(options.window ?? {}, {title: this.title});
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const document = this.document;
    return Object.assign(context, {
      document,
      source: document._source,
      fields: document.schema.fields,
      editable: this.isEditable,
      user: game.user,
      rootId: document.collection?.has(document.id) ? this.id : foundry.utils.randomID()
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderFrame(options) {
    const frame = await super._renderFrame(options);
    if ( !this.hasFrame ) return frame;

    // Add form options
    if ( this.options.tag === "form" ) frame.autocomplete = "off";

    // Add document ID copy
    if ( this.document.id ) {
      const copyLabel = game.i18n.localize("SHEETS.CopyUuid");
      const copyId = `
        <button type="button" class="header-control fa-solid fa-passport icon" data-action="copyUuid"
                data-tooltip="${copyLabel}" aria-label="${copyLabel}"></button>
      `;
      this.window.close.insertAdjacentHTML("beforebegin", copyId);
    }

    // Add compendium import
    const { documentName, isEmbedded, inCompendium } = this.document;
    if ( (documentName !== "Folder") && !isEmbedded && inCompendium ) {
      this.window.close.insertAdjacentHTML("beforebegin", `
        <button type="button" class="header-control fa-solid fa-download icon" data-action="importDocument"
                data-tooltip="Import" aria-label="${game.i18n.localize("Import")}"></button>
      `);
    }

    return frame;
  }

  /* -------------------------------------------- */

  /**
   * Disable or reenable all form fields in this application.
   * @param {boolean} disabled Should the fields be disabled?
   * @protected
   */
  _toggleDisabled(disabled) {
    const form = this.form;
    if ( !this.form ) return;
    const framed = this.options.window.frame;
    for ( const element of form.elements ) {
      if ( !framed || element.closest(".window-content") ) element.disabled = disabled;
    }
    const contentEl = framed ? form.querySelector(".window-content") : form;
    for ( const input of contentEl.querySelectorAll("input[type=image]") ) {
      input.disabled = disabled; // By specification, these are not included in a HTMLFormControlsCollection
    }
    for ( const img of contentEl.querySelectorAll("img[data-edit]") ) {
      img.classList.toggle("disabled", disabled);
    }
  }

  /* -------------------------------------------- */
  /*  Application Life-Cycle Events               */
  /* -------------------------------------------- */

  /** @override */
  _canRender(_options) {
    if ( !this.isVisible ) throw new Error(game.i18n.format("SHEETS.DocumentSheetPrivate", {
      type: game.i18n.localize(this.document.constructor.metadata.label)
    }));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    this.document.apps[this.id] = this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    if ( !this.isEditable ) this._toggleDisabled(true);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    delete this.document.apps[this.id];
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Whether it's possible to configure this Document's sheet.
   * @this {DocumentSheetV2}
   * @returns {boolean}
   */
  static #canConfigureSheet() {
    if ( !this.options.sheetConfig || !this.isEditable ) return false;
    const document = this.#document;
    return !!document.collection?.has(document.id) && !document.flags.core?.sheetLock;
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to configure the sheet used for this document.
   * @param {PointerEvent} event
   * @this {DocumentSheetV2}
   */
  static #onConfigureSheet(event) {
    event.stopPropagation(); // Don't trigger other events
    if ( event.detail > 1 ) return; // Ignore repeated clicks

    const docSheetConfigWidth = foundry.applications.apps.DocumentSheetConfig.DEFAULT_OPTIONS.position.width;
    new foundry.applications.apps.DocumentSheetConfig({
      document: this.document,
      position: {
        top: this.position.top + 40,
        left: this.position.left + ((this.position.width - docSheetConfigWidth) / 2)
      }
    }).render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to copy the UUID of this document to clipboard.
   * @param {PointerEvent} event
   * @this {DocumentSheetV2}
   */
  static #onCopyUuid(event) {
    event.preventDefault(); // Don't open context menu
    event.stopPropagation(); // Don't trigger other events
    if ( event.detail > 1 ) return; // Ignore repeated clicks
    const id = event.button === 2 ? this.document.id : this.document.uuid;
    const type = event.button === 2 ? "id" : "uuid";
    const label = game.i18n.localize(this.document.constructor.metadata.label);
    game.clipboard.copyPlainText(id);
    ui.notifications.info("DOCUMENT.IdCopiedClipboard", {format: {label, type, id}});
  }

  /**
   * Edit a Document image.
   * @this {DocumentSheetV2}
   * @type {ApplicationClickAction}
   */
  static async #onEditImage(_event, target) {
    if ( target.nodeName !== "IMG" ) {
      throw new Error("The editImage action is available only for IMG elements.");
    }
    const attr = target.dataset.edit;
    const current = foundry.utils.getProperty(this.document._source, attr);
    const defaultArtwork = this.document.constructor.getDefaultArtwork?.(this.document._source) ?? {};
    const defaultImage = foundry.utils.getProperty(defaultArtwork, attr);
    const fp = new FilePicker.implementation({
      current,
      type: "image",
      redirectToRoot: defaultImage ? [defaultImage] : [],
      callback: path => {
        target.src = path;
        if ( this.options.form.submitOnChange ) {
          const submit = new Event("submit", {cancelable: true});
          this.form.dispatchEvent(submit);
        }
      },
      position: {
        top: this.position.top + 40,
        left: this.position.left + 10
      }
    });
    await fp.browse();
  }

  /* -------------------------------------------- */

  /**
   * Handle importing a document from a compendium pack.
   * @this {DocumentSheetV2}
   */
  static async #onImportDocument() {
    await this.close();
    const { documentName, collection, id } = this.document;
    return game.collections.get(documentName).importFromCompendium(collection, id);
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    if ( event.target instanceof HTMLSecretBlockElement ) return this._onRevealSecret(event);
    super._onChangeForm(formConfig, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the revealed state of a secret embedded in some content.
   * @param {Event} event  The triggering event.
   * @protected
   */
  _onRevealSecret(event) {
    const editor = event.target.closest("prose-mirror");
    if ( !editor?.name ) return;
    const content = foundry.utils.getProperty(this.document, editor.name);
    const modified = event.target.toggleRevealed(content);
    this.document.update({ [editor.name]: modified });
  }

  /* -------------------------------------------- */

  /**
   * Process form submission for the sheet.
   * @this {DocumentSheetV2}                      The handler is called with the application as its bound scope
   * @param {SubmitEvent} event                   The originating form submission event
   * @param {HTMLFormElement} form                The form element that was submitted
   * @param {FormDataExtended} formData           Processed data for the submitted form
   * @param {object} [options]                    Additional options provided by a manual submit call. All options
   *                                              except `options.updateData` are forwarded along to _processSubmitData.
   * @param {object} [options.updateData]         Additional data passed in if this form is submitted manually which
   *                                              should be merged with prepared formData.
   * @returns {Promise<void>}
   */
  static async #onSubmitDocumentForm(event, form, formData, options={}) {
    if ( !this.isEditable ) return;
    const {updateData, ...updateOptions} = options;
    const submitData = this._prepareSubmitData(event, form, formData, updateData);
    await this._processSubmitData(event, form, submitData, updateOptions);
  }

  /* -------------------------------------------- */

  /**
   * Prepare data used to update the Document upon form submission.
   * This data is cleaned and validated before being returned for further processing.
   * @param {SubmitEvent} event                   The originating form submission event
   * @param {HTMLFormElement} form                The form element that was submitted
   * @param {FormDataExtended} formData           Processed data for the submitted form
   * @param {object} [updateData]                 Additional data passed in if this form is submitted manually which
   *                                              should be merged with prepared formData.
   * @returns {object}                            Prepared submission data as an object
   * @throws {Error}                              Subclasses may throw validation errors here to prevent form submission
   * @protected
   */
  _prepareSubmitData(event, form, formData, updateData) {
    const submitData = this._processFormData(event, form, formData);
    if ( updateData ) {
      foundry.utils.mergeObject(submitData, updateData, {performDeletions: true});
      foundry.utils.mergeObject(submitData, updateData, {performDeletions: false});
    }
    this.document.validate({changes: submitData, clean: true, fallback: false});
    return submitData;
  }

  /* -------------------------------------------- */

  /**
   * Customize how form data is extracted into an expanded object.
   * @param {SubmitEvent|null} event              The originating form submission event
   * @param {HTMLFormElement} form                The form element that was submitted
   * @param {FormDataExtended} formData           Processed data for the submitted form
   * @returns {object}                            An expanded object of processed form data
   * @throws {Error}                              Subclasses may throw validation errors here to prevent form submission
   * @protected
   */
  _processFormData(event, form, formData) {
    return foundry.utils.expandObject(formData.object);
  }

  /* -------------------------------------------- */

  /**
   * Submit a document update or creation request based on the processed form data.
   * @param {SubmitEvent} event                   The originating form submission event
   * @param {HTMLFormElement} form                The form element that was submitted
   * @param {object} submitData                   Processed and validated form data to be used for a document update
   * @param {Partial<DatabaseCreateOperation|DatabaseUpdateOperation>} [options] Additional options altering the request
   * @returns {Promise<void>}
   * @protected
   */
  async _processSubmitData(event, form, submitData, options={}) {
    const document = this.#document;
    if ( document.collection?.has(document.id) ) {
      await document.update(submitData, options);
    }
    else if ( this.options.canCreate ) {
      const {parent, pack} = document;
      const operation = Object.assign(options, {parent, pack, keepId: true});
      const created = await document.constructor.create(submitData, operation);
      if ( created ) {
        if ( !document.id && document.rendered ) document.object.destroy({children: true});
        this.#document = created;
      }
      else throw new Error("Failed to create document.");
    }
    else {
      throw new Error(`Document creation from ${this.constructor.name} is not supported.`);
    }
  }

  /* -------------------------------------------- */
  /*  Deprecation and Compatibility               */
  /* -------------------------------------------- */

  /**
   * Provide a deprecation path for converted V1 document sheets.
   * @param {unknown} first The first parameter received by this class's constructor
   * @param {unknown[]} rest Any additional parameters received
   * @returns {Partial<ApplicationConfiguration & DocumentSheetConfiguration>}
   * @internal
   */
  static _migrateConstructorParams(first, rest) {
    if ( (first instanceof Object) && (first.document instanceof foundry.abstract.Document) ) {
      return first;
    }

    // Probably using V1 constructor args, but make sure the first is in fact a Document.
    if ( !(first instanceof foundry.abstract.Document) ) {
      throw new Error("A DocumentSheetV2 application must be provided a Document instance.");
    }

    // Warn, create a new partial configuration object, and recover at least some of the other options.
    const message = [
      `DocumentSheet V1 arguments passed to a ${this.name} constructor`,
      "the first argument must be an options object with a document property."
    ].join(": ");
    foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15});
    const options = {document: first};
    const legacyOptions = rest[1] instanceof Object ? rest[1] : {};
    if ( typeof legacyOptions.title === "string" ) options.window = {title: legacyOptions.title};
    const positionKeys = ["top", "left", "width", "height", "scale", "zIndex"];
    options.position = positionKeys.reduce((position, key) => {
      if ( legacyOptions[key] !== undefined ) position[key] = legacyOptions[key];
      return position;
    }, {});
    return options;
  }
}

/**
 * @import {ApplicationConfiguration, ApplicationRenderContext, ApplicationRenderOptions} from "../_types.mjs";
 * @import {DocumentSheetConfiguration, DocumentSheetRenderOptions} from "../api/document-sheet.mjs";
 * @import Application from "@client/appv1/api/application-v1.mjs";
 * @import ApplicationV2 from "../api/application.mjs";
 */

/**
 * @typedef DefaultSheetDescriptor
 * @property {string} sheet  The identifier of the default sheet.
 * @property {string} theme  The default theme.
 */

/**
 * @typedef SheetRegistrationDescriptor
 * @property {typeof ClientDocument} documentClass  The Document class to register a new sheet option for.
 * @property {string} id                            The identifier of the sheet being registered.
 * @property {typeof Application|typeof ApplicationV2} sheetClass An Application class used to render the sheet.
 * @property {string|(()=>string)} [label]          A human-readable label for the sheet name, or a function that
 *                                                  returns one. Will be localized.
 * @property {string[]} [types]                     An array of Document sub-types to register the sheet for.
 * @property {Record<string, string>|null} [themes] An object of theme keys to labels that the sheet supports. If this
 *                                                  option is not supplied, the sheet is assumed to support both light
 *                                                  and dark themes. If null is supplied, it indicates that the sheet
 *                                                  does not support theming.
 * @property {boolean} [makeDefault=false]          Whether to make this sheet the default for the provided sub-types.
 * @property {boolean} [canBeDefault=true]          Whether this sheet is available to be selected as a default sheet
 *                                                  for all Documents of that type.
 * @property {boolean} [canConfigure=true]          Whether this sheet appears in the sheet configuration UI for users.
 */

/**
 * @typedef {Omit<SheetRegistrationDescriptor, "documentClass"|"id"|"sheetClass">} SheetRegistrationOptions
 */

/**
 * @typedef DocumentSheetConfigRenderContext
 * @property {DocumentSheetConfigFieldDescriptor} sheet  Context for the sheet field.
 * @property {DocumentSheetConfigFieldDescriptor} theme  Context for the theme field.
 */

/**
 * @typedef DocumentSheetConfigFieldDescriptor
 * @property {DataField} field     The field instance.
 * @property {string} name         The field's form name.
 * @property {string} value        The field's value.
 * @property {boolean} [disabled]  Whether the field should be disabled in the form.
 */

/**
 * An Application for configuring Document sheet settings.
 * @extends {ApplicationV2<
 *  ApplicationConfiguration & DocumentSheetConfiguration,
 *  ApplicationRenderOptions & DocumentSheetRenderOptions
 * >}
 * @mixes HandlebarsApplication
 */
class DocumentSheetConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "sheet-config-{id}",
    classes: ["sheet-config"],
    sheetConfig: false,
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-gear"
    },
    position: {width: 500},
    form: {
      handler: DocumentSheetConfig.#onSubmitForm,
      closeOnSubmit: true
    }
  };

  /** @override */
  static PARTS = {
    form: {
      classes: ["standard-form"],
      template: "templates/sheets/document-sheet-config.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The cached render context for the sheet defaults.
   * @type {DocumentSheetConfigRenderContext}
   */
  #defaults;

  /**
   * The cached render context for the Document.
   * @type {DocumentSheetConfigRenderContext}
   */
  #document;

  /** @override */
  get title() {
    const { constructor: cls, documentName, type } = this.document;
    const prefix = cls.hasTypeData && (type !== "base")
      ? CONFIG[documentName].typeLabels[type]
      : cls.metadata.label;
    return game.i18n.format("SHEETS.ConfigureTitle", { prefix: game.i18n.localize(prefix) });
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _preparePartContext(partId, context, options) {
    context.partId = partId;
    switch ( partId ) {
      case "footer": await this._prepareFooterContext(context, options); break;
      case "form": await this._prepareFormContext(context, options); break;
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the footer part.
   * @param {ApplicationRenderContext} context
   * @param {ApplicationRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareFooterContext(context, options) {
    context.buttons = [{
      type: "submit",
      icon: "fa-solid fa-floppy-disk",
      label: "SHEETS.Save"
    }];
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the form part.
   * @param {ApplicationRenderContext} context
   * @param {ApplicationRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareFormContext(context, options) {
    const document = this.document;
    const { documentName, type=CONST.BASE_DOCUMENT_TYPE } = document;
    const {
      sheetClasses, defaultClasses, defaultClass
    } = DocumentSheetConfig.getSheetClassesForSubType(documentName, type);
    const sheetClass = document.flags.core?.sheetClass ?? "";
    const config = CONFIG[documentName].sheetClasses[type] ?? {};
    const themes = game.settings.get("core", "sheetThemes");
    const currentClass = sheetClass || defaultClass;

    context.document = this.#document = {
      sheet: {
        field: new foundry.data.fields.StringField({
          label: "SHEETS.ThisSheet",
          hint: "SHEETS.DocumentSheetHint",
          choices: sheetClasses
        }),
        name: "sheetClass",
        value: sheetClass
      },
      theme: {
        field: new foundry.data.fields.StringField({
          label: "SHEETS.Theme",
          hint: "SHEETS.ThemeHint",
          choices: config[currentClass]?.themes ?? {}
        }),
        name: "theme",
        value: themes.documents?.[document.uuid] || "",
        disabled: isSubclass(config[currentClass]?.cls, foundry.appv1.api.Application)
      }
    };

    context.defaults = this.#defaults = {
      sheet: {
        field: new foundry.data.fields.StringField({
          label: "SHEETS.DefaultSheet",
          hint: "SHEETS.TypeSheetHint",
          choices: defaultClasses
        }),
        name: "defaultClass",
        disabled: !game.user.isGM,
        value: defaultClass
      },
      theme: {
        field: new foundry.data.fields.StringField({
          label: "SHEETS.Theme",
          hint: "SHEETS.ThemeHint",
          choices: config[defaultClass]?.themes ?? {}
        }),
        name: "defaultTheme",
        value: foundry.utils.getProperty(themes, `defaults.${documentName}.${type}`) || "",
        disabled: isSubclass(config[defaultClass]?.cls, foundry.appv1.api.Application)
      }
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    // Update theme options based on which sheet is selected.
    super._onChangeForm(formConfig, event);
    const { defaultTheme, theme } = new FormDataExtended(this.form).object;
    this.form.elements.defaultTheme.replaceChildren(...this.#defaults.theme.field.toInput({
      localize: true, blank: "Default"
    }).children);
    this.form.elements.defaultTheme.value = defaultTheme ?? "";
    this.form.elements.theme.replaceChildren(...this.#document.theme.field.toInput({
      localize: true, blank: "Default"
    }).children);
    this.form.elements.theme.value = theme ?? "";

    // Disable theme selection if the sheet class doesn't support it.
    if ( ["sheetClass", "defaultClass"].includes(event.target.name) ) {
      for ( const [sheetSelectName, themeSelectName] of [["sheetClass", "theme"], ["defaultTheme", "defaultTheme"]] ) {
        const sheetSelectEl = this.form.elements[sheetSelectName];
        const {documentName, type=CONST.BASE_DOCUMENT_TYPE} = this.document;
        const config = CONFIG[documentName].sheetClasses[type] ?? {};
        const sheetValue = sheetSelectEl.value || this.form.elements.defaultClass.value;
        const Cls = config[sheetValue]?.cls;
        this.form.elements[themeSelectName].disabled = isSubclass(Cls, foundry.appv1.api.Application);
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onClose(_options) {}

  /* -------------------------------------------- */

  /** @override */
  _onFirstRender(_context, _options) {}

  /* -------------------------------------------- */

  /**
   * Process form submission for the sheet.
   * @this {DocumentSheetConfig}
   * @param {SubmitEvent} event          The form submission event.
   * @param {HTMLFormElement} form       The form element that was submitted.
   * @param {FormDataExtended} formData  Processed data for the submitted form.
   * @returns {Promise<void>}
   */
  static async #onSubmitForm(event, form, formData) {
    const { object } = formData;
    const { documentName, type=CONST.BASE_DOCUMENT_TYPE } = this.document;

    // Update themes.
    const themes = game.settings.get("core", "sheetThemes");
    const defaultTheme = foundry.utils.getProperty(themes, `defaults.${documentName}.${type}`);
    const documentTheme = themes.documents?.[this.document.uuid];
    const themeChanged = (object.defaultTheme !== defaultTheme) || (object.theme !== documentTheme);
    if ( themeChanged ) {
      foundry.utils.setProperty(themes, `defaults.${documentName}.${type}`, object.defaultTheme);
      themes.documents ??= {};
      themes.documents[this.document.uuid] = object.theme;
      await game.settings.set("core", "sheetThemes", themes);
    }

    // Update sheets.
    const { defaultClass } = this.constructor.getSheetClassesForSubType(documentName, type);
    const sheetClass = this.document.getFlag("core", "sheetClass") ?? "";
    const defaultSheetChanged = object.defaultClass !== defaultClass;
    const documentSheetChanged = object.sheetClass !== sheetClass;

    if ( themeChanged || (game.user.isGM && defaultSheetChanged) ) {
      if ( game.user.isGM && defaultSheetChanged ) {
        const setting = game.settings.get("core", "sheetClasses");
        foundry.utils.setProperty(setting, `${documentName}.${type}`, object.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.document._onSheetChange({ sheetOpen: true });
    }

    // Update the document-specific override.
    if ( documentSheetChanged ) return this.document.setFlag("core", "sheetClass", object.sheetClass);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * 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, if applicable.
   * @returns {{sheetClasses: Record<string, string>, defaultClasses: Record<string, string>, defaultClass: string}}
   */
  static getSheetClassesForSubType(documentName, subType) {
    subType ||= CONST.BASE_DOCUMENT_TYPE;
    let defaultClass;
    const config = CONFIG[documentName];
    const {defaultClasses, sheetClasses} = Object.values(config.sheetClasses[subType]).reduce((obj, cfg) => {
      if ( cfg.canConfigure ) obj.sheetClasses[cfg.id] = cfg.label;
      if ( cfg.default && !defaultClass ) defaultClass = cfg.id;
      if ( cfg.canConfigure && cfg.canBeDefault ) obj.defaultClasses[cfg.id] = cfg.label;
      return obj;
    }, {defaultClasses: {}, sheetClasses: {}});
    defaultClass ??= Object.keys(defaultClasses)[0];
    return {sheetClasses, defaultClasses, defaultClass};
  }

  /* -------------------------------------------- */

  /**
   * Retrieve the user's theme preference for the given Document.
   * @param {ClientDocument} document  The Document.
   * @returns {string}                 The theme identifier, or a blank string if the user has no preference.
   */
  static getSheetThemeForDocument(document) {
    const { documentName, uuid, type=CONST.BASE_DOCUMENT_TYPE } = document;
    const { defaultClass } = this.getSheetClassesForSubType(documentName, type);
    const sheetClass = document.getFlag("core", "sheetClass");
    const config = CONFIG[documentName].sheetClasses[type];
    const themes = config?.[sheetClass || defaultClass]?.themes ?? {};
    const setting = game.settings.get("core", "sheetThemes");
    const defaultTheme = foundry.utils.getProperty(setting, `defaults.${documentName}.${type}`);
    const documentTheme = setting.documents?.[uuid];
    if ( documentTheme && (documentTheme in themes) ) return documentTheme;
    if ( defaultTheme && (defaultTheme in themes) ) return defaultTheme;
    return "";
  }

  /* -------------------------------------------- */
  /*  Sheet Configuration API                     */
  /* -------------------------------------------- */

  /**
   * An array of pending sheet assignments which are submitted before other elements of the framework are ready.
   * @type {Array<SheetRegistrationDescriptor & { action: "register"|"unregister" }>}
   */
  static #pending = [];

  /* -------------------------------------------- */

  /**
   * Get the available Document sub-types for the given Document class.
   * @param {typeof ClientDocument} cls  The Document class.
   * @param {string[]} types             A sub-set of Document sub-types to return instead.
   */
  static #getDocumentTypes(cls, types=[]) {
    return types.length ? types : game.documentTypes[cls.documentName];
  }

  /* -------------------------------------------- */

  /**
   * Initialize the configured sheet preferences for Documents which support dynamic sheet assignment.
   * @returns {Promise<void>}
   */
  static async initializeSheets() {
    for ( const documentName of CONST.ALL_DOCUMENT_TYPES ) {
      const cls = foundry.utils.getDocumentClass(documentName);
      const types = DocumentSheetConfig.#getDocumentTypes(cls);
      CONFIG[documentName].sheetClasses = types.reduce((obj, type) => {
        obj[type] = {};
        return obj;
      }, {});
    }

    // Register any pending sheets.
    for ( const pending of DocumentSheetConfig.#pending ) {
      if ( pending.action === "register" ) DocumentSheetConfig.#registerSheet(pending);
      else if ( pending.action === "unregister" ) DocumentSheetConfig.#unregisterSheet(pending);
    }
    DocumentSheetConfig.#pending = [];

    // Update default sheet preferences.
    const defaults = game.settings.get("core", "sheetClasses");
    this.updateDefaultSheets(defaults);
  }

  /* -------------------------------------------- */

  /**
   * Register a sheet class as a candidate to be used to display Documents of a given type.
   * @param {typeof ClientDocument} documentClass                 The Document class to register a new sheet for.
   * @param {string} scope                                        A unique namespace scope for this sheet.
   * @param {typeof Application|typeof ApplicationV2} sheetClass  An Application class used to render the sheet.
   * @param {SheetRegistrationOptions} options                    Sheet registration configuration options.
   */
  static registerSheet(documentClass, scope, sheetClass, options={}) {
    const { label, types, themes, makeDefault=false, canBeDefault=true, canConfigure=true } = options;
    const id = `${scope}.${sheetClass.name}`;
    const config = { documentClass, id, label, sheetClass, types, themes, makeDefault, canBeDefault, canConfigure };
    if ( game.ready ) DocumentSheetConfig.#registerSheet(config);
    else {
      config.action = "register";
      this.#pending.push(config);
    }
  }

  /* -------------------------------------------- */

  /**
   * Perform the sheet registration.
   * @param {SheetRegistrationDescriptor} config  Sheet registration configuration.
   */
  static #registerSheet(config={}) {
    let { documentClass, id, label, sheetClass, types, themes, makeDefault, canBeDefault, canConfigure } = config;
    types = DocumentSheetConfig.#getDocumentTypes(documentClass, types);
    if ( themes === undefined ) {
      themes = {
        dark: "SETTINGS.UI.FIELDS.colorScheme.choices.dark",
        light: "SETTINGS.UI.FIELDS.colorScheme.choices.light"
      };
    }
    if ( label instanceof Function ) label = label();
    else if ( label ) label = game.i18n.localize(label);
    else label = id;
    const classes = CONFIG[documentClass.documentName]?.sheetClasses;
    const defaultSheets = game.settings.get("core", "sheetClasses");
    if ( foundry.utils.getType(classes) !== "Object" ) return;
    for ( const t of types ) {
      classes[t] ??= {};
      const existingDefault = defaultSheets[documentClass.documentName]?.[t];
      const isDefault = existingDefault ? existingDefault === id : makeDefault;
      if ( isDefault ) Object.values(classes[t]).forEach(s => s.default = false);
      classes[t][id] = {
        id, label, themes, 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 to register a new sheet option for.
   * @param {string} scope                                        A unique namespace scope for this sheet.
   * @param {typeof Application|typeof ApplicationV2} sheetClass  An Application class used to render the sheet.
   * @param {object} [options]
   * @param {string[]} [options.types]                            The sub-types this sheet should be removed for,
   *                                                              otherwise all sub-types are unregistered.
   */
  static unregisterSheet(documentClass, scope, sheetClass, { types }={}) {
    const id = `${scope}.${sheetClass.name}`;
    const config = { documentClass, id, types };
    if ( game.ready ) DocumentSheetConfig.#unregisterSheet(config);
    else {
      config.action = "unregister";
      this.#pending.push(config);
    }
  }

  /* -------------------------------------------- */

  /**
   * Perform the sheet de-registration.
   * @param {Partial<SheetRegistrationDescriptor>} config  Sheet de-registration configuration.
   */
  static #unregisterSheet(config={}) {
    let { documentClass, id, types } = config;
    types = DocumentSheetConfig.#getDocumentTypes(documentClass, types);
    const classes = CONFIG[documentClass.documentName]?.sheetClasses;
    if ( foundry.utils.getType(classes) !== "Object" ) return;
    for ( const t of types ) delete classes[t][id];
  }

  /* -------------------------------------------- */

  /**
   * Update the current default sheets using a new core World setting.
   * @param {Record<string, string>} setting  The stored default sheet settings.
   */
  static updateDefaultSheets(setting={}) {
    if ( foundry.utils.isEmpty(setting) ) return;
    for ( const documentName of CONST.ALL_DOCUMENT_TYPES ) {
      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 ( const [type, sheetId] of Object.entries(defaults) ) {
        if ( !sheetId ) continue;
        const sheets = Object.values(classes[type] || {});
        const 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 ( const document of collection ) {
        for ( const app of Object.values(document.apps) ) app.close();
        document._sheet = null;
      }
    }
  }
}

/**
 * @typedef DragDropConfiguration
 * @property {string|null} [dragSelector=null]  The CSS selector used to target draggable elements.
 * @property {string|null} [dropSelector=null]  The CSS selector used to target viable drop targets.
 * @property {Record<"dragstart"|"drop", (selector: string) => boolean>} [permissions]
 *                                         Permission tests for each action
 * @property {Record<
 *  "dragstart"|"dragover"|"drop"|"dragenter"|"dragleave"|"dragend",
 *  (event: DragEvent) => void
 * >} [callbacks]                         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.
 *
 * @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 {
  /**
   * @param {DragDropConfiguration} [config]
   */
  constructor({dragSelector=null, dropSelector=null, permissions={}, callbacks={}}={}) {
    this.dragSelector = dragSelector;
    this.dropSelector = dropSelector;
    this.permissions = permissions;
    this.callbacks = callbacks;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A set of callback functions for each action of the drag & drop workflow.
   * @type {Record<"dragstart"|"dragover"|"drop"|"dragenter"|"dragleave"|"dragend", (event: DragEvent) => void>}
   */
  callbacks;

  /**
   * The HTML selector which identifies draggable elements.
   * @type {string|null}
   */
  dragSelector;

  /**
   * The HTML selector which identifies drop targets.
   * @type {string|null}
   */
  dropSelector;

  /**
   * A set of functions to control authorization to begin drag workflows, and drop content.
   * @type {Record<"dragstart"|"drop", (selector: string) => boolean>}
   */
  permissions;

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * 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
    const canDrag = !!this.dragSelector && this.can("dragstart", this.dragSelector);
    const draggables = this.dragSelector ? html.querySelectorAll(this.dragSelector) : [];
    for ( const element of draggables ) {
      element.setAttribute("draggable", canDrag);
      element.ondragstart = canDrag ? this._handleDragStart.bind(this) : null;
      element.ondragend = canDrag ? this._handleDragEnd.bind(this) : null;
    }

    // Identify and activate drop targets
    const canDrop = this.can("drop", this.dropSelector);
    const droppables = !this.dropSelector || html.matches(this.dropSelector) ? [html]
      : html.querySelectorAll(this.dropSelector);
    for ( const element of droppables ) {
      element.ondragover = canDrop ? this._handleDragOver.bind(this) : null;
      element.ondrop = canDrop ? this._handleDrop.bind(this) : null;
      element.ondragenter = canDrop ? this._handleDragEnter.bind(this) : null;
      element.ondragleave = canDrop ? this._handleDragLeave.bind(this) : null;
    }
    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
   * @returns {boolean}          Can the action be performed?
   */
  can(action, selector) {
    const fn = this.permissions[action];
    if ( fn instanceof Function ) return fn(selector);
    return true;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle the start of a drag workflow
   * @param {DragEvent} event   The drag event being handled
   * @protected
   */
  _handleDragStart(event) {
    this.callback(event, "dragstart");
    if ( event.dataTransfer.items.length ) event.stopPropagation();
  }

  /* -------------------------------------------- */

  /**
   * Handle a drag workflow ending for any reason.
   * @param {DragEvent} event  The drag event.
   * @protected
   */
  _handleDragEnd(event) {
    this.callback(event, "dragend");
  }

  /* -------------------------------------------- */

  /**
   * Handle entering a drop target while dragging.
   * @param {DragEvent} event  The drag event.
   * @protected
   */
  _handleDragEnter(event) {
    this.callback(event, "dragenter");
  }

  /* -------------------------------------------- */

  /**
   * Handle leaving a drop target while dragging.
   * @param {DragEvent} event  The drag event.
   * @protected
   */
  _handleDragLeave(event) {
    this.callback(event, "dragleave");
  }

  /* -------------------------------------------- */

  /**
   * Handle a dragged element over a droppable target
   * @param {DragEvent} event   The drag event being handled
   * @protected
   */
  _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
   * @protected
   */
  _handleDrop(event) {
    event.preventDefault();
    return this.callback(event, "drop");
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * A helper to create an image preview element for use during HTML element dragging.
   * @param {HTMLImageElement} img
   * @param {number} width
   * @param {number} height
   * @returns {HTMLDivElement}
   */
  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 i = document.createElement("img");
      i.classList.add("noborder");
      div.appendChild(i);
      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;
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Retrieve the configured DragDrop implementation.
   * @type {typeof DragDrop}
   */
  static get implementation() {
    let Class = CONFIG.ux.DragDrop;
    if ( !foundry.utils.isSubclass(Class, DragDrop) ) {
      console.warn("Configured DragDrop override must be a subclass of DragDrop.");
      Class = DragDrop;
    }
    return Class;
  }
}

/**
 * @import {DataField} from "../../../common/data/fields.mjs";
 * @import {FormGroupConfig, FormInputConfig} from "../../../common/data/_types.mjs";
 */

/**
 * @callback CustomFormGroup
 * @param {DataField} field
 * @param {FormGroupConfig} groupConfig
 * @param {FormInputConfig} inputConfig
 * @returns {HTMLDivElement}
 */

/**
 * @callback CustomFormInput
 * @param {DataField} field
 * @param {FormInputConfig} config
 * @returns {HTMLElement|HTMLCollection}
 */

/**
 * Create a standardized form field group.
 * @param {FormGroupConfig} config
 * @returns {HTMLDivElement}
 */
function createFormGroup(config) {
  let {classes, hint, label, input, rootId, stacked, localize, units} = config;
  classes ||= [];
  if ( stacked ) classes.unshift("stacked");
  classes.unshift("form-group");

  // Assign identifiers to each input
  input = input instanceof HTMLCollection ? input : [input];
  let labelFor;
  if ( rootId ) {
    for ( const [i, el] of input.entries() ) {
      const id = [rootId, el.name, input.length > 1 ? i : ""].filterJoin("-");
      labelFor ||= id;
      el.setAttribute("id", id);
    }
  }

  // Create the group element
  const group = document.createElement("div");
  group.className = classes.join(" ");
  group.hidden = config.hidden ?? false;

  // Label element
  const lbl = document.createElement("label");
  lbl.innerText = localize ? game.i18n.localize(label) : label;
  if ( labelFor ) lbl.setAttribute("for", labelFor);
  if ( units ) lbl.insertAdjacentHTML("beforeend", ` <span class="units">(${game.i18n.localize(units)})</span>`);
  group.prepend(lbl);

  // Form fields and inputs
  const fields = document.createElement("div");
  fields.className = "form-fields";
  fields.append(...input);
  group.append(fields);

  // Hint element
  if ( hint ) {
    const h = document.createElement("p");
    h.className = "hint";
    h.innerText = localize ? game.i18n.localize(hint) : hint;
    group.append(h);
  }
  return group;
}

/* ---------------------------------------- */

/**
 * Create an `<input type="checkbox">` element for a BooleanField.
 * @param {FormInputConfig<boolean>} config
 * @returns {HTMLInputElement}
 */
function createCheckboxInput(config) {
  const input = document.createElement("input");
  input.type = "checkbox";
  input.name = config.name;
  if ( config.value ) input.setAttribute("checked", "");
  setInputAttributes(input, config);
  return input;
}

/* ---------------------------------------- */

/**
 * @typedef EditorInputConfig
 * @property {string} [engine="prosemirror"]
 * @property {number} [height]
 * @property {boolean} [editable=true]
 * @property {boolean} [button=false]
 * @property {boolean} [collaborate=false]
 */

/**
 * Create a `<div class="editor">` element for a StringField.
 * @param {FormInputConfig<string> & EditorInputConfig} config
 * @returns {HTMLDivElement}
 */
function createEditorInput(config) {
  const {engine="prosemirror", editable=true, button=false, collaborate=false, height} = config;
  const editor = document.createElement("div");
  editor.className = "editor";
  if ( height !== undefined ) editor.style.height = `${height}px`;

  // Dataset attributes
  let dataset = { engine, collaborate };
  if ( editable ) dataset.edit = config.name;
  dataset = Object.entries(dataset).map(([k, v]) => `data-${k}="${foundry.utils.escapeHTML(v)}"`).join(" ");

  // Editor HTML
  let editorHTML = "";
  if ( button && editable ) editorHTML += '<a class="editor-edit"><i class="fa-solid fa-pen-to-square"></i></a>';
  editorHTML += `<div class="editor-content" ${dataset}>${config.value ?? ""}</div>`;
  editor.innerHTML = editorHTML;
  return editor;
}

/* ---------------------------------------- */

/**
 * Create a `<multi-select>` or `<multi-checkbox>` element for fields supporting multiple choices.
 * @param {FormInputConfig<(string|number)[]> & Omit<SelectInputConfig, "blank">} config
 *        Configuration object for creating the multi-select element.
 *        The `value` property accepts an array of strings or numbers corresponding to the selected choices.
 * @returns {AbstractMultiSelectElement}
 */
function createMultiSelectInput(config) {
  const tagName = config.type === "checkboxes" ? "multi-checkbox" : "multi-select";
  const select = document.createElement(tagName);
  select.name = config.name;
  setInputAttributes(select, config);
  const groups = prepareSelectOptionGroups(config);
  for ( const g of groups ) {
    let parent = select;
    if ( g.group ) parent = _appendOptgroup(g.group, select);
    for ( const o of g.options ) _appendOption(o, parent);
  }
  return select;
}

/* ---------------------------------------- */

/**
 * @typedef NumberInputConfig
 * @property {number} min
 * @property {number} max
 * @property {number|"any"} step
 * @property {"range"|"number"} [type]
 */

/**
 * Create an `<input type="number">` element for a NumberField.
 * @param {FormInputConfig<number> & NumberInputConfig} config
 * @returns {HTMLInputElement}
 */
function createNumberInput(config) {
  const input = document.createElement("input");
  input.type = "number";
  if ( config.name ) input.name = config.name;

  // Assign value
  const step = typeof config.step === "number" ? config.step : "any";
  let value = config.value;
  if ( Number.isNumeric(value) ) {
    if ( typeof config.step === "number" ) value = value.toNearest(config.step);
    input.setAttribute("value", String(value));
  }
  else input.setAttribute("value", "");

  // Min, max, and step size
  if ( typeof config.min === "number" ) input.setAttribute("min", String(config.min));
  if ( typeof config.max === "number" ) input.setAttribute("max", String(config.max));
  input.setAttribute("step", String(step));
  setInputAttributes(input, config);
  return input;
}

/* ---------------------------------------- */

/**
 * @typedef FormSelectOption
 * @property {string} [value]
 * @property {string} [label]
 * @property {string} [group]
 * @property {boolean} [disabled]
 * @property {boolean} [selected]
 * @property {boolean} [rule]
 * @property {Record<string, string>} [dataset]
 */

/**
 * @typedef SelectInputConfig
 * @property {FormSelectOption[]} options
 * @property {string[]} [groups]        An option to control the order and display of optgroup elements. The order of
 *                                      strings defines the displayed order of optgroup elements.
 *                                      A blank string may be used to define the position of ungrouped options.
 *                                      If not defined, the order of groups corresponds to the order of options.
 * @property {string} [blank]
 * @property {string} [valueAttr]       An alternative value key of the object passed to the options array
 * @property {string} [labelAttr]       An alternative label key of the object passed to the options array
 * @property {boolean} [localize=false] Localize value labels
 * @property {boolean} [sort=false]     Sort options alphabetically by label within groups
 * @property {"single"|"multi"|"checkboxes"} [type] Customize the type of select that is created
 */

/**
 * Create a `<select>` element for a StringField.
 * @param {FormInputConfig<string> & SelectInputConfig} config
 * @returns {HTMLSelectElement}
 */
function createSelectInput(config) {
  const select = document.createElement("select");
  select.name = config.name;
  setInputAttributes(select, config);
  const groups = prepareSelectOptionGroups(config);
  for ( const g of groups ) {
    let parent = select;
    if ( g.group ) parent = _appendOptgroup(g.group, select);
    for ( const o of g.options ) _appendOption(o, parent);
  }
  return select;
}

/* ---------------------------------------- */

/**
 * @typedef TextAreaInputConfig
 * @property {number} rows
 */

/**
 * Create a `<textarea>` element for a StringField.
 * @param {FormInputConfig<string> & TextAreaInputConfig} config
 * @returns {HTMLTextAreaElement}
 */
function createTextareaInput(config) {
  const textarea = document.createElement("textarea");
  textarea.name = config.name;
  textarea.textContent = config.value ?? "";
  if ( config.rows ) textarea.setAttribute("rows", String(config.rows));
  setInputAttributes(textarea, config);
  return textarea;
}

/* ---------------------------------------- */

/**
 * Create an `<input type="text">` element for a StringField.
 * @param {FormInputConfig<string>} config
 * @returns {HTMLInputElement}
 */
function createTextInput(config) {
  const input = document.createElement("input");
  input.type = "text";
  input.name = config.name;
  input.setAttribute("value", config.value ?? "");
  setInputAttributes(input, config);
  return input;
}

/* ---------------------------------------- */
/*  Helper Methods                          */
/* ---------------------------------------- */

/**
 * Structure a provided array of select options into a standardized format for rendering optgroup and option elements.
 * @param {FormInputConfig & SelectInputConfig} config
 * @returns {{group: string, options: FormSelectOption[]}[]}
 *
 * @example
 * const options = [
 *   {value: "bar", label: "Bar", selected: true, group: "Good Options"},
 *   {value: "foo", label: "Foo", disabled: true, group: "Bad Options"},
 *   {value: "baz", label: "Baz", group: "Good Options"}
 * ];
 * const groups = ["Good Options", "Bad Options", "Unused Options"];
 * const optgroups = foundry.applications.fields.prepareSelectOptionGroups({options, groups, blank: true, sort: true});
 */
function prepareSelectOptionGroups(config) {

  // Coerce values to string array
  let values = [];
  if ( (config.value === undefined) || (config.value === null) ) values = [];
  else if ( typeof config.value === "object" ) {
    for ( const v of config.value ) values.push(String(v));
  }
  else values = [String(config.value)];
  const isSelected = value => values.includes(value);

  // Organize options into groups
  let hasBlank = false;
  const groups = {};
  for ( const option of (config.options || []) ) {
    let {group, value, label, disabled, rule, dataset} = option;

    // Value
    if ( config.valueAttr ) value = option[config.valueAttr];
    if ( value !== undefined ) {
      value = String(value);
      if ( value === "" ) hasBlank = true;
    }

    // Label
    if ( config.labelAttr ) label = option[config.labelAttr];
    label ??= value;
    if ( label !== undefined ) {
      if ( typeof label !== "string" ) label = label.toString();
      if ( config.localize ) label = game.i18n.localize(label);
    }

    const selected = option.selected || isSelected(value);
    disabled = !!disabled;

    // Add to group
    group ||= "";
    groups[group] ||= [];
    groups[group].push({type: "option", value, label, selected, disabled, rule, dataset});
  }

  // Add groups into an explicitly desired order
  const result = [];
  if ( config.groups instanceof Array ) {
    for ( let group of config.groups ) {
      const options = groups[group] ?? [];
      delete groups[group];
      if ( config.localize ) group = game.i18n.localize(group);
      result.push({group, options});
    }
  }

  // Add remaining groups
  for ( let [groupName, options] of Object.entries(groups) ) {
    if ( groupName && config.localize ) groupName = game.i18n.localize(groupName);
    result.push({group: groupName, options});
  }

  // Sort options
  if ( config.sort ) {
    if ( !config.groups ) result.sort((a, b) => a.group.localeCompare(b.group, game.i18n.lang));
    for ( const group of result ) group.options.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
  }

  // A blank option always comes first
  if ( (typeof config.blank === "string") && !hasBlank ) result.unshift({group: "", options: [{
    value: "",
    label: config.localize ? game.i18n.localize(config.blank) : config.blank,
    selected: isSelected("")
  }]});
  return result;
}

/* ---------------------------------------- */

/**
 * Create and append an option element to a parent select or optgroup.
 * @param {FormSelectOption} option
 * @param {HTMLSelectElement|HTMLOptGroupElement} parent
 * @internal
 */
function _appendOption(option, parent) {
  const {value, label, selected, disabled, rule, dataset} = option;
  if ( (value !== undefined) && (label !== undefined) ) {
    const o = document.createElement("option");
    o.value = value;
    o.innerText = label;
    if ( selected ) o.toggleAttribute("selected", true);
    if ( disabled ) o.toggleAttribute("disabled", true);
    if ( dataset ) {
      for ( const [key, value] of Object.entries(dataset) ) {
        o.dataset[key] = value;
      }
    }
    parent.appendChild(o);
  }
  if ( rule ) parent.insertAdjacentHTML("beforeend", "<hr>");
}

/* ---------------------------------------- */

/**
 * Create and append an optgroup element to a parent select.
 * @param {string} label
 * @param {HTMLSelectElement} parent
 * @returns {HTMLOptGroupElement}
 * @internal
 */
function _appendOptgroup(label, parent) {
  const g = document.createElement("optgroup");
  g.label = label;
  parent.appendChild(g);
  return g;
}

/* ---------------------------------------- */

/**
 * Apply standard attributes to all input elements.
 * @param {HTMLElement} input           The element being configured
 * @param {FormInputConfig<*>} config   Configuration for the element
 */
function setInputAttributes(input, config) {
  input.toggleAttribute("required", config.required === true);
  input.toggleAttribute("disabled", config.disabled === true);
  input.toggleAttribute("readonly", config.readonly === true);
  input.toggleAttribute("autofocus", config.autofocus === true);
  if ( config.id ) input.id = config.id;
  if ( "classes" in config ) input.className = config.classes;
  if ( config.placeholder !== undefined ) input.setAttribute("placeholder", config.placeholder);
  if ( "dataset" in config ) {
    for ( const [k, v] of Object.entries(config.dataset) ) {
      input.dataset[k] = v;
    }
  }
  if ( "aria" in config ) {
    for ( const [k, v] of Object.entries(config.aria) ) {
      input.setAttribute(`aria-${k}`, v);
    }
  }
}

/**
 * Create an HTML element for a FontAwesome icon
 * @param {string} glyph A FontAwesome glyph name, such as "file" or "user"
 * @param {object} [options] Additional options to configure the icon
 * @param {"solid"|"regular"|"duotone"} [options.style="solid"] The style name for the icon
 * @param {boolean} [options.fixedWidth=false] Should icon be fixed-width?
 * @param {string[]} [options.classes] Additional classes to append to the class list
 * @returns {HTMLElement} The configured FontAwesome icon element
 * @see {@link https://fontawesome.com/search}
 */
function createFontAwesomeIcon(glyph, {style="solid", fixedWidth=false, classes=[]}={}) {
  const glyphClass = glyph.startsWith("fa-") ? glyph : `fa-${glyph}`;
  const styleClass = `fa-${style}`;
  const icon = document.createElement("i");
  icon.inert = true;
  icon.classList.add(styleClass, glyphClass, ...classes);
  if (fixedWidth) icon.classList.add("fa-fw");
  return icon;
}

var fields$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  createCheckboxInput: createCheckboxInput,
  createEditorInput: createEditorInput,
  createFontAwesomeIcon: createFontAwesomeIcon,
  createFormGroup: createFormGroup,
  createMultiSelectInput: createMultiSelectInput,
  createNumberInput: createNumberInput,
  createSelectInput: createSelectInput,
  createTextInput: createTextInput,
  createTextareaInput: createTextareaInput,
  prepareSelectOptionGroups: prepareSelectOptionGroups,
  setInputAttributes: setInputAttributes
});

/**
 * @import Application from "@client/appv1/api/application-v1.mjs"
 * @import ApplicationV2 from "../api/application.mjs"
 */

/**
 * @typedef DraggableResizeOptions
 * @property {string} [selector]  A CSS selector for the resize handle.
 * @property {boolean} [resizeX=true]  Enable resizing along the X axis.
 * @property {boolean} [resizeY=true]  Enable resizing along the Y axis.
 * @property {boolean} [rtl]           Modify the resizing direction to be right-to-left.
 */

/**
 * A UI utility to make an element draggable.
 */
class Draggable {
  /**
   * @param {Application|ApplicationV2} app The Application that is being made draggable.
   * @param {HTMLElement|jQuery} element    The Application's outer-most element.
   * @param {HTMLElement|false} handle      The element that acts as a drag handle. Supply false to disable dragging.
   * @param {boolean|DraggableResizeOptions} resizable  Is the application resizable? Supply an object to configure
   *                                                    resizing behavior or true to have it automatically configured.
   */
  constructor(app, element, handle, resizable) {

    // Setup element data
    this.app = app;
    this.element = element instanceof HTMLElement ? element : element[0];
    this.handle = handle ?? this.element;
    this.resizable = resizable || false;

    // Activate interactivity
    this.activateListeners();
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The Application being made draggable.
   * @type {Application|ApplicationV2}
   */
  app;

  /**
   * The Application's outer-most element.
   * @type {HTMLElement}
   */
  element;

  /**
   * The drag handle, or false to disable dragging.
   * @type {HTMLElement|false}
   */
  handle;

  /**
   * Registered event handlers.
   * @type {Record<string, Function>}
   */
  handlers = {};

  /**
   * The Application's starting position, pre-drag.
   * @type {object}
   */
  position = null;

  /**
   * Resize configuration.
   * @type {boolean|DraggableResizeOptions}
   */
  resizable;

  /**
   * Record move time for throttling.
   * @type {number}
   */
  #moveTime = 0;

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * 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", () => this.app.bringToFront(), {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 = document.createElement("div");
      handle.classList.add("window-resizable-handle");
      handle.append(createFontAwesomeIcon("left-right", {classes: ["fa-rotate-by"]}));
      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
   * @param {PointerEvent} event
   * @protected
   */
  _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
   * @param {PointerEvent} event
   * @protected
   */
  _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
   * @param {PointerEvent} event
   * @protected
   */
  _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
   * @param {PointerEvent} event
   * @protected
   */
  _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
   * @param {PointerEvent} event
   * @protected
   */
  _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
   * @param {PointerEvent} event
   * @protected
   */
  _onResizeMouseUp(event) {
    event.preventDefault();
    window.removeEventListener(...this.handlers.resizeMove);
    window.removeEventListener(...this.handlers.resizeUp);
    this.app._onResize(event);
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Retrieve the configured Draggable implementation.
   * @type {typeof Draggable}
   */
  static get implementation() {
    let Class = CONFIG.ux.Draggable;
    if ( !foundry.utils.isSubclass(Class, Draggable) ) {
      console.warn("Configured Draggable override must be a subclass of Draggable.");
      Class = Draggable;
    }
    return Class;
  }
}

/**
 * @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 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 foundry.applications.api.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("secret-block").forEach(secret => {
        secret.addEventListener("change", this._onToggleSecret.bind(this));
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a secret's revealed state.
   * @param {MouseEvent} event           The triggering click event.
   * @returns {Promise<ClientDocument>|void}  The Document whose content was modified.
   * @protected
   */
  _onToggleSecret(event) {
    const content = this.callbacks.content(event.target.secret);
    if ( !content ) return;
    const modified = event.target.toggleRevealed(content);
    return this.callbacks.update(event.target.secret, modified);
  }
}

/**
 * @typedef 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 foundry.applications.api.ApplicationV2}
 *
 * @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 foundry.applications.ux.Tabs({navSelector: ".tabs", contentSelector: ".content", initial: "tab1"});
 * tabs.bind(html);
 * ```
 */
class Tabs {
  /**
   * @param {TabsConfiguration} config The Tabs Configuration to use for this tabbed container
   */
  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;

    this._navSelector = navSelector;
    this._contentSelector = contentSelector;
  }

  /* -------------------------------------------- */

  /**
   * The CSS selector used to target the tab navigation element
   * @type {string}
   * @internal
   */
  _navSelector;

  /* -------------------------------------------- */

  /**
   * A reference to the HTML navigation element the tab controller is bound to
   * @type {HTMLElement|null}
   * @internal
   */
  _nav = null;

  /* -------------------------------------------- */

  /**
   * The CSS selector used to target the tab content element
   * @type {string}
   * @internal
   */
  _contentSelector;

  /* -------------------------------------------- */

  /**
   * A reference to the HTML container element of the tab content
   * @type {HTMLElement|null}
   * @internal
   */
  _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 ( const 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 ( const 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 {PointerEvent} event    A left click event
   * @protected
   */
  _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});
  }
}

var _module$Z = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ContextMenu: ContextMenu,
  DragDrop: DragDrop,
  Draggable: Draggable,
  FormDataExtended: FormDataExtended,
  HTMLSecret: HTMLSecret,
  ProseMirrorEditor: ProseMirrorEditor,
  SearchFilter: SearchFilter,
  Tabs: Tabs,
  TextEditor: TextEditor$1
});

/**
 * @import {TabsConfiguration} from "@client/applications/ux/tabs.mjs";
 * @import {DragDropConfiguration} from "@client/applications/ux/drag-drop.mjs";
 * @import {SearchFilterConfiguration} from "@client/applications/ux/search-filter.mjs";
 * @import ContextMenu from "@client/applications/ux/context-menu.mjs";
 * @import ApplicationV2 from "@client/applications/api/application.mjs";
 */

const MIN_WINDOW_WIDTH = 200;
const MIN_WINDOW_HEIGHT = 50;

/**
 * @typedef ApplicationV1Options
 * 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.
 * @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 foundry.applications.ux.DragDrop} behaviour.
 * @property {SearchFilterConfiguration[]} filters  An array of
 *   {@link foundry.applications.ux.SearchFilter} configuration objects.
 */

/**
 * @typedef {{label: string, class: string, icon: string, [tooltip]: string,
 *   onclick: Function|null}} ApplicationV1HeaderButton
 */

/**
 * The legacy application window that is rendered for some UI elements in Foundry VTT.
 * @abstract
 * @deprecated since v13
 */
class Application {
  /**
   * @param {ApplicationV1Options} [options] Configuration options which control how the application is rendered.
   */
  constructor(options={}) {

    /** @deprecated since v13 until v16 */
    if ( !this.constructor._warnedAppV1 ) {
      foundry.utils.logCompatibilityWarning("The V1 Application framework is deprecated, and will be removed in a "
        + "later core software version. Please use the V2 version of the Application framework available under "
        + "foundry.applications.api.ApplicationV2.", { since: 13, until: 16, once: true });
      this.constructor._warnedAppV1 = true;
    }

    /**
     * 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
    });

    // Force .theme-light for legacy ApplicationV1 instances
    // TODO: Remove when all setup apps are replaced with AppV2.
    const isSetup = game?.constructor.name === "Setup";
    if ( !this.options.classes.includes("theme-dark") && !isSetup ) this.options.classes.push("themed", "theme-light");

    /**
     * An internal reference to the HTML element this application renders
     * @type {jQuery}
     * @internal
     */
    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[]}
     * @internal
     */
    this._dragDrop = this._createDragDropHandlers();

    /**
     * Tab navigation handlers which are active for this Application
     * @type {Tabs[]}
     * @internal
     */
    this._tabs = this._createTabHandlers();

    /**
     * SearchFilter handlers which are active for this Application
     * @type {SearchFilter[]}
     * @internal
     */
    this._searchFilters = this._createSearchFilters();

    /**
     * Track whether the Application is currently minimized
     * @type {boolean|null}
     * @internal
     */
    this._minimized = false;

    /**
     * The current render state of the Application
     * @see {@link 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}
     * @internal
     */
    this._scrollPositions = null;
  }

  static _warnedAppV1 = false;

  /**
   * 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
   * @internal
   */
  _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.implementation(d);
    });
  }

  /* -------------------------------------------- */

  /**
   * Create tabbed navigation handlers for this Application
   * @returns {Tabs[]}     An array of Tabs handlers
   * @internal
   */
  _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
   * @internal
   */
  _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 {ApplicationV1Options}
   */
  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;
    const 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 its 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$1.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(`${CONST.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") ?? ++foundry.applications.api.ApplicationV2._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.implementation(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[]}
   * @internal
   */
  static _getInheritanceChain() {
    const parents = foundry.utils.getParentClasses(this);
    const base = this.defaultOptions.baseApplication;
    const chain = [this];
    for ( const 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
   */
  _callHooks(hookName, ...hookArgs) {
    const formatHook = typeof hookName === "string" ? className => `${hookName}${className}` : hookName;
    for ( const cls of this.constructor._getInheritanceChain() ) {
      if ( !cls.name ) continue;
      Hooks$1.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 ( const 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 foundry.applications.handlebars.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.implementation(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(++foundry.applications.api.ApplicationV2._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
   * @internal
   */
  async _renderInner(data) {
    const html = await foundry.applications.handlebars.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
   * @internal
   */
  _replaceHTML(element, html) {
    if ( !element.length ) return;

    // For pop-out window, 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
   * @internal
   */
  _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 getApplicationV1HeaderButtons hook.
   * @fires getApplicationHeaderButtons
   * @returns {ApplicationV1HeaderButton[]}
   * @protected
   */
  _getHeaderButtons() {
    const buttons = [
      {
        label: "Close",
        class: "close",
        icon: "fa-solid fa-xmark",
        onclick: () => this.close()
      }
    ];
    this._callHooks(className => `get${className}HeaderButtons`, buttons);
    return buttons;
  }

  /* -------------------------------------------- */

  /**
   * Create a {@link foundry.applications.ux.ContextMenu} for this Application.
   * @param {jQuery} html  The Application's HTML.
   * @internal
   */
  _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 < foundry.applications.api.ApplicationV2._maxZ ) {
      this.position.zIndex = Math.min(++foundry.applications.api.ApplicationV2._maxZ, 99999);
      element.style.zIndex = this.position.zIndex;
      ui.activeWindow = this;
    }
  }

  /* -------------------------------------------- */

  /**
   * A convenience alias for {@link bringToTop} for when operating on an object that is either an Application or an
   * {@link ApplicationV2}
   */
  bringToFront() {
    this.bringToTop();
  }

  /* -------------------------------------------- */

  /**
   * 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 ( this._state === states.CLOSED ) return;
    if ( !options.force && ![states.RENDERED, states.ERROR].includes(this._state) ) return;
    this._state = states.CLOSING;

    // Get the element
    const el = this.element;
    el.css({minHeight: 0});

    // Dispatch Hooks for closing the base and subclass applications
    this._callHooks("close", el);

    // Tear down SearchFilters
    for ( const filter of this._searchFilters ) {
      filter.unbind();
    }

    const cleanUp = () => {
      this._element = null;
      delete ui.windows[this.appId];
      this._minimized = false;
      this._scrollPositions = null;
      this._state = states.CLOSED;
    };

    // Animate closing the element
    if ( options.animate !== false ) {
      return new Promise(resolve => {
        el.slideUp(200, () => {
          el.remove();
          cleanUp();
          resolve();
        });
      });
    }

    // Clean up data
    cleanUp();
  }

  /* -------------------------------------------- */

  /**
   * 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
    const window = this.element;
    const 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
   * @internal
   */
  _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
   * @internal
   */
  _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 ) {
        // eslint-disable-next-line no-loop-func
        img.onload = img.onerror = () => {
          loaded++;
          img.onload = img.onerror = null;
          if ( loaded >= images.length ) resolve();
        };
      }
    });
  }
}

/**
 * @import * as TinyMCE from "tinymce";
 * @import * as ProseMirror from "prosemirror-view";
 * @import {ApplicationV1Options} from "../api/application-v1.mjs";
 */

/**
 * @typedef 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 outermost element
 * 3) This abstract layer has no knowledge of what is being updated, so the implementation must define _updateObject
 *
 * @abstract
 * @deprecated since V13
 */
class FormApplication extends Application {
  /**
   * @param {object} object                    Some object which is the target data structure to be updated by the form.
   * @param {FormApplicationOptions & ApplicationV1Options} [options] Additional options which modify the rendering of
   *                                                                  the sheet.
   */
  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 get _customElements() {
    return 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 {ApplicationV1Options & 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 ( const i of inputs ) {
      for ( const 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} options
   * @param {object | null} [options.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} [options.preventClose]     Override the standard behavior of whether to close the form on submit
   * @param {boolean} [options.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 foundry.applications.ux.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.prosemirror, .editor.tinymce") ) 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 foundry.applications.ux.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$1.implementation.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;
    const event = new Event("submit", {cancelable: true});
    await this._onSubmit(event, { 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 ( this._state === states.CLOSED ) return;
    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 ( const fp of (this.#filepickers) ) fp.close();
    this.#filepickers.length = 0;
    if ( this.element?.length ) {
      for ( const fp of this.element[0].querySelectorAll("file-picker") ) fp.picker?.close();
    }

    // Close any open MCE editors
    for ( const 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<this>}  Return a self-reference for convenient method chaining
   */
  async submit(options={}) {
    if ( this._submitting ) return this;
    const submitEvent = new Event("submit", {cancelable: true});
    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.implementation(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) {}
}

/**
 * @import Document from "@common/abstract/document.mjs";
 * @import {HTMLSecretConfiguration} from "@client/applications/ux/html-secret.mjs";
 */

/**
 * @typedef DocumentSheetV1Options
 * @property {number} viewPermission                The default permissions required to view this Document sheet.
 * @property {HTMLSecretConfiguration[]} [secrets]  An array of {@link foundry.applications.ux.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.
 *
 * @abstract
 * @deprecated since V13
 */
class DocumentSheet extends FormApplication {
  /**
   * @param {Document} object                     A Document instance which should be managed by this form.
   * @param {FormApplicationOptions & DocumentSheetV1Options} [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 {FormApplicationOptions & DocumentSheetV1Options}
   */
  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() {
    const suffix = this.document.uuid ?? foundry.utils.randomID();
    return `${this.constructor.name}-${suffix.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("DOCUMENT.IdCopiedClipboard", {format: {label, type: "uuid", id: this.object.uuid}});
    });
    idLink.addEventListener("contextmenu", event => {
      event.preventDefault();
      game.clipboard.copyPlainText(this.object.id);
      ui.notifications.info("DOCUMENT.IdCopiedClipboard", {format: {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() {
    const document = this.document;
    if ( !document.isOwner || (document.inCompendium && document.collection.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() {
    const buttons = super._getHeaderButtons();

    // Compendium Import
    const {documentName, isEmbedded, inCompendium} = this.document;
    if ( (documentName !== "Folder") && !isEmbedded && inCompendium
      && this.document.constructor.canUserCreate(game.user) ) {
      buttons.unshift({
        label: "Import",
        class: "import",
        icon: "fa-solid fa-download",
        onclick: async () => {
          await this.close();
          return game.collections.get(documentName).importFromCompendium(this.document.collection, 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: "fa-solid fa-gear",
        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|void}
   * @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|undefined>|void} 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 {jQuery.ClickEvent} event
   * @protected
   */
  _onConfigureSheet(event) {
    event.preventDefault();
    new DocumentSheetConfig({
      document: this.document,
      position: {
        top: this.position.top + 40,
        left: this.position.left + ((this.position.width - DocumentSheet.defaultOptions.width) / 2)
      }
    }).render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle changing a Document's image.
   * @param {MouseEvent} event  The click event.
   * @returns {Promise<FilePicker>}
   * @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.implementation({
      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);
  }
}

/**
 * @import JournalEntry from "../journal-entry.mjs";
 * @import JournalEntryPage from "../journal-entry-page.mjs";
 * @import {ShareImageConfig} from "@client/applications/apps/image-popout.mjs";
 */

/**
 * 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<JournalEntry>}
 * @category Collections
 *
 * @see {@link foundry.documents.JournalEntry}: The JournalEntry document
 * @see {@link foundry.applications.sidebar.tabs.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<void>}
   */
  static async showDialog(doc) {
    if ( !((doc instanceof foundry.documents.JournalEntry)
      || (doc instanceof foundry.documents.JournalEntryPage)) ) return;
    if ( !doc.isOwner ) {
      ui.notifications.error("JOURNAL.ShowBadPermissions", {localize: true});
      return;
    }
    if ( game.users.size < 2 ) {
      ui.notifications.warn("JOURNAL.ShowNoPlayers", {localize: true});
      return;
    }
    new foundry.applications.sheets.journal.ShowToPlayersDialog({ document: doc }).render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * 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 async show(doc, {force=false, users=[]}={}) {
    if ( !((doc instanceof foundry.documents.JournalEntry)
      || (doc instanceof foundry.documents.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("JOURNAL.ActionShowSuccess", {format: {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("JOURNAL.ImageShowSuccess", {format: {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", foundry.applications.apps.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 foundry.utils.fromUuid(uuid);
    const {VIEW_MODES} = foundry.appv1.sheets.JournalSheet;
    const options = {tempOwnership: force, mode: VIEW_MODES.MULTIPLE, pageIndex: 0};
    const {OBSERVER} = CONST.DOCUMENT_OWNERSHIP_LEVELS;
    if ( entry instanceof foundry.documents.JournalEntryPage ) {
      options.mode = 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 foundry.documents.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);
  }
}

/**
 * @import {JournalEntryPageHeading} from "@client/_types.mjs";
 * @import Note from "@client/canvas/placeables/note.mjs";
 */

/**
 * The client-side JournalEntryPage document which extends the common BaseJournalEntryPage document model.
 * @extends BaseJournalEntryPage
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.JournalEntry}: The JournalEntry document type which contains
 *   JournalEntryPage embedded documents.
 */
let JournalEntryPage$1 = class JournalEntryPage extends ClientDocumentMixin(BaseJournalEntryPage) {

  /**
   * 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|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) && !("noToc" in element.dataset) ) {
        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);
      }
    };
    if ( Array.isArray(html) ) html.forEach(searchHeadings);
    else searchHeadings(html);
    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 ??= foundry.utils.escapeHTML(eventData.anchor.name);
      return `@UUID[${uuid}#${eventData.anchor.slug}]{${label}}`;
    }
    return super._createDocumentLink(eventData, {relativeTo, label});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickDocumentLink(event) {
    const target = event.target.closest("a[data-link]");
    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", "==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="fa-solid 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 ?? this.isOwner,
      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$1.implementation.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="fa-solid 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;
  }
};

/**
 * @import {JournalPageSheet} from "./journal-page-sheet.mjs";
 * @import {ApplicationV1Options} from "../api/application-v1.mjs";
 * @import {DocumentSheetV1Options} from "../api/document-sheet-v1.mjs";
 */

/**
 * @typedef 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.
 * @deprecated since v13
 * @param {JournalEntry} object            The JournalEntry instance which is being edited
 * @param {JournalSheetOptions & DocumentSheetV1Options & ApplicationV1Options} [options] Application options
 */
class JournalSheet extends DocumentSheet {

  /**
   * @override
   * @returns {JournalSheetOptions & DocumentSheetV1Options & ApplicationV1Options}
   */
  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: 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>}
   */
  #filteredPages = new Set();

  /**
   * The pages that are currently scrolled into view and marked as 'active' in the sidebar.
   * @type {HTMLElement[]}
   */
  #pagesInView = [];

  /**
   * The index of the currently viewed page.
   * @type {number}
   */
  #pageIndex = 0;

  /**
   * Has the player been granted temporary ownership of this journal entry or its pages?
   * @type {boolean}
   */
  #tempOwnership = false;

  /**
   * A mapping of page IDs to {@link foundry.appv1.sheets.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") || 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") === DIRECTORY_SEARCH_MODES.NAME
      ? DIRECTORY_SEARCH_MODES.FULL : 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 = {
    [DOCUMENT_OWNERSHIP_LEVELS.NONE]: "fa-solid fa-eye-slash",
    [DOCUMENT_OWNERSHIP_LEVELS.OBSERVER]: "fa-solid fa-eye",
    [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: "fa-solid 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 === DIRECTORY_SEARCH_MODES.NAME ? "fa-search"
      : "fa-file-magnifying-glass";
    context.searchTooltip = this.searchMode === 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(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 !== undefined) && (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));
  }

  /* -------------------------------------------- */

  /**
   * @param {boolean} [force]
   * @param {object} [options]
   * @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?
   * @override
   */
  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);
      if ( sheet.isV2 ) {
        await this._renderPageView(pageNode, sheet);
        new foundry.applications.ux.HTMLSecret({
          parentSelector: "section.journal-page-content",
          callbacks: {
            content: () => sheet.document.text.content,
            update: (secret, content) => sheet.document.update({ "text.content": content })
          }
        }).bind(pageNode);
      }
      else await this._renderAppV1PageView(pageNode, sheet);
      if ( edit ) pageNode.appendChild(edit);
      await this._renderHeadings(pageNode, sheet.toc);
    }
    this._observePages();
    this._observeHeadings();
  }

  /* -------------------------------------------- */

  /**
   * Render the page view for an app v1 page sheet.
   * @param {HTMLElement} element     The existing page element in the journal entry view.
   * @param {JournalPageSheet} sheet  The page sheet.
   * @returns {Promise<void>}
   * @protected
   */
  async _renderAppV1PageView(element, sheet) {
    const data = await sheet.getData();
    const view = await sheet._renderInner(data);
    element.replaceChildren(...view.get());
    sheet._activateCoreListeners(view.parent());
    sheet.activateListeners(view);
    sheet._callHooks("render", view, data);
  }

  /* -------------------------------------------- */

  /**
   * Render the page view for a page sheet.
   * @param {HTMLElement} element          The existing page element in the journal entry view.
   * @param {JournalEntryPageSheet} sheet  The page sheet.
   * @returns {Promise<void>}
   * @protected
   */
  async _renderPageView(element, sheet) {
    await sheet.render({ force: true });
    element.replaceChildren(...sheet.element.children);
  }

  /**
   * 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 !== undefined ? [this._pages[pageIndex]] : this._pages;
    for ( const page of pages ) {
      const sheet = this.getPageSheet(page._id);
      if ( sheet.isV2 ) sheet._doEvent?.(sheet._onCloseView, { eventName: "closeView", hookName: "closeView" });
      else {
        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 foundry.applications.handlebars.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={}) {
    for ( const sheet of Object.values(this.#sheets) ) {
      // noinspection ES6MissingAwait
      sheet.close({animate: false});
    }

    // 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 = { position: {width: 320, top: bounds.bottom - 200, left: bounds.left + 10} };
    const sort = (this._pages.at(-1)?.sort ?? 0) + SORT_INTEGER_DENSITY;
    return JournalEntryPage$1.implementation.createDialog({sort}, {parent: this.object}, 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 ) {
      if ( sheetClass.isV2 ) sheet = new sheetClass({
        document: page,
        mode: "view",
        window: {
          frame: false,
          positioned: false
        }
      });
      else 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) {
    const sheet = this.getPageSheet(page.id);
    return sheet.isVisible ?? sheet._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 foundry.applications.apps.ImagePopout({
      src: target.getAttribute("src"),
      caption: page?.image.caption,
      window: {title}
    });
    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) {
    foundry.applications.ux.ContextMenu.implementation.create(this, html, ".directory-item", this._getEntryContextOptions(), {
      onOpen: this._onContextMenuOpen.bind(this),
      onClose: this._onContextMenuClose.bind(this),
      jQuery: false
    });
  }

  /* -------------------------------------------- */

  /**
   * 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, {strict=false}={}) => this.object.pages.get(li.dataset.pageId, {strict});
    return [{
      name: "SIDEBAR.Edit",
      icon: '<i class="fa-solid fa-pen-to-square"></i>',
      condition: li => this.isEditable && getPage(li)?.canUserModify(game.user, "update"),
      callback: li => getPage(li)?.sheet.render(true)
    }, {
      name: "SIDEBAR.Delete",
      icon: '<i class="fa-solid fa-trash"></i>',
      condition: li => this.isEditable && getPage(li)?.canUserModify(game.user, "delete"),
      callback: li => {
        const bounds = li.getBoundingClientRect();
        return getPage(li)?.deleteDialog({position: {top: bounds.top, left: bounds.right}});
      }
    }, {
      name: "SIDEBAR.Duplicate",
      icon: '<i class="fa-regular 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="fa-solid fa-lock"></i>',
      condition: () => game.user.isGM,
      callback: li => {
        const bounds = li.getBoundingClientRect();
        const apps = foundry.applications.apps;
        new apps.DocumentOwnershipConfig({
          document: getPage(li, {strict: true}),
          position: {
            top: bounds.top,
            left: bounds.right
          }
        }).render({force: true});
      }
    }, {
      name: "JOURNAL.ActionShow",
      icon: '<i class="fa-solid 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$1.implementation.getDragEventData(event);
    if ( data.type !== "JournalEntryPage" ) return;
    const page = await JournalEntryPage$1.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 === 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 = foundry.applications.ux.SearchFilter.testQuery(rgx, 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;
    }
  }
}

/** @import Folder from "../folder.mjs"; */

/**
 * 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<Folder>}
 * @category Collections
 *
 * @see {@link foundry.documents.Folder}: The Folder document
 */
class Folders extends WorldCollection {

  /** @override */
  static documentName = "Folder";

  /**
   * Track which Folders are currently expanded in the UI
   * @type {Record<string, boolean>}
   * @internal
   */
  _expanded = {};

  /* -------------------------------------------- */

  /** @override */
  _onModifyContents(action, documents, result, operation, user) {
    this.initializeTree();
    const folderTypes = new Set(documents.map(f => f.type));
    for ( const type of folderTypes ) {
      if ( type === "Compendium" ) {
        game.packs.initializeTree();
        if ( operation.render && !operation.parent ) ui.compendium.render(false);
      } else {
        const collection = game.collections.get(type);
        collection.initializeTree();
        if ( operation.render && !operation.parent ) {
          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.
   */
  #refreshJournalEntrySheets() {
    for ( const 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");
  }
}

/** @import Item from "../item.mjs"; */

/**
 * 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<Item>}
 * @category Collections
 *
 * @see {@link foundry.documents.Item}: The Item document
 * @see {@link foundry.applications.sidebar.tabs.ItemDirectory}: The ItemDirectory sidebar directory
 */
class Items extends WorldCollection {

  /** @override */
  static documentName = "Item";
}

/** @import Macro from "../macro.mjs" */

/**
 * 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<Macro>}
 * @category Collections
 *
 * @see {@link foundry.documents.Macro}: The Macro document
 * @see {@link foundry.applications.sidebar.tabs.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;
  }
}

/**
 * @import Playlist from "../playlist.mjs";
 * @import Scene from "../scene.mjs";
 */

/**
 * 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<Playlist>}
 * @category Collections
 *
 * @see {@link foundry.documents.Playlist}: The Playlist document
 * @see {@link foundry.applications.sidebar.tabs.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 ( const playlist of this ) {
      for ( const 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|null} scene        The new active Scene
   * @param {Scene|null} priorScene   The previously active Scene
   * @internal
   */
  async _onChangeScene(scene, priorScene) {
    const {playlist: p0, playlistSound: s0} = (priorScene || {});
    const {playlist: p1, playlistSound: s1} = scene ?? {};
    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();
    }
  }
}

/** @import RollTable from "../roll-table.mjs" */

/**
 * 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<RollTable>}
 * @category Collections
 *
 * @see {@link foundry.documents.RollTable}: The RollTable document
 * @see {@link foundry.applications.sidebar.tabs.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})
    });
  }
}

/** @import Scene from "../scene.mjs"; */

/**
 * 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<Scene>}
 * @category Collections
 *
 * @see {@link foundry.documents.Scene}: The Scene document
 * @see {@link foundry.applications.sidebar.tabs.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));
    const 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(foundry.canvas.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.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle requests pulling the current User to a specific Scene
   * @param {string} sceneId
   */
  static #pullToScene(sceneId) {
    const scene = game.scenes.get(sceneId);
    if ( scene ) scene.view();
  }

  /* -------------------------------------------- */
  /*  Importing and Exporting                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  fromCompendium(document, { clearSort=true, ...options }={}) {
    const data = super.fromCompendium(document, { clearSort, ...options });
    if ( clearSort ) {
      data.navigation = false;
      delete data.navOrder;
    }
    return data;
  }
}

/** @import Setting from "../setting.mjs"; */

/**
 * The Collection of Setting documents which exist within the active World.
 * This collection is accessible as game.settings.storage.get("world")
 * @extends {WorldCollection<Setting>}
 * @category Collections
 *
 * @see {@link foundry.documents.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
   * @param {string} [user]     For user-scoped settings, the user ID.
   * @returns {Setting}         The Setting
   */
  getSetting(key, user=null) {
    return this.find(s => (s.key === key) && (s.user === user));
  }

  /**
   * Return the serialized value of the world setting as a string
   * @param {string} key     The setting key
   * @param {string} [user]  For user-scoped settings, the user ID.
   * @returns {string|null}  The serialized setting string
   */
  getItem(key, user) {
    return this.getSetting(key, user)?.value ?? null;
  }
}

/**
 * @import User from "../user.mjs";
 * @import {ActivityData} from "@client/_types.mjs";
 */

/**
 * The singleton collection of User documents which exist within the active World.
 * This Collection is accessible within the Game object as game.users.
 *
 * ### Hook Events
 * - {@link hookEvents.userConnected}
 *
 * @extends {WorldCollection<User>}
 * @category Collections
 *
 * @see {@link foundry.documents.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;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _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 ( const 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() {
    return this.getDesignatedUser(user => user.active && user.isGM);
  }

  /* -------------------------------------------- */

  /**
   * Get the designated User among the Users that satisfy the given condition.
   * Returns `null` if no Users satisfy the given condition.
   * Returns a User with the highest role among the qualifying Users.
   * Qualifying Users aren't necessary active Users unless it is part of the condition.
   * @example
   * // Get the designated User for creating Tokens that is active
   * const user = game.users.getDesignatedUser(user => user.active && user.can("TOKEN_CREATE"));
   * @param {(user: User) => boolean} condition    The condition the Users must satisfy
   * @returns {User|null}                          The designated User or `null`
   */
  getDesignatedUser(condition) {
    const qualifying = game.users.filter(condition);
    if ( qualifying.length === 0 ) return null;
    let designated = qualifying[0];
    for ( let i = 1; i < qualifying.length; i++ ) {
      const candidate = qualifying[i];
      if ( ((candidate.role - designated.role) || designated.id.compare(candidate.id)) > 0 ) designated = candidate;
    }
    return designated;
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  static _activateSocketListeners(socket) {
    socket.on("userActivity", Users.#handleUserActivity.bind(this));
    socket.on("userQuery", Users.#handleUserQuery.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * 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
   */
  static #handleUserActivity(userId, activityData={}) {
    const user = game.users.get(userId);
    if ( !user || user.isSelf ) return;

    // Track the last observed activity time for the user
    user.lastActivityTime = Date.now();
    let renderNav = false;

    // Update User active state
    const active = "active" in activityData ? activityData.active : true;
    if ( user.active !== active ) {
      user.active = active;
      game.users.render();
      renderNav = true;
      if ( !active ) user.movingTokens.forEach(token => token._stopMovementOnDisconnect());
      Hooks$1.callAll("userConnected", user, active);
    }

    // Set viewed scene
    const sceneChange = ("sceneId" in activityData) && (activityData.sceneId !== user.viewedScene);
    if ( sceneChange ) {
      user.viewedScene = activityData.sceneId;
      renderNav = true;
    }
    if ( renderNav ) ui.nav?.render();

    // Everything below here requires the game to be ready
    if ( !game.ready ) return;
    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);
      // noinspection ES6MissingAwait
      canvas.controls.updateRuler(user, []);
      canvas.tokens._updatePlannedMovements(user, null);
      user._onUpdateTokenTargets([]);
      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 ) {
      // noinspection ES6MissingAwait
      canvas.controls.updateRuler(user, activityData.ruler);
    }

    // Token planned movements
    if ( "plannedMovements" in activityData ) {
      canvas.tokens._updatePlannedMovements(user, activityData.plannedMovements);
    }

    // Token targets
    if ( "targets" in activityData ) {
      user._onUpdateTokenTargets(activityData.targets);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle the User query received via the socket.
   * @param {string} userId                            The ID of the querying User
   * @param {string} queryId                           The query ID
   * @param {string} queryName                         The query name
   * @param {object} queryData                         The query data
   * @param {object} queryOptions                      The query options
   * @param {number|undefined} queryOptions.timeout    The timeout that the querying User set for this query, if any
   * @param {Function} ack                             The acknowledgement function to return the result
   *                                                   of the confirmation to the server
   */
  static async #handleUserQuery(userId, queryId, queryName, queryData, {timeout}, ack) {
    const queryHandler = CONFIG.queries[queryName];
    if ( !queryHandler ) throw new Error(`Received invalid User query '${queryName}'`);
    const queryOptions = {timeout};
    const user = game.users.get(userId);
    if ( !user ) throw new Error(`User [${userId}] does not exist`);
    if ( CONFIG.debug.queries ) {
      console.debug(`${CONST.vtt} | Received "${queryName}" query [${queryId}] from User [${userId}]:`,
        queryData, queryOptions);
    }
    let queryResult;
    try {
      const value = await queryHandler(queryData, queryOptions);
      queryResult = {status: "fulfilled", value};
    } catch(e) {
      queryResult = {status: "rejected", reason: e.message};
    }
    if ( CONFIG.debug.queries ) {
      console.debug(`${CONST.vtt} | Sending result of "${queryName}" query [${queryId}] to User [${userId}]:`,
        queryResult.status === "rejected" ? new Error(queryResult.reason) : queryResult.value);
    }
    ack(queryResult);
  }
}

/**
 * @import {CompendiumCollection} from "./_module.mjs";
 * @import Folder from "../folder.mjs";
 */

/**
 * A mapping of CompendiumCollection instances, one per Compendium pack
 * @extends {Collection<string, CompendiumCollection>}
 * @category Collections
 */
class CompendiumPacks extends DirectoryCollectionMixin(Collection) {

  /**
   * The Collection class name
   * @type {string}
   */
  get name() {
    return this.constructor.name;
  }

  /* -------------------------------------------- */

  /**
   * Get a Collection of Folders which contain Compendium Packs
   * @returns {Collection<string, 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);
  }
}

var _module$Y = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Actors: Actors,
  CardStacks: CardStacks,
  ChatMessages: ChatMessages,
  CombatEncounters: CombatEncounters,
  CompendiumCollection: CompendiumCollection,
  CompendiumFolderCollection: CompendiumFolderCollection,
  CompendiumPacks: CompendiumPacks,
  FogExplorations: FogExplorations,
  Folders: Folders,
  Items: Items,
  Journal: Journal,
  Macros: Macros,
  Playlists: Playlists,
  RollTables: RollTables,
  Scenes: Scenes,
  Users: Users,
  WorldSettings: WorldSettings
});

/**
 * @import EmbeddedCollection from "@common/abstract/embedded-collection.mjs";
 * @import Token from "@client/canvas/placeables/token.mjs";
 * @import Combat from "./combat.mjs";
 * @import Scene from "./scene.mjs";
 * @import TokenDocument from "./token.mjs";
 * @import ActiveEffect from "./active-effect.mjs";
 * @import Item from "./item.mjs";
 */

/**
 * The client-side Actor document which extends the common BaseActor model.
 *
 * ### Hook Events
 * - {@link hookEvents.applyCompendiumArt}
 * - {@link hookEvents.modifyTokenAttribute}
 *
 * @extends BaseActor
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.Actors}: The world-level collection of Actor documents
 * @see {@link foundry.applications.sheets.ActorSheet}: The Actor configuration application
 *
 * @example Create a new Actor
 * ```js
 * let actor = await Actor.implementation.create({
 *   name: "New Test Actor",
 *   type: "character",
 *   img: "artwork/character-profile.jpg"
 * });
 * ```
 *
 * @example Retrieve an existing Actor
 * ```js
 * let actor = game.actors.get(actorId);
 * ```
 */
let Actor$1 = class Actor extends ClientDocumentMixin(BaseActor) {
  /** @inheritDoc */
  _configure(options={}) {
    super._configure(options);

    /**
     * Maintain a list of Token Documents that represent this Actor, stored by Scene. This list may include unpersisted
     * Token Documents (along with possibly unpersisted parent Scenes), including those with a null _id.
     * @type {IterableWeakMap<Scene, IterableWeakSet<TokenDocument>>}
     * @internal
     */
    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 art.token === "string" ) source.prototypeToken.texture.src = art.token;
    else if ( art.token ) foundry.utils.mergeObject(source.prototypeToken, art.token);
    Hooks$1.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}
   */
  #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;
  }

  /* -------------------------------------------- */

  /**
   * A convenience getter to an object that organizes all embedded Item instances by subtype. The object is cached and
   * lazily re-computed as needed.
   * @type {Record<string, Item[]>}
   * @see {@link foundry.abstract.EmbeddedCollection#documentsByType}
   */
  get itemTypes() {
    return this.items.documentsByType;
  }

  /* -------------------------------------------- */

  /**
   * 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 foundry.documents.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 foundry.documents.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                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  clone(data, context) {
    const cloned = super.clone(data, context);
    if ( context?.keepId && !context.save ) {
      for ( const [scene, tokens] of this._dependentTokens.entries() ) {
        cloned._dependentTokens.set(scene, new foundry.utils.IterableWeakSet(tokens));
      }
    }
    return cloned;
  }

  /* -------------------------------------------- */

  /**
   * 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 ( const 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 ) {
        // Append the lowest number not in use by a token linked to this actor
        const tokens = canvas.scene.tokens.filter(t => t.actorId === this.id);
        const namePattern = new RegExp(`^${RegExp.escape(tokenData.name)} \\((\\d+)\\)$`);
        const usedNumbers = new Set(tokens.map(t => Number(namePattern.exec(t.name)?.[1]) || 0));
        const highestNumber = Math.max(...usedNumbers, tokens.length + 1);
        const newNumber = (Array(highestNumber).keys().find(n => !usedNumbers.has(n + 1)) ?? 0) + 1;
        tokenData.name = `${tokenData.name} (${newNumber})`;
      }

      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$1("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 CONFIG.ux.FilePicker.requestTokenImages(this.id, {pack: this.pack});
    } catch(err) {
      this.#tokenImages = [];
      Hooks$1.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<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$1.call("modifyTokenAttribute", {attribute, value, isDelta, isBar}, updates, this);
    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<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$1("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 ( const 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 ActiveEffect = getDocumentClass$1("ActiveEffect");
    const effect = await ActiveEffect.fromStatusEffect(statusId);
    if ( overlay ) effect.updateSource({"flags.core.overlay": true});
    return ActiveEffect.implementation.create(effect, {parent: this, keepId: true});
  }

  /* -------------------------------------------- */
  /*  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 || (this.compendium !== token.compendium) ) 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 foundry.applications.sheets.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 ( (("ownership" in changed) || ("==ownership" in changed)) && !game.user.isGM ) {
      for ( const token of this.getActiveTokens() ) {
        token.release();
        if ( !token.isOwner ) token.renderFlags.set({redraw: true});
      }
      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 foundry.documents.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 foundry.documents.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 foundry.documents.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 {object} [update={}]                               The update delta
   * @param {Partial<DatabaseUpdateOperation>} [options={}]    The database operation that was performed
   * @protected
   */
  _updateDependentTokens(update={}, options={}) {
    for ( const token of this.getDependentTokens() ) {
      token._onUpdateBaseActor(update, options);
    }
  }
};

/**
 * @import {AdventureImportData, AdventureImportOptions, AdventureImportResult} from "./_types.mjs";
 */

/**
 * The client-side Adventure document which extends the common {@link foundry.documents.BaseAdventure} model.
 *
 * ### Hook Events
 * - {@link hookEvents.preImportAdventure} (emitted by {@link Adventure#import})
 * - {@link hookEvents.importAdventure} (emitted by {@link Adventure#import})
 *
 * @extends BaseAdventure
 * @mixes ClientDocumentMixin
 * @category Documents
 */
let Adventure$1 = class Adventure extends ClientDocumentMixin(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 {AdventureImportOptions} [options]    Options which configure and customize the import process
   * @returns {Promise<AdventureImportResult>}    The import result
   */
  async import(options={}) {
    options.dialog ??= true;
    options.importFields ??= [];
    options.preImport ??= [];
    options.postImport ??= [];
    const sheet = /** @type {AdventureImporter|foundry.applications.sheets.AdventureImporterV2} */ this.sheet;

    // Prepare import data
    await sheet._configureImport?.(options);
    const importData = await this.prepareImport(options);

    // Allow modules to preprocess adventure data or to intercept the import process
    const allowed = Hooks$1.call("preImportAdventure", this, options, importData.toCreate, importData.toUpdate);
    if ( allowed === false ) {
      console.log(`"${this.name}" Adventure import was prevented by the "preImportAdventure" hook`);
      return {created: [], updated: []};
    }

    // Invoke custom preImport workflows
    for ( const fn of options.preImport ) {
      await fn.call(this, importData, options);
    }
    await sheet._preImport?.(importData, options);

    // Warn the user if the import operation will overwrite existing World content
    if ( !foundry.utils.isEmpty(importData.toUpdate) && options.dialog ) {
      const alert = game.i18n.localize("Warning");
      const warning = game.i18n.format("ADVENTURE.ImportOverwriteWarning", {name: this.name});
      const confirm = await foundry.applications.api.DialogV2.confirm({
        window: {title: "ADVENTURE.ImportOverwriteTitle"},
        position: {width: 480},
        content: `<p><strong>${alert}:</strong> ${warning}</p>`
      });
      if ( !confirm ) return {created: [], updated: []};
    }

    // Perform the import
    const result = await this.importContent(importData);

    // Allow modules to perform additional post-import workflows
    Hooks$1.callAll("importAdventure", this, options, result.created, result.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);

    // Invoke custom preImport workflows
    for ( const fn of options.postImport ) {
      await fn.call(this, result, options);
    }
    await sheet._onImport?.(result, options);

    // Perform an extra refresh of the sidebar (all tabs) since many documents have changed
    await ui.sidebar.render();
    return result;
  }

  /* -------------------------------------------- */

  /**
   * Prepare Adventure data for import into the World.
   * @param {AdventureImportOptions} [options]  Options which configure import behavior
   * @returns {Promise<AdventureImportData>}
   */
  async prepareImport(options) {
    const importFields = new Set(options.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 = {};
    const bar = ui.notifications.info("ADVENTURE.ImportProgress", {localize: true, progress: true});

    // Create new documents
    let nImported = 0;
    for ( const [documentName, createData] of Object.entries(toCreate) ) {
      const cls = getDocumentClass$1(documentName);
      const docs = await cls.createDocuments(createData, {
        keepId: true,       // Keep adventure document IDs
        render: false,      // Do not re-render related applications
        renderSheet: false  // Do not render new sheets
      });
      created[documentName] = docs;
      nImported += docs.length;
      bar.update({pct: nImported / documentCount});
    }

    // Update existing documents
    for ( const [documentName, updateData] of Object.entries(toUpdate) ) {
      const cls = getDocumentClass$1(documentName);
      const docs = await cls.updateDocuments(updateData, {
        diff: false,
        recursive: false,
        noHook: true,
        render: false      // Do not re-render related applications
      });
      updated[documentName] = docs;
      nImported += docs.length;
      bar.update({pct: nImported / documentCount});
    }
    bar.update({pct: 1});
    return {created, updated};
  }
};

/**
 * @import Card from "./card.mjs";
 * @import ChatMessage from "./chat-message.mjs";
 */

/**
 * The client-side Cards document which extends the common BaseCards model.
 * Each Cards document contains CardsData which defines its data schema.
 *
 * ### Hook Events
 * - {@link hookEvents.dealCards}
 * - {@link hookEvents.passCards}
 * - {@link hookEvents.returnCards}
 *
 * @extends BaseCards
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.CardStacks}: The world-level collection of Cards documents
 * @see {@link foundry.applications.sheets.CardsConfig}: The Cards configuration application
 */
let Cards$1 = class Cards extends ClientDocumentMixin(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("TYPES.Cards.deck");
      case "hand": return game.i18n.localize("TYPES.Cards.hand");
      case "pile": return game.i18n.localize("TYPES.Cards.pile");
      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$1.call("dealCards", this, to, {
      action: action,
      toCreate: toCreate,
      fromUpdate: toUpdate,
      fromDelete: toDelete
    });
    if ( allowed === false ) {
      console.debug(`${CONST.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 = [];
    let maxSort = to.cards.size ? Math.max(...to.cards.contents.map(c => c.sort || 0)) : 0;

    // Validate the provided cards
    for ( const 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;
        if ( !copyCard ) {
          maxSort += CONST.SORT_INTEGER_DENSITY;
          createData.sort = maxSort;
        }
        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$1.call("passCards", this, to, {action, toCreate, toUpdate, fromUpdate, fromDelete});
    if ( allowed === false ) {
      console.debug(`${CONST.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.
   */
  async #resetDeck({updateData={}, chatNotification=true}={}) {

    // Recover all cards which belong to this stack
    for ( const cards of game.cards ) {
      if ( cards === this ) continue;
      const toDelete = [];
      for ( const 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.
   */
  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$1.call("returnCards", this, fromDelete.map(id => this.cards.get(id)), {toUpdate, fromDelete});
    if ( allowed === false ) {
      console.debug(`${CONST.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
    const 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
   */
  #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="${foundry.utils.escapeHTML(source.thumbnail)}" alt="${foundry.utils.escapeHTML(source.name)}">
        <p>${game.i18n.format(action, context)}</p>
      </div>`
    };
    foundry.documents.ChatMessage.implementation.applyRollMode(messageData, "roll");
    return foundry.documents.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 */
  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 foundry.applications.handlebars.renderTemplate("templates/cards/dialog-deal.hbs", {
      hands,
      modes: {
        [CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
        [CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
        [CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
      }
    });
    const content = document.createElement("div");
    content.innerHTML = html;

    // Display the prompt
    return foundry.applications.api.DialogV2.prompt({
      window: {title: "CARDS.DealTitle"},
      content,
      ok: {
        label: "CARDS.ACTIONS.Deal",
        callback: (event, button) => {
          const fd = new foundry.applications.ux.FormDataExtended(button.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;
          });
        }
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * 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 foundry.applications.handlebars.renderTemplate("templates/cards/dialog-draw.hbs", {
      decks,
      modes: {
        [CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
        [CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
        [CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
      }
    });
    const content = document.createElement("div");
    content.innerHTML = html;

    // Display the prompt
    return foundry.applications.api.DialogV2.prompt({
      window: {title: "CARDS.DrawTitle"},
      content,
      ok: {
        label: "CARDS.ACTIONS.Draw",
        callback: (event, button) => {
          const fd = new foundry.applications.ux.FormDataExtended(button.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 [];
          });
        }
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * 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 foundry.applications.handlebars.renderTemplate("templates/cards/dialog-pass.hbs", {
      cards,
      modes: {
        [CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
        [CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
        [CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
      }
    });
    const content = document.createElement("div");
    content.innerHTML = html;

    // Display the prompt
    return foundry.applications.api.DialogV2.prompt({
      window: {title: "CARDS.PassTitle"},
      content,
      ok: {
        label: "CARDS.ACTIONS.Pass",
        callback: (event, button) => {
          const fd = new foundry.applications.ux.FormDataExtended(button.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;
          });
        }
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * 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 foundry.applications.handlebars.renderTemplate("templates/cards/dialog-play.hbs", {card, cards});
    const content = document.createElement("div");
    content.innerHTML = html;

    // Display the prompt
    return foundry.applications.api.DialogV2.prompt({
      window: {title: "CARD.Play"},
      content,
      ok: {
        label: "CARD.Play",
        callback: (event, button) => {
          const fd = new foundry.applications.ux.FormDataExtended(button.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 [];
          });
        }
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Display a confirmation dialog for whether the user wishes to reset a Cards stack
   * @see {@link Cards#recall}
   * @returns {Promise<Cards|false|null>}
   */
  async resetDialog() {
    const action = this.type === "deck" ? "Reset" : "Return";
    return foundry.applications.api.DialogV2.confirm({
      window: {title: "CARDS.ACTIONS.Reset"},
      content: `<p>${game.i18n.format(`CARDS.${action}Confirm`, {name: foundry.utils.escapeHTML(this.name)})}</p>`,
      yes: {callback: () => this.recall()}
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async deleteDialog(options={}) {
    if ( !this.drawnCards.length ) return super.deleteDialog(options);
    const positionKeys = ["top", "left", "width", "height", "scale", "zIndex"];
    if ( positionKeys.some(k => k in options) ) {
      foundry.utils.logCompatibilityWarning(
        "options is now an object containing entries supported by DialogV2.confirm.",
        {since: 13, until: 15}
      );
      options.position = positionKeys.reduce((position, key) => {
        if ( options[key] !== undefined ) position[key] = options[key];
        delete options[key];
        return position;
      }, {});
    }
    const type = this.typeLabel;
    const title = `${game.i18n.format("DOCUMENT.Delete", {type})}: ${this.name}`;
    return foundry.applications.api.DialogV2.wait(foundry.utils.mergeObject({
      window: {title}, // FIXME: double localization
      content: `
        <strong>${game.i18n.localize("CARDS.DeleteCannot")}</strong>
        <p>${game.i18n.format("CARDS.DeleteMustReset", {type})}</p>
      `,
      buttons: [{
        action: "reset",
        label: "CARDS.ACTIONS.ResetDelete",
        icon: "fa-solid fa-arrow-rotate-left",
        callback: () => this.delete(),
        default: true
      }, {
        action: "cancel",
        label: "Cancel",
        icon: "fa-solid fa-xmark",
        callback: () => false
      }]
    }, options));
  }

  /* -------------------------------------------- */

  /** @override */
  static async createDialog(data={}, createOptions={}, {folders, types, template, context, ...dialogOptions}={}) {
    const applicationOptions = {
      top: "position", left: "position", width: "position", height: "position", scale: "position", zIndex: "position",
      title: "window", id: "", classes: "", jQuery: ""
    };

    for ( const [k, v] of Object.entries(createOptions) ) {
      if ( k in applicationOptions ) {
        foundry.utils.logCompatibilityWarning("The ClientDocument.createDialog signature has changed. "
          + "It now accepts database operation options in its second parameter, "
          + "and options for DialogV2.prompt in its third parameter.", { since: 13, until: 15, once: true });
        const dialogOption = applicationOptions[k];
        if ( dialogOption ) foundry.utils.setProperty(dialogOptions, `${dialogOption}.${k}`, v);
        else dialogOptions[k] = v;
        delete createOptions[k];
      }
    }

    const {parent=null, pack=null} = createOptions;

    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);
    }
    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
    template ??= "templates/sidebar/cards-create.html";
    const html = await foundry.applications.handlebars.renderTemplate(template, {
      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,
      ...context
    });
    const content = document.createElement("div");
    content.innerHTML = html;

    // Render the confirmation dialog window
    return foundry.applications.api.DialogV2.prompt(foundry.utils.mergeObject({
      window: {title}, // FIXME: double localization
      content,
      render: (event, dialog) => {
        dialog.element.querySelector('[name="type"]').addEventListener("change", e => {
          dialog.element.querySelector('[name="name"]').placeholder = this.implementation.defaultName(
            {type: e.target.value, parent, pack});
        });
      },
      ok: {
        label: title, // FIXME: double localization
        callback: async (event, button) => {
          const fd = new foundry.applications.ux.FormDataExtended(button.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});
        }
      }
    }, dialogOptions));
  }
};

/**
 * @import Messages from "./collections/chat-messages.mjs";
 * @import {ChatSpeakerData} from "@common/documents/_types.mjs";
 * @import User from "./user.mjs";
 * @import {Roll} from "../dice/_module.mjs";
 * @import Actor from "./actor.mjs";
 * @import Scene from "./scene.mjs";
 * @import TokenDocument from "./token.mjs";
 */

/**
 * The client-side ChatMessage document which extends the common BaseChatMessage model.
 *
 * ### Hook Events
 * - {@link hookEvents.renderChatMessageHTML}
 *
 * @extends BaseChatMessage
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.ChatMessages}: The world-level collection of ChatMessage
 *   documents
 *
 * @property {Roll[]} rolls             The prepared array of Roll instances
 */
let ChatMessage$1 = class ChatMessage extends ClientDocumentMixin(BaseChatMessage) {

  /**
   * 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 authorName = this.author?.name ?? game.i18n.localize("CHAT.UnknownUser");
    if ( this.style === CHAT_MESSAGE_STYLES.OOC ) return authorName;
    const speakerAlias = this.speaker.alias || null;
    return speakerAlias ?? this.speakerActor?.name ?? authorName;
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * The Actor which represents the speaker of this message (if any).
   * @type {Actor|null}
   */
  get speakerActor() {
    return this.constructor.getSpeakerActor(this.speaker) ?? this.author?.character ?? null;
  }

  /* -------------------------------------------- */
  /*  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$1.onError("ChatMessage#rolls", err, {rollData, log: "error"});
      }
      return rolls;
    }, []);
  }

  /* -------------------------------------------- */

  /**
   * Transform a provided object of ChatMessage data by applying a certain roll mode to the data object.
   *  - Public: `whisper` is set to `[]` and `blind` is set to `false`.
   *  - Self: `whisper` is set to `[game.user.id]` and `blind` is set to `false`.
   *  - Private: `whisper` is set to the GM users unless `whisper` is nonempty and `blind` is set to `false`.
   *  - Blind: `whisper` is set to the GM users unless `whisper` is nonempty and `blind` is set to `true`.
   * @param {object} chatData     The object of ChatMessage data
   * @param {"roll"|"publicroll"|"gmroll"|"blindroll"|"selfroll"} rollMode
   *   The roll mode to apply to this message data. `"roll"` is the current roll mode.
   * @returns {object}            The modified ChatMessage data with the roll mode applied
   */
  static applyRollMode(chatData, rollMode) {
    const modes = DICE_ROLL_MODES;
    if ( rollMode === "roll" ) rollMode = game.settings.get("core", "rollMode");
    let whisper;

    // Public roll
    if ( rollMode === modes.PUBLIC ) whisper = [];

    // Self roll
    else if ( rollMode === modes.SELF ) whisper = [game.user.id];

    // Don't override existing whisper recipients in the case of private and blind rolls
    else if ( chatData.whisper?.length ) whisper = chatData.whisper;

    // Private or blind roll
    else whisper = game.users.filter(u => u.isGM).map(u => u.id);

    chatData.whisper = whisper;
    chatData.blind = rollMode === modes.BLIND;
    return chatData;
  }

  /* -------------------------------------------- */

  /**
   * Update the data of a ChatMessage instance to apply a requested roll mode.
   * This function calls {@link ChatMessage.applyRollMode} and updates the source of the ChatMessage.
   * @param {"roll"|"publicroll"|"gmroll"|"blindroll"|"selfroll"} rollMode
   *   The roll mode to apply to this message data. `"roll"` is the current roll mode.
   */
  applyRollMode(rollMode) {
    const data = this.toObject();
    this.constructor.applyRollMode(data, rollMode);
    this.updateSource(data);
  }

  /* -------------------------------------------- */

  /**
   * 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 {ChatSpeakerData}             The identified speaker data
   */
  static getSpeaker({scene, actor, token, alias}={}) {

    // CASE 1 - A Token is explicitly provided
    const hasToken = (token instanceof foundry.canvas.placeables.Token)
      || (token instanceof foundry.documents.TokenDocument);
    if ( hasToken ) return ChatMessage.#getSpeakerFromToken({token, alias});
    const hasActor = actor instanceof foundry.documents.Actor;
    if ( hasActor && actor.isToken ) return ChatMessage.#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 ChatMessage.#getSpeakerFromActor({scene, actor, alias});
      const controlled = tokens.filter(t => t.controlled);
      token = controlled.length ? controlled.shift() : tokens.shift();
      return ChatMessage.#getSpeakerFromToken({token: token.document, alias});
    }

    // CASE 3 - Not the viewed Scene
    else if ( ( scene instanceof foundry.documents.Scene ) && !scene.isView ) {
      const char = game.user.character;
      if ( char ) return ChatMessage.#getSpeakerFromActor({scene, actor: char, alias});
      return ChatMessage.#getSpeakerFromUser({scene, user: game.user, alias});
    }

    // CASE 4 - Infer from controlled tokens
    if ( canvas.ready ) {
      const controlled = canvas.tokens.controlled;
      if (controlled.length) return ChatMessage.#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 ChatMessage.#getSpeakerFromToken({token: tokens.shift(), alias});
      return ChatMessage.#getSpeakerFromActor({actor: char, alias});
    }

    // CASE 6 - From the alias and User
    return ChatMessage.#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 {ChatSpeakerData}                  The identified speaker data
   */
  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 {ChatSpeakerData}                 The identified speaker data
   */
  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 {ChatSpeakerData}                 The identified speaker data
   */
  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() {
    return this.speakerActor?.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
   * @param {object} [options]             Additional options passed to the Handlebars template.
   * @param {boolean} [options.canDelete]  Render a delete button. By default, this is true for GM users.
   * @param {boolean} [options.canClose]   Render a close button for dismissing chat card notifications.
   * @returns {Promise<HTMLElement>}
   */
  async renderHTML({ canDelete, canClose=false, ...rest }={}) {
    canDelete ??= game.user.isGM; // By default, GM users have the trash-bin icon in the chat log itself

    if ( typeof this.system.renderHTML === "function" ) {
      const html = await this.system.renderHTML({ canDelete, canClose, ...rest });
      Hooks$1.callAll("renderChatMessageHTML", this, html);
      return html;
    }

    // Determine some metadata
    const speakerActor = this.style === CHAT_MESSAGE_STYLES.OOC ? null : this.speakerActor;
    const data = this.toObject(false);
    data.content = await TextEditor$1.implementation.enrichHTML(this.content, {rollData: this.getRollData(),
      secrets: speakerActor?.isOwner ?? game.user.isGM});
    const isWhisper = this.whisper.length;

    // Construct message data
    const messageData = {
      ...rest,
      canDelete, canClose,
      message: data,
      user: game.user,
      author: this.author,
      speakerActor,
      alias: this.alias,
      cssClass: [
        this.style === CHAT_MESSAGE_STYLES.IC ? "ic" : null,
        this.style === CHAT_MESSAGE_STYLES.EMOTE ? "emote" : null,
        isWhisper ? "whisper" : null,
        this.blind ? "blind": null
      ].filterJoin(" "),
      isWhisper: this.whisper.length,
      whisperTo: this.whisper.map(u => game.users.get(u)?.name).filterJoin(", ")
    };

    // Render message data specifically for ROLL type messages
    if ( this.isRoll ) await this.#renderRollContent(messageData);

    // Define a border color
    if ( this.style === CHAT_MESSAGE_STYLES.OOC ) messageData.borderColor = this.author?.color.css;

    // Render the chat message
    let html = await foundry.applications.handlebars.renderTemplate(CONFIG.ChatMessage.template, messageData);
    html = foundry.utils.parseHTML(html);

    // Flag expanded state of dice rolls
    Hooks$1.callAll("renderChatMessageHTML", this, html, messageData);

    /** @deprecated since v13 */
    if ( "renderChatMessage" in Hooks$1.events ) {
      foundry.utils.logCompatibilityWarning("The renderChatMessage hook is deprecated. Please use "
        + "renderChatMessageHTML instead, which now passes an HTMLElement argument instead of jQuery.",
      { since: 13, until: 15, once: true });
      Hooks$1.callAll("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<void>}
   */
  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: foundry.utils.escapeHTML(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
   */
  async #renderRollHTML(isPrivate) {
    let html = "";
    for ( const roll of this.rolls ) {
      html += await roll.render({isPrivate, message: this});
    }
    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$1.implementation.enrichHTML(expression, {
          documents: false,
          secrets: false,
          links: false,
          rolls: true,
          custom: false,
          rollData: this.getRollData()
        }));
      }
      this.updateSource({content});
    }
    if ( this.isRoll ) {
      if ( !("sound" in data) ) this.updateSource({sound: CONFIG.sounds.dice});
      if ( options.rollMode ) this.applyRollMode(options.rollMode);
    }
  }

  /* -------------------------------------------- */

  /** @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 article = document.createElement("article");
      article.innerHTML = this.content;
      let message = "";
      const buildMessage = element => {
        if ( element.tagName === "BR" ) {
          message += "\n";
          return;
        }
        for ( const child of element.childNodes ) {
          if ( child.nodeType === Node.ELEMENT_NODE ) {
            if ( child.tagName === "DIV" ) message += "\n";
            buildMessage(child);
            if ( child.tagName === "DIV" ) message += "\n";
          } else {
            message += (child.textContent?.replace(/\s+/g, " ") ?? "");
          }
        }
      };
      buildMessage(article);
      content = message.trim().split(/\n+/).map(l => l.replace(/\s+/g, " ").trim()).filter(s => s !== "");
    }

    // 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")}`;
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @ignore
   * @deprecated since v13
   */
  async getHTML(options) {
    foundry.utils.logCompatibilityWarning("ChatMessage#getHTML is deprecated. Please use ChatMessage#renderHTML "
      + "instead, which now returns an HTMLElement instead of a jQuery object.", { since: 13, until: 15, once: true });
    return $(await this.renderHTML(options));
  }
};

/**
 * @import Combatant from "./combatant.mjs";
 * @import Actor from "./actor.mjs";
 * @import TokenDocument from "./token.mjs";
 * @import User from "./user.mjs";
 * @import {CombatHistoryData, CombatRoundEventContext, CombatTurnEventContext} from "./_types.mjs";
 * @import {DatabaseDeleteOperation, DatabaseUpdateOperation} from "@common/abstract/_types.mjs";
 */

/**
 * The client-side Combat document which extends the common BaseCombat model.
 *
 * ### Hook Events
 * - {@link hookEvents.combatRound}
 * - {@link hookEvents.combatStart}
 * - {@link hookEvents.combatTurn}
 * - {@link hookEvents.combatTurnChange}
 *
 * @extends BaseCombat
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.CombatEncounters}: The world-level collection of Combat
 *   documents
 * @see {@link Combatant}: The Combatant embedded document which exists within a Combat
 *   document
 * @see {@link foundry.applications.sidebar.tabs.CombatTracker}: The CombatTracker application
 * @see {@link foundry.applications.apps.CombatTrackerConfig}: The CombatTracker configuration
 *   application
 */
let Combat$1 = class Combat extends ClientDocumentMixin(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|null}
   */
  get combatant() {
    return this.turn !== null ? this.turns[this.turn] : null;
  }

  /* -------------------------------------------- */

  /**
   * 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 foundry.documents.collections.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;
  }

  /**
   * Is this Combat currently being viewed?
   * @type {boolean}
   */
  get isView() {
    return this.collection.viewed === this;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * A convenience alias for updating this document to become active.
   * @param {Partial<DatabaseUpdateOperation>} [options] Additional context to customize the update workflow
   * @returns {Promise<this>}
   */
  async activate(options) {
    return this.update({active: true}, 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 foundry.documents.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 foundry.documents.Actor;
    if ( isActor && actor.isToken ) return this.getCombatantsByToken(actor.token);
    const actorId = isActor ? actor.id : actor;
    return this.combatants.filter(c => c.actorId === actorId);
  }

  /* -------------------------------------------- */

  /**
   * Calculate the time delta between two turns.
   * @param {number} fromRound        The from-round
   * @param {number|null} fromTurn    The from-turn
   * @param {number} toRound          The to-round
   * @param {number|null} toTurn      The to-turn
   * @returns {number}                The time delta
   */
  getTimeDelta(fromRound, fromTurn, toRound, toTurn) {
    const rounds = Math.max(toRound, 1) - Math.max(fromRound, 1);
    let turns = Math.max(rounds - 1, 0) * this.turns.length;
    if ( fromRound < toRound ) {
      turns += (toTurn === null ? 0 : toTurn);
      if ( fromRound > 0 ) turns += (fromTurn === null ? 0 : this.turns.length - fromTurn);
    } else if ( fromRound > toRound ) {
      turns -= (fromTurn === null ? 0 : fromTurn + 1);
      if ( fromRound > 0 ) turns -= (toTurn === null ? 0 : this.turns.length - (toTurn + 1));
    }
    else turns += (toTurn - fromTurn);
    return (rounds * CONFIG.time.roundTime) + (turns * CONFIG.time.turnTime);
  }

  /* -------------------------------------------- */

  /**
   * Begin the combat encounter, advancing to round 1 and turn 1
   * @returns {Promise<this>}
   */
  async startCombat() {
    this._playCombatSound("startEncounter");
    const updateData = {round: 1, turn: 0};
    Hooks$1.callAll("combatStart", this, updateData);
    await this.update(updateData);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Advance the combat to the next round
   * @returns {Promise<this>}
   */
  async nextRound() {
    let turn = (this.turn === null) || (this.turns.length === 0) ? 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;
      }
    }
    const nextRound = this.round + 1;
    const advanceTime = this.getTimeDelta(this.round, this.turn, nextRound, turn);

    // Update the document, passing data through a hook first
    const updateData = {round: nextRound, turn};
    const updateOptions = {direction: 1, worldTime: {delta: advanceTime}};
    Hooks$1.callAll("combatRound", this, updateData, updateOptions);
    await this.update(updateData, updateOptions);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Rewind the combat to the previous round
   * @returns {Promise<this>}
   */
  async previousRound() {
    if ( this.round === 0 ) return this;
    const turn = (this.round === 1) || (this.turn === null) || (this.turns.length === 0) ? null : this.turns.length - 1;
    const previousRound = this.round - 1;
    const advanceTime = this.getTimeDelta(this.round, this.turn, previousRound, turn);

    // Update the document, passing data through a hook first
    const updateData = {round: previousRound, turn};
    const updateOptions = {direction: -1, worldTime: {delta: advanceTime}};
    Hooks$1.callAll("combatRound", this, updateData, updateOptions);
    await this.update(updateData, updateOptions);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Advance the combat to the next turn
   * @returns {Promise<this>}
   */
  async nextTurn() {
    if ( this.round === 0 ) return this.nextRound();

    const turn = this.turn ?? -1;

    // Determine the next turn number
    let nextTurn = null;
    if ( this.settings.skipDefeated ) {
      for ( let i = turn + 1; i < this.turns.length; i++ ) {
        if ( !this.turns[i].isDefeated ) {
          nextTurn = i;
          break;
        }
      }
    }
    else nextTurn = turn + 1;

    // Maybe advance to the next round
    if ( (nextTurn === null) || (nextTurn >= this.turns.length) ) return this.nextRound();

    const advanceTime = this.getTimeDelta(this.round, this.turn, this.round, nextTurn);

    // Update the document, passing data through a hook first
    const updateData = {round: this.round, turn: nextTurn};
    const updateOptions = {direction: 1, worldTime: {delta: advanceTime}};
    Hooks$1.callAll("combatTurn", this, updateData, updateOptions);
    await this.update(updateData, updateOptions);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Rewind the combat to the previous turn
   * @returns {Promise<this>}
   */
  async previousTurn() {
    if ( this.round === 0 ) return this;
    if ( (this.turn === 0) || (this.turns.length === 0) ) return this.previousRound();
    const previousTurn = (this.turn ?? this.turns.length) - 1;
    const advanceTime = this.getTimeDelta(this.round, this.turn, this.round, previousTurn);

    // Update the document, passing data through a hook first
    const updateData = {round: this.round, turn: previousTurn};
    const updateOptions = {direction: -1, worldTime: {delta: advanceTime}};
    Hooks$1.callAll("combatTurn", this, updateData, updateOptions);
    await this.update(updateData, updateOptions);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Display a dialog querying the GM whether they wish to end the combat encounter and empty the tracker
   * @returns {Promise<this>}
   */
  async endCombat() {
    await foundry.applications.api.DialogV2.confirm({
      window: {title: "COMBAT.EndTitle"},
      content: `<p>${game.i18n.localize("COMBAT.EndConfirmation")}</p>`,
      yes: {callback: () => this.delete()},
      modal: true
    });
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Toggle whether this combat is linked to the scene or globally available.
   * @returns {Promise<this>}
   */
  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;
    }
    await this.update({scene});
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Reset all combatant initiative scores.
   * @param {object} [options={}]                   Additional options
   * @param {boolean} [options.updateTurn=true]     Update the Combat turn after resetting initiative scores to
   *                                                keep the turn on the same Combatant.
   * @returns {Promise<this>}
   */
  async resetAll({updateTurn=true}={}) {
    const currentId = this.combatant?.id;
    for ( const c of this.combatants ) c.updateSource({initiative: null});
    this.setupTurns();
    const update = {combatants: this.combatants.toObject()};
    if ( updateTurn && currentId ) update.turn = this.turns.findIndex(t => t.id === currentId);
    await this.update(update, {turnEvents: false, 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<this>}       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 chatRollMode = game.settings.get("core", "rollMode");

    // Iterate over Combatants, performing an initiative roll for each
    const updates = [];
    const messages = [];
    for ( const [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});

      // If the combatant is hidden, use a private roll unless an alternative rollMode was explicitly requested
      const rollMode = "rollMode" in messageOptions ? messageOptions.rollMode
        : (combatant.hidden ? CONST.DICE_ROLL_MODES.PRIVATE : chatRollMode);

      // Construct chat message data
      const messageData = foundry.utils.mergeObject({
        speaker: foundry.documents.ChatMessage.implementation.getSpeaker({
          actor: combatant.actor,
          token: combatant.token,
          alias: combatant.name
        }),
        flavor: game.i18n.format("COMBAT.RollsInitiative", {name: foundry.utils.escapeHTML(combatant.name)}),
        flags: {"core.initiativeRoll": true}
      }, messageOptions);
      const chatData = await roll.toMessage(messageData, {rollMode, create: false});

      // Play 1 sound for the whole rolled set
      if ( i > 0 ) chatData.sound = null;
      messages.push(chatData);
    }
    if ( !updates.length ) return this;

    // Update combatants and combat turn
    const updateOptions = { turnEvents: false };
    if ( !updateTurn ) updateOptions.combatTurn = this.turn;
    await this.updateEmbeddedDocuments("Combatant", updates, updateOptions);

    // Create multiple chat messages
    await foundry.documents.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
   * @returns {Promise<this>}
   */
  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
   * @returns {Promise<this>}
   */
  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 ) {
      if ( this.turn < 0 ) this.turn = 0;
      else if ( this.turn >= turns.length ) {
        this.turn = 0;
        this.round++;
      }
    }

    // Update state tracking
    const 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 ( this.isView ) 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();
  }

  /* -------------------------------------------- */

  /**
   * Clear the movement history of all Tokens within this Combat.
   * @overload
   * @returns {Promise<void>}
   */
  /**
   * Clear the movement history of the Combatants' Tokens.
   * @overload
   * @param {Iterable<Combatant>} combatants    The combatants whose movement history is cleared
   * @returns {Promise<void>}
   */
  async clearMovementHistories(combatants) {
    combatants ??= this.combatants;
    const tokensByScene = new Map();
    for ( const combatant of combatants ) {
      if ( combatant.parent !== this ) throw Error("Combatant must be in this Combat");
      const token = combatant.token;
      if ( !token || (token._source._movementHistory.length === 0) ) continue;
      const scene = token.parent;
      let tokens = tokensByScene.get(scene);
      if ( !tokens ) tokensByScene.set(scene, tokens = new Set());
      tokens.add(token);
    }
    const promises = [];
    for ( const [scene, tokens] of tokensByScene.entries() ) {
      promises.push(scene.updateEmbeddedDocuments("Token", Array.from(tokens, t => ({_id: t.id})),
        {diff: false, noHook: true, _clearMovementHistory: true}));
    }
    await Promise.all(promises);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( !this.collection.viewed && this.collection.combats.includes(this) ) ui.combat.viewed = this;
    if ( game.user.isActiveGM ) {
      for ( const combatant of this.combatants ) this.#onEnter(combatant);
    }
    this._manageTurnEvents();
    this._updateTurnMarkers();
  }

  /* -------------------------------------------- */

  /** @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
    const wasActivated = changed.active === true;
    if ( wasActivated && this.isActive ) ui.combat.render({combat: this});
    else if ( "scene" in changed ) ui.combat.render({combat: null});

    // Refresh token combat markers
    if ( stateChanged || (wasActivated && this.isView) ) this._updateTurnMarkers();

    // Trigger combat sound cues in the active encounter
    if ( this.active && stateChanged && 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.isView ) ui.combat.render({ combat: null });
    if ( userId === game.userId ) this.collection.viewed?.activate();
    this.turn = null;
    this._updateTurnMarkers();
    if ( game.user.isActiveGM ) {
      for ( const combatant of this.combatants ) this.#onExit(combatant);
    }
  }

  /* -------------------------------------------- */
  /*  Combatant Management Workflows              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
    super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
    if ( collection !== "combatants" ) return;
    if ( game.user.isActiveGM ) {
      for ( const combatant of documents ) this.#onEnter(combatant);
    }
    this.#onModifyCombatants(parent, documents, options);
    if ( this.started ) this._updateTurnMarkers();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
    if ( collection !== "combatants" ) return;
    this.#onModifyCombatants(parent, documents, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
    super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
    if ( collection !== "combatants" ) return;
    if ( game.user.isActiveGM ) {
      for ( const combatant of documents ) this.#onExit(combatant);
    }
    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();

    // Additional actions if the turn order changed
    const turnChange = this.#recordPreviousState(priorState);
    if ( turnChange ) {
      if ( turnEvents !== false ) this._manageTurnEvents();
      this._updateTurnMarkers();
    }
    if ( (ui.combat.viewed === parent) && (render !== false) ) ui.combat.render();
  }

  /* -------------------------------------------- */

  /**
   * This workflow occurs after a Combatant is added to the Combat.
   * 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 that entered the Combat
   * @returns {Promise<void>}
   */
  async #onEnter(combatant) {
    if ( CONFIG.debug.combat ) console.debug(` | Combat Enter: ${combatant.name}`);
    await this._onEnter(combatant);
  }

  /* -------------------------------------------- */

  /**
   * This workflow occurs after a Combatant is added to the Combat.
   * This can be overridden to implement system-specific combat tracking behaviors.
   * The default implementation of this function does nothing.
   * 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 that entered the Combat
   * @returns {Promise<void>}
   * @protected
   */
  async _onEnter(combatant) {}

  /* -------------------------------------------- */

  /**
   * This workflow occurs after a Combatant is removed from the Combat.
   * 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 that exited the Combat
   * @returns {Promise<void>}
   */
  async #onExit(combatant) {
    if ( CONFIG.debug.combat ) console.debug(` | Combat Exit: ${combatant.name}`);
    await this._onExit(combatant);
    if ( combatant.token ) await this._clearMovementHistoryOnExit(combatant);
  }

  /* -------------------------------------------- */

  /**
   * This workflow occurs after a Combatant is removed from the Combat.
   * This can be overridden to implement system-specific combat tracking behaviors.
   * The default implementation of this function does nothing.
   * 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 that exited the Combat
   * @returns {Promise<void>}
   * @protected
   */
  async _onExit(combatant) {}

  /* -------------------------------------------- */

  /**
   * Called after {@link Combat#_onExit} and takes care of clearing the movement history of the
   * Combatant's Token.
   * This function is not called for Combatants that don't have a Token.
   * The default implementation clears the movement history always.
   * @param {Combatant} combatant    The Combatant that exited the Combat
   * @returns {Promise<void>}
   * @protected
   */
  async _clearMovementHistoryOnExit(combatant) {
    await combatant.token.clearMovementHistory();
  }

  /* -------------------------------------------- */

  /**
   * 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, turn, combatantId} = this.current;
    const turnChange = (combatantId !== priorState.combatantId) || (round !== priorState.round)
      || (turn !== priorState.turn);
    Object.assign(this.previous, priorState);
    return turnChange;
  }

  /* -------------------------------------------- */

  /**
   * Update display of Token combat turn markers.
   * @protected
   */
  _updateTurnMarkers() {
    if ( !canvas.ready ) return;
    const currentToken = this.combatant?.token?._object;
    for ( const token of canvas.tokens.turnMarkers ) {
      if ( token !== currentToken ) token.renderFlags.set({refreshTurnMarker: true});
    }
    if ( this.isView && currentToken ) currentToken.renderFlags.set({refreshTurnMarker: true});
  }

  /* -------------------------------------------- */
  /*  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;

    // Capture current and previous states
    const {current, previous} = this;

    // Gamemaster handling only
    if ( game.user.isActiveGM ) await this.#triggerTurnEvents();

    // Hooks handled by all clients
    Hooks$1.callAll("combatTurnChange", this, previous, current);
  }

  /* -------------------------------------------- */

  /**
   * Trigger round/turn events.
   * @returns {Promise<void>}
   */
  async #triggerTurnEvents() {
    const {turns, current, previous} = this;
    const intervals = [];
    let roundDelta = current.round - previous.round;

    // Add intervals for turn advancement within the current round
    if ( roundDelta === 0 ) {
      if ( (current.round > 0) && (previous.turn < current.turn) ) intervals.push([previous.turn + 1, current.turn]);
    }

    // Add intervals for round advancement
    else if ( roundDelta > 0 ) {
      if ( previous.round > 0 ) intervals.push([previous.turn + 1, turns.length - 1]);
      while ( --roundDelta ) intervals.push([0, turns.length - 1]);
      intervals.push([0, current.turn ?? 0]);
    }

    // Dispatch events when either the round or turn progressed
    if ( intervals.length > 0 ) {
      let prior = {
        combatant: this.combatants.get(previous.combatantId) ?? null,
        round: previous.round,
        turn: previous.turn,
        skipped: false
      };
      for ( const [from, to] of intervals ) {
        for ( let turn = from; turn <= to; turn++ ) {
          const round = prior.round + (turn === 0);
          const next = {
            combatant: turns[turn],
            round,
            turn,
            skipped: (round !== current.round) || (turn !== current.turn)
          };
          if ( prior.combatant ) {
            await this.#onEndTurn(prior.combatant, {round: prior.round, turn: prior.turn, skipped: prior.skipped});
          }
          if ( prior.round !== next.round ) {
            await this.#onEndRound({round: prior.round, skipped: prior.round !== previous.round});
            await this.#onStartRound({round: next.round, skipped: next.round !== current.round});
          }
          if ( next.combatant ) {
            await this.#onStartTurn(next.combatant, {round: next.round, turn: next.turn, skipped: next.skipped});
          }
          prior = next;
        }
      }
    }

    // Dispatch events when the turn order is changed
    else {
      const changeCombatant = (current.combatantId !== previous.combatantId)
        && (current.round === previous.round) && (current.turn === previous.turn);
      if ( changeCombatant ) {
        const prior = this.combatants.get(previous.combatantId);
        const next = this.combatant;
        if ( prior ) await this.#onEndTurn(prior, { round: current.round, turn: current.turn, skipped: false });
        if ( next ) await this.#onStartTurn(next, { round: current.round, turn: current.turn, skipped: false });
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * A workflow that occurs at the end of each Combat Turn.
   * This workflow occurs after the Combat document update.
   * 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
   * @param {CombatTurnEventContext} context    The context of the turn that just ended
   * @returns {Promise<void>}
   */
  async #onEndTurn(combatant, context) {
    if ( CONFIG.debug.combat ) console.debug(` | Combat End Turn: ${combatant.name}`);
    await this._onEndTurn(combatant, context);
    // noinspection ES6MissingAwait
    this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_TURN_END, context, [combatant]);
  }

  /* -------------------------------------------- */

  /**
   * A workflow that occurs at the end of each Combat Turn.
   * This workflow occurs after the Combat document update.
   * This can be overridden to implement system-specific combat tracking behaviors.
   * The default implementation of this function does nothing.
   * 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
   * @param {CombatTurnEventContext} context    The context of the turn that just ended
   * @returns {Promise<void>}
   * @protected
   */
  async _onEndTurn(combatant, context) {}

  /* -------------------------------------------- */

  /**
   * A workflow that occurs at the end of each Combat Round.
   * This workflow occurs after the Combat document update.
   * This method only executes for one designated GM user. If no GM users are present this method will not be called.
   * @param {CombatRoundEventContext} context    The context of the round that just ended
   * @returns {Promise<void>}
   */
  async #onEndRound(context) {
    if ( CONFIG.debug.combat ) console.debug(` | Combat End Round: ${context.round}`);
    await this._onEndRound(context);
    // noinspection ES6MissingAwait
    this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_ROUND_END, context, this.combatants);
  }

  /* -------------------------------------------- */

  /**
   * A workflow that occurs at the end of each Combat Round.
   * This workflow occurs after the Combat document update.
   * This can be overridden to implement system-specific combat tracking behaviors.
   * The default implementation of this function does nothing.
   * This method only executes for one designated GM user. If no GM users are present this method will not be called.
   * @param {CombatRoundEventContext} context    The context of the round that just ended
   * @returns {Promise<void>}
   * @protected
   */
  async _onEndRound(context) {}

  /* -------------------------------------------- */

  /**
   * A workflow that occurs at the start of each Combat Round.
   * This workflow occurs after the Combat document update.
   * This method only executes for one designated GM user. If no GM users are present this method will not be called.
   * @param {CombatRoundEventContext} context    The context of the round that just started
   * @returns {Promise<void>}
   */
  async #onStartRound(context) {
    if ( CONFIG.debug.combat ) console.debug(` | Combat Start Round: ${context.round}`);
    await this._onStartRound(context);
    // noinspection ES6MissingAwait
    this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_ROUND_START, context, this.combatants);
  }

  /* -------------------------------------------- */

  /**
   * A workflow that occurs at the start of each Combat Round.
   * This workflow occurs after the Combat document update.
   * This can be overridden to implement system-specific combat tracking behaviors.
   * The default implementation of this function does nothing.
   * This method only executes for one designated GM user. If no GM users are present this method will not be called.
   * @param {CombatRoundEventContext} context    The context of the round that just started
   * @returns {Promise<void>}
   * @protected
   */
  async _onStartRound(context) {}

  /* -------------------------------------------- */

  /**
   * A workflow that occurs at the start of each Combat Turn.
   * This workflow occurs after the Combat document update.
   * 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
   * @param {CombatTurnEventContext} context    The context of the turn that just started
   * @returns {Promise<void>}
   */
  async #onStartTurn(combatant, context) {
    if ( CONFIG.debug.combat ) console.debug(` | Combat Start Turn: ${combatant.name}`);
    await this._onStartTurn(combatant, context);
    if ( combatant.token ) await this._clearMovementHistoryOnStartTurn(combatant, context);
    // noinspection ES6MissingAwait
    this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_TURN_START, context, [combatant]);
  }

  /* -------------------------------------------- */

  /**
   * A workflow that occurs at the start of each Combat Turn.
   * This workflow occurs after the Combat document update.
   * This can be overridden to implement system-specific combat tracking behaviors.
   * The default implementation of this function does nothing.
   * 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
   * @param {CombatTurnEventContext} context    The context of the turn that just started
   * @returns {Promise<void>}
   * @protected
   */
  async _onStartTurn(combatant, context) {}

  /* -------------------------------------------- */

  /**
   * Called after {@link Combat#_onStartTurn} and takes care of clearing the movement history of the
   * Combatant's Token.
   * This function is not called for Combatants that don't have a Token.
   * The default implementation clears the movement history always.
   * @param {Combatant} combatant               The Combatant whose turn just started
   * @param {CombatTurnEventContext} context    The context of the turn that just started
   * @returns {Promise<void>}
   * @protected
   */
  async _clearMovementHistoryOnStartTurn(combatant, context) {
    await combatant.token.clearMovementHistory();
  }

  /* -------------------------------------------- */

  /**
   * Trigger Region events for Combat events.
   * @param {string} eventName                  The event name
   * @param {object & {token: never, combatant: never, combat: never}} eventData
   *                                            The event data (without `token`, `combatant`, and `combat`)
   * @param {Iterable<Combatant>} combatants    The combatants to trigger the event for
   * @returns {Promise<void>}
   */
  async #triggerRegionEvents(eventName, eventData, 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, combat: this, ...eventData}));
      }
    }
    await Promise.allSettled(promises);
  }

  /* -------------------------------------------- */

  /**
   * When Tokens are deleted, handle actions to update/delete Combatants of these Tokens.
   * @param {TokenDocument[]} tokens               An array of Tokens which have been deleted
   * @param {DatabaseDeleteOperation} operation    The operation that deleted the Tokens
   * @param {User} user                            The User that deleted the Tokens
   * @internal
   */
  static _onDeleteTokens(tokens, operation, user) {
    if ( operation.pack ) return;
    const sceneId = operation.parent.id;

    // Prepare Combatant updates and deletions
    const modifications = [];
    for ( const token of tokens ) {
      const replacement = operation.replacements?.[token.id];
      if ( replacement ) {
        const {primaryId: newSceneId, id: newTokenId} = foundry.utils.parseUuid(replacement);
        modifications.push([token.id, newSceneId, newTokenId]);
      }
      else modifications.push([token.id]);
    }

    // Iterate over all potentially relevant Combat encounters
    for ( const combat of game.combats ) {

      // Skip Combats that cannot contain the deleted Tokens
      if ( (combat.scene !== null) && (combat.scene !== sceneId) ) continue;

      // Determine necessary Combatant updates and deletions
      const updates = [];
      const deletions = [];
      for ( const combatant of combat.combatants ) {
        for ( const [tokenId, newSceneId, newTokenId] of modifications ) {
          if ( (combatant.sceneId === sceneId) && (combatant.tokenId === tokenId) ) {

            // Token was replaced
            if ( newTokenId ) updates.push({_id: combatant.id, sceneId: newSceneId, tokenId: newTokenId});

            // Token was deleted
            else deletions.push(combatant.id);
          }
        }
      }

      // Perform necessary Combatant updates and deletions
      if ( updates.length ) combat.updateEmbeddedDocuments("Combatant", updates);
      if ( deletions.length ) combat.deleteEmbeddedDocuments("Combatant", deletions);
    }
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @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 FogExploration document which extends the common BaseFogExploration model.
 * @extends BaseFogExploration
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.FogExplorations}: The world-level collection of
 *   FogExploration documents
 */
let FogExploration$1 = class FogExploration extends ClientDocumentMixin(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;
    user ??= game.user;
    if ( !sceneId || !user ) return null;
    if ( !(game.user.isGM || user.isSelf) ) {
      throw new Error("You do not have permission to access the FogExploration object of another user");
    }
    let exploration;

    // Return cached exploration
    if ( user.isSelf ) {
      exploration = collection.find(x => (x.user === user) && (x.scene === scene));
      if ( exploration ) return exploration;
    }

    // Return persisted exploration
    const query = {scene: sceneId, user: user.id};
    const response = await this.database.get(this, {query, ...options});
    exploration = response.length ? response.shift() : null;
    if ( exploration && user.isSelf ) 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, {alphaMode: PIXI.ALPHA_MODES.NPM});
    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();
  }

  /* -------------------------------------------- */
  /*  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);
  }
};

/**
 * @import WorldCollection from "./abstract/world-collection.mjs";
 * @import Collection from "@common/utils/collection.mjs";
 * @import CompendiumCollection from "./collections/compendium-collection.mjs";
 * @import {FolderChildNode} from "./_types.mjs";
 */

/**
 * The client-side Folder document which extends the common BaseFolder model.
 * @extends BaseFolder
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.Folders}: The world-level collection of Folder documents
 * @see {@link foundry.applications.sheets.FolderConfig}: The Folder configuration application
 */
let Folder$1 = class Folder extends ClientDocumentMixin(BaseFolder) {

  /**
   * The depth of this folder in its sidebar tree
   * @type {number}
   */
  depth;

  /**
   * An array of nodes representing the children of this one. This differs from the results of
   * {@link Folder.getSubfolders}, which reports the subset of child Folders displayed to the current User in the UI.
   * @type {FolderChildNode[]}
   */
  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];
  }

  /* -------------------------------------------- */

  /** @override */
  get inCompendium() {
    return !!game.packs.get(this.pack)?.folders.has(this.id);
  }

  /* -------------------------------------------- */
  /*  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={}, createOptions={}, dialogOptions={}) {
    const applicationOptions = {
      top: "position", left: "position", width: "position", height: "position", scale: "position", zIndex: "position",
      title: "window", id: "", classes: ""
    };
    for ( const [k, v] of Object.entries(createOptions) ) {
      if ( k in applicationOptions ) {
        foundry.utils.logCompatibilityWarning("The Folder.createDialog signature has changed. "
          + "It now accepts database operation options in its second parameter, "
          + "and FolderConfig options in its third parameter.", {since: 13, until: 15, once: true});
        const dialogOption = applicationOptions[k];
        if ( dialogOption ) foundry.utils.setProperty(dialogOptions, `${dialogOption}.${k}`, v);
        else dialogOptions[k] = v;
        delete createOptions[k];
      }
    }
    const folder = new Folder.implementation(foundry.utils.mergeObject({
      name: Folder.implementation.defaultName({pack: createOptions.pack}),
      sorting: "a"
    }, data), createOptions);
    return new Promise(resolve => {
      dialogOptions.document = folder;
      dialogOptions.resolve = resolve;
      new foundry.applications.sheets.FolderConfig(dialogOptions).render({force: 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 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 document
   * @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("FOLDER.Exporting", {format: {compendium: pack.collection,
      type: game.i18n.localize(getDocumentClass$1(this.type).metadata.labelPlural)}});
    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
     */
    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 ( const 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 foundry.documents.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 ( const 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("FOLDER.ExportDone", {format: {compendium: pack.collection,
      type: game.i18n.localize(getDocumentClass$1(this.type).metadata.labelPlural)}});
    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|null} pack                      A pack ID to set as the default choice in the select input
   * @param {object} [options]                      Additional options which customize how content is exported
   * @param {boolean} [options.merge=true]          Update existing entries in the Compendium pack, matching by name
   * @param {boolean} [options.keepId=true]         Retain the original _id attribute when updating an document
   * @param {boolean} [options.keepFolders=true]    Retain the existing Folder structure
   * @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("FOLDER.ExportWarningNone", {format: {
        type: game.i18n.localize(getDocumentClass$1(this.type).metadata.label)
      }});
    }

    // Render the HTML form
    const html = await foundry.applications.handlebars.renderTemplate("templates/sidebar/apps/folder-export.hbs", {
      packs: packs.reduce((obj, p) => {
        obj[p.collection] = p.title;
        return obj;
      }, {}),
      pack,
      merge: options.merge ?? true,
      keepId: options.keepId ?? true,
      keepFolders: options.keepFolders ?? true,
      hasFolders: pack?.folders?.length ?? false,
      folders: pack?.folders?.map(f => ({id: f.id, name: f.name})) || []
    });
    const content = document.createElement("div");
    content.innerHTML = html;

    // Display it as a dialog prompt
    return foundry.applications.sidebar.apps.FolderExport.prompt({
      window: {title: `${game.i18n.localize("FOLDER.ExportTitle")}: ${this.name}`}, // FIXME: double localization
      content,
      ok: {
        label: "FOLDER.ExportTitle",
        callback: (event, button) => {
          const form = button.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
          });
        }
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * 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 ( const 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() {
    const folders = [];
    let parent = this.folder;
    while ( parent ) {
      folders.push(parent);
      parent = parent.folder;
    }
    return folders;
  }
};

/**
 * @import Actor from "./actor.mjs";
 * @import ActiveEffect from "./active-effect.mjs";
 */

/**
 * The client-side Item document which extends the common BaseItem model.
 * @extends BaseItem
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.Items}: The world-level collection of Item documents
 * @see {@link foundry.applications.sheets.ItemSheet}: The Item configuration application
 */
let Item$1 = class Item extends ClientDocumentMixin(BaseItem) {

  /**
   * A convenience alias of Item#parent which is more semantically intuitive
   * @type {Actor|null}
   */
  get actor() {
    return this.parent instanceof foundry.documents.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 foundry.documents.Actor) || (this.parent instanceof foundry.documents.ActorDelta))
      && !CONFIG.ActiveEffect.legacyTransferral ) {
      for ( const effect of this.effects ) {
        if ( effect.transfer ) effect.updateSource(foundry.documents.ActiveEffect.implementation.getInitialDuration());
      }
    }
    return super._preCreate(data, options, user);
  }

  /* -------------------------------------------- */

  /** @override */
  static async _onCreateOperation(documents, operation, user) {
    const actor = operation.parent;
    if ( !((actor instanceof foundry.documents.Actor) || (actor instanceof foundry.documents.ActorDelta))
      || !CONFIG.ActiveEffect.legacyTransferral || !user.isSelf ) return;
    const cls = foundry.utils.getDocumentClass("ActiveEffect");

    // Create effect data
    const toCreate = [];
    for ( const item of documents ) {
      for ( const effect of item.effects ) {
        if ( !effect.transfer ) continue;
        const effectData = effect.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;
    if ( !((actor instanceof foundry.documents.Actor) || (actor instanceof foundry.documents.ActorDelta))
      || !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;
    getDocumentClass$1("ActiveEffect").deleteDocuments(toDelete, operation);
  }
};

/**
 * @import Note from "@client/canvas/placeables/note.mjs";
 * @import JournalEntryCategory from "./journal-entry-category.mjs";
 */

/**
 * The client-side JournalEntry document which extends the common BaseJournalEntry model.
 * @extends BaseJournalEntry
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.Journal}: The world-level collection of JournalEntry documents
 * @see {@link foundry.applications.sheets.journal.JournalEntrySheet}: The JournalEntry sheet
 *   application
 */
let JournalEntry$1 = class JournalEntry extends ClientDocumentMixin(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.inCompendium && (this.collection.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
   */
  async show(force=false) {
    return foundry.documents.collections.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", "==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 ( const n of canvas.notes.placeables ) {
      if ( n.document.entryId === this.id ) n.draw();
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * A sorting comparator for JournalEntryCategory documents.
   * @param {JournalEntryCategory} a
   * @param {JournalEntryCategory} b
   * @returns {number}                An integer in the range [-1, 1].
   */
  static sortCategories(a, b) {
    return a.sort - b.sort;
  }
};

/**
 * @import User from "./user.mjs";
 * @import ChatMessage from "./chat-message.mjs";
 * @import {RegionEvent} from "@client/documents/_types.mjs";
 */

/**
 * The client-side Macro document which extends the common BaseMacro model.
 * @extends BaseMacro
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.Macros}: The world-level collection of Macro documents
 * @see {@link foundry.applications.sheets.MacroConfig}: The Macro configuration application
 */
let Macro$1 = class Macro extends ClientDocumentMixin(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) {
    return this.testUserPermission(user, "LIMITED");
  }

  /* -------------------------------------------- */

  /**
   * 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 promise containing a created {@link foundry.documents.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).
   */
  async #executeChat(speaker) {
    return ui.chat.processMessage(this.command, {speaker}).catch(err => {
      Hooks$1.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 || foundry.documents.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
    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});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( (userId === game.user.id) && (typeof options.hotbarSlot === "number") ) {
      game.user.assignHotbarMacro(this, options.hotbarSlot);
    }
  }
};

/**
 * The client-side Playlist document which extends the common BasePlaylist model.
 * @extends BasePlaylist
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.Playlists}: The world-level collection of Playlist documents
 * @see {@link foundry.documents.PlaylistSound}: The PlaylistSound embedded document within a parent
 *   Playlist
 * @see {@link foundry.applications.sheets.PlaylistConfig}: The Playlist configuration application
 */
let Playlist$1 = class Playlist extends ClientDocumentMixin(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 PLAYLIST_MODES.SHUFFLE: {
        const ids = this.sounds.map(s => s.id);
        const mt = new foundry.dice.MersenneTwister(this.seed ?? 0);
        const 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 foundry.documents.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 PLAYLIST_MODES.DISABLED:
        updateData.playing = false;
        break;

      // Sequential or Shuffled Playback
      case PLAYLIST_MODES.SEQUENTIAL:
      case 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 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<this|null|undefined>} If successfully updated, this Playlist document
   */
  async playNext(soundId, {direction=1}={}) {
    if ( ![PLAYLIST_MODES.SEQUENTIAL, 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 => ({_id: s.id, playing: s.id === next?.id, pausedTime: null}));
    const updateOptions = next?.id === soundId ? {diff: false, render: false, forceSync: true} : undefined;
    return this.update({sounds}, updateOptions);
  }

  /* -------------------------------------------- */

  /**
   * 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 PLAYLIST_MODES.SEQUENTIAL:
      case PLAYLIST_MODES.SHUFFLE:
        updates.sounds = this.sounds.map(s => {
          const 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
   * @returns {Promise<Playlist>}   A promise which resolves to the updated Playlist instance
   */
  async cycleMode() {
    const modes = Object.values(PLAYLIST_MODES);
    let mode = this.mode + 1;
    mode = mode > Math.max(...modes) ? modes[0] : mode;
    for ( const 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.
   * @param {number} soundId
   * @protected
   */
  _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.
   * @param {number} soundId
   * @protected
   */
  _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.
   * @param {Sound} a
   * @param {Sound} b
   * @protected
   */
  _sortSounds(a, b) {
    switch ( this.sorting ) {
      case PLAYLIST_SORT_MODES.ALPHABETICAL: return a.name.compare(b.name);
      case 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 ( "channel" in changed ) {
      changed.playing = false;
      const sounds = (changed.sounds ??= this.sounds.map(s => ({_id: s._id})));
      for ( const sound of sounds ) {
        sound.playing = false;
        sound.pausedTime = null;
      }
    }
    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 ( game.audio.locked ) return;

    if ( "channel" in changed ) {
      for ( const s of this.sounds ) {
        if ( s.sound ) s.sound.stop();
        s.sound = s._createSound();
        s.sync();
      }
    }
    else if ( (("sounds" in changed) || (options.forceSync === true)) && !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);
    this.#playbackOrder = undefined;
    if ( options.render !== false ) this.collection.render();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
    if ( (collection === "sounds") && changes.some(c => "sort" in c) ) this.#playbackOrder = undefined;
    if ( options.render !== false ) this.collection.render();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
    super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
    this.#playbackOrder = undefined;
    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 PLAYLIST_MODES.SEQUENTIAL:
      case PLAYLIST_MODES.SHUFFLE:
        return this.playNext(sound.id);
      case PLAYLIST_MODES.SIMULTANEOUS:
      case PLAYLIST_MODES.DISABLED: {
        const updates = {playing: true, sounds: [{_id: sound.id, playing: false, pausedTime: null}]};
        for ( const 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 ( ![PLAYLIST_MODES.SEQUENTIAL, PLAYLIST_MODES.SHUFFLE].includes(this.mode) ) return;
    const apl = CONFIG.Playlist.autoPreloadSeconds;
    if ( !Number.isNumeric(apl) || !Number.isFinite(sound.sound.duration) ) return;

    // If active timeout, clear it
    clearTimeout(sound._preloadTimer);

    // Clamp to 0 to avoid negative values
    const delayMS = Math.max(0, (sound.sound.duration - apl) * 1000);

    // Launch the preload timeout
    sound._preloadTimer = setTimeout(() => {
      if ( !sound.playing ) return;
      this._getNextSound(sound.id)?.load();
    }, delayMS);
  }

  /* -------------------------------------------- */

  /**
   * 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                     */
  /* -------------------------------------------- */

  /**
   * Spawn a dialog for bulk importing sound files into a playlist.
   * @returns {Promise<boolean>}  Returns true if any sound files were successfully imported.
   */
  bulkImportDialog() {
    const filePicker = new foundry.applications.elements.HTMLFilePickerElement();
    Object.assign(filePicker, { type: "folder", noupload: true });
    return foundry.applications.api.DialogV2.prompt({
      window: {
        title: "PLAYLIST.BulkImport.Title",
        icon: "fa-solid fa-files"
      },
      content: `<p>${game.i18n.localize("PLAYLIST.BulkImport.Hint")}</p>${filePicker.outerHTML}`,
      ok: {
        label: "PLAYLIST.BulkImport.Button",
        callback: async (event, button) => {
          const picker = button.form.querySelector("file-picker").picker;
          const result = (await picker.browse(picker.target, { type: "audio", render: false })).result;
          const paths = result?.files ?? [];
          if ( !paths.length ) return false;
          const created = await this.bulkImportSounds(paths);
          if ( created.length ) return true;
          ui.notifications.warn("PLAYLIST.BulkImport.Warning", { format: { path: picker.target } });
          return false;
        }
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Create PlaylistSounds in this Playlist from the given file paths.
   * @param {string[]} paths  File paths to import.
   * @returns {Promise<PlaylistSound[]>}
   */
  async bulkImportSounds(paths) {
    const currentSources = new Set(this.sounds.map(s => s.path));
    const toCreate = paths.reduce((list, path) => {
      if ( !foundry.audio.AudioHelper.hasAudioExtension(path) || currentSources.has(path) ) return list;
      list.push({ path, name: foundry.audio.AudioHelper.getDefaultSoundName(path) });
      return list;
    }, []);
    return this.createEmbeddedDocuments("PlaylistSound", toCreate);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  toCompendium(pack, options={}) {
    const data = super.toCompendium(pack, options);
    if ( options.clearState ) {
      data.playing = false;
      for ( const s of data.sounds ) {
        s.playing = false;
      }
    }
    return data;
  }
};

/**
 * @import {CanvasViewPosition, User} from "@client/_types.mjs";
 * @import {SceneDimensions} from "./_types.mjs";
 * @import TokenDocument from "./token.mjs";
 */

/**
 * The client-side Scene document which extends the common BaseScene model.
 * @extends BaseScene
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.Scenes}: The world-level collection of Scene documents
 * @see {@link foundry.applications.sheets.SceneConfig}: The Scene configuration application
 */
let Scene$1 = class Scene extends ClientDocumentMixin(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}
   * @internal
   */
  _viewPosition = {};

  /**
   * Track whether the scene is the active view
   * @type {boolean}
   * @internal
   */
  _view = this.active;

  /**
   * The grid instance.
   * @type {BaseGrid}
   */
  grid = this.grid; // Workaround for subclass property instantiation issue.

  /**
   * Determine the canvas dimensions this Scene would occupy, if rendered
   * @type {SceneDimensions}
   */
  dimensions = this.dimensions; // Workaround for subclass property instantiation issue.

  /* -------------------------------------------- */
  /*  Scene Properties                            */
  /* -------------------------------------------- */

  /**
   * Provide a thumbnail image path used to represent this document.
   * @type {string|null}
   */
  get thumbnail() {
    return this.thumb;
  }

  /* -------------------------------------------- */

  /**
   * A convenience accessor for whether the Scene is currently viewed
   * @type {boolean}
   */
  get isView() {
    return this._view;
  }

  /* -------------------------------------------- */
  /*  Scene Methods                               */
  /* -------------------------------------------- */

  /**
   * Pull the specified users to this Scene.
   * @param {(User|string)[]} [users=[]]  An array of User documents or IDs.
   */
  pullUsers(users=[]) {
    if ( !game.user.isGM ) throw new Error("You must be a GM to pull players to a scene");
    if ( !users.length ) throw new Error("You must pass at least one User document or ID");

    for ( let user of users ) {
      if ( typeof user === "string" ) user = game.users.get(user);
      if ( !user?.active ) continue;
      game.socket.emit("pullToScene", this.id, user.id);
    }
  }

  /* -------------------------------------------- */

  /**
   * 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 ( const 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("INFO.SceneViewCanvasDisabled", {format: {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.render({ combat: null });
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Unview the current Scene, clearing the game canvas.
   */
  async unview() {
    if ( !this._view ) return;
    this._view = false;

    // Re-draw the canvas with a blank scene
    await canvas.draw(null);

    // Render apps for the collection
    this.collection.render();
    ui.combat.render({ combat: null });
    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);
      })
      .catch(err => {
        if ( err.cause?.thumbUploadDenied ) {
          ui.notifications.warn("SCENE.GenerateThumbUploadDenied", {localize: true});
          createData.thumb = this.thumbnail; // Reusing the existing thumb image if any exist
        }
        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 {BaseGrid}
   */
  static #getGrid(scene) {
    const grid = scene.grid;
    if ( grid instanceof 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);
      config.diagonals = game.settings.get("core", "gridDiagonals");
      if ( scene.flags.core?.legacyHex ) config.size *= (Math.SQRT3 / 2);
      return new foundry.grid.HexagonalGrid(config);
    }

    throw new Error("Invalid grid type");
  }

  /* -------------------------------------------- */

  /**
   * 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: new PIXI.Rectangle(0, 0, width, height),
      sceneX, sceneY, sceneWidth, sceneHeight,
      sceneRect: new PIXI.Rectangle(sceneX, sceneY, sceneWidth, 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);
  }

  /* -------------------------------------------- */

  /**
   * Clear the movement history of all Tokens within this Scene.
   * @returns {Promise<void>}
   */
  async clearMovementHistories() {
    const updates = this.tokens.reduce((arr, t) => {
      if ( t._source._movementHistory.length !== 0 ) arr.push({_id: t.id});
      return arr;
    }, []);
    await this.updateEmbeddedDocuments("Token", updates, {diff: false, noHook: true, _clearMovementHistory: true});
  }

  /* -------------------------------------------- */

  /**
   * For all Tokens in this Scene identify the Regions that each Token is contained in and update the regions of each
   * Token accordingly.
   *
   * This function doesn't need to be called by the systems/modules unless
   * {@link foundry.documents.TokenDocument#testInsideRegion} is overridden and non-Token properties other than
   * `Scene#grid.type` and `Scene#grid.size` change that are used in the override of
   * {@link foundry.documents.TokenDocument#testInsideRegion}.
   * @overload
   * @returns {Promise<TokenDocument[]>}        The array of Tokens whose regions changed
   */
  /**
   * For the given Tokens in this Scene identify the Regions that each Token is contained in and update the regions of
   * each Token accordingly.
   *
   * This function doesn't need to be called by the systems/modules unless
   * {@link foundry.documents.TokenDocument#testInsideRegion} is overridden and non-Token properties other than
   * `Scene#grid.type` and `Scene#grid.size` change that are used in the override of
   * {@link foundry.documents.TokenDocument#testInsideRegion}.
   * @overload
   * @param {Iterable<TokenDocument>} tokens    The Tokens whoses regions should be updates
   * @returns {Promise<TokenDocument[]>}        The array of Tokens whose regions changed
   */
  async updateTokenRegions(tokens) {
    const updates = [];
    for ( const token of tokens ?? this.tokens ) {
      if ( token.parent !== this ) throw new Error("Token must be in this Scene");
      updates.push({_id: token.id, _regions: token._identifyRegions()});
    }
    return this.updateEmbeddedDocuments("Token", updates, {noHook: true});
  }

  /* -------------------------------------------- */
  /*  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 ) {
      try {
        const t = await this.createThumbnail({img: this.background.src});
        this.updateSource({thumb: t.thumb});
      }
      catch(err) {
        if ( err.cause?.thumbUploadDenied ) {
          ui.notifications.warn("SCENE.GenerateThumbUploadDenied", {localize: true});
        }
      }
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static async _preCreateOperation(documents, operation, user) {
    if ( game.scenes.active ) operation.priorActiveScene = game.scenes.active.id;
    else if ( documents.every(s => !s.active) ) {
      const candidate = documents.find((s, i) => !("active" in operation.data[i]));
      candidate?.updateSource({ active: true }); // Set a scene as active if none currently are.
    }
  }

  /* -------------------------------------------- */

  /** @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_ACTIVATED, data: {}, region, user});
      /** @deprecated since v13 */
      region._handleEvent({name: "behaviorStatus", data: {active: true}, region, user});
    }

    // Activate the new Scene
    if ( data.active === true ) {
      this._onActivate(true);
      if ( game.userId === userId ) {
        // noinspection ES6MissingAwait
        game.playlists._onChangeScene(this, game.scenes.get(options.priorActiveScene));
      }
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    const allowed = await super._preUpdate(changed, options, user);
    if ( allowed === false ) return false;

    // Record the prior active scene for later use
    if ( "active" in changed ) options.priorActiveScene = game.scenes.active?.id;

    // 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);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle repositioning of placed objects when the Scene dimensions change
   * @param {object} sceneUpdateData
   * @returns {object}
   */
  #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("SCENE.GridSizeError"));
      foundry.utils.setProperty(sceneUpdateData, "grid.size", gridSize);
    }

    const 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 ( const 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 ( const 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));
      const fontSize = Math.clamp(Math.round(p.fontSize * averageTranslationScale), 8, 128);
      return {_id: p.id, x, y, iconSize, fontSize};
    });

    // 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);
      const 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;
  }

  /* -------------------------------------------- */

  /** @override */
  static async _onUpdateOperation(documents, operation, user) {
    await super._onUpdateOperation(documents, operation, user);

    // Only activate or deactivate a single Scene per operation
    let nowActive;
    let nowInactive;
    for ( const change of operation.updates ) {
      if ( change.active === true ) {
        nowActive = documents.find(d => d._id === change._id);
        break;
      } else if ( (change.active === false) && (change._id === operation.priorActiveScene) ) {
        nowInactive = documents.find(d => d._id === change._id);
      }
    }
    if ( nowActive ) nowActive._onActivate(true);
    else nowInactive?._onActivate(false);
  }

  /* -------------------------------------------- */

  /** @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"));

    // Change audio
    const audioChange = changed.active || ((changed.active === false) && (this.id === options.priorActiveScene))
      || (this.active && ["playlist", "playlistSound"].some(k => k in changed));
    if ( audioChange && (game.userId === userId) ) {
      // noinspection ES6MissingAwait
      game.playlists._onChangeScene(this, game.scenes.get(options.priorActiveScene));
    }

    // 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)) ) this.updateTokenRegions();

    // If the scene is already active, maybe re-draw the canvas
    if ( canvas.scene === this ) {

      // New initial view position
      const initializeCanvasPosition = ["grid.size", "initial.x", "initial.y", "initial.scale", "width", "height",
        "padding"].some(k => changedKeys.has(k));
      if ( initializeCanvasPosition ) this._viewPosition = {};

      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();
      }

      // Re-initialize canvas position
      if ( initializeCanvasPosition ) canvas.initializeCanvasPosition();

      /**
       * @type {SceneConfig}
       */
      const sheet = this.sheet;
      if ( changedKeys.has("environment.darknessLock") ) {
        // Reset scene controls to omit darkness level transition buttons
        ui.controls.render({reset: true});
        // Live preview if the sheet is rendered (force all)
        if ( sheet?.rendered ) sheet._previewScene("environment.darknessLock", {force: true});
      }
    }
  }

  /* -------------------------------------------- */

  /** @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);
    }

    // Change audio state
    if ( this.active && (game.userId === userId) ) {
      // noinspection ES6MissingAwait
      game.playlists._onChangeScene(null, 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_DEACTIVATED, data: {}, region, user});
      /** @deprecated since v13 */
      region._handleEvent({name: "behaviorStatus", 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) {
    if ( active ) this.view();
    else if ( canvas.initialized && (canvas.id === this.id) ) canvas.draw(null);
  }

  /* -------------------------------------------- */

  /** @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})), options);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preUpdateDescendantDocuments(parent, collection, changes, options, userId) {
    if ( collection === "tokens" ) foundry.documents.TokenDocument._addTeleportAndForcedShims(options);

    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, options);
    }
  }

  /* -------------------------------------------- */

  /** @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, options);
    }
  }

  /* -------------------------------------------- */

  /** @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 ) {
      data.navigation = false;
      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/jpeg, 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("SCENE.GenerateThumbNoCanvas"));
    if ( !game.user.can("FILES_UPLOAD") ) {
      throw new Error(game.i18n.localize("SCENE.GenerateThumbUploadDenied"), {cause: {thumbUploadDenied: true}});
    }

    // Create counter-factual scene data
    img ??= undefined; // Be sure to turn null to undefined
    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 foundry.canvas.TextureLoader.loader.load(toLoad, {message: "SCENE.GenerateThumbProgress", format: {name: this.name}});

    // Update the cloned image with new background image dimensions
    const backgroundTexture = img ? foundry.canvas.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 = foundry.canvas.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 = foundry.canvas.groups.PrimaryCanvasGroup.BACKGROUND_ELEVATION;
      bg.zIndex = -Infinity;
      baseContainer.addChild(bg);
    }

    // Foreground container
    if ( this.foreground ) {
      const fgTex = foundry.canvas.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 ( const 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 foundry.helpers.media.ImageHelper.createThumbnail(stage, {width, height, format, quality});
  }
};

/**
 * The client-side Setting document which extends the common BaseSetting model.
 * @extends BaseSetting
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.WorldSettings}: The world-level collection of Setting
 *   documents
 */
let Setting$1 = class Setting extends ClientDocumentMixin(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, BigInt]);

  /**
   * The setting configuration for this setting document.
   * @type {foundry.applications.settings.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;

    const type = this.config?.type;

    // DataField types
    if ( type instanceof foundry.data.fields.DataField ) {
      return type.initialize(type.clean(this.value));
    }

    // Undefined type stays as a string
    if ( !(type instanceof Function) ) return this.value;

    // DataModel types
    if ( foundry.utils.isSubclass(type, foundry.abstract.DataModel) ) {
      return type.fromSource(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);
    }

    // Constructed types
    const isConstructed = type?.prototype?.constructor === type;
    return isConstructed ? new type(this.value) : type(this.value);
  }
};

/**
 * @import {RollTableDraw} from "./_types.mjs";
 * @import {RollTableHTMLEmbedConfig} from "@client/_types.mjs";
 * @import {Roll} from "../dice/_module.mjs";
 * @import TableResult from "./table-result.mjs";
 */

/**
 * The client-side RollTable document which extends the common BaseRollTable model.
 * @extends BaseRollTable
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.RollTables}: The world-level collection of RollTable documents
 * @see {@link foundry.documents.TableResult}: The embedded TableResult document
 * @see {@link foundry.applications.sheets.RollTableSheet}: The RollTable sheet application
 */
let RollTable$1 = class RollTable extends ClientDocumentMixin(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={}}={}) {
    messageOptions.rollMode ??= game.settings.get("core", "rollMode");

    // Construct chat data
    const flavorKey = `TABLE.DrawFlavor${results.length > 1 ? "Plural" : ""}`;
    messageData = foundry.utils.mergeObject({
      flavor: game.i18n.format(flavorKey, {number: results.length, name: foundry.utils.escapeHTML(this.name)}),
      author: game.user.id,
      speaker: foundry.documents.ChatMessage.implementation.getSpeaker(),
      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
    const detailsPromises = await Promise.allSettled(results.map(r => r.getHTML()));
    messageData.content = await foundry.applications.handlebars.renderTemplate(CONFIG.RollTable.resultTemplate, {
      description: await TextEditor$1.implementation.enrichHTML(this.description, {documents: true,
        secrets: this.isOwner}),
      results: results.map((result, i) => {
        const r = result.toObject(false);
        r.details = detailsPromises[i].value ?? "";
        const useTableIcon = (result.icon === CONFIG.RollTable.resultIcon)
          && (this.img !== this.constructor.DEFAULT_ICON);
        r.icon = useTableIcon ? this.img : result.icon;
        return r;
      }),
      rollHTML: this.displayRoll && roll ? await roll.render() : null,
      table: this
    });

    // Create the chat message
    return foundry.documents.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 = roll ? this.getResultsForRoll(roll.total) : results;
      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++ ) {
      const 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 ( const 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 ) {
      const inner = [];
      for ( const result of results ) {
        const { type, documentUuid } = result;
        const documentName = foundry.utils.parseUuid(documentUuid)?.type;
        if ( (type === "document") && (documentName === "RollTable") ) {
          const innerTable = await fromUuid(documentUuid);
          if ( innerTable ) {
            const innerRoll = await innerTable.roll({_depth: _depth + 1});
            inner.push(...innerRoll.results);
          }
        }
        else inner.push(result);
      }
      results = inner;
    }

    // Return the Roll and the results
    return {roll, results};
  }

  /* -------------------------------------------- */

  /**
   * 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));
  }

  /* -------------------------------------------- */
  /*  Roll Table Embedding                        */
  /* -------------------------------------------- */
  /**
   * 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="fa-solid 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="fa-solid fa-table-list"></i>
   *         Rollable Table
   *     </cite>
   *   </figcaption>
   * </figure>
   * ```
   */
  async _buildEmbedHTML(config, options={}) {
    options = { ...options, relativeTo: this };
    const { rangeLabel, resultLabel } = config;
    const rollable = config.rollable || config.values.includes("rollable");
    const results = this.results.toObject();
    results.sort((a, b) => a.range[0] - b.range[0]);
    const context = {
      rollable,
      labels: {
        range: rangeLabel ?? game.i18n.localize("TABLE_RESULT.FIELDS.range.label"),
        result: resultLabel ?? this.name
      },
      results: await Promise.all(results.map(async ({ range, type, name, description, documentUuid, drawn }) => {
        const [lo, hi] = range;
        const doc = await fromUuid(documentUuid);
        return {
          drawn, type, name,
          range: lo === hi ? lo : `${lo}—${hi}`,
          description: await TextEditor$1.implementation.enrichHTML(description ?? "", {
            ...options, secrets: options.secrets ?? this.isOwner
          }),
          result: doc
            ? doc.toAnchor().outerHTML
            : TextEditor$1.implementation.createAnchor({
              label: name, icon: "fa-solid fa-link-slash", classes: ["content-link", "broken"]
            }).outerHTML
        };
      }))
    };
    const html = await foundry.applications.handlebars.renderTemplate("templates/tables/embed.hbs", context);
    return foundry.utils.parseHTML(html);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _createFigureEmbed(content, config, options) {
    const embed = await super._createFigureEmbed(content, config, options);
    const figure = embed.querySelector("figure.content-embed");
    if ( config.caption && !config.label ) {
      // Add the table description as the caption.
      options = { ...options, relativeTo: this, secrets: options.secrets ?? this.isOwner };
      const description = await TextEditor$1.implementation.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 embed;
  }

  /* -------------------------------------------- */

  /**
   * Handle a roll from within embedded content.
   * @param {PointerEvent} event  The originating event
   * @param {string} action       The named action that was clicked
   * @protected
   */
  async _onClickEmbedAction(event, action) {
    switch ( action ) {
      case "rollTable": {
        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);
        }
        break;
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  onEmbed(element) {
    element.addEventListener("click", event => {
      const target = event.target;
      const actionButton = target.closest("[data-action]");
      if ( actionButton ) return this._onClickEmbedAction(event, actionButton.dataset.action);
    });
  }

  /* -------------------------------------------- */
  /*  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 ( const 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((doc, i) => ({
      name: doc.name,
      type: "document",
      documentUuid: doc.uuid,
      img: doc.thumbnail || doc.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);
  }
};

/**
 * @import TokenDocument from "./token.mjs";
 * @import Macro from "./macro.mjs";
 * @import {ActivityData} from "@client/_types.mjs";
 * @import Users from "./collections/users.mjs";
 */

/**
 * The client-side User document which extends the common BaseUser model.
 * Each User document contains UserData which defines its data schema.
 *
 * @extends BaseUser
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.Users}: The world-level collection of User documents
 * @see {@link foundry.applications.sheets.UserConfig}: The User configuration application
 */
let User$1 = class User extends ClientDocumentMixin(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 foundry.canvas.placeables.tokens.UserTargets(this);

  /**
   * Track the ID of the Scene that is currently being viewed by the User
   * @type {string|null}
   */
  viewedScene = null;

  /**
   * Track the Token documents that this User is currently moving.
   * @type {ReadonlySet<TokenDocument>}
   * @readonly
   */
  movingTokens = new Set();

  /**
   * 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;
  }

  /**
   * Is this User the active GM?
   * @type {boolean}
   */
  get isActiveGM() {
    return this === game.users.activeGM;
  }

  /**
   * A localized label for this User's role.
   * @type {string}
   */
  get roleLabel() {
    const R = CONST.USER_ROLES;
    const label = {
      [R.NONE]: "USER.RoleNone",
      [R.PLAYER]: "USER.RolePlayer",
      [R.TRUSTED]: "USER.RoleTrusted",
      [R.ASSISTANT]: "USER.RoleAssistant",
      [R.GAMEMASTER]: "USER.RoleGamemaster"
    }[this.role];
    return game.i18n.localize(label);
  }

  /**
   * The timestamp of the last observed activity for the user.
   * @type {number}
   */
  get lastActivityTime() {
    return this.#lastActivityTime;
  }

  set lastActivityTime(timestamp) {
    const dt = timestamp - this.#lastActivityTime;
    this.#lastActivityTime = timestamp;
    if ( ui.players && (dt > ui.players.constructor.IDLE_THRESHOLD_MS) ) ui.players.refreshLatency();
  }

  #lastActivityTime = 0;

  /* ---------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.avatar = this.avatar || this.character?.img || CONST.DEFAULT_TOKEN;
    this.border = this.color.multiply(2);
  }

  /* ---------------------------------------- */
  /*  User Methods                            */
  /* ---------------------------------------- */

  /**
   * Is this User the designated User among the Users that satisfy the given condition?
   * This function calls {@link foundry.documents.collections.Users#getDesignatedUser} and compares the designated User
   * to this User.
   * @example
   * // Is the current User the designated User to create Tokens?
   * const isDesignated = game.user.isDesignated(user => user.active && user.can("TOKEN_CREATE"));
   * @param {(user: User) => boolean} condition    The condition the Users must satisfy
   * @returns {boolean}                            Is designated User?
   */
  isDesignated(condition) {
    return this === game.users.getDesignatedUser(condition);
  }

  /* ---------------------------------------- */

  /**
   * Assign a Macro to a numbered hotbar slot between 1 and 50
   * @param {Macro|null} macro          The Macro document to assign
   * @param {number|string|null} [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 foundry.documents.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});
  }

  /* -------------------------------------------- */

  /**
   * 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}={}) {
    if ( !this.isSelf ) throw new Error("You can only broadcast your own User activity data.");
    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 ( const [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.
   * This function handles changes made elsewhere and does not broadcast to other connected clients.
   * @param {string[]} targetIds      An array of Token ids which represents the new target set
   * @internal
   */
  _onUpdateTokenTargets(targetIds=[]) {

    // Clear targets outside the viewed scene
    if ( this.viewedScene !== canvas.scene.id ) {
      for ( const t of this.targets ) t._updateTarget(false, this);
      return;
    }

    // Remove old targets
    const ids = new Set(targetIds);
    for ( const t of this.targets ) {
      if ( !ids.has(t.id) ) t._updateTarget(false, this);
    }

    // Add new targets
    for ( const id of ids ) {
      const token = canvas.tokens.get(id);
      if ( !token || this.targets.has(token) ) continue;
      token._updateTarget(true, this);
    }
  }

  /* -------------------------------------------- */

  /**
   * Query this User.
   * @param {string} queryName                         The query name (must be registered in `CONFIG.queries`)
   * @param {object} queryData                         The query data (must be JSON-serializable)
   * @param {object} [queryOptions]                    The query options
   * @param {number} [queryOptions.timeout]            The timeout in milliseconds
   * @returns {Promise<*>}                             The query result
   */
  async query(queryName, queryData, {timeout}={}) {
    if ( !(queryName in CONFIG.queries) ) throw new Error(`User query '${queryName}' is not registered`);
    if ( !game.user.hasPermission("QUERY_USER") ) throw new Error("You do not have permission to query users");
    if ( !this.active ) throw new Error(`User [${this.id}] is not active`);
    const queryOptions = {timeout};
    const queryId = foundry.utils.randomID();
    if ( CONFIG.debug.queries ) {
      console.debug(` | Sending "${queryName}" query [${queryId}] to User [${this.id}]:`,
        queryData, queryOptions);
    }
    const queryResult = await new Promise(resolve => {
      game.socket.emit("userQuery", this.id, queryId, queryName, queryData, queryOptions, resolve);
    });
    if ( CONFIG.debug.queries ) {
      console.debug(` | Received result of "${queryName}" query [${queryId}] from User [${this.id}]:`,
        queryResult.status === "rejected" ? new Error(queryResult.reason) : queryResult.value);
    }
    if ( queryResult.status === "rejected" ) throw new Error(queryResult.reason);
    return queryResult.value;
  }

  /* -------------------------------------------- */
  /*  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.refresh();
      }
      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);
    const settings = game.settings.storage.get("world");
    for ( const { id, user } of settings ) {
      if ( user === this.id ) settings.delete(id);
    }
    if ( this.id === game.user.id ) return game.logOut();
  }
};

/**
 * @import {ActiveEffectData, ActiveEffectDuration, EffectChangeData} from "@client/documents/_types.mjs";
 * @import {DocumentConstructionContext} from "@common/abstract/_types.mjs";
 * @import Document from "@common/abstract/document.mjs";
 */

/**
 * 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.
 *
 * ### Hook Events
 * - {@link hookEvents.applyActiveEffect}
 *
 * @extends BaseActiveEffect
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Actor}: The Actor document which contains ActiveEffect embedded documents
 * @see {@link foundry.documents.Item}: The Item document which contains ActiveEffect embedded documents
 *
 * @property {ActiveEffectDuration} duration        Expanded effect duration data.
 */
let ActiveEffect$1 = class ActiveEffect extends ClientDocumentMixin(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: _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() {
    if ( this.system instanceof foundry.abstract.TypeDataModel ) return this.system.isSuppressed ?? false;
    return false;
  }

  /* --------------------------------------------- */

  /**
   * Retrieve the Document that this ActiveEffect targets for modification.
   * @type {Document|null}
   */
  get target() {
    if ( this.parent instanceof Actor$1 ) 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 is 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$1;
    return this.target instanceof Actor$1;
  }

  /* --------------------------------------------- */

  /** @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
   * @internal
   */
  _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
   * @internal
   */
  _getDurationLabel(rounds, turns) {
    const parts = [];
    const pluralRules = new Intl.PluralRules(game.i18n.lang);
    if ( rounds > 0 ) {
      const unit = game.i18n.localize(`COMBAT.DURATION.ROUNDS.${pluralRules.select(rounds)}`);
      parts.push(`${rounds} ${unit}`);
    }
    if ( turns > 0 ) {
      const unit = game.i18n.localize(`COMBAT.DURATION.TURNS.${pluralRules.select(turns)}`);
      parts.push(game.i18n.localize(`${turns} ${unit}`));
    }
    else if (( rounds + turns ) === 0 ) parts.push(game.i18n.localize("COMBAT.DURATION.None"));
    return game.i18n.getListFormatter({style: "narrow"}).format(parts);
  }

  /* -------------------------------------------- */

  /**
   * 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 = foundry.utils.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);
    if ( update !== undefined ) foundry.utils.setProperty(model, change.key, update);
    return update;
  }

  /* -------------------------------------------- */

  /**
   * Apply this ActiveEffect to a provided Actor.
   * @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) {
    // 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
    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;
    }
    const 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
   */
  #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
   */
  #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
   */
  #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
   * @protected
   */
  _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;
    }
    if ( update !== current ) 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
   * @protected
   */
  _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;
    }
    if ( update !== current ) 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
   * @protected
   */
  _applyOverride(actor, change, current, delta, changes) {
    if ( delta !== current ) 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
   * @protected
   */
  _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;
    }
    if ( (update !== current) && (update !== undefined) ) 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
   * @protected
   */
  _applyCustom(actor, change, current, delta, changes) {
    const preHook = foundry.utils.getProperty(actor, change.key);
    Hooks$1.call("applyActiveEffect", actor, change, current, delta, changes);
    const postHook = foundry.utils.getProperty(actor, change.key);
    if ( (postHook !== preHook) && (postHook !== undefined) ) 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;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;

    // Set initial duration data for Actor-owned effects
    if ( this.parent instanceof Actor$1 ) {
      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 */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( !(this.target instanceof Actor$1) ) 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 ( const token of tokens ) {
      if ( !token.visible || token.document.isSecret ) continue;
      canvas.interface.createScrollingText(token.center, text, {
        anchor: CONST.TEXT_ANCHOR_POINTS.CENTER,
        direction: enabled ? CONST.TEXT_ANCHOR_POINTS.TOP : CONST.TEXT_ANCHOR_POINTS.BOTTOM,
        distance: (2 * token.h),
        fontSize: 28,
        stroke: 0x000000,
        strokeThickness: 4,
        jitter: 0.25
      });
    }
  }
};

/**
 * The client-side ActorDelta embedded document which extends the common BaseActorDelta document model.
 * @extends BaseActorDelta
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.TokenDocument}: The TokenDocument document type which contains ActorDelta embedded
 *   documents.
 */
let ActorDelta$1 = class ActorDelta extends ClientDocumentMixin(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;
  }

  /** @internal */
  _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);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onSheetChange() {
    // No-op as ActorDeltas do not have sheets.
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareDeltaUpdate(changes={}, options={}) {
    super._prepareDeltaUpdate(changes, options);
    options._deltaModel = this;
  }

  /* -------------------------------------------- */
  /*  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$1.call("preUpdateActor", this.syntheticActor, data, options, user.id));
    if ( allowed === false ) {
      console.debug(`${CONST.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$1.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);
    // Emulate descendant events on the synthetic actor
    if ( !_parent ) {
      const fn = this.syntheticActor[`_${event}DescendantDocuments`];
      fn?.call(this.syntheticActor, this.syntheticActor, collection, ...args);
    }
  }
};

/**
 * @import {CardFaceData} from "@common/documents/_types.mjs";
 * @import Cards from "./cards.mjs";
 */

/**
 * The client-side Card document which extends the common BaseCard document model.
 * @extends BaseCard
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Cards}: The Cards document type which contains Card embedded documents
 * @see {@link foundry.applications.sheets.CardConfig}: The Card configuration application
 */
let Card$1 = class Card extends ClientDocumentMixin(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];
  }

  /* -------------------------------------------- */

  /**
   * @see {@link Card#pass}
   * @inheritDoc
   */
  async play(to, {updateData={}, ...options}={}) {
    const created = await this.parent.pass(to, [this.id], {updateData, action: "play", ...options});
    return created[0];
  }

  /* -------------------------------------------- */

  /**
   * @see {@link 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={}) {
    if ( !("content" in messageData) ) {
      const div = document.createElement("div");
      div.className = "card-draw flexrow";
      const img = document.createElement("img");
      img.className = "card-face";
      img.src = this.img;
      img.alt = this.name;
      div.appendChild(img);
      const h4 = document.createElement("h4");
      h4.className = "card-name";
      h4.innerText = this.name;
      div.appendChild(h4);
      messageData = {...messageData, content: div.outerHTML};
    }
    return ChatMessage$1.implementation.create(messageData, options);
  }
};

/**
 * @import Combat from "./combat.mjs";
 * @import User from "./user.mjs";
 * @import Actor from "./actor.mjs";
 * @import TokenDocument from "./token.mjs";
 * @import {Roll} from "../dice/_module.mjs";
 */

/**
 * The client-side Combatant document which extends the common BaseCombatant model.
 *
 * @extends BaseCombatant
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Combat}: The Combat document which contains Combatant embedded documents
 * @see {@link foundry.applications.sheets.CombatantConfig}: The application which configures a
 *   Combatant
 */
let Combatant$1 = class Combatant extends ClientDocumentMixin(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                                     */
  /* -------------------------------------------- */

  /**
   * 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 foundry.dice.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() {
    const token = this.token;
    this.actorId ??= token?.actorId ?? null;
    const tex = token?.texture.src;
    this._videoSrc = foundry.helpers.media.VideoHelper.hasVideoExtension(tex) ? tex : null;

    // Assign image for combatant (undefined if the token src image is a video)
    this.img ||= (this._videoSrc ? undefined : (token?.texture.src || this.actor?.img));
    this.name ||= token?.name || this.actor?.name || game.i18n.localize("COMBAT.UnknownCombatant");

    this.updateResource();
    this._prepareGroup();
  }

  /* -------------------------------------------- */

  /**
   * 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);
  }

  /* -------------------------------------------- */

  /**
   * Prepare derived data based on group membership.
   * @protected
   */
  _prepareGroup() {
    const group = this.parent.groups.get(this._source.group);
    if ( !group ) return;
    group.members.add(this);
    group.hidden &&= this.hidden;
    group.defeated &&= this.isDefeated;
    if ( Number.isFinite(group.initiative) ) this.initiative = group.initiative;
    this.group = group;
  }

  /* -------------------------------------------- */

  /**
   * Clear the movement history of the Combatant's Token.
   * @returns {Promise<void>}
   */
  async clearMovementHistory() {
    await this.token?.clearMovementHistory();
  }

  /* -------------------------------------------- */
  /*  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);
    operation.turnEvents = false;
  }

  /* -------------------------------------------- */

  /** @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.combatTurn === undefined ) {
      operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0);
      operation.turnEvents = false;
    }
  }

  /* -------------------------------------------- */

  /** @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);
        operation.turnEvents = (survivor === nextSurvivor);
      }
    }

    // Otherwise maintain the same combatant turn
    else {
      operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0);
      operation.turnEvents = false;
    }
  }
};

/**
 * @import Combatant from "./combatant.mjs";
 */

/**
 * The client-side CombatantGroup document which extends the common BaseCombatantGroup model.
 * @extends BaseCombatantGroup
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Combat}: The Combat document which contains Combatant embedded documents
 */
let CombatantGroup$1 = class CombatantGroup extends ClientDocumentMixin(BaseCombatantGroup) {
  /**
   * A group is considered defeated if all its members are defeated, or it has no members.
   * @type {boolean}
   */
  defeated = this.defeated; // Workaround for subclass property instantiation issue.

  /**
   * A group is considered hidden if all its members are hidden, or it has no members.
   * @type {boolean}
   */
  hidden = this.hidden; // Workaround for subclass property instantiation issue.

  /**
   * The Combatant members of this group.
   * @type {Set<Combatant>}
   */
  members = this.members; // Workaround for subclass property instantiation issue.

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    this.hidden = true;
    this.defeated = true;
    if ( this.members ) this.members.clear();
    else this.members = new Set();
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Clear the movement history of all Tokens within this Combatant Group.
   * @returns {Promise<void>}
   */
  async clearMovementHistories() {
    await this.parent.clearMovementHistories(this.members);
  }
};

/**
 * The client-side JournalEntryCategory document which extends the common BaseJournalEntryCategory model.
 * @extends BaseJournalEntryCategory
 * @mixes ClientDocumentMixin
 * @category Documents
 */
let JournalEntryCategory$1 = class JournalEntryCategory extends ClientDocumentMixin(BaseJournalEntryCategory) {
  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.name ||= game.i18n.localize("JOURNAL.UnnamedCategory");
  }
};

/**
 * The client-side PlaylistSound document which extends the common BasePlaylistSound model.
 * Each PlaylistSound belongs to the sounds collection of a Playlist document.
 * @extends BasePlaylistSound
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Playlist}: The Playlist document which contains PlaylistSound embedded
 *   documents
 * @see {@link foundry.applications.sheets.PlaylistSoundConfig}: The PlaylistSound configuration
 *   application
 * @see {@link foundry.audio.Sound}   The Sound API which manages web audio playback
 */
let PlaylistSound$1 = class PlaylistSound extends ClientDocumentMixin(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;

  /**
   * Handle returned by {@link Sound#schedule} for the pending fade-out.
   * @type {Promise|undefined}
   */
  #fadeHandle;

  /**
   * A debounced function, accepting a single volume parameter to adjust the volume of this sound
   * @type {(volume: number) => void}
   */
  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-in length:
   * - If the track is not decoded yet, just honor the configured value.
   * - Once we know the real duration, cap the fade to half duration of the track.
   * @type {number}
   */
  get fadeDuration() {
    const fade = this.fade ?? this.parent?.fade ?? 0;
    if ( fade <= 0 ) return 0;

    const soundDuration = this.sound?.duration;
    if ( !Number.isFinite(soundDuration) ) return fade;

    const half = Math.ceil(soundDuration / 2) * 1000;
    return Math.clamp(fade, 0, half);
  }

  /**
   * 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                                     */
  /* -------------------------------------------- */

  /**
   * schedule the fade-out that should occur when repeat is off.
   * Does nothing if the sound is set to repeat or has no finite duration.
   * @protected
   */
  _scheduleFadeOut() {
    const {sound, repeat, fadeDuration} = this;
    if ( repeat || !sound || !Number.isFinite(sound.duration) ) return;

    // Cancel any previous handle
    this._cancelFadeOut();

    // Compute delay until fade should start
    const now = sound.currentTime ?? 0;
    const startAfter = Math.max(0, (sound.duration - now) - (fadeDuration / 1000));

    // Schedule and store the new handle
    this.#fadeHandle = sound.schedule(
      () => sound.fade(0, {duration: fadeDuration}),
      now + startAfter
    );
  }

  /* -------------------------------------------- */

  /**
   * Cancel any pending fade-out on the current sound.
   * @protected
   */
  _cancelFadeOut() {
    if ( this.#fadeHandle ) {
      this.sound.unschedule(this.#fadeHandle);
      this.#fadeHandle = undefined;
    }
  }

  /* -------------------------------------------- */

  /**
   * 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 ) {
      if ( sound.loop !== this.repeat ) { // loop change case
        sound.loop = this.repeat;
        if ( !this.repeat ) this._scheduleFadeOut();
        else this._cancelFadeOut();
      }
      sound.fade(this.volume, {duration: 500});
      return;
    }

    // Begin playback
    sound.load({
      autoplay: true,
      autoplayOptions: {
        loop: this.repeat,
        volume: this.volume,
        fade: this.fadeDuration,
        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 */
  async _preUpdate(changed, options, user) {
    if ( "channel" in changed ) changed.playing = false;
    return super._preUpdate(changed, options, user);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( ("path" in changed) || ("channel" 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);
    this.playing = false;
    this._cancelFadeOut();
    this.sync();
  }

  /* -------------------------------------------- */

  /**
   * Special handling that occurs when playback of a PlaylistSound is started.
   * @protected
   */
  async _onStart() {
    if ( !this.playing ) return this.sound.stop();

    const fd = this.fadeDuration;
    if ( fd ) this.sound.fade(this.volume, {duration: fd, from: 0}); // fade-in from silence

    this._scheduleFadeOut();

    // 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;
  }
};

/**
 * @import RegionDocument from "./region.mjs";
 * @import Scene from "./scene.mjs";
 * @import {RegionEvent} from "./_types.mjs";
 */

/**
 * The client-side RegionBehavior document which extends the common BaseRegionBehavior model.
 * @extends BaseRegionBehavior
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.RegionDocument}: The Region document type which contains
 *   RegionBehavior documents
 * @see {@link foundry.applications.sheets.RegionBehaviorConfig}: The RegionBehaviorConfig
 *   configuration application
 */
let RegionBehavior$1 = class RegionBehavior extends ClientDocumentMixin(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, createOptions, dialogOptions) {
    if ( !game.user.can("MACRO_SCRIPT") ) {
      dialogOptions = {
        ...dialogOptions,
        types: (dialogOptions?.types ?? this.TYPES).filter(t => t !== "executeScript")
      };
    }
    return super.createDialog(data, createOptions, dialogOptions);
  }
};

/**
 * The client-side TableResult document which extends the common BaseTableResult document model.
 * @extends BaseTableResult
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.RollTable}: The RollTable document type which contains TableResult
 *   documents
 */
let TableResult$1 = class TableResult extends ClientDocumentMixin(BaseTableResult) {

  /**
   * A path reference to the icon image used to represent this result
   * @type {string}
   */
  get icon() {
    return this.img ?? CONFIG.RollTable.resultIcon;
  }

  /* -------------------------------------------- */

  /** @override */
  prepareBaseData() {
    super.prepareBaseData();
    if ( this.type === "text" ) this.documentUuid &&= null;
    else if ( this.type === "document" && game._documentsReady ) {
      const resultDocument = fromUuidSync(this.documentUuid);
      this.name ||= resultDocument?.name || "";
      this.img ??= resultDocument?.img ?? null;
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare a string representation for this result.
   * @returns {Promise<string>} The enriched text to display
   */
  async getHTML() {
    const documentLink = this.documentToAnchor()?.outerHTML ?? null;
    const description = await TextEditor$1.implementation.enrichHTML(this.description, {relativeTo: this,
      secrets: this.isOwner});
    const data = {result: {name: this.name, documentLink, description}};
    return foundry.applications.handlebars.renderTemplate("templates/sheets/roll-table/result-details.hbs", data);
  }

  /* -------------------------------------------- */

  /**
   * Create a content-link anchor from this Result's referenced Document.
   * @returns {HTMLAnchorElement|null}
   */
  documentToAnchor() {
    if ( this.type === "text" || !this.documentUuid ) return null;

    const document = fromUuidSync(this.documentUuid);
    if ( document instanceof foundry.abstract.Document ) return document.toAnchor();
    const {id, type, name, pack, documentName, uuid} = this.#documentDataFromUuid(this.documentUuid ?? "");
    const documentConfig = CONFIG[documentName];
    const typeName = game.i18n.localize(documentConfig?.typeLabels?.[type] ?? "");
    const documentNameLabel = documentName ? game.i18n.localize(`DOCUMENT.${documentName}`) : "";
    const tooltip = typeName
      ? game.i18n.format("DOCUMENT.TypePageFormat", {type: typeName, page: documentNameLabel})
      : documentNameLabel;
    const classes = ["content-link"];
    if ( !id || !documentName ) classes.push("broken");
    return TextEditor$1.implementation.createAnchor({
      classes,
      attrs: {draggable: "true"},
      dataset: {link: "", uuid, id, type: documentName, pack, tooltip},
      name: this.name || name || game.i18n.localize("Unknown"),
      icon: documentConfig?.typeIcons?.[type] ?? documentConfig?.sidebarIcon ?? "fa-solid fa-link-slash"
    });
  }

  /* -------------------------------------------- */

  /**
   * Acquire necessary data to create a content link from a Document uuid.
   * @param {string} uuid
   * @returns {object}
   */
  #documentDataFromUuid(uuid) {
    const parsedUuid = foundry.utils.parseUuid(uuid) ?? {};
    const documentOrIndexEntry = fromUuidSync(uuid) ?? {};
    const collection = documentOrIndexEntry.collection ?? parsedUuid.collection;
    return {
      id: parsedUuid.id,
      type: documentOrIndexEntry.type,
      name: documentOrIndexEntry.name,
      pack: collection?.metadata?.id,
      documentName: parsedUuid.type,
      uuid
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changes, options, user) {
    if ( (changes.type === "text") && this._source.documentUuid ) {
      changes.documentUuid = null;
    }
    return super._preUpdate(changes, options, user);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since V13
   * @ignore
   */
  getChatText() {
    const warning = "TableResult#getChatText is deprecated. Use the asynchronous TableResult#getHTML instead.";
    foundry.utils.logCompatibilityWarning(warning, {since: 13, until: 15});
    return this.type === "document" ? `@UUID[${this.documentUuid}]{${this.name}}` : this.description;
  }

};

/**
 * The client-side AmbientLight document which extends the common BaseAmbientLight document model.
 * @extends BaseAmbientLight
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Scene}: The Scene document type which contains AmbientLight documents
 * @see {@link foundry.applications.sheets.AmbientLightConfig}: The AmbientLight configuration
 *   application
 */
class AmbientLightDocument extends CanvasDocumentMixin(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 BaseAmbientSound
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Scene}: The Scene document type which contains AmbientSound documents
 * @see {@link foundry.applications.sheets.AmbientSoundConfig}: The AmbientSound configuration
 *   application
 */
class AmbientSoundDocument extends CanvasDocumentMixin(BaseAmbientSound) {}

/**
 * @import {DrawingData} from "@common/documents/_types.mjs";
 */

/**
 * The client-side Drawing document which extends the common BaseDrawing model.
 *
 * @extends BaseDrawing
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Scene}: The Scene document type which contains Drawing embedded documents
 * @see {@link foundry.applications.sheets.DrawingConfig}: The Drawing configuration application
 */
class DrawingDocument extends CanvasDocumentMixin(BaseDrawing) {
  /**
   * Fields included in the drawing defaults setting
   * @type {(keyof DrawingData)[]}
   */
  static defaultDrawingFields = [
    "strokeWidth",
    "strokeColor",
    "strokeAlpha",
    "bezierFactor",
    "fillType",
    "fillColor",
    "fillAlpha",
    "texture",
    "text",
    "fontFamily",
    "fontSize",
    "textColor",
    "textAlpha"
  ];

  /* -------------------------------------------- */
  /*  Model Properties                            */
  /* -------------------------------------------- */

  /**
   * Is the current User the author of this drawing?
   * @type {boolean}
   */
  get isAuthor() {
    return game.user === this.author;
  }
}

/**
 * The client-side MeasuredTemplate document which extends the common BaseMeasuredTemplate document model.
 * @extends BaseMeasuredTemplate
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Scene}: The Scene document type which contains MeasuredTemplate documents
 * @see {@link foundry.applications.sheets.MeasuredTemplateConfig}: The MeasuredTemplate
 *   configuration application
 */
class MeasuredTemplateDocument extends CanvasDocumentMixin(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;
  }
}

/**
 * @import JournalEntry from "./journal-entry.mjs";
 * @import JournalEntryPage from "./journal-entry-page.mjs";
 */

/**
 * The client-side Note document which extends the common BaseNote document model.
 * @extends BaseNote
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Scene}: The Scene document type which contains Note documents
 * @see {@link foundry.applications.sheets.NoteConfig}: The Note configuration application
 */
class NoteDocument extends CanvasDocumentMixin(BaseNote) {
  /** @inheritDoc */
  static async createDialog(noteData={}, createOptions={}, dialogOptions={}) {
    const parent = (createOptions.parent ??= canvas.scene);
    foundry.utils.mergeObject(dialogOptions, {
      folders: game.journal._formatFolderSelectOptions() ?? [],
      template: "templates/scene/note/create-dialog.hbs",
      position: {width: 500},
      ok: {
        callback: async event => {
          const form = event.target.closest("form");
          const fd = new foundry.applications.ux.FormDataExtended(form).object;
          if ( !fd.folder ) delete fd.folder;
          if ( !fd.name.trim() ) {
            fd.name = fd.journal
              ? foundry.documents.JournalEntry.implementation.defaultName()
              : this.implementation.defaultName({parent});
          }
          const newEntry = fd.journal ? await foundry.documents.JournalEntry.implementation.create(
            fd, {renderSheet: true}) : null;
          // Create a note for a created JournalEntry
          if ( game.journal.has(newEntry?.id) ) {
            noteData.entryId = newEntry.id;
            return this.implementation.create(noteData, createOptions);
          }
          // Create a preview Note
          else {
            noteData.entryId = canvas.scene.journal?.id ?? null;
            noteData.text = fd.name;
            return canvas.notes._createPreview(noteData, {top: event.clientY - 20, left: event.clientX + 40});
          }
        }
      },
      render: event => {
        const form = event.target.element.querySelector("form");
        if ( !form.folder ) return;
        form.folder.disabled = true;
        form.journal.addEventListener("change", () => {
          form.folder.disabled = !form.journal.checked;
          form.name.placeholder = form.journal.checked
            ? foundry.documents.JournalEntry.implementation.defaultName()
            : this.implementation.defaultName({parent});
        });
      }
    }, {overwrite: false});
    return super.createDialog(noteData, createOptions, dialogOptions);
  }

  /* -------------------------------------------- */
  /*  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";
  }
}

/**
 * @import {Point} from "@common/_types.mjs";
 */

/**
 * The node of a {@link foundry.data.regionShapes.RegionPolygonTree}.
 */
class RegionPolygonTreeNode {

  /**
   * Create a RegionPolygonTreeNode.
   * @param {RegionPolygonTreeNode|null} parent    The parent node.
   * @internal
   */
  constructor(parent) {
    this.#parent = parent;
    this.#children = [];
    this.#depth = parent ? parent.depth + 1 : 0;
    this.#isHole = this.#depth % 2 === 0;
    if ( parent ) parent.#children.push(this);
    else {
      this.#polygon = null;
      this.#clipperPath = null;
    }
  }

  /* -------------------------------------------- */

  /**
   * Create a node from the Clipper path and add it to the children of the parent.
   * @param {ClipperLib.IntPoint[]} clipperPath              The clipper path of this node.
   * @param {RegionPolygonTreeNode|null} parent    The parent node or `null` if root.
   * @internal
   */
  static _fromClipperPath(clipperPath, parent) {
    const node = new RegionPolygonTreeNode(parent);
    if ( parent ) node.#clipperPath = clipperPath;
    return node;
  }

  /* -------------------------------------------- */

  /**
   * The parent of this node or `null` if this is the root node.
   * @type {RegionPolygonTreeNode|null}
   */
  get parent() {
    return this.#parent;
  }

  #parent;

  /* -------------------------------------------- */

  /**
   * The children of this node.
   * @type {ReadonlyArray<RegionPolygonTreeNode>}
   */
  get children() {
    return this.#children;
  }

  #children;

  /* -------------------------------------------- */

  /**
   * The depth of this node.
   * The depth of the root node is 0.
   * @type {number}
   */
  get depth() {
    return this.#depth;
  }

  #depth;

  /* -------------------------------------------- */

  /**
   * Is this a hole?
   * The root node is a hole.
   * @type {boolean}
   */
  get isHole() {
    return this.#isHole;
  }

  #isHole;

  /* -------------------------------------------- */

  /**
   * The Clipper path of this node.
   * It is empty in case of the root node.
   * @type {ReadonlyArray<ClipperLib.IntPoint>|null}
   */
  get clipperPath() {
    return this.#clipperPath;
  }

  #clipperPath;

  /* -------------------------------------------- */

  /**
   * The polygon of this node.
   * It is `null` in case of the root node.
   * @type {PIXI.Polygon|null}
   */
  get polygon() {
    let polygon = this.#polygon;
    if ( polygon === undefined ) polygon = this.#polygon = this.#createPolygon();
    return polygon;
  }

  #polygon;

  /* -------------------------------------------- */

  /**
   * The points of the polygon ([x0, y0, x1, y1, ...]).
   * They are `null` in case of the root node.
   * @type {ReadonlyArray<number>|null}
   */
  get points() {
    const polygon = this.polygon;
    if ( !polygon ) return null;
    return polygon.points;
  }

  /* -------------------------------------------- */

  /**
   * The bounds of the polygon.
   * They are `null` in case of the root node.
   * @type {PIXI.Rectangle|null}
   */
  get bounds() {
    let bounds = this.#bounds;
    if ( bounds === undefined ) bounds = this.#bounds = this.polygon?.getBounds() ?? null;
    return bounds;
  }

  #bounds;

  /* -------------------------------------------- */

  /**
   * Iterate over recursively over the children in depth-first order.
   * @yields {RegionPolygonTreeNode}
   */
  *[Symbol.iterator]() {
    for ( const child of this.children ) {
      yield child;
      yield *child;
    }
  }

  /* -------------------------------------------- */

  /**
   * Test whether given point is contained within this node.
   * @param {Point} point    The point.
   * @returns {boolean}
   */
  testPoint(point) {
    return this.#testPoint(point) === 2;
  }

  /* -------------------------------------------- */

  /**
   * Test point containment.
   * @param {Point} point    The point.
   * @returns {0|1|2}        - 0: not contained within the polygon of this node.
   *                         - 1: contained within the polygon of this node but also contained
   *                              inside the polygon of a sub-node that is a hole.
   *                         - 2: contained within the polygon of this node and not contained
   *                              inside any polygon of a sub-node that is a hole.
   */
  #testPoint(point) {
    const {x, y} = point;
    if ( this.parent ) {
      if ( !this.bounds.contains(x, y) || !this.polygon.contains(x, y) ) return 0;
    }
    const children = this.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      const result = children[i].#testPoint(point);
      if ( result !== 0 ) return result;
    }
    return this.isHole ? 1 : 2;
  }

  /* -------------------------------------------- */

  /**
   * Test circle containment/intersection with this node.
   * @param {Point} center     The center point of the circle.
   * @param {number} radius    The radius of the circle.
   * @returns {-1|0|1}          - -1: the circle is in the exterior and does not intersect the boundary.
   *                            - 0: the circle is intersects the boundary.
   *                            - 1: the circle is in the interior and does not intersect the boundary.
   */
  testCircle(center, radius) {
    switch ( this.#testCircle(center, radius) ) {
      case 2: return 1;
      case 3: return 0;
      default: return -1;
    }
  }

  /* -------------------------------------------- */

  /**
   * Test circle containment/intersection with this node.
   * @param {Point} center     The center point of the circle.
   * @param {number} radius    The radius of the circle.
   * @returns {0|1|2|3}         - 0: does not intersect the boundary or interior of this node.
   *                            - 1: contained within the polygon of this node but also contained
   *                                 inside the polygon of a sub-node that is a hole.
   *                            - 2: contained within the polygon of this node and not contained
   *                                 inside any polygon of a sub-node that is a hole.
   *                            - 3: intersects the boundary of this node or any sub-node.
   */
  #testCircle(center, radius) {
    if ( this.parent ) {
      const {x, y} = center;

      // Test whether the circle intersects the bounds of this node
      const {left, right, top, bottom} = this.bounds;
      if ( (x < left - radius) || (x > right + radius) || (y < top - radius) || (y > bottom + radius) ) return 0;

      // Test whether the circle intersects any edge of the polygon of this node
      const intersects = foundry.utils.pathCircleIntersects(this.points, true, center, radius);
      if ( intersects ) return 3;

      // Test whether the circle is completely outside of the polygon
      const inside = this.polygon.contains(x, y);
      if ( !inside ) return 0;
    }

    // Test the children of this node now that we know that the circle is
    // completely inside of the polygon of this node
    const children = this.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      const result = children[i].#testCircle(center, radius);
      if ( result !== 0 ) return result;
    }
    return this.isHole ? 1 : 2;
  }

  /* -------------------------------------------- */

  /**
   * Create the polygon of this node.
   * @returns {PIXI.Polygon|null}
   */
  #createPolygon() {
    if ( !this.parent ) return null;
    const polygon = PIXI.Polygon.fromClipperPoints(this.clipperPath, {scalingFactor: CLIPPER_SCALING_FACTOR});
    polygon._isPositive = !this.isHole;
    return polygon;
  }
}

/* -------------------------------------------- */

/**
 * The polygon tree of a Region.
 */
class RegionPolygonTree extends RegionPolygonTreeNode {

  /**
   * Create a RegionPolygonTree.
   * @internal
   */
  constructor() {
    super(null);
  }

  /* -------------------------------------------- */

  /**
   * Create the tree from a Clipper polygon tree.
   * @param {ClipperLib.PolyTree} clipperPolyTree
   * @internal
   */
  static _fromClipperPolyTree(clipperPolyTree) {
    const visit = (clipperPolyNode, parent) => {
      const clipperPath = clipperPolyNode.Contour();
      const node = RegionPolygonTreeNode._fromClipperPath(clipperPath, parent);
      clipperPolyNode.Childs().forEach(child => visit(child, node));
      return node;
    };
    const tree = new RegionPolygonTree();
    clipperPolyTree.Childs().forEach(child => visit(child, tree));
    return tree;
  }
}

/**
 * @import {BaseShapeData} from "@common/data/data.mjs";
 */

/**
 * A shape of a {@link foundry.documents.RegionDocument}.
 * @template {BaseShapeData} [ShapeData=BaseShapeData]
 * @abstract
 */
class RegionShape {

  /**
   * Create a RegionShape.
   * @param {ShapeData} data    The shape data.
   * @internal
   */
  constructor(data) {
    this.#data = data;
  }

  /* -------------------------------------------- */

  /**
   * Create the RegionShape from the shape data.
   * @param {CircleShapeData|EllipseShapeData|PolygonShapeData|RectangleShapeData} data    The shape data.
   * @returns {RegionShape}
   */
  static create(data) {
    switch ( data.type ) {
      case "circle": return new RegionCircleShape(data);
      case "ellipse": return new RegionEllipseShape(data);
      case "polygon": return new RegionPolygonShape(data);
      case "rectangle": return new RegionRectangleShape(data);
      default: throw new Error("Invalid shape type");
    }
  }

  /* -------------------------------------------- */

  /**
   * The data of this shape.
   * It is owned by the shape and must not be modified.
   * @type {ShapeData}
   */
  get data() {
    return this.#data;
  }

  #data;

  /* -------------------------------------------- */

  /**
   * Is this a hole?
   * @type {boolean}
   */
  get isHole() {
    return this.data.hole;
  }

  /* -------------------------------------------- */

  /**
   * The Clipper paths of this shape.
   * The winding numbers are 1 or 0.
   * @type {ReadonlyArray<ReadonlyArray<ClipperLib.IntPoint>>}
   */
  get clipperPaths() {
    return this.#clipperPaths ??= ClipperLib.Clipper.PolyTreeToPaths(this.clipperPolyTree);
  }

  #clipperPaths;

  /* -------------------------------------------- */

  /**
   * The Clipper polygon tree of this shape.
   * @type {ClipperLib.PolyTree}
   */
  get clipperPolyTree() {
    let clipperPolyTree = this.#clipperPolyTree;
    if ( !clipperPolyTree ) {
      clipperPolyTree = this._createClipperPolyTree();
      if ( Array.isArray(clipperPolyTree) ) {
        const clipperPolyNode = new ClipperLib.PolyNode();
        clipperPolyNode.m_polygon = clipperPolyTree;
        clipperPolyTree = new ClipperLib.PolyTree();
        clipperPolyTree.AddChild(clipperPolyNode);
        clipperPolyTree.m_AllPolys.push(clipperPolyNode);
      }
      this.#clipperPolyTree = clipperPolyTree;
    }
    return clipperPolyTree;
  }

  #clipperPolyTree;

  /* -------------------------------------------- */

  /**
   * Create the Clipper polygon tree of this shape.
   * This function may return a single positively-orientated and non-selfintersecting Clipper path instead of a tree,
   * which is automatically converted to a Clipper polygon tree.
   * This function is called only once. It is not called if the shape is empty.
   * @returns {ClipperLib.PolyTree|ClipperLib.IntPoint[]}
   * @protected
   * @abstract
   */
  _createClipperPolyTree() {
    throw new Error("A subclass of the RegionShape must implement the _createClipperPolyTree method.");
  }
}

/* -------------------------------------------- */

/**
 * A circle of a {@link foundry.documents.RegionDocument}.
 * @extends {RegionShape<CircleShapeData>}
 */
class RegionCircleShape extends RegionShape {
  /**
   * @param {CircleShapeData} data   The circle shape data.
   */
  constructor(data) {
    if ( !(data instanceof CircleShapeData) ) throw new Error("Invalid shape data");
    super(data);
  }

  /* -------------------------------------------- */

  /**
   * The vertex density epsilon used to create a polygon approximation of the circle.
   * @type {number}
   */
  static #VERTEX_DENSITY_EPSILON = 1;

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    const data = this.data;
    const x = data.x * CLIPPER_SCALING_FACTOR;
    const y = data.y * CLIPPER_SCALING_FACTOR;
    const radius = data.radius * CLIPPER_SCALING_FACTOR;
    const epsilon = RegionCircleShape.#VERTEX_DENSITY_EPSILON * CLIPPER_SCALING_FACTOR;
    const density = PIXI.Circle.approximateVertexDensity(radius, epsilon);
    const path = new Array(density);
    for ( let i = 0; i < density; i++ ) {
      const angle = 2 * Math.PI * (i / density);
      path[i] = new ClipperLib.IntPoint(
        Math.round(x + (Math.cos(angle) * radius)),
        Math.round(y + (Math.sin(angle) * radius))
      );
    }
    return path;
  }
}

/* -------------------------------------------- */

/**
 * An ellipse of a {@link foundry.documents.RegionDocument}.
 * @extends {RegionShape<EllipseShapeData>}
 */
class RegionEllipseShape extends RegionShape {
  /**
   * @param {EllipseShapeData} data   The ellipse shape data.
   */
  constructor(data) {
    if ( !(data instanceof EllipseShapeData) ) throw new Error("Invalid shape data");
    super(data);
  }

  /* -------------------------------------------- */

  /**
   * The vertex density epsilon used to create a polygon approximation of the circle.
   * @type {number}
   */
  static #VERTEX_DENSITY_EPSILON = 1;

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    const data = this.data;
    const x = data.x * CLIPPER_SCALING_FACTOR;
    const y = data.y * CLIPPER_SCALING_FACTOR;
    const radiusX = data.radiusX * CLIPPER_SCALING_FACTOR;
    const radiusY = data.radiusY * CLIPPER_SCALING_FACTOR;
    const epsilon = RegionEllipseShape.#VERTEX_DENSITY_EPSILON * CLIPPER_SCALING_FACTOR;
    const density = PIXI.Circle.approximateVertexDensity((radiusX + radiusY) / 2, epsilon);
    const rotation = Math.toRadians(data.rotation);
    const cos = Math.cos(rotation);
    const sin = Math.sin(rotation);
    const path = new Array(density);
    for ( let i = 0; i < density; i++ ) {
      const angle = 2 * Math.PI * (i / density);
      const dx = Math.cos(angle) * radiusX;
      const dy = Math.sin(angle) * radiusY;
      path[i] = new ClipperLib.IntPoint(
        Math.round(x + ((cos * dx) - (sin * dy))),
        Math.round(y + ((sin * dx) + (cos * dy)))
      );
    }
    return path;
  }
}

/* -------------------------------------------- */

/**
 * A polygon of a {@link foundry.documents.RegionDocument}.
 * @extends {RegionShape<PolygonShapeData>}
 */
class RegionPolygonShape extends RegionShape {
  /**
   * @param {PolygonShapeData} data   The polygon shape data.
   */
  constructor(data) {
    if ( !(data instanceof PolygonShapeData) ) throw new Error("Invalid shape data");
    super(data);
  }

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    const points = this.data.points;
    const path = new Array(points.length / 2);
    for ( let i = 0, j = 0; i < path.length; i++ ) {
      path[i] = new ClipperLib.IntPoint(
        Math.round(points[j++] * CLIPPER_SCALING_FACTOR),
        Math.round(points[j++] * CLIPPER_SCALING_FACTOR)
      );
    }
    if ( !ClipperLib.Clipper.Orientation(path) ) path.reverse();
    return path;
  }
}

/* -------------------------------------------- */

/**
 * A rectangle of a {@link foundry.documents.RegionDocument}.
 * @extends {RegionShape<RectangleShapeData>}
 */
class RegionRectangleShape extends RegionShape {
  /**
   * @param {RectangleShapeData} data   The rectangle shape data.
   */
  constructor(data) {
    if ( !(data instanceof RectangleShapeData) ) throw new Error("Invalid shape data");
    super(data);
  }

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    let p0;
    let p1;
    let p2;
    let p3;
    const {x, y, width, height, rotation} = this.data;
    let x0 = x * CLIPPER_SCALING_FACTOR;
    let y0 = y * CLIPPER_SCALING_FACTOR;
    let x1 = (x + width) * CLIPPER_SCALING_FACTOR;
    let y1 = (y + height) * CLIPPER_SCALING_FACTOR;

    // The basic non-rotated case
    if ( rotation === 0 ) {
      x0 = Math.round(x0);
      y0 = Math.round(y0);
      x1 = Math.round(x1);
      y1 = Math.round(y1);
      p0 = new ClipperLib.IntPoint(x0, y0);
      p1 = new ClipperLib.IntPoint(x1, y0);
      p2 = new ClipperLib.IntPoint(x1, y1);
      p3 = new ClipperLib.IntPoint(x0, y1);
    }

    // The more complex rotated case
    else {
      const tx = (x0 + x1) / 2;
      const ty = (y0 + y1) / 2;
      x0 -= tx;
      y0 -= ty;
      x1 -= tx;
      y1 -= ty;
      const angle = Math.toRadians(rotation);
      const cos = Math.cos(angle);
      const sin = Math.sin(angle);
      const x00 = Math.round((cos * x0) - (sin * y0) + tx);
      const y00 = Math.round((sin * x0) + (cos * y0) + ty);
      const x10 = Math.round((cos * x1) - (sin * y0) + tx);
      const y10 = Math.round((sin * x1) + (cos * y0) + ty);
      const x11 = Math.round((cos * x1) - (sin * y1) + tx);
      const y11 = Math.round((sin * x1) + (cos * y1) + ty);
      const x01 = Math.round((cos * x0) - (sin * y1) + tx);
      const y01 = Math.round((sin * x0) + (cos * y1) + ty);
      p0 = new ClipperLib.IntPoint(x00, y00);
      p1 = new ClipperLib.IntPoint(x10, y10);
      p2 = new ClipperLib.IntPoint(x11, y11);
      p3 = new ClipperLib.IntPoint(x01, y01);
    }
    return [p0, p1, p2, p3];
  }
}

var _module$X = /*#__PURE__*/Object.freeze({
  __proto__: null,
  RegionCircleShape: RegionCircleShape,
  RegionEllipseShape: RegionEllipseShape,
  RegionPolygonShape: RegionPolygonShape,
  RegionPolygonTree: RegionPolygonTree,
  RegionPolygonTreeNode: RegionPolygonTreeNode,
  RegionRectangleShape: RegionRectangleShape,
  RegionShape: RegionShape
});

/**
 * @import {Point, ElevatedPoint} from "@common/_types.mjs";
 * @import {RegionSocketEvent} from "@common/documents/_types.mjs";
 * @import {RegionEvent, RegionMovementSegment, RegionSegmentizeMovementPathWaypoint} from "./_types.mjs";
 * @import TokenDocument from "./token.mjs";
 */


/**
 * The client-side Region document which extends the common BaseRegion model.
 * @extends BaseRegion
 * @mixes CanvasDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Scene}: The Scene document type which contains Region documents
 * @see {@link foundry.applications.sheets.RegionConfig}: The Region configuration application
 */
class RegionDocument extends CanvasDocumentMixin(BaseRegion) {

  /**
   * Shared point instance.
   * @type {Point}
   */
  static #SHARED_POINT = {x: 0.0, y: 0.0};

  /* -------------------------------------------- */

  /**
   * The minimum distance from the boundary for a point to be considered interior/exterior.
   * @type {number}
   */
  static #MIN_BOUNDARY_DISTANCE = (Math.SQRT1_2 / CLIPPER_SCALING_FACTOR) + 1e-6;

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The shapes of this Region.
   *
   * The value of this property must not be mutated.
   *
   * This property is updated only by a document update.
   * @type {ReadonlyArray<RegionShape>}
   */
  get regionShapes() {
    return this.#regionShapes ??= this.shapes.map(shape => RegionShape.create(shape));
  }

  #regionShapes;

  /* -------------------------------------------- */

  /**
   * The polygons of this Region.
   *
   * The value of this property must not be mutated.
   *
   * This property is updated only by a document update.
   * @type {ReadonlyArray<PIXI.Polygon>}
   */
  get polygons() {
    return this.#polygons ??= Array.from(this.polygonTree, node => node.polygon);
  }

  #polygons;

  /* -------------------------------------------- */

  /**
   * The polygon tree of this Region.
   *
   * The value of this property must not be mutated.
   *
   * This property is updated only by a document update.
   * @type {RegionPolygonTree}
   */
  get polygonTree() {
    return this.#polygonTree ??= RegionPolygonTree._fromClipperPolyTree(
      this.#createClipperPolyTree());
  }

  #polygonTree;

  /* -------------------------------------------- */

  /**
   * The Clipper paths of this Region.
   *
   * The value of this property must not be mutated.
   *
   * This property is updated only by a document update.
   * @type {ReadonlyArray<ReadonlyArray<ClipperLib.IntPoint>>}
   */
  get clipperPaths() {
    return this.#clipperPaths ??= Array.from(this.polygonTree, node => node.clipperPath);
  }

  #clipperPaths;

  /* -------------------------------------------- */

  /**
   * The triangulation of this Region.
   *
   * The value of this property must not be mutated.
   *
   * This property is updated only by a document update.
   * @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 bounds of this Region.
   *
   * The value of this property must not be mutated.
   *
   * This property is updated only by a document update.
   * @type {PIXI.Rectangle}
   */
  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;
  }

  #bounds;

  /* -------------------------------------------- */

  /**
   * The tokens inside this region.
   * @type {ReadonlySet<TokenDocument>}
   * @readonly
   */
  tokens = new Set();

  /* -------------------------------------------- */
  /*  Document Preparation                        */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    this.elevation.bottom ??= -Infinity;
    this.elevation.top ??= Infinity;
  }

  /* -------------------------------------------- */
  /*  Shape Methods                               */
  /* -------------------------------------------- */

  /**
   * Test whether the given point (at the given elevation) is inside this Region.
   * @param {ElevatedPoint} point    The point.
   * @returns {boolean}              Is the point inside this Region?
   */
  testPoint(point) {
    const elevation = point.elevation;
    const {bottom, top} = this.elevation;
    return ((bottom <= elevation) && (elevation <= top)) && this.polygonTree.testPoint(point);
  }

  /* -------------------------------------------- */

  /**
   * Create the Clipper polygon tree for this Region.
   * @returns {ClipperLib.PolyTree}
   */
  #createClipperPolyTree() {
    const i0 = this.regionShapes.findIndex(s => !s.isHole);
    if ( i0 < 0 ) return new ClipperLib.PolyTree();
    if ( i0 === this.regionShapes.length - 1 ) {
      const shape = this.regionShapes[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.regionShapes;
    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;
  }

  /* -------------------------------------------- */

  /**
   * Split the movement path into its segments.
   * @param {RegionSegmentizeMovementPathWaypoint[]} 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.
   * @returns {RegionMovementSegment[]}             The movement split into its segments.
   */
  segmentizeMovementPath(waypoints, samples) {
    if ( samples.length === 0 ) return [];
    if ( waypoints.length === 2 ) return this.#segmentizeMovement(waypoints[0], waypoints[1], samples);
    const segments = [];
    for ( let i = 1; i < waypoints.length; i++ ) {
      for ( const segment of this.#segmentizeMovement(waypoints[i - 1], waypoints[i], samples) ) {
        segments.push(segment);
      }
    }
    return segments;
  }

  /* -------------------------------------------- */

  /**
   * Split the movement into its segments.
   * @param {RegionSegmentizeMovementPathWaypoint} origin         The origin of movement.
   * @param {RegionSegmentizeMovementPathWaypoint} destination    The destination of movement.
   * @param {Point[]} samples                                 The points relative to the waypoints that are tested.
   * @returns {RegionMovementSegment[]}                       The movement split into its segments.
   */
  #segmentizeMovement(origin, destination, samples) {
    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 [];

    let segments;

    // Teleportation
    const teleport = destination.teleport ?? false;
    if ( teleport ) {
      segments = this.#getTeleportationSegments(originX, originY, originElevation,
        destinationX, destinationY, destinationElevation, samples);
    }

    // Movement with no elevation change
    else if ( originElevation === destinationElevation ) {
      segments = this.#getNoElevationChangeSegments(originX, originY, destinationX,
        destinationY, destinationElevation, samples);
    }

    // Movement with elevation change
    else {
      segments = this.#getElevationChangeSegments(originX, originY, originElevation,
        destinationX, destinationY, destinationElevation, samples);
    }

    // Add segment properties
    for ( const segment of segments ) segment.teleport = teleport;

    return segments;
  }

  /* -------------------------------------------- */

  /**
   * Get the teleporation segments 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 {{type: REGION_MOVEMENT_SEGMENTS; from: ElevatedPoint; to: ElevatedPoint}[]}
   */
  #getTeleportationSegments(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.elevation;
    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_SEGMENTS.MOVE;
    else if ( originInside ) type = REGION_MOVEMENT_SEGMENTS.EXIT;
    else if ( destinationInside ) type = REGION_MOVEMENT_SEGMENTS.ENTER;
    else return [];
    return [{
      type,
      from: {x: originX, y: originY, elevation: originElevation},
      to: {x: destinationX, y: destinationY, elevation: destinationElevation}
    }];
  }

  /* -------------------------------------------- */

  /**
   * Get the segments from the origin to the destination where both are at the same elevation.
   * If no elevation change, we don't have to deal with enter/exit segments at the bottom/top elevation range
   * @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 {number} elevation                The elevation.
   * @param {Point[]} samples                 The samples relative to the position.
   * @returns {{type: REGION_MOVEMENT_SEGMENTS; from: ElevatedPoint; to: ElevatedPoint}[]}
   */
  #getNoElevationChangeSegments(originX, originY, destinationX, destinationY, elevation, samples) {
    if ( !((this.elevation.bottom <= elevation) && (elevation <= this.elevation.top)) ) return [];
    return this.#getMovementSegments(originX, originY, elevation, destinationX, destinationY, elevation, samples);
  }

  /* -------------------------------------------- */

  /**
   * Get the segments from the origin to the destination where both are at different elevations.
   * @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 {{type: REGION_MOVEMENT_SEGMENTS; from: ElevatedPoint; to: ElevatedPoint}[]}
   */
  #getElevationChangeSegments(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.elevation.bottom)
      : Math.min(originElevation, this.elevation.top);
    const e2 = upwards ? Math.min(destinationElevation, this.elevation.top)
      : Math.max(destinationElevation, this.elevation.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
    const defaultGrid = foundry.documents.BaseScene.defaultGrid;
    if ( (originElevation !== e1) && this.#testSamples(x1, y1, samples) ) {
      const grid = this.parent?.grid ?? defaultGrid;
      const epsilon = Math.min(Math.abs(originElevation - e1), grid.distance / grid.size);
      segments.unshift({
        type: REGION_MOVEMENT_SEGMENTS.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.parent?.grid ?? defaultGrid;
      const epsilon = Math.min(Math.abs(destinationElevation - e2), grid.distance / grid.size);
      segments.push({
        type: REGION_MOVEMENT_SEGMENTS.EXIT,
        from: {x: x2, y: y2, elevation: e2},
        to: {x: x2, y: y2, elevation: e2 + (upwards ? epsilon : -epsilon)}
      });
    }

    return segments;
  }

  /* -------------------------------------------- */

  /**
   * 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 = RegionDocument.#SHARED_POINT;
    const n = samples.length;
    for ( let i = 0; i < n; i++ ) {
      const sample = samples[i];
      point.x = x + sample.x;
      point.y = y + sample.y;
      if ( this.#polygonTree.testPoint(point) ) 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 {{type: REGION_MOVEMENT_SEGMENTS; from: ElevatedPoint; to: ElevatedPoint}[]}
   */
  #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_SEGMENTS.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_SEGMENTS.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_SEGMENTS.MOVE
            : inside10 ? REGION_MOVEMENT_SEGMENTS.ENTER : REGION_MOVEMENT_SEGMENTS.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_SEGMENTS.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_SEGMENTS.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_SEGMENTS.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_SEGMENTS.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_SEGMENTS.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_SEGMENTS.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_SEGMENTS.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_SEGMENTS.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 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) * CLIPPER_SCALING_FACTOR);
      origin.Y = Math.round((originY + dy) * CLIPPER_SCALING_FACTOR);
      destination.X = Math.round((destinationX + dx) * CLIPPER_SCALING_FACTOR);
      destination.Y = Math.round((destinationY + dy) * CLIPPER_SCALING_FACTOR);

      // 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) {

    // Special case in square/hexagonal grids: we do not want from/to positions of crossings to end up exactly on the
    // boundary; we do not want the from/to positions of crossings to end up on grid edges for grid-shaped regions
    const grid = this.parent?.grid ?? foundry.documents.BaseScene.defaultGrid;
    if ( !grid.isGridless ) {
      return [
        this.#findNextInteriorExteriorPosition(currentX, currentY, startX, startY, samples, !enter),
        this.#findNextInteriorExteriorPosition(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}];
  }

  /* -------------------------------------------- */

  /**
   * Find the next interior/exterior position along the line from (x0, y0) to (x1, y1).
   * @param {number} x0          The start x-coordinate.
   * @param {number} y0          The start y-coordinate.
   * @param {number} x1          The end x-coordinate.
   * @param {number} y1          The end y-coordinate.
   * @param {boolean} samples    The samples.
   * @param {boolean} inside     Find first interior point? Otherwise find first exterior point.
   * @returns {{x: number; y: number; inside: boolean}}
   */
  #findNextInteriorExteriorPosition(x0, y0, x1, y1, samples, inside) {
    if ( this.#testSamplesInteriorExterior(x0, y0, samples, inside) ) return {x: x0, y: y0, inside};
    const sx = x0 < x1 ? 1 : -1;
    const sy = y0 < y1 ? 1 : -1;
    const dx = Math.abs(x0 - x1);
    const dy = 0 - Math.abs(y0 - y1);
    let e = dx + dy;
    while ( (x0 !== x1) || (y0 !== y1) ) {
      const e2 = e * 2;
      if ( e2 <= dx ) {
        e += dx;
        y0 += sy;
      }
      if ( e2 >= dy ) {
        e += dy;
        x0 += sx;
      }
      if ( this.#testSamplesInteriorExterior(x0, y0, samples, inside) ) return {x: x0, y: y0, inside};
    }
    return {x: x1, y: y1, inside: this.#testSamples(x1, y1, samples)};
  }

  /* -------------------------------------------- */

  /**
   * Test whether one/all of the samples relative to the given position is/are in the interior/exterior of 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.
   * @param {boolean} inside       If true, test whether at least one point is in the interior, if false, test
   *                               whether all points are in the exterior.
   * @returns {boolean}            Is one of the samples contained within this Region?
   */
  #testSamplesInteriorExterior(x, y, samples, inside) {
    const point = RegionDocument.#SHARED_POINT;
    const n = samples.length;
    const radius = RegionDocument.#MIN_BOUNDARY_DISTANCE;
    for ( let i = 0; i < n; i++ ) {
      const sample = samples[i];
      point.x = x + sample.x;
      point.y = y + sample.y;
      const result = this.#polygonTree.testCircle(point, radius);
      if ( result === 0 ) return false;
      if ( result === 1 ) return inside;
    }
    return !inside;
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Update the shapes
    if ( "shapes" in changed ) {
      this.#regionShapes = undefined;
      this.#polygons = undefined;
      this.#polygonTree = undefined;
      this.#clipperPaths = undefined;
      this.#triangulation = undefined;
      this.#bounds = undefined;
    }
  }

  /* -------------------------------------------- */
  /*  API Methods                                 */
  /* -------------------------------------------- */

  /**
   * Teleport a Token into this Region.
   * The Token may be in the same Scene as this Region, or in a different Scene.
   * The current User must be an owner of the Token Document in order to teleport it
   * For teleportation to a different Scene the current User requires `TOKEN_CREATE` and
   * `TOKEN_DELETE` permissions. If the Token is teleported to different Scene, it is deleted
   * and a new Token Document in the other Scene is created.
   * @param {TokenDocument} token         An existing Token Document to teleport
   * @returns {Promise<TokenDocument>}    The same Token Document if teleported within the same Scene,
   *                                      or a new Token Document if teleported to a different Scene
   */
  async teleportToken(token) {
    if ( !token.isOwner ) throw new Error("You must be an owner of the Token in order to teleport it.");
    if ( (token.parent !== this.parent) && !(game.user.can("TOKEN_CREATE") && game.user.can("TOKEN_DELETE")) ) {
      throw new Error("You must have TOKEN_CREATE and TOKEN_DELETE permissions to teleport the Token to a different Scene.");
    }
    const originScene = token.parent;
    const destinationScene = this.parent;
    let destinationToken;
    if ( originScene === destinationScene ) destinationToken = token;
    else {
      const originTokenData = token.toObject();
      delete originTokenData._id;
      destinationToken = foundry.documents.TokenDocument.implementation.fromSource(originTokenData,
        {parent: destinationScene});
    }

    // Get the destination position
    const destination = this.#getDestination(destinationToken);

    // If we didn't find a position that places the token within the destination region,
    // the region is not a valid destination for teleporation or we didn't have luck finding one in 10 tries.
    if ( !destination ) throw new Error(`${this.uuid} cannot accomodate ${token.uuid}`);

    // If the origin and destination scene are the same
    if ( token === destinationToken ) {
      await token.move({...destination, action: "displace"});
      return token;
    }

    // Otherwise teleport the token to the different scene
    destinationToken.updateSource(destination);

    // Create the new token
    const destinationTokenData = destinationToken.toObject();
    if ( destinationScene.tokens.has(token.id) ) delete destinationTokenData._id;
    else destinationTokenData._id = token.id;
    destinationToken = await foundry.documents.TokenDocument.implementation.create(destinationTokenData,
      {parent: destinationScene, keepId: true});
    if ( !destinationToken ) throw new Error("Failed to create Token in destination Scene");

    // Delete the old token
    await token.delete({replacements: {[token.id]: destinationToken.uuid}});

    return destinationToken;
  }

  /* ---------------------------------------- */

  /**
   * Get a destination for the Token within this Region that places the Token and its center point inside it.
   * @param {TokenDocument} token          The token that is teleported.
   * @returns {ElevatedPoint|undefined}    The destination, if there is one.
   */
  #getDestination(token) {
    const scene = this.parent;

    // Not all regions are valid teleportation destinations
    if ( this.polygons.length === 0 ) return;

    // Clamp the elevation of the token the elevation range of the destination region
    const elevation = Math.clamp(token._source.elevation, this.elevation.bottom, this.elevation.top);

    // Now we look for a random position within the destination region for the token
    let position;
    const {width, height, shape} = token._source;
    const pivot = token.getCenterPoint({x: 0, y: 0, elevation: 0, width, height, shape});

    // Find a random snapped position in square/hexagonal grids that place the token within the destination region
    const grid = scene.grid;
    if ( !grid.isGridless ) {

      // Identify token positions that place the token and its center point within the region
      const positions = [];
      const [i0, j0, i1, j1] = grid.getOffsetRange(new PIXI.Rectangle(
        0, 0, scene.dimensions.width, scene.dimensions.height).fit(this.bounds).pad(1));
      for ( let i = i0; i < i1; i++ ) {
        for ( let j = j0; j < j1; j++ ) {

          // Drop the token with its center point on the grid space center and snap the token position
          const center = grid.getCenterPoint({i, j});

          // The grid space center must be inside the region to be a valid drop target
          if ( !this.polygonTree.testPoint(center) ) continue;

          const position = token.getSnappedPosition({x: center.x - pivot.x, y: center.y - pivot.y,
            elevation: 0, width, height, shape});
          const x = position.x = Math.round(position.x);
          const y = position.y = Math.round(position.y);
          position.elevation = elevation;
          const data = {x, y, elevation, width, height, shape};

          // The center point of the token must be inside the region
          if ( !this.polygonTree.testPoint(token.getCenterPoint(data)) ) continue;

          // The token itself must be inside the region
          if ( !token.testInsideRegion(this, data) ) continue;

          positions.push(position);
        }
      }

      // Pick a random position
      if ( positions.length !== 0 ) position = positions[Math.floor(positions.length * Math.random())];
    }

    // If we found a snapped position, we're done. Otherwise, search for an unsnapped position.
    if ( position ) return position;

    // Calculate the areas of each triangle of the triangulation
    const {vertices, indices} = this.triangulation;
    const areas = [];
    let totalArea = 0;
    for ( let k = 0; k < indices.length; k += 3 ) {
      const i0 = indices[k] * 2;
      const i1 = indices[k + 1] * 2;
      const i2 = indices[k + 2] * 2;
      const x0 = vertices[i0];
      const y0 = vertices[i0 + 1];
      const x1 = vertices[i1];
      const y1 = vertices[i1 + 1];
      const x2 = vertices[i2];
      const y2 = vertices[i2 + 1];
      const area = Math.abs(((x1 - x0) * (y2 - y0)) - ((x2 - x0) * (y1 - y0))) / 2;
      totalArea += area;
      areas.push(area);
    }

    // Try to find a position that places the token inside the region
    for ( let n = 0; n < 10; n++ ) {

      // Choose a triangle randomly weighted by area
      let j;
      let a = totalArea * Math.random();
      for ( j = 0; j < areas.length - 1; j++ ) {
        a -= areas[j];
        if ( a < 0 ) break;
      }
      const k = 3 * j;
      const i0 = indices[k] * 2;
      const i1 = indices[k + 1] * 2;
      const i2 = indices[k + 2] * 2;
      const x0 = vertices[i0];
      const y0 = vertices[i0 + 1];
      const x1 = vertices[i1];
      const y1 = vertices[i1 + 1];
      const x2 = vertices[i2];
      const y2 = vertices[i2 + 1];

      // Select a random point within the triangle
      const r1 = Math.sqrt(Math.random());
      const r2 = Math.random();
      const s = r1 * (1 - r2);
      const t = r1 * r2;
      const x = Math.round(x0 + ((x1 - x0) * s) + ((x2 - x0) * t) - pivot.x);
      const y = Math.round(y0 + ((y1 - y0) * s) + ((y2 - y0) * t) - pivot.y);
      const data = {x, y, elevation, width, height, shape};

      // The center point of the token must be inside the region
      if ( !this.polygonTree.testPoint(token.getCenterPoint(data)) ) continue;

      // The token itself must be inside the region
      if ( !token.testInsideRegion(this, data) ) continue;

      return {x, y, elevation};
    }
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /**
   * Activate the Socket event listeners.
   * @param {io.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 {RegionSocketEvent} socketEvent    The socket Region event
   */
  static async #onSocketEvent(socketEvent) {
    const {regionUuid, userId, eventName, eventData, eventDataUuids} = socketEvent;
    if ( userId === game.user.id ) return; // Already handled when the event was triggered
    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?
   */
  static async #updateTokens(regions, {deleted=false}={}) {
    if ( regions.length === 0 ) return;
    const updates = [];
    const scene = regions[0].parent;
    if ( deleted ) {
      const deletedRegionIds = new Set();
      const affectedTokens = new Set();
      for ( const region of regions ) {
        deletedRegionIds.add(region.id);
        for ( const token of region.tokens ) affectedTokens.add(token);
      }
      for ( const token of affectedTokens ) {
        const updatedRegionIds = token._source._regions.filter(id => !deletedRegionIds.has(id));
        if ( updatedRegionIds.length === token._source._regions.length ) continue;
        updates.push({_id: token.id, _regions: updatedRegionIds});
      }
    } else {
      for ( const token of scene.tokens ) {
        const regionIds = new Set(token._source._regions);
        let regionsChanged = false;
        for ( const region of regions ) {
          const inside = token.testInsideRegion(region);
          if ( inside === regionIds.has(region.id) ) continue;
          if ( inside ) regionIds.add(region.id);
          else regionIds.delete(region.id);
          regionsChanged = true;
        }
        if ( !regionsChanged ) continue;
        updates.push({_id: token.id, _regions: Array.from(regionIds).sort()});
      }
    }
    if ( updates.length === 0 ) return;
    await scene.updateEmbeddedDocuments("Token", updates, {diff: false, noHook: true});
  }

  /* -------------------------------------------- */

  /** @override */
  static async _onCreateOperation(documents, operation, user) {
    if ( user.isSelf ) {
      // noinspection ES6MissingAwait
      RegionDocument.#updateTokens(documents);
    }
    for ( const region of documents ) {
      const status = {active: true};
      region._handleEvent({name: REGION_EVENTS.BEHAVIOR_ACTIVATED, data: {}, region, user});
      if ( region.parent.isView ) {
        status.viewed = true;
        // noinspection ES6MissingAwait
        region._handleEvent({name: REGION_EVENTS.BEHAVIOR_VIEWED, data: {}, region, user});
      }
      // noinspection ES6MissingAwait
      /** @deprecated since v13 */
      region._handleEvent({name: "behaviorStatus", 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);
    }
    for ( const region of changedRegions ) {
      // noinspection ES6MissingAwait
      region._handleEvent({
        name: 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: REGION_EVENTS.TOKEN_EXIT,
          data: {token, movement: null},
          region,
          user
        });
      }
      region.tokens.clear();
    }
    for ( const region of documents ) {
      const status = {active: false};
      if ( region.parent.isView ) {
        status.viewed = false;
        regionEvents.push({name: REGION_EVENTS.BEHAVIOR_UNVIEWED, data: {}, region, user});
      }
      regionEvents.push({name: REGION_EVENTS.BEHAVIOR_DEACTIVATED, data: {}, region, user});
      /** @deprecated since v13 */
      regionEvents.push({name: "behaviorStatus", data: status, region, user});
    }
    for ( const event of regionEvents ) {
      // noinspection ES6MissingAwait
      event.region._handleEvent(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Trigger the Region event.
   * @param {string} eventName        The event name
   * @param {object} eventData        The event data
   * @returns {Promise<void>}
   * @internal
   */
  async _triggerEvent(eventName, eventData) {
    const event = {name: eventName, data: eventData, region: this, user: game.user};

    // 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 event for the current user immediately
    await this._handleEvent(event);
  }

  /* -------------------------------------------- */

  /**
   * 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                     */
  /* -------------------------------------------- */

  /** @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
      behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_ACTIVATED, data: {}, region: this, user});
      const status = {active: true};
      if ( this.parent.isView ) {
        status.viewed = true;
        behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_VIEWED, data: {}, region: this, user});
      }
      /** @deprecated since v13 */
      behavior._handleRegionEvent({name: "behaviorStatus", 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: REGION_EVENTS.TOKEN_ENTER,
          data: {token, movement: null},
          region: this,
          user
        });
      }
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
    if ( collection !== "behaviors" ) return;

    // Trigger 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: REGION_EVENTS.TOKEN_EXIT,
            data: {token, movement: null},
            region: this,
            user
          });
        }
      }

      // Triger status event
      if ( disabled ) {
        if ( this.parent.isView ) {
          behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_UNVIEWED, data: {}, region: this, user});
        }
        behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_DEACTIVATED, data: {}, region: this, user});
      } else {
        behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_ACTIVATED, data: {}, region: this, user});
        if ( this.parent.isView ) {
          behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_VIEWED, data: {}, region: this, user});
        }
      }
      const status = {active: !disabled};
      if ( this.parent.isView ) status.viewed = !disabled;
      /** @deprecated since v13 */
      behavior._handleRegionEvent({name: "behaviorStatus", 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: REGION_EVENTS.TOKEN_ENTER,
            data: {token, movement: null},
            region: this,
            user
          });
        }
      }
    }
  }

  /* -------------------------------------------- */

  /** @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: REGION_EVENTS.TOKEN_EXIT,
          data: {token, movement: null},
          region: this,
          user
        });
      }

      // Trigger status event
      const status = {active: false};
      if ( this.parent.isView ) {
        status.viewed = false;
        behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_UNVIEWED, data: {}, region: this, user});
      }
      behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_DEACTIVATED, data: {}, region: this, user});
      /** @deprecated since v13 */
      behavior._handleRegionEvent({name: "behaviorStatus", data: status, region: this, user});
    }
  }
}

/**
 * The client-side Tile document which extends the common BaseTile document model.
 * @extends BaseTile
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Scene}: The Scene document type which contains Tile documents
 * @see {@link foundry.applications.sheets.TileConfig}: The Tile configuration application
 */
class TileDocument extends CanvasDocumentMixin(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);
  }
}

/**
 * @import {DeepReadonly, ElevatedPoint, TokenConstrainMovementPathOptions} from "../_types.mjs";
 * @import {RegionEventType, TokenShapeType} from "@common/constants.mjs";
 * @import {
 *   TokenCompleteMovementWaypoint, TokenGetCompleteMovementPathWaypoint, TokenMeasuredMovementWaypoint,
 *   TokenMeasureMovementPathWaypoint, TokenResumeMovementCallback, TokenMovementContinuationData,
 *   TokenMovementCostFunction, TokenMovementData, TokenMovementOperation, TokenMovementSegmentData,
 *   TokenMovementWaypoint, TokenRegionMovementSegment, TokenSegmentizeMovementWaypoint,
 *   TrackedAttributesDescription, TokenMovementContinuationHandle, TokenMovementMethod,
 TokenMovementCostAggregator
 * } from "./_types.mjs";
 * @import {TokenData, TokenDimensions, TokenPosition} from "@common/documents/_types.mjs";
 * @import {Actor, Combat, Combatant, RegionDocument, User} from "./_module.mjs";
 * @import {DatabaseOperation, DatabaseUpdateOperation} from "@common/abstract/_types.mjs".
 * @import Collection from "@common/utils/collection.mjs";
 * @import DataModel from "@common/abstract/data.mjs";
 * @import {SchemaField} from "@common/data/fields.mjs";
 * @import {BaseGrid, HexagonalGrid} from "@common/grid/_module.mjs";
 * @import {GridMeasurePathResult, GridMeasurePathWaypointData3D} from "@common/grid/_types.mjs";
 */

/**
 * The client-side Token document which extends the common BaseToken document model.
 *
 * The following fields must no be altered from source during data preparation:
 * `x`, `y`, `elevation`, `width`, `height`, `shape`.
 *
 * ### Hook Events
 * - {@link hookEvents.moveToken}
 * - {@link hookEvents.pauseToken}
 * - {@link hookEvents.preMoveToken}
 * - {@link hookEvents.stopToken}
 *
 * @extends BaseToken
 * @mixes CanvasDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Scene}: The Scene document type which contains Token documents
 * @see {@link foundry.applications.sheets.TokenConfig}: The Token configuration application
 */
class TokenDocument extends CanvasDocumentMixin(BaseToken) {

  /**
   * The current movement data of this Token document.
   * @type {DeepReadonly<TokenMovementData>}
   */
  get movement() {
    if ( this.#movement ) return this.#movement;
    const {x, y, elevation, width, height, shape} = this._source;
    return foundry.utils.deepFreeze({
      id: "",
      chain: [],
      origin: {x, y, elevation, width, height, shape},
      destination: {x, y, elevation, width, height, shape},
      passed: {waypoints: [], distance: 0, cost: 0, spaces: 0, diagonals: 0},
      pending: {waypoints: [], distance: 0, cost: 0, spaces: 0, diagonals: 0},
      history: {
        recorded: {waypoints: [], distance: 0, cost: 0, spaces: 0, diagonals: 0},
        unrecorded: {waypoints: [], distance: 0, cost: 0, spaces: 0, diagonals: 0},
        distance: 0, cost: 0, spaces: 0, diagonals: 0
      },
      recorded: false,
      method: "api",
      constrainOptions: {},
      autoRotate: false,
      showRuler: false,
      user: game.user,
      state: "completed",
      updateOptions: {}
    });
  }

  #movement;

  /* -------------------------------------------- */

  /**
   * The movement continuation state of this Token document.
   * @type {TokenMovementContinuationData}
   * @internal
   */
  _movementContinuation = {
    movementId: "",
    continueCounter: 0,
    continued: false,
    continuePromise: Promise.resolve(false),
    waitPromise: Promise.resolve(),
    resolveWaitPromise: undefined,
    postWorkflowPromise: Promise.resolve(),
    states: {}
  };

  /* -------------------------------------------- */

  /**
   * The list of movement operation properties that are writeable in preUpdateMovement.
   * @type {string[]}
   */
  static #WRITEABLE_MOVEMENT_OPERATION_PROPERTIES = ["autoRotate", "showRuler"];


  /* -------------------------------------------- */

  /**
   * Infer the subject texture path if one is existing.
   * @param {string} src        The path to test.
   * @returns {string|null}     The inferred path, or null otherwise.
   */
  static #inferSubjectTexture(src) {
    if ( !src ) return null;
    for ( const [prefix, replacement] of Object.entries(CONFIG.Token.ring.subjectPaths) ) {
      if ( src.startsWith(prefix) ) return src.replace(prefix, replacement);
    }
    return null;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A singleton collection which holds a reference to the synthetic token actor by its base actor's ID.
   * @type {Collection<string, Actor>}
   */
  actors = (function() {
    const collection = new foundry.utils.Collection();
    collection.documentClass = foundry.documents.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|null}
   */
  get baseActor() {
    return game.actors.get(this.actorId) ?? null;
  }

  /* -------------------------------------------- */

  /**
   * An indicator for whether the current User has full control over this Token document.
   * @type {boolean}
   */
  get isOwner() {
    if ( game.user.isGM ) return true;
    if ( this.inCompendium ) return super.isOwner;
    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 === 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 movement history.
   * @type {TokenMeasuredMovementWaypoint[]}
   */
  get movementHistory() {
    return this._movementHistory;
  }

  /* -------------------------------------------- */

  /**
   * Check if the document has a distinct subject texture (inferred or explicit).
   * @type {boolean}
   */
  get hasDistinctSubjectTexture() {
    if ( this._source.ring?.subject.texture ) return true;
    return !!TokenDocument.#inferSubjectTexture(this.texture.src);
  }

  /* -------------------------------------------- */

  /**
   * The Regions this Token is currently in.
   * @type {Set<RegionDocument>}
   */
  regions = game._documentsReady ? new Set() : null;

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeSource(data, options) {
    data.shape ??= ((this.parent?.grid ?? BaseScene.defaultGrid).isHexagonal
      ? TOKEN_SHAPES.ELLIPSE_1 : TOKEN_SHAPES.RECTANGLE_1);
    if ( (typeof data.movementAction === "string") && !(data.movementAction in CONFIG.Token.movement.actions) ) {
      data.movementAction = null;
    }
    return super._initializeSource(data, options);
  }

  /* -------------------------------------------- */

  /** @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._source._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._movementHistory.forEach(waypoint => waypoint.cost ??= Infinity);
    this.sight.range ??= Infinity;
    this._prepareDetectionModes();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareEmbeddedDocuments() {
    if ( game._documentsReady && !this._source.delta ) this.updateSource({ delta: { _id: this.id } });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    if ( this.ring.enabled && !this.ring.subject.texture ) {
      this.ring.subject.texture = this._inferRingSubjectTexture();
    }
    this.movementAction ??= this._inferMovementAction();
  }

  /* -------------------------------------------- */

  /**
   * Infer the subject texture path to use for a token ring.
   * @returns {string}
   * @protected
   */
  _inferRingSubjectTexture() {
    const tex = this.texture.src;
    return TokenDocument.#inferSubjectTexture(tex) ?? tex;
  }

  /* -------------------------------------------- */

  /**
   * Infer the movement action.
   * The default implementation returns `CONFIG.Token.movement.defaultAction`.
   * @returns {string}
   * @protected
   */
  _inferMovementAction() {
    return CONFIG.Token.movement.defaultAction;
  }

  /* -------------------------------------------- */

  /**
   * Prepare detection modes which are available to the Token.
   * Ensure that every Token has the basic sight detection mode configured.
   * @protected
   */
  _prepareDetectionModes() {
    for ( const mode of this.detectionModes ) mode.range ??= Infinity;
    if ( !this.sight.enabled ) return;
    const lightMode = this.detectionModes.find(m => m.id === "lightPerception");
    if ( !lightMode ) this.detectionModes.push({id: "lightPerception", enabled: true, range: Infinity});
    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;
  }

  /* -------------------------------------------- */

  /**
   * Move the Token through the given waypoint(s).
   * @param {Partial<TokenMovementWaypoint> | Partial<TokenMovementWaypoint>[]} waypoints
   *                                       The waypoint(s) to move the Token through
   * @param {Partial<Omit<DatabaseUpdateOperation, "updates"> & {method: TokenMovementMethod;
   *   autoRotate: boolean; showRuler: boolean, constrainOptions: Omit<TokenConstrainMovementPathOptions,
   *   "preview"|"history">}>} [options]    Parameters of the update operation
   * @returns {Promise<boolean>}    A Promise that resolves to true if the Token was moved, otherwise resolves to false
   */
  async move(waypoints, {method, constrainOptions, autoRotate, showRuler, ...options}={}) {
    if ( !Array.isArray(waypoints) ) waypoints = [waypoints];
    else if ( waypoints.length === 0 ) return false;
    const args = {result: false};
    const updated = await this.update({}, {...options, movement: {[this.id]: {waypoints, method, constrainOptions,
      autoRotate, showRuler}}, _movementArguments: args});
    return args.result && !!updated;
  }

  /* -------------------------------------------- */

  /**
   * Undo all recorded movement or the recorded movement corresponding to given movement ID up to the last movement.
   * The token is displaced to the prior recorded position and the movement history it rolled back accordingly.
   * @overload
   * @param {string} [movementId]    The ID of the recorded movement to undo
   * @returns {Promise<boolean>}     True if the movement was undone, otherwise false
   */
  async revertRecordedMovement(movementId) {
    const history = this._source._movementHistory;
    if ( history.length === 0 ) return false;
    movementId ??= history.at(0).movementId;
    const index = history.findIndex(waypoint => waypoint.movementId === movementId);
    if ( index < 0 ) return false;
    const {x, y, elevation, width, height, shape} = history[Math.max(index - 1, 0)];
    const args = {result: false};
    const updated = await this.update({x, y, elevation, width, height, shape,
      _movementHistory: history.slice(0, index)}, {isUndo: true, diff: false, animate: false,
      _movementArguments: args});
    return args.result && !!updated;
  }

  /* -------------------------------------------- */

  /**
   * Resize the token Token such that its center point remains (almost) unchanged. The center point might change
   * slightly because the new (x, y) position is rounded.
   * @param {Partial<TokenDimensions>} dimensions                            The new dimensions
   * @param {Partial<Omit<DatabaseUpdateOperation, "updates">>} [options]    Parameters of the update operation
   * @returns {Promise<boolean>}  A Promise that resolves to true if the Token was resized, otherwise resolves to false
   */
  async resize({width, height, shape}, options) {
    width ??= this._source.width;
    height ??= this._source.height;
    shape ??= this._source.shape;
    const center = this.getCenterPoint(this._source);
    const pivot = this.getCenterPoint({x: 0, y: 0, elevation: 0, width, height, shape});
    const x = Math.round(center.x - pivot.x);
    const y = Math.round(center.y - pivot.y);
    const elevation = center.elevation - pivot.elevation;
    return this.move({x, y, elevation, width, height, shape, action: this.movementAction,
      snapped: false, explicit: false, checkpoint: true}, {...options,
      constrainOptions: {ignoreWalls: true, ignoreCost: true}, autoRotate: false, showRuler: false});
  }

  /* -------------------------------------------- */

  /**
   * Stop the movement of this Token document. The movement cannot be continued after being stopped.
   * Only the User that initiated the movement can stop it.
   * @returns {boolean}    True if the movement was or is stopped, otherwise false
   */
  stopMovement() {
    return this.#stopMovement(true);
  }

  /* -------------------------------------------- */

  /**
   * This function is called on Token documents that are still being moved by a User that just disconnected.
   * @internal
   */
  _stopMovementOnDisconnect() {
    if ( this.movement.user.active ) throw new Error("The User is still connected.");
    this.#stopMovement(false);
  }

  /* -------------------------------------------- */

  /**
   * Stop the movement of this Token document.
   * @param {boolean|Promise<*>} broadcast    Broadcast now? Or broadcast after promise.
   * @returns {boolean}                       True if the movement was or is stopped, otherwise false.
   */
  #stopMovement(broadcast) {
    if ( broadcast && !this.movement.user.isSelf ) throw new Error("Only the User that initiated the movement can stop it.");
    if ( this.movement.state === "stopped" ) return true;
    if ( this.movement.state === "completed" ) return false;
    this.#resetMovementContinuation();
    this.movement.user.movingTokens.delete(this);
    this.#movement = Object.freeze({...this.movement, state: "stopped", pending: Object.freeze({waypoints: Object.freeze([]),
      distance: 0, cost: 0, spaces: 0, diagonals: 0})});
    this._onMovementStopped();
    Hooks$1.callAll("stopToken", this);
    const movementId = this.movement.id;
    if ( broadcast instanceof Promise ) {
      broadcast.finally(() => this.update({}, {diff: false, noHook: true, _stopMovement: movementId}));
    }
    else if ( broadcast ) this.update({}, {diff: false, noHook: true, _stopMovement: movementId});
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Pause the movement of this Token document. The movement can be resumed after being paused.
   * Only the User that initiated the movement can pause it.
   * Returns a callback that can be used to resume the movement later.
   * Only after all callbacks and keys have been called the movement of the Token is resumed.
   * If the callback is called within the update operation workflow, the movement is resumed after the workflow.
   * @overload
   * @returns {TokenResumeMovementCallback|null}  The callback to resume movement if the movement was or is paused,
   *                                              otherwise null
   * @example
   * ```js
   * // This is an Execute Script Region Behavior that makes the token invisible
   * // On TOKEN_MOVE_IN...
   * if ( !event.user.isSelf ) return;
   * const resumeMovement = event.data.token.pauseMovement();
   * if ( event.data.token.rendered ) await event.data.token.object.movementAnimationPromise;
   * await event.data.token.actor.toggleStatusEffect("invisible", {active: true});
   * const resumed = await resumeMovement();
   * ```
   */
  /**
   * Pause the movement of this Token document. The movement can be resumed after being paused.
   * Only the User that initiated the movement can pause it.
   * Returns a promise that resolves to true if the movement was resumed by
   * {@link TokenDocument#resumeMovement} with the same key that was passed to this function.
   * Only after all callbacks and keys have been called the movement of the Token is resumed.
   * If the callback is called within the update operation workflow, the movement is resumed after the workflow.
   * @overload
   * @param {string} key               The key to resume movement with {@link TokenDocument#resumeMovement}
   * @returns {Promise<boolean>|null}  The continuation promise if the movement was paused, otherwise null
   * @example
   * ```js
   * // This is an Execute Script Region Behavior of a pressure plate that activates a trap
   * // On TOKEN_MOVE_IN...
   * if ( event.user.isSelf ) {
   *   event.data.token.pauseMovement(this.parent.uuid);
   * }
   * if ( game.user.isActiveGM ) {
   *   if ( event.data.token.rendered ) await event.data.token.object.movementAnimationPromise;
   *   const trapUuid; // The Region Behavior UUID of the trap
   *   const trapBehavior = await fromUuid(trapUuid);
   *   await trapBehavior.update({disabled: false});
   *   event.data.token.resumeMovement(event.data.movement.id, this.parent.uuid);
   * }
   * ```
   */
  pauseMovement(key) {
    const paused = this.#pauseMovement(true);
    if ( !paused ) return null;

    // Create or get continuation callback/promise
    const continuation = this._movementContinuation;
    if ( continuation.continued ) return null;
    continuation.continueCounter++;
    const movementId = this.movement.id;
    const state = continuation.states[movementId] ??= {handles: new Map(), callbacks: [], pending: new Set()};
    /** @type {TokenMovementContinuationHandle} */
    const handle = {movementId, callback: undefined, promise: undefined};
    if ( state.handles.has(key) ) {
      throw new Error(`TokenDocument#pauseMovement was already called with key "${key}" for the current movement`);
    }
    state.handles.set(key ?? Symbol(""), handle);
    const callback = async () => {
      if ( handle.continuePromise ) return handle.continuePromise;
      await continuation.postWorkflowPromise;
      if ( handle.continuePromise ) return handle.continuePromise;
      continuation.continueCounter--;
      if ( continuation.continueCounter !== 0 ) {
        if ( continuation.movementId === handle.movementId ) {
          return handle.continuePromise = continuation.continuePromise;
        }
        return handle.continuePromise = new Promise(resolve => {
          continuation.states[handle.movementId].callbacks.push(resolve);
        });
      }
      return handle.continuePromise = this.#continueMovement(handle.movementId);
    };
    if ( key === undefined ) return handle.callback = callback;
    const promise = new Promise((resolve, reject) => {
      handle.callback = () => callback().then(resolve).catch(reject);
    });
    if ( state.pending.delete(key) ) handle.callback();
    return promise;
  }

  /* -------------------------------------------- */

  /**
   * Pause the movement of this Token document.
   * @param {boolean} broadcast    Broadcast?
   * @returns {boolean}            True if the movement was or is paused, otherwise false.
   */
  #pauseMovement(broadcast) {
    if ( broadcast && !this.movement.user.isSelf ) throw new Error("Only the User that initiated the movement can pause it.");
    if ( this.movement.state === "paused" ) return true;
    if ( this.movement.state !== "pending") return false;
    this.#movement = Object.freeze({...this.movement, state: "paused"});
    this._onMovementPaused();
    Hooks$1.callAll("pauseToken", this);
    if ( broadcast ) this.update({}, {diff: false, noHook: true, _pauseMovement: this.movement.id});
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Resume the movement given its ID and the key that was passed to {@link TokenDocument#pauseMovement}.
   * @param {string} movementId    The movement ID
   * @param {string} key           The key that was passed to {@link TokenDocument#pauseMovement}
   */
  resumeMovement(movementId, key) {
    if ( this.movement.user.isSelf ) {
      const continuation = this._movementContinuation;
      let state = continuation.states[movementId];
      if ( !state ) {
        if ( this.movement.id !== movementId ) return;
        if ( this.movement.state !== "pending" ) return;
        state = {handles: new Map(), callbacks: [], pending: new Set()};
        continuation.states[movementId] = state;
      }
      const handle = state.handles.get(key);
      if ( handle ) handle.callback();
      else state.pending.add(key);
    }
    else this.update({}, {diff: false, noHook: true, _resumeMovement: [movementId, key]});
  }

  /* -------------------------------------------- */

  /**
   * Continue the movement of this Token document after the current movement animation of the Token is completed.
   * The movement is not continued if the Token's positional state is different from the state associated
   * with the given movement ID.
   * @param {string} movementId     The movement ID
   * @returns {Promise<boolean>}    True if the movement was continued, otherwise false
   */
  async #continueMovement(movementId) {
    const continuation = this._movementContinuation;

    // Check whether the token movement is still in the position we expect it to be
    if ( this.movement.id !== movementId ) return this.#resolveMovementContinuation(movementId, false);

    // Don't continue movement twice
    if ( continuation.continuePromise ) {
      if ( continuation.movementId !== movementId ) {
        continuation.movementId = movementId;
        continuation.continuePromise = null;
        continuation.continuePromise = this.#continueMovement(movementId);
      }
      return continuation.continuePromise;
    }

    // Wait for the movement animation to finish first
    await continuation.waitPromise;

    // Check whether the token movement is still in the position we expect it to be
    if ( this.movement.id !== movementId ) return this.#resolveMovementContinuation(movementId, false);

    // We are continuing the movement
    continuation.continued = true;

    // Check whether the token's movement was stopped
    if ( this.movement.state === "stopped" ) return this.#resolveMovementContinuation(movementId, false);

    // Check whether the token document was deleted
    if ( this.parent?.tokens.get(this.id) !== this ) return this.#resolveMovementContinuation(movementId, false);

    // Continue token movement
    const waypoints = this.movement.pending.waypoints.filter(waypoint => !waypoint.intermediate);
    const {method, autoRotate, showRuler, constrainOptions} = this.movement;
    const updateOptions = foundry.utils.deepClone(this.movement.updateOptions);
    updateOptions.movement = {[this.id]: {waypoints, method, constrainOptions, autoRotate, showRuler}};
    const args = {result: false, movementId: movementId, unrecorded: null,
      chain: [...this.movement.chain, this.movement.id]};
    if ( !this.movement.recorded ) {
      args.unrecorded = this.movement.history.unrecorded.waypoints.concat(this.movement.passed.waypoints);
    }
    updateOptions._movementArguments = args;
    const updated = await this.update({}, updateOptions);
    return this.#resolveMovementContinuation(movementId, args.result && !!updated);
  }

  /* -------------------------------------------- */

  /**
   * Resolve the movement continuation.
   * @param {string} movementId     The movement ID
   * @param {boolean} result        True if the movement was continued, otherwise false
   * @returns {boolean}             The result that was passed
   */
  #resolveMovementContinuation(movementId, result) {
    const continuation = this._movementContinuation;
    const state = continuation.states[movementId];
    if ( !state ) return result;
    delete continuation.states[movementId];
    for ( const handle of state.handles.values() ) handle.continuePromise ??= Promise.resolve(result);
    for ( const callback of state.callbacks ) callback(result);
    return result;
  }

  /* -------------------------------------------- */

  /**
   * Reset the movement continuation.
   */
  #resetMovementContinuation() {
    const continuation = this._movementContinuation;
    if ( continuation.continued ) continuation.continued = false;
    else if ( continuation.movementId ) this.#resolveMovementContinuation(continuation.movementId, false);
    continuation.continueCounter = 0;
    continuation.resolveWaitPromise?.();
    continuation.resolveWaitPromise = undefined;
  }

  /* -------------------------------------------- */

  /**
   * Measure the movement path for this Token.
   * @param {TokenMeasureMovementPathWaypoint[]} waypoints     The waypoints of movement
   * @param {object} [options]                                 Additional measurement options
   * @param {TokenMovementCostFunction} [options.cost]         The function that returns the cost
   *   for a given move between grid spaces (default is the distance travelled along the direct path)
   * @param {TokenMovementCostAggregator} [options.aggregator] The cost aggregator.
   *                                                           Default: `CONFIG.Token.movement.costAggregator`.
   * @returns {GridMeasurePathResult}
   */
  measureMovementPath(waypoints, {cost, aggregator}={}) {
    aggregator ??= CONFIG.Token.movement.costAggregator;
    const grid = this.parent?.grid ?? foundry.documents.BaseScene.defaultGrid;
    const path = [];
    let {x: previousX, y: previousY, elevation: previousElevation,
      width: previousWidth, height: previousHeight, shape: previousShape} = this._source;
    let previousAction = this.movementAction;
    let anchorX;
    let anchorY;

    // Create the path for measurement
    for ( let i = 0; i < waypoints.length; i++ ) {
      let {x=previousX, y=previousY, elevation=previousElevation, width=previousWidth,
        height=previousHeight, shape=previousShape, action=previousAction, terrain=null, cost: c} = waypoints[i];
      x = Math.round(x);
      y = Math.round(y);
      const waypoint = {x, y, elevation, width, height, shape};

      // If in snapped position, use the exact instead of rounded snapped position to prevent measurement inaccuracies
      const snapped = this.getSnappedPosition(waypoint);
      if ( (Math.round(snapped.x) === waypoint.x) && (Math.round(snapped.y) === waypoint.y) ) {
        waypoint.x = snapped.x;
        waypoint.y = snapped.y;
      }

      // Calculate the anchor
      if ( grid.isGridless ) {
        anchorX = 0;
        anchorY = 0;
      } else if ( grid.isSquare ) {
        anchorX = grid.size * (Number.isInteger(width) ? 0.5 : 0.25);
        anchorY = grid.size * (Number.isInteger(height) ? 0.5 : 0.25);
      } else {
        const {anchor} = BaseToken._getHexagonalOffsets(width, height, shape, grid.columns);
        anchorX = grid.sizeX * anchor.x;
        anchorY = grid.sizeY * anchor.y;
      }

      const resize = (i > 0) && ((width !== previousWidth) || (height !== previousHeight) || (shape !== previousShape));
      if ( resize ) {
        const pivot = this.getCenterPoint({x: 0, y: 0, elevation: 0, width, height, shape});
        const center = this.getCenterPoint({x: previousX, y: previousY, elevation: previousElevation,
          width: previousWidth, height: previousHeight, shape: previousShape});
        const waypoint = {x: Math.round(center.x - pivot.x), y: Math.round(center.y - pivot.y),
          elevation: center.elevation - pivot.elevation, width, height, shape};
        const snapped = this.getSnappedPosition(waypoint);
        if ( (Math.round(snapped.x) === waypoint.x) && (Math.round(snapped.y) === waypoint.y) ) {
          waypoint.x = snapped.x;
          waypoint.y = snapped.y;
        }
        waypoint.x += anchorX;
        waypoint.y += anchorY;
        waypoint.action = undefined; // Identify resize segments by undefined action
        waypoint.measure = false; // Resizing segments are not measured
        path.push(waypoint);
      }

      // Measure between anchor points, which lie in the top-left grid offset of the token at this position
      waypoint.x += anchorX;
      waypoint.y += anchorY;
      waypoint.action = action;
      const actionConfig = CONFIG.Token.movement.actions[action] ?? CONFIG.Token.movement.actions.displace;
      waypoint.actionConfig = actionConfig;
      waypoint.teleport = actionConfig.teleport;
      waypoint.measure = actionConfig.measure;
      waypoint.terrain = terrain;
      // TODO: optimize (use the same function for same size)
      waypoint.cost = TokenDocument.#createMovementCostFunction(grid, c ?? cost, aggregator, width, height, shape);

      path.push(waypoint);

      previousX = x;
      previousY = y;
      previousElevation = elevation;
      previousWidth = width;
      previousHeight = height;
      previousShape = shape;
      previousAction = action;
    }

    const result = grid.measurePath(path);
    TokenDocument.#removeResizeSegmentsFromMeasurementResult(path, result);
    return result;
  }

  /* -------------------------------------------- */

  /**
   * Remove waypoints that were added to measure the path correctly.
   * @param {(ElevatedPoint & GridMeasurePathWaypointData3D & TokenMovementSegmentData)[]} path   The measurement path
   * @param {GridMeasurePathResult} result                                                        The measurement result
   */
  static #removeResizeSegmentsFromMeasurementResult(path, result) {
    let removed = false;
    for ( let i = 1; i < path.length - 1; i++ ) {
      if ( path[i].action !== undefined ) continue;

      // This is a waypoint added for resizing
      const {backward: {from}, forward} = result.waypoints[i];
      from.forward = forward;
      forward.from = from;
      removed = true;
    }
    if ( !removed ) return;
    let waypoint = result.waypoints[0];
    result.waypoints.length = 0;
    result.waypoints.push(waypoint);
    result.segments.length = 0;
    while ( waypoint.forward ) {
      waypoint = waypoint.forward.to;
      result.waypoints.push(waypoint);
      result.segments.push(waypoint.backward);
    }
  }

  /* -------------------------------------------- */

  /**
   * Create the cost function for {@link foundry.grid.BaseGrid#measurePath}.
   * The `from` and `to` parameters of the cost function are top-left offsets
   * ({@link foundry.documents.BaseToken##getTopLeftGridOffset}).
   * @param {BaseGrid} grid                                    The grid
   * @param {TokenMovementCostFunction|number|undefined} cost  The cost function for a single step or predetermined cost
   * @param {TokenMovementCostAggregator} aggregator           The cost aggregator
   * @param {number} width                                     The width in grid spaces (positive)
   * @param {number} height                                    The height in grid spaces (positive)
   * @param {TokenShapeType} shape                             The shape (one of {@link CONST.TOKEN_SHAPES})
   * @returns {TokenMovementCostFunction|number|undefined}     The cost function for measuring
   */
  static #createMovementCostFunction(grid, cost, aggregator, width, height, shape) {
    if ( cost === undefined ) return undefined;

    // Predetermined cost
    if ( typeof cost !== "function" ) return Number(cost);

    // In gridless grids we use the cost function directly
    if ( grid.isGridless ) return cost;

    // For 1x1 and 0.5x0.5 token we can use the cost function directly
    if ( (width === height) && (width <= 1) ) return cost;

    // Square grid
    if ( grid.isSquare ) return TokenDocument.#createSquareMovementCostFunction(cost, aggregator, width, height);

    // Hexagonal grid
    return TokenDocument.#createHexagonalMovementCostFunction(grid, cost, aggregator, width, height, shape);
  }

  /* -------------------------------------------- */

  /**
   * Create the cost function for {@link foundry.grid.SquareGrid#measurePath}.
   * @param {TokenMovementCostFunction} cost         The cost function for a single step
   * @param {TokenMovementCostAggregator} aggregator The cost aggregator
   * @param {number} width                           The width in grid spaces (positive)
   * @param {number} height                          The height in grid spaces (positive)
   * @returns {TokenMovementCostFunction}            The combined cost function
   */
  static #createSquareMovementCostFunction(cost, aggregator, width, height) {
    const w = Math.ceil(width);
    const h = Math.ceil(height);
    const results = [];
    for ( let l = w * h; l > 0; l-- ) {
      results.push({from: {i: 0, j: 0, k: 0}, to: {i: 0, j: 0, k: 0}, cost: 0});
    }
    return (from, to, distance, segment) => {
      const {i: i0, j: j0} = from;
      const {i: i1, j: j1} = to;
      for ( let di = 0, l = 0; di < height; di++ ) {
        for ( let dj = 0; dj < width; dj++, l++ ) {
          const result = results[l];
          const {from: o0, to: o1} = result;
          o0.i = i0 + di;
          o0.j = j0 + dj;
          o1.i = i1 + di;
          o1.j = j1 + dj;
          result.cost = cost(o0, o1, distance, segment);
        }
      }
      return aggregator(results, distance, segment);
    };
  }

  /* -------------------------------------------- */

  /**
   * Create the cost function for {@link foundry.grid.HexagonalGrid#measurePath}.
   * @param {HexagonalGrid} grid                       The hexagonal grid
   * @param {TokenMovementCostFunction} cost           The cost function for a single step
   * @param {TokenMovementCostAggregator} aggregator   The cost aggregator
   * @param {number} width                             The width in grid spaces (positive)
   * @param {number} height                            The height in grid spaces (positive)
   * @param {TokenShapeType} shape                     The shape type (one of {@link CONST.TOKEN_SHAPES})
   * @returns {TokenMovementCostFunction}              The combined cost function
   */
  static #createHexagonalMovementCostFunction(grid, cost, aggregator, width, height, shape) {
    const {columns, even} = grid;
    const {even: offsetsEven, odd: offsetsOdd} = BaseToken._getHexagonalOffsets(width, height, shape, columns);
    const results = [];
    for ( let l = offsetsEven.length; l > 0; l-- ) {
      results.push({from: {i: 0, j: 0, k: 0}, to: {i: 0, j: 0, k: 0}, cost: 0});
    }
    return (from, to, distance, segment) => {
      const {i: i0, j: j0} = from;
      const isEven0 = ((columns ? j0 : i0) % 2 === 0) === even;
      const offsets0 = isEven0 ? offsetsEven : offsetsOdd;
      const {i: i1, j: j1} = to;
      const isEven1 = ((columns ? j1 : i1) % 2 === 0) === even;
      const offsets1 = isEven1 ? offsetsEven : offsetsOdd;
      for ( let l = results.length - 1; l >= 0; l-- ) {
        const result = results[l];
        const {from: o0, to: o1} = result;
        const {i: di0, j: dj0} = offsets0[l];
        o0.i = i0 + di0;
        o0.j = j0 + dj0;
        const {i: di1, j: dj1} = offsets1[l];
        o1.i = i1 + di1;
        o1.j = j1 + dj1;
        result.cost = cost(o0, o1, distance, segment);
      }
      return aggregator(results, distance, segment);
    };
  }

  /* -------------------------------------------- */

  /**
   * Get the path of movement with the intermediate steps of the direct path between waypoints.
   * @param {TokenGetCompleteMovementPathWaypoint[]} waypoints    The waypoints of movement
   * @returns {TokenCompleteMovementWaypoint[]}                   The path of movement with all intermediate steps
   */
  getCompleteMovementPath(waypoints) {
    const grid = this.parent?.grid ?? foundry.documents.BaseScene.defaultGrid;
    const path = [];
    let from;
    let {x: previousX, y: previousY, elevation: previousElevation,
      width: previousWidth, height: previousHeight, shape: previousShape} = this._source;
    let previousAction = this.movementAction;

    for ( let i = 0; i < waypoints.length; i++ ) {
      let {x=previousX, y=previousY, elevation=previousElevation, width=previousWidth, height=previousHeight,
        shape=previousShape, action=previousAction, terrain=null, snapped=false, explicit=false,
        checkpoint=false, intermediate=false} = waypoints[i];
      x = Math.round(x);
      y = Math.round(y);
      if ( terrain ) terrain = terrain.clone();
      const to = this._positionToGridOffset({x, y, elevation, width, height, shape});

      if ( (i > 0) && !CONFIG.Token.movement.actions[action].teleport ) {
        let s = 1;
        if ( ((width !== previousWidth) || (height !== previousHeight) || (shape !== previousShape)) ) {
          const pivot = this.getCenterPoint({x: 0, y: 0, elevation: 0, width, height, shape});
          const center = this.getCenterPoint({x: previousX, y: previousY, elevation: previousElevation,
            width: previousWidth, height: previousHeight, shape: previousShape});
          from = this._positionToGridOffset({x: center.x - pivot.x, y: center.y - pivot.y,
            elevation: center.elevation - pivot.elevation, width, height, shape});
          s = 0;
        }
        const dimensions = {width, height, shape};
        const steps = grid.getDirectPath([from, to]);
        for ( ; s < steps.length - 1; s++ ) {
          const offset = steps[s];
          const {x, y, elevation} = this._gridOffsetToPosition(offset, dimensions);
          path.push({x: Math.round(x), y: Math.round(y), elevation, width, height, shape, action,
            terrain: terrain ? terrain.clone() : null, snapped: true, explicit: false, checkpoint: false,
            intermediate: true});
        }
      }

      path.push({x, y, elevation, width, height, shape, action, terrain, snapped, explicit, checkpoint,
        intermediate});

      from = to;
      previousX = x;
      previousY = y;
      previousElevation = elevation;
      previousWidth = width;
      previousHeight = height;
      previousShape = shape;
      previousAction = action;
    }
    return path;
  }

  /* -------------------------------------------- */
  /*  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 = foundry.utils.getDocumentClass("Combat");
        combat = await cls.create({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<TokenDocument|undefined>}    The updated Document instance, or undefined not updated.
   */
  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");
    }
    const 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._source._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;
    await this.#preUpdateMovement(changed, options);
  }

  /* -------------------------------------------- */

  /**
   * Handle movement pre update.
   * @param {object} changed    The candidate changes to the Document
   * @param {object} options    Additional options which modify the update request
   */
  async #preUpdateMovement(changed, options) {

    // Prevent movement history from being changed directly except for explicit reset
    if ( !options.isUndo ) delete changed._movementHistory;
    if ( options._clearMovementHistory ) changed._movementHistory = [];

    // Prepare movement waypoints
    let {waypoints, method, constrainOptions, autoRotate, showRuler} = options.movement?.[this.id] ?? {};
    if ( waypoints && (waypoints.length === 0) ) waypoints = undefined;
    if ( !waypoints && TokenDocument._isMovementUpdate(changed) ) {
      waypoints = TokenDocument.#inferMovementWaypoints(this, changed, options);
    }
    if ( !waypoints ) {
      for ( const k of TokenDocument.MOVEMENT_FIELDS ) delete changed[k];
      return;
    }
    method ??= (options.isPaste ? "paste" : (options.isUndo ? "undo" : "api"));
    if ( !["api", "config", "dragging", "keyboard", "paste", "undo"].includes(method)
      || ((method === "paste") !== !!options.isPaste)
      || ((method === "undo") !== !!options.isUndo) ) {
      throw new Error("Invalid movement method");
    }
    constrainOptions = foundry.utils.deepClone(constrainOptions) ?? {};
    if ( autoRotate !== undefined ) autoRotate = !!autoRotate;
    if ( showRuler !== undefined ) showRuler = !!showRuler;

    // Set default options based on the method of movement
    if ( (method === "paste") || (method === "undo") ) {
      autoRotate = false;
      showRuler = false;
    } else if ( method === "keyboard" ) {
      autoRotate ??= game.settings.get("core", "tokenAutoRotate");
      showRuler ??= false;
    } else if ( method === "dragging" ) {
      autoRotate ??= game.settings.get("core", "tokenAutoRotate");
      showRuler ??= true;
    } else {
      autoRotate ??= false;
      showRuler ??= false;
    }

    // Create post workflow promise if it wasn't already
    options._movement ??= {};
    if ( !options._movement._postWorkflow ) {
      Object.defineProperty(options._movement, "_postWorkflow", {value: {}});
      options._movement._postWorkflow.promise = new Promise(resolve => {
        options._movement._postWorkflow.resolve = resolve;
      });
    }

    // Clean and validate waypoints
    waypoints = this.#cleanAndValiateMovementWaypoints(waypoints);

    // Get the origin of movement
    const origin = Object.freeze({
      x: this._source.x,
      y: this._source.y,
      elevation: this._source.elevation,
      width: this._source.width,
      height: this._source.height,
      shape: this._source.shape
    });

    // Set movement speed based on the animation duration
    foundry.canvas.placeables.Token._configureAnimationMovementSpeed(options, origin, waypoints, this);

    // Generate movement ID
    const movementId = foundry.utils.randomID();

    const recorded = !options.isUndo && this._shouldRecordMovementHistory();
    const recordedHistory = this._source._movementHistory.map(waypoint => ({...waypoint,
      terrain: waypoint.terrain ? CONFIG.Token.movement.TerrainData.fromSource(waypoint.terrain) : null,
      cost: waypoint.cost ?? Infinity}));
    const combined = recordedHistory.concat(recorded ? [] : options._movementArguments?.unrecorded ?? []);

    // Add the initial waypoint or a teleport waypoint if there's a gap if we previously moved without recording
    const previous = combined.at(-1);
    if ( !previous || !TokenDocument.arePositionsEqual(previous, origin) ) {
      const {x, y, elevation, width, height, shape} = origin;
      combined.push({x, y, elevation, width, height, shape, action: previous ? "displace" : waypoints[0].action,
        terrain: null, snapped: false, explicit: false, checkpoint: true, intermediate: false,
        userId: game.user.id, movementId, cost: 0});
    }

    // Set unrecorded history
    const unrecordedHistory = combined.slice(recordedHistory.length);

    // Split the path of movement at the first checkpoint
    let passed;
    let pending;
    let constrained = false;
    if ( options.isPaste || options.isUndo ) [passed, pending] = [waypoints, []];
    else ({passed, pending} = this.#splitMovementPath(origin, waypoints));

    // Regionalize and constrain movement
    if ( !(options.isPaste || options.isUndo) && this.rendered ) {

      // Regionalize the passed movement
      passed = this.object.createTerrainMovementPath([origin, ...passed]);
      passed.shift();

      // Contrain the path of movement
      const [constrainedPath, wasConstrained] = this.object.constrainMovementPath([origin, ...passed],
        {...constrainOptions, preview: false, history: combined});
      if ( wasConstrained ) {
        constrainedPath.shift();
        passed = constrainedPath;
        pending.length = 0;
        constrained = true;
      }
    }

    // Movement was constrained and impossible entirely
    if ( passed.length === 0 ) {
      for ( const k of TokenDocument.MOVEMENT_FIELDS ) delete changed[k];
      if ( options._movementArguments?.movementId === this._movementContinuation.movementId ) {
        this.#stopMovement(options._movement._postWorkflow);
      }
      return;
    }

    // Force destination to be a checkpoint
    const destination = passed.at(-1);
    destination.checkpoint = true;

    // Assign the checkpoint target to the changes object
    for ( const k of TokenDocument.MOVEMENT_FIELDS ) changed[k] = destination[k];

    // Regionalize the pending movement
    if ( (pending.length !== 0) && this.rendered ) {
      pending = this.object.createTerrainMovementPath([destination, ...pending], {preview: true});
      pending.shift();
    }

    // Measure the distances and spaces of the history, passed waypoints, and pending waypoints
    const nonintermediateRecordedHistory = TokenDocument.#filterNonintermediateWaypoints(recordedHistory);
    const nonintermediateUnrecordedHistory = TokenDocument.#filterNonintermediateWaypoints(unrecordedHistory);
    const nonintermediatePassed = passed;
    const nonintermediatePending = pending;
    const [
      recordedHistoryDistance, recordedHistorySpaces, recordedHistoryDiagonals,
      unrecordedHistoryDistance, unrecordedHistorySpaces, unrecordedHistoryDiagonals,
      passedDistance, passedSpaces, passedDiagonals,
      pendingDistance, pendingSpaces, pendingDiagonals
    ] = this.#measureMovementSectionDistances(nonintermediateRecordedHistory, nonintermediateUnrecordedHistory,
      nonintermediatePassed, nonintermediatePending);

    // Add intermediate waypoints
    passed = this.getCompleteMovementPath([origin, ...passed]);
    passed.shift();
    pending = this.getCompleteMovementPath([destination, ...pending]);
    pending.shift();

    // Add the passed waypoints
    for ( const waypoint of passed ) {
      waypoint.userId = game.user.id;
      waypoint.movementId = movementId;
      combined.push(waypoint);
    }

    // Measure movement cost
    let passedCost = 0;
    let pendingCost = 0;
    let measurement;
    if ( this.rendered ) {

      // First measure the true cost of passed waypoints
      measurement = this.object.measureMovementPath(combined);
      for ( let i = combined.length - passed.length; i < combined.length; i++ ) {
        passedCost += combined[i].cost = measurement.waypoints[i].backward.cost;
      }

      // Then add pending waypoints and measure the preview cost of pending waypoints
      if ( pending.length !== 0 ) {
        for ( const waypoint of pending ) {
          waypoint.userId = game.user.id;
          waypoint.movementId = movementId;
          combined.push(waypoint);
        }
        measurement = this.object.measureMovementPath(combined, {preview: true});
        for ( let i = combined.length - pending.length; i < combined.length; i++ ) {
          pendingCost += combined[i].cost = measurement.waypoints[i].backward.cost;
        }
      }
    } else {
      for ( const waypoint of passed ) waypoint.cost = 0;
      for ( const waypoint of pending ) {
        waypoint.userId = game.user.id;
        waypoint.movementId = movementId;
        waypoint.cost = 0;
        combined.push(waypoint);
      }
      measurement = this.measureMovementPath(combined);
    }
    let recordedHistoryCost = 0;
    for ( const waypoint of recordedHistory ) {
      recordedHistoryCost += waypoint.cost;
    }
    let unrecordedHistoryCost = 0;
    for ( const waypoint of unrecordedHistory ) {
      unrecordedHistoryCost += waypoint.cost;
    }

    /**
     * @type {DeepReadonly<Omit<TokenMovementOperation, "autoRotate"|"showRuler">>
     *   & Pick<TokenMovementOperation, "autoRotate"|"showRuler">}
     */
    const move = Object.seal({
      id: movementId,
      chain: options._movementArguments?.chain ?? [],
      origin,
      destination: ({x: destination.x, y: destination.y, elevation: destination.elevation,
        width: destination.width, height: destination.height, shape: destination.shape}),
      passed: {
        waypoints: passed,
        distance: passedDistance,
        cost: passedCost,
        spaces: passedSpaces,
        diagonals: passedDiagonals
      },
      pending: {
        waypoints: pending,
        distance: pendingDistance,
        cost: pendingCost,
        spaces: pendingSpaces,
        diagonals: pendingDiagonals
      },
      history: {
        recorded: {
          waypoints: recordedHistory,
          distance: recordedHistoryDistance,
          cost: recordedHistoryCost,
          spaces: recordedHistorySpaces,
          diagonals: recordedHistoryDiagonals
        },
        unrecorded: {
          waypoints: unrecordedHistory,
          distance: unrecordedHistoryDistance,
          cost: unrecordedHistoryCost,
          spaces: unrecordedHistorySpaces,
          diagonals: unrecordedHistoryDiagonals
        },
        distance: recordedHistoryDistance + unrecordedHistoryDistance,
        cost: recordedHistoryCost + unrecordedHistoryCost,
        spaces: recordedHistorySpaces + unrecordedHistorySpaces,
        diagonals: recordedHistoryDiagonals + unrecordedHistoryDiagonals
      },
      constrained,
      recorded,
      method,
      constrainOptions,
      autoRotate,
      showRuler
    });

    // Freeze non-writeable movement operation properties
    for ( const [key, value] of Object.entries(move) ) {
      if ( TokenDocument.#WRITEABLE_MOVEMENT_OPERATION_PROPERTIES.includes(key) ) continue;
      Object.defineProperty(move, key, {value: foundry.utils.deepFreeze(value)});
    }

    // Allow systems/modules to reject the movement and to modify the operation as needed
    let movementAllowed = (await this._preUpdateMovement(move, options)) ?? true;
    movementAllowed &&= (options.noHook || Hooks$1.call("preMoveToken", this, move, options));
    if ( movementAllowed === false ) {
      for ( const k of TokenDocument.MOVEMENT_FIELDS ) delete changed[k];
      if ( options._movementArguments?.movementId === this._movementContinuation.movementId ) {
        this.#stopMovement(options._movement._postWorkflow);
      }
      return;
    }

    // Freeze writeable movement operation properties
    for ( const key of TokenDocument.#WRITEABLE_MOVEMENT_OPERATION_PROPERTIES ) {
      Object.defineProperty(move, key, {value: foundry.utils.deepFreeze(move[key])});
    }

    // Automatically set the rotation based of the last nonzero segment if the autoRotate option is set to true
    if ( move.autoRotate ) TokenDocument.#rotateInMovementDirection(this, changed, move);

    // Add a record of movement history to the set of database changes
    if ( recorded ) changed._movementHistory = combined.slice(0, combined.length - pending.length).map(
      waypoint => ({...waypoint, cost: waypoint.cost === Infinity ? null : waypoint.cost}));

    // Record the token movement operation instructions
    Object.defineProperty(options._movement, this.id, {value: move, enumerable: true});

    // Need to disable diffing to force the update to go through even in case when the movement is a loop
    options.diff = false;

    if ( options._movementArguments ) options._movementArguments.result = true;
    if ( options._movementArguments?.movementId !== undefined ) this._movementContinuation.continued = true;
    else this.#resetMovementContinuation();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {

    // 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
    this.#onUpdateMovement(changed, options, userId);

    const configs = Object.values(this.apps).filter(app => app instanceof foundry.applications.sheets.TokenConfig);
    configs.forEach(app => {
      if ( app._preview ) options.animate = false;
      app._previewChanges(changed);
    });

    // 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._source._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);
  }

  /* -------------------------------------------- */

  /**
   * Handle movement.
   * @param {object} changed    The changes
   * @param {object} options    Options which modified the update operation
   * @param {string} userId     The ID of the User who triggered the operation
   */
  #onUpdateMovement(changed, options, userId) {
    /** @type {TokenMovementOperation} */
    const movement = options._movement?.[this.id];

    // Update movement data
    if ( movement ) {

      // Create post workflow promise if it wasn't already
      if ( !options._movement._postWorkflow ) {
        Object.defineProperty(options._movement, "_postWorkflow", {value: {}});
        options._movement._postWorkflow.promise = new Promise(resolve => {
          options._movement._postWorkflow.resolve = resolve;
        });
      }

      // Impute waypoint costs as Infinity if undefined/null and create TerrainData models
      const imputeCostAndCreateTerrainData = waypoints => waypoints.forEach(w => {
        if ( w.terrain ) w.terrain = CONFIG.Token.movement.TerrainData.fromSource(w.terrain);
        w.cost ??= Infinity;
      });
      imputeCostAndCreateTerrainData(movement.passed.waypoints);
      imputeCostAndCreateTerrainData(movement.pending.waypoints);
      imputeCostAndCreateTerrainData(movement.history.recorded.waypoints);
      imputeCostAndCreateTerrainData(movement.history.unrecorded.waypoints);

      // Impute movement costs as Infinity if undefined/null
      movement.passed.cost ??= Infinity;
      movement.pending.cost ??= Infinity;
      movement.history.recorded.cost ??= Infinity;
      movement.history.unrecorded.cost ??= Infinity;
      movement.history.cost ??= Infinity;

      // Freeze movement data
      foundry.utils.deepFreeze(movement);

      // Stop current movement unless chained
      if ( this.movement.id !== movement.chain.at(-1) ) this.#stopMovement(false);

      // Update movement data
      this.#resetMovementContinuation();
      this._movementContinuation.postWorkflowPromise = options._movement._postWorkflow.promise;
      this.movement.user.movingTokens.delete(this);
      const user = game.users.get(userId);
      const state = movement.constrained ? "stopped" : (movement.pending.waypoints.length > 0 ? "pending" : "completed");
      if ( state === "pending" ) user.movingTokens.add(this);
      const {animate, animation, diff, noHook, pan, render, renderSheet, isPaste, isUndo} = options;
      const updateOptions = foundry.utils.deepFreeze({animate, animation, diff, noHook, pan, render, renderSheet,
        isPaste, isUndo});
      this.#movement = Object.freeze({...movement, user, state, updateOptions});
    }

    // Another user stopped movement
    else if ( options._stopMovement ) {
      if ( (options._stopMovement === this.movement.id) && !this.movement.user.isSelf ) this.#stopMovement(false);
    }

    // Another user paused movement
    else if ( options._pauseMovement ) {
      if ( (options._pauseMovement === this.movement.id) && !this.movement.user.isSelf ) this.#pauseMovement(false);
    }

    // Another user resumed movement
    else if ( options._resumeMovement ) {
      if ( this.movement.user.isSelf ) {
        const [movementId, key] = options._resumeMovement;
        this.resumeMovement(movementId, key);
      }
    }

    // Clearing of the movement history stops movement
    else if ( options._clearMovementHistory ) this.#stopMovement(false);

    // The movement was recorded
    if ( "_movementHistory" in changed ) {
      this._onMovementRecorded();
      Hooks$1.callAll("recordToken", this);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    this.#stopMovement(false);
    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 that will be applied to this Token
   * @returns {string[]}          The Region IDs this Token is in after the changes are applied (sorted)
   * @internal
   */
  _identifyRegions(changes={}) {
    const regionIds = [];
    for ( const region of this.parent.regions ) {
      const isInside = this.testInsideRegion(region, changes);
      if ( isInside ) regionIds.push(region.id);
    }
    return regionIds.sort();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static async _preCreateOperation(documents, operation, user) {
    const allowed = await super._preCreateOperation(documents, operation, user);
    if ( allowed === false ) return false;

    // Wipe movement history and identify the regions the token is in
    for ( const document of documents ) {
      document.updateSource({_movementHistory: [], _regions: document._identifyRegions()});
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static async _preUpdateOperation(documents, operation, user) {
    const allowed = await super._preUpdateOperation(documents, operation, user);
    if ( allowed === false ) return false;
    TokenDocument.#preUpdateOperationMovement(documents, operation, user);
    TokenDocument.#preUpdateOperationRegions(documents, operation);
  }

  /* -------------------------------------------- */

  /** @type {TOKEN_SHAPES[]} */
  static #VALID_SHAPES = Object.values(TOKEN_SHAPES);

  /** @type {Set<string>} */
  static #VALID_MOVEMENT_WAYPOINT_PROPERTIES = new Set(["x", "y", "elevation", "width", "height", "shape",
    "action", "snapped", "explicit", "checkpoint"]);

  /**
   * Clean and validate movement waypoints.
   * @param {Partial<TokenMovementWaypoint>[]} waypoints    The waypoints
   * @returns {TokenMovementWaypoint[]}                     The cleaned waypoints
   */
  #cleanAndValiateMovementWaypoints(waypoints) {
    waypoints = waypoints.slice();
    const {x, y, elevation, width, height, shape} = this._source;
    let previousWaypoint = {x, y, elevation, width, height, shape, action: this.movementAction};
    for ( let i = 0; i < waypoints.length; i++ ) {
      const waypoint = waypoints[i] = {...waypoints[i]};
      // TODO: create DataModel for this cleaning and validation
      if ( waypoint.x === undefined ) waypoint.x = previousWaypoint.x;
      else if ( typeof waypoint.x !== "number" ) throw new Error("x must be a number");
      else if ( !Number.isFinite(waypoint.x) ) throw new Error("x must be finite");
      else waypoint.x = Math.round(waypoint.x);
      if ( waypoint.y === undefined ) waypoint.y = previousWaypoint.y;
      else if ( typeof waypoint.y !== "number" ) throw new Error("y must be a number");
      else if ( !Number.isFinite(waypoint.y) ) throw new Error("y must be finite");
      else waypoint.y = Math.round(waypoint.y);
      if ( waypoint.elevation === undefined ) waypoint.elevation = previousWaypoint.elevation;
      else if ( typeof waypoint.elevation !== "number" ) throw new Error("elevation must be a number");
      else if ( !Number.isFinite(waypoint.elevation) ) throw new Error("elevation must be finite");
      if ( waypoint.width === undefined ) waypoint.width = previousWaypoint.width;
      else if ( typeof waypoint.width !== "number" ) throw new Error("width must be a number");
      else if ( !Number.isFinite(waypoint.width) ) throw new Error("width must be finite");
      else if ( !(waypoint.width > 0) ) throw new Error("width must be positive");
      if ( waypoint.height === undefined ) waypoint.height = previousWaypoint.height;
      else if ( typeof waypoint.height !== "number" ) throw new Error("height must be a number");
      else if ( !Number.isFinite(waypoint.height) ) throw new Error("height must be finite");
      else if ( !(waypoint.height > 0) ) throw new Error("height must be positive");
      if ( waypoint.shape === undefined ) waypoint.shape = previousWaypoint.shape;
      else if ( !TokenDocument.#VALID_SHAPES.includes(waypoint.shape) ) throw new Error("shape is invalid");
      if ( waypoint.action === undefined ) waypoint.action = previousWaypoint.action;
      else if ( typeof waypoint.action !== "string" ) throw new Error("action must be a string");
      else if ( !(waypoint.action in CONFIG.Token.movement.actions) ) throw new Error("action is invalid");
      if ( waypoint.snapped === undefined ) waypoint.snapped = false;
      else if ( typeof waypoint.snapped !== "boolean" ) throw new Error("snapped must be a boolean");
      if ( waypoint.explicit === undefined ) waypoint.explicit = false;
      else if ( typeof waypoint.explicit !== "boolean" ) throw new Error("explicit must be a boolean");
      if ( waypoint.checkpoint === undefined ) waypoint.checkpoint = false;
      else if ( typeof waypoint.checkpoint !== "boolean" ) throw new Error("checkpoint must be a boolean");
      for ( const key in waypoint ) {
        if ( !TokenDocument.#VALID_MOVEMENT_WAYPOINT_PROPERTIES.has(key) ) delete waypoint[key];
      }
      previousWaypoint = waypoint;
    }
    return waypoints;
  }

  /* -------------------------------------------- */

  /**
   * Finalize movement operation.
   * @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 #preUpdateOperationMovement(documents, operation, user) {
    delete operation.movement;
    operation._movement?._postWorkflow.resolve();
    if ( foundry.utils.isEmpty(operation._movement) ) delete operation._movement;
    delete operation._movementArguments;
    /** @deprecated since v13 */
    delete operation.teleport;
    /** @deprecated since v13 */
    delete operation.forced;
  }

  /* -------------------------------------------- */

  /**
   * Convert a set of {x, y, elevation} changes in a Token document update into an array of waypoints.
   * @param {TokenDocument} document
   * @param {Partial<TokenData>} changes
   * @param {DatabaseUpdateOperation} operation
   * @returns {TokenMovementWaypoint[]}
   */
  static #inferMovementWaypoints(document, changes, operation) {
    const x = changes.x ?? document._source.x;
    const y = changes.y ?? document._source.y;
    const elevation = changes.elevation ?? document._source.elevation;
    const width = changes.width ?? document._source.width;
    const height = changes.height ?? document._source.height;
    const shape = changes.shape ?? document._source.shape;
    /** @deprecated since v13 */
    if ( "teleport" in operation ) {
      foundry.utils.logCompatibilityWarning("DatabaseUpdateOperation#teleport has been deprecated. "
        + "Use DatabaseUpdateOperation#waypoints or TokenDocument#movement instead.", {since: 13, until: 15});
    }
    /** @deprecated since v13 */
    if ( "forced" in operation ) {
      foundry.utils.logCompatibilityWarning("DatabaseUpdateOperation#forced has been deprecated. "
        + "Use DatabaseUpdateOperation#waypoints or TokenDocument#movement instead.", {since: 13, until: 15});
    }
    const action = (operation.teleport === true) || (operation.isPaste === true) ? "displace" : undefined;
    return [{x, y, elevation, width, height, shape, action, snapped: false, explicit: false, checkpoint: true}];
  }

  /* -------------------------------------------- */

  /**
   * Filter nonintermediate waypoints.
   * @param {TokenMeasuredMovementWaypoint[]} waypoints    The waypoints
   * @returns {TokenMovementWaypoint[]}                    The nonintermediate waypoints
   */
  static #filterNonintermediateWaypoints(waypoints) {
    const path = [];
    for ( const {x, y, elevation, width, height, shape, action, snapped, explicit, checkpoint,
      intermediate} of waypoints ) {
      if ( intermediate ) continue;
      path.push({x, y, elevation, width, height, shape, action, snapped, explicit, checkpoint});
    }
    return path;
  }

  /* -------------------------------------------- */

  /**
   * Measure the distances and spaces of each movement path section.
   * @param {TokenPosition[]} recordedHistory      The recorded history waypoints
   * @param {TokenPosition[]} unrecordedHistory    The unrecorded history waypoints
   * @param {TokenPosition[]} passed               The passed waypoints
   * @param {TokenPosition[]} pending              The pending waypoints
   * @returns {[
   *   recordedHistoryDistance: number; recordedHistorySpaces: number; recordedHistoryDiagonals: number;
   *   unrecordedHistoryDistance: number; unrecordedHistorySpaces: number; unrecordedHistoryDiagonals: number;
   *   passedDistance: number; passedSpaces: number; passedDiagonals: number;
   *   pendingDistance: number; pendingSpaces: number; pendingDiagonals: number
   * ]}
   *   The history, passed, and pending distances and spaces
   */
  #measureMovementSectionDistances(recordedHistory, unrecordedHistory, passed, pending) {
    const measurement = this.measureMovementPath([...recordedHistory, ...unrecordedHistory, ...passed,
      ...pending]).waypoints;
    const i0 = recordedHistory.length - 1;
    const i1 = i0 + unrecordedHistory.length;
    const i2 = i1 + passed.length;
    const i3 = i2 + pending.length;
    const recordedHistoryDistance = measurement[i0]?.distance ?? 0;
    const unrecordedHistoryDistance = measurement[i1].distance - recordedHistoryDistance;
    const passedDistance = measurement[i2].distance - (recordedHistoryDistance + unrecordedHistoryDistance);
    const pendingDistance = measurement[i3].distance - (recordedHistoryDistance + unrecordedHistoryDistance
      + passedDistance);
    const recordedHistorySpaces = measurement[i0]?.spaces ?? 0;
    const unrecordedHistorySpaces = measurement[i1].spaces - recordedHistorySpaces;
    const passedSpaces = measurement[i2].spaces - (recordedHistorySpaces + unrecordedHistorySpaces);
    const pendingSpaces = measurement[i3].spaces - (recordedHistorySpaces + unrecordedHistorySpaces + passedSpaces);
    const recordedHistoryDiagonals = measurement[i0]?.diagonals ?? 0;
    const unrecordedHistoryDiagonals = measurement[i1].diagonals - recordedHistoryDiagonals;
    const passedDiagonals = measurement[i2].diagonals - (recordedHistoryDiagonals + unrecordedHistoryDiagonals);
    const pendingDiagonals = measurement[i3].diagonals - (recordedHistoryDiagonals + unrecordedHistoryDiagonals
      + passedDiagonals);
    return [
      recordedHistoryDistance, recordedHistorySpaces, recordedHistoryDiagonals,
      unrecordedHistoryDistance, unrecordedHistorySpaces, unrecordedHistoryDiagonals,
      passedDistance, passedSpaces, passedDiagonals,
      pendingDistance, pendingSpaces, pendingDiagonals
    ];
  }

  /* -------------------------------------------- */

  /**
   * Automatically set the rotation of the Token in the direction of the last nonzero movement segment
   * unless the rotation was explicity changed.
   * @param {TokenDocument} document
   * @param {Partial<TokenData>} changes
   * @param {TokenMovementOperation} movement
   */
  static #rotateInMovementDirection(document, changes, movement) {
    if ( "rotation" in changes ) return;
    const {origin, destination, passed, method} = movement;
    const isUndo = method === "undo";
    let c1 = document.getCenterPoint(destination);
    for ( let i = passed.waypoints.length - 1; i >= 0; i-- ) {
      const waypoint = i > 0 ? passed.waypoints[i - 1] : origin;
      if ( waypoint.intermediate ) continue;
      const c0 = document.getCenterPoint(waypoint);
      const ray = new foundry.canvas.geometry.Ray(c0, c1);
      if ( ray.distance > 0 ) {
        changes.rotation = Math.normalizeDegrees(Math.toDegrees(ray.angle) + (isUndo ? 90 : -90));
        break;
      }
      c1 = c0;
    }
  }

  /* -------------------------------------------- */

  /**
   * Split the given movement path at the first checkpoint.
   * This function adds nonexplict checkpoints to the path for Region that have active Behaviors that subscribe to
   * `TOKEN_MOVE_*` events as necessary.
   * @param {TokenPosition} origin                                                     The origin of movement
   * @param {TokenMovementWaypoint[]} waypoints                                        The waypoints of movement
   * @returns {{passed: TokenMovementWaypoint[]; pending: TokenMovementWaypoint[]}}    The split movement path
   */
  #splitMovementPath(origin, waypoints) {
    const regions = [];

    // Determine the initially active regions and where to add checkpoints
    for ( const region of this.parent.regions ) {

      // We add checkpoints if a region event handler could interact with the movement of this Token
      let toOut = false;
      let toIn = false;

      const E = REGION_EVENTS;
      for ( const behavior of region.behaviors ) {
        if ( behavior.disabled ) continue;
        toIn ||= behavior.hasEvent(E.TOKEN_MOVE_WITHIN) || behavior.hasEvent(E.TOKEN_MOVE_IN)
          || behavior.hasEvent(E.TOKEN_ENTER);
        toOut ||= behavior.hasEvent(E.TOKEN_MOVE_WITHIN) || behavior.hasEvent(E.TOKEN_MOVE_OUT)
          || behavior.hasEvent(E.TOKEN_EXIT);
      }

      const mask = (toOut << 0) | (toIn << 2);
      if ( mask === 0 ) continue;
      regions.push({region, mask});
    }

    const passed = [];
    const pending = [];
    let split = false;
    let previous = origin;
    const distancePixels = this.parent.dimensions.distancePixels;
    for ( let i = 0; i < waypoints.length; i++ ) {
      const current = waypoints[i];

      if ( split ) pending.push(current);
      else {

        // Find region waypoint
        let t0 = Infinity;
        let regionCheckpoint;
        const previousCenter = this.getCenterPoint(previous);
        const segment = [previous, current];
        for ( const data of regions ) {
          const {region, mask} = data;
          for ( const {type, to} of this.segmentizeRegionMovementPath(region, segment) ) {
            if ( mask & (1 << (type + 1)) ) {
              const center = this.getCenterPoint(to);
              const dx = center.x - previousCenter.x;
              const dy = center.y - previousCenter.y;
              const dz = (center.elevation - previousCenter.elevation) * distancePixels;
              const t = (dx * dx) + (dy * dy) + (dz * dz);
              if ( t < t0 ) {
                t0 = t;
                regionCheckpoint = to;
              }
              break;
            }
          }
        }

        if ( regionCheckpoint ) {

          // Skip the region waypoint if it matches the previous movement waypoint
          if ( TokenDocument.arePositionsEqual(regionCheckpoint, previous) && (previous !== origin) ) {
            previous.checkpoint = true;
            pending.push(current);
          }

          // Skip the region waypoint if it matches the current movement waypoint
          else if ( TokenDocument.arePositionsEqual(regionCheckpoint, current) ) {
            current.checkpoint = true;
            passed.push(current);
          }

          else {
            passed.push({x: regionCheckpoint.x, y: regionCheckpoint.y, elevation: regionCheckpoint.elevation,
              width: regionCheckpoint.width, height: regionCheckpoint.height, shape: regionCheckpoint.shape,
              action: current.action, snapped: false, explicit: false, checkpoint: true});
            pending.push(current);
          }

          split = true;
        } else {
          passed.push(current);
          split = current.checkpoint;
        }
      }

      previous = current;
    }

    return {passed, pending};
  }

  /* -------------------------------------------- */

  /**
   * Reject the movement or modify the update operation as needed based on the movement.
   * Called after the movement for this document update has been determined.
   * The waypoints of movement are final and cannot be changed. The movement can only be rejected entirely.
   * @param {DeepReadonly<Omit<TokenMovementOperation, "autoRotate"|"showRuler">>
   *   & Pick<TokenMovementOperation, "autoRotate"|"showRuler">} movement    The pending movement of this Token
   * @param {Partial<DatabaseUpdateOperation>} operation                     The update operation
   * @returns {Promise<boolean|void>}                                        If false, the movement is prevented
   * @protected
   */
  async _preUpdateMovement(movement, operation) {}

  /* -------------------------------------------- */

  /**
   * Post-process an update operation of a movement.
   * @param {DeepReadonly<TokenMovementOperation>} movement    The movement of this Token
   * @param {Partial<DatabaseUpdateOperation>} operation       The update operation
   * @param {User} user                                        The User that requested the update operation
   * @protected
   */
  _onUpdateMovement(movement, operation, user) {}

  /* -------------------------------------------- */

  /**
   * Called when the current movement is stopped.
   * @protected
   */
  _onMovementStopped() {
    if ( this.rendered ) this.object.renderFlags.set({refreshRuler: true});
  }

  /* -------------------------------------------- */

  /**
   * Called when the current movement is paused.
   * @protected
   */
  _onMovementPaused() {
    if ( this.rendered ) this.object.renderFlags.set({refreshRuler: true});
  }

  /* -------------------------------------------- */

  /**
   * Called when the movement is recorded or cleared.
   * @protected
   */
  _onMovementRecorded() {
    if ( this.rendered ) this.object.renderFlags.set({refreshRuler: true});
  }

  /* -------------------------------------------- */

  /**
   * 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 #preUpdateOperationRegions(documents, operation) {
    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: REGION_EVENTS.TOKEN_ENTER,
          data: {token, movement: null},
          region,
          user
        });
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Add deprecated getters for the teleport and forced option.
   * @param {DatabaseUpdateOperation} operation
   * @internal
   * @deprecated since v13
   */
  static _addTeleportAndForcedShims(operation) {
    if ( !operation._movement ) return;
    Object.defineProperties(operation, {
      /** @deprecated since v13 */
      teleport: {
        get() {
          foundry.utils.logCompatibilityWarning("DatabaseUpdateOperation#teleport has been deprecated. "
            + " Override TokenDocument#_onUpdateMovement or hook 'moveToken' to handle movement.", {since: 13, until: 15});
          return Object.values(operation._movement).every(movement => CONFIG.Token.movement.actions[
            movement.passed.waypoints.at(-1).action].teleport);
        },
        configurable: true
      },
      /** @deprecated since v13 */
      forced: {
        get() {
          foundry.utils.logCompatibilityWarning("DatabaseUpdateOperation#forced has been deprecated. "
            + " Override TokenDocument#_onUpdateMovement or hook 'moveToken' to handle movement.", {since: 13, until: 15});
          return Object.values(operation._movement).every(movement => movement.passed.waypoints.at(-1).action === "displace");
        }
      }
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static async _onUpdateOperation(documents, operation, user) {
    TokenDocument._addTeleportAndForcedShims(operation);
    TokenDocument.#onUpdateOperationMovement(documents, operation, user);
    TokenDocument.#onUpdateHandleMoveWithinRegionEvents(documents, operation, user);
    TokenDocument.#onUpdateHandleEnterExitMoveInOutRegionEvents(documents, operation, user);
    // noinspection ES6MissingAwait
    TokenDocument.#onUpdateContinueMovement(documents, operation, user);
  }

  /* -------------------------------------------- */

  /**
   * Handle TOKEN_ENTER, TOKEN_EXIT, TOKEN_MOVE_IN, and TOKEN_MOVE_OUT region events.
   * @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 #onUpdateHandleEnterExitMoveInOutRegionEvents(documents, operation, user) {
    if ( !operation._priorRegions ) return; // Position did not change
    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);
      const movement = operation._movement?.[token.id] ?? null;
      for ( const region of removedRegions ) {
        // noinspection ES6MissingAwait
        region._handleEvent({
          name: REGION_EVENTS.TOKEN_EXIT,
          data: {token, movement},
          region,
          user
        });
      }
      for ( const region of addedRegions ) {
        // noinspection ES6MissingAwait
        region._handleEvent({
          name: REGION_EVENTS.TOKEN_ENTER,
          data: {token, movement},
          region,
          user
        });
      }
      if ( movement ) {
        for ( const region of removedRegions ) {
          token.#handleMoveRegionEvent(REGION_EVENTS.TOKEN_MOVE_OUT, region, user, movement);
        }
        for ( const region of addedRegions ) {
          token.#handleMoveRegionEvent(REGION_EVENTS.TOKEN_MOVE_IN, region, user, movement);
        }
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle {@link TokenDocument#_onUpdateMovement} and `moveToken` hook calls.
   * @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 #onUpdateOperationMovement(documents, operation, user) {
    if ( !operation._movement ) return;
    for ( const document of documents ) {
      const movement = operation._movement[document.id];
      if ( !movement ) continue;
      document._onUpdateMovement(movement, operation, user);
      Hooks$1.callAll("moveToken", document, movement, operation, user);
      if ( (document.movement.id === movement.id) && movement.constrained ) {
        document._onMovementStopped();
        Hooks$1.callAll("stopToken", document);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle TOKEN_MOVE_WITHIN region events.
   * @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 #onUpdateHandleMoveWithinRegionEvents(documents, operation, user) {
    if ( !operation._movement ) return;
    for ( const token of documents ) {
      const movement = operation._movement[token.id];
      if ( !movement ) continue;
      const priorRegionIds = operation._priorRegions?.[token.id];

      // Don't trigger MOVE_WITHIN events if the token just moved into the region and trigger MOVE_WITHIN
      // events for movement out of the region at the exit position
      if ( priorRegionIds ) {
        for ( const id of priorRegionIds ) {
          const region = token.parent.regions.get(id);
          if ( !region ) continue;
          token.#handleMoveRegionEvent(REGION_EVENTS.TOKEN_MOVE_WITHIN, region, user, movement);
        }
      } else {
        for ( const region of token.regions ) {
          token.#handleMoveRegionEvent(REGION_EVENTS.TOKEN_MOVE_WITHIN, region, user, movement);
        }
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle move region event.
   * @param {Extract<RegionEventType, "tokenMoveIn"|"tokenMoveOut"|"tokenMoveWithin">} name The region event type
   * @param {RegionDocument} region             The region
   * @param {User} user                         The user
   * @param {TokenMovementOperation} movement        The movement operation
   */
  #handleMoveRegionEvent(name, region, user, movement) {
    const token = this;
    const data = {token, movement};
    if ( name !== REGION_EVENTS.TOKEN_MOVE_WITHIN ) {
      Object.defineProperties(data, {
        /** @deprecated since v13 */
        segments: {
          get() {
            const segments = token.segmentizeRegionMovementPath(region, [this.movement.origin,
              ...this.movement.passed.waypoints]);
            Object.defineProperty(this, "segments", {get() {
              foundry.utils.logCompatibilityWarning("The segments property is deprecated. "
                + "Use the passed waypoints instead.", {since: 13, until: 15});
              return segments;
            }});
            return this.segments;
          },
          configurable: true
        },
        /** @deprecated since v13 */
        teleport: {
          get() {
            foundry.utils.logCompatibilityWarning("The teleport property is deprecated. "
              + "Use the action property of the passed waypoints instead.", {since: 13, until: 15});
            return CONFIG.Token.movement.actions[this.movement.passed.waypoints.at(-1).action].teleport;
          }
        },
        /** @deprecated since v13 */
        forced: {
          get() {
            foundry.utils.logCompatibilityWarning("The forced property is deprecated. "
              + "Use the action property of the passed waypoints instead.", {since: 13, until: 15});
            return this.movement.passed.waypoints.at(-1).action === "displace";
          }
        }
      });
    }
    // noinspection ES6MissingAwait
    region._handleEvent({name, data, region, user});
  }

  /* -------------------------------------------- */

  /**
   * Continue movement for tokens which are on a multi-checkpoint path of waypoints.
   * @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
   * @returns {Promise<void>}                     This function must not be awaited!
   */
  static async #onUpdateContinueMovement(documents, operation, user) {
    if ( !operation._movement ) return;
    operation._movement._postWorkflow?.resolve();
    if ( !user.isSelf ) return; // Movement is continued by the user that initiated it
    const toContinueMaybe = [];
    for ( const token of documents ) {
      const movement = operation._movement[token.id];
      if ( !movement ) continue;
      const movementId = movement.id;

      // Protect against edge cases where movement changed during update workflow
      if ( token.movement.id !== movementId ) continue;

      // No pending movement
      if ( token.movement.state !== "pending" ) continue;

      toContinueMaybe.push({token, movementId});
    }
    if ( !toContinueMaybe.length ) return;

    // Break out of the current update workflow
    await new Promise(resolve => {
      setTimeout(resolve, 0);
    });

    // Continue movement if it was not paused before the movement animation completes
    for ( const {token, movementId} of toContinueMaybe ) {

      // Movement has changed
      if ( token.movement.id !== movementId ) return;

      // No pending movement
      if ( token.movement.state !== "pending" ) return;

      const maybeContinue = () => {

        // Movement has changed
        if ( token.movement.id !== movementId ) return;

        // No pending movement
        if ( token.movement.state !== "pending" ) return;

        token.#continueMovement(movementId);
      };
      // noinspection ES6MissingAwait
      if ( token._movementContinuation.waitPromise ) token._movementContinuation.waitPromise.then(maybeContinue);
      else maybeContinue();
    }
  }

  /* -------------------------------------------- */

  /** @override */
  static async _onDeleteOperation(documents, operation, user) {

    // Handle Region events
    const regionEvents = [];
    for ( const token of documents ) {
      for ( const region of token.regions ) {
        region.tokens.delete(token);
        regionEvents.push({
          name: REGION_EVENTS.TOKEN_EXIT,
          data: {token, movement: null},
          region,
          user
        });
      }
      token.regions.clear();
    }
    for ( const event of regionEvents ) {
      // noinspection ES6MissingAwait
      event.region._handleEvent(event);
    }

    // Update/delete Combatants
    if ( user.isSelf ) foundry.documents.Combat._onDeleteTokens(documents, operation, user);
  }

  /* -------------------------------------------- */

  /**
   * Are these changes moving the Token?
   * @overload
   * @param {object} changes    The (candidate) changes
   * @returns {boolean}         Is movement?
   * @internal
   */
  /**
   * Are these changes moving the Token from the given origin?
   * @overload
   * @param {object} changes          The (candidate) changes
   * @param {TokenPosition} origin    The origin
   * @returns {boolean}               Is movement?
   * @internal
   */
  static _isMovementUpdate(changes, origin) {
    if ( !origin ) return this.MOVEMENT_FIELDS.some(k => k in changes);
    return this.MOVEMENT_FIELDS.some(k => (k in changes) && (changes[k] !== origin[k]));
  }

  /* -------------------------------------------- */

  /**
   * Should the movement of this Token update be recorded in the movement history?
   * Called as part of the preUpdate workflow if the Token is moved.
   * @returns {boolean}    Should the movement of this Token update be recorded in the movement history?
   * @protected
   */
  _shouldRecordMovementHistory() {
    const combatant = this.combatant;
    if ( !combatant ) return false;
    return combatant.parent.started;
  }

  /* -------------------------------------------- */

  /**
   * Clear the movement history of this Token.
   * @returns {Promise<void>}
   */
  async clearMovementHistory() {
    if ( this._source._movementHistory.length === 0 ) return;
    await this.update({}, {diff: false, noHook: true, _clearMovementHistory: true});
  }

  /* -------------------------------------------- */

  /**
   * Is the 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) {
    return TokenDocument._isMovementUpdate(changes);
  }

  /* -------------------------------------------- */

  /**
   * Test whether the Token is inside the Region.
   * This function determines the state of {@link TokenDocument#regions} and
   * {@link foundry.documents.RegionDocument#tokens}.
   * The Token and the Region must be in the same Scene.
   *
   * Implementations of this function are restricted in the following ways:
   *   - If the bounds (given by {@link TokenDocument#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.
   *   - This function must not use prepared field values that are animated. In particular, it must use the source
   *     instead of prepared values of the following fields: `x`, `y`, `elevation`, `width`, `height`, and `shape`.
   *
   * If this function is overridden, then {@link TokenDocument#segmentizeRegionMovementPath} must be
   * overridden too.
   *
   * If an override of this function uses Token document fields other than `x`, `y`, `elevation`, `width`, `height`, and
   * `shape`, {@link TokenDocument#_couldRegionsChange} must be overridden to return true for changes
   * of these fields. If an override of this function uses non-Token properties other than `Scene#grid.type` and
   * `Scene#grid.size`,
   * {@link foundry.documents.Scene#updateTokenRegions} must be called when any of those properties change.
   * @overload
   * @param {RegionDocument} region                              The region.
   * @returns {boolean}                                          Is inside the Region?
   */
  /**
   * @overload
   * @param {RegionDocument} region                              The region.
   * @param {(Partial<ElevatedPoint & TokenDimensions>)} data    The position and dimensions. Defaults to the values of
   *                                                             the document source.
   * @returns {boolean}                                          Is inside the Region?
   */
  testInsideRegion(region, data={}) {
    if ( !this.parent || (this.parent !== region.parent) ) throw new Error("The Token and the Region must be in the same Scene");
    const {x, y, elevation, width, height, shape} = this._source;
    const inside = region.testPoint(this.getCenterPoint({x, y, elevation, width, height, shape, ...data}));
    return inside;
  }

  /* -------------------------------------------- */

  /**
   * Split the Token movement path through the Region into its segments.
   * The Token and the Region must be in the same Scene.
   *
   * 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 TokenDocument#testInsideRegion}) within the Region
   *     at the *from* and *to* of MOVE segments.
   *   - The Token must be contained (w.r.t. {@link TokenDocument#testInsideRegion}) within the Region
   *     at the *to* position of ENTER segments.
   *   - The Token must be contained (w.r.t. {@link TokenDocument#testInsideRegion}) within the Region
   *     at the *from* position of EXIT segments.
   *   - The Token must not be contained (w.r.t. {@link TokenDocument#testInsideRegion}) within the
   *     Region at the *from* position of ENTER segments.
   *   - The Token must not be contained (w.r.t. {@link TokenDocument#testInsideRegion}) within the
   *     Region at the *to* position of EXIT segments.
   *   - This function must not use prepared field values that are animated. In particular, it must use the source
   *     instead of prepared values of the following fields: `x`, `y`, `elevation`, `width`, `height`, and `shape`.
   * @param {RegionDocument} region                           The region
   * @param {TokenSegmentizeMovementWaypoint[]} waypoints     The waypoints of movement
   * @returns {TokenRegionMovementSegment[]}                  The movement split into its segments
   */
  segmentizeRegionMovementPath(region, waypoints) {
    if ( !this.parent || (this.parent !== region.parent) ) throw new Error("The Token and the Region must be in the same Scene");
    if ( waypoints.length <= 1 ) return [];
    const samples = [{x: 0, y: 0}];
    const segments = [];
    const source = this._source;
    let {x=source.x, y=source.y, elevation=source.elevation,
      width: previousWidth=source.width, height: previousHeight=source.height,
      shape: previousShape=source.shape, action: previousAction=this.movementAction} = waypoints[0];
    let from = {x, y, elevation};
    for ( let i = 1; i < waypoints.length; i++ ) {
      let {x=from.x, y=from.y, elevation=from.elevation, width=previousWidth, height=previousHeight,
        shape=previousShape, action=previousAction, terrain=null, snapped=false} = waypoints[i];
      x = Math.round(x);
      y = Math.round(y);
      const to = {x, y, elevation, teleport: CONFIG.Token.movement.actions[action].teleport};
      const pivot = this.getCenterPoint({x: 0, y: 0, elevation, width, height, shape});
      samples[0].x = pivot.x;
      samples[0].y = pivot.y;

      if ( (width !== previousWidth) || (height !== previousHeight) || (shape !== previousShape) ) {
        const center = this.getCenterPoint({x: from.x, y: from.y, elevation: from.elevation,
          width: previousWidth, height: previousHeight, shape: previousShape});
        from.x = Math.round(center.x - pivot.x);
        from.y = Math.round(center.y - pivot.y);
        from.elevation = center.elevation - pivot.elevation;
      }

      for ( const segment of region.segmentizeMovementPath([from, to], samples) ) {
        delete segment.teleport;
        segment.action = action;
        segment.terrain = terrain ? terrain.clone() : null;
        segment.snapped = snapped;
        const {from, to} = segment;
        from.width = width;
        from.height = height;
        from.shape = shape;
        to.width = width;
        to.height = height;
        to.shape = shape;
        segments.push(segment);
      }

      from = to;
      previousWidth = width;
      previousHeight = height;
      previousShape = shape;
      previousAction = action;
    }
    return segments;
  }

  /* -------------------------------------------- */
  /*  Actor Delta Operations                      */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _preCreateDescendantDocuments(parent, collection, data, options, userId) {
    // 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.
    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={}]                               The update delta
   * @param {Partial<DatabaseUpdateOperation>} [options={}]    The database operation that was performed
   * @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.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|object[]} [update] The update delta
   * @param {Partial<DatabaseOperation>} [operation] The database operation that was performed
   * @protected
   */
  _onRelatedUpdate(update={}, operation={}) {
    // Update tracked Combat resource
    const combatant = this.combatant;
    if ( combatant ) {
      const isActorUpdate = [this, null, undefined].includes(operation.parent);
      const resource = game.combat.settings.resource;
      const updates = Array.isArray(update) ? update : [update];
      if ( isActorUpdate && resource && updates.some(u => foundry.utils.hasProperty(u.system ?? {}, resource)) ) {
        combatant.updateResource();
      }
      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 TokenConfig = foundry.applications.sheets.TokenConfig;
      const configs = foundry.applications.instances.values().filter(a => a instanceof TokenConfig);
      for ( const app of configs ) {
        app._preview?.updateSource({delta: this.toObject().delta}, {diff: false, recursive: false});
        app._preview?.object?.renderFlags.set({refreshBars: true, redrawEffects: true});
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * 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 ( const [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 ( const [k, v] of Object.entries(data) ) {
      const 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 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}));
  }
}

/**
 * The client-side Wall document which extends the common BaseWall document model.
 * @extends BaseWall
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Scene}: The Scene document type which contains Wall documents
 * @see {@link foundry.applications.sheets.WallConfig}: The Wall configuration application
 */
class WallDocument extends CanvasDocumentMixin(BaseWall) {}

/** @module documents */

var _module$W = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActiveEffect: ActiveEffect$1,
  Actor: Actor$1,
  ActorDelta: ActorDelta$1,
  Adventure: Adventure$1,
  AmbientLightDocument: AmbientLightDocument,
  AmbientSoundDocument: AmbientSoundDocument,
  BaseActiveEffect: BaseActiveEffect,
  BaseActor: BaseActor,
  BaseActorDelta: BaseActorDelta,
  BaseAdventure: BaseAdventure,
  BaseAmbientLight: BaseAmbientLight,
  BaseAmbientSound: BaseAmbientSound,
  BaseCard: BaseCard,
  BaseCards: BaseCards,
  BaseChatMessage: BaseChatMessage,
  BaseCombat: BaseCombat,
  BaseCombatant: BaseCombatant,
  BaseCombatantGroup: BaseCombatantGroup,
  BaseDrawing: BaseDrawing,
  BaseFogExploration: BaseFogExploration,
  BaseFolder: BaseFolder,
  BaseItem: BaseItem,
  BaseJournalEntry: BaseJournalEntry,
  BaseJournalEntryCategory: BaseJournalEntryCategory,
  BaseJournalEntryPage: BaseJournalEntryPage,
  BaseMacro: BaseMacro,
  BaseMeasuredTemplate: BaseMeasuredTemplate,
  BaseNote: BaseNote,
  BasePlaylist: BasePlaylist,
  BasePlaylistSound: BasePlaylistSound,
  BaseRegion: BaseRegion,
  BaseRegionBehavior: BaseRegionBehavior,
  BaseRollTable: BaseRollTable,
  BaseScene: BaseScene,
  BaseSetting: BaseSetting,
  BaseTableResult: BaseTableResult,
  BaseTile: BaseTile,
  BaseToken: BaseToken,
  BaseUser: BaseUser,
  BaseWall: BaseWall,
  Card: Card$1,
  Cards: Cards$1,
  ChatMessage: ChatMessage$1,
  Combat: Combat$1,
  Combatant: Combatant$1,
  CombatantGroup: CombatantGroup$1,
  DrawingDocument: DrawingDocument,
  FogExploration: FogExploration$1,
  Folder: Folder$1,
  Item: Item$1,
  JournalEntry: JournalEntry$1,
  JournalEntryCategory: JournalEntryCategory$1,
  JournalEntryPage: JournalEntryPage$1,
  Macro: Macro$1,
  MeasuredTemplateDocument: MeasuredTemplateDocument,
  NoteDocument: NoteDocument,
  Playlist: Playlist$1,
  PlaylistSound: PlaylistSound$1,
  RegionBehavior: RegionBehavior$1,
  RegionDocument: RegionDocument,
  RollTable: RollTable$1,
  Scene: Scene$1,
  Setting: Setting$1,
  TableResult: TableResult$1,
  TileDocument: TileDocument,
  TokenDocument: TokenDocument,
  User: User$1,
  WallDocument: WallDocument,
  abstract: _module$_,
  collections: _module$Y,
  types: _types$i
});

/**
 * @typedef CombatConfigurationData
 * Default combat tracker settings used in Foundry VTT.
 * @property {string} resource            A resource identifier for the tracker.
 * @property {boolean} skipDefeated       Whether to skip defeated tokens during combat.
 * @property {object} turnMarker          Turn marker configuration.
 * @property {boolean} turnMarker.enabled     Whether the turn marker is enabled.
 * @property {string} turnMarker.path         The file path for the turn marker icon.
 * @property {string} turnMarker.animation    The identifier for the default turn marker animation.
 * @property {string} turnMarker.disposition  Tint the turn marker according to token disposition.
 */

/**
 * @typedef CalendarConfig
 * @property {string} name                                The name of the calendar being used.
 * @property {string} description                         A text description of the calendar configuration.
 * @property {CalendarConfigYears} years                  Configuration of years.
 * @property {CalendarConfigMonths|null} months           Configuration of months.
 * @property {CalendarConfigDays} days                    Configuration of days.
 * @property {CalendarConfigSeasons|null} seasons         Configuration of seasons.
 */

/**
 * @typedef CalendarConfigYears
 * A definition of a year within a calendar.
 * @property {number} [yearZero=0]                        The year which is presented as 0 when formatting a time
 *                                                        into a string representation.
 * @property {number} [firstWeekday=0]                    The index of days.values that is the first weekday at time=0
 * @property {CalendarConfigLeapYear|null} [leapYear]     A definition of how leap years work within a calendar.
 */

/**
 * @typedef CalendarConfigLeapYear
 * A definition of how leap years work within a calendar.
 * @property {number} leapStart                           The year number of the first leap year.
 * @property {number} leapInterval                        The number of years between leap years.
 */

/**
 * @typedef CalendarConfigMonths
 * Month related configuration for a calendar.
 * @property {CalendarConfigMonth[]} values               An array of months in the calendar year.
 */

/**
 * @typedef CalendarConfigMonth
 * A definition of a month within a calendar year.
 * @property {string} name                                The full name of the month.
 * @property {string} [abbreviation]                      The abbreviated name of the month.
 * @property {number} ordinal                             The ordinal position of this month in the year.
 * @property {number} days                                The number of days in the month.
 * @property {number} [leapDays]                          The number of days in the month during a leap year.
 *                                                        If not defined the value of days is used.
 * @property {number} [dayOffset=0]                       The amount to offset day numbers for this month.
 * @property {boolean} [intercalary=false]                If this month is an intercalary month.
 * @property {number|null} [startingWeekday=null]         The day of the week this month should always start on.
 *                                                        If the value is null the month will start on the next weekday
 *                                                        after the previous month
 */

/**
 * @typedef CalendarConfigDays
 * Day related configuration for a calendar.
 * @property {CalendarConfigDay[]} values                 The configuration of the days of the week.
 * @property {number} [daysPerYear=365]                   The number of days in a year.
 * @property {number} [hoursPerDay=24]                    The number of hours in a day.
 * @property {number} [minutesPerHour=60]                 The number of minutes in an hour.
 * @property {number} [secondsPerMinute=60]               The number of seconds in a minute.
 */

/**
 * @typedef CalendarConfigDay
 * A definition of the days of the week within a calendar.
 * @property {string} name                                The full name of the weekday.
 * @property {string} [abbreviation]                      The abbreviated name of the weekday.
 * @property {number} ordinal                             The ordinal position of this weekday in the week.
 * @property {boolean} [isRestDay=false]                  Is this weekday considered a rest day (weekend)?
 */

/**
 * @typedef CalendarConfigSeasons
 * Season related configuration for a calendar.
 * @property {CalendarConfigSeason[]} values              An array of seasons in the calendar year.
 */

/**
 * @typedef CalendarConfigSeason
 * A definition of a season within a calendar year. By default, seasons can be defined as aligning to either months or
 * specific ranges of days. A range in either months or in days must be specified.
 * @property {string} name                                The full name of the season.
 * @property {string} [abbreviation]                      The abbreviated name of the season.
 * @property {number|null} [monthStart]                   An ordinal month at the beginning of which the season starts.
 * @property {number|null} [monthEnd]                     An ordinal month at the end of which the season starts.
 * @property {number|null} [dayStart]                     A day of the year at the beginning of which the season starts.
 * @property {number|null} [dayEnd]                       A day of the year at the end of which the season ends.
 */

/**
 * @typedef TimeComponents
 * A decomposition of the integer world time in seconds into component parts.
 * Each component expresses the number of that temporal unit since the time=0 epoch.
 * @property {number} year                                The number of years completed since zero
 * @property {number} day                                 The number of days completed within the year
 * @property {number} hour                                The number of hours completed within the year
 * @property {number} minute                              The number of minutes completed within the hour
 * @property {number} second                              The number of seconds completed within the minute
 * @property {number} month                               The month, an index of the months.values array
 * @property {number} dayOfMonth                          The day of the month, starting from zero
 * @property {number} dayOfWeek                           The weekday, an index of the days.values array
 * @property {number} season                              The season, an index of the seasons.values array
 * @property {boolean} leapYear                           Is it a leap year?
 */

/**
 * @callback TimeFormatter
 * @param {CalendarData} calendar                         The configured calendar
 * @param {TimeComponents} components                     Time components to format
 * @param {object} options                                Additional formatting options
 * @returns {string}                                      The returned string format
 */

var _types$h = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * A specialized subclass of DataModel, intended to represent a Document's type-specific data.
 * Systems or Modules that provide DataModel implementations for sub-types of Documents (such as Actors or Items)
 * should subclass this class instead of the base DataModel class.
 *
 * @see {@link foundry.abstract.Document}
 * @template {object} [ModelData=object]
 * @extends {DataModel<ModelData>}
 * @abstract
 *
 * @example Registering a custom sub-type for a Module.
 *
 * **module.json**
 * ```json
 * {
 *   "id": "my-module",
 *   "esmodules": ["main.mjs"],
 *   "documentTypes": {
 *     "Actor": {
 *       "sidekick": {},
 *       "villain": {}
 *     },
 *     "JournalEntryPage": {
 *       "dossier": {},
 *       "quest": {
 *         "htmlFields": ["description"]
 *       }
 *     }
 *   }
 * }
 * ```
 *
 * **main.mjs**
 * ```js
 * Hooks.on("init", () => {
 *   Object.assign(CONFIG.Actor.dataModels, {
 *     "my-module.sidekick": SidekickModel,
 *     "my-module.villain": VillainModel
 *   });
 *   Object.assign(CONFIG.JournalEntryPage.dataModels, {
 *     "my-module.dossier": DossierModel,
 *     "my-module.quest": QuestModel
 *   });
 * });
 *
 * class QuestModel extends foundry.abstract.TypeDataModel {
 *   static defineSchema() {
 *     const fields = foundry.data.fields;
 *     return {
 *       description: new fields.HTMLField({required: false, blank: true, initial: ""}),
 *       steps: new fields.ArrayField(new fields.StringField())
 *     };
 *   }
 *
 *   prepareDerivedData() {
 *     this.totalSteps = this.steps.length;
 *   }
 * }
 * ```
 *
 * **en.json** To provide the localization for methods like {@link foundry.ClientDocument.createDialog |
 * ClientDocument.createDialog}
 * ```json
 * {
 *   "TYPES": {
 *     "Actor": {
 *       "sidekick": "Sidekick",
 *       "villain": "Villain"
 *     },
 *     "JournalEntryPage": {
 *       "dossier": "Dossier",
 *       "quest": "Quest"
 *     }
 *   }
 * }
 * ```
 */
class TypeDataModel extends DataModel {

  /** @inheritdoc */
  constructor(data={}, options={}) {
    super(data, options);

    /**
     * The package that is providing this DataModel for the given sub-type.
     * @type {System|Module|null}
     */
    Object.defineProperty(this, "modelProvider", {value: TypeDataField.getModelProvider(this), writable: false});
  }

  /** @inheritdoc*/
  static LOCALIZATION_PREFIXES = [];

  /* ---------------------------------------- */

  /** @override */
  static get schema() {
    if ( this.hasOwnProperty("_schema") ) return this._schema;
    const schema = super.schema;
    schema.name = "system";
    return schema;
  }

  /* -------------------------------------------- */

  /**
   * Prepare data related to this DataModel itself, before any derived data (including Active Effects)
   * is computed. This is especially useful for initializing numbers, arrays, and sets you expect to be
   * modified by active effects.
   *
   * Called before {@link foundry.ClientDocument.prepareBaseData | ClientDocument#prepareBaseData} in
   * {@link foundry.ClientDocument.prepareData | ClientDocument#prepareData}.
   *
   * @example
   * ```js
   * prepareBaseData() {
   *   // Ensures an active effect of `system.encumbrance.max | ADD | 10` doesn't produce `NaN`
   *   this.encumbrance = {
   *     max: 0
   *   }
   *   // If you need to access the owning Document, `this.parent` provides a reference for properties like the name
   *   // or embedded collections, e.g. `this.parent.name` or `this.parent.items`
   * }
   * ```
   */
  prepareBaseData() {}

  /* -------------------------------------------- */

  /**
   * Apply transformations or derivations to the values of the source data object.
   * Compute data fields whose values are not stored to the database.
   *
   * Called before {@link foundry.ClientDocument.prepareDerivedData | ClientDocument#prepareDerivedData} in
   * {@link foundry.ClientDocument.prepareData | ClientDocument#prepareData}.
   *
   * @example
   * ```js
   * prepareDerivedData() {
   *   this.hp.bloodied = Math.floor(this.hp.max / 2);
   *
   *   // this.parent accesses the Document, allowing access to embedded collections
   *   this.encumbrance.value = this.parent.items.reduce((total, item) => {
   *     total += item.system.weight;
   *     return total;
   *   }, 0)
   * }
   * ```
   */
  prepareDerivedData() {}

  /* -------------------------------------------- */

  /**
   * Convert this 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<HTMLDocumentEmbedElement|HTMLElement|HTMLCollection|null>}
   */
  async toEmbed(config, options={}) {
    return null;
  }

  /* -------------------------------------------- */
  /*  Database Operations                         */
  /* -------------------------------------------- */

  /**
   * Called by ClientDocument#_preCreate.
   *
   * @param {object} data                         The initial data object provided to the document creation request
   * @param {object} options                      Additional options which modify the creation request
   * @param {documents.BaseUser} user             The User requesting the document creation
   * @returns {Promise<boolean|void>}             Return false to exclude this Document from the creation operation
   * @internal
   */
  async _preCreate(data, options, user) {}

  /* -------------------------------------------- */

  /**
   * Called by ClientDocument#_onCreate.
   *
   * @param {object} data                         The initial data object provided to the document creation request
   * @param {object} options                      Additional options which modify the creation request
   * @param {string} userId                       The id of the User requesting the document update
   * @protected
   */
  _onCreate(data, options, userId) {}

  /* -------------------------------------------- */

  /**
   * Called by ClientDocumentMixin#_preUpdate.
   *
   * @param {object} changes            The candidate changes to the Document
   * @param {object} options            Additional options which modify the update request
   * @param {documents.BaseUser} user   The User requesting the document update
   * @returns {Promise<boolean|void>}   A return value of false indicates the update operation should be cancelled.
   * @protected
   */
  async _preUpdate(changes, options, user) {}

  /* -------------------------------------------- */

  /**
   * Called by ClientDocumentMixin#_onUpdate.
   *
   * @param {object} changed            The differential data that was changed relative to the documents prior values
   * @param {object} options            Additional options which modify the update request
   * @param {string} userId             The id of the User requesting the document update
   * @protected
   */
  _onUpdate(changed, options, userId) {}

  /* -------------------------------------------- */


  /**
   * Called by ClientDocumentMixin#_preDelete.
   *
   * @param {object} options            Additional options which modify the deletion request
   * @param {documents.BaseUser} user   The User requesting the document deletion
   * @returns {Promise<boolean|void>}   A return value of false indicates the deletion operation should be cancelled.
   * @protected
   */
  async _preDelete(options, user) {}

  /* -------------------------------------------- */

  /**
   * Called by ClientDocumentMixin#_onDelete.
   *
   * @param {object} options            Additional options which modify the deletion request
   * @param {string} userId             The id of the User requesting the document update
   * @protected
   */
  _onDelete(options, userId) {}
}

/**
 * @import {TokenMovementWaypoint} from "../../_types.mjs";
 * @import {RegionDocument, RegionBehavior, Scene} from "../../documents/_module.mjs";
 * @import {RegionEvent} from "@client/documents/_types.mjs";
 */

/**
 * The data model for a behavior that receives Region events.
 * @abstract
 *
 * @property {Set<string>} events    The Region events that are handled by the behavior.
 */
class RegionBehaviorType extends TypeDataModel {

  /**
   * Create the events field.
   * @param {object} options      Options which configure how the events field is declared
   * @param {string[]} [options.events]     The event names to restrict to.
   * @param {string[]} [options.initial]    The initial set of events that should be default for the field
   * @returns {fields.SetField}
   * @protected
   */
  static _createEventsField({events, initial}={}) {
    const setFieldOptions = {
      label: "BEHAVIOR.TYPES.base.FIELDS.events.label",
      hint: "BEHAVIOR.TYPES.base.FIELDS.events.hint"
    };
    if ( initial ) setFieldOptions.initial = initial;
    const field = new SetField(new StringField({
      required: true,
      choices: Object.values(CONST.REGION_EVENTS).concat(/** @deprecated since v13 */ ["behaviorStatus", "tokenMove", "tokenPreMove"])
        .reduce((obj, e) => {
          if ( events && !events.includes(e) ) return obj;
          obj[e] = `REGION.EVENTS.${e}.label`;
          return obj;
        }, {})
    }), setFieldOptions);
    field._toInput = function(config) {
      if ( !config.options ) {
        config.choices ??= this.element.choices;
        StringField._prepareChoiceConfig(config);
      }
      /** @deprecated since v13 */
      config.options = config.options.filter(({value}) => (value !== "behaviorStatus") && (value !== "tokenMove") && (value !== "tokenPreMove"));
      return foundry.applications.fields.createMultiSelectInput(config);
    };
    return field;
  }

  /* ---------------------------------------- */

  /**
   * A RegionBehaviorType may register to always receive certain events by providing a record of handler functions.
   * These handlers are called with the behavior instance as its bound scope.
   * @type {Record<string, (this: RegionBehaviorType, event: RegionEvent) => Promise<void>>}
   */
  static events = {};

  /* ---------------------------------------- */

  /**
   * The events that are handled by the behavior.
   * @type {Set<string>}
   */
  events = this.events ?? new Set();

  /* ---------------------------------------- */

  /**
   * A convenience reference to the RegionBehavior which contains this behavior sub-type.
   * @type {RegionBehavior|null}
   */
  get behavior() {
    return this.parent;
  }

  /* ---------------------------------------- */

  /**
   * A convenience reference to the RegionDocument which contains this behavior sub-type.
   * @type {RegionDocument|null}
   */
  get region() {
    return this.behavior?.region ?? null;
  }

  /* ---------------------------------------- */

  /**
   * A convenience reference to the Scene which contains this behavior sub-type.
   * @type {Scene|null}
   */
  get scene() {
    return this.behavior?.scene ?? null;
  }

  /* ---------------------------------------- */

  /**
   * Handle the Region event.
   * @param {RegionEvent} event    The Region event
   * @returns {Promise<void>}
   * @protected
   */
  async _handleRegionEvent(event) {}

  /* ---------------------------------------- */

  /**
   * Get the terrain effects of this behavior for the movement of the given token.
   * This function is called only for behaviors that are not disabled.
   * The terrain data is created from the terrain effects
   * ({@link CONFIG.Token.movement.TerrainData.resolveTerrainEffects}).
   * Returns an empty array by default.
   * @template TerrainEffect
   * @param {TokenDocument} token    The token being or about to be moved within the region of this behavior
   * @param {Pick<TokenMovementWaypoint, "width"|"height"|"shape"|"action"> & {preview: boolean}} segment
   *                                 The segment data of the token's movement
   * @returns {TerrainEffect[]}      The terrain effects that apply to this token's movement
   * @protected
   */
  _getTerrainEffects(token, segment) {
    return [];
  }
}

/**
 * A mixin which decorates a PIXI.Filter or PIXI.Shader with common properties.
 * @category Mixins
 * @param {typeof PIXI.Filter|typeof PIXI.Shader} ShaderClass The parent ShaderClass class being mixed.
 */
function BaseShaderMixin(ShaderClass) {
  class BaseShader 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); }
    `;

    /* -------------------------------------------- */

    /**
     * Simplex 3D noise functions
     * @type {string}
     */
    static SIMPLEX_3D = `
    vec4 permute(in vec4 x) {
      return mod(((x * 34.0) + 1.0) * x, 289.0);
    }

    vec4 taylorInvSqrt(in vec4 r) {
      return 1.79284291400159 - 0.85373472095314 * r;
    }

    float snoise(in vec3 v) {
      const vec2 C = vec2(1.0/6.0, 1.0/3.0);
      const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);

      vec3 i = floor(v + dot(v, C.yyy));
      vec3 x0 = v - i + dot(i, C.xxx);
      vec3 g = step(x0.yzx, x0.xyz);
      vec3 l = 1.0 - g;
      vec3 i1 = min(g.xyz, l.zxy);
      vec3 i2 = max(g.xyz, l.zxy);
      vec3 x1 = x0 - i1 + C.xxx;
      vec3 x2 = x0 - i2 + 2.0*C.xxx;
      vec3 x3 = x0 - 1.0 + 3.0*C.xxx;
      i = mod(i, 289.0);

      vec4 p = permute(
                 permute(
                   permute(i.z + vec4(0.0, i1.z, i2.z, 1.0))
                 + i.y + vec4(0.0, i1.y, i2.y, 1.0))
               + i.x + vec4(0.0, i1.x, i2.x, 1.0));

      float n_ = 1.0 / 7.0;
      vec3 ns = n_ * D.wyz - D.xzx;
      vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
      vec4 x_ = floor(j * ns.z);
      vec4 y_ = floor(j - 7.0 * x_);
      vec4 xx = x_ * ns.x + ns.yyyy;
      vec4 yy = y_ * ns.x + ns.yyyy;
      vec4 h = 1.0 - abs(xx) - abs(yy);
      vec4 b0 = vec4(xx.xy, yy.xy);
      vec4 b1 = vec4(xx.zw, yy.zw);
      vec4 s0 = floor(b0) * 2.0 + 1.0;
      vec4 s1 = floor(b1) * 2.0 + 1.0;
      vec4 sh = -step(h, vec4(0.0));
      vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
      vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
      vec3 p0 = vec3(a0.xy, h.x);
      vec3 p1 = vec3(a0.zw, h.y);
      vec3 p2 = vec3(a1.xy, h.z);
      vec3 p3 = vec3(a1.zw, h.w);
      vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
      p0 *= norm.x;
      p1 *= norm.y;
      p2 *= norm.z;
      p3 *= norm.w;

      vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
      m *= m;
      return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
    }
    `;

    /* -------------------------------------------- */

    /**
     * Conversion 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]               Number of octaves (iteration).
     * @param {string} [fbmFuncName="fbm"]       Name of the fbm function.
     * @param {string} [noiseFuncName="noise"]   Name of the noise function to use inside fbm (must return a `float`).
     * @param {string} [vecType="vec2"]          The vec type the function accepts as a parameter.
     * @returns {string} The formed fbm function
     */
    static FBMHQ(octaves=3, fbmFuncName="fbm", noiseFuncName="noise", vecType="vec2") {
      return `float ${fbmFuncName}(in ${vecType} 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 * ${noiseFuncName}(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 {@link PRNG2D}
     * @see {@link CONSTANTS}
     * @type {string}
     */
    static VORONOI = `
    vec3 voronoi(in vec2 uv, in float t, in float zd) {
      vec2 uvi = floor(uv);
      vec2 uvf = fract(uv);
      vec3 vor = vec3(0.0, 0.0, zd);
      float bestDist2 = zd * zd;

      vec2 OFFSETS[9];
      OFFSETS[0] = vec2(-1.0, -1.0);
      OFFSETS[1] = vec2( 0.0, -1.0);
      OFFSETS[2] = vec2( 1.0, -1.0);
      OFFSETS[3] = vec2(-1.0,  0.0);
      OFFSETS[4] = vec2( 0.0,  0.0);
      OFFSETS[5] = vec2( 1.0,  0.0);
      OFFSETS[6] = vec2(-1.0,  1.0);
      OFFSETS[7] = vec2( 0.0,  1.0);
      OFFSETS[8] = vec2( 1.0,  1.0);

      for ( int k = 0; k < 9; k++ ) {
        vec2 uvn = OFFSETS[k];
        float rnd = random(uvi + uvn);

        float r1 = 0.5 * sin(TWOPI * rnd + t) + 0.5;
        float r2 = 0.5 * sin(TWOPI * r1  + t) + 0.5;
        vec2 uvr = vec2(r2, r2);
        vec2 diff = (uvn + uvr - uvf);
        float dist2 = dot(diff, diff);
        if ( dist2 < bestDist2 ) {
          float dist = sqrt(dist2);
          vor.xy   = uvr;
          vor.z    = dist;
          bestDist2 = dist2;
        }
      }
      return vor;
    }

    vec3 voronoi(vec2 vuv, float zd) {
      return voronoi(vuv, 0.0, zd);
    }

    vec3 voronoi(vec3 vuv, 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 BaseShader;
}

/**
 * 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|(...args: any[]) => 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
   */
  _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;
  }
}

/**
 * Identify this class to be compatible with ShaderField
 * @type {boolean}
 * @internal
 * @readonly
 */
Object.defineProperty(AbstractBaseShader, "_isShaderFieldCompatible", {
  value: true,
  writable: false,
  enumerable: false,
  configurable: false
});

/**
 * The shader used by {@link foundry.canvas.placeables.regions.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;
  }
}

/**
 * @import {Point} from "@common/_types.mjs";
 * @import Region from "../region.mjs";
 */

/**
 * A mesh of a {@link foundry.canvas.placeables.Region}.
 */
class RegionMesh extends PIXI.Container {

  /**
   * Create a RegionMesh.
   * @param {Region} region                       The Region to create the RegionMesh from.
   * @param {AbstractBaseShader} [shaderClass]    The shader class to use.
   */
  constructor(region, shaderClass=RegionShader) {
    super();
    this.#region = region;
    this.region.geometry.refCount++;
    if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) {
      throw new Error("RegionMesh shader class must inherit from AbstractBaseShader.");
    }
    this.#shader = shaderClass.create();
  }

  /* ---------------------------------------- */

  /**
   * Shared point instance.
   * @type {PIXI.Point}
   */
  static #SHARED_POINT = new PIXI.Point();

  /* ---------------------------------------- */

  /**
   * The Region of this RegionMesh.
   * @type {RegionMesh}
   */
  get region() {
    return this.#region;
  }

  #region;

  /* ---------------------------------------- */

  /**
   * The shader bound to this RegionMesh.
   * @type {AbstractBaseShader}
   */
  get shader() {
    return this.#shader;
  }

  #shader;

  /* ---------------------------------------- */

  /**
   * The blend mode assigned to this RegionMesh.
   * @type {PIXI.BLEND_MODES}
   */
  get blendMode() {
    return this.#state.blendMode;
  }

  set blendMode(value) {
    if ( this.#state.blendMode === value ) return;
    this.#state.blendMode = value;
    this._tintAlphaDirty = true;
  }

  #state = PIXI.State.for2d();

  /* ---------------------------------------- */

  /**
   * The tint applied to the mesh. 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) {
    const currentTint = this._tintColor.value;
    this._tintColor.setValue(tint);
    if ( currentTint === this._tintColor.value ) return;
    this._tintAlphaDirty = true;
  }

  /* ---------------------------------------- */

  /**
   * The tint applied to the mesh. This is a hex value. A value of 0xFFFFFF will remove any tint effect.
   * @type {PIXI.Color}
   * @protected
   */
  _tintColor = new PIXI.Color(0xFFFFFF);

  /* ---------------------------------------- */

  /**
   * Cached tint value for the shader uniforms.
   * @type {[red: number, green: number, blue: number, alpha: number]}
   * @protected
   */
  _cachedTint = [1, 1, 1, 1];

  /* ---------------------------------------- */

  /**
   * Used to track a tint or alpha change to execute a recomputation of _cachedTint.
   * @type {boolean}
   * @protected
   */
  _tintAlphaDirty = true;

  /* ---------------------------------------- */

  /**
   * Initialize shader based on the shader class type.
   * @param {typeof AbstractBaseShader} shaderClass The shader class, which must inherit from AbstractBaseShader.
   */
  setShaderClass(shaderClass) {
    if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) {
      throw new Error("RegionMesh shader class must inherit from AbstractBaseShader.");
    }
    if ( this.#shader.constructor === shaderClass ) return;

    // Create shader program
    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;

  /* ---------------------------------------- */

  /** @override */
  _render(renderer) {
    if ( this._tintAlphaDirty ) {
      const premultiply = PIXI.utils.premultiplyBlendMode[1][this.blendMode] === this.blendMode;
      PIXI.Color.shared.setValue(this._tintColor)
        .premultiply(this.worldAlpha, premultiply)
        .toArray(this._cachedTint);
      this._tintAlphaDirty = false;
    }
    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);
    const geometry = this.region.geometry;
    geometry._updateBuffers();
    renderer.geometry.bind(geometry, this.#shader);

    // Draw the geometry
    renderer.geometry.draw(PIXI.DRAW_MODES.TRIANGLES);
  }

  /* ---------------------------------------- */

  /** @override */
  _calculateBounds() {
    const {left, top, right, bottom} = this.region.bounds;
    this._bounds.addFrame(this.transform, left, top, right, bottom);
  }

  /* ---------------------------------------- */

  /**
   * Tests if a point is inside this RegionMesh.
   * @param {Point} point
   * @returns {boolean}
   */
  containsPoint(point) {
    return this.region.document.polygonTree.testPoint(
      this.worldTransform.applyInverse(point, RegionMesh.#SHARED_POINT));
  }

  /* ---------------------------------------- */

  /** @override */
  destroy(options) {
    super.destroy(options);
    const geometry = this.region.geometry;
    geometry.refCount--;
    if ( geometry.refCount === 0 ) geometry.dispose();
    this.#shader = null;
    this.#state = null;
  }
}

/**
 * @import {RegionBehaviorViewedEvent, RegionBehaviorUnviewedEvent,
 *   RegionRegionBoundaryEvent} from "@client/documents/_types.mjs";
 */

/**
 * The data model for a behavior that allows to adjust the darkness level within the Region.
 */
class AdjustDarknessLevelRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.adjustDarknessLevel", "BEHAVIOR.TYPES.base"];

  /* ---------------------------------------- */

  /**
   * Darkness level behavior modes.
   * @enum {number}
   */
  static get MODES() {
    return AdjustDarknessLevelRegionBehaviorType.#MODES;
  }

  static #MODES = Object.freeze({
    /**
     * Override the darkness level with the modifier.
     */
    OVERRIDE: 0,

    /**
     * Brighten the darkness level: `darknessLevel * (1 - modifier)`
     */
    BRIGHTEN: 1,

    /**
     * Darken the darkness level: `1 - (1 - darknessLevel) * (1 - modifier)`.
     */
    DARKEN: 2
  });

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      mode: new NumberField({required: true, blank: false, choices: Object.fromEntries(Object.entries(this.MODES)
          .map(([key, value]) => [value, `BEHAVIOR.TYPES.adjustDarknessLevel.MODES.${key}.label`])),
        initial: this.MODES.OVERRIDE, validationError: "must be a value in AdjustDarknessLevelRegionBehaviorType.MODES"}),
      modifier: new AlphaField({initial: 0, step: 0.01})
    };
  }

  /* ---------------------------------------- */

  /**
   * Called when the darkness behavior is viewed.
   * @param {RegionBehaviorViewedEvent} event
   * @this {AdjustDarknessLevelRegionBehaviorType}
   */
  static async #onBehaviorViewed(event) {
    const {AdjustDarknessLevelRegionShader, IlluminationDarknessLevelRegionShader} = foundry.canvas.rendering.shaders;

    // Create darkness level mesh
    const dlMesh = new RegionMesh(this.region.object, AdjustDarknessLevelRegionShader);
    if ( canvas.performance.mode > CONST.CANVAS_PERFORMANCE_MODES.LOW ) {
      dlMesh._blurFilter = canvas.createBlurFilter(8, 2);
      dlMesh.filters = [dlMesh._blurFilter];
    }

    // Create illumination mesh
    const illMesh = new RegionMesh(this.region.object, IlluminationDarknessLevelRegionShader);

    // Common properties
    illMesh.name = dlMesh.name = this.behavior.uuid;
    illMesh.shader.mode = dlMesh.shader.mode = this.mode;
    illMesh.shader.modifier = dlMesh.shader.modifier = this.modifier;

    // Adding the mesh to their respective containers
    canvas.effects.illumination.darknessLevelMeshes.addChild(dlMesh);
    canvas.visibility.vision.light.global.meshes.addChild(illMesh);

    // Invalidate darkness level container and refresh vision if global light is enabled
    canvas.effects.illumination.invalidateDarknessLevelContainer(true);
    canvas.perception.update({refreshLighting: true, refreshVision: canvas.environment.globalLightSource.active});
  }

  /* ---------------------------------------- */

  /**
   * Called when the darkness behavior is unviewed.
   * @param {RegionBehaviorUnviewedEvent} event
   * @this {AdjustDarknessLevelRegionBehaviorType}
   */
  static async #onBehaviorUnviewed(event) {
    const dlMesh = canvas.effects.illumination.darknessLevelMeshes.getChildByName(this.behavior.uuid);
    if ( dlMesh._blurFilter ) canvas.blurFilters.delete(dlMesh._blurFilter);
    dlMesh.destroy();
    const ilMesh = canvas.visibility.vision.light.global.meshes.getChildByName(this.behavior.uuid);
    ilMesh.destroy();
    canvas.effects.illumination.invalidateDarknessLevelContainer(true);
    canvas.perception.update({refreshLighting: true, refreshVision: canvas.environment.globalLightSource.active});
  }

  /* ---------------------------------------- */

  /**
   * Called when the boundary of an event has changed.
   * @param {RegionRegionBoundaryEvent} event
   * @this {AdjustDarknessLevelRegionBehaviorType}
   */
  static async #onRegionBoundary(event) {
    if ( !this.behavior.viewed ) return;
    canvas.effects.illumination.invalidateDarknessLevelContainer(true);
    canvas.perception.update({refreshLighting: true, refreshVision: canvas.environment.globalLightSource.active});
  }

  /* ---------------------------------------- */

  /** @override */
  static events = {
    [REGION_EVENTS.BEHAVIOR_VIEWED]: this.#onBehaviorViewed,
    [REGION_EVENTS.BEHAVIOR_UNVIEWED]: this.#onBehaviorUnviewed,
    [REGION_EVENTS.REGION_BOUNDARY]: this.#onRegionBoundary
  };

  /* ---------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( !("system" in changed) || !this.behavior.viewed ) return;
    const dlMesh = canvas.effects.illumination.darknessLevelMeshes.getChildByName(this.behavior.uuid);
    dlMesh.shader.mode = this.mode;
    dlMesh.shader.modifier = this.modifier;
    const ilMesh = canvas.visibility.vision.light.global.meshes.getChildByName(this.behavior.uuid);
    ilMesh.shader.mode = this.mode;
    ilMesh.shader.modifier = this.modifier;
    canvas.effects.illumination.invalidateDarknessLevelContainer(true);
    canvas.perception.update({refreshLighting: true, refreshVision: canvas.environment.globalLightSource.active});
  }
}

/**
 * @import {RegionEvent} from "@client/documents/_types.mjs";
 */

/**
 * The data model for a behavior that displays scrolling text above a token when one of the subscribed events occurs.
 *
 * @property {boolean} once           Disable the behavior after it triggers once
 * @property {string} text            The text to display
 * @property {string} color           Optional color setting for the text
 * @property {number} visibility      Which users the scrolling text will display for
                                      (see {@link DisplayScrollingTextRegionBehaviorType.VISIBILITY_MODES})
 */
class DisplayScrollingTextRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.displayScrollingText", "BEHAVIOR.TYPES.base"];

  /* ---------------------------------------- */

  /**
   * Text visibility behavior modes.
   * @enum {number}
   */
  static get VISIBILITY_MODES() {
    return DisplayScrollingTextRegionBehaviorType.#VISIBILITY_MODES;
  }

  static #VISIBILITY_MODES = Object.freeze({
    /**
     * Display only for gamemaster users
     */
    GAMEMASTER: 0,

    /**
     * Display only for users with observer permissions on the triggering token (and for the GM)
     */
    OBSERVER: 1,

    /**
     * Display for all users
     */
    ANYONE: 2
  });

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      events: this._createEventsField({events: [
        REGION_EVENTS.TOKEN_ANIMATE_IN,
        REGION_EVENTS.TOKEN_ANIMATE_OUT,
        REGION_EVENTS.TOKEN_TURN_START,
        REGION_EVENTS.TOKEN_TURN_END,
        REGION_EVENTS.TOKEN_ROUND_START,
        REGION_EVENTS.TOKEN_ROUND_END
      ]}),
      text: new StringField({required: true}),
      color: new ColorField({required: true, nullable: false, initial: "#ffffff"}),
      visibility: new NumberField({
        required: true,
        choices: Object.entries(this.VISIBILITY_MODES).reduce((obj, [key, value]) => {
          obj[value] = `BEHAVIOR.TYPES.displayScrollingText.VISIBILITY_MODES.${key}.label`;
          return obj;
        }, {}),
        initial: this.VISIBILITY_MODES.ANYONE,
        validationError: "must be a value in DisplayScrollingTextRegionBehaviorType.VISIBILITY_MODES"}),
      once: new BooleanField()
    };
  }

  /* ---------------------------------------- */

  /**
   * Display the scrolling text to the current User?
   * @param {RegionEvent} event    The Region event.
   * @returns {boolean}            Display the scrolling text to the current User?
   */
  #canView(event) {
    if ( !this.parent.scene.isView ) return false;
    if ( game.user.isGM ) return true;
    if ( event.data.token.isSecret ) return false;

    const token = event.data.token.object;
    if ( !token || !token.visible ) return false;

    const M = DisplayScrollingTextRegionBehaviorType.VISIBILITY_MODES;
    if ( this.visibility === M.ANYONE ) return true;
    if ( this.visibility === M.OBSERVER ) return event.data.token.testUserPermission(game.user, "OBSERVER");
    return false;
  }

  /* ---------------------------------------- */

  /** @override */
  async _handleRegionEvent(event) {
    if ( this.once ) {

      // If TOKEN_ANIMATE_IN/_OUT and no GM is viewing this scene, the behavior won't be disabled because the
      // TOKEN_ANIMATE_IN/_OUT isn't triggered for users that do not view the scene the token is in
      const E = CONST.REGION_EVENTS;
      const requiresView = [E.TOKEN_ANIMATE_IN, E.TOKEN_ANIMATE_OUT].includes(event.name);
      if ( game.user.isDesignated(u => u.active && u.isGM && (!requiresView
        || (u.viewedScene === this.parent.scene.id))) ) {

        // A second scrolling text might be displayed that should not be if the behavior update reaches the client after
        // a second TOKEN_ANIMATE_IN/_OUT event occurs for the same movement, which depends on the latency of the
        // designated user and each client, and the movement animation speed
        // noinspection ES6MissingAwait
        this.parent.update({disabled: true});
      }
    }

    if ( !this.text ) return;
    const canView = this.#canView(event);
    if ( !canView ) return;

    const token = event.data.token.object;
    await canvas.interface.createScrollingText(
      token.center,
      this.text,
      {
        distance: 2 * token.h,
        fontSize: 28,
        fill: this.color,
        stroke: 0x000000,
        strokeThickness: 4
      }
    );
  }
}

/**
 * @import {RegionEvent} from "@client/documents/_types.mjs";
 */

/**
 * The data model for a behavior that executes a Macro.
 *
 * @property {string} uuid           The Macro UUID.
 */
class ExecuteMacroRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.executeMacro", "BEHAVIOR.TYPES.base"];

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      events: this._createEventsField(),
      uuid: new DocumentUUIDField({type: "Macro"}),
      everyone: new BooleanField()
    };
  }

  /* ---------------------------------------- */

  /** @override */
  async _handleRegionEvent(event) {
    if ( !this.uuid ) return;
    const macro = await foundry.utils.fromUuid(this.uuid);
    if ( !(macro instanceof foundry.documents.Macro) ) {
      console.error(`${this.uuid} does not exist`);
      return;
    }
    if ( !this.#shouldExecute(macro, event.user) ) return;
    const {scene, region, behavior} = this;
    const token = event.data.token;
    const speaker = token
      ? {scene: token.parent?.id ?? null, actor: token.actor?.id ?? null, token: token.id, alias: token.name}
      : {scene: scene.id, actor: null, token: null, alias: region.name};
    await macro.execute({speaker, actor: token?.actor, token: token?.object, scene, region, behavior, event});
  }

  /* ---------------------------------------- */

  /**
   * Should the client execute the macro?
   * @param {Macro} macro    The macro.
   * @param {User} user      The user that triggered the event.
   * @returns {boolean}      Should the client execute the macro?
   */
  #shouldExecute(macro, user) {
    if ( this.everyone ) return true;
    if ( macro.canUserExecute(user) ) return user.isSelf;
    const eligibleUsers = game.users.filter(u => u.active && macro.canUserExecute(u));
    if ( eligibleUsers.length === 0 ) return false;
    eligibleUsers.sort((a, b) => (b.role - a.role) || a.id.compare(b.id));
    const designatedUser = eligibleUsers[0];
    return designatedUser.isSelf;
  }
}

/**
 * @import {RegionEvent} from "@client/documents/_types.mjs";
 */

/**
 * The data model for a behavior that executes a script.
 *
 * @property {string} source    The source code of the script.
 */
class ExecuteScriptRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.executeScript", "BEHAVIOR.TYPES.base"];

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      events: this._createEventsField(),
      source: new JavaScriptField({async: true, gmOnly: true})
    };
  }

  /* ---------------------------------------- */

  /** @override */
  async _handleRegionEvent(event) {
    try {
      // eslint-disable-next-line no-new-func
      const fn = new AsyncFunction("scene", "region", "behavior", "event", `{${this.source}\n}`);
      await fn.call(globalThis, this.scene, this.region, this.behavior, event);
    } catch(err) {
      console.error(err);
    }
  }
}

/**
 * @import {RegionBehaviorViewedEvent, RegionBehaviorUnviewedEvent,
 *   RegionRegionBoundaryEvent} from "@client/documents/_types.mjs";
 */

/**
 * The data model for a behavior that allows to modify the movement cost within the Region.
 *
 * @property {{[movementAction: string]: number}} difficulties    The difficulty of each movement action
 */
class ModifyMovementCostRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.modifyMovementCost", "BEHAVIOR.TYPES.base"];

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    const difficulties = [];
    for ( const [action, {label, deriveTerrainDifficulty}] of Object.entries(CONFIG.Token.movement.actions) ) {
      if ( deriveTerrainDifficulty ) continue;
      difficulties.push({action, label, hint: ""});
    }
    difficulties.at(-1).hint = "BEHAVIOR.TYPES.modifyMovementCost.FIELDS.difficulties.hint";
    return {
      difficulties: new SchemaField(difficulties.reduce((schema, {action, label, hint}) => {
        schema[action] = new NumberField({required: true, nullable: true, initial: 1, step: 0.25,
          min: 0, max: 5, label, hint});
        return schema;
      }, {}))
    };
  }

  /* ---------------------------------------- */

  /**
   * Called when the darkness behavior is viewed.
   * @param {RegionBehaviorViewedEvent} event
   * @this {ModifyMovementCostRegionBehaviorType}
   */
  static async #onBehaviorViewed(event) {
    canvas.tokens.recalculatePlannedMovementPaths();
  }

  /* ---------------------------------------- */

  /**
   * Called when the darkness behavior is unviewed.
   * @param {RegionBehaviorUnviewedEvent} event
   * @this {ModifyMovementCostRegionBehaviorType}
   */
  static async #onBehaviorUnviewed(event) {
    canvas.tokens.recalculatePlannedMovementPaths();
  }

  /* ---------------------------------------- */

  /**
   * Called when the boundary of an event has changed.
   * @param {RegionRegionBoundryEvent} event
   * @this {ModifyMovementCostRegionBehaviorType}
   */
  static async #onRegionBoundary(event) {
    if ( !this.behavior.viewed ) return;
    canvas.tokens.recalculatePlannedMovementPaths();
  }

  /* ---------------------------------------- */

  /** @override */
  static events = {
    [REGION_EVENTS.BEHAVIOR_VIEWED]: this.#onBehaviorViewed,
    [REGION_EVENTS.BEHAVIOR_UNVIEWED]: this.#onBehaviorUnviewed,
    [REGION_EVENTS.REGION_BOUNDARY]: this.#onRegionBoundary
  };

  /* ---------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    const actionConfigs = CONFIG.Token.movement.actions;
    for ( const action in actionConfigs ) {
      if ( action in this._source.difficulties ) continue;
      this.difficulties[action] = actionConfigs[action].deriveTerrainDifficulty(this._source.difficulties);
    }
  }

  /* ---------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( ("system" in changed) && !this.behavior.viewed ) return;
    canvas.tokens.recalculatePlannedMovementPaths();
  }

  /* ---------------------------------------- */

  /** @override */
  _getTerrainEffects(token, segment) {
    const difficulty = this.difficulties[segment.action];
    if ( difficulty === 1 ) return [];
    return [{name: "difficulty", difficulty}];
  }
}

/**
 * @import {RegionMoveInEvent} from "@client/documents/_types.mjs";
 */

/**
 * The data model for a behavior that pauses the game when a player-controlled Token enters the Region.
 *
 * @property {boolean} once    Disable the behavior once a player-controlled Token enters the region?
 */
class PauseGameRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.pauseGame", "BEHAVIOR.TYPES.base"];

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      once: new BooleanField()
    };
  }

  /* ---------------------------------------- */

  /**
   * Pause the game if a player-controlled Token moves into the Region.
   * @param {RegionMoveInEvent} event
   * @this {PauseGameRegionBehaviorType}
   */
  static async #onTokenMoveIn(event) {
    if ( event.user.isGM ) return;
    if ( event.user.isSelf ) event.data.token.stopMovement();
    if ( !game.user.isActiveGM ) return;
    game.togglePause(true, {broadcast: true});
    if ( this.once ) {
      // noinspection ES6MissingAwait
      this.parent.update({disabled: true});
    }
  }

  /* ---------------------------------------- */

  /** @override */
  static events = {
    [REGION_EVENTS.TOKEN_MOVE_IN]: this.#onTokenMoveIn
  };
}

/**
 * @import {RegionBehaviorViewedEvent, RegionBehaviorUnviewedEvent} from "@client/documents/_types.mjs";
 */

/**
 * The data model for a behavior that allows to suppress weather effects within the Region
 */
class SuppressWeatherRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.suppressWeather", "BEHAVIOR.TYPES.base"];

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    return {};
  }

  /* ---------------------------------------- */

  /**
   * Called when the weather behavior is viewed.
   * @param {RegionBehaviorViewedEvent} event
   * @this {SuppressWeatherRegionBehaviorType}
   */
  static async #onBehaviorViewed(event) {
    const mesh = new RegionMesh(this.region.object);
    mesh.name = this.behavior.uuid;
    mesh.blendMode = PIXI.BLEND_MODES.ERASE;
    canvas.weather.suppression.addChild(mesh);
  }

  /* ---------------------------------------- */

  /**
   * Called when the weather behavior is unviewed.
   * @param {RegionBehaviorUnviewedEvent} event
   * @this {SuppressWeatherRegionBehaviorType}
   */
  static async #onBehaviorUnviewed(event) {
    const mesh = canvas.weather.suppression.getChildByName(this.behavior.uuid);
    mesh.destroy();
  }

  /* ---------------------------------------- */

  /** @override */
  static events = {
    [REGION_EVENTS.BEHAVIOR_VIEWED]: this.#onBehaviorViewed,
    [REGION_EVENTS.BEHAVIOR_UNVIEWED]: this.#onBehaviorUnviewed
  };
}

/**
 * @import {ApplicationConfiguration} from "../_types.mjs";
 */

/**
 * @typedef DialogV2Button
 * @property {string} action                      The button action identifier.
 * @property {string} label                       The button label. Will be localized.
 * @property {string} [icon]                      FontAwesome icon classes.
 * @property {string} [class]                     CSS classes to apply to the button.
 * @property {Record<string, string>} [style]     CSS style to apply to the button.
 * @property {string} [type="submit"]             The button type.
 * @property {boolean} [disabled]                 Whether the button is disabled
 * @property {boolean} [default]                  Whether this button represents the default action to take if the user
 *                                                submits the form without pressing a button, i.e. with an Enter
 *                                                keypress.
 * @property {DialogV2ButtonCallback} [callback]  A function to invoke when the button is clicked. The value returned
 *                                                from this function will be used as the dialog's submitted value.
 *                                                Otherwise, the button's identifier is used.
 */

/**
 * @callback DialogV2ButtonCallback
 * @param {PointerEvent|SubmitEvent} event        The button click event, or a form submission event if the dialog was
 *                                                submitted via keyboard.
 * @param {HTMLButtonElement} button              If the form was submitted via keyboard, this will be the default
 *                                                button, otherwise the button that was clicked.
 * @param {DialogV2} dialog                       The DialogV2 instance.
 * @returns {Promise<any>}
 */

/**
 * @typedef DialogV2Configuration
 * @property {boolean} [modal]                    Modal dialogs prevent interaction with the rest of the UI until they
 *                                                are dismissed or submitted.
 * @property {DialogV2Button[]} buttons           Button configuration.
 * @property {string|HTMLDivElement} [content]    The dialog content: a HTML string or a <div> element. If string,
 *                                                the content is cleaned with {@link foundry.utils.cleanHTML}.
 *                                                Otherwise, the content is not cleaned.
 * @property {DialogV2SubmitCallback} [submit]    A function to invoke when the dialog is submitted. This will not be
 *                                                called if the dialog is dismissed.
 */

/**
 * @callback DialogV2RenderCallback
 * @param {Event} event                           The render event.
 * @param {DialogV2} dialog                       The DialogV2 instance.
 */

/**
 * @callback DialogV2CloseCallback
 * @param {Event} event                           The close event.
 * @param {DialogV2} dialog                       The DialogV2 instance.
 */

/**
 * @callback DialogV2SubmitCallback
 * @param {any} result                            Either the identifier of the button that was clicked to submit the
 *                                                dialog, or the result returned by that button's callback.
 * @param {DialogV2} dialog                       The DialogV2 instance.
 * @returns {Promise<void>}
 */

/**
 * @typedef DialogV2WaitOptions
 * @property {DialogV2RenderCallback} [render] A synchronous function to invoke whenever the dialog is rendered.
 * @property {DialogV2CloseCallback} [close]   A synchronous function to invoke when the dialog is closed under any
 *                                             circumstances.
 * @property {boolean} [rejectClose=false]     Throw a Promise rejection if the dialog is dismissed.
 */

/**
 * A lightweight Application that renders a dialog containing a form with arbitrary content, and some buttons.
 * @extends {ApplicationV2<ApplicationConfiguration & DialogV2Configuration>}
 *
 * @example Prompt the user to confirm an action.
 * ```js
 * const proceed = await foundry.applications.api.DialogV2.confirm({
 *   content: "Are you sure?",
 *   rejectClose: false,
 *   modal: true
 * });
 * if ( proceed ) console.log("Proceed.");
 * else console.log("Do not proceed.");
 * ```
 *
 * @example Prompt the user for some input.
 * ```js
 * let guess;
 * try {
 *   guess = await foundry.applications.api.DialogV2.prompt({
 *     window: { title: "Guess a number between 1 and 10" },
 *     content: '<input name="guess" type="number" min="1" max="10" step="1" autofocus>',
 *     ok: {
 *       label: "Submit Guess",
 *       callback: (event, button, dialog) => button.form.elements.guess.valueAsNumber
 *     }
 *   });
 * } catch {
 *   console.log("User did not make a guess.");
 *   return;
 * }
 * const n = Math.ceil(CONFIG.Dice.randomUniform() * 10);
 * if ( n === guess ) console.log("User guessed correctly.");
 * else console.log("User guessed incorrectly.");
 * ```
 *
 * @example A custom dialog.
 * ```js
 * new foundry.applications.api.DialogV2({
 *   window: { title: "Choose an option" },
 *   content: `
 *     <label><input type="radio" name="choice" value="one" checked> Option 1</label>
 *     <label><input type="radio" name="choice" value="two"> Option 2</label>
 *     <label><input type="radio" name="choice" value="three"> Options 3</label>
 *   `,
 *   buttons: [{
 *     action: "choice",
 *     label: "Make Choice",
 *     default: true,
 *     callback: (event, button, dialog) => button.form.elements.choice.value
 *   }, {
 *     action: "all",
 *     label: "Take All"
 *   }],
 *   submit: result => {
 *     if ( result === "all" ) console.log("User picked all options.");
 *     else console.log(`User picked option: ${result}`);
 *   }
 * }).render({ force: true });
 * ```
 */
class DialogV2 extends ApplicationV2 {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "dialog-{id}",
    classes: ["dialog"],
    tag: "dialog",
    form: {
      closeOnSubmit: true
    },
    window: {
      frame: true,
      positioned: true,
      minimizable: false
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options = super._initializeApplicationOptions(options);
    options.content ||= "";
    if ( options.content instanceof HTMLElement ) {
      if ( options.content.tagName !== "DIV" ) throw new Error("config.content must be <div> element");
      if ( options.content.attributes.length ) throw new Error("config.content element must have no attributes");
      options.content = options.content.innerHTML;
    }
    else options.content = foundry.utils.cleanHTML(String(options.content));
    if ( !options.buttons?.length ) throw new Error("You must define at least one entry in config.buttons");
    options.buttons = options.buttons.reduce((obj, button) => {
      options.actions[button.action] = this.constructor._onClickButton;
      obj[button.action] = button;
      return obj;
    }, {});
    return options;
  }

  /* -------------------------------------------- */

  /** @override */
  async _renderHTML(_context, _options) {
    const form = document.createElement("form");
    form.className = "dialog-form standard-form";
    form.autocomplete = "off";
    form.innerHTML = `
      ${this.options.content ? `<div class="dialog-content standard-form">${this.options.content}</div>` : ""}
      <footer class="form-footer">${this._renderButtons()}</footer>
    `;
    form.addEventListener("submit", event => this._onSubmit(event.submitter, event));
    return form;
  }

  /* -------------------------------------------- */

  /**
   * Render configured buttons.
   * @returns {string}
   * @protected
   */
  _renderButtons() {
    const buttons = Object.values(this.options.buttons);
    return buttons.map((buttonOptions, i) => {
      const { action, label, icon, class: cls="", style={}, type="submit", disabled } = buttonOptions;
      const isDefault = !!buttonOptions.default || ((i === 0) && !buttons.some(b => b.default));
      const button = document.createElement("button");
      button.setAttribute("type", type);
      button.setAttribute("data-action", action);
      button.setAttribute("class", cls);
      for ( const [key, value] of Object.entries(style) ) {
        if ( key in button.style ) button.style[key] = value;
        else button.style.setProperty(key, value);
      }
      button.toggleAttribute("disabled", !!disabled);
      button.toggleAttribute("autofocus", isDefault);
      if ( icon ) {
        const i = document.createElement("i");
        i.className = icon;
        button.appendChild(i);
      }
      const span = document.createElement("span");
      span.innerText = game.i18n.localize(label);
      button.appendChild(span);
      return button.outerHTML;
    }).join("");
  }

  /* -------------------------------------------- */

  /**
   * Handle submitting the dialog.
   * @param {HTMLButtonElement} target        The button that was clicked or the default button.
   * @param {PointerEvent|SubmitEvent} event  The triggering event.
   * @returns {Promise<DialogV2>}
   * @protected
   */
  async _onSubmit(target, event) {
    event.preventDefault();
    const priorDisabledStates = [];
    for ( const action of Object.keys(this.options.buttons) ) {
      const button = this.element.querySelector(`button[data-action="${action}"]`);
      priorDisabledStates.push([button, button.disabled]);
      button.disabled = true;
    }
    const button = this.options.buttons[target?.dataset.action];
    const result = (await button?.callback?.(event, target, this)) ?? button?.action;
    await this.options.submit?.(result, this);
    for ( const [button, disabled] of priorDisabledStates ) button.disabled = disabled;
    return this.options.form.closeOnSubmit ? this.close({ submitted: true }) : this;
  }

  /* -------------------------------------------- */

  /** @override */
  async _onFirstRender(_context, _options) {
    if ( this.options.modal ) this.element.showModal();
    else this.element.show();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("keydown", this._onKeyDown.bind(this));
  }

  /* -------------------------------------------- */

  /** @override */
  _replaceHTML(result, content, _options) {
    content.replaceChildren(result);
  }

  /* -------------------------------------------- */

  /**
   * Handle keypresses within the dialog.
   * @param {KeyboardEvent} event  The triggering event.
   * @protected
   */
  _onKeyDown(event) {
    // Capture Escape keypresses for dialogs to ensure that close is called properly.
    if ( event.key === "Escape" ) {
      event.preventDefault(); // Prevent default browser dialog dismiss behavior.
      event.stopPropagation();
      this.close();
    }
  }

  /* -------------------------------------------- */

  /**
   * Redirect all clicks of buttons with action specifications to the submit handler.
   * @this {DialogV2}
   * @param {PointerEvent} event        The originating click event.
   * @param {HTMLButtonElement} target  The button element that was clicked.
   * @protected
   */
  static _onClickButton(event, target) {
    this._onSubmit(target, event);
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * A utility helper to generate a dialog with yes and no buttons.
   * @param {Partial<ApplicationConfiguration & DialogV2Configuration & DialogV2WaitOptions>} [config]
   * @param {Partial<DialogV2Button>} [config.yes] Options to overwrite the default yes button configuration.
   * @param {Partial<DialogV2Button>} [config.no]  Options to overwrite the default no button configuration.
   * @returns {Promise<any>}                Resolves to true if the yes button was pressed, or false if the no button
   *                                        was pressed. If additional buttons were provided, the Promise resolves to
   *                                        the identifier of the one that was pressed, or the value returned by its
   *                                        callback. If the dialog was dismissed, and rejectClose is false, the
   *                                        Promise resolves to null.
   */
  static async confirm({yes={}, no={}, ...config}={}) {
    config.buttons ??= [];
    config.buttons.unshift(mergeObject({
      action: "yes", label: "Yes", icon: "fa-solid fa-check", callback: () => true
    }, yes), mergeObject({
      action: "no", label: "No", icon: "fa-solid fa-xmark", default: true, callback: () => false
    }, no));
    return this.wait(foundry.utils.mergeObject({ position: { width: 400 } }, config));
  }

  /* -------------------------------------------- */

  /**
   * A utility helper to generate a dialog with a single confirmation button.
   * @param {Partial<ApplicationConfiguration & DialogV2Configuration & DialogV2WaitOptions>} [config]
   * @param {Partial<DialogV2Button>} [config.ok]   Options to overwrite the default confirmation button configuration.
   * @returns {Promise<any>}                        Resolves to the identifier of the button used to submit the dialog,
   *                                                or the value returned by that button's callback. If additional
   *                                                buttons were provided, the Promise resolves to the identifier of
   *                                                the one that was pressed, or the value returned by its callback.
   *                                                If the dialog was dismissed, and rejectClose is false, the Promise
   *                                                resolves to null.
   */
  static async prompt({ok={}, ...config}={}) {
    config.buttons ??= [];
    config.buttons.unshift(mergeObject({
      action: "ok", label: "Confirm", icon: "fa-solid fa-check", default: true
    }, ok));
    return this.wait(foundry.utils.mergeObject({ position: { width: 400 } }, config));
  }

  /* -------------------------------------------- */

  /**
   * A utility helper to generate a dialog for user input.
   * @param {Partial<ApplicationConfiguration & DialogV2Configuration & DialogV2WaitOptions>} [config]
   * @param {Partial<DialogV2Button>} [config.ok]   Options to overwrite the default confirmation button configuration.
   * @returns {Promise<any>}                        Resolves to the data of the form if the ok button was pressed,
   *                                                or the value returned by that button's callback. If additional
   *                                                buttons were provided, the Promise resolves to the identifier of
   *                                                the one that was pressed, or the value returned by its callback.
   *                                                If the dialog was dismissed, and rejectClose is false, the Promise
   *                                                resolves to null.
   */
  static async input({ok, ...config}={}) {
    const callback = (_event, button) => new FormDataExtended(button.form).object;
    return this.prompt({ok: {callback, ...ok}, ...config});
  }

  /* -------------------------------------------- */

  /**
   * Spawn a dialog and wait for it to be dismissed or submitted.
   * @param {Partial<ApplicationConfiguration & DialogV2Configuration & DialogV2WaitOptions>} [config]
   * @returns {Promise<any>}                          Resolves to the identifier of the button used to submit the
   *                                                  dialog, or the value returned by that button's callback. If the
   *                                                  dialog was dismissed, and rejectClose is false, the Promise
   *                                                  resolves to null.
   */
  static async wait({rejectClose=false, close, render, ...config}={}) {
    return new Promise((resolve, reject) => {
      // Wrap submission handler with Promise resolution.
      const originalSubmit = config.submit;
      config.submit = async (result, dialog) => {
        await originalSubmit?.(result, dialog);
        resolve(result);
      };

      const dialog = new this(config);
      dialog.addEventListener("close", event => {
        const result = close instanceof Function ? close(event, dialog) : undefined;
        if ( rejectClose ) reject(new Error("Dialog was dismissed without pressing a button."));
        else resolve(result ?? null);
      }, {once: true});
      if ( render instanceof Function ) {
        dialog.addEventListener("render", event => render(event, dialog));
      }
      dialog.render({force: true});
    });
  }

  /* -------------------------------------------- */

  /**
   * Present an asynchronous Dialog query to a specific User for response.
   * @param {User|string} user                A User instance or a User id
   * @param {"prompt"|"confirm"|"input"|"wait"} type  The type of Dialog to present
   * @param {object} [config]                 Dialog configuration forwarded on to the Dialog.prompt, Dialog.confirm,
   *                                          Dialog.input, or Dialog.wait function depending on the query type.
   *                                          Callback options are not supported.
   * @returns {Promise<any|null>}             The query response or null if no response was provided
   *
   * @see {@link DialogV2.prompt}
   * @see {@link DialogV2.confirm}
   * @see {@link DialogV2.input}
   * @see {@link DialogV2.wait}
   */
  static async query(user, type, config={}) {
    if ( typeof user === "string" ) {
      const userId = user;
      user = game.users.get(userId);
      if ( !user ) throw new Error(`User [${userId}] does not exist`);
    }
    if ( user.isSelf ) return this[type](config);
    return user.query("dialog", {type, config});
  }

  /* -------------------------------------------- */

  /**
   * The dialog query handler.
   * @type {({type, config}: {type: "prompt"|"confirm"|"input"|"wait"; config: object}) => Promise<any>}
   * @internal
   */
  static _handleQuery = ({type, config}) => {
    switch ( type ) {
      case "confirm": return this.confirm(config);
      case "input": return this.input(config);
      case "prompt": return this.prompt(config);
      case "wait": return this.wait(config);
    }
  };
}

/**
 * @import {ElevatedPoint} from "../../_types.mjs";
 * @import TokenDocument from "@client/documents/token.mjs";
 * @import {RegionMoveInEvent} from "@client/documents/_types.mjs";
 */

/**
 * The data model for a behavior that teleports Token that enter the Region to a preset destination Region.
 *
 * @property {RegionDocument} destination    The destination Region the Token is teleported to.
 * @property {boolean} choice                Show teleportation confirmation dialog?
 */
class TeleportTokenRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.teleportToken", "BEHAVIOR.TYPES.base"];

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      destination: new DocumentUUIDField({type: "Region"}),
      choice: new BooleanField()
    };
  }

  /* ---------------------------------------- */

  /**
   * Teleport the Token if it moves into the Region.
   * @param {RegionMoveInEvent} event
   * @this {TeleportTokenRegionBehaviorType}
   */
  static async #onTokenMoveIn(event) {
    if ( !this.destination || (event.data.movement.passed.waypoints.at(-1).action === "displace") ) return;
    const destination = fromUuidSync(this.destination);
    if ( !(destination instanceof RegionDocument) ) {
      console.error(`${this.destination} does not exist`);
      return;
    }
    const token = event.data.token;
    const user = event.user;
    if ( user.isSelf ) token.stopMovement();
    if ( !this.#shouldTeleport(token, destination, user) ) return;

    // When the browser tab is/becomes hidden, don't wait for the movement animation and
    // proceed immediately. Otherwise wait for the movement animation to complete.
    if ( token.rendered && token.object.movementAnimationPromise && !window.document.hidden ) {
      let visibilitychange;
      await Promise.race([token.object.movementAnimationPromise, new Promise(resolve => {
        visibilitychange = event => {
          if ( window.document.hidden ) resolve();
        };
        window.document.addEventListener("visibilitychange", visibilitychange);
      }).finally(() => {
        window.document.removeEventListener("visibilitychange", visibilitychange);
      })]);
    }

    if ( this.choice ) {
      let confirmed;
      if ( user.isSelf ) confirmed = await TeleportTokenRegionBehaviorType.#confirmDialog(token, destination);
      else confirmed = await user.query("confirmTeleportToken", {behaviorUuid: this.parent.uuid, tokenUuid: token.uuid});
      if ( !confirmed ) return;
    }
    await destination.teleportToken(token);

    // View destination scene / Pull the user to the destination scene only if the user is currently viewing the origin
    // scene
    if ( token.parent !== destination.parent ) {
      if ( user.isSelf ) {
        if ( token.parent.isView ) await destination.parent.view();
      } else {
        if ( token.parent.id === user.viewedScene ) await game.socket.emit("pullToScene", destination.parent.id, user.id);
      }
    }
  }

  /* ---------------------------------------- */

  /** @override */
  static events = {
    [REGION_EVENTS.TOKEN_MOVE_IN]: this.#onTokenMoveIn
  };

  /* ---------------------------------------- */

  /**
   * Should the current user teleport the token?
   * @param {TokenDocument} token           The token that is teleported.
   * @param {RegionDocument} destination    The destination region.
   * @param {User} user                     The user that moved the token.
   * @returns {boolean}                     Should the current user teleport the token?
   */
  #shouldTeleport(token, destination, user) {
    const userCanTeleport = (token.parent === destination.parent) || (user.can("TOKEN_CREATE") && user.can("TOKEN_DELETE"));
    if ( userCanTeleport ) return user.isSelf;
    return game.user.isDesignated(u => u.active && u.can("TOKEN_CREATE") && u.can("TOKEN_DELETE") && (!this.choice || u.can("QUERY_USER")));
  }

  /* -------------------------------------------- */

  /**
   * The query handler for teleporation confirmation.
   * @type {(queryData: {behaviorUuid: string; token: tokenUuid}) => Promise<boolean>}
   * @internal
   */
  static _confirmQuery = async ({behaviorUuid, tokenUuid}) => {
    const behavior = await fromUuid(behaviorUuid);
    if ( !behavior || (behavior.type !== "teleportToken") || !behavior.system.destination ) return false;
    const destination = await fromUuid(behavior.system.destination);
    if ( !destination ) return false;
    const token = await fromUuid(tokenUuid);
    if ( !token ) return false;
    return TeleportTokenRegionBehaviorType.#confirmDialog(token, destination);
  };

  /* -------------------------------------------- */

  /**
   * Display a dialog to confirm the teleportation?
   * @param {TokenDocument} token           The token that is teleported.
   * @param {RegionDocument} destination    The destination region.
   * @returns {Promise<boolean>}            The result of the dialog.
   */
  static async #confirmDialog(token, destination) {
    const question = game.i18n.format(`BEHAVIOR.TYPES.teleportToken.${game.user.isGM ? "ConfirmGM" : "Confirm"}`, {
      token: foundry.utils.escapeHTML(token.name),
      region: foundry.utils.escapeHTML(destination.name),
      scene: foundry.utils.escapeHTML(destination.parent.name)
    });
    return DialogV2.confirm({
      window: {title: CONFIG.RegionBehavior.typeLabels.teleportToken},
      content: `<p>${question}</p>`
    });
  }
}

/**
 * @import {RegionEvent} from "@client/documents/_types.mjs";
 */

/**
 * The data model for a behavior that toggles Region Behaviors when one of the subscribed events occurs.
 *
 * @property {Set<string>} enable     The Region Behavior UUIDs that are enabled.
 * @property {Set<string>} disable    The Region Behavior UUIDs that are disabled.
 */
class ToggleBehaviorRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.toggleBehavior", "BEHAVIOR.TYPES.base"];

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      events: this._createEventsField({events: [
        REGION_EVENTS.TOKEN_ENTER,
        REGION_EVENTS.TOKEN_EXIT,
        REGION_EVENTS.TOKEN_MOVE_IN,
        REGION_EVENTS.TOKEN_MOVE_OUT,
        REGION_EVENTS.TOKEN_TURN_START,
        REGION_EVENTS.TOKEN_TURN_END,
        REGION_EVENTS.TOKEN_ROUND_START,
        REGION_EVENTS.TOKEN_ROUND_END
      ]}),
      enable: new SetField(new DocumentUUIDField({type: "RegionBehavior"})),
      disable: new SetField(new DocumentUUIDField({type: "RegionBehavior"}))
    };
  }

  /* -------------------------------------------- */

  /** @override */
  static validateJoint(data) {
    if ( new Set(data.enable).intersection(new Set(data.disable)).size !== 0 ) {
      throw new Error("A RegionBehavior cannot be both enabled and disabled");
    }
  }

  /* ---------------------------------------- */

  /** @override */
  async _handleRegionEvent(event) {
    if ( event.data.movement && event.user.isSelf ) event.data.token.pauseMovement(this.parent.uuid);
    if ( !game.user.isActiveGM ) return;
    const toggle = async (uuid, disabled) => {
      try {
        const behavior = await foundry.utils.fromUuid(uuid);
        if ( !(behavior instanceof RegionBehavior$1) ) throw new Error(`${uuid} does not exist`);
        await behavior.update({disabled});
      } catch(err) {
        console.error(err);
      }
    };
    await Promise.allSettled(this.disable.map(uuid => toggle(uuid, true)));
    await Promise.allSettled(this.enable.map(uuid => toggle(uuid, false)));
    if ( event.data.movement ) event.data.token.resumeMovement(event.data.movement.id, this.parent.uuid);
  }
}

var _module$V = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AdjustDarknessLevelRegionBehaviorType: AdjustDarknessLevelRegionBehaviorType,
  DisplayScrollingTextRegionBehaviorType: DisplayScrollingTextRegionBehaviorType,
  ExecuteMacroRegionBehaviorType: ExecuteMacroRegionBehaviorType,
  ExecuteScriptRegionBehaviorType: ExecuteScriptRegionBehaviorType,
  ModifyMovementCostRegionBehaviorType: ModifyMovementCostRegionBehaviorType,
  PauseGameRegionBehaviorType: PauseGameRegionBehaviorType,
  RegionBehaviorType: RegionBehaviorType,
  SuppressWeatherRegionBehaviorType: SuppressWeatherRegionBehaviorType,
  TeleportTokenRegionBehaviorType: TeleportTokenRegionBehaviorType,
  ToggleBehaviorRegionBehaviorType: ToggleBehaviorRegionBehaviorType
});

/**
 * A special subclass of DataField used to reference an AbstractBaseShader definition. Client only.
 */
class ShaderField extends DataField {
  /** @inheritdoc */
  static get _defaults() {
    const defaults = super._defaults;
    defaults.nullable = true;
    defaults.initial = undefined;
    return defaults;
  }

  /** @override */
  _cast(value) {
    if ( !(typeof value === "function" && value._isShaderFieldCompatible) ) {
      throw new Error("The value provided to a ShaderField must be a compatible Shader Class.");
    }
    return value;
  }
}

var fields = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AlphaField: AlphaField,
  AngleField: AngleField,
  AnyField: AnyField,
  ArrayField: ArrayField,
  BooleanField: BooleanField,
  ColorField: ColorField,
  DataField: DataField,
  DocumentAuthorField: DocumentAuthorField,
  DocumentFlagsField: DocumentFlagsField,
  DocumentIdField: DocumentIdField,
  DocumentOwnershipField: DocumentOwnershipField,
  DocumentStatsField: DocumentStatsField,
  DocumentTypeField: DocumentTypeField,
  DocumentUUIDField: DocumentUUIDField,
  EmbeddedCollectionDeltaField: EmbeddedCollectionDeltaField,
  EmbeddedCollectionField: EmbeddedCollectionField,
  EmbeddedDataField: EmbeddedDataField,
  EmbeddedDocumentField: EmbeddedDocumentField,
  FilePathField: FilePathField,
  ForeignDocumentField: ForeignDocumentField,
  HTMLField: HTMLField,
  HueField: HueField,
  IntegerSortField: IntegerSortField,
  JSONField: JSONField,
  JavaScriptField: JavaScriptField,
  NumberField: NumberField,
  ObjectField: ObjectField,
  SchemaField: SchemaField,
  SetField: SetField,
  ShaderField: ShaderField,
  StringField: StringField,
  TypeDataField: TypeDataField,
  TypedObjectField: TypedObjectField,
  TypedSchemaField: TypedSchemaField
});

/**
 * @import {TokenMeasureMovementPathOptions, TokenMovementCostFunction} from "../_types.mjs";
 * @import TokenDocument from "@client/documents/token.mjs";
 */

/**
 * The base TerrainData.
 * @template TerrainEffect
 * @abstract
 */
class BaseTerrainData extends DataModel {

  /**
   * Create the terrain data from the given array of terrain effects.
   * The type of the terrain effects and data is system-defined.
   * The terrain effects are not passed in any particular order.
   * Ownership of the array is passed to this function.
   * This function must return null if the array of terrain effects is empty.
   * @param {TerrainEffect[]} effects                  An array of terrain effects
   * @returns {BaseTerrainData<TerrainEffect>|null}    The terrain data or null
   * @abstract
   */
  static resolveTerrainEffects(effects) {
    throw new Error("A subclass of the BaseTerrainData must implement the resolveTerrainEffects method.");
  }

  /* -------------------------------------------- */

  /**
   * Create the terrain movement cost function for the given token.
   * Only movement cost that is caused by the terrain should be calculated by this function,
   * which includes the base movement cost.
   * Extra movement cost unrelated to terrain must be calculated in
   * {@link foundry.canvas.placeables.Token#_getMovementCostFunction}.
   * In square and hexagonal grids it calculates the cost for single grid space move between two grid space offsets.
   * For tokens that occupy more than one grid space the cost of movement is calculated as the median of all individual
   * grid space moves unless the cost of any of these is infinite, in which case total cost is always infinite.
   * In gridless grids the `from` and `to` parameters of the cost function are top-left offsets.
   * If the movement cost function is undefined, the cost equals the distance moved.
   * @param {TokenDocument} token                          The Token that moves
   * @param {TokenMeasureMovementPathOptions} [options]    Additional options that affect cost calculations
   * @returns {TokenMovementCostFunction|void}
   * @abstract
   */
  static getMovementCostFunction(token, options) {
    throw new Error("A subclass of the BaseTerrainData must implement the getMovementCostFunction method.");
  }

  /* -------------------------------------------- */

  /**
   * Is this terrain data the same as some other terrain data?
   * @param {any} other    Some other terrain data
   * @returns {boolean}    Are the terrain datas equal?
   * @abstract
   */
  equals(other) {
    throw new Error("A subclass of the BaseTerrainData must implement the equals method.");
  }
}

/* -------------------------------------------- */

/**
 * The core TerrainData implementation.
 * @extends {BaseTerrainData<{name: "difficulty", difficulty: number}>}
 *
 * @property {number} difficulty    The difficulty of the terrain (the movement cost multiplier)
 */
class TerrainData extends BaseTerrainData {
  /** @override */
  static defineSchema() {
    return {
      difficulty: new NumberField({required: true, nullable: true, min: 0, initial: 1})
    };
  }

  /* -------------------------------------------- */

  /** @override */
  static resolveTerrainEffects(effects) {
    let difficulty = 1;
    for ( const effect of effects ) {
      if ( effect.name === "difficulty" ) difficulty *= effect.difficulty;
    }
    if ( difficulty === 1 ) return null;
    return new this({difficulty});
  }

  /* -------------------------------------------- */

  /** @override */
  static getMovementCostFunction(token, options) {
    return (from, to, distance, segment) => distance * (segment.terrain?.difficulty ?? 1);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initialize(options) {
    super._initialize(options);
    this.difficulty ??= Infinity;
  }

  /* -------------------------------------------- */

  /** @override */
  equals(other) {
    if ( !(other instanceof TerrainData) ) return false;
    return this.difficulty === other.difficulty;
  }
}

/**
 * @typedef TurnMarkerAnimationData
 * The turn marker animation data.
 * @property {string} id                              The ID of the animation.
 * @property {string} label                           The label for the animation.
 * @property {TurnMarkerAnimationConfigData} [config] The configuration of the animation.
 */

/**
 * @typedef TurnMarkerAnimationConfigData
 * The turn marker config data.
 * @property {number} [spin]                 The spin speed for the animation.
 * @property {Object} pulse                  The pulse settings.
 * @property {number} [pulse.speed]          The speed of the pulse.
 * @property {number} [pulse.min]            The minimum pulse value.
 * @property {number} [pulse.max]            The maximum pulse value.
 * @property {typeof AbstractBaseShader|null} [shader] A shader class to apply or null.
 */

/**
 * Turn marker configuration data model.
 * @extends {foundry.abstract.DataModel}
 * @mixes TurnMarkerAnimationData
 */
class TurnMarkerData extends DataModel {
  /** @inheritDoc */
  static defineSchema() {
    const fields = foundry.data.fields;

    // Return model schema
    return {
      id: new fields.StringField({blank: false, nullable: false}),
      label: new fields.StringField({blank: false, nullable: false}),
      config: new fields.SchemaField({
        shader: new fields.ShaderField(),
        spin: new fields.NumberField({required: true, nullable: false, initial: 0}),
        pulse: new fields.SchemaField({
          speed: new fields.NumberField({required: true, nullable: false, initial: 0}),
          min: new fields.NumberField({required: true, nullable: false, initial: 0.8}),
          max: new fields.NumberField({required: true, nullable: false, initial: 1})
        })
      })
    };
  }
}

/**
 * @import {TurnMarkerAnimationData} from "../canvas/placeables/tokens/turn-marker-data.mjs"
 * @import {CombatConfigurationData} from "../_types.mjs"
 */

/**
 * A configuration class managing the Combat Turn Markers.
 */
class CombatConfiguration {
  constructor() {
    if ( CombatConfiguration.#instance ) {
      throw new Error("An instance of CombatConfiguration has already been created. "
        + "Use `CONFIG.Combat.settings` to access it.");
    }
    CombatConfiguration.#instance = this;
  }

  /**
   * The token ring config instance.
   * @type {CombatConfiguration}
   */
  static #instance;

  /**
   * To know if the ring config is initialized.
   * @type {boolean}
   */
  static #initialized = false;

  /**
   * Combat turn marker animation configurations
   * @type {Record<string, TurnMarkerAnimationData>}
   */
  static #DEFAULT_TURN_MARKER_ANIMATIONS = {
    spin: {
      id: "spin",
      label: "COMBAT.TURN_MARKERS.ANIMATIONS.SPIN",
      config: {spin: -4, pulse: {speed: 0, min: 1, max: 1}}
    },
    spinPulse: {
      id: "spinPulse",
      label: "COMBAT.TURN_MARKERS.ANIMATIONS.SPIN_PULSE",
      config: {spin: -4, pulse: {speed: 30, min: 0.9, max: 1.1}}
    },
    pulse: {
      id: "pulse",
      label: "COMBAT.TURN_MARKERS.ANIMATIONS.PULSE",
      config: {spin: 0, pulse: {speed: 30, min: 0.9, max: 1.1}}
    }
  };

  /**
   * The configuration setting used to record Combat preferences
   * @type {string}
   */
  static CONFIG_SETTING = "combatTrackerConfig";

  /**
   * The data model schema used to structure and validate the stored setting.
   * @type {SchemaField}
   */
  static get schema() {
    return this.#schema;
  }

  static #schema;

  /* -------------------------------------------- */

  /**
   * Register the token ring config and initialize it
   */
  static initialize() {
    // If combat tracker settings are initialized
    if ( this.#initialized ) {
      throw new Error("The combat tracker settings class can be initialized only once!");
    }
    foundry.helpers.Localization.localizeDataModel({schema: this.#schema}, {prefixes: ["COMBAT.CONFIG"]});

    // Add default turn markers animations
    for ( const id in this.#DEFAULT_TURN_MARKER_ANIMATIONS ) {
      const config = new TurnMarkerData(this.#DEFAULT_TURN_MARKER_ANIMATIONS[id]);
      CONFIG.Combat.settings.addTurnMarkerAnimation(config.id, config);
    }

    // Call an explicit hook for combat tracker settings configuration
    Hooks$1.callAll("initializeCombatConfiguration", CONFIG.Combat.settings);

    // Initialize combat tracker settings configuration
    CONFIG.Combat.settings.useTurnMarkerAnimation(this.#instance.turnMarker.animation);
    this.#initialized = true;
  }

  /* -------------------------------------------- */

  /**
   * Register game settings used by the Combat Tracker
   */
  static registerSettings() {
    this.#schema = new SchemaField({
      resource: new StringField({required: true, blank: true, initial: ""}),
      skipDefeated: new BooleanField({required: true, initial: false}),
      turnMarker: new SchemaField({
        enabled: new BooleanField({required: true, initial: true}),
        animation: new StringField({initial: "spin"}),
        src: new FilePathField({categories: ["IMAGE", "VIDEO"], blank: true, initial: ""}),
        disposition: new BooleanField()
      })
    });
    game.settings.register("core", this.CONFIG_SETTING, {
      name: "Combat Tracker Configuration",
      scope: "world",
      config: false,
      type: this.#schema,
      onChange: () => this.#onChangeCombatConfiguration()
    });
  }

  /* -------------------------------------------- */

  /**
   * Called when turn markers settings are changed.
   */
  static #onChangeCombatConfiguration() {
    if ( !this.#initialized ) return;
    CONFIG.Combat.settings.useTurnMarkerAnimation(this.#instance.turnMarker.animation);
    if ( game.combat ) {
      game.combat.reset();
      game.combat._updateTurnMarkers();
      game.combats.render();
    }
  }

  /* -------------------------------------------- */

  /**
   * Turn marker animations.
   * @type {Map<string, TurnMarkerAnimationData>}
   */
  #turnMarkerAnimations = new Map();

  /**
   * The current turn marker animation.
   * @type {TurnMarkerAnimationData}
   */
  #currentAnimation;

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Get turn marker settings.
   * @type {Object}
   */
  get turnMarker() {
    return game.settings.get("core", this.constructor.CONFIG_SETTING).turnMarker;
  }

  /**
   * Get tracked resource setting.
   * @type {string}
   */
  get resource() {
    return game.settings.get("core", this.constructor.CONFIG_SETTING).resource;
  }

  /**
   * Get skip defeated setting.
   * @type {boolean}
   */
  get skipDefeated() {
    return game.settings.get("core", this.constructor.CONFIG_SETTING).skipDefeated;
  }

  /**
   * Get current turn marker animation.
   * @type {TurnMarkerAnimationData}
   */
  get currentTurnMarkerAnimation() {
    return this.#currentAnimation;
  }

  /* -------------------------------------------- */
  /*  Management                                  */
  /* -------------------------------------------- */

  /**
   * Add a new turn marker animation.
   * @param {string} id                       The id of the turn marker animation.
   * @param {TurnMarkerAnimationData} config  The configuration object for the turn marker animation.
   */
  addTurnMarkerAnimation(id, config) {
    this.#turnMarkerAnimations.set(id, config);
  }

  /* -------------------------------------------- */

  /**
   * Get a turn marker animation by id.
   * @param {string} id                  The id of the turn marker configuration.
   * @returns {TurnMarkerAnimationData}  The turn marker configuration object.
   */
  getTurnMarkerAnimation(id) {
    return this.#turnMarkerAnimations.get(id);
  }

  /* -------------------------------------------- */

  /**
   * Use a turn marker animation.
   * @param {string} animationId  The id of the turn marker animation to use.
   * @returns {boolean}           True if the animation was successfully set, false otherwise.
   */
  useTurnMarkerAnimation(animationId) {
    if ( this.#turnMarkerAnimations.has(animationId) ) {
      this.#currentAnimation = this.#turnMarkerAnimations.get(animationId);
      return true;
    }
    this.#currentAnimation = null;
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Get all animations and labels as an array of choices suitable for a select element.
   * @type {{value: string, label: string}[]} An array of objects containing an id and a localized label.
   */
  get turnMarkerAnimations() {
    return Array.from(this.#turnMarkerAnimations, ([value, {label}]) => ({value, label: game.i18n.localize(label)}));
  }
}

/**
 * @import BaseUser from "../documents/user.mjs";
 * @import {
 *   DatabaseCreateOperation,
 *   DatabaseGetOperation,
 *   DatabaseUpdateOperation,
 *   DatabaseDeleteOperation,
 *   DatabaseOperation
 * } from "./_types.mjs";
 */

/**
 * An abstract base class extended on both the client and server which defines how Documents are retrieved, created,
 * updated, and deleted.
 * @abstract
 */
let DatabaseBackend$1 = class DatabaseBackend {

  /* -------------------------------------------- */
  /*  Get Operations                              */
  /* -------------------------------------------- */

  /**
   * Retrieve Documents based on provided query parameters.
   * It recommended to use CompendiumCollection#getDocuments or CompendiumCollection#getIndex rather
   * than calling this method directly.
   * @param {typeof Document} documentClass           The Document class definition
   * @param {DatabaseGetOperation} operation          Parameters of the get operation
   * @param {BaseUser} [user]                         The requesting User
   * @returns {Promise<Document[]|object[]>}          An array of retrieved Document instances or index objects
   */
  async get(documentClass, operation, user) {
    operation = await this.#configureGet(operation);
    return this._getDocuments(documentClass, operation, user);
  }

  /* -------------------------------------------- */

  /**
   * Validate and configure the parameters of the get operation.
   * @param {DatabaseGetOperation} operation          The requested operation
   */
  async #configureGet(operation) {
    operation.action = "get";
    await this.#configureOperation(operation);
    operation.broadcast = false; // Get requests are never broadcast
    return operation;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve Document instances using the specified operation parameters.
   * @param {typeof Document} documentClass           The Document class definition
   * @param {DatabaseGetOperation} operation          Parameters of the get operation
   * @param {BaseUser} [user]                         The requesting User
   * @returns {Promise<Document[]|object[]>}          An array of retrieved Document instances or index objects
   * @abstract
   * @internal
   * @ignore
   */
  async _getDocuments(documentClass, operation, user) {}

  /* -------------------------------------------- */
  /*  Create Operations                           */
  /* -------------------------------------------- */

  /**
   * Create new Documents using provided data and context.
   * It is recommended to use {@link foundry.abstract.Document.createDocuments} or {@link foundry.abstract.Document.create} rather than calling this
   * method directly.
   * @param {typeof Document} documentClass           The Document class definition
   * @param {DatabaseCreateOperation} operation       Parameters of the create operation
   * @param {BaseUser} [user]                         The requesting User
   * @returns {Promise<Document[]>}                   An array of created Document instances
   */
  async create(documentClass, operation, user) {
    operation = await this.#configureCreate(operation);
    return this._createDocuments(documentClass, operation, user);
  }

  /* -------------------------------------------- */

  /**
   * Validate and configure the parameters of the create operation.
   * @param {DatabaseCreateOperation} operation       The requested operation
   */
  async #configureCreate(operation) {
    operation.action = "create";
    if ( !Array.isArray(operation.data) ) {
      throw new Error("The data provided to the DatabaseBackend#create operation must be an array of data objects");
    }
    await this.#configureOperation(operation);
    operation.render ??= true;
    operation.renderSheet ??= false;
    return operation;
  }

  /* -------------------------------------------- */

  /**
   * Create Document instances using provided data and operation parameters.
   * @param {typeof Document} documentClass           The Document class definition
   * @param {DatabaseCreateOperation} operation       Parameters of the create operation
   * @param {BaseUser} [user]                         The requesting User
   * @returns {Promise<Document[]>}                   An array of created Document instances
   * @abstract
   * @internal
   * @ignore
   */
  async _createDocuments(documentClass, operation, user) {}

  /* -------------------------------------------- */
  /*  Update Operations                           */
  /* -------------------------------------------- */

  /**
   * Update Documents using provided data and context.
   * It is recommended to use {@link foundry.abstract.Document.updateDocuments} or {@link foundry.abstract.Document#update} rather than calling this
   * method directly.
   * @param {typeof Document} documentClass           The Document class definition
   * @param {DatabaseUpdateOperation} operation       Parameters of the update operation
   * @param {BaseUser} [user]                         The requesting User
   * @returns {Promise<Document[]>}                   An array of updated Document instances
   */
  async update(documentClass, operation, user) {
    operation = await this.#configureUpdate(operation);
    return this._updateDocuments(documentClass, operation, user);
  }

  /* -------------------------------------------- */

  /**
   * Validate and configure the parameters of the update operation.
   * @param {DatabaseUpdateOperation} operation       The requested operation
   */
  async #configureUpdate(operation) {
    operation.action = "update";
    if ( !Array.isArray(operation.updates) ) {
      throw new Error("The updates provided to the DatabaseBackend#update operation must be an array of data objects");
    }
    await this.#configureOperation(operation);
    operation.diff ??= true;
    operation.recursive ??= true;
    operation.render ??= true;
    return operation;
  }

  /* -------------------------------------------- */

  /**
   * Update Document instances using provided data and operation parameters.
   * @param {typeof Document} documentClass           The Document class definition
   * @param {DatabaseUpdateOperation} operation       Parameters of the update operation
   * @param {BaseUser} [user]                         The requesting User
   * @returns {Promise<Document[]>}                   An array of updated Document instances
   * @abstract
   * @internal
   * @ignore
   */
  async _updateDocuments(documentClass, operation, user) {}

  /* -------------------------------------------- */
  /*  Delete Operations                           */
  /* -------------------------------------------- */

  /**
   * Delete Documents using provided ids and context.
   * It is recommended to use {@link foundry.abstract.Document.deleteDocuments} or
   * {@link foundry.abstract.Document#delete} rather than calling this method directly.
   * @param {typeof Document} documentClass           The Document class definition
   * @param {DatabaseDeleteOperation} operation       Parameters of the delete operation
   * @param {BaseUser} [user]                         The requesting User
   * @returns {Promise<Document[]>}                   An array of deleted Document instances
   */
  async delete(documentClass, operation, user) {
    operation = await this.#configureDelete(operation);
    return this._deleteDocuments(documentClass, operation, user);
  }

  /* -------------------------------------------- */

  /**
   * Validate and configure the parameters of the delete operation.
   * @param {DatabaseDeleteOperation} operation       The requested operation
   */
  async #configureDelete(operation) {
    operation.action = "delete";
    if ( !Array.isArray(operation.ids) ) {
      throw new Error("The document ids provided to the DatabaseBackend#delete operation must be an array of strings");
    }
    await this.#configureOperation(operation);
    operation.deleteAll ??= false;
    operation.render ??= true;
    return operation;
  }

  /* -------------------------------------------- */

  /**
   * Delete Document instances using provided ids and operation parameters.
   * @param {typeof Document} documentClass           The Document class definition
   * @param {DatabaseDeleteOperation} operation       Parameters of the delete operation
   * @param {BaseUser} [user]                         The requesting User
   * @returns {Promise<Document[]>}                   An array of deleted Document instances
   * @abstract
   * @internal
   * @ignore
   */
  async _deleteDocuments(documentClass, operation, user) {}

  /* -------------------------------------------- */
  /*  Helper Methods                              */
  /* -------------------------------------------- */

  /**
   * Common database operation configuration steps.
   * @param {DatabaseOperation} operation           The requested operation
   * @returns {Promise<void>}
   */
  async #configureOperation(operation) {
    this.#assertCompendiumUnlocked(operation);
    operation.parent = await this._getParent(operation);
    operation.modifiedTime = Date.now();
  }

  /* -------------------------------------------- */

  /**
   * Get the parent Document (if any) associated with a request context.
   * @param {DatabaseOperation} operation           The requested database operation
   * @returns {Promise<Document|null>}              The parent Document, or null
   * @internal
   * @ignore
   */
  async _getParent(operation) {
    if ( operation.parent && !(operation.parent instanceof Document) ) {
      throw new Error("A parent Document provided to the database operation must be a Document instance");
    }
    else if ( operation.parent ) return operation.parent;
    if ( operation.parentUuid ) return globalThis.fromUuid(operation.parentUuid, {invalid: true});
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Assert that a target compendium pcak for an operation is unlocked and able to be modified.
   * @param {DatabaseOperation} operation     The requested database operation
   * @throws {Error}                          An Error if the target compendium pack is locked
   */
  #assertCompendiumUnlocked(operation) {
    if ( !operation.pack ) return;

    // Validate the compendium identifier
    if ( !this.getCompendiumScopes().includes(operation.pack) ) {
      throw new Error(`Compendium pack "${operation.pack}" is not a valid Compendium identifier`);
    }
    if ( operation.action === "get" ) return;

    // Determine the pack locked state
    const cfg = game.compendiumConfiguration ?? {};
    let locked = cfg[operation.pack]?.locked;
    if ( locked === undefined ) {
      const pack = game.packs.get(operation.pack);
      locked = pack?.metadata?.packageType !== "world";
    }
    locked ??= true; // Safety net

    // Prevent modification if locked
    if ( locked ) {
      const m = `You may not ${operation.action} documents in the locked compendium "${operation.pack}".`;
      if ( globalThis.ui?.notifications ) ui.notifications.error(m, {console: true});
      throw new Error(m);
    }
  }

  /* -------------------------------------------- */

  /**
   * Describe the scopes which are suitable as the namespace for a flag key
   * @returns {string[]}
   */
  getFlagScopes() {}

  /* -------------------------------------------- */

  /**
   * Describe the scopes which are suitable as the namespace for a flag key
   * @returns {string[]}
   */
  getCompendiumScopes() {}

  /* -------------------------------------------- */

  /**
   * Log a database operations message.
   * @param {string} level      The logging level
   * @param {string} message    The message
   * @abstract
   * @protected
   */
  _log(level, message) {}

  /* -------------------------------------------- */

  /**
   * Log a database operation for an embedded document, capturing the action taken and relevant IDs
   * @param {string} action                  The action performed
   * @param {string} type                    The document type
   * @param {abstract.Document[]} documents  The documents modified
   * @param {object} [context]               The context of the log request
   * @param {Document} [context.parent]      A parent document
   * @param {string} [context.pack]          A compendium pack within which the operation occurred
   * @param {string} [context.level=info]    The logging level
   * @protected
   */
  _logOperation(action, type, documents, {parent, pack, level="info"}={}) {
    let msg = (documents.length === 1) ? `${action} ${type}` : `${action} ${documents.length} ${type} documents`;
    if (documents.length === 1) msg += ` with id [${documents[0].id}]`;
    else if (documents.length <= 5) msg += ` with ids: [${documents.map(d => d.id)}]`;
    msg += this.#logContext(parent, pack);
    this._log(level, msg);
  }

  /* -------------------------------------------- */

  /**
   * Construct a standardized error message given the context of an attempted operation
   * @param {BaseUser} user
   * @param {string} action
   * @param {Document} subject
   * @param {object} [context]
   * @param {Document} [context.parent]
   * @param {string} [context.pack]
   * @returns {string}
   * @protected
   */
  _logError(user, action, subject, {parent, pack}={}) {
    if ( subject instanceof Document ) {
      subject = subject.id ? `${subject.documentName} [${subject.id}]` : `a new ${subject.documentName}`;
    }
    const msg = `User ${user.name} lacks permission to ${action} ${subject}`;
    return msg + this.#logContext(parent, pack);
  }

  /* -------------------------------------------- */

  /**
   * Determine a string suffix for a log message based on the parent and/or compendium context.
   * @param {Document|null} parent
   * @param {string|null} pack
   * @returns {string}
   */
  #logContext(parent, pack) {
    let context = "";
    if ( parent ) context += ` in parent ${parent.constructor.metadata.name} [${parent.id}]`;
    if ( pack ) context += ` in Compendium ${pack}`;
    return context;
  }
};

/**
 * @import {DocumentSocketRequest} from "@common/abstract/_types.mjs";
 */

/**
 * 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) {
    const 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;
  }
}

/**
 * @import {
 *   DatabaseAction,
 *   DatabaseOperation,
 *   DatabaseGetOperation,
 *   DatabaseCreateOperation,
 *   DatabaseUpdateOperation,
 *   DatabaseDeleteOperation,
 *   DocumentSocketRequest
 * } from "@common/abstract/_types.mjs";
 * @import DocumentSocketResponse from "@common/abstract/socket.mjs";
 * @import Collection from "@common/utils/collection.mjs";
 * @import User from "../documents/user.mjs";
 */

/**
 * The client-side database backend implementation which handles Document modification operations.
 */
class ClientDatabaseBackend extends DatabaseBackend$1 {

  /* -------------------------------------------- */
  /*  Get Operations                              */
  /* -------------------------------------------- */

  /**
   * @override
   * @ignore
   */
  async _getDocuments(documentClass, operation, user) {
    const request = ClientDatabaseBackend.#buildRequest(documentClass, "get", operation);
    const response = await ClientDatabaseBackend.#dispatchRequest(request);
    if ( operation.index ) return response.result;
    return response.result.map(data => documentClass.fromSource(data, {pack: operation.pack}));
  }

  /* -------------------------------------------- */
  /*  Create Operations                           */
  /* -------------------------------------------- */

  /**
   * @override
   * @ignore
   */
  async _createDocuments(documentClass, operation, user) {
    user ||= game.user;
    await ClientDatabaseBackend.#preCreateDocumentArray(documentClass, operation, user);
    if ( !operation.data.length ) return [];
    /** @deprecated since v12 */
    // Legacy support for temporary creation option
    if ( "temporary" in operation ) {
      foundry.utils.logCompatibilityWarning("It is no longer supported to create temporary documents using the "
        + "Document.createDocuments API. Use the new Document() constructor instead.", {since: 12, until: 14});
      if ( operation.temporary ) return operation.data;
    }
    const request = ClientDatabaseBackend.#buildRequest(documentClass, "create", operation);
    const response = await ClientDatabaseBackend.#dispatchRequest(request);
    return this.#handleCreateDocuments(response);
  }

  /* -------------------------------------------- */

  /**
   * Perform a standardized pre-creation workflow for all Document types.
   * This workflow mutates the operation data array.
   * @param {typeof ClientDocument} documentClass
   * @param {DatabaseCreateOperation} operation
   * @param {User} user
   */
  static async #preCreateDocumentArray(documentClass, operation, user) {
    const {data, noHook, pack, parent, ...options} = operation;
    const type = documentClass.documentName;
    const toCreate = [];
    const documents = [];
    for ( let d of data ) {

      // Clean input data
      d = ( d instanceof foundry.abstract.DataModel ) ? d.toObject() : foundry.utils.expandObject(d);
      d = documentClass.migrateData(d);
      const createData = foundry.utils.deepClone(d); // Copy for later passing original input data to preCreate

      // Create pending document
      let doc;
      try {
        doc = new documentClass(createData, {parent, pack});
      } catch(err) {
        Hooks$1.onError("ClientDatabaseBackend##preCreateDocumentArray", err, {id: d._id, log: "error", notify: "error"});
        continue;
      }

      // Call per-document workflows
      let documentAllowed = await doc._preCreate(d, options, user) ?? true;
      documentAllowed &&= (noHook || Hooks$1.call(`preCreate${type}`, doc, d, options, user.id));
      if ( documentAllowed === false ) {
        console.debug(`${CONST.vtt} | ${type} creation prevented by _preCreate`);
        continue;
      }
      documents.push(doc);
      toCreate.push(d);
    }
    operation.data = toCreate;
    if ( !documents.length ) return;

    // Call final pre-operation workflow
    Object.assign(operation, options); // Hooks may have changed options
    const operationAllowed = await documentClass._preCreateOperation(documents, operation, user);
    if ( operationAllowed === false ) {
      console.debug(`${CONST.vtt} | ${type} creation operation prevented by _preCreateOperation`);
      operation.data = [];
    }
    else operation.data = documents;
  }

  /* -------------------------------------------- */

  /**
   * Handle a SocketResponse from the server when one or multiple documents were created.
   * @param {foundry.abstract.DocumentSocketResponse} response  A document modification socket response
   * @returns {Promise<ClientDocument[]>}  An Array of created Document instances
   */
  async #handleCreateDocuments(response) {
    const {type, operation, result, userId} = response;
    const documentClass = getDocumentClass$1(type);
    const parent = /** @type {ClientDocument|null} */ operation.parent = await this._getParent(operation);
    const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
    const user = game.users.get(userId);
    const {pack, parentUuid, syntheticActorUpdate, ...options} = operation;
    operation.data = response.result; // Record created data objects back to the operation

    // Initial descendant document events
    const preArgs = [result, options, userId];
    parent?._dispatchDescendantDocumentEvents("preCreate", collection.name, preArgs);

    // Create documents and prepare post-creation callback functions
    const callbacks = result.map(data => {
      const doc = collection.createDocument(data, {parent, pack});
      collection.set(doc.id, doc, options);
      return () => {
        doc._onCreate(data, options, userId);
        Hooks$1.callAll(`create${type}`, doc, options, userId);
        return doc;
      };
    });
    parent?.reset();
    let documents = callbacks.map(fn => fn());

    // Call post-operation workflows
    const postArgs = [documents, result, options, userId];
    parent?._dispatchDescendantDocumentEvents("onCreate", collection.name, postArgs);
    await documentClass._onCreateOperation(documents, operation, user);
    collection._onModifyContents("create", documents, result, operation, user);

    // Log and return result
    if ( CONFIG.debug.documents ) this._logOperation("Created", type, documents, {level: "info", parent, pack});
    if ( syntheticActorUpdate ) documents = ClientDatabaseBackend.#adjustActorDeltaResponse(documents);
    return documents;
  }

  /* -------------------------------------------- */
  /*  Update Operations                           */
  /* -------------------------------------------- */

  /**
   * @override
   * @ignore
   */
  async _updateDocuments(documentClass, operation, user) {
    user ||= game.user;
    await ClientDatabaseBackend.#preUpdateDocumentArray(documentClass, operation, user);
    if ( !operation.updates.length ) return [];
    const request = ClientDatabaseBackend.#buildRequest(documentClass, "update", operation);
    const response = await ClientDatabaseBackend.#dispatchRequest(request);
    return this.#handleUpdateDocuments(response);
  }

  /* -------------------------------------------- */

  /**
   * Perform a standardized pre-update workflow for all Document types.
   * This workflow mutates the operation updates array.
   * @param {typeof ClientDocument} documentClass
   * @param {DatabaseUpdateOperation} operation
   * @param {User} user
   */
  static async #preUpdateDocumentArray(documentClass, operation, user) {
    const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
    const type = documentClass.documentName;
    const {updates, restoreDelta, noHook, pack, parent, ...options} = operation;

    // Ensure all Documents which are update targets have been loaded
    await ClientDatabaseBackend.#loadCompendiumDocuments(collection, updates);

    // Iterate over requested changes
    const toUpdate = [];
    const documents = [];
    for ( const update of updates ) {
      if ( !update._id ) throw new Error("You must provide an _id for every object in the update data Array.");

      // Retrieve the target document and the request changes
      let changes;
      if ( update instanceof foundry.abstract.DataModel ) changes = update.toObject();
      else changes = foundry.utils.expandObject(update);
      const doc = collection.get(update._id, {strict: true, invalid: true});

      // Migrate provided changes, including document sub-type
      const addType = ("type" in doc) && !("type" in changes);
      documentClass.schema._addTypes(doc._source, changes);
      changes = documentClass.migrateData(changes);

      // Use the original update object if it was expanded
      if ( changes !== update ) {
        for ( const key in update ) delete update[key];
        changes = Object.assign(update, changes);
      }

      // Perform pre-update operations
      let documentAllowed = await doc._preUpdate(changes, options, user) ?? true;
      documentAllowed &&= (noHook || Hooks$1.call(`preUpdate${type}`, doc, changes, options, user.id));
      if ( documentAllowed === false ) {
        console.debug(`${CONST.vtt} | ${type} update prevented during pre-update`);
        continue;
      }

      // Attempt updating the document to validate the changes
      let diff = {};
      try {
        diff = doc.updateSource(changes, {dryRun: true, fallback: false, restoreDelta, recursive: options.recursive});
      } catch(err) {
        ui.notifications.error(err.message.split("] ").pop());
        Hooks$1.onError("ClientDatabaseBackend##preUpdateDocumentArray", err, {id: doc.id, log: "error"});
        continue;
      }

      // Retain only the differences against the current source
      if ( options.diff ) {
        if ( foundry.utils.isEmpty(diff) ) continue;
        diff._id = doc.id;
        changes = diff;
      }
      // TODO: We don't strip out the types added for TypedSchemaFields by the updateSource call above
      // that didn't actually change. Is this a problem? #11865
      else if ( addType ) delete changes.type;
      documents.push(doc);
      toUpdate.push(changes);
    }
    operation.updates = toUpdate;
    if ( !toUpdate.length ) return;

    // Call final pre-operation workflow
    Object.assign(operation, options); // Hooks may have changed options
    const operationAllowed = await documentClass._preUpdateOperation(documents, operation, user);
    if ( operationAllowed === false ) {
      console.debug(`${CONST.vtt} | ${type} creation operation prevented by _preUpdateOperation`);
      operation.updates = [];
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle a SocketResponse from the server when one or multiple documents were updated.
   * @param {foundry.abstract.DocumentSocketResponse} response  A document modification socket response
   * @returns {Promise<ClientDocument[]>}  An Array of updated Document instances
   */
  async #handleUpdateDocuments(response) {
    const {type, operation, result, userId} = response;
    const documentClass = getDocumentClass$1(type);
    const parent = /** @type {ClientDocument|null} */ operation.parent = await this._getParent(operation);
    const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
    const user = game.users.get(userId);
    const {pack, parentUuid, syntheticActorUpdate, ...options} = operation;
    operation.updates = response.result; // Record update data objects back to the operation

    // Ensure all Documents which are update targets have been loaded.
    await ClientDatabaseBackend.#loadCompendiumDocuments(collection, operation.updates);

    // Pre-operation actions
    const preArgs = [result, options, userId];
    parent?._dispatchDescendantDocumentEvents("preUpdate", collection.name, preArgs);

    // Perform updates and create a callback function for each document
    const callbacks = [];
    const changes = [];
    for ( let change of result ) {
      const doc = collection.get(change._id, {strict: false});
      if ( !doc ) continue;
      change = doc.updateSource(change, options);
      change._id = doc.id;
      collection.set(doc.id, doc, options);
      callbacks.push(() => {
        change = documentClass.shimData(change);
        doc._onUpdate(change, options, userId);
        Hooks$1.callAll(`update${type}`, doc, change, options, userId);
        changes.push(change);
        return doc;
      });
    }
    parent?.reset();
    let documents = callbacks.map(fn => fn());
    operation.updates = changes;

    // Post-operation actions
    const postArgs = [documents, changes, options, userId];
    parent?._dispatchDescendantDocumentEvents("onUpdate", collection.name, postArgs);
    await documentClass._onUpdateOperation(documents, operation, user);
    collection._onModifyContents("update", documents, changes, operation, user);

    // Log and return result
    if ( CONFIG.debug.documents ) this._logOperation("Updated", type, documents, {level: "debug", parent, pack});
    if ( syntheticActorUpdate ) documents = ClientDatabaseBackend.#adjustActorDeltaResponse(documents);
    return documents;
  }

  /* -------------------------------------------- */
  /*  Delete Operations                           */
  /* -------------------------------------------- */

  /**
   * @override
   * @ignore
   */
  async _deleteDocuments(documentClass, operation, user) {
    user ||= game.user;
    await ClientDatabaseBackend.#preDeleteDocumentArray(documentClass, operation, user);
    if ( !operation.ids.length ) return operation.ids;
    const request = ClientDatabaseBackend.#buildRequest(documentClass, "delete", operation);
    const response = await ClientDatabaseBackend.#dispatchRequest(request);
    return this.#handleDeleteDocuments(response);
  }

  /* -------------------------------------------- */

  /**
   * Perform a standardized pre-delete workflow for all Document types.
   * This workflow mutates the operation ids array.
   * @param {typeof ClientDocument} documentClass
   * @param {DatabaseDeleteOperation} operation
   * @param {User} user
   */
  static async #preDeleteDocumentArray(documentClass, operation, user) {
    const {ids: explicitIds, deleteAll, noHook, pack, parent, ...options} = operation;
    const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
    const type = documentClass.documentName;

    // Ensure all Documents which are deletion targets have been loaded
    const ids = deleteAll ? Array.from(collection.index?.keys() ?? collection.keys()) : explicitIds;
    await ClientDatabaseBackend.#loadCompendiumDocuments(collection, ids);

    // Iterate over ids requested for deletion
    const toDelete = [];
    const documents = [];
    for ( const id of ids ) {
      const doc = collection.get(id, {strict: true, invalid: true});
      let documentAllowed = await doc._preDelete(options, user) ?? true;
      documentAllowed &&= (noHook || Hooks$1.call(`preDelete${type}`, doc, options, user.id));
      if ( documentAllowed === false ) {
        console.debug(`${CONST.vtt} | ${type} deletion prevented during pre-delete`);
        continue;
      }
      toDelete.push(id);
      documents.push(doc);
    }
    operation.ids = toDelete;
    if ( !toDelete.length ) return;

    // Call final pre-operation workflow
    Object.assign(operation, options); // Hooks may have changed options
    const operationAllowed = await documentClass._preDeleteOperation(documents, operation, user);
    if ( operationAllowed === false ) {
      console.debug(`${CONST.vtt} | ${type} creation operation prevented by _preDeleteOperation`);
      operation.ids = [];
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle a SocketResponse from the server where Documents are deleted.
   * @param {DocumentSocketResponse} response  A document modification socket response
   * @returns {Promise<ClientDocument[]>}  An Array of deleted Document instances
   */
  async #handleDeleteDocuments(response) {
    const {type, operation, result, userId} = response;
    const documentClass = getDocumentClass$1(type);
    const parent = /** @type {ClientDocument|null} */ operation.parent = await this._getParent(operation);
    const collection = ClientDatabaseBackend.#getCollection(documentClass, operation);
    const user = game.users.get(userId);
    const {deleteAll, pack, parentUuid, syntheticActorUpdate, ...options} = operation;
    operation.ids = response.result; // Record deleted document ids back to the operation

    await ClientDatabaseBackend.#loadCompendiumDocuments(collection, operation.ids);

    // Pre-operation actions
    const preArgs = [result, options, userId];
    parent?._dispatchDescendantDocumentEvents("preDelete", collection.name, preArgs);

    // Perform deletions and create a callback function for each document
    const callbacks = [];
    const ids = [];
    for ( const id of result ) {
      const doc = collection.get(id, {strict: false});
      if ( !doc ) continue;
      collection.delete(id);
      callbacks.push(() => {
        doc._onDelete(options, userId);
        Hooks$1.callAll(`delete${type}`, doc, options, userId);
        ids.push(id);
        return doc;
      });
    }
    parent?.reset();
    let documents = callbacks.map(fn => fn());
    operation.ids = ids;

    // Post-operation actions
    const postArgs = [documents, ids, options, userId];
    parent?._dispatchDescendantDocumentEvents("onDelete", collection.name, postArgs);
    await documentClass._onDeleteOperation(documents, operation, user);
    collection._onModifyContents("delete", documents, ids, operation, user);

    // Log and return result
    if ( CONFIG.debug.documents ) this._logOperation("Deleted", type, documents, {level: "info", parent, pack});
    if ( syntheticActorUpdate ) documents = ClientDatabaseBackend.#adjustActorDeltaResponse(documents);
    return documents;
  }

  /* -------------------------------------------- */
  /*  Socket Workflows                            */
  /* -------------------------------------------- */

  /**
   * Activate the Socket event listeners used to receive responses from events which modify database documents
   * @param {io.Socket} socket                           The active game socket
   * @internal
   * @ignore
   */
  activateSocketListeners(socket) {
    socket.on("modifyDocument", this.#onModifyDocument.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle a socket response broadcast back from the server.
   * @param {DocumentSocketResponse} response  A document modification socket response
   */
  #onModifyDocument(response) {
    switch ( response.action ) {
      case "create":
        this.#handleCreateDocuments(response);
        break;
      case "update":
        this.#handleUpdateDocuments(response);
        break;
      case "delete":
        this.#handleDeleteDocuments(response);
        break;
      default:
        throw new Error(`Invalid Document modification action ${response.action} provided`);
    }

    // Update user last activity
    const user = game.users.get(response.userId);
    if ( user ) user.lastActivityTime = Date.now();
  }

  /* -------------------------------------------- */
  /*  Helper Methods                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  getFlagScopes() {
    if ( this.#flagScopes ) return this.#flagScopes;
    const scopes = ["core", "world", game.system.id];
    for ( const module of game.modules ) {
      if ( module.active ) scopes.push(module.id);
    }
    return this.#flagScopes = scopes;
  }

  /**
   * A cached array of valid flag scopes which can be read and written.
   * @type {string[]}
   */
  #flagScopes;

  /* -------------------------------------------- */

  /** @inheritDoc */
  getCompendiumScopes() {
    return Array.from(game.packs.keys());
  }

  /* -------------------------------------------- */

  /** @override */
  _log(level, message) {
    globalThis.logger[level](`${CONST.vtt} | ${message}`);
  }

  /* -------------------------------------------- */

  /**
   * Obtain the document collection for a given Document class and database operation.
   * @param {typeof ClientDocument} documentClass   The Document class being operated upon
   * @param {object} operation                The database operation being performed
   * @param {ClientDocument|null} operation.parent  A parent Document, if applicable
   * @param {string|null} operation.pack        A compendium pack identifier, if applicable
   * @returns {DocumentCollection|CompendiumCollection}  The relevant collection instance for this request
   */
  static #getCollection(documentClass, {parent, pack}) {
    const documentName = documentClass.documentName;
    if ( parent ) return parent.getEmbeddedCollection(documentName);
    if ( pack ) {
      const collection = game.packs.get(pack);
      return documentName === "Folder" ? collection.folders : collection;
    }
    return game.collections.get(documentName);
  }

  /* -------------------------------------------- */

  /**
   * Structure a database operation as a web socket request.
   * @param {typeof ClientDocument} documentClass
   * @param {DatabaseAction} action
   * @param {DatabaseOperation} operation
   * @returns {DocumentSocketRequest}
   */
  static #buildRequest(documentClass, action, operation) {
    const request = {type: documentClass.documentName, action, operation};
    if ( operation.parent ) { // Don't send full parent data
      operation.parentUuid = operation.parent.uuid;
      ClientDatabaseBackend.#adjustActorDeltaRequest(documentClass, request);
      delete operation.parent;
    }
    return request;
  }

  /* -------------------------------------------- */

  /**
   * Dispatch a document modification socket request to the server.
   * @param {DocumentSocketRequest} request
   * @returns {DocumentSocketResponse}
   */
  static async #dispatchRequest(request) {
    const responseData = await SocketInterface.dispatch("modifyDocument", request);
    return new foundry.abstract.DocumentSocketResponse(responseData);
  }

  /* -------------------------------------------- */

  /**
   * Ensure the given list of documents is loaded into the compendium collection so that they can be retrieved by
   * subsequent operations.
   * @param {Collection} collection        The candidate collection.
   * @param {object[]|string[]} documents  An array of update deltas, or IDs, depending on the operation.
   */
  static async #loadCompendiumDocuments(collection, documents) {
    // Ensure all Documents which are update targets have been loaded
    if ( collection instanceof foundry.documents.collections.CompendiumCollection ) {
      const ids = documents.reduce((arr, doc) => {
        const id = doc._id ?? doc;
        if ( id && !collection.has(id) ) arr.push(id);
        return arr;
      }, []);
      await collection.getDocuments({_id__in: ids});
    }
  }

  /* -------------------------------------------- */
  /*  Token and ActorDelta Special Case           */
  /* -------------------------------------------- */

  /**
   * Augment a database operation with alterations needed to support ActorDelta and TokenDocuments.
   * @param {typeof ClientDocument} documentClass    The document class being operated upon
   * @param {DocumentSocketRequest} request                     The document modification socket request
   */
  static #adjustActorDeltaRequest(documentClass, request) {
    const operation = request.operation;
    const parent = operation.parent;

    // Translate updates to a token actor to the token's ActorDelta instead.
    if ( foundry.utils.isSubclass(documentClass, Actor$1) && (parent instanceof TokenDocument) ) {
      request.type = "ActorDelta";
      if ( "updates" in operation ) operation.updates[0]._id = parent.delta.id;
      operation.syntheticActorUpdate = true;
    }

    // Translate operations on a token actor's embedded children to the token's ActorDelta instead.
    const token = ClientDatabaseBackend.#getTokenAncestor(parent);
    if ( token && !(parent instanceof TokenDocument) ) {
      const {embedded} = foundry.utils.parseUuid(parent?.uuid) ?? {};
      if ( !embedded ) throw new Error(`Failed to parse uuid "${parent?.uuid}"`);
      operation.parentUuid = [token.delta.uuid, embedded?.slice(4).join(".")].filterJoin(".");
    }
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a Document's Token ancestor, if it exists.
   * @param {ClientDocument|null} parent        The parent Document
   * @returns {TokenDocument|null}              The Token ancestor, or null
   */
  static #getTokenAncestor(parent) {
    if ( !parent ) return null;
    if ( parent instanceof TokenDocument ) return parent;
    return ClientDatabaseBackend.#getTokenAncestor(parent.parent);
  }

  /* -------------------------------------------- */

  /**
   * Build a CRUD response.
   * @param {ActorDelta[]} documents   An array of ActorDelta documents modified by a database workflow
   * @returns {ClientDocument[]}       The modified ActorDelta documents mapped to their synthetic Actor
   */
  static #adjustActorDeltaResponse(documents) {
    return documents.map(delta => delta.syntheticActor);
  }
}

/**
 * @import {CalendarConfig, CalendarData, TimeComponents, TimeFormatter} from "./_types.mjs";
 */

/**
 * Game Time Calendar configuration data model.
 * @extends {foundry.abstract.DataModel}
 * @mixes CalendarConfig
 * @template {TimeComponents} Components
 */
class CalendarData extends DataModel {

  /** @inheritDoc */
  static defineSchema() {
    const fields = foundry.data.fields;
    return {
      name: new fields.StringField({required: true, blank: false}),
      description: new fields.StringField(),
      years: new fields.SchemaField({
        yearZero: new fields.NumberField({required: true, nullable: false, integer: true, initial: 0}),
        firstWeekday: new fields.NumberField({required: true, nullable: false, min: 0, integer: true}),
        leapYear: new fields.SchemaField({
          leapStart: new fields.NumberField({required: true, nullable: false, integer: true}),
          leapInterval: new fields.NumberField({required: true, nullable: false, min: 2, integer: true})
        }, {required: true, nullable: true, initial: null})
      }),
      months: new fields.SchemaField({
        values: new fields.ArrayField(new fields.SchemaField({
          name: new fields.StringField({required: true, blank: false}),
          abbreviation: new fields.StringField(),
          ordinal: new fields.NumberField({required: true, nullable: false, min: 1, integer: true}),
          days: new fields.NumberField({required: true, nullable: false}),
          leapDays: new fields.NumberField({required: false, nullable: true})
        }))
      }, {required: true, nullable: true, initial: null}),
      days: new fields.SchemaField({
        values: new fields.ArrayField(new fields.SchemaField({
          name: new fields.StringField({required: true, blank: false}),
          abbreviation: new fields.StringField(),
          ordinal: new fields.NumberField({required: true, nullable: false, min: 1, integer: true})
        }), {required: true, nullable: false}),
        daysPerYear: new fields.NumberField({required: true, nullable: false, positive: true}),
        hoursPerDay: new fields.NumberField({required: true, nullable: false, positive: true}),
        minutesPerHour: new fields.NumberField({required: true, nullable: false, positive: true}),
        secondsPerMinute: new fields.NumberField({required: true, nullable: false, positive: true})
      }),
      seasons: new fields.SchemaField({
        values: new fields.ArrayField(new fields.SchemaField({
          name: new fields.StringField({required: true, blank: false}),
          abbreviation: new fields.StringField(),
          monthStart: new fields.NumberField({required: true, nullable: true, min: 0, integer: true}),
          monthEnd: new fields.NumberField({required: true, nullable: true, min: 0, integer: true}),
          dayStart: new fields.NumberField({required: true, nullable: true, min: 0, integer: true}),
          dayEnd: new fields.NumberField({required: true, nullable: true, min: 0, integer: true})
        }))
      }, {required: true, nullable: true, initial: null})
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initialize(options = {}) {
    super._initialize(options);

    // Prepare some derived data used in later calculations
    this.days.daysPerLeapYear = this.months.values.length ? this.months.values.reduce((sum, month) => {
      return sum + (month.days ?? 0);
    }, 0) : this.days.daysPerYear;
  }

  /* -------------------------------------------- */
  /*  Calendar Helper Methods                     */
  /* -------------------------------------------- */

  /**
   * Expand a world time integer into an object containing the relevant time components.
   * @param {Partial<Components>} components        An amount of time expressed as components
   * @returns {number}                              The cumulative time in seconds
   */
  componentsToTime(components) {
    const {secondsPerMinute, minutesPerHour, hoursPerDay, daysPerYear, daysPerLeapYear} = this.days;
    const year = components.year ?? 0;

    // Compute the total number of days before the current year
    let totalDays = 0;
    if ( !this.years.leapYear ) totalDays = year * daysPerYear; // No leap years
    else {
      const nLeapYears = this.countLeapYears(year);
      const nStandardYears = year - nLeapYears;
      totalDays = (nLeapYears * daysPerLeapYear) + (nStandardYears * daysPerYear);
    }
    totalDays += (components.day ?? 0);

    // Accumulate time from days, hours, minutes, and seconds
    let time = (totalDays * secondsPerMinute * minutesPerHour * hoursPerDay);
    time += ((components.hour ?? 0) * secondsPerMinute * minutesPerHour);
    time += ((components.minute ?? 0) * secondsPerMinute);
    time += (components.second ?? 0);
    return time;
  }

  /* -------------------------------------------- */

  /**
   * Modify some start time by adding a number of seconds or components to it. The delta components may be negative.
   * @param {number|Components} startTime           The initial time
   * @param {number|Components} deltaTime           Differential components to add
   * @returns {Components}                          The resulting time
   */
  add(startTime, deltaTime) {
    if ( typeof startTime === "object" ) startTime = this.componentsToTime(startTime);
    if ( typeof deltaTime === "object" ) deltaTime = this.componentsToTime(deltaTime);
    const endTime = startTime + deltaTime;
    return this.timeToComponents(endTime);
  }

  /* -------------------------------------------- */

  /**
   * Compute the difference between some new time and some other time.
   * @param {number|Components} endTime             A time to difference relative to the start time.
   * @param {number|Components} [startTime]         The starting time. If not provided the current world time is used.
   * @returns {Components}                          The time difference expressed as components
   */
  difference(endTime, startTime) {
    startTime ??= this.worldTime;
    if ( typeof startTime === "object" ) startTime = this.componentsToTime(startTime);
    if ( typeof endTime === "object" ) endTime = this.componentsToTime(endTime);
    const delta = endTime - startTime;
    return this.timeToComponents(delta);
  }

  /* -------------------------------------------- */

  /**
   * Format a time using one of several supported display formats.
   * @param {number|Components} [time]            The time components to format, by default the current world time.
   * @param {string|TimeFormatter} [formatter]    The formatter function applied to the time.
   *                                              If a string is provided, it must be a function configured in
   *                                              CONFIG.time.formatters.
   * @param {object} options                      Options passed to the formatter function
   * @returns {string}                            The formatted date and time string
   */
  format(time, formatter="timestamp", options={}) {
    const components = typeof time === "number" ? this.timeToComponents(time) : time;
    if ( typeof formatter !== "function" ) {
      const formatterName = formatter;
      formatter = CONFIG.time.formatters[formatterName] ?? this.constructor[formatterName];
      if ( !(formatter instanceof Function) ) {
        throw new Error(`The requested formatter "${formatterName}" did not resolve as a configured formatter
          function in CONFIG.time.formatters or as a named static function in the CalendarData class.`);
      }
    }
    return formatter(this, components, options);
  }

  /* -------------------------------------------- */

  /**
   * Test whether a year is a leap year.
   * @param {number} year                           The year to test
   * @returns {boolean}                             Is it a leap year?
   */
  isLeapYear(year) {
    if ( !this.years.leapYear ) return false;
    const {leapStart, leapInterval} = this.years.leapYear;
    if ( year < leapStart ) return false;
    return ((year - leapStart) % leapInterval) === 0;
  }

  /* -------------------------------------------- */

  /**
   * Count the number of leap years which have completed prior to some current year.
   * @param {number} year                           The current year
   * @returns {number}                              The number of leap years which have occurred prior to this year
   */
  countLeapYears(year) {
    if ( !this.years.leapYear?.leapInterval ) return 0;
    const {leapStart, leapInterval} = this.years.leapYear;
    if ( year < leapStart ) return 0;
    return Math.floor((year - leapStart) / leapInterval) + 1; // leapStart is a leap year
  }

  /* -------------------------------------------- */

  /**
   * Expand a world time integer into an object containing the relevant time components.
   * @param {number} time                     A time in seconds
   * @returns {Components}                    The time expressed as components
   */
  timeToComponents(time=0) {
    const {secondsPerMinute, minutesPerHour, hoursPerDay, daysPerYear} = this.days;
    const secondsPerDay = secondsPerMinute * minutesPerHour * hoursPerDay;

    // Compute year
    let {year, second, leapYear} = this._decomposeTimeYears(time);

    // Day of Year
    const day = Math.floor(second / secondsPerDay);
    second -= (day * secondsPerDay);

    // Month and Day Of Month
    let dayOfMonth = day;
    let month;
    for ( month=0; month<this.months.values.length; month++ ) {
      const m = this.months.values[month];
      const md = leapYear ? (m.leapDays ?? m.days) : m.days;
      if ( dayOfMonth < md ) break;
      dayOfMonth -= md;
    }

    // Day of Week
    const totalWeekdays = Math.floor(time / secondsPerDay) + this.years.firstWeekday;
    const dayOfWeek = totalWeekdays % this.days.values.length;

    // Hours
    const hourSeconds = secondsPerMinute * minutesPerHour;
    const hour = Math.floor(second / hourSeconds);
    second -= (hour * hourSeconds);

    // Minute
    const minute = Math.floor(second / secondsPerMinute);
    second -= (minute * secondsPerMinute);

    // Season
    let season;
    for ( season=0; season<this.seasons.values.length; season++ ) {
      const s = this.seasons.values[season];
      let {dayStart, dayEnd, monthStart, monthEnd} = s;
      const od = day + 1;
      const om = this.months.values[month].ordinal;

      // Match on days
      if ( (typeof dayStart === "number") && (typeof dayEnd === "number") ) {
        if ( dayEnd < dayStart ) {
          if ( od <= dayEnd ) dayStart -= daysPerYear;
          else if ( od >= dayStart ) dayEnd += daysPerYear;
        }
        if ( od.between(dayStart, dayEnd) ) break;
      }

      // Match on months
      else if ( (typeof monthStart === "number") && (typeof monthEnd === "number") ) {
        if ( monthEnd < monthStart ) {
          if ( om <= monthEnd ) monthStart -= this.months.values.length;
          else if ( om >= monthStart ) monthEnd += this.months.values.length;
        }
        if ( om.between(monthStart, monthEnd) ) break;
      }

      // No match
      else {
        season = undefined;
        break;
      }
    }
    return {day, dayOfMonth, dayOfWeek, hour, leapYear, minute, month, season, second, year};
  }

  /* -------------------------------------------- */

  /**
   * Decompose a timestamp in seconds to identify the number of completed years and remaining seconds.
   * Also returns whether the remaining seconds fall within a leap year.
   * This method is factored out so calendars which require advanced leap year handling can override this logic.
   * @param {number} time
   * @returns {year: number, seconds: number, leapYear: boolean}
   * @protected
   */
  _decomposeTimeYears(time) {
    const {secondsPerMinute, minutesPerHour, hoursPerDay, daysPerYear, daysPerLeapYear} = this.days;
    const secondsPerDay = secondsPerMinute * minutesPerHour * hoursPerDay;
    const secondsPerStandardYear = daysPerYear * secondsPerDay;
    const secondsPerLeapYear = daysPerLeapYear * secondsPerDay;

    // Initialize data
    let year = 0;
    let leapYear = false;
    let second = time;

    // Basic case - no leap years
    if ( !this.years.leapYear ) {
      year = Math.floor(second / secondsPerStandardYear);
      second -= (year * secondsPerStandardYear);
      return {year, second, leapYear};
    }

    // Advanced case - leap years
    const {leapStart, leapInterval} = this.years.leapYear;
    const firstLeapSeconds = Math.max(leapStart -1, 0) * secondsPerStandardYear;

    // Special case - time before leapStart
    if ( second < firstLeapSeconds ) {
      year = Math.floor(second / secondsPerStandardYear);
      second -= (year * secondsPerStandardYear);
      return {year, second, leapYear};
    }

    // Start by deducting standard years before leapStart
    const preLeapStartYears = Math.floor(Math.min(second, firstLeapSeconds) / secondsPerStandardYear);
    year += preLeapStartYears;
    second -= (preLeapStartYears * secondsPerStandardYear);

    // Next deduct complete leap intervals (1 leap year + leapInterval-1 standard years)
    const leapIntervalSeconds = (daysPerLeapYear + ((leapInterval - 1) * daysPerYear)) * secondsPerDay;
    const leapIntervals = Math.floor(second / leapIntervalSeconds);
    year += (leapIntervals * leapInterval);
    second -= (leapIntervals * secondsPerLeapYear);
    second -= (leapIntervals * (leapInterval - 1) * secondsPerStandardYear);

    // The current leap interval begins with a leap year
    if ( second >= secondsPerLeapYear ) {
      year++;
      second -= secondsPerLeapYear;
    }
    else leapYear = true; // It is currently a leap year

    // Remaining years within the leap interval are standard years
    const remainderYears = Math.floor(second / secondsPerStandardYear);
    year += remainderYears;
    second -= (remainderYears * secondsPerStandardYear);
    return {year, second, leapYear};
  }

  /* -------------------------------------------- */
  /*  Formatter Functions                         */
  /* -------------------------------------------- */

  /**
   * Format time components as a YYYY-MM-DD HH:MM:SS timestamp.
   * @type {TimeFormatter}
   */
  static formatTimestamp(calendar, components, _options) {
    const yyyy = components.year.paddedString(4);
    const month = calendar.months.values[components.month];
    const mm = month.ordinal.paddedString(2);
    const dd = (components.dayOfMonth + 1).paddedString(2);
    const h = components.hour.paddedString(2);
    const m = components.minute.paddedString(2);
    const s = components.second.paddedString(2);
    return `${yyyy}-${mm}-${dd} ${h}:${m}:${s}`;
  }

  /* -------------------------------------------- */

  /**
   * Format time components as "{years}, {days}, {hours}, {minutes}, {seconds} ago".
   * @type {TimeFormatter}
   */
  static formatAgo(_calendar, components, {short=false, maxTerms}={}) {
    const terms = {
      year: "TIME.Year",
      day: "TIME.Day",
      hour: "TIME.Hour",
      minute: "TIME.Minute",
      second: "TIME.Second"
    };
    const plurals = new Intl.PluralRules(game.i18n.lang);
    let hasNegative = false; // Was a negative-value component found?
    let parts = Object.entries(terms).reduce((arr, [k, t]) => {
      const v = Math.round(components[k]);
      if ( v < 0 ) hasNegative = true;
      if ( hasNegative || (v < 1) ) return arr;
      if ( short ) arr.push(`${v}${game.i18n.localize(t + ".abbr")}`);
      else arr.push(`${v} ${game.i18n.localize(`${t}.${plurals.select(v)}`).toLowerCase()}`);
      return arr;
    }, []);
    if ( !parts.length ) return game.i18n.localize("TIME.Now");
    if ( maxTerms ) parts = parts.slice(0, maxTerms);
    const since = short ? parts.join(" ") : game.i18n.getListFormatter().format(parts);
    return game.i18n.format("TIME.Since", {since});
  }
}

/* -------------------------------------------- */
/*  Baseline Calendar Configuration             */
/* -------------------------------------------- */

/**
 * @type {CalendarConfig}
 */
const SIMPLIFIED_GREGORIAN_CALENDAR_CONFIG = {
  name: "Simplified Gregorian",
  description: "The Gregorian calendar with some simplifications regarding leap years or seasonal timing.",
  years: {
    yearZero: 0,
    firstWeekday: 0,
    leapYear: {
      leapStart: 8,
      leapInterval: 4
    }
  },
  months: {
    values: [
      {name: "CALENDAR.GREGORIAN.January", abbreviation: "CALENDAR.GREGORIAN.JanuaryAbbr", ordinal: 1, days: 31},
      {name: "CALENDAR.GREGORIAN.February", abbreviation: "CALENDAR.GREGORIAN.FebruaryAbbr", ordinal: 2, days: 28, leapDays: 29},
      {name: "CALENDAR.GREGORIAN.March", abbreviation: "CALENDAR.GREGORIAN.MarchAbbr", ordinal: 3, days: 31},
      {name: "CALENDAR.GREGORIAN.April", abbreviation: "CALENDAR.GREGORIAN.AprilAbbr", ordinal: 4, days: 30},
      {name: "CALENDAR.GREGORIAN.May", abbreviation: "CALENDAR.GREGORIAN.MayAbbr", ordinal: 5, days: 31},
      {name: "CALENDAR.GREGORIAN.June", abbreviation: "CALENDAR.GREGORIAN.JuneAbbr", ordinal: 6, days: 30},
      {name: "CALENDAR.GREGORIAN.July", abbreviation: "CALENDAR.GREGORIAN.JulyAbbr", ordinal: 7, days: 31},
      {name: "CALENDAR.GREGORIAN.August", abbreviation: "CALENDAR.GREGORIAN.AugustAbbr", ordinal: 8, days: 31},
      {name: "CALENDAR.GREGORIAN.September", abbreviation: "CALENDAR.GREGORIAN.SeptemberAbbr", ordinal: 9, days: 30},
      {name: "CALENDAR.GREGORIAN.October", abbreviation: "CALENDAR.GREGORIAN.OctoberAbbr", ordinal: 10, days: 31},
      {name: "CALENDAR.GREGORIAN.November", abbreviation: "CALENDAR.GREGORIAN.NovemberAbbr", ordinal: 11, days: 30},
      {name: "CALENDAR.GREGORIAN.December", abbreviation: "CALENDAR.GREGORIAN.DecemberAbbr", ordinal: 12, days: 31}
    ]
  },
  days: {
    values: [
      {name: "CALENDAR.GREGORIAN.Monday", abbreviation: "CALENDAR.GREGORIAN.MondayAbbr", ordinal: 1},
      {name: "CALENDAR.GREGORIAN.Tuesday", abbreviation: "CALENDAR.GREGORIAN.TuesdayAbbr", ordinal: 2},
      {name: "CALENDAR.GREGORIAN.Wednesday", abbreviation: "CALENDAR.GREGORIAN.WednesdayAbbr", ordinal: 3},
      {name: "CALENDAR.GREGORIAN.Thursday", abbreviation: "CALENDAR.GREGORIAN.ThursdayAbbr", ordinal: 4},
      {name: "CALENDAR.GREGORIAN.Friday", abbreviation: "CALENDAR.GREGORIAN.FridayAbbr", ordinal: 5},
      {name: "CALENDAR.GREGORIAN.Saturday", abbreviation: "CALENDAR.GREGORIAN.SaturdayAbbr", ordinal: 6, isRestDay: true},
      {name: "CALENDAR.GREGORIAN.Sunday", abbreviation: "CALENDAR.GREGORIAN.SundayAbbr", ordinal: 7, isRestDay: true}
    ],
    daysPerYear: 365,
    hoursPerDay: 24,
    minutesPerHour: 60,
    secondsPerMinute: 60
  },
  seasons: {
    values: [
      {name: "CALENDAR.GREGORIAN.Spring", monthStart: 3, monthEnd: 5},
      {name: "CALENDAR.GREGORIAN.Summer", monthStart: 6, monthEnd: 8},
      {name: "CALENDAR.GREGORIAN.Fall", monthStart: 9, monthEnd: 11},
      {name: "CALENDAR.GREGORIAN.Winter", monthStart: 12, monthEnd: 2}
    ]
  }
};

/** @module data */

var _module$U = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActorDeltaField: ActorDeltaField,
  BaseShapeData: BaseShapeData,
  BaseTerrainData: BaseTerrainData,
  CalendarData: CalendarData,
  CircleShapeData: CircleShapeData,
  ClientDatabaseBackend: ClientDatabaseBackend,
  CombatConfiguration: CombatConfiguration,
  EllipseShapeData: EllipseShapeData,
  LightData: LightData,
  PolygonShapeData: PolygonShapeData,
  PrototypeToken: PrototypeToken,
  PrototypeTokenOverrides: PrototypeTokenOverrides,
  RectangleShapeData: RectangleShapeData,
  SIMPLIFIED_GREGORIAN_CALENDAR_CONFIG: SIMPLIFIED_GREGORIAN_CALENDAR_CONFIG,
  ShapeData: ShapeData,
  TerrainData: TerrainData,
  TextureData: TextureData,
  TombstoneData: TombstoneData,
  fields: fields,
  regionBehaviors: _module$V,
  regionShapes: _module$X,
  types: _types$h,
  validation: validationFailure,
  validators: validators
});

/**
 * @import {PackageCompendiumData, PackageCompatibilityData, PackageManifestData,
 *   RelatedPackageData, PackageRelationshipsData} from "./_types.mjs";
 * @import Collection from "../utils/collection.mjs";
 * @import {Module, System} from "../../client/packages/_module.mjs";
 */

/**
 * A custom SchemaField for defining package compatibility versions.
 * @mixes PackageCompatibilityData
 */
class PackageCompatibility extends SchemaField {
  constructor(options) {
    super({
      minimum: new StringField({required: false, blank: false, initial: undefined,
        validate: BasePackage.validateVersion}),
      verified: new StringField({required: false, blank: false, initial: undefined,
        validate: BasePackage.validateVersion}),
      maximum: new StringField({required: false, blank: false, initial: undefined,
        validate: BasePackage.validateVersion})
    }, options);
  }
}

/* -------------------------------------------- */

/**
 * A custom SchemaField for defining package relationships.
 * @mixes PackageRelationshipsData
 */
class PackageRelationships extends SchemaField {
  /** @inheritdoc */
  constructor(options) {
    super({
      systems: new PackageRelationshipField(new RelatedPackage({packageType: "system"})),
      requires: new PackageRelationshipField(new RelatedPackage()),
      recommends: new PackageRelationshipField(new RelatedPackage()),
      conflicts: new PackageRelationshipField(new RelatedPackage()),
      flags: new ObjectField()
    }, options);
  }
}

/* -------------------------------------------- */

/**
 * A SetField with custom casting behavior.
 */
class PackageRelationshipField extends SetField {
  /** @override */
  _cast(value) {
    return value instanceof Array ? value : [value];
  }
}

/* -------------------------------------------- */

/**
 * A custom SchemaField for defining a related Package.
 * It may be required to be a specific type of package, by passing the packageType option to the constructor.
 * @mixes RelatedPackageData
 */
class RelatedPackage extends SchemaField {
  constructor({packageType, ...options}={}) {
    let typeOptions = {choices: PACKAGE_TYPES$1, initial: "module"};
    if ( packageType ) typeOptions = {choices: [packageType], initial: packageType};
    super({
      id: new StringField({required: true, blank: false, validate: BasePackage.validateId}),
      type: new StringField(typeOptions),
      manifest: new StringField({required: false, blank: false, initial: undefined}),
      compatibility: new PackageCompatibility(),
      reason: new StringField({required: false, blank: false, initial: undefined})
    }, options);
  }
}

/* -------------------------------------------- */

/**
 * A custom SchemaField for defining the folder structure of the included compendium packs.
 */
class PackageCompendiumFolder extends SchemaField {
  constructor({depth=1, ...options}={}) {
    const schema = {
      name: new StringField({required: true, blank: false}),
      sorting: new StringField({required: false, blank: false, initial: undefined,
        choices: BaseFolder.SORTING_MODES}),
      color: new ColorField(),
      packs: new SetField(new StringField({required: true, blank: false}))
    };
    if ( depth < 4 ) schema.folders = new SetField(new PackageCompendiumFolder(
      {depth: depth+1, options}));
    super(schema, options);
  }
}

/* -------------------------------------------- */

/**
 * A special ObjectField which captures a mapping of USER_ROLES to DOCUMENT_OWNERSHIP_LEVELS.
 */
class CompendiumOwnershipField extends ObjectField {

  /** @inheritdoc */
  static get _defaults() {
    return mergeObject(super._defaults, {
      initial: {PLAYER: "OBSERVER", ASSISTANT: "OWNER"},
      validationError: "is not a mapping of USER_ROLES to DOCUMENT_OWNERSHIP_LEVELS"
    });
  }

  /** @override */
  _validateType(value, options) {
    for ( const [k, v] of Object.entries(value) ) {
      if ( !(k in USER_ROLES) ) throw new Error(`Compendium ownership key "${k}" is not a valid choice in USER_ROLES`);
      if ( !(v in DOCUMENT_OWNERSHIP_LEVELS) ) throw new Error(`Compendium ownership value "${v}" is not a valid
      choice in DOCUMENT_OWNERSHIP_LEVELS`);
    }
  }
}

/* -------------------------------------------- */

/**
 * A special SetField which provides additional validation and initialization behavior specific to compendium packs.
 */
class PackageCompendiumPacks extends SetField {

  /** @override */
  _cleanType(value, options) {
    return value.map(v => {
      v = this.element.clean(v, options);
      if ( v.path ) v.path = v.path.replace(/\.db$/, ""); // Strip old NEDB extensions
      else v.path = `packs/${v.name}`; // Auto-populate a default pack path
      return v;
    });
  }

  /* ---------------------------------------- */

  /** @override */
  initialize(value, model, options={}) {
    const packs = new Set();
    const packageName = model._source.id;
    for ( const v of value ) {
      try {
        const pack = this.element.initialize(v, model, options);
        pack.packageType = model.constructor.type;
        pack.packageName = packageName;
        pack.id = `${model.constructor.type === "world" ? "world" : packageName}.${pack.name}`;
        packs.add(pack);
      } catch(err) {
        logger.warn(err.message);
      }
    }
    return packs;
  }

  /* ---------------------------------------- */

  /** @inheritDoc */
  _validateElements(value, options) {
    // Extend the logic for validating the complete set of packs to ensure uniqueness.
    const packNames = new Set();
    const duplicateNames = new Set();
    const packPaths = new Set();
    const duplicatePaths = new Set();
    for ( const pack of value ) {
      if ( packNames.has(pack.name) ) duplicateNames.add(pack.name);
      packNames.add(pack.name);
      if ( pack.path ) {
        if ( packPaths.has(pack.path) ) duplicatePaths.add(pack.path);
        packPaths.add(pack.path);
      }
    }
    return super._validateElements(value, {...options, duplicateNames, duplicatePaths});
  }

  /* ---------------------------------------- */

  /** @inheritDoc */
  _validateElement(value, {duplicateNames, duplicatePaths, ...options}={}) {
    // Validate each individual compendium pack, ensuring its name and path are unique.
    if ( duplicateNames.has(value.name) ) {
      return new DataModelValidationFailure({
        invalidValue: value.name,
        message: `Duplicate Compendium name "${value.name}" already declared by some other pack`,
        unresolved: true
      });
    }
    if ( duplicatePaths.has(value.path) ) {
      return new DataModelValidationFailure({
        invalidValue: value.path,
        message: `Duplicate Compendium path "${value.path}" already declared by some other pack`,
        unresolved: true
      });
    }
    return this.element.validate(value, options);
  }
}

/* -------------------------------------------- */

/**
 * The data schema used to define a Package manifest.
 * Specific types of packages extend this schema with additional fields.
 * @template {PackageManifestData} PackageSchema The source data from the package manifest
 * @extends DataModel<PackageSchema>
 */
class BasePackage extends DataModel {
  /**
   * @param {PackageManifestData} data  Source data for the package
   * @param {object} [options={}]       Options which affect DataModel construction
   */
  constructor(data, options={}) {
    const {availability, locked, exclusive, owned, tags, hasStorage} = data;
    super(data, options);

    /**
     * An availability code in PACKAGE_AVAILABILITY_CODES which defines whether this package can be used.
     * @type {number}
     */
    this.availability = availability ?? this.constructor.testAvailability(this);

    /**
     * A flag which tracks whether this package is currently locked.
     * @type {boolean}
     */
    this.locked = locked ?? false;

    /**
     * A flag which tracks whether this package is a free Exclusive pack
     * @type {boolean}
     */
    this.exclusive = exclusive ?? false;

    /**
     * A flag which tracks whether this package is owned, if it is protected.
     * @type {boolean|null}
     */
    this.owned = owned ?? false;

    /**
     * A set of Tags that indicate what kind of Package this is, provided by the Website
     * @type {string[]}
     */
    this.tags = tags ?? [];

    /**
     * A flag which tracks if this package has files stored in the persistent storage folder
     * @type {boolean}
     */
    this.hasStorage = hasStorage ?? false;
  }

  /**
   * Define the package type in CONST.PACKAGE_TYPES that this class represents.
   * Each BasePackage subclass must define this attribute.
   * @abstract
   * @type {string}
   */
  static type = "package";

  /**
   * The type of this package instance. A value in CONST.PACKAGE_TYPES.
   * @type {string}
   */
  get type() {
    return this.constructor.type;
  }

  /**
   * A flag which defines whether this package is unavailable to be used.
   * @type {boolean}
   */
  get unavailable() {
    return this.availability > PACKAGE_AVAILABILITY_CODES.UNVERIFIED_GENERATION;
  }

  /**
   * Is this Package incompatible with the currently installed core Foundry VTT software version?
   * @type {boolean}
   */
  get incompatibleWithCoreVersion() {
    return this.constructor.isIncompatibleWithCoreVersion(this.availability);
  }

  /**
   * Test if a given availability is incompatible with the core version.
   * @param {number} availability  The availability value to test.
   * @returns {boolean}
   */
  static isIncompatibleWithCoreVersion(availability) {
    const codes = CONST.PACKAGE_AVAILABILITY_CODES;
    return (availability >= codes.REQUIRES_CORE_DOWNGRADE) && (availability <= codes.REQUIRES_CORE_UPGRADE_UNSTABLE);
  }

  /**
   * The named collection to which this package type belongs
   * @type {string}
   */
  static get collection() {
    return `${this.type}s`;
  }

  /** @inheritDoc */
  static defineSchema() {
    const optionalString = {required: false, blank: false, initial: undefined};
    return {

      // Package metadata
      id: new StringField({required: true, blank: false, validate: this.validateId}),
      title: new StringField({required: true, blank: false}),
      description: new HTMLField({required: true}),
      authors: new SetField(new SchemaField({
        name: new StringField({required: true, blank: false}),
        email: new StringField(optionalString),
        url: new StringField(optionalString),
        discord: new StringField(optionalString),
        flags: new ObjectField()
      })),
      url: new StringField(optionalString),
      license: new StringField(optionalString),
      readme: new StringField(optionalString),
      bugs: new StringField(optionalString),
      changelog: new StringField(optionalString),
      flags: new ObjectField(),
      media: new SetField(new SchemaField({
        type: new StringField(optionalString),
        url: new StringField(optionalString),
        caption: new StringField(optionalString),
        loop: new BooleanField({required: false, blank: false, initial: false}),
        thumbnail: new StringField(optionalString),
        flags: new ObjectField()
      })),

      // Package versioning
      version: new StringField({required: true, blank: false, initial: "0", validate: BasePackage.validateVersion}),
      compatibility: new PackageCompatibility(),

      // Included content
      scripts: new SetField(new StringField({required: true, blank: false})),
      esmodules: new SetField(new StringField({required: true, blank: false})),
      styles: new ArrayField(new SchemaField({
        layer: new StringField({required: false, nullable: true, blank: false, initial: undefined}),
        src: new StringField({required: true, blank: false})
      })),
      languages: new SetField(new SchemaField({
        lang: new StringField({required: true, blank: false, validate: Intl.getCanonicalLocales,
          validationError: "must be supported by the Intl.getCanonicalLocales function"
        }),
        name: new StringField({required: false}),
        path: new StringField({required: true, blank: false}),
        system: new StringField(optionalString),
        module: new StringField(optionalString),
        flags: new ObjectField()
      })),
      packs: new PackageCompendiumPacks(new SchemaField({
        name: new StringField({required: true, blank: false, validate: this.validateId}),
        label: new StringField({required: true, blank: false}),
        banner: new StringField({...optionalString, nullable: true}),
        path: new StringField({required: false}),
        type: new StringField({required: true, blank: false, choices: COMPENDIUM_DOCUMENT_TYPES,
          validationError: "must be a value in CONST.COMPENDIUM_DOCUMENT_TYPES"}),
        system: new StringField(optionalString),
        ownership: new CompendiumOwnershipField(),
        flags: new ObjectField()
      }, {validate: BasePackage.#validatePack})),
      packFolders: new SetField(new PackageCompendiumFolder()),

      // Package relationships
      relationships: new PackageRelationships(),
      socket: new BooleanField(),

      // Package downloading
      manifest: new StringField(),
      download: new StringField({required: false, blank: false, initial: undefined}),
      protected: new BooleanField(),
      exclusive: new BooleanField(),
      persistentStorage: new BooleanField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["PACKAGE"];

  /* -------------------------------------------- */

  /**
   * Check the given compatibility data against the current installation state and determine its availability.
   * @param {Partial<PackageManifestData>} data  The compatibility data to test.
   * @param {object} [options]
   * @param {ReleaseData} [options.release]      A specific software release for which to test availability.
   *                                             Tests against the current release by default.
   * @returns {number}
   */
  static testAvailability({ compatibility }, { release }={}) {
    release ??= globalThis.release ?? game.release;
    const codes = CONST.PACKAGE_AVAILABILITY_CODES;
    const {minimum, maximum, verified} = compatibility;
    const isGeneration = version => Number.isInteger(Number(version));

    // Require a certain minimum core version.
    if ( minimum && isNewerVersion(minimum, release.version) ) {
      const generation = Number(minimum.split(".").shift());
      const isStable = generation <= release.maxStableGeneration;
      return isStable ? codes.REQUIRES_CORE_UPGRADE_STABLE : codes.REQUIRES_CORE_UPGRADE_UNSTABLE;
    }

    // Require a certain maximum core version.
    if ( maximum ) {
      const compatible = isGeneration(maximum)
        ? release.generation <= Number(maximum)
        : !isNewerVersion(release.version, maximum);
      if ( !compatible ) return codes.REQUIRES_CORE_DOWNGRADE;
    }

    // Require a certain compatible core version.
    if ( verified ) {
      const compatible = isGeneration(verified)
        ? Number(verified) >= release.generation
        : !isNewerVersion(release.version, verified);
      const sameGeneration = release.generation === Number(verified.split(".").shift());
      if ( compatible ) return codes.VERIFIED;
      return sameGeneration ? codes.UNVERIFIED_BUILD : codes.UNVERIFIED_GENERATION;
    }

    // FIXME: Why do we not check if all of this package's dependencies are satisfied?
    // Proposal: Check all relationships.requires and set MISSING_DEPENDENCY if any dependencies are not VERIFIED,
    // UNVERIFIED_BUILD, or UNVERIFIED_GENERATION, or if they do not satisfy the given compatibility range for the
    // relationship.

    // No compatible version is specified.
    return codes.UNKNOWN;
  }

  /* -------------------------------------------- */

  /**
   * Test that the dependencies of a package are satisfied as compatible.
   * This method assumes that all packages in modulesCollection have already had their own availability tested.
   * @param {Collection<string,Module>} modulesCollection   A collection which defines the set of available modules
   * @returns {Promise<boolean>}                            Are all required dependencies satisfied?
   * @internal
   */
  async _testRequiredDependencies(modulesCollection) {
    const requirements = this.relationships.requires;
    for ( const {id, type, manifest, compatibility} of requirements ) {
      if ( type !== "module" ) continue; // Only test modules
      let pkg;

      // If the requirement specifies an explicit remote manifest URL, we need to load it
      if ( manifest ) {
        try {
          pkg = await this.constructor.fromRemoteManifest(manifest, {strict: true});
        } catch(err) {
          return false;
        }
      }

      // Otherwise the dependency must belong to the known modulesCollection
      else pkg = modulesCollection.get(id);
      if ( !pkg ) return false;

      // Ensure that the package matches the required compatibility range
      if ( !this.constructor.testDependencyCompatibility(compatibility, pkg) ) return false;

      // Test compatibility of the dependency
      if ( pkg.unavailable ) return false;
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Test compatibility of a package's supported systems.
   * @param {Collection<string, System>} systemCollection  A collection which defines the set of available systems.
   * @returns {Promise<boolean>}                           True if all supported systems which are currently installed
   *                                                       are compatible or if the package has no supported systems.
   *                                                       Returns false otherwise, or if no supported systems are
   *                                                       installed.
   * @internal
   */
  async _testSupportedSystems(systemCollection) {
    const systems = this.relationships.systems;
    if ( !systems?.size ) return true;
    let supportedSystem = false;
    for ( const { id, compatibility } of systems ) {
      const pkg = systemCollection.get(id);
      if ( !pkg ) continue;
      if ( !this.constructor.testDependencyCompatibility(compatibility, pkg) || pkg.unavailable ) return false;
      supportedSystem = true;
    }
    return supportedSystem;
  }

  /* -------------------------------------------- */

  /**
   * Determine if a dependency is within the given compatibility range.
   * @param {PackageCompatibility} compatibility      The compatibility range declared for the dependency, if any
   * @param {BasePackage} dependency                  The known dependency package
   * @returns {boolean}                               Is the dependency compatible with the required range?
   */
  static testDependencyCompatibility(compatibility, dependency) {
    if ( !compatibility ) return true;
    const {minimum, maximum} = compatibility;
    if ( minimum && isNewerVersion(minimum, dependency.version) ) return false;
    if ( maximum && isNewerVersion(dependency.version, maximum) ) return false;
    return true;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static cleanData(source={}, { installed, ...options }={}) {

    // Auto-assign language name
    for ( const l of source.languages || [] ) l.name = l.name ?? l.lang;

    // Identify whether this package depends on a single game system
    let systemId = undefined;
    if ( this.type === "system" ) systemId = source.id;
    else if ( this.type === "world" ) systemId = source.system;
    else if ( source.relationships?.systems?.length === 1 ) systemId = source.relationships.systems[0].id;

    // Auto-configure some package data
    for ( const pack of source.packs || [] ) {
      if ( !pack.system && systemId ) pack.system = systemId; // System dependency
      if ( typeof pack.ownership === "string" ) pack.ownership = {PLAYER: pack.ownership};
    }
    return super.cleanData(source, options);
  }

  /* -------------------------------------------- */

  /**
   * Validate that a Package ID is allowed.
   * @param {string} id     The candidate ID
   * @throws                An error if the candidate ID is invalid
   */
  static validateId(id) {
    const allowed = /^[A-Za-z0-9-_]+$/;
    if ( !allowed.test(id) ) {
      throw new Error("Package and compendium pack IDs may only be alphanumeric with hyphens or underscores.");
    }
    const prohibited = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
    if ( prohibited.test(id) ) throw new Error(`The ID "${id}" uses an operating system prohibited value.`);
  }

  /* -------------------------------------------- */

  /**
   * Validate that a version is allowed.
   * @param {string} version     The candidate version
   * @throws                     An error if the version is invalid
   */
  static validateVersion(version) {
    if ( /['"<>&]/.test(version) ) throw new Error("contains an illegal character: ' \" < > &");
  }

  /* -------------------------------------------- */

  /**
   * Validate a single compendium pack object
   * @param {PackageCompendiumData} packData  Candidate compendium packs data
   * @throws                                  An error if the data is invalid
   */
  static #validatePack(packData) {
    if ( SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(packData.type) && !packData.system ) {
      throw new Error(`The Compendium pack "${packData.name}" of the "${packData.type}" type must declare the "system"`
      + " upon which it depends.");
    }
  }

  /* -------------------------------------------- */

  /**
   * A wrapper around the default compatibility warning logger which handles some package-specific interactions.
   * @param {string} packageId            The package ID being logged
   * @param {string} message              The warning or error being logged
   * @param {object} options              Logging options passed to foundry.utils.logCompatibilityWarning
   * @param {object} [options.installed]  Is the package installed?
   * @internal
   */
  static _logWarning(packageId, message, { installed, ...options }={}) {
    logCompatibilityWarning(message, options);
    if ( installed ) globalThis.packages?.warnings?.add(packageId, {type: this.type, level: "warning", message});
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static migrateData(data, {installed}={}) {
    this._migratePackIDs(data, {since: 12, until: 14, stack: false, installed});
    this._migrateStyles(data);
    return super.migrateData(data);
  }

  /* -------------------------------------------- */

  /**
   * Migrate to v13-schema styles array from string array
   * @param {PackageManifestData} data
   * @internal
   */
  static _migrateStyles(data) {
    if ( !Array.isArray(data.styles) ) return;
    data.styles = data.styles.map(src => {
      if ( foundry.utils.getType(src) === "Object" ) return src;
      return { src };
    });
  }

  /* -------------------------------------------- */

  /**
   * Adjust pack names to conform to a slugified version
   * @param {PackageManifestData} data
   * @param {object} logOptions
   * @internal
   */
  static _migratePackIDs(data, logOptions) {
    if ( !data.packs ) return;
    for ( const pack of data.packs ) {
      const slugified = pack.name.replace(/[^A-Za-z0-9-_]/g, "");
      if ( pack.name !== slugified ) {
        const msg = `The ${this.type} "${data.id}" contains a pack with an invalid name "${pack.name}". `
          + "Pack names containing any character that is non-alphanumeric or an underscore will cease loading in "
          + "version 14 of the software.";
        pack.name = slugified;
        this._logWarning(data.id, msg, logOptions);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Retrieve the latest Package manifest from a provided remote location.
   * @param {string} manifestUrl        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<ServerPackage>}  A Promise which resolves to a constructed ServerPackage instance
   * @throws {Error}                    An error if the retrieved manifest data is invalid
   */
  static async fromRemoteManifest(manifestUrl, {strict=true}={}) {
    throw new Error("Not implemented");
  }
}

/**
 * A special ObjectField available to packages which configures any additional Document subtypes
 * provided by the package.
 */
class AdditionalTypesField extends ObjectField {

  /** @inheritDoc */
  static get _defaults() {
    return mergeObject(super._defaults, {
      readonly: true,
      validationError: "is not a valid sub-types configuration"
    });
  }

  /* ----------------------------------------- */

  /** @inheritDoc */
  _validateType(value, options={}) {
    super._validateType(value, options);
    for ( const [documentName, subtypes] of Object.entries(value) ) {
      const cls = getDocumentClass(documentName);
      if ( !cls ) throw new Error(`${this.validationError}: '${documentName}' is not a valid Document type`);
      if ( !cls.hasTypeData ) {
        throw new Error(`${this.validationError}: ${documentName} Documents do not support sub-types`);
      }
      if ( getType(subtypes) !== "Object" ) throw new Error(`Malformed ${documentName} documentTypes declaration`);
      for ( const [type, config] of Object.entries(subtypes) ) this.#validateSubtype(cls, type, config);
    }
  }

  /* ----------------------------------------- */

  /**
   * Validate a single defined document subtype.
   * @param {typeof Document} documentClass       The document for which the subtype is being registered
   * @param {string} type                         The requested subtype name
   * @param {object} config                       The provided subtype configuration
   * @throws {Error}                              An error if the subtype is invalid or malformed
   */
  #validateSubtype(documentClass, type, config) {
    const dn = documentClass.documentName;
    if ( documentClass.metadata.coreTypes.includes(type) ) {
      throw new Error(`"${type}" is a reserved core type for the ${dn} document`);
    }
    if ( getType(config) !== "Object" ) {
      throw new Error(`Malformed "${type}" subtype declared for ${dn} documentTypes`);
    }
  }
}

/**
 * @import {ModuleManifestData} from "./_types.mjs";
 */

/**
 * The data schema used to define Module manifest files.
 * Extends the basic PackageData schema with some additional module-specific fields.
 * @extends BasePackage<ModuleManifestData>
 */
class BaseModule extends BasePackage {

  /** @inheritDoc */
  static defineSchema() {
    const parentSchema = super.defineSchema();
    return Object.assign({}, parentSchema, {
      coreTranslation: new BooleanField(),
      library: new BooleanField(),
      documentTypes: new AdditionalTypesField()
    });
  }

  /** @override */
  static type = "module";

  /**
   * The default icon used for this type of Package.
   * @type {string}
   */
  static icon = "fa-plug";
}

/**
 * @import BasePackage from "@common/packages/base-package.mjs";
 * @import {Constructor} from "@common/_types.mjs";
 * @import {PackageCompatibilityBadge, PackageManifestData} from "./_types.mjs";
 */

/**
 * A client-side mixin used for all Package types.
 * @category Mixins
 * @param {Constructor<BasePackage>} Base    The parent BasePackage class being mixed
 */
function ClientPackageMixin(Base) {
  /**
   * The ClientDocument extends the BasePackage class by adding client-specific behaviors to all Package types.
   * @extends {BasePackage<PackageManifestData>}
   */
  class ClientPackage extends Base {

    /**
     * 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);
    }

    /* -------------------------------------------- */

    /**
     * Retrieve a Package of this type from its collection.
     * @param {string} id           The package ID to retrieve
     * @returns {ClientPackage}     The retrieved package instance, or undefined
     */
    static get(id) {
      return game[this.collection].get(id);
    }

    /* -------------------------------------------- */

    /**
     * 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-solid fa-file-slash"
          };
        }
        case codes.MISSING_SYSTEM:
          return {
            type: "error",
            tooltip: game.i18n.format("SETUP.RequireDep", { dependencies: foundry.utils.escapeHTML(data.system) }),
            label: version,
            icon: "fa-solid fa-file-slash"
          };

        case codes.MISSING_DEPENDENCY:
        case codes.REQUIRES_DEPENDENCY_UPDATE:
          return {
            type: "error",
            label: version,
            icon: "fa-solid 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: "fa-solid fa-triangle-exclamation"
          };

        case codes.UNVERIFIED_SYSTEM:
          return {
            type: "warning",
            label: version,
            icon: "fa-solid fa-triangle-exclamation",
            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: "fa-solid fa-code-branch"
          };

        // Safe
        case codes.VERIFIED:
          return {
            type: "success",
            tooltip: game.i18n.localize("SETUP.Verified"),
            label: version,
            icon: "fa-solid 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 {Error}                          An error if the retrieved manifest data is invalid
     */
    static async fromRemoteManifest(manifest, {strict=false}={}) {
      try {
        const data = await game.post({action: "getPackageFromRemoteManifest", type: this.type, manifest});
        return new this(data, {installed: false, strict: strict});
      }
      catch(e) {
        if ( strict ) throw e;
        return null;
      }
    }
  }
  return ClientPackage;
}

/**
 * @extends BaseModule
 * @mixes {@link ClientPackageMixin}
 * @see {@link foundry.packages.types.ModuleManifestData} For the module.json schema
 * @see {@link foundry.ClientPackage}
 * @category Packages
 */
class Module extends ClientPackageMixin(BaseModule) {
  constructor(data, options = {}) {
    const {active} = data;
    super(data, options);

    /**
     * Is this package currently active?
     * @type {boolean}
     */
    this.active = data.active;
    Object.defineProperty(this, "active", {value: active, writable: false, configurable: false});
  }
}

/**
 * @import {SystemManifestData} from "./_types.mjs";
 */

/**
 * The data schema used to define System manifest files.
 * Extends the basic PackageData schema with some additional system-specific fields.
 * @extends BasePackage<SystemManifestData>
 */
class BaseSystem extends BasePackage {

  /** @inheritDoc */
  static defineSchema() {
    return Object.assign({}, super.defineSchema(), {
      documentTypes: new AdditionalTypesField(),
      background: new StringField({required: false, blank: false}),
      initiative: new StringField(),
      grid: new SchemaField({
        type: new NumberField({required: true, choices: Object.values(CONST.GRID_TYPES),
          initial: CONST.GRID_TYPES.SQUARE, validationError: "must be a value in CONST.GRID_TYPES"}),
        distance: new NumberField({required: true, nullable: false, positive: true, initial: 1}),
        units: new StringField({required: true}),
        diagonals: new NumberField({required: true, choices: Object.values(CONST.GRID_DIAGONALS),
          initial: CONST.GRID_DIAGONALS.EQUIDISTANT, validationError: "must be a value in CONST.GRID_DIAGONALS"})
      }),
      primaryTokenAttribute: new StringField(),
      secondaryTokenAttribute: new StringField()
    });
  }

  /** @override */
  static type = "system";

  /**
   * The default icon used for this type of Package.
   * @type {string}
   */
  static icon = "fa-dice";

  /**
   * Does the system template request strict type checking of data compared to template.json inferred types.
   * @type {boolean}
   */
  strictDataCleaning = false;

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * Static initializer block for deprecated properties.
   */
  static {
    /**
     * Shim grid distance and units.
     * @deprecated since v12
     */
    Object.defineProperties(this.prototype, Object.fromEntries(
      Object.entries({
        gridDistance: "grid.distance",
        gridUnits: "grid.units"
      }).map(([o, n]) => [o, {
        get() {
          const msg = `You are accessing BasePackage#${o} which has been migrated to BasePackage#${n}.`;
          foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
          return foundry.utils.getProperty(this, n);
        },
        set(v) {
          const msg = `You are accessing BasePackage#${o} which has been migrated to BasePackage#${n}.`;
          foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
          return foundry.utils.setProperty(this, n, v);
        },
        configurable: true
      }])
    ));
  }

  /* ---------------------------------------- */

  /** @inheritdoc */
  static migrateData(data, options) {
    /**
     * Migrate grid distance and units.
     * @deprecated since v12
     */
    for ( const [oldKey, [newKey, apply]] of Object.entries({
      gridDistance: ["grid.distance", d => Math.max(d.gridDistance || 0, 1)],
      gridUnits: ["grid.units", d => d.gridUnits || ""]
    })) {
      if ( (oldKey in data) && !foundry.utils.hasProperty(data, newKey) ) {
        foundry.utils.setProperty(data, newKey, apply(data));
        delete data[oldKey];
        const warning = `The ${this.type} "${data.id}" is using "${oldKey}" which is deprecated in favor of "${newKey}".`;
        this._logWarning(data.id, warning, {since: 12, until: 14, stack: false, installed: options.installed});
      }
    }
    return super.migrateData(data, options);
  }

  /* ---------------------------------------- */

  /** @inheritdoc */
  static shimData(data, options) {
    /**
     * Shim grid distance and units.
     * @deprecated since v12
     */
    for ( const [oldKey, newKey] of Object.entries({
      gridDistance: "grid.distance",
      gridUnits: "grid.units"
    })) {
      if ( !data.hasOwnProperty(oldKey) && foundry.utils.hasProperty(data, newKey) ) {
        Object.defineProperty(data, oldKey, {
          get: () => {
            const msg = `You are accessing BasePackage#${oldKey} which has been migrated to BasePackage#${newKey}.`;
            foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
            return foundry.utils.getProperty(data, newKey);
          },
          set: value => foundry.utils.setProperty(data, newKey, value),
          configurable: true
        });
      }
    }
    return super.shimData(data, options);
  }
}

/**
 * @extends BaseSystem
 * @mixes {@link ClientPackageMixin}
 * @see {@link foundry.packages.types.SystemManifestData} For the system.json schema
 * @see {@link foundry.ClientPackage}
 * @category Packages
 */
class System extends ClientPackageMixin(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;
  }
}

/**
 * @import {WorldManifestData} from "./_types.mjs";
 */

/**
 * The data schema used to define World manifest files.
 * Extends the basic PackageData schema with some additional world-specific fields.
 * @extends BasePackage<WorldManifestData>
 */
class BaseWorld extends BasePackage {

  /** @inheritDoc */
  static defineSchema() {
    return Object.assign({}, super.defineSchema(), {
      system: new StringField({required: true, blank: false}),
      background: new FilePathField({categories: ["IMAGE"], required: false}),
      description: new HTMLField({required: true}),
      joinTheme: new StringField({
        required: false, initial: undefined, nullable: false, choices: Object.keys(CONST.WORLD_JOIN_THEMES)
      }),
      coreVersion: new StringField({required: true, blank: false, validate: BasePackage.validateVersion}),
      systemVersion: new StringField({required: true, blank: false, initial: "0", validate: BasePackage.validateVersion}),
      lastPlayed: new StringField(),
      playtime: new NumberField({integer: true, min: 0, initial: 0}),
      nextSession: new StringField({blank: false, nullable: true, initial: null}),
      resetKeys: new BooleanField({required: false, initial: undefined}),
      safeMode: new BooleanField({required: false, initial: undefined}),
      version: new StringField({required: true, blank: false, nullable: true, initial: null,
        validate: BasePackage.validateVersion}),
      demo: new SchemaField({
        sourceZip: new StringField({ required: true, blank: false, nullable: true, initial: null })
      })
    });
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["PACKAGE", "WORLD"];

  /** @override */
  static type = "world";

  /**
   * The default icon used for this type of Package.
   * @type {string}
   */
  static icon = "fa-globe-asia";

  /** @inheritDoc */
  static migrateData(data) {
    super.migrateData(data);

    // Legacy compatibility strings
    data.compatibility = data.compatibility || {};
    if ( data.compatibility.maximum === "1.0.0" ) data.compatibility.maximum = undefined;
    if ( data.coreVersion && !data.compatibility.verified ) {
      data.compatibility.minimum = data.compatibility.verified = data.coreVersion;
    }

    // Clear the background field if it is not a valid image file path
    if ( (typeof data.background === "string") && !hasFileExtension(data.background, Object.keys(FILE_CATEGORIES.IMAGE)) ) {
      data.background = null;
    }

    return data;
  }

  /* -------------------------------------------- */

  /**
   * Check the given compatibility data against the current installation state and determine its availability.
   * @param {Partial<WorldManifestData>} data  The compatibility data to test.
   * @param {object} [options]
   * @param {ReleaseData} [options.release]      A specific software release for which to test availability.
   *                                             Tests against the current release by default.
   * @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.
   * @param {number} [options.systemAvailabilityThreshold]  Ignore the world's own core software compatibility and
   *                                                        instead defer entirely to the system's core software
   *                                                        compatibility, if the world's availability is less than
   *                                                        this.
   * @returns {number}
   */
  static testAvailability(data, { release, modules, systems, systemAvailabilityThreshold }={}) {
    systems ??= globalThis.packages?.System ?? game.systems;
    modules ??= globalThis.packages?.Module ?? game.modules;
    const { relationships } = data;
    const codes = CONST.PACKAGE_AVAILABILITY_CODES;
    systemAvailabilityThreshold ??= codes.UNKNOWN;

    // If the World itself is incompatible for some reason, report that directly.
    const wa = super.testAvailability(data, { release });
    if ( this.isIncompatibleWithCoreVersion(wa) ) return wa;

    // If the System is missing or incompatible, report that directly.
    const system = data.system instanceof foundry.packages.BaseSystem ? data.system : systems.get(data.system);
    if ( !system ) return codes.MISSING_SYSTEM;
    const sa = system.availability;
    // FIXME: Why do we only check if the system is incompatible with the core version or UNKNOWN?
    // Proposal: If the system is anything but VERIFIED, UNVERIFIED_BUILD, or UNVERIFIED_GENERATION, we should return
    // the system availability.
    if ( system.incompatibleWithCoreVersion || (sa === codes.UNKNOWN) ) return sa;

    // Test the availability of all required modules.
    const checkedModules = new Set();
    // TODO: We do not need to check system requirements here if the above proposal is implemented.
    const requirements = [...relationships.requires.values(), ...system.relationships.requires.values()];
    for ( const r of requirements ) {
      if ( (r.type !== "module") || checkedModules.has(r.id) ) continue;
      const module = modules.get(r.id);
      if ( !module ) return codes.MISSING_DEPENDENCY;
      // FIXME: Why do we only check if the module is incompatible with the core version?
      // Proposal: We should check the actual compatibility information for the relationship to ensure that the module
      // satisfies it.
      if ( module.incompatibleWithCoreVersion ) return codes.REQUIRES_DEPENDENCY_UPDATE;
      checkedModules.add(r.id);
    }

    // Inherit from the System availability in certain cases.
    if ( wa <= systemAvailabilityThreshold ) return sa;
    return wa;
  }
}

/**
 * @extends BaseWorld
 * @mixes {@link ClientPackageMixin}
 * @see {@link foundry.packages.types.WorldManifestData} For the world.json schema
 * @see {@link foundry.ClientPackage}
 * @category Packages
 */
class World extends ClientPackageMixin(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-solid fa-file-slash"
    };
    const badge = system.getVersionBadge();
    if ( badge.type === "safe" ) {
      badge.type = "neutral";
      badge.icon = null;
    }
    badge.tooltip = `<p>${foundry.utils.escapeHTML(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);
  }
}

/**
 * @typedef 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
 */

var _types$g = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/** @module packages */


/**
 * A mapping of allowed package types and the classes which implement them.
 * @type {{world: World, system: System, module: Module}}
 */
const PACKAGE_TYPES = Object.freeze({
  world: World,
  system: System,
  module: Module
});

var _module$T = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AdditionalTypesField: AdditionalTypesField,
  BaseModule: BaseModule,
  BasePackage: BasePackage,
  BaseSystem: BaseSystem,
  BaseWorld: BaseWorld,
  ClientPackageMixin: ClientPackageMixin,
  Module: Module,
  PACKAGE_TYPES: PACKAGE_TYPES,
  PackageCompatibility: PackageCompatibility,
  RelatedPackage: RelatedPackage,
  System: System,
  World: World,
  types: _types$g
});

/**
 * @import {DataModel, Document} from "./_module.mjs"
 * @import {DOCUMENT_OWNERSHIP_LEVELS, USER_ROLES} from "@common/constants.mjs"
 * @import {DataField} from "@common/data/fields.mjs";
 * @import BaseUser from "@common/documents/user.mjs"
 */

/**
 * @typedef {Record<string, DataField>} DataSchema
 */

/**
 * @typedef _DocumentConstructionContext
 * @property {Document|null} [parent=null]    The parent Document of this one, if this one is embedded
 * @property {string|null} [pack=null]        The compendium collection ID which contains this Document, if any
 * @property {boolean} [strict=true]          Whether to validate initial data strictly?
 * @property {string|null} [parentCollection] The name of the parent Document's collection that would contain this one
 */

/**
 * @typedef {DataModelConstructionContext & _DocumentConstructionContext} DocumentConstructionContext
 */

/**
 * @typedef DataModelValidationOptions
 * @property {boolean} [fields]           Validate each individual field
 * @property {boolean} [joint]            Perform joint validation on the full data model?
 *                                        Joint validation will be performed by default if no changes are passed.
 *                                        Joint validation will be disabled by default if changes are passed.
 *                                        Joint validation can be performed on a complete set of changes (for example
 *                                        testing a complete data model) by explicitly passing true.
 * @property {object} [changes]           A specific set of proposed changes to validate, rather than the full
 *                                        source data of the model.
 * @property {boolean} [clean]            If changes are provided, attempt to clean the changes before validating
 *                                        them? This option mutates the provided changes.
 * @property {boolean} [strict=true]      Throw an error if validation fails.
 * @property {boolean} [fallback=false]   Allow replacement of invalid values with valid defaults? This option mutates
 *                                        the provided changes.
 * @property {boolean} [dropInvalidEmbedded=false]  If true, invalid embedded documents will emit a warning and be
 *                                        placed in the invalidDocuments collection rather than causing the parent
 *                                        to be considered invalid. This option mutates the provided changes.
 */

/**
 * @typedef DataModelConstructionOptions
 * @property {DataModel|null} [parent]    A parent DataModel instance to which this DataModel belongs
 */

/**
 * @typedef {DataModelConstructionOptions &
 *           Pick<DataModelValidationOptions, "strict"|"fallback"|"dropInvalidEmbedded">} DataModelConstructionContext
 */

/**
 * @typedef DataModelUpdateOptions
 * @property {boolean} [dryRun=false]     Do not finally apply the change, but instead simulate the update workflow
 * @property {boolean} [fallback=false]   Allow automatic fallback to a valid initial value if the value provided for
 *                                        a field in the model is invalid.
 * @property {boolean} [recursive=true]   Apply changes to inner objects recursively rather than replacing the
 *                                         top-level object.
 * @property {boolean} [restoreDelta]     An advanced option used specifically and internally by the ActorDelta model
 */

/**
 * @typedef {"get"|"create"|"update"|"delete"} DatabaseAction
 */

/**
 * @typedef DatabaseGetOperation
 * @property {Record<string, any>} query        A query object which identifies the set of Documents retrieved
 * @property {"get"} action                     The action of this database operation
 * @property {false} [broadcast]                Get requests are never broadcast
 * @property {boolean} [index]                  Return indices only instead of full Document records
 * @property {string[]} [indexFields]           An array of field identifiers which should be indexed
 * @property {string|null} [pack=null]          A compendium collection ID which contains the Documents
 * @property {Document|null} [parent=null]      A parent Document within which Documents are embedded
 * @property {string} [parentUuid]              A parent Document UUID provided when the parent instance is unavailable
 */

/**
 * @typedef DatabaseCreateOperation
 * @property {boolean} broadcast                Whether the database operation is broadcast to other connected clients
 * @property {"create"} action                  The action of this database operation
 * @property {object[]} data                    An array of data objects from which to create Documents
 * @property {boolean} [keepId=false]           Retain the _id values of provided data instead of generating new ids
 * @property {boolean} [keepEmbeddedIds=true]   Retain the _id values of embedded document data instead of generating
 *                                              new ids for each embedded document
 * @property {number} [modifiedTime]            The timestamp when the operation was performed
 * @property {boolean} [noHook=false]           Block the dispatch of hooks related to this operation
 * @property {boolean} [render=true]            Re-render Applications whose display depends on the created Documents
 * @property {boolean} [renderSheet=false]      Render the sheet Application for any created Documents
 * @property {Document|null} [parent=null]      A parent Document within which Documents are embedded
 * @property {string|null} pack                 A compendium collection ID which contains the Documents
 * @property {string|null} [parentUuid]         A parent Document UUID provided when the parent instance is unavailable
 * @property {Record<string, object>} [_createData] Used internally by server-side backend
 * @property {(string|object)[]} [_result]      Used internally by the server-side backend
 */

/**
 * @typedef DatabaseUpdateOperation
 * @property {boolean} broadcast                Whether the database operation is broadcast to other connected clients
 * @property {"update"} action                  The action of this database operation
 * @property {object[]} updates                 An array of data objects used to update existing Documents.
 *                                              Each update object must contain the _id of the target Document
 * @property {boolean} [diff=true]              Difference each update object against current Document data and only use
 *                                              differential data for the update operation
 * @property {number} [modifiedTime]            The timestamp when the operation was performed
 * @property {boolean} [recursive=true]         Merge objects recursively. If false, inner objects will be replaced
 *                                              explicitly. Use with caution!
 * @property {boolean} [render=true]            Re-render Applications whose display depends on the created Documents
 * @property {boolean} [noHook=false]           Block the dispatch of hooks related to this operation
 * @property {Document|null} [parent=null]      A parent Document within which Documents are embedded
 * @property {string|null} pack                 A compendium collection ID which contains the Documents
 * @property {string|null} [parentUuid]         A parent Document UUID provided when the parent instance is unavailable
 * @property {Record<string, object>} [_updateData] Used internally by the server-side backend
 * @property {(string|object)[]} [_result]      Used internally by the server-side backend
 */

/**
 * @typedef DatabaseDeleteOperation
 * @property {boolean} broadcast                Whether the database operation is broadcast to other connected clients
 * @property {"delete"} action                  The action of this database operation
 * @property {string[]} ids                     An array of Document ids which should be deleted
 * @property {boolean} [deleteAll=false]        Delete all documents in the Collection, regardless of _id
 * @property {Record<string, string>} [replacements] The mapping of IDs of deleted Documents to the UUIDs of the
 *                                              Documents that replace the deleted Documents
 * @property {number} [modifiedTime]            The timestamp when the operation was performed
 * @property {boolean} [noHook=false]           Block the dispatch of hooks related to this operation
 * @property {boolean} [render=true]            Re-render Applications whose display depends on the deleted Documents
 * @property {Document|null} [parent=null]      A parent Document within which Documents are embedded
 * @property {string|null} pack                 A compendium collection ID which contains the Documents
 * @property {string|null} [parentUuid]         A parent Document UUID provided when the parent instance is unavailable
 * @property {(string|object)[]} [_result]      An alias for 'ids' used internally by the server-side backend
 */

/**
 * @typedef {DatabaseGetOperation
 *   |DatabaseCreateOperation
 *   |DatabaseUpdateOperation
 *   |DatabaseDeleteOperation} DatabaseOperation
 */

/**
 * @typedef DocumentSocketRequest
 * @property {string} type                      The type of Document being transacted
 * @property {DatabaseAction} action            The action of the request
 * @property {DatabaseOperation} operation      Operation parameters for the request
 * @property {string} userId                    The id of the requesting User
 * @property {boolean} broadcast                Should the response be broadcast to other connected clients?
 */

/**
 * @typedef DataModelFromSourceOptions
 * @property {boolean} [strict=false]    Models created from trusted source data are validated non-strictly.
 *                                       Default: `false`.
 */

/**
 * @typedef DocumentCloneOptions
 * @property {boolean} [save=false]             Save the clone to the World database? Default: `false`.
 * @property {boolean} [keepId=false]           Keep the same ID of the original document. Default: `false`.
 * @property {boolean} [addSource=false]        Track the clone source. Default: `false`.
 */

/**
 * @callback DocumentPermissionTest
 * @param {BaseUser} user         The User attempting the operation
 * @param {Document} document     The Document being operated upon
 * @param {object} [data]         Data provided to a creation or update operation
 * @returns {boolean}
 */

/**
 * @typedef DocumentClassMetadata
 * @property {string} name
 * @property {string} label
 * @property {string[]} coreTypes
 * @property {string} collection
 * @property {Record<string, string>} embedded
 * @property {Record<
 *   "view"|"create"|"update"|"delete", keyof USER_ROLES|keyof DOCUMENT_OWNERSHIP_LEVELS|DocumentPermissionTest
 * >} permissions
 * @property {boolean} hasTypeData
 * @property {boolean} indexed
 * @property {string[]} compendiumIndexFields
 * @property {string[]} preserveOnImport
 * @property {string} [schemaVersion]
 */

var _types$f = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * @import {DatabaseAction, DatabaseOperation, DocumentSocketRequest} from "./_types.mjs";
 */

/**
 * The data structure of a modifyDocument socket response.
 */
class DocumentSocketResponse {
  /**
   * Prepare a response for an incoming request.
   * @param {DocumentSocketRequest} request     The incoming request that is being responded to
   */
  constructor(request) {
    for ( const [k, v] of Object.entries(request) ) {
      if ( this.hasOwnProperty(k) ) this[k] = v;
    }
  }

  /**
   * The type of Document being transacted.
   * @type {string}
   */
  type;

  /**
   * The database action that was performed.
   * @type {DatabaseAction}
   */
  action;

  /**
   * Was this response broadcast to other connected clients?
   * @type {boolean}
   */
  broadcast;

  /**
   * The database operation that was requested.
   * @type {DatabaseOperation}
   */
  operation;

  /**
   * The identifier of the requesting user.
   * @type {string}
   */
  userId;

  /**
   * The result of the request. Present if successful
   * @type {object[]|string[]}
   */
  result;

  /**
   * An error that occurred. Present if unsuccessful
   * @type {Error}
   */
  error;
}

/** @module abstract */

var _module$S = /*#__PURE__*/Object.freeze({
  __proto__: null,
  DataModel: DataModel,
  DatabaseBackend: DatabaseBackend$1,
  Document: Document,
  DocumentSocketResponse: DocumentSocketResponse,
  EmbeddedCollection: EmbeddedCollection,
  EmbeddedCollectionDelta: EmbeddedCollectionDelta,
  SingletonEmbeddedCollection: SingletonEmbeddedCollection,
  TypeDataModel: TypeDataModel,
  types: _types$f
});

var _types$e = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/** @module utils */


/**
 * @deprecated since v13
 * @ignore
 */
const SortingHelpers = {
  /**
   * Given a source object to sort, a target to sort relative to, and an Array of siblings in the container.
   * @param {...Parameters<performIntegerSort>} args
   * @deprecated since v13
   * @ignore
   */
  performIntegerSort(...args) {
    foundry.utils.logCompatibilityWarning("foundry.utils.SortingHelpers.performIntegerSort has been deprecated. Access "
      + "this helper at foundry.utils.performIntegerSort instead.", {since: 13, until: 15});
    return performIntegerSort(...args);
  }
};

var _module$R = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AsyncFunction: AsyncFunction,
  BitMask: BitMask,
  Collection: Collection,
  Color: Color,
  EventEmitterMixin: EventEmitterMixin,
  HttpError: HttpError,
  IterableWeakMap: IterableWeakMap,
  IterableWeakSet: IterableWeakSet,
  Semaphore: Semaphore,
  SortingHelpers: SortingHelpers,
  StringTree: StringTree,
  WordTree: WordTree,
  applySpecialKeys: applySpecialKeys,
  benchmark: benchmark,
  buildUuid: buildUuid,
  circleCircleIntersects: circleCircleIntersects,
  cleanHTML: cleanHTML,
  closestPointToSegment: closestPointToSegment,
  debounce: debounce,
  debouncedReload: debouncedReload,
  deepClone: deepClone,
  deepFreeze: deepFreeze,
  deepSeal: deepSeal,
  deleteProperty: deleteProperty,
  diffObject: diffObject,
  duplicate: duplicate,
  encodeURL: encodeURL,
  escapeHTML: escapeHTML,
  expandObject: expandObject,
  fetchJsonWithTimeout: fetchJsonWithTimeout,
  fetchResource: fetchResource,
  fetchWithTimeout: fetchWithTimeout,
  filterObject: filterObject,
  flattenObject: flattenObject,
  formatFileSize: formatFileSize,
  fromUuid: fromUuid,
  fromUuidSync: fromUuidSync,
  getCacheBustURL: getCacheBustURL,
  getDefiningClass: getDefiningClass,
  getDocumentClass: getDocumentClass$1,
  getParentClasses: getParentClasses,
  getPlaceableObjectClass: getPlaceableObjectClass,
  getProperty: getProperty,
  getRoute: getRoute,
  getType: getType,
  hasProperty: hasProperty,
  invertObject: invertObject,
  isDeletionKey: isDeletionKey,
  isEmpty: isEmpty,
  isNewerVersion: isNewerVersion,
  isSubclass: isSubclass,
  lineCircleIntersection: lineCircleIntersection,
  lineLineIntersection: lineLineIntersection,
  lineSegmentIntersection: lineSegmentIntersection,
  lineSegmentIntersects: lineSegmentIntersects,
  logCompatibilityWarning: logCompatibilityWarning,
  mergeObject: mergeObject,
  objectsEqual: objectsEqual,
  orient2dFast: orient2dFast,
  parseHTML: parseHTML$1,
  parseS3URL: parseS3URL,
  parseUuid: parseUuid,
  pathCircleIntersects: pathCircleIntersects,
  performIntegerSort: performIntegerSort,
  polygonCentroid: polygonCentroid,
  quadraticIntersection: quadraticIntersection,
  randomID: randomID,
  readTextFromFile: readTextFromFile,
  saveDataToFile: saveDataToFile,
  setProperty: setProperty,
  srcExists: srcExists$1,
  threadLock: threadLock,
  throttle: throttle,
  timeSince: timeSince,
  types: _types$e,
  unescapeHTML: unescapeHTML
});

/**
 * A data model definition which describes the application configuration options.
 * These options are persisted in the user data Config folder in the options.json file.
 * The server-side software extends this class and provides additional validations.
 *
 * @property {string|null} adminPassword        The server administrator password (obscured)
 * @property {string|null} awsConfig            The relative path (to Config) of an AWS configuration file
 * @property {boolean} compressStatic           Whether to compress static files? True by default
 * @property {string} dataPath                  The absolute path of the user data directory (obscured)
 * @property {boolean} fullscreen               Whether the application should automatically start in fullscreen mode?
 * @property {string|null} hostname             A custom hostname applied to internet invitation addresses and URLs
 * @property {string} language                  The default language for the application
 * @property {string|null} localHostname        A custom hostname applied to local invitation addresses
 * @property {string|null} passwordSalt         A custom salt used for hashing user passwords (obscured)
 * @property {number} port                      The port on which the server is listening
 * @property {number} [protocol]                The Internet Protocol version to use, either 4 or 6.
 * @property {number} proxyPort                 An external-facing proxied port used for invitation addresses and URLs
 * @property {boolean} proxySSL                 Is the application running in SSL mode at a reverse-proxy level?
 * @property {string|null} routePrefix          A URL path part which prefixes normal application routing
 * @property {string|null} sslCert              The relative path (to Config) of a used SSL certificate
 * @property {string|null} sslKey               The relative path (to Config) of a used SSL key
 * @property {string} updateChannel             The current application update channel
 * @property {boolean} upnp                     Is UPNP activated?
 * @property {number} upnpLeaseDuration         The duration in seconds of a UPNP lease, if UPNP is active
 * @property {string} world                     A default world name which starts automatically on launch
 */
class ServerSettings extends DataModel {
  static defineSchema() {
    return {
      adminPassword: new StringField({required: true, blank: false, nullable: true, initial: null}),
      awsConfig: new StringField({blank: false, nullable: true, initial: null}),
      compressStatic: new BooleanField({initial: true}),
      compressSocket: new BooleanField({initial: true}),
      cssTheme: new StringField({blank: false, choices: CSS_THEMES, initial: "dark"}),
      dataPath: new StringField(),
      deleteNEDB: new BooleanField(),
      fullscreen: new BooleanField({initial: false}),
      hostname: new StringField({required: true, blank: false, nullable: true, initial: null}),
      hotReload: new BooleanField({initial: false}),
      language: new StringField({required: true, blank: false, initial: "en.core"}),
      localHostname: new StringField({required: true, blank: false, nullable: true, initial: null}),
      passwordSalt: new StringField({required: true, blank: false, nullable: true, initial: null}),
      port: new NumberField({required: true, nullable: false, integer: true, min: 0, initial: 30000,
        validate: ServerSettings.#validatePort}),
      protocol: new NumberField({integer: true, choices: [4, 6], nullable: true}),
      proxyPort: new NumberField({required: true, nullable: true, integer: true, initial: null}),
      proxySSL: new BooleanField({initial: false}),
      routePrefix: new StringField({required: true, blank: false, nullable: true, initial: null}),
      sslCert: new StringField({blank: false, nullable: true, initial: null}),
      sslKey: new StringField({blank: false, nullable: true, initial: null}),
      telemetry: new BooleanField({required: false, initial: undefined}),
      updateChannel: new StringField({required: true, choices: SOFTWARE_UPDATE_CHANNELS, initial: "stable"}),
      upnp: new BooleanField({initial: true}),
      upnpLeaseDuration: new NumberField(),
      world: new StringField({required: true, blank: false, nullable: true, initial: null}),
      noBackups: new BooleanField({required: false})
    };
  }

  static LOCALIZATION_PREFIXES = ["SERVER_SETTINGS"];

  /* ----------------------------------------- */

  /** @override */
  static migrateData(data) {

    // Backwards compatibility old update channels
    /** @deprecated since v9 */
    const channelMap = {alpha: "prototype", beta: "testing", release: "stable"};
    data.updateChannel = channelMap[data.updateChannel] || data.updateChannel;

    // Backwards compatibility for awsConfig of true
    /** @deprecated since v11 */
    if ( data.awsConfig === true ) data.awsConfig = "";

    // Theme name changes
    /** @deprecated since v13 */
    if ( data.cssTheme === "foundry" ) data.cssTheme = "dark";
    return data;
  }

  /* ----------------------------------------- */

  /**
   * Validate a port assignment.
   * @param {number} port     The requested port
   * @throws                  An error if the requested port is invalid
   */
  static #validatePort(port) {
    if ( !Number.isNumeric(port) || ((port < 1024) && ![80, 443].includes(port)) || (port > 65535) ) {
      throw new Error("The application port must be an integer, either 80, 443, or between 1024 and 65535");
    }
  }
}

/* ----------------------------------------- */

/**
 * A data object which represents the details of this Release of Foundry VTT
 *
 * @property {number} generation        The major generation of the Release
 * @property {number} [maxGeneration]   The maximum available generation of the software.
 * @property {number} [maxStableGeneration]  The maximum available stable generation of the software.
 * @property {string} channel           The channel the Release belongs to, such as "stable"
 * @property {string} suffix            An optional appended string display for the Release
 * @property {number} build             The internal build number for the Release
 * @property {number} time              When the Release was released
 * @property {number} [node_version]    The minimum required Node.js major version
 * @property {string} [notes]           Release notes for the update version
 * @property {string} [download]        A temporary download URL where this version may be obtained
 * @property {Record<string, any>} [flags]  Supplementary data that accompanies this release.
 */
class ReleaseData extends DataModel {
  /** @override */
  static defineSchema() {
    return {
      generation: new NumberField({required: true, nullable: false, integer: true, min: 1}),
      maxGeneration: new NumberField({
        required: false, nullable: false, integer: true, min: 1, initial: () => this.generation
      }),
      maxStableGeneration: new NumberField({
        required: false, nullable: false, integer: true, min: 1, initial: () => this.generation
      }),
      channel: new StringField({choices: SOFTWARE_UPDATE_CHANNELS, blank: false}),
      suffix: new StringField(),
      build: new NumberField({required: true, nullable: false, integer: true}),
      time: new NumberField({nullable: false, initial: Date.now}),
      node_version: new NumberField({required: true, nullable: false, integer: true, min: 10}),
      notes: new StringField(),
      download: new StringField(),
      flags: new ObjectField()
    };
  }

  /* ----------------------------------------- */

  /**
   * A formatted string for shortened display, such as "Version 9"
   * @returns {string}
   */
  get shortDisplay() {
    return `Version ${this.generation} Build ${this.build}`;
  }

  /**
   * A formatted string for general display, such as "V9 Prototype 1" or "Version 9"
   * @returns {string}
   */
  get display() {
    return ["Version", this.generation, this.suffix].filterJoin(" ");
  }

  /**
   * A formatted string for Version compatibility checking, such as "9.150"
   * @returns {string}
   */
  get version() {
    return `${this.generation}.${this.build}`;
  }

  /**
   * Whether this generation requires manual installation.
   * @returns {boolean}
   */
  get requiresManualInstall() {
    return !!(this.flags.requires_generation_reinstall
      || this.flags.requires_electron_reinstall
      || this.flags.requires_full_reinstall);
  }

  /* ----------------------------------------- */

  /** @override */
  toString() {
    return this.shortDisplay;
  }

  /* ----------------------------------------- */

  /**
   * Is this ReleaseData object newer than some other version?
   * @param {string|ReleaseData} other        Some other version to compare against
   * @returns {boolean}                       Is this ReleaseData a newer version?
   */
  isNewer(other) {
    const version = other instanceof ReleaseData ? other.version : other;
    return isNewerVersion(this.version, version);
  }

  /* ----------------------------------------- */

  /**
   * Is this ReleaseData object a newer generation than some other version?
   * @param {string|ReleaseData} other        Some other version to compare against
   * @returns {boolean}                       Is this ReleaseData a newer generation?
   */
  isGenerationalChange(other) {
    if ( !other ) return true;
    let generation;
    if ( other instanceof ReleaseData ) generation = other.generation.toString();
    else {
      other = String(other);
      const parts = other.split(".");
      if ( parts[0] === "0" ) parts.shift();
      generation = parts[0];
    }
    return isNewerVersion(this.generation, generation);
  }
}

var config = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ReleaseData: ReleaseData,
  ServerSettings: ServerSettings
});

/**
 * @abstract
 */
class ProseMirrorPlugin {
  /**
   * An abstract class for building a ProseMirror Plugin.
   * @see {Plugin}
   * @param {Schema} schema  The schema to build the plugin against.
   */
  constructor(schema) {
    /**
     * The ProseMirror schema to build the plugin against.
     * @type {Schema}
     */
    Object.defineProperty(this, "schema", {value: schema});
  }

  /* -------------------------------------------- */

  /**
   * Build the plugin.
   * @param {Schema} schema     The ProseMirror schema to build the plugin against.
   * @param {object} [options]  Additional options to pass to the plugin.
   * @returns {Plugin}
   * @abstract
   */
  static build(schema, options={}) {
    throw new Error("Subclasses of ProseMirrorPlugin must implement a static build method.");
  }
}

/**
 * A class responsible for building the input rules for the ProseMirror editor.
 * @extends {ProseMirrorPlugin}
 */
class ProseMirrorInputRules extends ProseMirrorPlugin {
  /**
   * Build the plugin.
   * @param {Schema} schema     The ProseMirror schema to build the plugin against.
   * @param {object} [options]  Additional options to pass to the plugin.
   * @param {number} [options.minHeadingLevel=0]  The minimum heading level to start from when generating heading input
   *                                              rules. The resulting heading level for a heading rule is equal to the
   *                                              number of leading hashes minus this number.
   * */
  static build(schema, {minHeadingLevel=0}={}) {
    const rules = new this(schema, {minHeadingLevel});
    return inputRules({rules: rules.buildRules()});
  }

  /* -------------------------------------------- */

  /**
   * Build input rules for node types present in the schema.
   * @returns {InputRule[]}
   */
  buildRules() {
    const rules = [ellipsis, ProseMirrorInputRules.#emDashRule()];
    if ( "blockquote" in this.schema.nodes ) rules.push(this.#blockQuoteRule());
    if ( "ordered_list" in this.schema.nodes ) rules.push(this.#orderedListRule());
    if ( "bullet_list" in this.schema.nodes ) rules.push(this.#bulletListRule());
    if ( "code_block" in this.schema.nodes ) rules.push(this.#codeBlockRule());
    if ( "heading" in this.schema.nodes ) rules.push(this.#headingRule(1, 6));
    if ( "horizontal_rule" in this.schema.nodes ) rules.push(this.#hrRule());
    return rules;
  }

  /* -------------------------------------------- */

  /**
   * Turn a "&gt;" at the start of a textblock into a blockquote.
   * @returns {InputRule}
   */
  #blockQuoteRule() {
    return wrappingInputRule(/^\s*>\s$/, this.schema.nodes.blockquote);
  }

  /* -------------------------------------------- */

  /**
   * Turn a number followed by a dot at the start of a textblock into an ordered list.
   * @returns {InputRule}
   */
  #orderedListRule() {
    return wrappingInputRule(
      /^(\d+)\.\s$/, this.schema.nodes.ordered_list,
      match => ({order: Number(match[1])}),
      (match, node) => (node.childCount + node.attrs.order) === Number(match[1])
    );
  }

  /* -------------------------------------------- */

  /**
   * Turn a -, +, or * at the start of a textblock into a bulleted list.
   * @returns {InputRule}
   */
  #bulletListRule() {
    return wrappingInputRule(/^\s*[-+*]\s$/, this.schema.nodes.bullet_list);
  }

  /* -------------------------------------------- */

  /**
   * Turn three backticks at the start of a textblock into a code block.
   * @returns {InputRule}
   */
  #codeBlockRule() {
    return textblockTypeInputRule(/^```$/, this.schema.nodes.code_block);
  }

  /* -------------------------------------------- */

  /**
   * Turns a double dash anywhere into an em-dash. Does not match at the start of the line to avoid conflict with the
   * HR rule.
   * @returns {InputRule}
   */
  static #emDashRule() {
    return new InputRule(/[^-]+(--)/, "—");
  }

  /* -------------------------------------------- */

  /**
   * Turns a number of # characters followed by a space at the start of a textblock into a heading up to a maximum
   * level.
   * @param {number} minLevel  The minimum heading level to start generating input rules for.
   * @param {number} maxLevel  The maximum number of heading levels.
   * @returns {InputRule}
   */
  #headingRule(minLevel, maxLevel) {
    const range = maxLevel - minLevel + 1;
    return textblockTypeInputRule(
      new RegExp(`^(#{1,${range}})\\s$`), this.schema.nodes.heading,
      match => {
        const level = match[1].length;
        return {level: level + minLevel - 1};
      }
    );
  }

  /* -------------------------------------------- */

  /**
   * Turns three hyphens at the start of a line into a horizontal rule.
   * @returns {InputRule}
   */
  #hrRule() {
    const hr = this.schema.nodes.horizontal_rule;
    return new InputRule(/^---$/, (state, match, start, end) => {
      return state.tr.replaceRangeWith(start, end, hr.create()).scrollIntoView();
    });
  }
}

/**
 * @import {ProseMirrorCommand} from "./_types.mjs";
 */

/**
 * A class responsible for building the keyboard commands for the ProseMirror editor.
 * @extends {ProseMirrorPlugin}
 */
class ProseMirrorKeyMaps extends ProseMirrorPlugin {
  /**
   * @param {Schema} schema              The ProseMirror schema to build keymaps for.
   * @param {object} [options]           Additional options to configure the plugin's behaviour.
   * @param {Function} [options.onSave]  A function to call when Ctrl+S is pressed.
   */
  constructor(schema, {onSave}={}) {
    super(schema);

    /**
     * A function to call when Ctrl+S is pressed.
     * @type {Function}
     */
    Object.defineProperty(this, "onSave", {value: onSave, writable: false});
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static build(schema, options={}) {
    const keymaps = new this(schema, options);
    return keymap(keymaps.buildMapping());
  }

  /* -------------------------------------------- */

  /**
   * Build keyboard commands for nodes and marks present in the schema.
   * @returns {Record<string, ProseMirrorCommand>}  An object of keyboard shortcuts to editor functions.
   */
  buildMapping() {
    // TODO: Figure out how to integrate this with our keybindings system.
    const mapping = {};

    // Undo, Redo, Backspace.
    mapping["Mod-z"] = undo;
    mapping["Shift-Mod-z"] = redo;
    mapping["Backspace"] = undoInputRule;

    // ProseMirror-specific block operations.
    mapping["Alt-ArrowUp"] = joinUp;
    mapping["Alt-ArrowDown"] = joinDown;
    mapping["Mod-BracketLeft"] = lift;
    mapping["Escape"] = selectParentNode;

    // Bold.
    if ( "strong" in this.schema.marks ) {
      mapping["Mod-b"] = toggleMark(this.schema.marks.strong);
      mapping["Mod-B"] = toggleMark(this.schema.marks.strong);
    }

    // Italic.
    if ( "em" in this.schema.marks ) {
      mapping["Mod-i"] = toggleMark(this.schema.marks.em);
      mapping["Mod-I"] = toggleMark(this.schema.marks.em);
    }

    // Underline.
    if ( "underline" in this.schema.marks ) {
      mapping["Mod-u"] = toggleMark(this.schema.marks.underline);
      mapping["Mod-U"] = toggleMark(this.schema.marks.underline);
    }

    // Inline code.
    if ( "code" in this.schema.marks ) mapping["Mod-`"] = toggleMark(this.schema.marks.code);

    // Bulleted list.
    if ( "bullet_list" in this.schema.nodes ) mapping["Shift-Mod-8"] = wrapInList(this.schema.nodes.bullet_list);

    // Numbered list.
    if ( "ordered_list" in this.schema.nodes ) mapping["Shift-Mod-9"] = wrapInList(this.schema.nodes.ordered_list);

    // Blockquotes.
    if ( "blockquote" in this.schema.nodes ) mapping["Mod->"] = wrapInList(this.schema.nodes.blockquote);

    // Line breaks.
    if ( "hard_break" in this.schema.nodes ) this.#lineBreakMapping(mapping);

    // Block splitting.
    this.#newLineMapping(mapping);

    // List items.
    if ( "list_item" in this.schema.nodes ) {
      const li = this.schema.nodes.list_item;
      mapping["Shift-Tab"] = liftListItem(li);
      mapping["Tab"] = sinkListItem(li);
    }

    // Paragraphs.
    if ( "paragraph" in this.schema.nodes ) mapping["Shift-Mod-0"] = setBlockType(this.schema.nodes.paragraph);

    // Code blocks.
    if ( "code_block" in this.schema.nodes ) mapping["Shift-Mod-\\"] = setBlockType(this.schema.nodes.code_block);

    // Headings.
    if ( "heading" in this.schema.nodes ) this.#headingsMapping(mapping, 6);

    // Horizontal rules.
    if ( "horizontal_rule" in this.schema.nodes ) this.#horizontalRuleMapping(mapping);

    // Saving.
    if ( this.onSave ) this.#addSaveMapping(mapping);

    return mapping;
  }

  /* -------------------------------------------- */

  /**
   * Implement keyboard commands for heading levels.
   * @param {Record<string, ProseMirrorCommand>} mapping  The keyboard mapping.
   * @param {number} maxLevel                     The maximum level of headings.
   */
  #headingsMapping(mapping, maxLevel) {
    const h = this.schema.nodes.heading;
    Array.fromRange(maxLevel, 1).forEach(level => mapping[`Shift-Mod-${level}`] = setBlockType(h, {level}));
  }

  /* -------------------------------------------- */

  /**
   * Implement keyboard commands for horizontal rules.
   * @param {Record<string, ProseMirrorCommand>} mapping  The keyboard mapping.
   */
  #horizontalRuleMapping(mapping) {
    const hr = this.schema.nodes.horizontal_rule;
    mapping["Mod-_"] = (state, dispatch) => {
      dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView());
      return true;
    };
  }

  /* -------------------------------------------- */

  /**
   * Implement line-break keyboard commands.
   * @param {Record<string, ProseMirrorCommand>} mapping  The keyboard mapping.
   */
  #lineBreakMapping(mapping) {
    const br = this.schema.nodes.hard_break;

    // Exit a code block if we're in one, then create a line-break.
    const cmd = chainCommands(exitCode, (state, dispatch) => {
      dispatch(state.tr.replaceSelectionWith(br.create()).scrollIntoView());
      return true;
    });

    mapping["Mod-Enter"] = cmd;
    mapping["Shift-Enter"] = cmd;
  }

  /* -------------------------------------------- */

  /**
   * Implement some custom logic for how to split special blocks.
   * @param {Record<string, ProseMirrorCommand>} mapping  The keyboard mapping.
   */
  #newLineMapping(mapping) {
    const cmds = Object.values(this.schema.nodes).reduce((arr, node) => {
      if ( node.split instanceof Function ) arr.push(node.split);
      return arr;
    }, []);
    if ( !cmds.length ) return;
    mapping["Enter"] = cmds.length < 2 ? cmds[0] : chainCommands(...cmds);
  }

  /* -------------------------------------------- */

  /**
   * Implement save shortcut.
   * @param {Record<string, ProseMirrorCommand>} mapping  The keyboard mapping.
   */
  #addSaveMapping(mapping) {
    mapping["Mod-s"] = () => {
      this.onSave();
      return true;
    };
  }
}

/**
 * @import {ProseMirrorDropDownEntry} from "./_types.mjs";
 */

/**
 * A class responsible for creating a drop-down.
 */
class ProseMirrorDropDown {
  /**
   * A class responsible for rendering a menu drop-down.
   * @param {string} title                             The default title.
   * @param {ProseMirrorDropDownEntry[]} items         The configured menu items.
   * @param {object} [options]
   * @param {string} [options.cssClass]                The menu CSS class name. Required if providing an action.
   * @param {string} [options.icon]                    Use an icon for the dropdown rather than a text label.
   * @param {function(MouseEvent)} [options.onAction]  A callback to fire when a menu item is clicked.
   */
  constructor(title, items, {cssClass, icon, onAction}={}) {
    /**
     * The default title for this drop-down.
     * @type {string}
     */
    Object.defineProperty(this, "title", {value: title, writable: false});

    /**
     * The items configured for this drop-down.
     * @type {ProseMirrorDropDownEntry[]}
     */
    Object.defineProperty(this, "items", {value: items, writable: false});
    this.#icon = icon;
    this.#cssClass = cssClass;
    this.#onAction = onAction;
  }

  /* -------------------------------------------- */

  /**
   * The menu CSS class name.
   * @type {string}
   */
  #cssClass;

  /* -------------------------------------------- */

  /**
   * The icon to use instead of a text label, if any.
   * @type {string}
   */
  #icon;

  /* -------------------------------------------- */

  /**
   * The callback to fire when a menu item is clicked.
   * @type {function(MouseEvent)}
   */
  #onAction;

  /* -------------------------------------------- */

  /**
   * Attach event listeners.
   * @param {HTMLMenuElement} html  The root menu element.
   */
  activateListeners(html) {
    if ( !this.#onAction ) return;
    html.querySelector(`.pm-dropdown.${this.#cssClass}`).onclick = event => this.#onActivate(event);
  }

  /* -------------------------------------------- */

  /**
   * Construct the drop-down menu's HTML.
   * @returns {string}  HTML contents as a string.
   */
  render() {

    // Record which dropdown options are currently active
    const activeItems = [];
    this.forEachItem(item => {
      if ( !item.active ) return;
      activeItems.push(item);
    });
    activeItems.sort((a, b) => a.priority - b.priority);
    const activeItem = activeItems.shift();

    // Render the dropdown
    const active = game.i18n.localize(activeItem ? activeItem.title : this.title);
    const items = this.constructor._renderMenu(this.items);
    return `
      <button type="button" class="pm-dropdown ${this.#icon ? "icon" : ""} ${this.#cssClass}">
        ${this.#icon ? this.#icon : `<span>${active}</span>`}
        <i class="fa-solid fa-chevron-down"></i>
        ${items}
      </button>
    `;
  }

  /* -------------------------------------------- */

  /**
   * Recurse through the menu structure and apply a function to each item in it.
   * @param {function(ProseMirrorDropDownEntry):boolean} fn  The function to call on each item. Return false to prevent
   *                                                         iterating over any further items.
   */
  forEachItem(fn) {
    const forEach = items => {
      for ( const item of items ) {
        const result = fn(item);
        if ( result === false ) break;
        if ( item.children?.length ) forEach(item.children);
      }
    };
    forEach(this.items);
  }

  /* -------------------------------------------- */

  /**
   * Handle spawning a drop-down menu.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  #onActivate(event) {
    document.getElementById("prosemirror-dropdown")?.remove();
    const menu = event.currentTarget.querySelector(":scope > ul");
    if ( !menu ) return;
    const { top, left, bottom } = event.currentTarget.getBoundingClientRect();
    const dropdown = document.createElement("div");
    dropdown.id = "prosemirror-dropdown";
    // Apply theme.
    const nearestThemed = event.target.closest(".themed") ?? document.body;
    const [, theme] = nearestThemed.className.match(/(?:^|\s)(theme-\w+)/) ?? [];
    if ( theme ) dropdown.classList.add("themed", theme);
    dropdown.append(menu.cloneNode(true));
    Object.assign(dropdown.style, { left: `${left}px`, top: `${bottom}px` });
    document.body.append(dropdown);
    dropdown.querySelectorAll("li").forEach(item => {
      item.onclick = event => this.#onAction(event);
      item.onpointerover = event => this.#onHoverItem(event);
    });
    requestAnimationFrame(() => {
      const { width, height } = dropdown.querySelector(":scope > ul").getBoundingClientRect();
      const { clientWidth, clientHeight } = document.documentElement;
      if ( left + width > clientWidth ) dropdown.style.left = `${left - width}px`;
      if ( bottom + height > clientHeight ) dropdown.style.top = `${top - height}px`;
    });
  }

  /* -------------------------------------------- */

  /**
   * Adjust menu position when hovering over items.
   * @param {PointerEvent} event  The triggering event.
   */
  #onHoverItem(event) {
    const menu = event.currentTarget.querySelector(":scope > ul");
    if ( !menu ) return;
    const { clientWidth, clientHeight } = document.documentElement;
    const { top } = event.currentTarget.getBoundingClientRect();
    const { x, width, height } = menu.getBoundingClientRect();
    if ( top + height > clientHeight ) menu.style.top = `-${top + height - clientHeight}px`;
    if ( x + width > clientWidth ) menu.style.left = `-${width}px`;
  }

  /* -------------------------------------------- */

  /**
   * Render a list of drop-down menu items.
   * @param {ProseMirrorDropDownEntry[]} entries  The menu items.
   * @returns {string}  HTML contents as a string.
   * @protected
   */
  static _renderMenu(entries) {
    const groups = entries.reduce((arr, item) => {
      const group = item.group ?? 0;
      arr[group] ??= [];
      arr[group].push(this._renderMenuItem(item));
      return arr;
    }, []);
    const items = groups.reduce((arr, group) => {
      if ( group?.length ) arr.push(group.join(""));
      return arr;
    }, []);
    return `<ul>${items.join('<li class="divider"></li>')}</ul>`;
  }

  /* -------------------------------------------- */

  /**
   * Render an individual drop-down menu item.
   * @param {ProseMirrorDropDownEntry} item  The menu item.
   * @returns {string}  HTML contents as a string.
   * @protected
   */
  static _renderMenuItem(item) {
    const parts = [`<li data-action="${item.action}" class="${item.class ?? ""}">`];
    parts.push(`<span style="${item.style ?? ""}">${game.i18n.localize(item.title)}</span>`);
    if ( item.active && !item.children?.length ) parts.push('<i class="fa-solid fa-check"></i>');
    if ( item.children?.length ) {
      parts.push('<i class="fa-solid fa-chevron-right"></i>', this._renderMenu(item.children));
    }
    parts.push("</li>");
    return parts.join("");
  }
}

/**
 * @import {Schema} from "prosemirror-model"
 * @import {EditorView} from "prosemirror-view"
 * @import {MenuToggleBlockWrapCommand, ProseMirrorContentLinkOptions, ProseMirrorDropDownConfig,
 *   ProseMirrorMenuItem, ProseMirrorMenuOptions} from "./_types.mjs"
 */

/**
 * A class responsible for building a menu for a ProseMirror instance.
 * @extends {ProseMirrorPlugin}
 */
class ProseMirrorMenu extends ProseMirrorPlugin {

  /**
   * @param {Schema} schema                     The ProseMirror schema to build a menu for.
   * @param {EditorView} view                   The editor view.
   * @param {ProseMirrorMenuOptions} [options]  Additional options to configure the plugin's behaviour.
   */
  constructor(schema, view, options={}) {
    super(schema);
    this.options = options;

    /**
     * The editor view.
     * @type {EditorView}
     */
    Object.defineProperty(this, "view", {value: view});

    /**
     * The items configured for this menu.
     * @type {ProseMirrorMenuItem[]}
     */
    Object.defineProperty(this, "items", {value: this._getMenuItems()});

    /**
     * The ID of the menu element in the DOM.
     * @type {string}
     */
    Object.defineProperty(this, "id", {value: `prosemirror-menu-${foundry.utils.randomID()}`, writable: false});

    this._createDropDowns();
    this._wrapEditor();
  }

  /* -------------------------------------------- */

  /**
   * An enumeration of editor scopes in which a menu item can appear
   * @enum {string}
   * @protected
   */
  static _MENU_ITEM_SCOPES = {
    BOTH: "",
    TEXT: "text",
    HTML: "html"
  };

  /* -------------------------------------------- */

  /**
   * Additional options to configure the plugin's behaviour.
   * @type {ProseMirrorMenuOptions}
   */
  options;

  /* -------------------------------------------- */

  /**
   * An HTML element that we write HTML to before injecting it into the DOM.
   * @type {HTMLTemplateElement}
   */
  #renderTarget = document.createElement("template");

  /* -------------------------------------------- */

  /**
   * Track whether we are currently in a state of editing the HTML source.
   * @type {boolean}
   */
  get editingSource() {
    return this.#editingSource;
  }

  #editingSource = false;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static build(schema, options={}) {
    return new Plugin({
      view: editorView => {
        return new this(schema, editorView, options).render();
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Render the menu's HTML.
   * @returns {ProseMirrorMenu}
   */
  render() {
    const scopes = this.constructor._MENU_ITEM_SCOPES;
    const scopeKey = this.editingSource ? "HTML" : "TEXT";

    // Dropdown Menus
    const dropdowns = this.dropdowns.map(d => `<li class="text">${d.render()}</li>`);

    // Button items
    const buttons = this.items.reduce((buttons, item) => {
      if ( ![scopes.BOTH, scopes[scopeKey]].includes(item.scope) ) return buttons;
      const li = document.createElement("li");
      if ( item.active ) li.classList.add("active");
      if ( item.cssClass ) li.classList.add(...item.cssClass.split(/ +/));
      if ( item.scope ) li.classList.add(item.scope);
      const button = document.createElement("button");
      button.type = "button";
      if ( item.active ) button.classList.add("active");
      button.dataset.tooltip = item.title;
      button.dataset.action = item.action;
      button.innerHTML = item.icon;
      li.append(button);
      buttons.push(li.outerHTML);
      return buttons;
    }, []);

    // Add collaboration indicator.
    const collaborating = document.getElementById(this.id)?.querySelector(".concurrent-users");
    const tooltip = collaborating?.dataset.tooltipText || game.i18n.localize("EDITOR.CollaboratingUsers");
    buttons.push(`
      <li class="concurrent-users" data-tooltip-text="${foundry.utils.escapeHTML(tooltip)}">
        ${collaborating?.innerHTML || ""}
      </li>
    `);

    // Replace Menu HTML
    this.#renderTarget.innerHTML = `
      <menu class="editor-menu" id="${this.id}">
        ${dropdowns.join("")}
        ${buttons.join("")}
      </menu>
    `;
    document.getElementById(this.id).replaceWith(this.#renderTarget.content.getElementById(this.id));
    if ( !this.view.editable ) document.getElementById(this.id).querySelectorAll("button").forEach(b => b.disabled = true);

    // Toggle source editing state for the parent
    const editor = this.view.dom.closest(".editor");
    editor.classList.toggle("editing-source", this.editingSource);

    // Menu interactivity
    this.activateListeners(document.getElementById(this.id));
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Attach event listeners.
   * @param {HTMLMenuElement} html  The root menu element.
   */
  activateListeners(html) {
    html.querySelectorAll("button[data-action]").forEach(button => button.onclick = evt => this._onAction(evt));
    this.dropdowns.map(d => d.activateListeners(html));
  }

  /* -------------------------------------------- */

  /**
   * Called whenever the view's state is updated.
   * @param {EditorView} view       The current editor state.
   * @param {EditorView} prevState  The previous editor state.
   */
  update(view, prevState) {
    this.dropdowns.forEach(d => d.forEachItem(item => {
      item.active = this._isItemActive(item);
    }));
    this.items.forEach(item => item.active = this._isItemActive(item));
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Called when the view is destroyed or receives a state with different plugins.
   */
  destroy() {
    const menu = this.view.dom.closest(".editor").querySelector("menu");
    menu.nextElementSibling.remove();
    menu.remove();
  }

  /* -------------------------------------------- */

  /**
   * Instantiate the ProseMirrorDropDown instances and configure them with the defined menu items.
   * @protected
   */
  _createDropDowns() {
    const dropdowns = Object.values(this._getDropDownMenus()).map(({title, cssClass, icon, entries}) => {
      return new ProseMirrorDropDown(title, entries, { cssClass, icon, onAction: this._onAction.bind(this) });
    });

    /**
     * The dropdowns configured for this menu.
     * @type {ProseMirrorDropDown[]}
     */
    Object.defineProperty(this, "dropdowns", {value: dropdowns});
  }

  /* -------------------------------------------- */

  /**
   * Configure dropdowns for this menu. Each entry in the top-level array corresponds to a separate drop-down.
   * @returns {Record<string, ProseMirrorDropDownConfig>}
   * @protected
   */
  _getDropDownMenus() {
    const menus = {
      format: {
        title: "EDITOR.Format",
        cssClass: "format",
        entries: [
          {
            action: "block",
            title: "EDITOR.Block",
            children: [{
              action: "paragraph",
              title: "EDITOR.Paragraph",
              priority: 3,
              node: this.schema.nodes.paragraph
            }, {
              action: "blockquote",
              title: "EDITOR.Blockquote",
              priority: 1,
              node: this.schema.nodes.blockquote,
              cmd: () => this._toggleBlock(this.schema.nodes.blockquote, wrapIn)
            }, {
              action: "code-block",
              title: "EDITOR.CodeBlock",
              priority: 1,
              node: this.schema.nodes.code_block,
              cmd: () => this._toggleTextBlock(this.schema.nodes.code_block)
            }, {
              action: "secret",
              title: "EDITOR.Secret",
              priority: 1,
              node: this.schema.nodes.secret,
              cmd: () => {
                this._toggleBlock(this.schema.nodes.secret, wrapIn, {
                  attrs: {
                    id: `secret-${foundry.utils.randomID()}`
                  }
                });
              }
            }]
          }, {
            action: "inline",
            title: "EDITOR.Inline",
            children: [{
              action: "bold",
              title: "EDITOR.Bold",
              priority: 2,
              style: "font-weight: bold;",
              mark: this.schema.marks.strong,
              cmd: toggleMark(this.schema.marks.strong)
            }, {
              action: "italic",
              title: "EDITOR.Italic",
              priority: 2,
              style: "font-style: italic;",
              mark: this.schema.marks.em,
              cmd: toggleMark(this.schema.marks.em)
            }, {
              action: "code",
              title: "EDITOR.Code",
              priority: 2,
              style: "font-family: monospace;",
              mark: this.schema.marks.code,
              cmd: toggleMark(this.schema.marks.code)
            }, {
              action: "underline",
              title: "EDITOR.Underline",
              priority: 2,
              style: "text-decoration: underline;",
              mark: this.schema.marks.underline,
              cmd: toggleMark(this.schema.marks.underline)
            }, {
              action: "strikethrough",
              title: "EDITOR.Strikethrough",
              priority: 2,
              style: "text-decoration: line-through;",
              mark: this.schema.marks.strikethrough,
              cmd: toggleMark(this.schema.marks.strikethrough)
            }, {
              action: "superscript",
              title: "EDITOR.Superscript",
              priority: 2,
              mark: this.schema.marks.superscript,
              cmd: toggleMark(this.schema.marks.superscript)
            }, {
              action: "subscript",
              title: "EDITOR.Subscript",
              priority: 2,
              mark: this.schema.marks.subscript,
              cmd: toggleMark(this.schema.marks.subscript)
            }]
          }, {
            action: "alignment",
            title: "EDITOR.Alignment",
            children: [{
              action: "align-left",
              title: "EDITOR.AlignmentLeft",
              priority: 4,
              node: this.schema.nodes.paragraph,
              attrs: {alignment: "left"},
              cmd: () => this.#toggleAlignment("left")
            }, {
              action: "align-center",
              title: "EDITOR.AlignmentCenter",
              priority: 4,
              node: this.schema.nodes.paragraph,
              attrs: {alignment: "center"},
              cmd: () => this.#toggleAlignment("center")
            }, {
              action: "align-justify",
              title: "EDITOR.AlignmentJustify",
              priority: 4,
              node: this.schema.nodes.paragraph,
              attrs: {alignment: "justify"},
              cmd: () => this.#toggleAlignment("justify")
            }, {
              action: "align-right",
              title: "EDITOR.AlignmentRight",
              priority: 4,
              node: this.schema.nodes.paragraph,
              attrs: {alignment: "right"},
              cmd: () => this.#toggleAlignment("right")
            }]
          }
        ]
      }
    };

    const headings = Array.fromRange(6, 1).map(level => ({
      action: `h${level}`,
      title: game.i18n.format("EDITOR.Heading", {level}),
      priority: 1,
      class: `level${level}`,
      node: this.schema.nodes.heading,
      attrs: {level},
      cmd: () => this._toggleTextBlock(this.schema.nodes.heading, {attrs: {level}})
    }));

    menus.format.entries.unshift({
      action: "headings",
      title: "EDITOR.Headings",
      children: headings
    });

    const fonts = foundry.applications.settings.menus.FontConfig.getAvailableFonts().sort().map(family => ({
      action: `font-family-${family.slugify()}`,
      title: family,
      priority: 2,
      style: `font-family: '${family}';`,
      mark: this.schema.marks.font,
      attrs: {family},
      cmd: toggleMark(this.schema.marks.font, {family})
    }));

    if ( this.options.compact ) {
      menus.format.entries.push({
        action: "fonts",
        title: "EDITOR.Font",
        children: fonts
      });
    } else {
      menus.fonts = {
        title: "EDITOR.Font",
        cssClass: "fonts",
        entries: fonts
      };
    }

    menus.table = {
      title: "EDITOR.Table",
      cssClass: "tables",
      icon: '<i class="fa-solid fa-table fa-fw"></i>',
      entries: [{
        action: "insert-table",
        title: "EDITOR.TableInsert",
        group: 1,
        cmd: this._insertTablePrompt.bind(this)
      }, {
        action: "delete-table",
        title: "EDITOR.TableDelete",
        group: 1,
        cmd: deleteTable
      }, {
        action: "add-col-after",
        title: "EDITOR.TableAddColumnAfter",
        group: 2,
        cmd: addColumnAfter
      }, {
        action: "add-col-before",
        title: "EDITOR.TableAddColumnBefore",
        group: 2,
        cmd: addColumnBefore
      }, {
        action: "delete-col",
        title: "EDITOR.TableDeleteColumn",
        group: 2,
        cmd: deleteColumn
      }, {
        action: "add-row-after",
        title: "EDITOR.TableAddRowAfter",
        group: 3,
        cmd: addRowAfter
      }, {
        action: "add-row-before",
        title: "EDITOR.TableAddRowBefore",
        group: 3,
        cmd: addRowBefore
      }, {
        action: "delete-row",
        title: "EDITOR.TableDeleteRow",
        group: 3,
        cmd: deleteRow
      }, {
        action: "merge-cells",
        title: "EDITOR.TableMergeCells",
        group: 4,
        cmd: mergeCells
      }, {
        action: "split-cell",
        title: "EDITOR.TableSplitCell",
        group: 4,
        cmd: splitCell
      }]
    };

    Hooks.callAll("getProseMirrorMenuDropDowns", this, menus);
    return menus;
  }

  /* -------------------------------------------- */

  /**
   * Configure the items for this menu.
   * @returns {ProseMirrorMenuItem[]}
   * @protected
   */
  _getMenuItems() {
    const scopes = this.constructor._MENU_ITEM_SCOPES;
    const items = [
      {
        action: "bullet-list",
        title: "EDITOR.BulletList",
        icon: '<i class="fa-solid fa-list-ul fa-fw"></i>',
        node: this.schema.nodes.bullet_list,
        scope: scopes.TEXT,
        cmd: () => this._toggleBlock(this.schema.nodes.bullet_list, wrapInList)
      },
      {
        action: "number-list",
        title: "EDITOR.NumberList",
        icon: '<i class="fa-solid fa-list-ol fa-fw"></i>',
        node: this.schema.nodes.ordered_list,
        scope: scopes.TEXT,
        cmd: () => this._toggleBlock(this.schema.nodes.ordered_list, wrapInList)
      },
      {
        action: "horizontal-rule",
        title: "EDITOR.HorizontalRule",
        icon: '<i class="fa-solid fa-horizontal-rule fa-fw"></i>',
        scope: scopes.TEXT,
        cmd: this.#insertHorizontalRule.bind(this)
      },
      {
        action: "image",
        title: "EDITOR.InsertImage",
        icon: '<i class="fa-solid fa-image fa-fw"></i>',
        scope: scopes.TEXT,
        node: this.schema.nodes.image,
        cmd: this._insertImagePrompt.bind(this)
      },
      {
        action: "link",
        title: "EDITOR.Link",
        icon: '<i class="fa-solid fa-link fa-fw"></i>',
        scope: scopes.TEXT,
        mark: this.schema.marks.link,
        cmd: this._insertLinkPrompt.bind(this)
      },
      {
        action: "clear-formatting",
        title: "EDITOR.ClearFormatting",
        icon: '<i class="fa-solid fa-text-slash fa-fw"></i>',
        scope: scopes.TEXT,
        cmd: this._clearFormatting.bind(this)
      },
      {
        action: "cancel-html",
        title: "EDITOR.DiscardHTML",
        icon: '<i class="fa-solid fa-xmark fa-fw"></i>',
        scope: scopes.HTML,
        cmd: this.#clearSourceEditor.bind(this)
      }
    ];

    if ( this.view.state.plugins.some(p => p.spec.isHighlightMatchesPlugin) ) {
      items.push({
        action: "toggle-matches",
        title: "EDITOR.EnableHighlightDocumentMatches",
        icon: '<i class="fa-solid fa-wand-magic-sparkles fa-fw"></i>',
        scope: scopes.TEXT,
        cssClass: "toggle-matches",
        cmd: this._toggleMatches.bind(this),
        active: game.settings.get("core", "pmHighlightDocumentMatches")
      });
    }

    if ( this.options.onSave ) {
      items.push({
        action: "save",
        title: `EDITOR.${this.options.destroyOnSave ? "SaveAndClose" : "Save"}`,
        icon: `<i class="fa-solid fa-${this.options.destroyOnSave ? "floppy-disk-circle-arrow-right" : "save"} fa-fw"></i>`,
        scope: scopes.BOTH,
        cssClass: "right",
        cmd: this._handleSave.bind(this)
      });
    }

    items.push({
      action: "source-code",
      title: "EDITOR.SourceHTML",
      icon: '<i class="fa-solid fa-code fa-fw"></i>',
      scope: scopes.BOTH,
      cssClass: "source-code-edit right",
      cmd: this.#toggleSource.bind(this)
    });

    Hooks.callAll("getProseMirrorMenuItems", this, items);
    return items;
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the given menu item is currently active or not.
   * @param {ProseMirrorMenuItem} item  The menu item.
   * @returns {boolean}                 Whether the cursor or selection is in a state represented by the given menu
   *                                    item.
   * @protected
   */
  _isItemActive(item) {
    if ( item.action === "source-code" ) return !!this.#editingSource;
    if ( item.action === "toggle-matches" ) return game.settings.get("core", "pmHighlightDocumentMatches");
    if ( item.mark ) return this._isMarkActive(item);
    if ( item.node ) return this._isNodeActive(item);
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the given menu item representing a mark is active or not.
   * @param {ProseMirrorMenuItem} item  The menu item representing a MarkType.
   * @returns {boolean}                 Whether the cursor or selection is in a state represented by the given mark.
   * @protected
   */
  _isMarkActive(item) {
    const state = this.view.state;
    const {from, $from, to, empty} = state.selection;
    const markCompare = mark => {
      if ( mark.type !== item.mark ) return false;
      const attrs = foundry.utils.deepClone(mark.attrs);
      delete attrs._preserve;
      if ( item.attrs ) return foundry.utils.objectsEqual(attrs, item.attrs);
      return true;
    };
    if ( empty ) return $from.marks().some(markCompare);
    let active = false;
    state.doc.nodesBetween(from, to, node => {
      if ( node.marks.some(markCompare) ) active = true;
      return !active;
    });
    return active;
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the given menu item representing a node is active or not.
   * @param {ProseMirrorMenuItem} item  The menu item representing a NodeType.
   * @returns {boolean}                 Whether the cursor or selection is currently within a block of this menu item's
   *                                    node type.
   * @protected
   */
  _isNodeActive(item) {
    const state = this.view.state;
    const {$from, $to, empty} = state.selection;
    const sameParent = empty || $from.sameParent($to);
    // If the selection spans multiple nodes, give up on detecting whether we're in a given block.
    // TODO: Add more complex logic for detecting if all selected nodes belong to the same parent.
    if ( !sameParent ) return false;
    return (state.doc.nodeAt($from.pos)?.type === item.node) || $from.hasAncestor(item.node, item.attrs);
  }

  /* -------------------------------------------- */

  /**
   * Handle a button press.
   * @param {MouseEvent} event  The click event.
   * @protected
   */
  _onAction(event) {
    event.preventDefault();
    const action = event.currentTarget.dataset.action;
    let item;

    // Check dropdowns first
    this.dropdowns.forEach(d => d.forEachItem(i => {
      if ( i.action !== action ) return;
      item = i;
      return false;
    }));

    // Menu items
    if ( !item ) item = this.items.find(i => i.action === action);
    item?.cmd?.(this.view.state, this.view.dispatch, this.view);

    // Destroy the dropdown, if present, & refocus the editor.
    document.getElementById("prosemirror-dropdown")?.remove();
    this.view.focus();
  }

  /* -------------------------------------------- */

  /**
   * Wrap the editor view element and inject our template ready to be rendered into.
   * @protected
   */
  _wrapEditor() {
    const wrapper = document.createElement("div");
    const template = document.createElement("template");
    wrapper.classList.add("editor-container");
    template.setAttribute("id", this.id);
    this.view.dom.before(template);
    this.view.dom.replaceWith(wrapper);
    wrapper.appendChild(this.view.dom);
  }

  /* -------------------------------------------- */

  /**
   * Handle requests to save the editor contents
   * @protected
   */
  _handleSave() {
    if ( this.#editingSource ) this.#commitSourceEdits();
    return this.options.onSave?.();
  }

  /* -------------------------------------------- */

  /**
   * Global listeners for the drop-down menu.
   */
  static eventListeners() {
    document.addEventListener("pointerdown", event => {
      if ( !event.target.closest("#prosemirror-dropdown") ) {
        document.getElementById("prosemirror-dropdown")?.remove();
      }
    }, { passive: true, capture: true });
  }

  /* -------------------------------------------- */
  /*  Source Code Textarea Management             */
  /* -------------------------------------------- */

  /**
   * Handle a request to edit the source HTML directly.
   */
  #toggleSource() {
    if ( this.editingSource ) return this.#commitSourceEdits();
    this.#activateSourceEditor();
  }

  /* -------------------------------------------- */

  /**
   * Conclude editing the HTML source. Remove the editor and return its contents.
   * @returns {string} The HTML text contained within the CodeMirror editor before it was cleared
   */
  #clearSourceEditor() {
    const editor = this.view.dom.closest(".editor");
    const htmlEditor = editor.querySelector(":scope > code-mirror[language=html]");
    const html = htmlEditor.value;
    htmlEditor.remove();
    this.#editingSource = false;
    this.items.find(i => i.action === "source-code").active = false;
    this.render();
    return html;
  }

  /* -------------------------------------------- */

  /**
   * Create and activate the HTML source editor.
   */
  #activateSourceEditor() {
    const editor = this.view.dom.closest(".editor");
    const htmlEditor = document.createElement("code-mirror");
    Object.assign(htmlEditor, { language: "html", indent: 4, className: "source-editor", managed: true });
    htmlEditor.value = ProseMirror.dom.serializeString(this.view.state.doc.content, {spaces: 4});
    editor.appendChild(htmlEditor);
    htmlEditor
      .querySelector("[contenteditable]")
      .addEventListener("keydown", event => this.#handleSourceKeydown(event));
    this.#editingSource = true;
    this.items.find(i => i.action === "source-code").active = true;
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Commit changes from the source textarea to the view.
   */
  #commitSourceEdits() {
    const html = this.#clearSourceEditor();
    const newDoc = ProseMirror.dom.parseString(html);
    const selection = new ProseMirror.AllSelection(this.view.state.doc);
    this.view.dispatch(this.view.state.tr.setSelection(selection).replaceSelectionWith(newDoc));
  }

  /* -------------------------------------------- */

  /**
   * Handle keypresses while editing editor source.
   * @param {KeyboardEvent} event  The keyboard event.
   */
  #handleSourceKeydown(event) {
    if ( game.keyboard.isModifierActive("CONTROL") && (event.key === "s") ) {
      event.preventDefault();
      this._handleSave();
    }
  }

  /* -------------------------------------------- */

  /**
   * Display the insert image prompt.
   * @protected
   */
  async _insertImagePrompt() {
    const state = this.view.state;
    const { $from, empty } = state.selection;
    const image = this.schema.nodes.image;
    const data = { src: "", alt: "", width: "", height: "" };
    if ( !empty ) {
      const selected = state.doc.nodeAt($from.pos);
      Object.assign(data, selected?.attrs ?? {});
    }
    const dialog = await this._showDialog("image", "templates/journal/insert-image.html", { data });
    const form = dialog.querySelector("form");
    const src = form.elements.src;
    form.elements.save.addEventListener("click", () => {
      if ( !src.value ) return;
      this.view.dispatch(this.view.state.tr.replaceSelectionWith(image.create({
        src: src.value,
        alt: form.elements.alt.value,
        width: form.elements.width.value,
        height: form.elements.height.value
      })).scrollIntoView());
    });
  }

  /* -------------------------------------------- */

  /**
   * Display the insert link prompt.
   * @protected
   */
  async _insertLinkPrompt() {
    const state = this.view.state;
    const {$from, $to, $cursor} = state.selection;

    // Capture the selected text.
    const selection = state.selection.content().content;
    const data = {text: selection.textBetween(0, selection.size), href: "", title: ""};

    // Check if the user has placed the cursor within a single link, or has selected a single link.
    const links = [];
    state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
      if ( node.marks.some(m => m.type === this.schema.marks.link) ) links.push([node, pos]);
    });
    const existing = links.length === 1 && links[0];
    if ( existing ) {
      const [node] = existing;
      if ( $cursor ) data.text = node.text;
      // Pre-fill the dialog with the existing link's attributes.
      const link = node.marks.find(m => m.type === this.schema.marks.link);
      data.href = link.attrs.href;
      data.title = link.attrs.title;
    }

    const dialog = await this._showDialog("link", "templates/journal/insert-link.html", {data});
    const form = dialog.querySelector("form");
    form.elements.save.addEventListener("click", () => {
      const href = form.elements.href.value;
      const text = form.elements.text.value || href;
      if ( !href ) return;
      const link = this.schema.marks.link.create({href, title: form.elements.title.value});
      const tr = state.tr;

      // The user has placed the cursor within a link they wish to edit.
      if ( existing && $cursor ) {
        const [node, pos] = existing;
        const selection = TextSelection.create(state.doc, pos, pos + node.nodeSize);
        tr.setSelection(selection);
      }

      tr.addStoredMark(link).replaceSelectionWith(this.schema.text(text)).scrollIntoView();
      this.view.dispatch(tr);
    });
  }

  /* -------------------------------------------- */

  /**
   * Display the insert table prompt.
   * @protected
   */
  async _insertTablePrompt() {
    const dialog = await this._showDialog("insert-table", "templates/journal/insert-table.html");
    const form = dialog.querySelector("form");
    form.elements.save.addEventListener("click", () => {
      const rows = Number(form.elements.rows.value) || 1;
      const cols = Number(form.elements.cols.value) || 1;
      const html = `
        <table>
          ${Array.fromRange(rows).reduce(row => row + `
            <tr>${Array.fromRange(cols).reduce(col => col + "<td></td>", "")}</tr>
          `, "")}
        </table>
      `;
      const table = ProseMirror.dom.parseString(html, this.schema);
      this.view.dispatch(this.view.state.tr.replaceSelectionWith(table).scrollIntoView());
    });
  }

  /* -------------------------------------------- */

  /**
   * Create a dialog for a menu button.
   * @param {string} action                      The unique menu button action.
   * @param {string} template                    The dialog's template.
   * @param {object} [options]                   Additional options to configure the dialog's behaviour.
   * @param {object} [options.data={}]           Data to pass to the template.
   * @returns {HTMLDialogElement}
   * @protected
   */
  async _showDialog(action, template, {data={}}={}) {
    let button = document.getElementById("prosemirror-dropdown")?.querySelector(`[data-action="${action}"]`);
    button ??= this.view.dom.closest(".editor").querySelector(`[data-action="${action}"]`);
    button.classList.add("active");
    const rect = button.getBoundingClientRect();
    const dialog = document.createElement("dialog");
    dialog.classList.add("application", "menu-dialog", "prosemirror");
    const nearestThemed = this.view.dom.closest(".themed") ?? document.body;
    const [, theme] = nearestThemed.className.match(/(?:^|\s)(theme-\w+)/);
    if ( theme ) dialog.classList.add("themed", theme);
    dialog.innerHTML = await foundry.applications.handlebars.renderTemplate(template, data);
    document.body.appendChild(dialog);
    dialog.addEventListener("click", event => {
      if ( event.target.closest("form") ) return;
      button.classList.remove("active");
      dialog.remove();
    });
    const form = dialog.querySelector("form");
    form.classList.add("standard-form");
    Object.assign(form.style, { top: `${rect.top + 30}px`, left: `${rect.left - 200 + 15}px` });
    dialog.style.zIndex = ++foundry.applications.api.ApplicationV2._maxZ;
    form.elements.save?.addEventListener("click", () => {
      button.classList.remove("active");
      dialog.remove();
      this.view.focus();
    });
    dialog.open = true;
    return dialog;
  }

  /* -------------------------------------------- */

  /**
   * Clear any marks from the current selection.
   * @protected
   */
  _clearFormatting() {
    const state = this.view.state;
    const {empty, $from, $to} = state.selection;
    if ( empty ) return;
    const tr = this.view.state.tr;
    for ( const markType of Object.values(this.schema.marks) ) {
      if ( state.doc.rangeHasMark($from.pos, $to.pos, markType) ) tr.removeMark($from.pos, $to.pos, markType);
    }
    const range = $from.blockRange($to);
    const nodePositions = [];
    // Capture any nodes that are completely encompassed by the selection, or ones that begin and end exactly at the
    // selection boundaries (i.e., the user has selected all text inside the node).
    tr.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
      if ( node.isText ) return false;
      // Node is entirely contained within the selection.
      if ( (pos >= range.start) && (pos + node.nodeSize <= range.end) ) nodePositions.push(pos);
    });
    // Clear marks and attributes from all eligible nodes.
    nodePositions.forEach(pos => {
      const node = state.doc.nodeAt(pos);
      const attrs = {...node.attrs};
      for ( const [attr, spec] of Object.entries(node.type.spec.attrs) ) {
        if ( spec.formatting ) delete attrs[attr];
      }
      tr.setNodeMarkup(pos, null, attrs);
    });
    this.view.dispatch(tr);
  }

  /* -------------------------------------------- */

  /**
   * Toggle link recommendations
   * @protected
   */
  async _toggleMatches() {
    const enabled = game.settings.get("core", "pmHighlightDocumentMatches");
    await game.settings.set("core", "pmHighlightDocumentMatches", !enabled);
    this.items.find(i => i.action === "toggle-matches").active = !enabled;
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Inserts a horizontal rule at the cursor.
   */
  #insertHorizontalRule() {
    const hr = this.schema.nodes.horizontal_rule;
    this.view.dispatch(this.view.state.tr.replaceSelectionWith(hr.create()).scrollIntoView());
  }

  /* -------------------------------------------- */

  /**
   * Toggle a particular alignment for the given selection.
   * @param {string} alignment  The text alignment to toggle.
   */
  #toggleAlignment(alignment) {
    const state = this.view.state;
    const {$from, $to} = state.selection;
    const range = $from.blockRange($to);
    if ( !range ) return;
    const {paragraph, image} = this.schema.nodes;
    const positions = [];
    // The range positions are absolute, so we need to convert them to be relative to the parent node.
    const blockStart = range.parent.eq(state.doc) ? 0 : range.start;
    // Calculate the positions of all the paragraph nodes that are direct descendents of the blockRange parent node.
    range.parent.nodesBetween(range.start - blockStart, range.end - blockStart, (node, pos) => {
      if ( ![paragraph, image].includes(node.type) ) return false;
      positions.push({pos: blockStart + pos, attrs: node.attrs});
    });
    const tr = state.tr;
    positions.forEach(({pos, attrs}) => {
      const node = state.doc.nodeAt(pos);
      tr.setNodeMarkup(pos, null, {
        ...attrs, alignment: attrs.alignment === alignment ? node.type.attrs.alignment.default : alignment
      });
    });
    this.view.dispatch(tr);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the given selection by wrapping it in a given block or lifting it out of one.
   * @param {NodeType} node                    The type of node being interacted with.
   * @param {MenuToggleBlockWrapCommand} wrap  The wrap command specific to the given node.
   * @param {object} [options]                 Additional options to configure behaviour.
   * @param {object} [options.attrs]           Attributes for the node.
   * @protected
   */
  _toggleBlock(node, wrap, {attrs=null}={}) {
    const state = this.view.state;
    const {$from, $to} = state.selection;
    const range = $from.blockRange($to);
    if ( !range ) return;
    const inBlock = $from.hasAncestor(node);
    if ( inBlock ) {
      // FIXME: This will lift out of the closest block rather than only the given one, and doesn't work on multiple
      // list elements.
      const target = liftTarget(range);
      if ( target != null ) this.view.dispatch(state.tr.lift(range, target));
    } else autoJoin(wrap(node, attrs), [node.name])(state, this.view.dispatch);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the given selection by wrapping it in a given text block, or reverting to a paragraph block.
   * @param {NodeType} node           The type of node being interacted with.
   * @param {object} [options]        Additional options to configure behaviour.
   * @param {object} [options.attrs]  Attributes for the node.
   * @protected
   */
  _toggleTextBlock(node, {attrs=null}={}) {
    const state = this.view.state;
    const {$from, $to} = state.selection;
    const range = $from.blockRange($to);
    if ( !range ) return;
    const inBlock = $from.hasAncestor(node, attrs);
    if ( inBlock ) node = this.schema.nodes.paragraph;
    this.view.dispatch(state.tr.setBlockType(range.start, range.end, node, attrs));
  }
}

/**
 * Determine whether a given position has an ancestor node of the given type.
 * @param {NodeType} other  The other node type.
 * @param {object} [attrs]  An object of attributes that must also match, if provided.
 * @returns {boolean}
 */
ResolvedPos.prototype.hasAncestor = function(other, attrs) {
  if ( !this.depth ) return false;
  for ( let i = this.depth; i > 0; i-- ) { // Depth 0 is the root document, so we don't need to test that.
    const node = this.node(i);
    if ( node.type === other ) {
      const nodeAttrs = foundry.utils.deepClone(node.attrs);
      delete nodeAttrs._preserve; // Do not include our internal attributes in the comparison.
      if ( attrs ) return foundry.utils.objectsEqual(nodeAttrs, attrs);
      return true;
    }
  }
  return false;
};

const paragraph = {
  attrs: {alignment: {default: "left", formatting: true}},
  managed: {styles: ["text-align"]},
  content: "inline*",
  group: "block",
  parseDOM: [{tag: "p", getAttrs: el => ({alignment: el.style.textAlign || "left"})}],
  toDOM: node => {
    const {alignment} = node.attrs;
    if ( alignment === "left" ) return ["p", 0];
    return ["p", {style: `text-align: ${alignment};`}, 0];
  }
};

/* -------------------------------------------- */

const blockquote = {
  content: "block+",
  group: "block",
  defining: true,
  parseDOM: [{tag: "blockquote"}],
  toDOM: () => ["blockquote", 0]
};

/* -------------------------------------------- */

const hr = {
  group: "block",
  parseDOM: [{tag: "hr"}],
  toDOM: () => ["hr"]
};

/* -------------------------------------------- */

const heading = {
  attrs: {level: {default: 1}},
  content: "inline*",
  group: "block",
  defining: true,
  parseDOM: [
    {tag: "h1", attrs: {level: 1}},
    {tag: "h2", attrs: {level: 2}},
    {tag: "h3", attrs: {level: 3}},
    {tag: "h4", attrs: {level: 4}},
    {tag: "h5", attrs: {level: 5}},
    {tag: "h6", attrs: {level: 6}}
  ],
  toDOM: node => [`h${node.attrs.level}`, 0]
};

/* -------------------------------------------- */

const pre = {
  content: "text*",
  marks: "",
  group: "block",
  code: true,
  defining: true,
  parseDOM: [{tag: "pre", preserveWhitespace: "full"}],
  toDOM: () => ["pre", ["code", 0]]
};

/* -------------------------------------------- */

const br = {
  inline: true,
  group: "inline",
  selectable: false,
  parseDOM: [{tag: "br"}],
  toDOM: () => ["br"]
};

// A list of tag names that are considered allowable inside a node that only supports inline content.
const INLINE_TAGS = new Set(["A", "EM", "I", "STRONG", "B", "CODE", "U", "S", "DEL", "SUP", "SUB", "SPAN"]);

/**
 * Determine if an HTML element contains purely inline content, i.e. only text nodes and 'mark' elements.
 * @param {HTMLElement} element  The element.
 * @returns {boolean}
 */
function onlyInlineContent(element) {
  for ( const child of element.children ) {
    if ( !INLINE_TAGS.has(child.tagName) ) return false;
  }
  return true;
}

/* -------------------------------------------- */

/**
 * Determine if an HTML element is empty.
 * @param {HTMLElement} element  The element.
 * @returns {boolean}
 */
function isElementEmpty(element) {
  return !element.childNodes.length;
}

/* -------------------------------------------- */

/**
 * Convert an element's style attribute string into an object.
 * @param {string} str  The style string.
 * @returns {object}
 */
function stylesFromString(str) {
  return Object.fromEntries(str.split(/;\s*/g).map(prop => prop.split(/:\s*/)));
}

/* -------------------------------------------- */

/**
 * Merge two style attribute strings.
 * @param {string} a  The first style string.
 * @param {string} b  The second style string.
 * @returns {string}
 */
function mergeStyle(a, b) {
  const allStyles = mergeObject(stylesFromString(a), stylesFromString(b));
  return Object.entries(allStyles).map(([k, v]) => v ? `${k}: ${v}` : null).filterJoin("; ");
}

/* -------------------------------------------- */

/**
 * Convert an element's class attribute string into an array of class names.
 * @param {string} str  The class string.
 * @returns {string[]}
 */
function classesFromString(str) {
  return str.split(/\s+/g);
}

/* -------------------------------------------- */

/**
 * Merge two class attribute strings.
 * @param {string} a  The first class string.
 * @param {string} b  The second class string.
 * @returns {string}
 */
function mergeClass(a, b) {
  const allClasses = classesFromString(a).concat(classesFromString(b));
  return Array.from(new Set(allClasses)).join(" ");
}

const ol = {
  content: "(list_item | list_item_text)+",
  managed: {attributes: ["start"]},
  group: "block",
  attrs: {order: {default: 1}},
  parseDOM: [{tag: "ol", getAttrs: el => ({order: el.hasAttribute("start") ? Number(el.start) : 1})}],
  toDOM: node => node.attrs.order === 1 ? ["ol", 0] : ["ol", {start: node.attrs.order}, 0]
};

/* -------------------------------------------- */

const ul = {
  content: "(list_item | list_item_text)+",
  group: "block",
  parseDOM: [{tag: "ul"}],
  toDOM: () => ["ul", 0]
};

/* -------------------------------------------- */

/**
 * ProseMirror enforces a stricter subset of HTML where block and inline content cannot be mixed. For example, the
 * following is valid HTML:
 * <ul>
 *   <li>
 *     The first list item.
 *     <ul>
 *       <li>An embedded list.</li>
 *     </ul>
 *   </li>
 * </ul>
 *
 * But, since the contents of the <li> would mix inline content (the text), with block content (the inner <ul>), the
 * schema is defined to only allow block content, and would transform the items to look like this:
 * <ul>
 *   <li>
 *     <p>The first list item.</p>
 *     <ul>
 *       <li><p>An embedded list.</p></li>
 *     </ul>
 *   </li>
 * </ul>
 *
 * We can address this by hooking into the DOM parsing and 'tagging' the extra paragraph elements inserted this way so
 * that when the contents are serialized again, they can be removed. This is left as a TODO for now.
 */

// In order to preserve existing HTML we define two types of list nodes. One that contains block content, and one that
// contains text content. We default to block content if the element is empty, in order to make integration with the
// wrapping and lifting helpers simpler.
const li = {
  content: "paragraph block*",
  defining: true,
  parseDOM: [{tag: "li", getAttrs: el => {
      // If this contains only inline content and no other elements, do not use this node type.
      if ( !isElementEmpty(el) && onlyInlineContent(el) ) return false;
    }}],
  toDOM: () => ["li", 0]
};

/* -------------------------------------------- */

const liText = {
  content: "text*",
  defining: true,
  parseDOM: [{tag: "li", getAttrs: el => {
      // If this contains any non-inline elements, do not use this node type.
      if ( isElementEmpty(el) || !onlyInlineContent(el) ) return false;
    }}],
  toDOM: () => ["li", 0]
};

const CELL_ATTRS = {
  colspan: {default: 1},
  rowspan: {default: 1},
  colwidth: {default: null}
};

const MANAGED_CELL_ATTRS = {
  attributes: ["colspan", "rowspan", "data-colwidth"]
};

// If any of these elements are part of a table, consider it a 'complex' table and do not attempt to make it editable.
const COMPLEX_TABLE_ELEMENTS = new Set(["CAPTION", "COLGROUP", "THEAD", "TFOOT"]);

/* -------------------------------------------- */
/*  Utilities                                   */
/* -------------------------------------------- */

/**
 * Determine node attributes for a table cell when parsing the DOM.
 * @param {HTMLTableCellElement} cell  The table cell DOM node.
 * @returns {{colspan: number, rowspan: number}}
 */
function getTableCellAttrs(cell) {
  const colspan = cell.getAttribute("colspan") || 1;
  const rowspan = cell.getAttribute("rowspan") || 1;
  return {
    colspan: Number(colspan),
    rowspan: Number(rowspan)
  };
}

/**
 * Determine the HTML attributes to be set on the table cell DOM node based on its ProseMirror node attributes.
 * @param {Node} node  The table cell ProseMirror node.
 * @returns {object}   An object of attribute name -> attribute value.
 */
function setTableCellAttrs(node) {
  const attrs = {};
  const {colspan, rowspan} = node.attrs;
  if ( colspan !== 1 ) attrs.colspan = colspan;
  if ( rowspan !== 1 ) attrs.rowspan = rowspan;
  return attrs;
}

/**
 * Whether this element exists as part of a 'complex' table.
 * @param {HTMLElement} el  The element to test.
 * @returns {boolean|void}
 */
function inComplexTable(el) {
  const table = el.closest("table");
  if ( !table ) return;
  return Array.from(table.children).some(child => COMPLEX_TABLE_ELEMENTS.has(child.tagName));
}

/* -------------------------------------------- */
/*  Built-in Tables                             */
/* -------------------------------------------- */

const builtInTableNodes = tableNodes({
  tableGroup: "block",
  cellContent: "block+"
});

/* -------------------------------------------- */
/*  'Complex' Tables                            */
/* -------------------------------------------- */

const tableComplex = {
  content: "(caption | caption_block)? colgroup? thead? tbody tfoot?",
  isolating: true,
  group: "block",
  parseDOM: [{tag: "table", getAttrs: el => {
      if ( inComplexTable(el) === false ) return false;
    }}],
  toDOM: () => ["table", 0]
};

/* -------------------------------------------- */

const colgroup = {
  content: "col*",
  isolating: true,
  parseDOM: [{tag: "colgroup"}],
  toDOM: () => ["colgroup", 0]
};

/* -------------------------------------------- */

const col = {
  tableRole: "col",
  parseDOM: [{tag: "col"}],
  toDOM: () => ["col"]
};

/* -------------------------------------------- */

const thead = {
  content: "table_row_complex+",
  isolating: true,
  parseDOM: [{tag: "thead"}],
  toDOM: () => ["thead", 0]
};

/* -------------------------------------------- */

const tbody = {
  content: "table_row_complex+",
  isolating: true,
  parseDOM: [{tag: "tbody", getAttrs: el => {
      if ( inComplexTable(el) === false ) return false;
    }}],
  toDOM: () => ["tbody", 0]
};

/* -------------------------------------------- */

const tfoot = {
  content: "table_row_complex+",
  isolating: true,
  parseDOM: [{tag: "tfoot"}],
  toDOM: () => ["tfoot", 0]
};

/* -------------------------------------------- */

const caption = {
  content: "text*",
  isolating: true,
  parseDOM: [{tag: "caption", getAttrs: el => {
      if ( !isElementEmpty(el) && !onlyInlineContent(el) ) return false;
    }}],
  toDOM: () => ["caption", 0]
};

/* -------------------------------------------- */

const captionBlock = {
  content: "block*",
  isolating: true,
  parseDOM: [{tag: "caption", getAttrs: el => {
      if ( isElementEmpty(el) || onlyInlineContent(el) ) return false;
    }}],
  toDOM: () => ["caption", 0]
};

/* -------------------------------------------- */

const tableRowComplex = {
  content: "(table_cell_complex | table_header_complex | table_cell_complex_block | table_header_complex_block)*",
  parseDOM: [{tag: "tr", getAttrs: el => {
      if ( inComplexTable(el) === false ) return false;
    }}],
  toDOM: () => ["tr", 0]
};

/* -------------------------------------------- */

const tableCellComplex = {
  content: "text*",
  attrs: CELL_ATTRS,
  managed: MANAGED_CELL_ATTRS,
  isolating: true,
  parseDOM: [{tag: "td", getAttrs: el => {
      if ( inComplexTable(el) === false ) return false;
      if ( !isElementEmpty(el) && !onlyInlineContent(el) ) return false;
      return getTableCellAttrs(el);
    }}],
  toDOM: node => ["td", setTableCellAttrs(node), 0]
};

/* -------------------------------------------- */

const tableCellComplexBlock = {
  content: "block*",
  attrs: CELL_ATTRS,
  managed: MANAGED_CELL_ATTRS,
  isolating: true,
  parseDOM: [{tag: "td", getAttrs: el => {
      if ( inComplexTable(el) === false ) return false;
      if ( isElementEmpty(el) || onlyInlineContent(el) ) return false;
      return getTableCellAttrs(el);
    }}],
  toDOM: node => ["td", setTableCellAttrs(node), 0]
};

/* -------------------------------------------- */

const tableHeaderComplex = {
  content: "text*",
  attrs: CELL_ATTRS,
  managed: MANAGED_CELL_ATTRS,
  isolating: true,
  parseDOM: [{tag: "th", getAttrs: el => {
      if ( inComplexTable(el) === false ) return false;
      if ( !isElementEmpty(el) && !onlyInlineContent(el) ) return false;
      return getTableCellAttrs(el);
    }}],
  toDOM: node => ["th", setTableCellAttrs(node), 0]
};

/* -------------------------------------------- */

const tableHeaderComplexBlock = {
  content: "block*",
  attrs: CELL_ATTRS,
  managed: MANAGED_CELL_ATTRS,
  isolating: true,
  parseDOM: [{tag: "th", getAttrs: el => {
      if ( inComplexTable(el) === false ) return false;
      if ( isElementEmpty(el) || onlyInlineContent(el) ) return false;
      return getTableCellAttrs(el);
    }}],
  toDOM: node => ["th", setTableCellAttrs(node), 0]
};

// These nodes are supported for HTML preservation purposes, but do not have robust editing support for now.

const details = {
  content: "(summary | summary_block) block*",
  group: "block",
  defining: true,
  parseDOM: [{tag: "details"}],
  toDOM: () => ["details", 0]
};

/* -------------------------------------------- */

const summary = {
  content: "text*",
  defining: true,
  parseDOM: [{tag: "summary", getAttrs: el => {
      // If this contains any non-inline elements, do not use this node type.
      if ( !isElementEmpty(el) && !onlyInlineContent(el) ) return false;
    }}],
  toDOM: () => ["summary", 0]
};

/* -------------------------------------------- */

const summaryBlock = {
  content: "block+",
  defining: true,
  parseDOM: [{tag: "summary", getAttrs: el => {
      // If this contains only text nodes and no elements, do not use this node type.
      if ( isElementEmpty(el) || onlyInlineContent(el) ) return false;
    }}],
  toDOM: () => ["summary", 0]
};

/* -------------------------------------------- */

const dl = {
  content: "(block|dt|dd)*",
  group: "block",
  defining: true,
  parseDOM: [{tag: "dl"}],
  toDOM: () => ["dl", 0]
};

/* -------------------------------------------- */

const dt = {
  content: "block+",
  defining: true,
  parseDOM: [{tag: "dt"}],
  toDOM: () => ["dt", 0]
};

/* -------------------------------------------- */

const dd = {
  content: "block+",
  defining: true,
  parseDOM: [{tag: "dd"}],
  toDOM: () => ["dd", 0]
};

/* -------------------------------------------- */

const fieldset = {
  content: "legend block*",
  group: "block",
  defining: true,
  parseDOM: [{tag: "fieldset"}],
  toDOM: () => ["fieldset", 0]
};

/* -------------------------------------------- */

const legend = {
  content: "inline+",
  defining: true,
  parseDOM: [{tag: "legend"}],
  toDOM: () => ["legend", 0]
};

/* -------------------------------------------- */

const picture = {
  content: "source* image",
  group: "block",
  defining: true,
  parseDOM: [{tag: "picture"}],
  toDOM: () => ["picture", 0]
};

/* -------------------------------------------- */

const audio = {
  content: "source* track*",
  group: "block",
  parseDOM: [{tag: "audio"}],
  toDOM: () => ["audio", 0]
};

/* -------------------------------------------- */

const video = {
  content: "source* track*",
  group: "block",
  parseDOM: [{tag: "video"}],
  toDOM: () => ["video", 0]
};

/* -------------------------------------------- */

const track = {
  parseDOM: [{tag: "track"}],
  toDOM: () => ["track"]
};

/* -------------------------------------------- */

const source = {
  parseDOM: [{tag: "source"}],
  toDOM: () => ["source"]
};

/* -------------------------------------------- */

const object$1 = {
  inline: true,
  group: "inline",
  parseDOM: [{tag: "object"}],
  toDOM: () => ["object"]
};

/* -------------------------------------------- */

const figure = {
  content: "(figcaption|block)*",
  group: "block",
  defining: true,
  parseDOM: [{tag: "figure"}],
  toDOM: () => ["figure", 0]
};

/* -------------------------------------------- */

const figcaption = {
  content: "inline+",
  defining: true,
  parseDOM: [{tag: "figcaption"}],
  toDOM: () => ["figcaption", 0]
};

/* -------------------------------------------- */

const small = {
  content: "paragraph block*",
  group: "block",
  defining: true,
  parseDOM: [{tag: "small"}],
  toDOM: () => ["small", 0]
};

/* -------------------------------------------- */

const ruby = {
  content: "(rp|rt|block)+",
  group: "block",
  defining: true,
  parseDOM: [{tag: "ruby"}],
  toDOM: () => ["ruby", 0]
};

/* -------------------------------------------- */

const rp = {
  content: "inline+",
  parseDOM: [{tag: "rp"}],
  toDOM: () => ["rp", 0]
};

/* -------------------------------------------- */

const rt = {
  content: "inline+",
  parseDOM: [{tag: "rt"}],
  toDOM: () => ["rt", 0]
};

/* -------------------------------------------- */

const iframe = {
  attrs: { sandbox: { default: "allow-scripts allow-forms" } },
  managed: { attributes: ["sandbox"] },
  group: "block",
  defining: true,
  parseDOM: [{tag: "iframe", getAttrs: el => {
    let sandbox = "allow-scripts allow-forms";
    const url = URL.parseSafe(el.src);
    const host = url?.hostname;
    const isTrusted = !el.srcdoc && CONST.TRUSTED_IFRAME_DOMAINS.some(d => (host === d) || host?.endsWith(`.${d}`));
    if ( isTrusted ) sandbox = null;
    return { sandbox };
  }}],
  toDOM: node => {
    const attrs = {};
    if ( node.attrs.sandbox ) attrs.sandbox = node.attrs.sandbox;
    return ["iframe", attrs];
  }
};

const em = {
  parseDOM: [{tag: "i"}, {tag: "em"}, {style: "font-style=italic"}],
  toDOM: () => ["em", 0]
};

/* -------------------------------------------- */

const strong = {
  parseDOM: [
    {tag: "strong"},
    {tag: "b"},
    {style: "font-weight", getAttrs: weight => /^(bold(er)?|[5-9]\d{2})$/.test(weight) && null}
  ],
  toDOM: () => ["strong", 0]
};

/* -------------------------------------------- */

const code = {
  parseDOM: [{tag: "code"}],
  toDOM: () => ["code", 0]
};

/* -------------------------------------------- */

const underline = {
  parseDOM: [{tag: "u"}, {style: "text-decoration=underline"}],
  toDOM: () => ["span", {style: "text-decoration: underline;"}, 0]
};

/* -------------------------------------------- */

const strikethrough = {
  parseDOM: [{tag: "s"}, {tag: "del"}, {style: "text-decoration=line-through"}],
  toDOM: () => ["s", 0]
};

/* -------------------------------------------- */

const superscript = {
  parseDOM: [{tag: "sup"}, {style: "vertical-align=super"}],
  toDOM: () => ["sup", 0]
};

/* -------------------------------------------- */

const subscript = {
  parseDOM: [{tag: "sub"}, {style: "vertical-align=sub"}],
  toDOM: () => ["sub", 0]
};

/* -------------------------------------------- */

const span = {
  parseDOM: [{tag: "span", getAttrs: el => {
      if ( el.style.fontFamily ) return false;
      return {};
    }}],
  toDOM: () => ["span", 0]
};

/* -------------------------------------------- */

const font = {
  attrs: {
    family: {}
  },
  parseDOM: [{tag: '*[style*="font-family:"]', getAttrs: el => ({ family: el.style.fontFamily })}],
  toDOM: node => ["span", {style: `font-family: ${node.attrs.family.replaceAll('"', "'")}`}]
};

/**
 * An abstract interface for a ProseMirror schema definition.
 * @abstract
 */
class SchemaDefinition {
  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The HTML tag selector this node is associated with.
   * @type {string}
   */
  static tag = "";

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Schema attributes.
   * @returns {Record<string, AttributeSpec>}
   * @abstract
   */
  static get attrs() {
    throw new Error("SchemaDefinition subclasses must implement the attrs getter.");
  }

  /* -------------------------------------------- */

  /**
   * Check if an HTML element is appropriate to represent as this node, and if so, extract its schema attributes.
   * @param {HTMLElement} el    The HTML element.
   * @returns {object|boolean}  Returns false if the HTML element is not appropriate for this schema node, otherwise
   *                            returns its attributes.
   * @abstract
   */
  static getAttrs(el) {
    throw new Error("SchemaDefinition subclasses must implement the getAttrs method.");
  }

  /* -------------------------------------------- */

  /**
   * Convert a ProseMirror Node back into an HTML element.
   * @param {Node} node  The ProseMirror node.
   * @returns {[string, any]}
   * @abstract
   */
  static toDOM(node) {
    throw new Error("SchemaDefinition subclasses must implement the toDOM method.");
  }

  /* -------------------------------------------- */

  /**
   * Create the ProseMirror schema specification.
   * @returns {NodeSpec|MarkSpec}
   * @abstract
   */
  static make() {
    return {
      attrs: this.attrs,
      parseDOM: [{tag: this.tag, getAttrs: this.getAttrs.bind(this)}],
      toDOM: this.toDOM.bind(this)
    };
  }
}

/**
 * A class responsible for encapsulating logic around image nodes in the ProseMirror schema.
 * @extends {SchemaDefinition}
 */
class ImageNode extends SchemaDefinition {
  /** @override */
  static tag = "img[src]";

  /* -------------------------------------------- */

  /** @override */
  static get attrs() {
    return {
      src: {},
      alt: {default: null},
      title: {default: null},
      width: {default: ""},
      height: {default: ""},
      alignment: {default: "", formatting: true}
    };
  }

  /* -------------------------------------------- */

  /** @override */
  static getAttrs(el) {
    const attrs = {
      src: el.getAttribute("src"),
      title: el.title,
      alt: el.alt
    };
    if ( el.classList.contains("centered") ) attrs.alignment = "center";
    else if ( el.style.float ) attrs.alignment = el.style.float;
    if ( el.hasAttribute("width") ) attrs.width = el.width;
    if ( el.hasAttribute("height") ) attrs.height = el.height;
    return attrs;
  }

  /* -------------------------------------------- */

  /** @override */
  static toDOM(node) {
    const {src, alt, title, width, height, alignment} = node.attrs;
    const attrs = {src};
    if ( alignment === "center" ) attrs.class = "centered";
    else if ( alignment ) attrs.style = `float: ${alignment};`;
    if ( alt ) attrs.alt = alt;
    if ( title ) attrs.title = title;
    if ( width ) attrs.width = width;
    if ( height ) attrs.height = height;
    return ["img", attrs];
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static make() {
    return mergeObject(super.make(), {
      managed: {styles: ["float"], classes: ["centered"]},
      group: "block",
      draggable: true
    });
  }
}

/**
 * A class responsible for encapsulating logic around link marks in the ProseMirror schema.
 * @extends {SchemaDefinition}
 */
class LinkMark extends SchemaDefinition {
  /** @override */
  static tag = "a";

  /* -------------------------------------------- */

  /** @override */
  static get attrs() {
    return {
      href: { default: null },
      title: { default: null }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  static getAttrs(el) {
    if ( (el.children.length === 1) && (el.children[0]?.tagName === "IMG") ) return false;
    return { href: el.href, title: el.title };
  }

  /* -------------------------------------------- */

  /** @override */
  static toDOM(node) {
    const { href, title } = node.attrs;
    const attrs = {};
    if ( href ) attrs.href = href;
    if ( title ) attrs.title = title;
    return ["a", attrs];
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static make() {
    return mergeObject(super.make(), {
      inclusive: false
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle clicks on link marks while editing.
   * @param {EditorView} view     The ProseMirror editor view.
   * @param {number} pos          The position in the ProseMirror document that the click occurred at.
   * @param {PointerEvent} event  The click event.
   * @param {Mark} mark           The Mark instance.
   * @returns {boolean|void}      Returns true to indicate the click was handled here and should not be propagated to
   *                              other plugins.
   */
  static onClick(view, pos, event, mark) {
    if ( (event.ctrlKey || event.metaKey) && mark.attrs.href ) window.open(mark.attrs.href, "_blank");
    return true;
  }
}

/**
 * A class responsible for encapsulating logic around image-link nodes in the ProseMirror schema.
 * @extends {SchemaDefinition}
 */
class ImageLinkNode extends SchemaDefinition {
  /** @override */
  static tag = "a";

  /* -------------------------------------------- */

  /** @override */
  static get attrs() {
    return mergeObject(ImageNode.attrs, LinkMark.attrs);
  }

  /* -------------------------------------------- */

  /** @override */
  static getAttrs(el) {
    if ( (el.children.length !== 1) || (el.children[0].tagName !== "IMG") ) return false;
    const attrs = ImageNode.getAttrs(el.children[0]);
    attrs.href = el.href;
    attrs.title = el.title;
    return attrs;
  }

  /* -------------------------------------------- */

  /** @override */
  static toDOM(node) {
    const spec = LinkMark.toDOM(node);
    spec.push(ImageNode.toDOM(node));
    return spec;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static make() {
    return mergeObject(super.make(), {
      group: "block",
      draggable: true,
      managed: { styles: ["float"], classes: ["centered"] }
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking on image links while editing.
   * @param {EditorView} view     The ProseMirror editor view.
   * @param {number} pos          The position in the ProseMirror document that the click occurred at.
   * @param {PointerEvent} event  The click event.
   * @param {Node} node           The Node instance.
   */
  static onClick(view, pos, event, node) {
    if ( (event.ctrlKey || event.metaKey) && node.attrs.href ) window.open(node.attrs.href, "_blank");
    // For some reason, calling event.preventDefault in this (mouseup) handler is not enough to cancel the default click
    // behaviour. It seems to be related to the outer anchor being set to contenteditable="false" by ProseMirror.
    // This workaround seems to prevent the click.
    const parent = event.target.parentElement;
    if ( (parent.tagName === "A") && !parent.isContentEditable ) parent.contentEditable = "true";
    return true;
  }
}

/**
 * A class responsible for encapsulating logic around secret nodes in the ProseMirror schema.
 * @extends {SchemaDefinition}
 */
class SecretNode extends SchemaDefinition {
  /** @override */
  static tag = "section";

  /* -------------------------------------------- */

  /** @override */
  static get attrs() {
    return {
      revealed: { default: false },
      id: {}
    };
  }

  /* -------------------------------------------- */

  /** @override */
  static getAttrs(el) {
    if ( !el.classList.contains("secret") ) return false;
    return {
      revealed: el.classList.contains("revealed"),
      id: el.id || `secret-${randomID()}`
    };
  }

  /* -------------------------------------------- */

  /** @override */
  static toDOM(node) {
    const attrs = {
      id: node.attrs.id,
      class: `secret${node.attrs.revealed ? " revealed" : ""}`
    };
    return ["section", attrs, 0];
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static make() {
    return mergeObject(super.make(), {
      content: "block+",
      group: "block",
      defining: true,
      managed: { attributes: ["id"], classes: ["revealed"] }
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle splitting a secret block in two, making sure the new block gets a unique ID.
   * @param {EditorState} state                   The ProseMirror editor state.
   * @param {(tr: Transaction) => void} dispatch  The editor dispatch function.
   */
  static split(state, dispatch) {
    const secret = state.schema.nodes.secret;
    const { $cursor } = state.selection;
    // Check we are actually on a blank line and not splitting text content.
    if ( !$cursor || $cursor.parent.content.size ) return false;
    // Check that we are actually in a secret block.
    if ( $cursor.node(-1).type !== secret ) return false;
    // Check that the block continues past the cursor.
    if ( $cursor.after() === $cursor.end(-1) ) return false;
    const before = $cursor.before(); // The previous line.
    // Ensure a new ID assigned to the new secret block.
    dispatch(state.tr.split(before, 1, [{type: secret, attrs: {id: `secret-${randomID()}`}}]));
    return true;
  }
}

/**
 * @import {AllowedAttributeConfiguration, ManagedAttributesSpec} from "./_types.mjs";
 */

/**
 * A class responsible for injecting attribute capture logic into the ProseMirror schema.
 */
class AttributeCapture {
  constructor() {
    this.#parseAllowedAttributesConfig(ALLOWED_HTML_ATTRIBUTES ?? {});
  }

  /* -------------------------------------------- */

  /**
   * The configuration of attributes that are allowed on HTML elements.
   * @type {Record<string, AllowedAttributeConfiguration>}
   */
  #allowedAttrs = {};

  /* -------------------------------------------- */

  /**
   * Augments the schema definition to allow each node or mark to capture all the attributes on an element and preserve
   * them when re-serialized back into the DOM.
   * @param {NodeSpec|MarkSpec} spec  The schema specification.
   */
  attributeCapture(spec) {
    if ( !spec.parseDOM ) return;
    if ( !spec.attrs ) spec.attrs = {};
    spec.attrs._preserve = { default: {}, formatting: true };
    spec.parseDOM.forEach(rule => {
      if ( rule.style ) return; // This doesn't work for style rules. We need a different solution there.
      const getAttrs = rule.getAttrs;
      rule.getAttrs = el => {
        let attrs = getAttrs?.(el);
        if ( attrs === false ) return false;
        if ( typeof attrs !== "object" ) attrs = {};
        mergeObject(attrs, rule.attrs);
        mergeObject(attrs, { _preserve: this.#captureAttributes(el, spec.managed) });
        return attrs;
      };
    });
    const toDOM = spec.toDOM;
    spec.toDOM = node => {
      const domSpec = toDOM(node);
      const attrs = domSpec[1];
      const preserved = node.attrs._preserve ?? {};
      if ( preserved.style ) preserved.style = preserved.style.replaceAll('"', "'");
      if ( getType(attrs) === "Object" ) {
        domSpec[1] = mergeObject(preserved, attrs, { inplace: false });
        if ( ("style" in preserved) && ("style" in attrs) ) domSpec[1].style = mergeStyle(preserved.style, attrs.style);
        if ( ("class" in preserved) && ("class" in attrs) ) domSpec[1].class = mergeClass(preserved.class, attrs.class);
      }
      else domSpec.splice(1, 0, { ...preserved });
      return domSpec;
    };
  }

  /* -------------------------------------------- */

  /**
   * Capture all allowable attributes present on an HTML element and store them in an object for preservation in the
   * schema.
   * @param {HTMLElement} el                 The element.
   * @param {ManagedAttributesSpec} managed  An object containing the attributes, styles, and classes that are managed
   *                                         by the ProseMirror node and should not be preserved.
   * @returns {Attrs}
   */
  #captureAttributes(el, managed={}) {
    const allowed = this.#allowedAttrs[el.tagName.toLowerCase()] ?? this.#allowedAttrs["*"];
    return Array.from(el.attributes).reduce((obj, attr) => {
      if ( attr.name.startsWith("data-pm-") ) return obj; // Ignore attributes managed by the ProseMirror editor itself.
      if ( managed.attributes?.includes(attr.name) ) return obj; // Ignore attributes managed by the node.
      // Ignore attributes that are not allowed.
      if ( !allowed.wildcards.some(prefix => attr.name.startsWith(prefix)) && !allowed.attrs.has(attr.name) ) {
        return obj;
      }
      if ( (attr.name === "class") && managed.classes?.length ) {
        obj.class = classesFromString(attr.value).filter(cls => !managed.classes.includes(cls)).join(" ");
        return obj;
      }
      if ( (attr.name === "style") && managed.styles?.length ) {
        const styles = stylesFromString(attr.value);
        managed.styles.forEach(style => delete styles[style]);
        obj.style = Object.entries(styles).map(([k, v]) => v ? `${k}: ${v}` : null).filterJoin("; ");
        return obj;
      }
      obj[attr.name] = attr.value;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Parse the configuration of allowed attributes into a more performant structure.
   * @param {Record<string, string[]>} config  The allowed attributes configuration.
   */
  #parseAllowedAttributesConfig(config) {
    const all = this.#allowedAttrs["*"] = this.#parseAllowedAttributes(config["*"] ?? []);
    for ( const [tag, attrs] of Object.entries(config ?? {}) ) {
      if ( tag === "*" ) continue;
      const allowed = this.#allowedAttrs[tag] = this.#parseAllowedAttributes(attrs);
      all.attrs.forEach(allowed.attrs.add, allowed.attrs);
      allowed.wildcards.push(...all.wildcards);
    }
  }

  /* -------------------------------------------- */

  /**
   * Parse an allowed attributes configuration into a more efficient structure.
   * @param {string[]} attrs  The list of allowed attributes.
   * @returns {AllowedAttributeConfiguration}
   */
  #parseAllowedAttributes(attrs) {
    const allowed = { wildcards: [], attrs: new Set() };
    for ( const attr of attrs ) {
      const wildcard = attr.indexOf("*");
      if ( wildcard < 0 ) allowed.attrs.add(attr);
      else allowed.wildcards.push(attr.substring(0, wildcard));
    }
    return allowed;
  }
}

const doc = {
  content: "block+"
};

const text = {
  group: "inline"
};

const secret = SecretNode.make();
const link = LinkMark.make();
const image = ImageNode.make();
const imageLink = ImageLinkNode.make();

const nodes = {
  // Core Nodes.
  doc, text, paragraph, blockquote, secret, horizontal_rule: hr, heading, code_block: pre, image_link: imageLink, image,
  hard_break: br,

  // Lists.
  ordered_list: ol, bullet_list: ul, list_item: li, list_item_text: liText,

  // Tables
  table_complex: tableComplex, tbody, thead, tfoot, caption, caption_block: captionBlock, colgroup, col, table_row_complex: tableRowComplex, table_cell_complex: tableCellComplex,
  table_header_complex: tableHeaderComplex, table_cell_complex_block: tableCellComplexBlock, table_header_complex_block: tableHeaderComplexBlock,
  ...builtInTableNodes,

  // Misc.
  details, summary, summary_block: summaryBlock, dl, dt, dd, fieldset, legend, picture, audio, video, track, source, object: object$1, figure,
  figcaption, small, ruby, rp, rt, iframe
};

const marks = {superscript, subscript, span, font, link, em, strong, underline, strikethrough, code};

// Auto-generated specifications for HTML preservation.
["header", "main", "section", "article", "aside", "nav", "footer", "div", "address"].forEach(tag => {
  nodes[tag] = {
    content: "block+",
    group: "block",
    defining: true,
    parseDOM: [{tag}],
    toDOM: () => [tag, 0]
  };
});

["abbr", "cite", "mark", "q", "time", "ins"].forEach(tag => {
  marks[tag] = {
    parseDOM: [{tag}],
    toDOM: () => [tag, 0]
  };
});

const all = Object.values(nodes).concat(Object.values(marks));
const capture = new AttributeCapture();
all.forEach(capture.attributeCapture.bind(capture));

const schema = new Schema({nodes, marks});

/* -------------------------------------------- */
/*  Handlers                                    */
/* -------------------------------------------- */

schema.nodes.list_item.split = splitListItem(schema.nodes.list_item);
schema.nodes.secret.split = SecretNode.split;
schema.marks.link.onClick = LinkMark.onClick;
schema.nodes.image_link.onClick = ImageLinkNode.onClick;

let DOMParser$1 = class DOMParser extends DOMParser$2 {
  /** @inheritdoc */
  parse(dom, options) {
    this.#unwrapImages(dom);
    return super.parse(dom, options);
  }

  /* -------------------------------------------- */

  /**
   * Unwrap any image tags that may have been wrapped in <p></p> tags in earlier iterations of the schema.
   * @param {HTMLElement} dom  The root HTML element to parse.
   */
  #unwrapImages(dom) {
    dom.querySelectorAll("img").forEach(img => {
      const paragraph = img.parentElement;
      if ( paragraph?.tagName !== "P" ) return;
      const parent = paragraph.parentElement || dom;
      parent.insertBefore(img, paragraph);
      // If the paragraph element was purely holding the image element and is now empty, we can remove it.
      if ( !paragraph.childNodes.length ) paragraph.remove();
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static fromSchema(schema) {
    if ( schema.cached.domParser ) return schema.cached.domParser;
    return schema.cached.domParser = new this(schema, this.schemaRules(schema));
  }
};

/**
 * @import {ProseMirrorMarkOutput, ProseMirrorNodeOutput} from "./_types.mjs";
 */

/**
 * A class responsible for serializing a ProseMirror document into a string of HTML.
 */
class StringSerializer {
  /**
   * @param {Record<string, ProseMirrorNodeOutput>} nodes  The node output specs.
   * @param {Record<string, ProseMirrorMarkOutput>} marks  The mark output specs.
   */
  constructor(nodes, marks) {
    this.#nodes = nodes;
    this.#marks = marks;
  }

  /* -------------------------------------------- */

  /**
   * The node output specs.
   * @type {Record<string, ProseMirrorNodeOutput>}
   */
  #nodes;

  /* -------------------------------------------- */

  /**
   * The mark output specs.
   * @type {Record<string, ProseMirrorMarkOutput>}
   */
  #marks;

  /* -------------------------------------------- */

  /**
   * Build a serializer for the given schema.
   * @param {Schema} schema  The ProseMirror schema.
   * @returns {StringSerializer}
   */
  static fromSchema(schema) {
    if ( schema.cached.stringSerializer ) return schema.cached.stringSerializer;
    return schema.cached.stringSerializer =
      new StringSerializer(DOMSerializer.nodesFromSchema(schema), DOMSerializer.marksFromSchema(schema));
  }

  /* -------------------------------------------- */

  /**
   * Create a StringNode from a ProseMirror DOMOutputSpec.
   * @param {DOMOutputSpec} spec                            The specification.
   * @param {boolean} inline                                Whether this is a block or inline node.
   * @returns {{outer: StringNode, [content]: StringNode}}  An object describing the outer node, and a reference to the
   *                                                        child node where content should be appended, if applicable.
   * @protected
   */
  _specToStringNode(spec, inline) {
    if ( typeof spec === "string" ) {
      // This is raw text content.
      const node = new StringNode();
      node.appendChild(spec);
      return {outer: node};
    }

    // Our schema only uses the array type of DOMOutputSpec so we don't need to support the other types here.
    // Array specs take the form of [tagName, ...tail], where the tail elements may be an object of attributes, another
    // array representing a child spec, or the value 0 (read 'hole').
    let attrs = {};
    let [tagName, ...tail] = spec;
    if ( getType(tail[0]) === "Object" ) attrs = tail.shift();
    const outer = new StringNode(tagName, attrs, inline);
    let content;

    for ( const innerSpec of tail ) {
      if ( innerSpec === 0 ) {
        if ( tail.length > 1 ) throw new RangeError("Content hole must be the only child of its parent node.");
        // The outer node and the node to append content to are the same node. The vast majority of our output specs
        // are like this.
        return {outer, content: outer};
      }

      // Otherwise, recursively build any inner specifications and update our content reference to point to wherever the
      // hole is found.
      const {outer: inner, content: innerContent} = this._specToStringNode(innerSpec, true);
      outer.appendChild(inner);
      if ( innerContent ) {
        if ( content ) throw new RangeError("Multiple content holes.");
        content = innerContent;
      }
    }
    return {outer, content};
  }

  /* -------------------------------------------- */

  /**
   * Serialize a ProseMirror fragment into an HTML string.
   * @param {Fragment} fragment    The ProseMirror fragment, a collection of ProseMirror nodes.
   * @param {StringNode} [target]  The target to append to. Not required for the top-level invocation.
   * @returns {StringNode}         A DOM tree representation as a StringNode.
   */
  serializeFragment(fragment, target) {
    target = target ?? new StringNode();
    const stack = [];
    let parent = target;
    fragment.forEach(node => {
      /**
       * Handling marks is a little complicated as ProseMirror stores them in a 'flat' structure, rather than a
       * nested structure that is more natural for HTML. For example, the following HTML:
       *   <em>Almost before <strong>we knew it</strong>, we had left the ground.</em>
       * is represented in ProseMirror's internal structure as:
       *   {marks: [ITALIC], content: "Almost before "}, {marks: [ITALIC, BOLD], content: "we knew it"},
       *   {marks: [ITALIC], content: ", we had left the ground"}
       * In order to translate from the latter back into the former, we maintain a stack. When we see a new mark, we
       * push it onto the stack so that content is appended to that mark. When the mark stops appearing in subsequent
       * nodes, we pop off the stack until we find a mark that does exist, and start appending to that one again.
       *
       * The order that marks appear in the node.marks array is guaranteed to be the order that they were declared in
       * the schema.
       */
      if ( stack.length || node.marks.length ) {
        // Walk along the stack to find a mark that is not already pending (i.e. we haven't seen it yet).
        let pos = 0;
        while ( (pos < stack.length) && (pos < node.marks.length) ) {
          const next = node.marks[pos];
          // If the mark does not span multiple nodes, we can serialize it now rather than waiting.
          if ( !next.eq(stack[pos].mark) || (next.type.spec.spanning === false) ) break;
          pos++;
        }

        // Pop off the stack to reach the position of our mark.
        while ( pos < stack.length ) parent = stack.pop().parent;

        // Add the marks from this point.
        for ( let i = pos; i < node.marks.length; i++ ) {
          const mark = node.marks[i];
          const {outer, content} = this._serializeMark(mark, node.isInline);
          stack.push({mark, parent});
          parent.appendChild(outer);
          parent = content ?? outer;
        }
      }

      // Finally append the content to whichever parent node we've arrived at.
      parent.appendChild(this._toStringNode(node));
    });
    return target;
  }

  /* -------------------------------------------- */

  /**
   * Convert a ProseMirror node representation to a StringNode.
   * @param {Node} node  The ProseMirror node.
   * @returns {StringNode}
   * @protected
   */
  _toStringNode(node) {
    const {outer, content} = this._specToStringNode(this.#nodes[node.type.name](node), node.type.inlineContent);
    if ( content ) {
      if ( node.isLeaf ) throw new RangeError("Content hole not allowed in a leaf node spec.");
      this.serializeFragment(node.content, content);
    }
    return outer;
  }

  /* -------------------------------------------- */

  /**
   * Convert a ProseMirror mark representation to a StringNode.
   * @param {Mark} mark       The ProseMirror mark.
   * @param {boolean} inline  Does the mark appear in an inline context?
   * @returns {{outer: StringNode, [content]: StringNode}}
   * @protected
   */
  _serializeMark(mark, inline) {
    return this._specToStringNode(this.#marks[mark.type.name](mark, inline), true);
  }
}

/**
 * A class that behaves like a lightweight DOM node, allowing children to be appended. Serializes to an HTML string.
 */
class StringNode {
  /**
   * @param {string} [tag]            The tag name. If none is provided, this node's children will not be wrapped in an
   *                                  outer tag.
   * @param {Record<string, string>} [attrs]  The tag attributes.
   * @param {boolean} [inline=false]  Whether the node appears inline or as a block.
   */
  constructor(tag, attrs={}, inline=true) {
    /**
     * The tag name.
     * @type {string}
     */
    Object.defineProperty(this, "tag", {value: tag, writable: false});

    /**
     * The tag attributes.
     * @type {Record<string, string>}
     */
    Object.defineProperty(this, "attrs", {value: attrs, writable: false});

    this.#inline = inline;
  }

  /* -------------------------------------------- */

  /**
   * A list of HTML void elements that do not have a closing tag.
   * @type {Set<string>}
   */
  static #VOID = new Set([
    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"
  ]);

  /* -------------------------------------------- */

  /**
   * A list of children. Either other StringNodes, or plain strings.
   * @type {Array<StringNode|string>}
   */
  #children = [];

  /* -------------------------------------------- */

  /**
   * @ignore
   */
  #inline;

  /**
   * Whether the node appears inline or as a block.
   */
  get inline() {
    if ( !this.tag || StringNode.#VOID.has(this.tag) || !this.#children.length ) return true;
    return this.#inline;
  }

  /* -------------------------------------------- */

  /**
   * Append a child to this string node.
   * @param {StringNode|string} child  The child node or string.
   * @throws If attempting to append a child to a void element.
   */
  appendChild(child) {
    if ( StringNode.#VOID.has(this.tag) ) throw new Error("Void elements cannot contain children.");
    this.#children.push(child);
  }

  /* -------------------------------------------- */

  /**
   * Serialize the StringNode structure into a single string.
   * @param {string|number} spaces  The number of spaces to use for indentation (maximum 10). If this value is a string,
   *                                that string is used as indentation instead (or the first 10 characters if it is
   *                                longer).
   */
  toString(spaces=0, {_depth=0, _inlineParent=false}={}) {
    let indent = "";
    const isRoot = _depth < 1;
    if ( !_inlineParent ) {
      if ( typeof spaces === "number" ) indent = " ".repeat(Math.min(10, spaces));
      else if ( typeof spaces === "string" ) indent = spaces.substring(0, 10);
      indent = indent.repeat(Math.max(0, _depth - 1));
    }
    const attrs = isEmpty(this.attrs) ? "" : " " + Object.entries(this.attrs).map(([k, v]) => `${k}="${foundry.utils.escapeHTML(v)}"`).join(" ");
    const open = this.tag ? `${indent}<${this.tag}${attrs}>` : "";
    if ( StringNode.#VOID.has(this.tag) ) return open;
    const close = this.tag ? `${this.inline && !isRoot ? "" : indent}</${this.tag}>` : "";
    const children = this.#children.map(c => {
      let content = c.toString(spaces, {_depth: _depth + 1, _inlineParent: this.inline});
      if ( !isRoot && !this.tag ) content = StringNode.#escapeHTML(content);
      return content;
    });
    const lineBreak = (this.inline && !isRoot) || !spaces ? "" : "\n";
    return [open, ...children, close].filterJoin(lineBreak);
  }

  /* -------------------------------------------- */

  /**
   * Escape HTML tags within string content.
   * @param {string} content  The string content.
   * @returns {string}
   */
  static #escapeHTML(content) {
    return content.replace(/[<>]/g, char => {
      switch ( char ) {
        case "<": return "&lt;";
        case ">": return "&gt;";
      }
      return char;
    });
  }
}

/**
 * @import {ProseMirrorSliceTransformer} from "./_types.mjs";
 */

/**
 * Use the DOM and ProseMirror's DOMParser to construct a ProseMirror document state from an HTML string. This cannot be
 * used server-side.
 * @param {string} htmlString  A string of HTML.
 * @param {Schema} [schema]    The ProseMirror schema to use instead of the default one.
 * @returns {Node}             The document node.
 */
function parseHTMLString(htmlString, schema$1) {
  const target = document.createElement("template");
  target.innerHTML = htmlString;
  return DOMParser$1.fromSchema(schema$1 ?? schema).parse(target.content);
}

/**
 * Use the StringSerializer to convert a ProseMirror document into an HTML string. This can be used server-side.
 * @param {Node} doc                        The ProseMirror document.
 * @param {object} [options]                Additional options to configure serialization behavior.
 * @param {Schema} [options.schema]         The ProseMirror schema to use instead of the default one.
 * @param {string|number} [options.spaces]  The number of spaces to use for indentation. See {@link StringNode#toString}
 *                                          for details.
 * @returns {string}
 */
function serializeHTMLString(doc, {schema: schema$1, spaces}={}) {
  schema$1 = schema$1 ?? schema;
  // If the only content is an empty <p></p> tag, return an empty string.
  if ( (doc.size < 3) && (doc.content[0].type === schema$1.nodes.paragraph) ) return "";
  return StringSerializer.fromSchema(schema$1).serializeFragment(doc.content).toString(spaces);
}

/**
 * Apply a transformation to some nodes in a slice, and return the new slice.
 * @param {Slice} slice           The slice to transform.
 * @param {ProseMirrorSliceTransformer} transformer  The transformation function.
 * @returns {Slice}               Either the original slice if no changes were made, or the newly-transformed slice.
 */
function transformSlice(slice, transformer) {
  const nodeTree = new Map();
  slice.content.nodesBetween(0, slice.content.size, (node, start, parent, index) => {
    nodeTree.set(node, { parent, index });
  });
  let newSlice;
  const replaceNode = (node, { parent, index }) => {
    // If there is a parent, make the replacement, then recurse up the tree to the root, creating new nodes as we go.
    if ( parent ) {
      const newContent = parent.content.replaceChild(index, node);
      const newParent = parent.copy(newContent);
      replaceNode(newParent, nodeTree.get(parent));
      return;
    }

    // Otherwise, handle replacing the root slice's content.
    const targetSlice = newSlice ?? slice;
    const fragment = targetSlice.content;
    const newFragment = fragment.replaceChild(index, node);
    newSlice = new Slice(newFragment, targetSlice.openStart, targetSlice.openEnd);
  };
  for ( const [node, treeInfo] of nodeTree.entries() ) {
    const newNode = transformer(node);
    if ( newNode ) replaceNode(newNode, treeInfo);
  }
  return newSlice ?? slice;
}

/**
 * @import {Slice} from "prosemirror-model";
 * @import {EditorView} from "prosemirror-view";
 */

/**
 * A class responsible for handle drag-and-drop and pasting of image content. Ensuring no base64 data is injected
 * directly into the journal content and it is instead uploaded to the user's data directory.
 * @extends {ProseMirrorPlugin}
 */
class ProseMirrorImagePlugin extends ProseMirrorPlugin {
  /**
   * @param {Schema} schema                    The ProseMirror schema.
   * @param {object} options                   Additional options to configure the plugin's behaviour.
   * @param {ClientDocument} options.document  A related Document to store extract base64 images for.
   */
  constructor(schema, {document}={}) {
    super(schema);

    if ( !document ) {
      throw new Error("The image drop and pasting plugin requires a reference to a related Document to function.");
    }

    /**
     * The related Document to store extracted base64 images for.
     * @type {ClientDocument}
     */
    Object.defineProperty(this, "document", {value: document, writable: false});
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static build(schema, options={}) {
    const plugin = new ProseMirrorImagePlugin(schema, options);
    return new Plugin({
      props: {
        handleDrop: plugin._onDrop.bind(plugin),
        handlePaste: plugin._onPaste.bind(plugin)
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle a drop onto the editor.
   * @param {EditorView} view  The ProseMirror editor view.
   * @param {DragEvent} event  The drop event.
   * @param {Slice} slice      A slice of editor content.
   * @param {boolean} moved    Whether the slice has been moved from a different part of the editor.
   * @protected
   */
  _onDrop(view, event, slice, moved) {
    // This is a drag-drop of internal editor content which we do not need to handle specially.
    if ( moved ) return;
    const pos = view.posAtCoords({left: event.clientX, top: event.clientY});
    if ( !pos ) return; // This was somehow dropped outside the editor content.

    if ( event.dataTransfer.types.some(t => t === "text/uri-list") ) {
      const uri = event.dataTransfer.getData("text/uri-list");
      if ( !isBase64Data(uri) ) return; // This is a direct URL hotlink which we can just embed without issue.
    }

    // Handle image drops.
    if ( event.dataTransfer.files.length ) {
      this._uploadImages(view, event.dataTransfer.files, pos.pos);
      return true;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle a paste into the editor.
   * @param {EditorView} view       The ProseMirror editor view.
   * @param {ClipboardEvent} event  The paste event.
   * @protected
   */
  _onPaste(view, event) {
    if ( event.clipboardData.files.length ) {
      this._uploadImages(view, event.clipboardData.files);
      return true;
    }
    const html = event.clipboardData.getData("text/html");
    if ( !html ) return; // We only care about handling rich content.
    const images = this._extractBase64Images(html);
    if ( !images.length ) return; // If there were no base64 images, defer to the default paste handler.
    this._replaceBase64Images(view, html, images);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Upload any image files encountered in the drop.
   * @param {EditorView} view  The ProseMirror editor view.
   * @param {FileList} files   The files to upload.
   * @param {number} [pos]     The position in the document to insert at. If not provided, the current selection will be
   *                           replaced instead.
   * @protected
   */
  async _uploadImages(view, files, pos) {
    const image = this.schema.nodes.image;
    const imageExtensions = Object.keys(CONST.IMAGE_FILE_EXTENSIONS);
    for ( const file of files ) {
      if ( !hasFileExtension(file.name, imageExtensions) ) continue;
      const src = await foundry.applications.ux.TextEditor.implementation._uploadImage(this.document.uuid, file);
      if ( !src ) continue;
      const node = image.create({src});
      if ( pos === undefined ) {
        pos = view.state.selection.from;
        view.dispatch(view.state.tr.replaceSelectionWith(node));
      } else view.dispatch(view.state.tr.insert(pos, node));
      pos += 2; // Advance the position past the just-inserted image so the next image is inserted below it.
    }
  }

  /* -------------------------------------------- */

  /**
   * Capture any base64-encoded images embedded in the rich text paste and upload them.
   * @param {EditorView} view                                      The ProseMirror editor view.
   * @param {string} html                                          The HTML data as a string.
   * @param {[full: string, mime: string, data: string][]} images  An array of extracted base64 image data.
   * @protected
   */
  async _replaceBase64Images(view, html, images) {
    const byMimetype = Object.fromEntries(Object.entries(CONST.IMAGE_FILE_EXTENSIONS).map(([k, v]) => [v, k]));
    let cleaned = html;
    for ( const [full, mime, data] of images ) {
      const file = this.constructor.base64ToFile(data, `pasted-image.${byMimetype[mime]}`, mime);
      const path = await foundry.applications.ux.TextEditor.implementation._uploadImage(this.document.uuid, file) ?? "";
      cleaned = cleaned.replace(full, path);
    }
    const doc = foundry.prosemirror.dom.parseString(cleaned);
    view.dispatch(view.state.tr.replaceSelectionWith(doc));
  }

  /* -------------------------------------------- */

  /**
   * Detect base64 image data embedded in an HTML string and extract it.
   * @param {string} html  The HTML data as a string.
   * @returns {[full: string, mime: string, data: string][]}
   * @protected
   */
  _extractBase64Images(html) {
    const images = Object.values(CONST.IMAGE_FILE_EXTENSIONS);
    const rgx = new RegExp(`data:(${images.join("|")});base64,([^"']+)`, "g");
    return [...html.matchAll(rgx)];
  }

  /* -------------------------------------------- */

  /**
   * Convert a base64 string into a File object.
   * @param {string} data      Base64 encoded data.
   * @param {string} filename  The filename.
   * @param {string} mimetype  The file's mimetype.
   * @returns {File}
   */
  static base64ToFile(data, filename, mimetype) {
    const bin = atob(data);
    let n = bin.length;
    const buf = new ArrayBuffer(n);
    const bytes = new Uint8Array(buf);
    while ( n-- ) bytes[n] = bin.charCodeAt(n);
    return new File([bytes], filename, {type: mimetype});
  }
}

/**
 * A simple plugin that records the dirty state of the editor.
 * @extends {ProseMirrorPlugin}
 */
class ProseMirrorDirtyPlugin extends ProseMirrorPlugin {
  /** @inheritdoc */
  static build(schema, options={}) {
    return new Plugin({
      state: {
        init() {
          return false;
        },
        apply() {
          return true; // If any transaction is applied to the state, we mark the editor as dirty.
        }
      }
    });
  }
}

/**
 * @import {EditorView} from "prosemirror-view";
 * @import {ProseMirrorContentLinkOptions} from "./_types.mjs";
 */

/**
 * A class responsible for handling the dropping of Documents onto the editor and creating content links for them.
 * @extends {ProseMirrorPlugin}
 */
class ProseMirrorContentLinkPlugin extends ProseMirrorPlugin {

  /**
   * @param {Schema} schema                          The ProseMirror schema.
   * @param {ProseMirrorContentLinkOptions} options  Additional options to configure the plugin's behaviour.
   */
  constructor(schema, {document, relativeLinks=false}={}) {
    super(schema);

    if ( relativeLinks && !document ) {
      throw new Error("A document must be provided in order to generate relative links.");
    }

    /**
     * The parent document housing this editor.
     * @type {ClientDocument}
     */
    Object.defineProperty(this, "document", {value: document, writable: false});

    /**
     * Whether to generate links relative to the parent document.
     * @type {boolean}
     */
    Object.defineProperty(this, "relativeLinks", {value: relativeLinks, writable: false});
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static build(schema, options={}) {
    const plugin = new ProseMirrorContentLinkPlugin(schema, options);
    return new Plugin({
      props: {
        handleDrop: plugin._onDrop.bind(plugin)
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle a drop onto the editor.
   * @param {EditorView} view  The ProseMirror editor view.
   * @param {DragEvent} event  The drop event.
   * @param {Slice} slice      A slice of editor content.
   * @param {boolean} moved    Whether the slice has been moved from a different part of the editor.
   * @protected
   */
  _onDrop(view, event, slice, moved) {
    if ( moved ) return;
    const pos = view.posAtCoords({left: event.clientX, top: event.clientY});
    const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);
    if ( !data.type ) return;
    const options = {};
    if ( this.relativeLinks ) options.relativeTo = this.document;
    const selection = view.state.selection;
    if ( !selection.empty ) {
      const content = selection.content().content;
      options.label = content.textBetween(0, content.size);
    }
    foundry.applications.ux.TextEditor.implementation.getContentLink(data, options).then(link => {
      if ( !link ) return;
      const tr = view.state.tr;
      if ( selection.empty ) tr.insertText(link, pos.pos);
      else tr.replaceSelectionWith(this.schema.text(link));
      view.dispatch(tr);
      // Focusing immediately only seems to work in Chrome. In Firefox we must yield execution before attempting to
      // focus, otherwise the cursor becomes invisible until the user manually unfocuses and refocuses.
      setTimeout(view.focus.bind(view), 0);
    });
    event.stopPropagation();
    return true;
  }
}

/**
 * @import {ProseMirrorContentLinkOptions} from "./_types.mjs";
 */

/**
 * A class responsible for handling the display of automated link recommendations when a user highlights text in a
 * ProseMirror editor.
 */
class PossibleMatchesTooltip {

  /**
   * @param {EditorView} view   The editor view.
   */
  constructor(view) {
    this.update(view, null);
  }

  /* -------------------------------------------- */

  /**
   * A reference to any existing tooltip that has been generated as part of a highlight match.
   * @type {HTMLElement}
   */
  tooltip;

  /* -------------------------------------------- */

  /**
   * Update the tooltip based on changes to the selected text.
   * @param {EditorView} view   The editor view.
   * @param {State} lastState   The previous state of the document.
   */
  async update(view, lastState) {
    if ( !game.settings.get("core", "pmHighlightDocumentMatches") ) return;
    const state = view.state;

    // Deactivate tooltip if the document/selection didn't change or is empty
    const stateUnchanged = lastState && (lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection));
    if ( stateUnchanged || state.selection.empty ) return this.#deactivateTooltip();

    const selection = state.selection.content().content;
    const highlighted = selection.textBetween(0, selection.size);

    // If the user selected fewer than a certain amount of characters appropriate for the language, we bail out.
    if ( highlighted.length < CONFIG.i18n.searchMinimumCharacterLength ) return this.#deactivateTooltip();

    // Look for any matches based on the contents of the selection
    let html = this.#findMatches(highlighted);

    // If html is an empty string bail out and deactivate tooltip
    if ( !html ) return this.#deactivateTooltip();

    // Enrich the matches HTML to get proper content links
    html = await foundry.applications.ux.TextEditor.implementation.enrichHTML(html);
    html = html.replace(/data-tooltip="[^"]+"/g, "");

    // In-screen coordinates
    const start = view.coordsAtPos(state.selection.from);

    // Position the tooltip. This needs to be very close to the user's cursor, otherwise the locked tooltip will be
    // immediately dismissed for being too far from the tooltip.
    // TODO: We use the selection endpoints here which works fine for single-line selections, but not multi-line.
    const left = `${(start.left + 3)}px`;
    const bottom = `${window.innerHeight - start.bottom + 25}px`;
    const position = {bottom, left};

    if ( this.tooltip ) this.#updateTooltip(html);
    else this.#createTooltip(position, html, {cssClass: "link-matches"});
  }

  /* -------------------------------------------- */

  /**
   * 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 {string} [options.cssClass]   An optional, space-separated list of CSS classes to apply to the activated
   *                                      tooltip.
   */
  #createTooltip(position, text, options) {
    this.tooltip = game.tooltip.createLockedTooltip(position, text, options);
  }

  /* -------------------------------------------- */

  /**
   * Update the tooltip with new HTML
   * @param {string} html      The HTML to be included in the tooltip
   */
  #updateTooltip(html) {
    this.tooltip.innerHTML = html;
  }

  /* -------------------------------------------- */

  /**
   * Dismiss all locked tooltips and set this tooltip to undefined.
   */
  #deactivateTooltip() {
    if ( !this.tooltip ) return;
    game.tooltip.dismissLockedTooltip(this.tooltip);
    this.tooltip = undefined;
  }

  /* -------------------------------------------- */

  /**
   * Find all Documents in the world/compendia with names that match the selection insensitive to case.
   * @param {string} text      A string which will be matched against document names
   * @returns {string}
   */
  #findMatches(text) {
    let html = "";
    const matches = game.documentIndex.lookup(text.trim(), {ownership: "OBSERVER"});
    for ( const [type, collection] of Object.entries(matches) ) {
      if ( collection.length === 0 ) continue;
      html += `<section><h4>${type}</h4><p>`;
      for ( const document of collection ) {
        html += document.entry?.link ? document.entry.link : `@UUID[${document.uuid}]{${foundry.utils.escapeHTML(document.entry.name)}}`;
      }
      html += "</p></section>";
    }
    return html;
  }
}

/**
 * A ProseMirrorPlugin wrapper around the PossibleMatchesTooltip class.
 */
class ProseMirrorHighlightMatchesPlugin extends ProseMirrorPlugin {
  /**
   * @param {Schema} schema                     The ProseMirror schema.
   * @param {ProseMirrorMenuOptions} [options]  Additional options to configure the plugin's behaviour.
   */
  constructor(schema, options={}) {
    super(schema);
    this.options = options;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static build(schema, options={}) {
    return new Plugin({
      view(editorView) {
        return new PossibleMatchesTooltip(editorView);
      },
      isHighlightMatchesPlugin: true
    });
  }
}

/**
 * A class responsible for managing click events inside a ProseMirror editor.
 * @extends {ProseMirrorPlugin}
 */
class ProseMirrorClickHandler extends ProseMirrorPlugin {
  /** @override */
  static build(schema, options={}) {
    const plugin = new ProseMirrorClickHandler(schema);
    return new Plugin({
      props: {
        handleClickOn: plugin._onClick.bind(plugin)
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle a click on the editor.
   * @param {EditorView} view     The ProseMirror editor view.
   * @param {number} pos          The position in the ProseMirror document that the click occurred at.
   * @param {Node} node           The current ProseMirror Node that the click has bubbled to.
   * @param {number} nodePos      The position of the click within this Node.
   * @param {PointerEvent} event  The click event.
   * @param {boolean} direct      Whether this Node is the one that was directly clicked on.
   * @returns {boolean|void}      A return value of true indicates the event has been handled, it will not propagate to
   *                              other plugins, and ProseMirror will call preventDefault on it.
   * @protected
   */
  _onClick(view, pos, node, nodePos, event, direct) {
    // If this is the inner-most click bubble, check marks for onClick handlers.
    if ( direct ) {
      const $pos = view.state.doc.resolve(pos);
      for ( const mark of $pos.marks() ) {
        if ( mark.type.onClick?.(view, pos, event, mark) === true ) return true;
      }
    }

    // Check the current Node for onClick handlers.
    return node.type.onClick?.(view, pos, event, node);
  }
}

/**
 * A class responsible for applying transformations to content pasted inside the editor.
 */
class ProseMirrorPasteTransformer extends ProseMirrorPlugin {
  /** @override */
  static build(schema, options={}) {
    const plugin = new ProseMirrorPasteTransformer(schema);
    return new Plugin({
      props: {
        transformPasted: plugin.#onPaste.bind(plugin)
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Transform content before it is injected into the ProseMirror document.
   * @param {Slice} slice      The content slice.
   * @param {EditorView} view  The ProseMirror editor view.
   * @returns {Slice}          The transformed content.
   */
  #onPaste(slice, view) {
    // Give pasted secret blocks new IDs.
    const secret = view.state.schema.nodes.secret;
    return transformSlice(slice, node => {
      if ( node.type === secret ) {
        return secret.create({ ...node.attrs, id: `secret-${randomID()}` }, node.content, node.marks);
      }
    });
  }
}

/**
 * @import {Document} from "@common/abstract/_module.mjs";
 * @import {EditorState} from "prosemirror-state";
 * @import {EditorView} from "prosemirror-view";
 * @import {MarkType, Node, NodeType} from "prosemirror-model";
 */

/**
 * @typedef ProseMirrorContentLinkOptions
 * @property {Document} [document]            The parent document housing this editor.
 * @property {boolean} [relativeLinks=false]  Whether to generate links relative to the parent document.
 */

/**
 * @typedef ProseMirrorMenuOptions
 * @property {Function} [onSave]        A function to call when the save button is pressed.
 * @property {boolean} [destroyOnSave]  Whether this editor instance is intended to be destroyed when saved.
 * @property {boolean} [compact]        Whether to display a more compact version of the menu.
 */

/**
 * @typedef ProseMirrorMenuItem
 * @property {string} action             A string identifier for this menu item.
 * @property {string} title              The description of the menu item.
 * @property {string} [class]            An optional class to apply to the menu item.
 * @property {string} [style]            An optional style to apply to the title text.
 * @property {string} [icon]             The menu item's icon HTML.
 * @property {MarkType} [mark]           The mark to apply to the selected text.
 * @property {NodeType} [node]           The node to wrap the selected text in.
 * @property {object} [attrs]            An object of attributes for the node or mark.
 * @property {number} [group]            Entries with the same group number will be grouped together in the drop-down.
 *                                       Lower-numbered groups appear higher in the list.
 * @property {number} [priority]         A numeric priority which determines whether this item is displayed as the
 *                                       dropdown title. Lower priority takes precedence.
 * @property {ProseMirrorCommand} [cmd]  The command to run when the menu item is clicked.
 * @property {boolean} [active=false]    Whether the current item is active under the given selection or cursor.
 */

/**
 * @typedef _ProseMirrorDropDownEntry
 * @property {ProseMirrorDropDownEntry[]} [children]  Any child entries.
 */

/**
 * @typedef {ProseMirrorMenuItem & _ProseMirrorDropDownEntry} ProseMirrorDropDownEntry
 */

/**
 * @typedef ProseMirrorDropDownConfig
 * @property {string} title                        The default title of the drop-down.
 * @property {string} cssClass                     The menu CSS class.
 * @property {string} [icon]                       An optional icon to use instead of a text label.
 * @property {ProseMirrorDropDownEntry[]} entries  The drop-down entries.
 */

/**
 * @callback ProseMirrorCommand
 * @param {EditorState} state               The current editor state.
 * @param {Function} dispatch               A function to dispatch a transaction.
 * @param {EditorView} view                 Escape-hatch for when the command needs to interact directly with the UI.
 * @returns {boolean}                       Whether the command has performed any action and consumed the event.
 */

/**
 * @callback MenuToggleBlockWrapCommand
 * @param {NodeType} node   The node to wrap the selection in.
 * @param {object} [attrs]  Attributes for the node.
 * @returns {ProseMirrorCommand}
 */

/**
 * @callback ProseMirrorNodeOutput
 * @param {Node} node        The ProseMirror node.
 * @returns {DOMOutputSpec}  The specification to build a DOM node for this ProseMirror node.
 */

/**
 * @callback ProseMirrorMarkOutput
 * @param {Mark} mark        The ProseMirror mark.
 * @param {boolean} inline   Is the mark appearing in an inline context?
 * @returns {DOMOutputSpec}  The specification to build a DOM node for this ProseMirror mark.
 */

/**
 * @callback ProseMirrorSliceTransformer
 * @param {Node} node    The candidate node.
 * @returns {Node|void}  A new node to replace the candidate node, or nothing if a replacement should not be made.
 */

var _types$d = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/** @module prosemirror */


const dom = {
  parser: DOMParser$1.fromSchema(schema),
  serializer: DOMSerializer.fromSchema(schema),
  parseString: parseHTMLString,
  serializeString: serializeHTMLString
};

const defaultPlugins = {
  inputRules: ProseMirrorInputRules.build(schema),
  keyMaps: ProseMirrorKeyMaps.build(schema),
  menu: ProseMirrorMenu.build(schema),
  isDirty: ProseMirrorDirtyPlugin.build(schema),
  clickHandler: ProseMirrorClickHandler.build(schema),
  pasteTransformer: ProseMirrorPasteTransformer.build(schema),
  baseKeyMap: keymap(baseKeymap),
  dropCursor: dropCursor(),
  gapCursor: gapCursor(),
  history: history$1(),
  columnResizing: columnResizing(),
  tables: tableEditing()
};

var _module$Q = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AllSelection: AllSelection,
  DOMParser: DOMParser$1,
  DOMSerializer: DOMSerializer,
  EditorState: EditorState,
  EditorView: EditorView,
  Plugin: Plugin,
  PluginKey: PluginKey,
  ProseMirrorClickHandler: ProseMirrorClickHandler,
  ProseMirrorContentLinkPlugin: ProseMirrorContentLinkPlugin,
  ProseMirrorDirtyPlugin: ProseMirrorDirtyPlugin,
  ProseMirrorDropDown: ProseMirrorDropDown,
  ProseMirrorHighlightMatchesPlugin: ProseMirrorHighlightMatchesPlugin,
  ProseMirrorImagePlugin: ProseMirrorImagePlugin,
  ProseMirrorInputRules: ProseMirrorInputRules,
  ProseMirrorKeyMaps: ProseMirrorKeyMaps,
  ProseMirrorMenu: ProseMirrorMenu,
  ProseMirrorPasteTransformer: ProseMirrorPasteTransformer,
  ProseMirrorPlugin: ProseMirrorPlugin,
  Schema: Schema,
  Step: Step,
  TextSelection: TextSelection,
  collab: index,
  commands: index$1,
  defaultPlugins: defaultPlugins,
  defaultSchema: schema,
  dom: dom,
  input: index$2,
  keymap: keymap,
  list: index$3,
  state: index$4,
  tables: index$5,
  transform: index$6,
  types: _types$d
});

/**
 * @import {ColorSource, Point, ElevatedPoint, DeepReadonly} from "../_types.mjs";
 * @import {GridDiagonalRule} from "../constants.mjs";
 */

/**
 * @typedef GridOffset2D
 * 2D offset coordinates of a grid space.
 * @property {number} i    The row coordinate (an integer)
 * @property {number} j    The column coordinate (an integer)
 */

/**
 * @typedef GridOffset3D
 * 3D offset coordinates of a grid space.
 * @property {number} i    The row coordinate (an integer)
 * @property {number} j    The column coordinate (an integer)
 * @property {number} k    The vertical coordinate (an integer)
 */

/**
 * @typedef HexagonalGridCube2D
 * 2D cube coordinates in a hexagonal grid. q + r + s = 0.
 * @property {number} q    The coordinate along the E-W (columns) or SW-NE (rows) axis.
 *                         Equal to the offset column coordinate if column orientation.
 * @property {number} r    The coordinate along the NE-SW (columns) or N-S (rows) axis.
 *                         Equal to the offset row coordinate if row orientation.
 * @property {number} s    The coordinate along the SE-NW axis.
 */

/**
 * @typedef HexagonalGridCube3D
 * 3D cube coordinates in a hexagonal grid. q + r + s = 0.
 * @property {number} q    The coordinate along the E-W (columns) or SW-NE (rows) axis.
 *                         Equal to the offset column coordinate if column orientation.
 * @property {number} r    The coordinate along the NE-SW (columns) or N-S (rows) axis.
 *                         Equal to the offset row coordinate if row orientation.
 * @property {number} s    The coordinate along the SE-NW axis.
 * @property {number} k    The vertical coordinate.
 */

/**
 * @typedef {GridOffset2D|Point} GridCoordinates2D
 * A 2D offset of a grid space or a 2D point with pixel coordinates.
 */

/**
 * @typedef {GridOffset3D|ElevatedPoint} GridCoordinates3D
 * A 3D offset of a grid space or an elevated point.
 */

/**
 * @typedef {GridCoordinates2D|HexagonalGridCube2D} HexagonalGridCoordinates2D
 * 2D hexagonal cube coordinates, a 2D offset of a grid space, or a 2D point with pixel coordinates.
 */

/**
 * @typedef {GridCoordinates3D|HexagonalGridCube3D} HexagonalGridCoordinates3D
 * 3D hexagonal cube coordinates, a 3D offset of a grid space, or a 3D point with pixel coordinates.
 */

/**
 * @typedef GridSnappingBehavior
 * A snapping behavior is defined by the snapping mode at the given resolution of the grid.
 * @property {number} mode              The snapping mode (a union of {@link CONST.GRID_SNAPPING_MODES}).
 * @property {number} [resolution=1]    The resolution (a positive integer). Default: `1`.
 */

/**
 * @typedef GridMeasurePathWaypointData2D
 * @property {boolean} [teleport=false]                       Teleport to this waypoint? Default: `false`.
 * @property {boolean} [measure=true]                         Measure of the segment from the previous to
 *                                                            this waypoint? The distance, cost, spaces, diagonals,
 *                                                            and Euclidean length of a segment that is not measured are
 *                                                            always 0. Default: `true`.
 * @property {number|GridMeasurePathCostFunction2D} [cost]    A predetermined cost (nonnegative) or cost function
 *                                                            to be used instead of `options.cost`.
 */

/**
 * @typedef GridMeasurePathWaypointData3D
 * @property {boolean} [teleport=false]                       Teleport to this waypoint? Default: `false`.
 * @property {boolean} [measure=true]                         Measure of the segment from the previous to
 *                                                            this waypoint? The distance, cost, spaces, diagonals,
 *                                                            and Euclidean length of a segment that is not measured are
 *                                                            always 0. Default: `true`.
 * @property {number|GridMeasurePathCostFunction3D} [cost]    A predetermined cost (nonnegative) or cost function
 *                                                            to be used instead of `options.cost`.
 */

/**
 * @typedef GridMeasurePathResultWaypoint
 * A waypoint of {@link foundry.grid.types.GridMeasurePathResult}.
 * @property {GridMeasurePathResultSegment|null} backward  The segment from the previous waypoint to this waypoint.
 * @property {GridMeasurePathResultSegment|null} forward   The segment from this waypoint to the next waypoint.
 * @property {number} distance   The total distance travelled along the path up to this waypoint.
 * @property {number} cost       The total cost of the direct path ({@link foundry.grid.BaseGrid#getDirectPath}) up to
 *                               this waypoint.
 * @property {number} spaces     The total number of spaces moved along a direct path up to this waypoint.
 * @property {number} diagonals  The total number of diagonals moved along a direct path up to this waypoint.
 * @property {number} euclidean  The total Euclidean length of the straight line path up to this waypoint.
 */

/**
 * @typedef GridMeasurePathResultSegment
 * A segment of {@link foundry.grid.types.GridMeasurePathResult}.
 * @property {GridMeasurePathResultWaypoint} from  The waypoint that this segment starts from.
 * @property {GridMeasurePathResultWaypoint} to    The waypoint that this segment goes to.
 * @property {number} distance   The distance travelled in grid units along this segment.
 * @property {number} cost       The cost of the direct path ({@link foundry.grid.BaseGrid#getDirectPath}) between the
 *                               two waypoints.
 * @property {number} spaces     The number of spaces moved along this segment.
 * @property {number} diagonals  The number of diagonals moved along this segment.
 * @property {number} euclidean  The Euclidean length of the straight line segment between the two waypoints.
 */

/**
 * @typedef GridMeasurePathResult
 * A result of {@link foundry.grid.BaseGrid#measurePath}.
 * @property {GridMeasurePathResultWaypoint[]} waypoints  The measurements at each waypoint.
 * @property {GridMeasurePathResultSegment[]} segments    The measurements at each segment.
 * @property {number} distance   The total distance travelled along the path through all waypoints.
 * @property {number} cost       The total cost of the direct path ({@link foundry.grid.BaseGrid#getDirectPath})
 *                               through all waypoints.
 * @property {number} spaces     The total number of spaces moved along a direct path through all waypoints.
 *                               Moving from a grid space to any of its neighbors counts as 1 step.
 *                               Always 0 in gridless grids.
 * @property {number} diagonals  The total number of diagonals moved along a direct path through all waypoints.
 * @property {number} euclidean  The total Euclidean length of the straight line path through all waypoints.
 */

/**
 * @template [SegmentData={}]
 * @callback GridMeasurePathCostFunction2D
 * A function that returns the cost for a given move between grid spaces in 2D.
 * In square and hexagonal grids the grid spaces are always adjacent unless teleported.
 * The function is never called with the same offsets.
 * @param {Readonly<GridOffset2D>} from          The offset that is moved from
 * @param {Readonly<GridOffset2D>} to            The offset that is moved to
 * @param {number} distance                      The distance between the grid spaces
 * @param {DeepReadonly<SegmentData>} segment    The properties of the segment
 * @returns {number}                             The cost of the move between the grid spaces (nonnegative)
 */

/**
 * @template [SegmentData={}]
 * @callback GridMeasurePathCostFunction3D
 * A function that returns the cost for a given move between grid spaces in 3D.
 * In square and hexagonal grids the grid spaces are always adjacent unless teleported.
 * The function is never called with the same offsets.
 * @param {Readonly<GridOffset3D>} from          The offset that is moved from
 * @param {Readonly<GridOffset3D>} to            The offset that is moved to
 * @param {number} distance                      The distance between the grid spaces
 * @param {DeepReadonly<SegmentData>} segment    The properties of the segment
 * @returns {number}                             The cost of the move between the grid spaces (nonnegative)
 */

/**
 * @typedef GridConfiguration
 * @property {number} size                  The size of a grid space in pixels (a positive number).
 * @property {number} [distance=1]          The distance of a grid space in units (a positive number). Default: `1`.
 * @property {string} [units=""]            The units of measurement. Default: `""`.
 * @property {string} [style="solidLines"]  The style of the grid. Default: `"solidLines"`.
 * @property {ColorSource} [color=0]        The color of the grid. Default: `0x000000`.
 * @property {number} [alpha=1]             The alpha of the grid. Default: `1`.
 * @property {number} [thickness=1]         The line thickness of the grid. Default: `1`.
 */

/**
 * @typedef SquareGridConfiguration
 * @property {number} size                  The size of a grid space in pixels (a positive number).
 * @property {number} [distance=1]          The distance of a grid space in units (a positive number). Default: `1`.
 * @property {string} [units=""]            The units of measurement. Default: `""`.
 * @property {string} [style="solidLines"]  The style of the grid. Default: `"solidLines"`.
 * @property {ColorSource} [color=0]        The color of the grid. Default: `0x000000`.
 * @property {number} [alpha=1]             The alpha of the grid. Default: `1`.
 * @property {number} [thickness=1]         The line thickness of the grid. Default: `1`.
 * @property {GridDiagonalRule} [diagonals=0] The rule for diagonal measurement (see {@link CONST.GRID_DIAGONALS}).
 *                                            Default: `CONST.GRID_DIAGONALS.EQUIDISTANT`.
 */

/**
 * @typedef HexagonalGridConfiguration
 * @property {number} size                  The size of a grid space in pixels (a positive number).
 * @property {number} [distance=1]          The distance of a grid space in units (a positive number). Default: `1`.
 * @property {string} [units=""]            The units of measurement. Default: `""`.
 * @property {string} [style="solidLines"]  The style of the grid. Default: `"solidLines"`.
 * @property {ColorSource} [color=0]        The color of the grid. Default: `0x000000`.
 * @property {number} [alpha=1]             The alpha of the grid. Default: `1`.
 * @property {number} [thickness=1]         The line thickness of the grid. Default: `1`.
 * @property {boolean} [columns=false]      Is this grid column-based (flat-topped) or row-based (pointy-topped)?
 *                                          Default: `false`.
 * @property {boolean} [even=false]         Is this grid even or odd? Default: `false`.
 * @property {GridDiagonalRule} [diagonals=0] The rule for diagonal measurement (see {@link CONST.GRID_DIAGONALS}).
 *                                            Default: `CONST.GRID_DIAGONALS.EQUIDISTANT`.
 */

var _types$c = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * @import {GridOffset2D, HexagonalGridCube2D, HexagonalGridCoordinates2D} from "./_types.mjs";
 * @import {Point} from "../_types.mjs";
 */

/**
 * A helper class which represents a single hexagon as part of a HexagonalGrid.
 * This class relies on having an active canvas scene in order to know the configuration of the hexagonal grid.
 */
class GridHex {
  /**
   * Construct a GridHex instance by providing a hex coordinate.
   * @param {HexagonalGridCoordinates2D} coordinates    The coordinates of the hex to construct
   * @param {HexagonalGrid} grid                        The hexagonal grid instance to which this hex belongs
   */
  constructor(coordinates, grid) {
    if ( !(grid instanceof HexagonalGrid) ) {
      grid = new HexagonalGrid(grid);
      foundry.utils.logCompatibilityWarning("The GridHex class now requires a HexagonalGrid instance to be passed to "
        + "its constructor, rather than a HexagonalGridConfiguration", {since: 12, until: 14});
    }
    if ( "row" in coordinates ) {
      coordinates = {i: coordinates.row, j: coordinates.col};
      foundry.utils.logCompatibilityWarning("The coordinates used to construct the GridHex class are now a GridOffset"
        + " with format {i, j}.", {since: 12, until: 14});
    }

    /**
     * The hexagonal grid to which this hex belongs.
     * @type {HexagonalGrid}
     */
    this.grid = grid;

    /**
     * The cube coordinate of this hex
     * @type {HexagonalGridCube2D}
     */
    this.cube = this.grid.getCube(coordinates);

    /**
     * The offset coordinate of this hex
     * @type {GridOffset2D}
     */
    this.offset = this.grid.cubeToOffset(this.cube);
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the pixel point in the center of this hexagon.
   * @type {Point}
   */
  get center() {
    return this.grid.getCenterPoint(this.cube);
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the pixel point of the top-left corner of this hexagon.
   * @type {Point}
   */
  get topLeft() {
    return this.grid.getTopLeftPoint(this.cube);
  }

  /* -------------------------------------------- */

  /**
   * Return the array of hexagons which are neighbors of this one.
   * This result is un-bounded by the confines of the game canvas and may include hexes which are off-canvas.
   * @returns {GridHex[]}
   */
  getNeighbors() {
    return this.grid.getAdjacentCubes(this.cube).map(c => new this.constructor(c, this.grid));
  }

  /* -------------------------------------------- */

  /**
   * Get a neighboring hex by shifting along cube coordinates
   * @param {number} dq     A number of hexes to shift along the q axis
   * @param {number} dr     A number of hexes to shift along the r axis
   * @param {number} ds     A number of hexes to shift along the s axis
   * @returns {GridHex}     The shifted hex
   */
  shiftCube(dq, dr, ds) {
    const {q, r, s} = this.cube;
    return new this.constructor({q: q + dq, r: r + dr, s: s + ds}, this.grid);
  }

  /* -------------------------------------------- */

  /**
   * Return whether this GridHex equals the same position as some other GridHex instance.
   * @param {GridHex} other     Some other GridHex
   * @returns {boolean}         Are the positions equal?
   */
  equals(other) {
    return (this.offset.i === other.offset.i) && (this.offset.j === other.offset.j);
  }
}

var _module$P = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BaseGrid: BaseGrid,
  GridHex: GridHex,
  GridlessGrid: GridlessGrid,
  HexagonalGrid: HexagonalGrid,
  SquareGrid: SquareGrid,
  types: _types$c
});

/* -------------------------------------------- */
/*  HTML Template Loading                       */
/* -------------------------------------------- */

/**
 * A temporary store of template requests to the server.
 * Multiple callers for the same path will receive the same Promise if it is pending.
 * @type {Record<string, Promise<Handlebars.TemplateDelegate>>}
 */
Object.defineProperties(Handlebars, {promises: {value: {}}});

/* -------------------------------------------- */

/**
 * 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<Handlebars.TemplateDelegate>} A Promise which resolves to the compiled Handlebars template
 */
async function getTemplate(path, id=path) {
  if ( id in Handlebars.partials ) return Handlebars.partials[id];
  return Handlebars.promises[id] ??= new Promise((resolve, reject) => {
    game.socket.emit("template", path, resp => {
      delete Handlebars.promises[id];
      if ( resp.error ) return reject(new Error(resp.error));
      const compiled = Handlebars.compile(resp.html);
      Handlebars.registerPartial(id, compiled);
      console.log(`Foundry VTT | Retrieved and compiled template ${path}`);
      return resolve(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<Handlebars.TemplateDelegate[]>}
 *
 * @example Loading a list of templates.
 * ```js
 * await foundry.applications.handlebars.loadTemplates(["templates/apps/foo.html", "templates/apps/bar.html"]);
 * ```
 * ```hbs
 * <!-- Include a preloaded template as a partial -->
 * {{> "templates/apps/foo.html" }}
 * ```
 *
 * @example Loading an object of templates.
 * ```js
 * await foundry.applications.handlebars.loadTemplates({
 *   foo: "templates/apps/foo.html",
 *   bar: "templates/apps/bar.html"
 * });
 * ```
 * ```hbs
 * <!-- Include a preloaded 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
  });
}

/* -------------------------------------------- */

/**
 * Initialize Handlebars extensions and helpers.
 */
function initialize() {

  // Register Handlebars Extensions
  HandlebarsIntl.registerWith(Handlebars);

  // Register all handlebars helpers
  Handlebars.registerHelper({
    checked,
    disabled,
    colorPicker,
    concat,
    editor,
    formInput,
    formGroup,
    formField: formGroup, // Alias
    filePicker,
    ifThen,
    localize,
    numberFormat,
    numberInput,
    object,
    radioBoxes,
    rangePicker,
    select,
    selectOptions,
    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);}
  });
}

/* -------------------------------------------- */
/*  Handlebars Template Helpers                 */
/* -------------------------------------------- */

/**
 * For checkboxes, if the value of the checkbox is true, add the "checked" property, otherwise add nothing.
 * @param {unknown} value A value with a truthiness indicative of whether the checkbox is checked
 * @returns {string}
 *
 * @example
 * ```hbs
 * <label>My Checkbox</label>
 * <input type="checkbox" name="myCheckbox" {{checked myCheckbox}}>
 * ```
 */
function checked(value) {
  return value ? "checked" : "";
}

/* -------------------------------------------- */

/**
 * For use in form inputs. If the supplied value is truthy, add the "disabled" property, otherwise add nothing.
 * @param {unknown} value A value with a truthiness indicative of whether the input is disabled
 * @returns {string}
 *
 * @example
 * ```hbs
 * <button type="submit" {{disabled myValue}}>Submit</button>
 * ```
 */
function 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"}}
 * ```
 */
function 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 foundry.applications.ux.TextEditor.create}. Default: `"tinymce"`.
 * @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}}
 * ```
 */
function 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"}}
 * ```
 */
function ifThen(criteria, ifTrue, ifFalse) {
  return criteria ? ifTrue : ifFalse;
}

/* -------------------------------------------- */

/**
 * Translate a provided string key by using the loaded dictionary of localization strings.
 * @param {string} value The path to a localized string
 * @param {{hash: object}} options Interpolation data passed to Localization#format
 * @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." -->
 * ```
 */
function 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
 * {{numberFormat 5.5}} <!-- 5.5 -->
 * {{numberFormat 5.5 decimals=2}} <!-- 5.50 -->
 * {{numberFormat 5.5 decimals=2 sign=true}} <!-- +5.50 -->
 * {{numberFormat null decimals=2 sign=false}} <!-- NaN -->
 * {{numberFormat undefined decimals=0 sign=true}} <!-- NaN -->
 *  ```
 */
function 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
    });
  }
  const 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}}
 * ```
 */
function 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);
}

/* -------------------------------------------- */

/**
 * Create an object from a sequence of `key=value` pairs.
 * @param {Handlebars.HelperOptions} options
 * @returns {Record<string, unknown>}
 */
function object(options) {
  return options.hash;
}

/* -------------------------------------------- */

/**
 * 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>
 * ```
 */
function 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 element = document.createElement("label");
    element.classList.add("checkbox");
    const input = document.createElement("input");
    input.type = "radio";
    input.name = name;
    input.value = key;
    input.defaultChecked = (checked === key);
    element.append(input, " ", label);
    html += element.outerHTML;
  }
  return new Handlebars.SafeString(html);
}

/* -------------------------------------------- */

/**
 * @typedef 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 valueAttr 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 valueAttr="key" labelAttr="label"}}
 * </select>
 * ```
 *
 * @example Using valueAttr 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 valueAttr="key" labelAttr="label"}}
 * </select>
 * ```
 */
function selectOptions(choices, options) {
  let {localize=false, selected, blank, sort, nameAttr, valueAttr, labelAttr, inverted, groups} = options.hash;

  // Normalize
  if ( (selected === undefined) || (selected === null) ) selected = [];
  else if ( selected instanceof Set ) selected = Array.from(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}
 */
function formInput(field, options) {
  if ( !field ) {
    console.error("Non-existent data field provided to {{formInput}} handlebars helper.");
    return Handlebars.SafeString("");
  }
  try {
    const input = field.toInput(options.hash);
    return new Handlebars.SafeString(input.outerHTML);
  } catch(error) {
    console.error(error);
    return Handlebars.SafeString("");
  }
}

/* -------------------------------------------- */

/**
 * 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}
 */
function formGroup(field, options) {
  const {classes, label, hint, rootId, stacked, units, hidden, widget, ...inputConfig} = options.hash;
  const groupConfig = {label, hint, rootId, stacked, widget, localize: inputConfig.localize, units, hidden,
    classes: typeof classes === "string" ? classes.split(" ") : []};
  if ( !field ) {
    console.error("Non-existent data field provided to {{formGroup}} handlebars helper.");
    return Handlebars.SafeString("");
  }
  try {
    const group = field.toFormGroup(groupConfig, inputConfig);
    return new Handlebars.SafeString(group.outerHTML);
  } catch(error) {
    console.error(error);
    return Handlebars.SafeString("");
  }
}

/* -------------------------------------------- */
/*  Deprecations and Compatibility              */
/* -------------------------------------------- */

/**
 * @deprecated since v12
 * @ignore
 */
function 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 button = document.createElement("button");
  button.type = "button";
  button.classList.add("file-picker");
  button.dataset.type = type;
  button.dataset.target = target;
  button.dataset.tooltip = "FILES.BrowseTooltip";
  button.tabIndex = -1;
  button.innerHTML = '<i class="fa-solid fa-file-import fa-fw"></i>';
  return new Handlebars.SafeString(button.outerHTML);
}

/* -------------------------------------------- */

/**
 * @deprecated since v12
 * @ignore
 */
function 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 = Color.from(value || defaultColor || "");
  value = value.valid ? value.css : "";
  const element = foundry.applications.elements.HTMLColorPickerElement.create({name, value});
  return new Handlebars.SafeString(element.outerHTML);
}

/* -------------------------------------------- */

/**
 * @deprecated since v12
 * @ignore
 */
function 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");
}

/* -------------------------------------------- */

/**
 * @deprecated since v13
 * @ignore
 */
function rangePicker(options) {
  foundry.utils.logCompatibilityWarning("The {{rangePicker}} Handlebars helper is deprecated and replaced by"
    + " use of the <range-picker> custom HTML element", {since: 13, until: 15, once: true});
  let {name, value, min, max, step} = options.hash;
  name = name || "range";
  value = Number(value ?? "");
  if ( Number.isNaN(value) ) value = "";
  const input = foundry.applications.elements.HTMLRangePickerElement.create({name, value, min, max, step});
  return new Handlebars.SafeString(input.outerHTML);
}

var handlebars = /*#__PURE__*/Object.freeze({
  __proto__: null,
  checked: checked,
  colorPicker: colorPicker,
  concat: concat,
  disabled: disabled,
  editor: editor,
  filePicker: filePicker,
  formGroup: formGroup,
  formInput: formInput,
  getTemplate: getTemplate,
  ifThen: ifThen,
  initialize: initialize,
  loadTemplates: loadTemplates,
  localize: localize,
  numberFormat: numberFormat,
  numberInput: numberInput,
  object: object,
  radioBoxes: radioBoxes,
  rangePicker: rangePicker,
  renderTemplate: renderTemplate,
  select: select,
  selectOptions: selectOptions
});

/**
 * @import {ApplicationConfiguration, ApplicationRenderOptions, ApplicationTabsConfiguration} from "../_types.mjs"
 * @import {HandlebarsRenderOptions} from "./handlebars-application.mjs"
 * @import {SearchFilterCallback} from "../ux/search-filter.mjs"
 */

/**
 * @typedef CategoryBrowserConfiguration
 * @property {boolean} packageList Where this application displays is a list of tagged FVTT packages
 * @property {string|null} initialCategory The initial category tab: a `null` value will result in an initial active tab
 *                                         that corresponds with the first category by insertion order.
 * @property {object} subtemplates Additional Template partials for specific use with this class
 * @property {string} subtemplates.category The markup used for each category: required to be set by any subclass
 * @property {string|null} subtemplates.filters Optional template for secondary filtering (aside from text search)
 * @property {string|null} subtemplates.sidebarFooter Optional sidebar footer content
 */

/**
 * An abstract class responsible for displaying a 2-pane Application that allows for entries to be grouped and filtered
 * by category.
 * @extends ApplicationV2<ApplicationConfiguration & CategoryBrowserConfiguration, HandlebarsRenderOptions>
 */
class CategoryBrowser extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["category-browser"],
    window: {
      contentClasses: ["standard-form"]
    },
    form: {
      closeOnSubmit: true
    },
    initialCategory: null,
    packageList: false,
    subtemplates: {
      category: undefined,
      filters: null,
      sidebarFooter: null
    }
  };

  /** @override */
  static PARTS = {
    sidebar: {
      template: "templates/category-browser/sidebar.hbs",
      scrollable: ["nav"]
    },
    main: {
      template: "templates/category-browser/main.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * Search-filter handling
   * @type {SearchFilter}
   */
  #search = new SearchFilter({
    inputSelector: "input[type=search]",
    contentSelector: "[data-application-part=main]",
    callback: this._onSearchFilter.bind(this)
  });

  /**
   * Is category and/or entry data loaded? Most subclasses will already have their data close at hand.
   * @returns {boolean}
   * @protected
   */
  get _dataLoaded() {
    return true;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    const initialized = super._initializeApplicationOptions(options);
    if ( initialized.form.handler ) initialized.tag = "form";
    return initialized;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    const subtemplates = this.options.subtemplates;
    if ( !subtemplates.category ) {
      throw new Error(`${this.constructor.name} must configure a category template.`);
    }
    parts.main.templates.push(subtemplates.category);
    if ( subtemplates.filters ) parts.main.templates.push(subtemplates.filters);
    if ( subtemplates.sidebarFooter ) parts.sidebar.templates.push(subtemplates.sidebarFooter);
    return parts;
  }

  /**
   * Perform a text search without a `KeyboardEvent`.
   * @param {string} query
   */
  search(query) {
    this.#search.filter(null, query);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async render(options) {
    await super.render(options);
    if ( options?.reloadData || !this._dataLoaded ) {
      this._loadCategoryData().then(() => this.render()); // Do not block render
    }
    return this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const categoryData = await this._prepareCategoryData();
    if ( options.resetTabs ) this.constructor.TABS = {};
    this.#configureTabs(categoryData);
    const tabData = this._prepareTabs("categories");
    return {
      rootId: this.id,
      loading: null,
      categories: foundry.utils.mergeObject(tabData, categoryData),
      packageList: this.options.packageList,
      subtemplates: this.options.subtemplates,
      submitButton: this.options.tag === "form"
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare the structure of category data which is rendered in this configuration form.
   * @returns {Promise<Record<string, {id: string; label: string; entries: object[]}>>}
   * @protected
   * @abstract
   */
  async _prepareCategoryData() {
    throw new Error(`${this.constructor.name} must implement the _prepareCategoryData method.`);
  }

  /* -------------------------------------------- */

  /**
   * An optional method to make a potentially long-running request to load category data: a temporary message will be
   * displayed until completion.
   * @returns {Promise<void>}
   */
  async _loadCategoryData() {}

  /* -------------------------------------------- */

  /**
   * Reusable logic for how categories are sorted in relation to each other.
   * @param {{label: string; [key: string]: unknown}} a
   * @param {{label: string; [key: string]: unknown}} b
   * @protected
   */
  _sortCategories(a, b) {
    return a.label.localeCompare(b.label, game.i18n.lang);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _tearDown(options) {
    super._tearDown(options);
    this.#search.unbind();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.#search.bind(this.element);
  }

  /* -------------------------------------------- */

  /**
   * Dynamically assemble a tabs configuration from category data.
   * @param {Record<string, {id:string; label:string}>} categoryData
   * @see {@link foundry.applications.types.ApplicationTabsConfiguration}
   */
  #configureTabs(categoryData) {
    if ( Object.keys(this.constructor.TABS).length ) return;
    const tabs = Object.entries(categoryData)
      .sort(([, a], [, b]) => this._sortCategories(a, b))
      .map(([id, c]) => ({id, label: c.label, scrollable: [""]}));
    const initial = this.options.initialCategory ?? tabs[0]?.id ?? null;
    this.constructor.TABS = {categories: {tabs, initial}};
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle search inputs.
   * @type {SearchFilterCallback}
   * @protected
   */
  _onSearchFilter(_event, query, rgx, html) {
    const matchCounts = {};

    // Hide entries
    for ( const entry of html.querySelectorAll(".form-group:not([data-bulk-actions])") ) {
      const category = entry.closest("[data-category]").dataset.category;
      matchCounts[category] ??= 0;
      if ( !query ) {
        entry.hidden = false;
        matchCounts[category]++;
        continue;
      }
      const label = entry.querySelector(":scope > label, :scope > span.label")?.textContent ?? "";
      const hint = entry.querySelector(":scope > .hint")?.textContent ?? "";
      const other = Array.from(entry.querySelectorAll("[data-searchable]")).map(e => e.textContent ?? "");
      const isMatch = [label, hint, ...other].some(q => rgx.test(SearchFilter.cleanQuery(q)));
      entry.hidden = !isMatch;
      if ( isMatch ) matchCounts[category]++;
    }

    // Update match counts
    for ( const [category, count] of Object.entries(matchCounts) ) {
      const tabAnchor = this.element.querySelector(`button[data-tab="${category}"]`);
      const countEl = tabAnchor.querySelector(":scope > span[data-count]");
      countEl.innerText = `[${count}]`;
      tabAnchor.classList.toggle("no-matches", count === 0);
    }

    // Hide bulk actions if their category has no matching entries
    for ( const entry of html.querySelectorAll(".form-group[data-bulk-actions]") ) {
      const category = entry.closest("[data-category]")?.dataset.category;
      entry.hidden = (matchCounts[category] === 0);
    }
  }
}

var _module$O = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Application: ApplicationV2,
  ApplicationV2: ApplicationV2,
  CategoryBrowser: CategoryBrowser,
  Dialog: DialogV2,
  DialogV2: DialogV2,
  DocumentSheet: DocumentSheetV2,
  DocumentSheetV2: DocumentSheetV2,
  HandlebarsApplicationMixin: HandlebarsApplicationMixin
});

/**
 * @import {ApplicationConfiguration, ApplicationPosition} from "../../_types.mjs"
 * @import {HandlebarsRenderOptions} from "../../api/handlebars-application.mjs"
 */

/**
 * @typedef _CameraPopoutConfiguration
 * @property {User} user
 */

/**
 * @typedef {ApplicationConfiguration & _CameraPopoutConfiguration} CameraPopoutConfiguration
 */

/**
 * An application for a single popped-out camera.
 * @extends {ApplicationV2<CameraPopoutConfiguration, HandlebarsRenderOptions>}
 * @mixes HandlebarsApplication
 */
class CameraPopout extends HandlebarsApplicationMixin(ApplicationV2) {
  constructor(options={}) {
    super(options);
    this.#user = options.user;
  }

  /** @override */
  static DEFAULT_OPTIONS = {
    id: "camera-view-{id}",
    classes: ["camera-view", "popout"],
    window: {
      resizable: true,
      minimizable: false
    },
    position: {
      height: "auto"
    },
    actions: {
      toggleDocked: CameraPopout.#onToggleDocked
    }
  };

  /** @override */
  static PARTS = {
    camera: {
      root: true,
      template: "templates/apps/av/camera.hbs",
      templates: ["templates/apps/av/controls.hbs"]
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A debounced function to persist the position of the popout.
   * @type {Function}
   */
  #persistPosition = foundry.utils.debounce(this.#onPersistPosition.bind(this), 1000);

  /**
   * The user this camera view is for.
   * @returns {User}
   */
  get user() {
    return this.#user;
  }

  #user;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options = super._initializeApplicationOptions(options);
    options.uniqueId = options.user.id;
    const { top, left, width } = game.webrtc.settings.getUser(options.user.id);
    Object.assign(options.position, { top, left, width });
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    this.element.dataset.user = this.user.id;
    this.element.replaceChildren(...this.element.querySelector(".window-content").children, this.window.resize);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    const Draggable = foundry.applications.ux.Draggable.implementation;
    new Draggable(this, this.element, this.element.querySelector(".video-container"));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    Object.assign(context, ui.webrtc._prepareUserContext(this.user.id));
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _replaceHTML(result, content, options) {
    if ( !result.camera || options.isFirstRender ) return super._replaceHTML(result, content, options);
    const handle = this.element.querySelector(".window-resize-handle");
    this.element.replaceChildren(...result.camera.children, handle);
  }

  /* -------------------------------------------- */
  /*  Positioning                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _prePosition(position) {
    super._prePosition(position);
    position.height = "auto"; // Remove explicit height to maintain aspect ratio.
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  setPosition(position) {
    position = super.setPosition(position);
    this.#persistPosition(position);
    return position;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @override */
  _onClickAction(event, target) {
    const { action } = target.dataset;
    switch ( action ) {
      case "blockAudio": return ui.webrtc._onBlockAudio(event, target);
      case "blockVideo": return ui.webrtc._onBlockVideo(event, target);
      case "configure": return ui.webrtc._onConfigure(event, target);
      case "disableVideo": return ui.webrtc._onDisableVideo(event, target);
      case "hide": return ui.webrtc._onHideUser(event, target);
      case "mutePeers": return ui.webrtc._onMutePeers(event, target);
      case "toggleAudio": return ui.webrtc._onToggleAudio(event, target);
      case "toggleVideo": return ui.webrtc._onToggleVideo(event, target);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle re-docking a popped-out camera view.
   * @this {CameraPopout}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static async #onToggleDocked(event, target) {
    const { user } = target.closest("[data-user]")?.dataset ?? {};
    if ( !game.users.get(user) ) return;
    await game.webrtc.settings.set("client", `users.${user}.popout`, false);
    await this.close();
    await ui.webrtc.render();
    ui.hotbar._onResize();
    ui.chat._toggleNotifications();
  }

  /* -------------------------------------------- */

  /**
   * Persist the popout's position.
   * @param {Partial<ApplicationPosition>} position  The position.
   */
  #onPersistPosition(position) {
    const current = game.webrtc.settings.client.users[this.user.id] ?? {};
    game.webrtc.settings.set("client", `users.${this.user.id}`, foundry.utils.mergeObject(current, position));
  }
}

/**
 * @typedef 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.changed = 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"
  };

  /**
   * Schemas for world and client settings
   * @type {{world: foundry.data.fields.SchemaField; client: foundry.data.fields.SchemaField}}
   */
  static get schemaFields() {
    const schema = AVSettings.#defineSchemas();
    Object.defineProperty(this, "schemaFields", { value: schema });
    return schema;
  }

  /**
   * Default client settings for each connected user.
   * @type {object}
   */
  static get DEFAULT_USER_SETTINGS() {
    return game.settings.settings.get("core.rtcClientSettings").type.fields.users.element.getInitialValue();
  }

  /* -------------------------------------------- */

  /**
   * Define world and client settings schemas.
   * @returns {{world: DataSchema; client: DataSchema}}
   */
  static #defineSchemas() {
    const fields = foundry.data.fields;
    return {
      world: new fields.SchemaField({
        mode: new fields.NumberField({
          required: true,
          nullable: false,
          choices: Object.values(AVSettings.AV_MODES),
          initial: AVSettings.AV_MODES.DISABLED
        }),
        turn: new fields.SchemaField({
          type: new fields.StringField({required: true, choices: ["server", "custom"], initial: "server"}),
          url: new fields.StringField({required: true}),
          username: new fields.StringField({required: true}),
          password: new fields.StringField({required: true})
        })
      }),
      client: new fields.SchemaField({
        videoSrc: new fields.StringField({required: true, initial: "default"}),
        audioSrc: new fields.StringField({required: true, initial: "default"}),
        audioSink: new fields.StringField({required: true, initial: "default"}),
        dockPosition: new fields.StringField({
          required: true,
          choices: Object.values(AVSettings.DOCK_POSITIONS),
          initial: AVSettings.DOCK_POSITIONS.LEFT
        }),
        hidePlayerList: new fields.BooleanField(),
        hideDock: new fields.BooleanField(),
        muteAll: new fields.BooleanField(),
        disableVideo: new fields.BooleanField(),
        borderColors: new fields.BooleanField(),
        dockWidth: new fields.NumberField({
          required: true,
          nullable: false,
          integer: true,
          positive: true,
          initial: 240
        }),
        nameplates: new fields.NumberField({
          required: true,
          nullable: false,
          choices: Object.values(AVSettings.NAMEPLATE_MODES),
          initial: AVSettings.NAMEPLATE_MODES.BOTH
        }),
        voice: new fields.SchemaField({
          mode: new fields.StringField({
            required: true,
            choices: Object.values(AVSettings.VOICE_MODES),
            initial: AVSettings.VOICE_MODES.PTT
          }),
          pttName: new fields.StringField({required: true, initial: "`"}),
          pttDelay: new fields.NumberField({required: true, nullable: false, integer: true, min: 0, initial: 100}),
          activityThreshold: new fields.NumberField({required: true, nullable: false, integer: true, initial: -45})
        }),
        users: new fields.TypedObjectField(new fields.SchemaField({
          popout: new fields.BooleanField(),
          left: new fields.NumberField({required: true, nullable: false, integer: true, initial: 100}),
          top: new fields.NumberField({required: true, nullable: false, integer: true, initial: 100}),
          z: new fields.NumberField({required: true, nullable: false, integer: true, initial: 0}),
          width: new fields.NumberField({required: true, nullable: false, integer: true, positive: true, initial: 320}),
          volume: new fields.NumberField({required: true, nullable: false, min: 0, max: 1, initial: 1}),
          muted: new fields.BooleanField(),
          hidden: new fields.BooleanField(),
          blocked: new fields.BooleanField()
        }), {validateKey: foundry.data.validators.isValidId})
      })
    };
  }

  /* -------------------------------------------- */

  /**
   * Register world and client WebRTC settings.
   */
  static register() {
    game.settings.register("core", "rtcWorldSettings", {
      name: "WebRTC (Audio/Video Conferencing) World Settings",
      scope: "world",
      type: AVSettings.schemaFields.world,
      onChange: () => game.webrtc.settings.changed()
    });
    game.settings.register("core", "rtcClientSettings", {
      name: "WebRTC (Audio/Video Conferencing) Client Settings",
      scope: "client",
      type: AVSettings.schemaFields.client,
      onChange: () => game.webrtc.settings.changed()
    });
  }

  /* -------------------------------------------- */

  /**
   * A debounce callback for when either the world or client settings change.
   * @type {() => void}
   */
  changed;

  /**
   * 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}});

    // Localize the settings' field labels and hints
    for ( const [key, field] of Object.entries(AVSettings.schemaFields) ) {
      foundry.helpers.Localization.localizeSchema(
        field,
        [`WEBRTC.${key.titleCase()}Settings`],
        {prefixPath: `core.rtc${key.titleCase()}Settings.`}
      );
    }
  }

  /* -------------------------------------------- */

  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 ( const user of game.users ) {
      users[user.id] = this.#getUserSettings(user);
    }
    return users;
  }

  /* -------------------------------------------- */

  /**
   * A helper to determine if the dock is configured in a vertical position.
   * @type {boolean}
   */
  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
   * @param {User} user
   */
  #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);
    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.
   */
  #onSettingsChanged() {
    const original = this._original;
    this.#initialize();
    const changed = foundry.utils.diffObject(original, this._original);
    game.webrtc.onSettingsChanged(changed);
    Hooks$1.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.render({parts: [userId]});
    if ( "speaking" in settings ) ui.webrtc.setUserIsSpeaking(userId, settings.speaking);
  }
}

/**
 * @import {ApplicationConfiguration, ApplicationRenderContext} from "../../_types.mjs"
 * @import {HandlebarsRenderOptions} from "../../api/handlebars-application.mjs"
 * @import {AVSettingsData} from "@client/av/settings.mjs"
 */

/**
 * @typedef CameraViewUserContext
 * @property {User} user                The User instance.
 * @property {AVSettingsData} settings  The user's AV settings.
 * @property {boolean} local            Whether the user's AV stream is local.
 * @property {string} charname          The user's character name.
 * @property {string} css               The CSS class of the user's camera dock.
 * @property {boolean} hasVideo         Whether the user is broadcasting video.
 * @property {boolean} hasAudio         Whether the user is broadcasting audio.
 * @property {boolean} hidden           Whether the main camera dock is hidden.
 * @property {object} nameplates
 * @property {boolean} nameplates.hidden     Whether camera nameplates are entirely hidden.
 * @property {string} nameplates.css         Nameplate CSS classes.
 * @property {string} nameplates.playerName  Whether to show player names on nameplates.
 * @property {string} nameplates.charname    Whether to show character names on nameplates.
 * @property {object} video
 * @property {number} video.volume      The video stream's volume.
 * @property {boolean} video.muted      Whether to mute the video stream's audio.
 * @property {boolean} video.show       Whether to show this user's camera.
 * @property {object} volume
 * @property {number} volume.value      The user's configured volume level.
 * @property {DataField} volume.field   The volume range field.
 * @property {boolean} volume.show      Whether to show a volume bar for this user.
 * @property {Record<string, CameraViewControlContext>} controls
 */

/**
 * @typedef CameraViewControlContext
 * @property {string} icon
 * @property {string} label
 * @property {boolean} display
 */

/**
 * An application that shows docked camera views.
 * @extends {ApplicationV2<ApplicationConfiguration, HandlebarsRenderOptions>}
 * @mixes HandlebarsApplication
 */
class CameraViews extends HandlebarsApplicationMixin(ApplicationV2) {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "camera-views",
    window: {
      frame: false
    },
    actions: {
      blockAudio: this.prototype._onBlockAudio,
      blockVideo: this.prototype._onBlockVideo,
      configure: this.prototype._onConfigure,
      disableVideo: this.prototype._onDisableVideo,
      hide: this.prototype._onHideUser,
      mutePeers: this.prototype._onMutePeers,
      toggleAudio: this.prototype._onToggleAudio,
      toggleDock: CameraViews.#onToggleDock,
      toggleDocked: CameraViews.#onToggleDocked,
      toggleVideo: this.prototype._onToggleVideo
    }
  };

  /** @override */
  static PARTS = {
    cameras: {
      template: "templates/apps/av/cameras.hbs",
      scrollable: [".scrollable"]
    },
    controls: {
      template: "templates/apps/av/controls.hbs"
    }
  };

  /**
   * Icons for the docked state of the camera dock.
   * @type {Record<AVSettings.DOCK_POSITIONS, [string, string]>}
   */
  DOCK_ICONS = {
    [AVSettings.DOCK_POSITIONS.TOP]: ["up", "down"],
    [AVSettings.DOCK_POSITIONS.RIGHT]: ["right", "left"],
    [AVSettings.DOCK_POSITIONS.BOTTOM]: ["down", "up"],
    [AVSettings.DOCK_POSITIONS.LEFT]: ["left", "right"]
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * If all camera views are popped out, hide the dock.
   * @type {boolean}
   */
  get hidden() {
    return game.webrtc.client.getConnectedUsers().reduce((hidden, id) => {
      return hidden && game.webrtc.settings.getUser(id).popout;
    }, true);
  }

  /**
   * Whether the AV dock is in a horizontal configuration.
   * @type {boolean}
   */
  get isHorizontal() {
    const { DISABLED } = AVSettings.AV_MODES;
    const { TOP, BOTTOM } = AVSettings.DOCK_POSITIONS;
    const { mode } = game.webrtc.settings.world;
    const { dockPosition } = game.webrtc.settings.client;
    return (mode !== DISABLED) && ((dockPosition === TOP) || (dockPosition === BOTTOM));
  }

  /**
   * Whether the AV dock is in a vertical configuration.
   * @type {boolean}
   */
  get isVertical() {
    const { DISABLED } = AVSettings.AV_MODES;
    const { LEFT, RIGHT } = AVSettings.DOCK_POSITIONS;
    const { mode } = game.webrtc.settings.world;
    const { dockPosition } = game.webrtc.settings.client;
    return (mode !== DISABLED) && ((dockPosition === LEFT) || (dockPosition === RIGHT));
  }

  /**
   * Cameras which have been popped-out of this dock.
   * @type {CameraPopout[]}
   */
  get popouts() {
    const popouts = [];
    for ( const el of document.querySelectorAll('[id^="camera-views-"]') ) {
      popouts.push(foundry.applications.instances.get(el.id));
    }
    return popouts;
  }

  /**
   * The cached list of processed user entries.
   * @type {Record<string, CameraViewUserContext>}
   */
  get users() {
    return this.#users;
  }

  #users = {};

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Get a user's camera dock.
   * @param {string} userId  The user's ID.
   * @returns {HTMLElement|null}
   */
  getUserCameraView(userId) {
    return document.querySelector(`.camera-view[data-user="${userId}"]`) || null;
  }

  /* -------------------------------------------- */

  /**
   * Get the video element for a user broadcasting video.
   * @param {string} userId  The user's ID.
   * @returns {HTMLVideoElement|null}
   */
  getUserVideoElement(userId) {
    return this.getUserCameraView(userId)?.querySelector("video.user-camera") || null;
  }

  /* -------------------------------------------- */

  /**
   * Indicate a user is speaking on their camera dock.
   * @param {string} userId     The user's ID.
   * @param {boolean} speaking  Whether the user is speaking.
   */
  setUserIsSpeaking(userId, speaking) {
    this.getUserCameraView(userId)?.classList.toggle("speaking", speaking);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _canRender(options) {
    return game.webrtc.settings.world.mode !== AVSettings.AV_MODES.DISABLED;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    this.#users = Object.fromEntries(game.webrtc.client.getConnectedUsers().reduce((arr, id) => {
      const context = this._prepareUserContext(id);
      if ( !context.settings.blocked ) arr.push(context);
      return arr;
    }, []).sort(this.constructor._sortUsers).map(ctx => [ctx.user.id, ctx]));
    for ( const id in this.#users ) parts[id] = { template: "templates/apps/av/camera.hbs" };
    return parts;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    const { RIGHT, BOTTOM } = AVSettings.DOCK_POSITIONS;
    const { dockPosition, hideDock } = game.webrtc.settings.client;
    const isHorizontal = this.isHorizontal;
    this.element.classList.toggle("minimized", hideDock);
    this.element.classList.toggle("horizontal", isHorizontal);
    this.element.classList.toggle("vertical", !isHorizontal);
    document.body.classList.toggle("flexcol", isHorizontal);
    this.element.classList.remove("top", "right", "bottom", "left");
    this.element.classList.add(dockPosition);
    this.element.hidden = this.hidden;

    const iface = document.getElementById("interface");
    if ( (dockPosition === RIGHT) || (dockPosition === BOTTOM) ) iface.after(this.element);
    else iface.before(this.element);

    if ( !("parts" in options) ) return;
    for ( const partId of options.parts ) {
      if ( partId in this.constructor.PARTS ) continue;
      if ( !this.getUserCameraView(partId) ) {
        const ctx = this.#users[partId];
        if ( ctx.settings.popout ) await new CameraPopout({ user: game.users.get(partId) }).render({ force: true });
      }
      this.setUserIsSpeaking(partId, game.webrtc.settings.activity[partId]?.speaking || false);
      await foundry.applications.instances.get(`camera-view-${partId}`)?.render();
      const video = this.getUserVideoElement(partId);
      if ( video ) game.webrtc.client.setUserVideo(partId, video);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "controls": await this._prepareControlsContext(context, options); break;
    }
    if ( game.users.has(partId) ) Object.assign(context, this.#users[partId]);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for controls.
   * @param {ApplicationRenderContext} context
   * @param {HandlebarsRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareControlsContext(context, options) {
    context.user = this.#users[game.userId];
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the given user.
   * @param {string} id  The user's ID.
   * @returns {CameraViewUserContext|void}
   * @internal
   */
  _prepareUserContext(id) {
    const user = game.users.get(id);
    const clientSettings = game.webrtc.settings.client;
    const userSettings = game.webrtc.settings.getUser(id);
    if ( !user?.active ) return;
    const charname = user.character?.name.split(" ").shift() || "";
    const cbv = game.webrtc.canUserBroadcastVideo(id);
    const csv = game.webrtc.canUserShareVideo(id);
    const cba = game.webrtc.canUserBroadcastAudio(id);
    const csa = game.webrtc.canUserShareAudio(id);
    const minimized = clientSettings.hideDock;
    const { BOTH, OFF, CHAR_ONLY, PLAYER_ONLY } = AVSettings.NAMEPLATE_MODES;
    const nameplates = clientSettings.nameplates ?? BOTH;
    return {
      user,
      local: user.isSelf,
      charname: user.isGM ? game.i18n.localize("USER.GM") : charname,
      css: [csa ? "" : "no-audio", csv ? "" : "no-video"].filterJoin(" "),
      settings: userSettings,
      hasAudio: csa,
      hasVideo: csv,
      hidden: this.hidden,
      nameplates: {
        hidden: nameplates === OFF,
        css: (nameplates === PLAYER_ONLY) || (nameplates === CHAR_ONLY) ? "noanimate" : "",
        playerName: (nameplates === BOTH) || (nameplates === PLAYER_ONLY),
        charname: (nameplates === BOTH) || (nameplates === CHAR_ONLY)
      },
      video: {
        volume: userSettings.volume,
        muted: user.isSelf || clientSettings.mutaAll,
        show: csv && (user.isSelf || !clientSettings.disableVideo) && (!minimized || userSettings.popout)
      },
      volume: {
        field: new NumberField({ min: 0, max: 1, step: .05 }),
        value: foundry.audio.AudioHelper.volumeToInput(userSettings.volume),
        aria: { label: "WEBRTC.Volume" },
        show: !user.isSelf && cba
      },
      controls: {
        dock: {
          icon: `fa-caret-${this.DOCK_ICONS[clientSettings.dockPosition][Number(!!clientSettings.hideDock)]}`,
          label: `WEBRTC.Tooltip${clientSettings.hideDock ? "ExpandDock" : "MinimizeDock"}`,
          display: user.isSelf
        },
        video: {
          icon: csv ? "fa-camera-web" : "fa-camera-web-slash",
          label: `WEBRTC.Tooltip${csv ? "DisableMyVideo" : "EnableMyVideo"}`,
          display: user.isSelf && !minimized
        },
        audio: {
          icon: csa ? "fa-microphone" : "fa-microphone-slash",
          label: `WEBRTC.Tooltip${csa ? "DisableMyAudio" : "EnableMyAudio"}`,
          display: user.isSelf
        },
        deafen: {
          icon: clientSettings.muteAll ? "fa-volume-mute" : "fa-volume-up",
          label: `WEBRTC.Tooltip${clientSettings.muteAll ? "UnmutePeers" : "MutePeers"}`,
          display: user.isSelf
        },
        blind: {
          icon: clientSettings.disableVideo ? "fa-video-slash" : "fa-video",
          label: `WEBRTC.Tooltip${clientSettings.disableVideo ? "EnableVideo" : "DisableAllVideo"}`,
          display: user.isSelf && !minimized
        },
        blockVideo: {
          icon: cbv ? "fa-video" : "fa-video-slash",
          label: `WEBRTC.Tooltip${cbv ? "BlockUserVideo" : "AllowUserVideo"}`,
          display: game.user.isGM && !user.isSelf
        },
        blockAudio: {
          icon: cba ? "fa-microphone" : "fa-microphone-slash",
          label: `WEBRTC.Tooltip${cba ? "BlockUserAudio" : "AllowUserAudio"}`,
          display: game.user.isGM && !user.isSelf
        },
        hide: {
          icon: userSettings.blocked ? "fa-eye" : "fa-eye-slash",
          label: `WEBRTC.Tooltip${userSettings.blocked ? "ShowUser" : "HideUser"}`,
          display: !user.isSelf
        },
        popout: {
          icon: userSettings.popout ? "fa-external-link-square-alt fa-rotate-180" : "fa-external-link-alt",
          label: `WEBRTC.Tooltip${userSettings.popout ? "Dock" : "Popout"}`,
          display: true
        }
      }
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _replaceHTML(result, content, options) {
    super._replaceHTML(result, content, options);
    const container = (result.cameras ?? content).querySelector(".camera-container");
    const docked = new Set();
    for ( const [id, ctx] of Object.entries(this.#users) ) {
      if ( ctx.settings.popout ) continue;
      container.append(this.parts[id]);
      docked.add(id);
    }
    for ( const id of Object.keys(this.parts) ) {
      if ( !docked.has(id) && !(id in this.constructor.PARTS) ) {
        this.parts[id].remove();
        delete this.parts[id];
      }
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("change", this._onVolumeChange.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle blocking a user's audio stream.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @internal
   */
  async _onBlockAudio(event, target) {
    if ( !game.user.isGM ) return;
    const user = this.#getUser(target);
    await user.update({ "permissions.BROADCAST_AUDIO": !game.webrtc.settings.getUser(user.id).canBroadcastAudio });
    return this.render({ parts: [user.id] });
  }

  /* -------------------------------------------- */

  /**
   * Handle blocking a user's video stream.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @internal
   */
  async _onBlockVideo(event, target) {
    if ( !game.user.isGM ) return;
    const user = this.#getUser(target);
    await user.update({ "permissions.BROADCAST_VIDEO": !game.webrtc.settings.getUser(user.id).canBroadcastVideo });
    return this.render({ parts: [user.id] });
  }

  /* -------------------------------------------- */

  /**
   * Handle spawning the AV configuration dialog.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @internal
   */
  _onConfigure(event, target) {
    return game.webrtc.config.render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle disabling all incoming video streams.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @internal
   */
  async _onDisableVideo(event, target) {
    const user = this.#getUser(target);
    if ( !user.isSelf ) return;
    await game.webrtc.settings.set("client", "disableVideo", !game.webrtc.settings.client.disableVideo);
  }

  /* -------------------------------------------- */

  /**
   * Handle hiding a user from the AV UI entirely.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @internal
   */
  async _onHideUser(event, target) {
    const user = this.#getUser(target);
    if ( user.isSelf ) return;
    const blocked = game.webrtc.settings.getUser(user.id).blocked;
    await game.webrtc.settings.set("client", `users.${user.id}.blocked`, !blocked);
    return this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle disabling all incoming audio streams.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @internal
   */
  async _onMutePeers(event, target) {
    const user = this.#getUser(target);
    if ( !user.isSelf ) return;
    await game.webrtc.settings.set("client", "muteAll", !game.webrtc.settings.client.muteAll);
  }

  /* -------------------------------------------- */

  /**
   * Handle popping-out a user's camera dock.
   * @this {CameraViews}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static async #onToggleDocked(event, target) {
    const user = this.#getUser(target);
    await game.webrtc.settings.set("client", `users.${user.id}.popout`, true);
    await new CameraPopout({ user }).render({ force: true });
    await this.render();
    ui.hotbar._onResize();
    ui.chat._toggleNotifications();
  }

  /* -------------------------------------------- */

  /**
   * Handle the user toggling their own audio stream.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @internal
   */
  async _onToggleAudio(event, target) {
    const user = this.#getUser(target);
    if ( !user.isSelf ) return;
    const { muted, canBroadcastAudio } = game.webrtc.settings.getUser(user.id);
    if ( muted && !canBroadcastAudio ) {
      return ui.notifications.warn("WEBRTC.WarningCannotEnableAudio", { localize: true });
    }
    await game.webrtc.settings.set("client", `users.${user.id}.muted`, !muted);
    return this.render({ parts: ["controls", user.id] });
  }

  /* -------------------------------------------- */

  /**
   * Handle minimizing or maximizing the dock.
   * @this {CameraViews}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static async #onToggleDock(event, target) {
    await game.webrtc.settings.set("client", "hideDock", !game.webrtc.settings.client.hideDock);
    return this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle the user toggling their own video stream.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @internal
   */
  async _onToggleVideo(event, target) {
    const user = this.#getUser(target);
    if ( !user.isSelf ) return;
    const { hidden, canBroadcastVideo } = game.webrtc.settings.getUser(user.id);
    if ( !hidden && !canBroadcastVideo ) {
      return ui.notifications.warn("WEBRTC.WarningCannotEnableVideo", { localize: true });
    }
    await game.webrtc.settings.set("client", `users.${user.id}.hidden`, !hidden);
    return this.render({ parts: ["controls", user.id] });
  }

  /* -------------------------------------------- */

  /**
   * Handle changing another user's volume.
   * @param {Event} event  The triggering event.
   * @protected
   */
  _onVolumeChange(event) {
    if ( !event.target.closest(".webrtc-volume-slider") ) return;
    const { user } = event.target.closest("[data-user]").dataset;
    const value = event.target.closest("range-picker").value;
    const volume = foundry.audio.AudioHelper.inputToVolume(value);
    const video = this.getUserVideoElement(user);
    if ( video ) video.volume = volume;
    game.webrtc.settings.set("client", `users.${user}.volume`, volume);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve the User instance that a given control belongs to.
   * @param {HTMLElement} control  The control.
   * @returns {User}
   */
  #getUser(control) {
    const { user } = control.closest("[data-user]")?.dataset ?? {};
    return game.users.get(user);
  }

  /* -------------------------------------------- */

  /**
   * Sort users' cameras in the dock.
   * @param {CameraViewUserContext} a
   * @param {CameraViewUserContext} b
   * @returns {number}
   * @protected
   */
  static _sortUsers(a, b) {
    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.name.localeCompare(b.user.name, game.i18n.lang);
  }
}

var _module$N = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CameraPopout: CameraPopout,
  CameraViews: CameraViews
});

/**
 * @import {ApplicationClickAction} from "../_types.mjs";
 */

/**
 * The Application responsible for configuring the CombatTracker and its contents.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class CombatTrackerConfig extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "combat-tracker-config",
    tag: "form",
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-swords",
      title: "COMBAT.Settings"
    },
    position: {width: 480},
    form: {
      closeOnSubmit: true,
      handler: CombatTrackerConfig.#saveSettings
    },
    actions: {
      previewTheme: CombatTrackerConfig.#onPreviewTheme
    }
  };

  /** @override */
  static PARTS = {
    body: {template: "templates/apps/combat-tracker-config.hbs", scrollable: [""]},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext() {
    const TokenDocument = foundry.utils.getDocumentClass("Token");
    const attributes = TokenDocument.getTrackedAttributes();
    attributes.bar.forEach(a => a.push("value"));
    const combatThemeSetting = game.settings.settings.get("core.combatTheme");
    return {
      rootId: this.id,
      attributeChoices: TokenDocument.getTrackedAttributeChoices(attributes),
      canConfigure: game.user.can("SETTINGS_MODIFY"),
      combatTheme: combatThemeSetting,
      fields: CONFIG.Combat.settings.constructor.schema.fields,
      selectedTheme: game.settings.get("core", "combatTheme"),
      settings: game.settings.get("core", Combat$1.CONFIG_SETTING),
      animationChoices: CONFIG.Combat.settings.turnMarkerAnimations,
      buttons: [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "COMBAT.SettingsSave"}]
    };
  }

  /* -------------------------------------------- */

  /**
   * @inheritDoc
   */
  _onChangeForm(formConfig, event) {
    switch ( event.target.name ) {
      case "core.combatTheme":
        this.#audioPreviewState = 0;
        break;
      case "core.combatTrackerConfig.turnMarker.enabled": {
        const elements = this.form.elements;
        for ( const fieldName of ["animation", "src", "disposition"] ) {
          elements[`core.combatTrackerConfig.turnMarker.${fieldName}`].disabled = !event.target.checked;
        }
      }
    }
    super._onChangeForm(formConfig, event);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  #audioPreviewState = 0;

  /**
   * Handle previewing a sound file for a Combat Tracker setting
   * @type {ApplicationClickAction}
   * @this CombatTrackerConfig
   */
  static async #onPreviewTheme(_event, target) {
    const themeId = target.previousElementSibling.value;
    const theme = CONFIG.Combat.sounds[themeId];
    if ( !theme ) 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});
  }

  /* -------------------------------------------- */

  /**
   * Save all settings.
   * @type {ApplicationClickAction}
   * @this CombatTrackerConfig
   */
  static async #saveSettings(_event, _form, submitData) {
    const settings = foundry.utils.expandObject(submitData.object);
    await game.settings.set("core", "combatTheme", settings.core.combatTheme);
    if ( game.user.can("SETTINGS_MODIFY") ) {
      await game.settings.set("core", Combat$1.CONFIG_SETTING, settings.core[Combat$1.CONFIG_SETTING]);
    }
  }
}

/**
 * @import {ApplicationClickAction, ApplicationFormSubmission} from "../_types.mjs";
 */

/**
 * 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 CategoryBrowser {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "settings-config",
    window: {
      title: "SETTINGS.Title",
      icon: "fa-solid fa-gears",
      resizable: true
    },
    position: {
      width: 780,
      height: 680
    },
    form: {
      handler: SettingsConfig.#onSubmit
    },
    actions: {
      openSubmenu: SettingsConfig.#onOpenSubmenu,
      resetDefaults: SettingsConfig.#onResetDefaults
    },
    initialCategory: "core",
    subtemplates: {
      category: "templates/settings/config-category.hbs",
      sidebarFooter: "templates/category-browser/reset.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareCategoryData() {
    const categories = {};
    const getCategory = namespace => {
      const {id, label} = this._categorizeEntry(namespace);
      return categories[id] ??= {id, label, entries: []};
    };

    // Classify all menus
    const canConfigure = game.user.can("SETTINGS_MODIFY");
    for ( const menu of game.settings.menus.values() ) {
      if ( menu.restricted && !canConfigure ) continue;
      if ( (menu.key === "core.permissions") && !game.user.hasRole("GAMEMASTER") ) continue;
      const category = getCategory(menu.namespace);
      category.entries.push({
        key: menu.key,
        icon: menu.icon,
        label: menu.name,
        hint: menu.hint,
        menu: true,
        buttonText: menu.label
      });
    }

    // Classify all settings
    for ( const setting of game.settings.settings.values() ) {
      if ( !setting.config || (!canConfigure && (setting.scope === CONST.SETTING_SCOPES.WORLD)) ) continue;
      const data = {
        label: setting.value,
        value: game.settings.get(setting.namespace, setting.key),
        menu: false
      };

      // Define a DataField for each setting not originally defined with one
      const fields = foundry.data.fields;
      if ( setting.type instanceof fields.DataField ) {
        data.field = setting.type;
      }
      else if ( setting.type === Boolean ) {
        data.field = new fields.BooleanField({initial: setting.default ?? false});
      }
      else if ( setting.type === Number ) {
        const {min, max, step} = setting.range ?? {};
        data.field = new fields.NumberField({
          required: true,
          choices: setting.choices,
          initial: setting.default,
          min,
          max,
          step
        });
      }
      else if ( setting.filePicker ) {
        const categories = {
          audio: ["AUDIO"],
          folder: [],
          font: ["FONT"],
          graphics: ["GRAPHICS"],
          image: ["IMAGE"],
          imagevideo: ["IMAGE", "VIDEO"],
          text: ["TEXT"],
          video: ["VIDEO"]
        }[setting.filePicker] ?? Object.keys(CONST.FILE_CATEGORIES).filter(c => c !== "HTML");
        if ( categories.length ) {
          data.field = new fields.FilePathField({required: true, blank: true, categories});
        }
        else {
          data.field = new fields.StringField({required: true}); // Folder paths cannot be FilePathFields
          data.folderPicker = true;
        }
      }
      else {
        data.field = new fields.StringField({required: true, choices: setting.choices});
      }
      data.field.name = `${setting.namespace}.${setting.key}`;
      data.field.label ||= game.i18n.localize(setting.name ?? "");
      data.field.hint ||= game.i18n.localize(setting.hint ?? "");

      // Categorize setting
      const category = getCategory(setting.namespace);
      category.entries.push(data);
    }

    return categories;
  }

  /* -------------------------------------------- */

  /**
   * Classify what Category an Action belongs to
   * @param {string} namespace The entry to classify
   * @returns {{id: string; label: string}} The category the entry belongs to
   * @protected
   */
  _categorizeEntry(namespace) {
    switch ( namespace ) {
      case "core":
        return {id: "core", label: game.i18n.localize("PACKAGECONFIG.TABS.core")};
      case game.system.id:
        return {id: "system", label: game.system.title};
      default: {
        const module = game.modules.get(namespace);
        return module
          ? {id: module.id, label: module.title}
          : {id: "unmapped", label: game.i18n.localize("PACKAGECONFIG.TABS.unmapped")};
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Sort categories in order of core, system, and finally modules.
   * @param {{id: string; label: string}} a
   * @param {{id: string; label: string}} b
   * @protected
   * @override
   */
  _sortCategories(a, b) {
    const categoryOrder = {core: 0, system: 1};
    const indexOfA = categoryOrder[a.id] ?? 2;
    const indexOfB = categoryOrder[b.id] ?? 2;
    return (indexOfA - indexOfB) || a.label.localeCompare(b.label, game.i18n.lang);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * 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,
      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();
  }

  /* -------------------------------------------- */

  /**
   * Handle activating the button to configure User Role permissions
   * @this {SettingsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onOpenSubmenu(_event, button) {
    const menu = game.settings.menus.get(button.dataset.key);
    if ( !menu ) {
      ui.notifications.error("No submenu found for the provided key");
      return;
    }
    const app = new menu.type();
    await app.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle button click to reset default settings
   * @this {SettingsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onResetDefaults() {
    const form = this.form;
    for ( const [key, setting] of game.settings.settings.entries() ) {
      if ( !setting.config ) continue;
      const input = form[key];
      if ( !input ) continue;
      if ( input.type === "checkbox" ) input.checked = setting.default;
      else input.value = setting.default;
      input.dispatchEvent(new Event("change"));
    }
    ui.notifications.info("SETTINGS.ResetInfo", {localize: true});
  }

  /* -------------------------------------------- */

  /**
   * Update changed settings.
   * @this {SettingsConfig}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmit(_event, _form, formData) {
    let requiresClientReload = false;
    let requiresWorldReload = false;
    for ( const [key, value] of Object.entries(formData.object) ) {
      const setting = game.settings.settings.get(key);
      if ( !setting ) continue;
      const priorValue = game.settings.get(setting.namespace, setting.key, {document: true})?._source.value;
      let newSetting;
      try {
        newSetting = await game.settings.set(setting.namespace, setting.key, value, {document: true});
      } catch(error) {
        ui.notifications.error(error);
      }
      if ( priorValue === newSetting?._source.value ) continue; // Compare JSON strings
      requiresClientReload ||= (setting.scope !== CONST.SETTING_SCOPES.WORLD) && setting.requiresReload;
      requiresWorldReload ||= (setting.scope === CONST.SETTING_SCOPES.WORLD) && setting.requiresReload;
    }
    if ( requiresClientReload || requiresWorldReload ) {
      await this.constructor.reloadConfirm({world: requiresWorldReload});
    }
  }
}

/**
 * An application for configuring compendium art priorities.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class CompendiumArtConfig extends HandlebarsApplicationMixin(ApplicationV2) {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "compendium-art-config",
    tag: "form",
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-palette",
      title: "COMPENDIUM.ART.SETTING.Title"
    },
    position: {width: 600},
    form: {
      closeOnSubmit: true,
      handler: CompendiumArtConfig.#onSubmit
    },
    actions: {
      priority: CompendiumArtConfig.#onAdjustPriority
    }
  };

  /** @override */
  static PARTS = {
    priorities: {
      id: "priorities",
      template: "templates/apps/compendium-art-config.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(_options={}) {
    return {
      config: game.compendiumArt.getPackages(),
      buttons: [{ type: "submit", icon: "fa-solid fa-floppy-disk", label: "SETUP.SaveConfiguration" }]
    };
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Adjust the priority of a package.
   * @this {ApplicationV2}
   * @param {MouseEvent} _event         The click event.
   * @param {HTMLButtonElement} target  The button that was clicked.
   */
  static async #onAdjustPriority(_event, target) {
    const row = target.closest("[data-package-id]");
    const { packageId } = row.dataset;
    const configs = [];
    for ( const element of this.element.elements ) {
      const [id, key] = element.name.split(".");
      if ( key === "priority" ) configs.push({ packageId: id, priority: Number(element.value) });
    }
    const idx = configs.findIndex(config => config.packageId === packageId);
    if ( idx < 0 ) return;
    const sortBefore = "increase" in target.dataset;
    if ( sortBefore && (idx === 0) ) return;
    if ( !sortBefore && (idx >= configs.length - 1) ) return;
    const config = configs[idx];
    const sortTarget = configs[sortBefore ? idx - 1 : idx + 1];
    configs.splice(idx, 1);
    const updates = foundry.utils.performIntegerSort(config, {
      sortBefore, target: sortTarget, siblings: configs, sortKey: "priority"
    });
    updates.forEach(({ target, update }) => {
      this.element.elements[`${target.packageId}.priority`].value = update.priority;
    });
    if ( sortBefore ) row.previousElementSibling.insertAdjacentElement("beforebegin", row);
    else row.nextElementSibling.insertAdjacentElement("afterend", row);
  }

  /* -------------------------------------------- */

  /**
   * Save the compendium art configuration.
   * @this {ApplicationV2}
   * @param {SubmitEvent} _event         The form submission event.
   * @param {HTMLFormElement} _form      The form element that was submitted.
   * @param {FormDataExtended} formData  Processed data for the submitted form.
   */
  static async #onSubmit(_event, _form, formData) {
    await game.settings.set("core", game.compendiumArt.SETTING, foundry.utils.expandObject(formData.object));
    return SettingsConfig.reloadConfirm({ world: true });
  }
}

/**
 * @import {ApplicationFormSubmission} from "../_types.mjs";
 */

/**
 * A generic application for configuring permissions for various Document types.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class DocumentOwnershipConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["document-ownership"],
    template: "templates/apps/document-ownership.hbs",
    viewPermission: DOCUMENT_OWNERSHIP_LEVELS.OWNER,
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-file-lock"
    },
    position: {width: 420},
    form: {
      handler: DocumentOwnershipConfig.#onSubmitForm,
      closeOnSubmit: true
    },
    sheetConfig: false
  };

  /** @override */
  static PARTS = {
    ownership: {
      template: "templates/apps/document-ownership.hbs",
      root: true,
      scrollable: ["menu.scrollable"]
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /**
   * Are Gamemaster users currently hidden?
   * @type {boolean}
   */
  #gmHidden = true;

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.format("OWNERSHIP.Title", {object: this.document.name});
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const document = context.document;
    const isFolder = document instanceof Folder$1;
    const isEmbedded = document.isEmbedded;
    const ownership = document.ownership;
    if ( !ownership && !isFolder ) {
      throw new Error(`The ${document.documentName} document does not contain ownership data`);
    }

    // User permission levels
    const playerLevels = Object.entries(DOCUMENT_META_OWNERSHIP_LEVELS).map(([name, level]) => {
      return {level, label: game.i18n.localize(`OWNERSHIP.${name}`)};
    });

    if ( !isFolder ) playerLevels.pop();
    for ( const [name, level] of Object.entries(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 => ({
      user,
      level: isFolder ? DOCUMENT_META_OWNERSHIP_LEVELS.NOCHANGE : ownership[user.id],
      isAuthor: document.author === user
    })).sort((a, b) => a.user.name.localeCompare(b.user.name, game.i18n.lang));

    // Construct and return the data object
    return Object.assign(
      context,
      {
        currentDefault: ownership?.default ?? DOCUMENT_META_OWNERSHIP_LEVELS.DEFAULT,
        instructions: game.i18n.localize(isFolder ? "OWNERSHIP.HintFolder" : "OWNERSHIP.HintDocument"),
        defaultLevels,
        playerLevels,
        isFolder,
        showGM: !this.#gmHidden,
        users,
        buttons: [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "OWNERSHIP.Save"}]
      }
    );
  }

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.#toggleGamemasters(this.#gmHidden);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);

    // Toggle GM user visibility
    const toggle = this.element.querySelector("input[data-show-gm-toggle]");
    if ( event.target === toggle ) this.#toggleGamemasters(!this.#gmHidden);
  }

  /* -------------------------------------------- */

  /**
   * Toggle CSS classes which display or hide gamemaster users
   * @param {boolean} hidden      Should gamemaster users be hidden?
   */
  #toggleGamemasters(hidden) {
    this.form.classList.toggle("no-gm", hidden);
    this.#gmHidden = hidden;
  }

  /* -------------------------------------------- */

  /**
   * Update ownership level of this document for one or more users.
   * @this {DocumentOwnershipConfig}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmitForm(_event, _form, {object}) {
    // Collect new ownership levels from the form data
    const document = this.document;
    const metaLevels = DOCUMENT_META_OWNERSHIP_LEVELS;
    const isFolder = document instanceof Folder$1;
    const omit = isFolder ? metaLevels.NOCHANGE : metaLevels.DEFAULT;
    const ownershipLevels = Object.entries(object).reduce((levels, [userId, level]) => {
      if ( level === omit ) {
        delete levels[userId];
        return levels;
      }
      levels[userId] = level;
      return levels;
    }, {});

    // Update all documents in a Folder
    if ( document instanceof Folder$1 ) {
      const cls = foundry.utils.getDocumentClass(document.type);
      const updates = document.contents.map(doc => {
        const ownership = foundry.utils.deepClone(doc.ownership);
        for ( const [userId, value] of Object.entries(ownershipLevels) ) {
          ownership[userId] = value;
        }
        return {_id: doc.id, "==ownership": ownership};
      });
      await cls.updateDocuments(updates, {noHook: true});
    }
    // Update a single Document
    else {
      await document.update({"==ownership": ownershipLevels}, {noHook: true});
    }

  }
}

/**
 * @import {ApplicationClickAction, FormFooterButton} from "../_types.mjs";
 * @import EmbeddedCollection from "@common/abstract/embedded-collection.mjs";
 */

/**
 * The Application responsible for configuring a single Scene document.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class SceneConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["scene-config"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-map"
    },
    position: {width: 560},
    form: {
      closeOnSubmit: true
    },
    actions: {
      capturePosition: SceneConfig.#onCapturePosition,
      toggleLinkDimensions: SceneConfig.#onToggleLinkDimensions,
      openGridConfig: SceneConfig.#onOpenGridConfig,
      resetEnvironment: SceneConfig.#onResetEnvironment
    }
  };

  /** @override */
  static PARTS = {
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    basics: {template: "templates/scene/config/basics.hbs"},
    grid: {template: "templates/scene/config/grid.hbs"},
    lighting: {template: "templates/scene/config/lighting.hbs", scrollable: [""]},
    ambience: {template: "templates/scene/config/ambience.hbs", scrollable: ["div.tab[data-tab=environment]"]},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /** @override */
  static TABS = {
    sheet: {
      tabs: [
        {id: "basics", icon: "fa-solid fa-image"},
        {id: "grid", icon: "fa-solid fa-grid"},
        {id: "lighting", icon: "fa-solid fa-lightbulb"},
        {id: "ambience", icon: "fa-solid fa-cloud-sun"}
      ],
      initial: "basics",
      labelPrefix: "SCENE.TABS.SHEET"
    },
    ambience: {
      tabs: [
        {id: "basic", icon: "fa-solid fa-table-list"},
        {id: "environment", icon: "fa-solid fa-cloud-sun"}
      ],
      initial: "basic",
      labelPrefix: "SCENE.TABS.AMBIENCE"
    }
  };

  /* -------------------------------------------- */

  /**
   * Should the width and height change together to maintain aspect ratio?
   * @type {boolean}
   */
  #linkedDimensions = true;

  /**
   * The last inputted scene dimension values, or otherwise the source values: used to track successive changes while
   * dimensions are linked.
   * @type {{width: number; height: number}}
   */
  #lastDimensionValues = {
    width: this.document._source.width,
    height: this.document._source.height
  };

  /* -------------------------------------------- */

  /**
   * Get an enumeration of the available grid types which can be applied to this Scene
   * @returns {Record<GRID_TYPES, string>}
   * @internal
   */
  static _getGridTypes() {
    const labels = {
      GRIDLESS: "SCENE.GridGridless",
      SQUARE: "SCENE.GridSquare",
      HEXODDR: "SCENE.GridHexOddR",
      HEXEVENR: "SCENE.GridHexEvenR",
      HEXODDQ: "SCENE.GridHexOddQ",
      HEXEVENQ: "SCENE.GridHexEvenQ"
    };
    return Object.entries(CONST.GRID_TYPES).reduce((types, [key, value]) => {
      types[value] = game.i18n.localize(labels[key]);
      return types;
    }, {});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.tabs = this._prepareTabs("sheet");
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "basics":
        context.ownerships = [
          {value: 0, label: game.i18n.localize("SCENE.AccessibilityGM")},
          {value: 2, label: game.i18n.localize("SCENE.AccessibilityAll")}
        ];
        break;
      case "grid":
        context.pixelsLabel = game.i18n.localize("SCENE.Pixels");
        context.minGrid = CONST.GRID_MIN_SIZE;
        context.gridTypes = this.constructor._getGridTypes();
        context.gridStyles = Object.entries(CONFIG.Canvas.gridStyles).reduce((styles, [key, value]) => {
          styles[key] = game.i18n.localize(value.label);
          return styles;
        }, {});
        break;
      case "lighting":
        context.environmentFields = context.fields.environment.fields;
        context.fogFields = context.fields.fog.fields;
        context.globalLight = context.source.environment.globalLight;
        break;
      case "ambience": {
        context.baseFields = context.fields.environment.fields.base.fields;
        context.darkFields = context.fields.environment.fields.dark.fields;
        context.pages = this.#documentsToOptions(context.document.journal?.pages, {sortBy: "sort"});
        context.sounds = this.#documentsToOptions(context.document.playlist?.sounds, {sortBy: "name"});
        context.baseHueDisabled = (context.source.environment.base.intensity === 0);
        context.darkHueDisabled = (context.source.environment.dark.intensity === 0);
        context.weatherTypes = Object.entries(CONFIG.weatherEffects).reduce((types, [key, value]) => {
          types[key] = game.i18n.localize(value.label);
          return types;
        }, {});
        context.subtabs = this._prepareTabs("ambience");
        break;
      }
      case "footer":
        context.buttons = this.#prepareButtons();
    }
    if ( partId in context.tabs ) context.tab = context.tabs[partId];
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare buttons that are presented for this application view.
   * @returns {FormFooterButton[]}
   */
  #prepareButtons() {
    return [
      {
        type: "button",
        icon: "fa-solid fa-arrow-rotate-left",
        label: "SCENE.Ambience.ResetEnvironment",
        action: "resetEnvironment"
      },
      {type: "submit", icon: "fa-solid fa-floppy-disk", label: "SETTINGS.Save"}
    ];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  changeTab(tab, group, options) {
    super.changeTab(tab, group, options);
    this.#onChangeTab();
  }

  /* -------------------------------------------- */

  /**
   * Get the embedded documents of a linked JournalEntry or Playlist
   * @param {EmbeddedCollection|undefined} collection
   * @param {object} options
   * @param {"name"|"sort"} options.sortBy
   * @returns {{value: string; label: string}[]}
   */
  #documentsToOptions(collection, {sortBy}) {
    if ( !collection ) return [];
    const sorted = sortBy === "name"
      ? collection.contents.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang))
      : collection.contents.sort((a, b) => a.sort - b.sort);
    return sorted.reduce((options, document) => {
      options[document.id] = document.name;
      return options;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Live update the scene as certain properties are changed.
   * @param {string} changed The changed property
   * @param {object} [options]
   * @param {boolean} [options.force] Should the preview be forced, regardless of changes?
   * @internal
   */
  _previewScene(changed, {force=false}={}) {
    if ( !this.document.isView || !canvas.ready || (!changed && !force) ) return;

    // Preview triggered for the grid
    if ( force || ["grid.style", "grid.thickness", "grid.color", "grid.alpha"].includes(changed) ) {
      const elements = this.form.elements;
      canvas.interface.grid.initializeMesh({
        style: elements["grid.style"].value,
        thickness: Math.max(1, Number(elements["grid.thickness"].value) || 0),
        color: elements["grid.color"].value,
        alpha: Number(elements["grid.alpha"].value) || 0
      });
    }

    // Preview triggered for environment changes or the ambience manager
    const colorChanged = /color/i.test(changed);
    const environmentChanged = changed.includes("environment.");
    if ( force || colorChanged || environmentChanged ) canvas.environment.initialize(this.#getAmbienceFormData());
  }

  /* -------------------------------------------- */

  /**
   * Get the ambience form data.
   * @returns {object}
   */
  #getAmbienceFormData() {
    const formData = new FormDataExtended(this.form);
    const submitData = foundry.utils.expandObject(formData.object);
    return {
      backgroundColor: submitData.backgroundColor,
      fogExploredColor: submitData.fog.colors.explored,
      fogUnexploredColor: submitData.fog.colors.unexplored,
      environment: submitData.environment
    };
  }

  /* -------------------------------------------- */

  /**
   * Reset the previewed darkness level, background color, grid alpha, and grid color back to their true values.
   */
  #resetScenePreview() {
    if ( !this.document.isView || !canvas.ready ) return;
    canvas.scene.reset();
    canvas.environment.initialize();
    canvas.interface.grid.initializeMesh(canvas.scene.grid);
  }

  /* -------------------------------------------- */

  /** @override */
  async _processSubmitData(event, form, submitData, options) {
    // Determine the type of changes
    const scene = this.document;
    const current = scene._source;
    const hasDefaultDims = !current.background.src && (current.width === 4000) && (current.height === 3000);
    const hasImage = !!(submitData.background.src || current.background.src);
    const changedBackground = (submitData.background.src !== undefined)
      && (submitData.background.src !== current.background.src);
    const clearedDims = (submitData.width === null) || (submitData.height === null);
    const needsThumb = changedBackground || !current.thumb;
    const needsDims = submitData.background.src && (clearedDims || hasDefaultDims);
    const createThumbnail = hasImage && (needsThumb || needsDims);

    // Generate thumbnail and update dimensions if required
    if ( createThumbnail ) {
      if ( game.settings.get("core", "noCanvas") ) {
        ui.notifications.warn("SCENE.GenerateThumbNoCanvas", {localize: true});
        submitData.thumb = null;
      }
      else {
        try {
          const {thumb, width, height} = await scene.createThumbnail({
            img: submitData.background.src ?? current.background.src
          });
          if ( needsThumb ) submitData.thumb = thumb || null;
          if ( needsDims ) {
            submitData.width = width;
            submitData.height = height;
          }
        }
        catch(error) {
          if ( error.cause?.thumbUploadDenied ) {
            ui.notifications.warn("SCENES.GenerateThumbUploadDenied", {localize: true});
            delete submitData.thumb; // Reusing the current thumb image if any exist
          }
          else {
            Hooks$1.onError("SceneConfig#_processSubmitData", error, {
              msg: "Thumbnail generation for Scene failed",
              notify: "error",
              log: "error",
              scene: current._id
            });
          }
        }
      }
    }

    // Warn if scene dimensions are changing
    const delta = foundry.utils.diffObject(current, submitData);
    const changes = foundry.utils.flattenObject(delta);
    const textureChangeKeys = ["scaleX", "scaleY", "rotation"].map(k => `background.${k}`);
    if ( ["grid.size", ...textureChangeKeys].some(k => k in changes) ) {
      const confirm = await foundry.applications.api.DialogV2.confirm({
        window: {title: "SCENE.DimensionChangeTitle"},
        content: `<p>${game.i18n.localize("SCENE.DimensionChangeWarning")}</p>`
      });
      if ( !confirm ) return;
    }

    // Handle nonuniform canvas size changes
    let autoReposition = false;
    if ( (current.background.src || current.foreground)
      && ["width", "height", "padding", "grid.size"].some(x => x in changes)
    ) {
      autoReposition = true;
      const aspectRatioChanged = ("width" in changes) && ("height" in changes)
        && ((submitData.width / submitData.height) !== (current.width / current.height));

      if ( aspectRatioChanged || ["width", "height"].some(k => k in changes) ) {
        const confirm = await foundry.applications.api.DialogV2.confirm({
          window: {title: "SCENE.DistortedDimensionsTitle"},
          content: game.i18n.localize("SCENE.DistortedDimensionsWarning")
        });
        if ( !confirm ) autoReposition = false;
      }
    }

    // Remove unneeded fields and perform the update
    delete submitData.environment.darknessLock;
    await scene.update(submitData, {...options, autoReposition});
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.#onChangeTab();
    if ( options.parts.includes("grid") ) {
      const {width, height} = this.document._source;
      this.#lastDimensionValues = {width, height};
    }
  }

  /* -------------------------------------------- */

  /**
   * Show or hide the environment reset button depending on which tab and subtab are in view.
   */
  #onChangeTab() {
    const resetButton = this.element.querySelector("button[data-action=resetEnvironment]");
    resetButton.hidden = (this.tabGroups.sheet !== "ambience") || (this.tabGroups.ambience !== "environment");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    const formElements = this.form.elements;
    switch (event.target) {
      case formElements.journal:
        return this.#onChangeJournalEntry(event.target.value);
      case formElements.playlist:
        return this.#onChangePlaylist(event.target.value);
      case formElements.width:
      case formElements.height:
        this.#onChangeDimensions(event.target);
        break;
      case formElements["environment.base.intensity"]:
      case formElements["environment.dark.intensity"]: {
        const hueSlider = event.target.closest("fieldset").querySelector("hue-slider");
        hueSlider.disabled = event.target.value === 0;
        break;
      }
      case formElements["environment.darknessLock"]:
        this.#onChangeDarknessLock(event.target.checked);
    }
    this._previewScene(event.target.name);
  }

  /* -------------------------------------------- */

  /**
   * Handle updating the select menu of JournalEntryPage options when the JournlEntry is changed.
   * @param {string} entryId The id of the parent JournalEntry
   */
  #onChangeJournalEntry(entryId) {
    const entry = game.journal.get(entryId);
    const pages = entry?.pages.contents.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang)) ?? [];
    const options = pages.map(page => {
      const option = document.createElement("option");
      option.value = page.id;
      option.innerText = page.name;
      return option.outerHTML;
    }).join("");
    const select = this.form.elements.journalEntryPage;
    select.innerHTML = `<option></option>${options}`;
    const currentId = this.document.journalEntryPage;
    if ( entry?.pages.has(currentId) ) select.value = currentId;
  }

  /* -------------------------------------------- */

  /**
   * Handle updating the select menu of PlaylistSound options when the Playlist is changed.
   * @param {string} playlistId The id of the parent Playlist
   */
  #onChangePlaylist(playlistId) {
    const playlist = game.playlists.get(playlistId);
    const sounds = playlist?.sounds.contents.sort((a, b) => a.sort - b.sort) ?? [];
    const options = sounds.map(sound => {
      const option = document.createElement("option");
      option.value = sound.id;
      option.innerText = sound.name;
      return option.outerHTML;
    }).join("");
    const select = this.form.elements.playlistSound;
    select.innerHTML = `<option></option>${options}`;
    const currentId = this.document.playlistSound?.id;
    if ( playlist?.sounds.has(currentId) ) select.value = currentId;
  }

  /* -------------------------------------------- */

  /**
   * Capture the current Scene position and zoom level as the initial view in the Scene config
   * @this {SceneConfig}
   * @type {ApplicationClickAction}
   */
  static #onCapturePosition(event) {
    if ( !canvas.ready ) return;
    const form = this.form;
    form.elements["initial.x"].value = parseInt(canvas.stage.pivot.x);
    form.elements["initial.y"].value = parseInt(canvas.stage.pivot.y);
    form.elements["initial.scale"].value = canvas.stage.scale.x;
    ui.notifications.info("SCENE.CaptureInitialViewPosition", {localize: true});
  }

  /* -------------------------------------------- */

  /**
   * Capture the current Scene position and zoom level as the initial view in the Scene config
   * @this {SceneConfig}
   * @type {ApplicationClickAction}
   */
  static #onOpenGridConfig() {
    new foundry.applications.apps.GridConfig({document: this.document}).render({force: true});
    return this.minimize();
  }

  /* -------------------------------------------- */

  /**
   * Link or unlink the scene dimensions
   * @this {SceneConfig}
   * @type {ApplicationClickAction}
   */
  static #onToggleLinkDimensions(_event, button) {
    this.#linkedDimensions = !this.#linkedDimensions;
    const icon = button.querySelector("i");
    icon.classList.toggle("fa-link-simple", this.#linkedDimensions);
    icon.classList.toggle("fa-link-simple-slash", !this.#linkedDimensions);

    // Update Tooltip
    button.dataset.tooltip = this.#linkedDimensions ? "SCENE.DimensionLinked" : "SCENE.DimensionUnlinked";
    button.ariaLabel = game.i18n.localize(button.dataset.tooltip);
    game.tooltip.activate(button);
  }

  /* -------------------------------------------- */

  /**
   * Handle updating dimensions given the dimension link is toggled on
   * @param {HTMLInputElement} input The width or height input
   */
  #onChangeDimensions(input) {
    if ( !this.#linkedDimensions ) return;
    const widthInput = this.form.elements.width;
    const heightInput = this.form.elements.height;
    const value = input.valueAsNumber;
    const oldValue = this.#lastDimensionValues[input.name];
    const scale = value / oldValue;
    const otherInput = input === widthInput ? heightInput : widthInput;
    const otherValue = otherInput.valueAsNumber * scale;

    // If the new values are not integers, display an error and revert
    if ( Number.isInteger(value) && Number.isInteger(otherValue) ) otherInput.value = otherValue;
    else {
      ui.notifications.error(game.i18n.localize("SCENE.InvalidDimension"));
      input.value = oldValue;
    }
    this.#lastDimensionValues = {
      width: widthInput.valueAsNumber,
      height: heightInput.valueAsNumber
    };
  }

  /* -------------------------------------------- */

  /**
   * Handle darkness lock change and update immediately the database.
   * @param {boolean} darknessLock Is the darkness lock checked?
   */
  async #onChangeDarknessLock(darknessLock) {
    const levelInput = this.form.elements["environment.darknessLevel"];
    levelInput.disabled = darknessLock;
    const darknessLevel = levelInput.value;
    await this.document.update({environment: {darknessLock, darknessLevel}}, {render: false});
  }

  /**
   * Capture the current Scene position and zoom level as the initial view in the Scene config
   * @this {SceneConfig}
   * @type {ApplicationClickAction}
   */
  static async #onResetEnvironment() {
    const document = this.document;
    const def = Scene$1.cleanData().environment;
    const ori = foundry.utils.deepClone(document._source.environment);
    const defaults = {base: def.base, dark: def.dark};
    const original = {base: ori.base, dark: ori.dark};

    // Reset the elements to the default values
    const elements = this.form.elements;
    for ( const target of ["base", "dark"] ) {
      elements[`environment.${target}.hue`].disabled = (defaults[target].intensity === 0);
      elements[`environment.${target}.intensity`].value = defaults[target].intensity;
      elements[`environment.${target}.luminosity`].value = defaults[target].luminosity;
      elements[`environment.${target}.saturation`].value = defaults[target].saturation;
      elements[`environment.${target}.shadows`].value = defaults[target].shadows;
      elements[`environment.${target}.hue`].value = defaults[target].hue;
    }

    // Update the document with the default environment values
    document.updateSource({environment: defaults});

    // Preview the scene and re-render the config
    this._previewScene("", {force: true});
    await this.render();

    // Restore original environment values
    document.updateSource({environment: original});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    this.#resetScenePreview();
  }
}

/**
 * @import {ApplicationClickAction, ApplicationConfiguration} from "../_types.mjs";
 * @import {DocumentSheetConfiguration} from "../api/document-sheet.mjs";
 * @import {GridMesh} from "@client/canvas/containers/_module.mjs";
 */

/**
 * A tool for fine-tuning the grid in a Scene
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class GridConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /**
   * @param {ApplicationConfiguration & DocumentSheetConfiguration} options
   */
  constructor(options) {
    super(options);
    this.sheet = options.document.sheet;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["grid-config"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-ruler-combined"
    },
    position: {width: 480},
    form: {
      closeOnSubmit: true
    },
    actions: {
      resetChanges: GridConfig.#onResetChanges
    },
    sheetConfig: false
  };

  /** @override */
  static PARTS = {
    form: {
      template: "templates/scene/grid-config.hbs",
      root: true
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * Track the Scene Configuration sheet reference.
   * @type {SceneConfig}
   */
  sheet;

  /**
   * A reference to the bound key handler function.
   * @type {(event: KeyboardEvent) => void}
   */
  #keyHandler;

  /**
   * A reference to the bound mousewheel handler function.
   * @type {(event: WheelEvent) => void}
   */
  #wheelHandler;

  /**
   * The preview scene
   * @type {Scene|null}
   */
  #previewScene = null;

  /**
   * The container containing the preview background image and grid
   * @type {PIXI.Container|null}
   */
  #previewContainer = null;

  /**
   * The background preview
   * @type {PIXI.Sprite|null}
   */
  #background = null;

  /**
   * The grid preview
   * @type {GridMesh|null}
   */
  #grid = null;

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("SCENE.GridConfigTool");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    const states = GridConfig.RENDER_STATES;
    if ( options?.force && [states.CLOSED, states.NONE].includes(this.state) ) {
      if ( !this.document.background.src ) {
        ui.notifications.warn("WARNING.GridConfigNoBG", {localize: true});
      }
      this.#previewScene = this.document.clone();
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const scene = this.#previewScene;
    const bg = foundry.canvas.getTexture(scene.background.src);
    return Object.assign(context, {
      scene,
      gridTypes: SceneConfig._getGridTypes(),
      scale: scene.background.src ? this.document.width / bg.width : 1,
      pixelsLabel: game.i18n.localize("SCENE.Pixels"),
      buttons: [
        {type: "button", icon: "fa-solid fa-arrow-rotate-left", label: "SCENE.GridReset", action: "resetChanges"},
        {type: "submit", icon: "fa-solid fa-floppy-disk", label: "SETTINGS.Save"}
      ]
    });
  }

  /* -------------------------------------------- */
  /*  Application Life-Cycle Events               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    await this.#createPreview();
    this.#keyHandler ??= this.#onKeyDown.bind(this);
    document.addEventListener("keydown", this.#keyHandler);
    this.#wheelHandler ??= this.#onWheel.bind(this);
    document.addEventListener("wheel", this.#wheelHandler, {passive: false});
  }


  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    document.removeEventListener("keydown", this.#keyHandler);
    document.removeEventListener("wheel", this.#wheelHandler);
    this.#keyHandler = this.#wheelHandler = undefined;
    this.sheet.maximize();
    this.#previewScene = null;
    this.#destroyPreviewContainer();
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onChangeForm(formConfig, event) {
    await super._onChangeForm(formConfig, event);
    const formData = new FormDataExtended(this.form);
    const previewData = this._prepareSubmitData(event, this.form, formData);
    this.#previewChanges(previewData);
  }

  /* -------------------------------------------- */

  /**
   * 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, down, left, right].flat();
    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.
   * @this {GridConfig}
   * @type {ApplicationClickAction}
   */
  static async #onResetChanges() {
    if ( !this.#previewScene ) return;
    this.#previewScene = this.document.clone();
    await this.render();
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    const bg = foundry.canvas.getTexture(this.#previewScene.background.src);
    const tex = bg ? bg : {width: this.document.width, height: this.document.height};
    submitData.width = tex.width * submitData.scale;
    submitData.height = tex.height * submitData.scale;
    delete submitData.scale;
    return submitData;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _processSubmitData(event, form, submitData, options) {
    const changes = flattenObject(diffObject(this.document._source, submitData));
    const fieldNames = [
      "width",
      "height",
      "padding",
      "background.offsetX",
      "background.offsetY",
      "grid.size",
      "grid.type"
    ];
    const hasChanges = fieldNames.some(k => k in changes);
    const confirmed = hasChanges && await DialogV2.confirm({
      window: {title: "SCENE.DimensionChangeTitle"},
      content: `<p>${game.i18n.localize("SCENE.DimensionChangeWarning")}</p>`
    });
    if ( confirmed ) return super._processSubmitData(event, form, submitData, options);
  }

  /* -------------------------------------------- */
  /*  Previewing and Updating Functions           */
  /* -------------------------------------------- */

  /**
   * Create the preview container.
   */
  async #createPreview() {
    const scene = this.#previewScene;
    if ( !scene ) return;
    if ( this.#previewContainer ) this.#destroyPreviewContainer();
    const container = this.#previewContainer = canvas.stage.addChild(new PIXI.Container());
    container.eventMode = "none";
    const fill = container.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 = container.addChild(new PIXI.Sprite());
    this.#background.eventMode = "none";
    if ( scene.background.src ) {
      try {
        this.#background.texture = await foundry.canvas.loadTexture(scene.background.src);
      } catch(error) {
        this.#background.texture = PIXI.Texture.WHITE;
        console.error(error);
      }
    } else {
      this.#background.texture = PIXI.Texture.WHITE;
      this.#background.tint = 0x999999;
    }
    this.#grid = container.addChild(new foundry.canvas.containers.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.#previewScene ) return;
    if ( change ) this.#previewScene.updateSource(change);
    this.#refreshPreview();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the preview
   */
  #refreshPreview() {
    if ( !this.#previewScene || (this.#previewContainer?.destroyed !== false) ) return;

    // Update the background image
    const dims = this.#previewScene.dimensions;
    this.#background.position.set(dims.sceneX, dims.sceneY);
    this.#background.width = dims.sceneWidth;
    this.#background.height = dims.sceneHeight;

    // Update the grid
    this.#grid.initialize({
      type: this.#previewScene.grid.type,
      width: dims.width,
      height: dims.height,
      size: dims.size
    });
  }

  /* -------------------------------------------- */

  /**
   * Destroy the preview container.
   */
  #destroyPreviewContainer() {
    if ( this.#previewContainer?.destroyed === false ) this.#previewContainer.destroy({children: true});
    this.#previewContainer = 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) {
    if ( !this.#previewScene?.background.src ) return;
    const input = this.form.elements.scale;
    const rawValue = Number(input.value) || 0;
    const scale = (rawValue + (delta * 0.001)).toNearest(0.001);
    input.value = Math.clamp(scale, 0.25, 10.0);
    input.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 the minimum size and 300px.
   * @param {number} delta          The grid size in pixels
   */
  #scaleGridSize(delta) {
    const sizeInput = this.form.elements["grid.size"];
    const gridSize = (Number(sizeInput.value) || 0) + delta;
    sizeInput.value = Math.clamp(gridSize, CONST.GRID_MIN_SIZE, 300);
    sizeInput.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}) {
    if ( !this.#previewScene?.background.src ) return;
    const oxInput = this.form.elements["background.offsetX"];
    oxInput.value = (Number(oxInput.value) || 0) + deltaX;
    const oyInput = this.form.elements["background.offsetY"];
    oyInput.value = (Number(oyInput.value) || 0) + deltaY;
    oxInput.dispatchEvent(new Event("change", {bubbles: true}));
  }
}

/**
 * @import Document from "@common/abstract/document.mjs";
 * @import {ApplicationConfiguration} from "../_types.mjs";
 */

/**
 * @typedef ImagePopoutConfiguration
 * @property {string} src              The URL to the image or video file
 * @property {string} [caption]        Caption text to display below the image.
 * @property {string|null} [uuid=null] The UUID of some related {@link foundry.abstract.Document}.
 * @property {boolean} [showTitle]     Force showing or hiding the title.
 */

/**
 * @typedef ShareImageConfig
 * @property {string} image         The image URL to share.
 * @property {string} title         The image title.
 * @property {string} [uuid]        The UUID of a {@link foundry.abstract.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.
 */


/**
 * 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.
 *
 * @extends {ApplicationV2<ApplicationConfiguration & ImagePopoutConfiguration>}
 * @mixes HandlebarsApplication
 * @property {string} src The URL to the image or video file
 * @example Creating an Image Popout
 * ```js
 * // Construct the Application instance
 * const ip = new ImagePopout({
 *   src: "path/to/image.jpg",
 *   uuid: game.actors.getName("My Hero").uuid
 *   window: {title: "My Featured Image"}
 * });
 *
 * // Display the image popout
 * ip.render(true);
 *
 * // Share the image with other connected players
 * ip.shareImage();
 * ```
 */
class ImagePopout extends HandlebarsApplicationMixin(ApplicationV2) {
  // eslint-disable-next-line jsdoc/require-param
  /**
   * @param {ApplicationConfiguration & ImagePopoutConfiguration} options Application configuration options
   */
  constructor(options, _options={}) {
    if ( typeof options === "string" ) {
      foundry.utils.logCompatibilityWarning(
        "An ImagePopout image path must be assigned to options.src.",
        {since: 13, until: 15}
      );
      _options.src = options;
      super(_options);
    } else {
      super(options);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["image-popout"],
    caption: "",
    uuid: null,
    actions: {shareImage: function() {this.shareImage();}},
    window: {
      resizable: true,
      icon: "fa-solid fa-image",
      controls: [{
        label: "JOURNAL.ActionShow",
        icon: "fa-solid fa-eye",
        action: "shareImage",
        visible: () => game.user.isGM
      }]
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {popout: {template: "templates/apps/image-popout.hbs"}};

  /* -------------------------------------------- */

  /**
   * A cached reference to the related Document.
   * @type {Document|null}
   */
  #related = null;

  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    const isVisible = this.options.showTitle ?? this.#related?.testUserPermission(game.user, "LIMITED") ?? true;
    return isVisible ? super.title : "";
  }

  /* -------------------------------------------- */

  /**
   * Whether the application should display video content.
   * @type {boolean}
   */
  get isVideo() {
    return foundry.helpers.media.VideoHelper.hasVideoExtension(this.options.src);
  }

  /* -------------------------------------------- */

  /**
   * Share the displayed image with other connected Users
   * @param {ShareImageConfig} [options]
   */
  shareImage(options={}) {
    const title = options.title ?? this.options.window.title;
    game.socket.emit("shareImage", {
      image: options.image ?? this.options.src,
      title,
      caption: options.caption ?? this.options.caption,
      uuid: options.uuid ?? this.options.uuid,
      showTitle: options.showTitle ?? this.options.showTitle,
      users: Array.isArray(options.users) ? options.users : undefined
    });
    ui.notifications.info("JOURNAL.ActionShowSuccess", {format: {mode: "image", title, which: "all"}});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    const initialized = super._initializeApplicationOptions(options);
    if ( typeof initialized.title === "string" ) {
      foundry.utils.logCompatibilityWarning(
        "An ImagePopout title must be assigned to options.window.title.",
        {since: 13, until: 15}
      );
      initialized.window.title = initialized.title;
      delete initialized.title;
    }
    return initialized;
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    if ( options.isFirstRender ) {
      this.#related = this.options.uuid ? await foundry.utils.fromUuid(this.options.uuid) : null;
      options.window.title = this.options.window.title = this.title.trim();
    }
    const title = this.options.window.title;
    return {
      caption: this.options.caption,
      image: this.options.src,
      isVideo: this.isVideo,
      title,
      altText: title || game.i18n.localize("APPLICATION.IMAGE_POPOUT.AltText")
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async _preFirstRender(_context, options) {
    Object.assign(options.position, await ImagePopout.#getDimensions(this.options.src));
  }

  /* -------------------------------------------- */
  /*  Helper Methods                              */
  /* -------------------------------------------- */

  /**
   * Determine the correct dimensions for the displayed image
   * @param {string} img  The image URL.
   * @returns {Object}    The positioning object which should be used for rendering
   */
  static async #getDimensions(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 = () => {
        resolve([img.width, img.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;
    });
  }

  /* -------------------------------------------- */

  /**
   * 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({src: image, caption, uuid, showTitle, window: {title}});
    ip.render(true);
    return ip;
  }
}

/**
 * An application for configuring the permissions which are available to each User role.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class PermissionConfig extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "permissions-config",
    tag: "form",
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-shield-keyhole",
      title: "PERMISSION.Title"
    },
    position: {width: 660},
    form: {
      closeOnSubmit: true,
      handler: PermissionConfig.#onSubmit
    },
    actions: {
      reset: PermissionConfig.#onReset
    }
  };

  /** @override */
  static PARTS = {
    permissions: {
      id: "permissions",
      template: "templates/apps/permission-config.hbs",
      root: true,
      scrollable: [".permissions-list"]
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };


  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(_options={}) {
    const current = await game.settings.get("core", "permissions");
    return {
      roles: Object.keys(CONST.USER_ROLES).reduce((obj, r) => {
        if ( r === "NONE" ) return obj;
        obj[r] = `USER.Role${r.titleCase()}`;
        return obj;
      }, {}),
      permissions: this.#preparePermissions(current),
      buttons: [
        {type: "reset", action: "reset", icon: "fa-solid fa-arrows-rotate", label: "PERMISSION.Reset"},
        {type: "submit", icon: "fa-solid fa-floppy-disk", label: "PERMISSION.Submit"}
      ]
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare the permissions object used to render the configuration template
   * @param {object} current      The current permission configuration
   * @returns {object[]}          Permission data for sheet rendering
   */
  #preparePermissions(current) {
    const r = CONST.USER_ROLES;
    const rgm = r.GAMEMASTER;

    // Get permissions
    const perms = Object.entries(CONST.USER_PERMISSIONS).reduce((arr, e) => {
      const perm = foundry.utils.deepClone(e[1]);
      perm.id = e[0];
      perm.label = game.i18n.localize(perm.label);
      perm.hint = game.i18n.localize(perm.hint);
      arr.push(perm);
      return arr;
    }, []);
    perms.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));

    // Configure permission roles
    for ( const p of perms ) {
      const roles = current[p.id] || Array.fromRange(rgm + 1).slice(p.defaultRole);
      p.roles = Object.values(r).reduce((arr, role) => {
        if ( role === r.NONE ) return arr;
        arr.push({
          name: `${p.id}.${role}`,
          value: roles.includes(role),
          readonly: (role === rgm) && (!p.disableGM) ? "readonly" : ""
        });
        return arr;
      }, []);
    }
    return perms;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle submission
   * @this {DocumentSheetV2}                      The handler is called with the application as its bound scope
   * @param {SubmitEvent} event                   The originating form submission event
   * @param {HTMLFormElement} form                The form element that was submitted
   * @param {FormDataExtended} formData           Processed data for the submitted form
   * @returns {Promise<void>}
   */
  static async #onSubmit(event, form, formData) {
    const permissions = foundry.utils.expandObject(formData.object);
    for ( const [k, v] of Object.entries(permissions) ) {
      if ( !(k in CONST.USER_PERMISSIONS ) ) {
        delete permissions[k];
        continue;
      }
      permissions[k] = Object.entries(v).reduce((arr, r) => {
        if ( r[1] === true ) arr.push(parseInt(r[0]));
        return arr;
      }, []);
    }
    await game.settings.set("core", "permissions", permissions);
    ui.notifications.info("SETTINGS.PermissionUpdate", {localize: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle click actions to reset all permissions back to their initial state.
   * @this {PermissionConfig}
   * @param {PointerEvent} event
   * @returns {Promise<void>}
   */
  static async #onReset(event) {
    event.preventDefault();
    const defaults = Object.entries(CONST.USER_PERMISSIONS).reduce((obj, [id, perm]) => {
      obj[id] = Array.fromRange(CONST.USER_ROLES.GAMEMASTER + 1).slice(perm.defaultRole);
      return obj;
    }, {});
    await game.settings.set("core", "permissions", defaults);
    ui.notifications.info("SETTINGS.PermissionReset", {localize: true});
    await this.render();
  }
}

var _module$M = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CombatTrackerConfig: CombatTrackerConfig,
  CompendiumArtConfig: CompendiumArtConfig,
  DocumentOwnershipConfig: DocumentOwnershipConfig,
  DocumentSheetConfig: DocumentSheetConfig,
  FilePicker: FilePicker,
  GridConfig: GridConfig,
  ImagePopout: ImagePopout,
  PermissionConfig: PermissionConfig,
  av: _module$N
});

/**
 * @typedef DiceTermFulfillmentDescriptor
 * @property {string} id        A unique identifier for the term.
 * @property {DiceTerm} term    The term.
 * @property {string} method    The fulfillment method.
 * @property {boolean} [isNew]  Was the term newly-added to this resolver?
 */

/**
 * An application responsible for handling unfulfilled dice terms in a roll.
 * @extends {ApplicationV2<ApplicationConfiguration, ApplicationRenderOptions>}
 * @mixes HandlebarsApplication
 */
class RollResolver extends HandlebarsApplicationMixin(ApplicationV2) {
  constructor(roll, options={}) {
    super(options);
    this.#roll = roll;
  }

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "roll-resolver-{id}",
    tag: "form",
    classes: ["roll-resolver"],
    window: {
      title: "DICE.RollResolution"
    },
    position: {width: 500},
    form: {
      submitOnChange: false,
      closeOnSubmit: false,
      handler: this._fulfillRoll
    }
  };

  /** @override */
  static PARTS = {
    form: {
      id: "form",
      template: "templates/dice/roll-resolver.hbs"
    }
  };

  /**
   * A collection of fulfillable dice terms.
   * @type {Map<string, DiceTermFulfillmentDescriptor>}
   */
  get fulfillable() {
    return this.#fulfillable;
  }

  #fulfillable = new Map();

  /**
   * A function to call when the first pass of fulfillment is complete.
   * @type {function}
   */
  #resolve;

  /**
   * The roll being resolved.
   * @type {Roll}
   */
  get roll() {
    return this.#roll;
  }

  #roll;

  /* -------------------------------------------- */

  /**
   * Identify any terms in this Roll that should be fulfilled externally, and prompt the user to do so.
   * @returns {Promise<void>}  Returns a Promise that resolves when the first pass of fulfillment is complete.
   */
  async awaitFulfillment() {
    const fulfillable = await this.#identifyFulfillableTerms(this.roll.terms);
    if ( !fulfillable.length ) return;
    Roll.defaultImplementation.RESOLVERS.set(this.roll, this);
    this.render(true);
    return new Promise(resolve => this.#resolve = resolve);
  }

  /* -------------------------------------------- */

  /**
   * Register a fulfilled die roll.
   * @param {string} method        The method used for fulfillment.
   * @param {string} denomination  The denomination of the fulfilled die.
   * @param {number} result        The rolled number.
   * @returns {boolean}            Whether the result was consumed.
   */
  registerResult(method, denomination, result) {
    const query = `label[data-denomination="${denomination}"][data-method="${method}"] > input:not(:disabled)`;
    const term = Array.from(this.element.querySelectorAll(query)).find(input => input.value === "");
    if ( !term ) {
      ui.notifications.warn(`${denomination} roll was not needed by the resolver.`);
      return false;
    }
    term.value = `${result}`;
    const submitTerm = term.closest(".form-fields")?.querySelector("button");
    if ( submitTerm ) submitTerm.dispatchEvent(new MouseEvent("click"));
    else this._checkDone();
    return true;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async close(options={}) {
    if ( this.rendered ) await this.constructor._fulfillRoll.call(this, null, null, new FormDataExtended(this.element));
    Roll.defaultImplementation.RESOLVERS.delete(this.roll);
    this.#resolve?.();
    return super.close(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(_options) {
    const context = {
      formula: this.roll.formula,
      groups: {}
    };
    for ( const fulfillable of this.fulfillable.values() ) {
      const { id, term, method, isNew } = fulfillable;
      fulfillable.isNew = false;
      const config = CONFIG.Dice.fulfillment.methods[method];
      const group = context.groups[id] = {
        results: [],
        label: term.expression,
        icon: config.icon ?? '<i class="fa-solid fa-bluetooth"></i>',
        tooltip: game.i18n.localize(config.label)
      };
      const { denomination, faces } = term;
      const icon = CONFIG.Dice.fulfillment.dice[denomination]?.icon;
      for ( let i = 0; i < Math.max(term.number ?? 1, term.results.length); i++ ) {
        const result = term.results[i];
        const { result: value, exploded, rerolled } = result ?? {};
        group.results.push({
          denomination, faces, id, method, icon, exploded, rerolled, isNew,
          value: value ?? "",
          readonly: method !== "manual",
          disabled: !!result
        });
      }
    }
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onSubmitForm(formConfig, event) {
    this._toggleSubmission(false);
    this.element.querySelectorAll("input").forEach(input => {
      if ( !isNaN(input.valueAsNumber) ) return;
      const { term } = this.fulfillable.get(input.name);
      input.value = `${term.randomFace()}`;
    });
    await super._onSubmitForm(formConfig, event);
    this.element?.querySelectorAll("input").forEach(input => input.disabled = true);
    this.#resolve();
  }

  /* -------------------------------------------- */

  /**
   * Handle prompting for a single extra result from a term.
   * @param {DiceTerm} term  The term.
   * @param {string} method  The method used to obtain the result.
   * @param {object} [options]
   * @param {boolean} [options.reroll=false]
   * @param {boolean} [options.explode=false]
   * @returns {Promise<number|void>}
   */
  async resolveResult(term, method, {reroll=false, explode=false}={}) {
    const group = this.element.querySelector(`fieldset[data-term-id="${term._id}"]`);
    if ( !group ) {
      console.warn("Attempted to resolve a single result for an unregistered DiceTerm.");
      return;
    }
    const fields = document.createElement("div");
    fields.classList.add("form-fields");
    const label = document.createElement("label");
    label.classList.add("icon", "die-input", "new-addition");
    label.dataset.denomination = term.denomination;
    label.dataset.method = method;
    const input = foundry.applications.fields.createNumberInput({
      name: term._id,
      min: 1,
      max: term.faces,
      step: 1,
      readonly: method !== "manual",
      placeholder: game.i18n.localize(term.denomination)
    });
    label.append(input);
    if ( reroll ) {
      label.append(createFontAwesomeIcon("arrow-rotate-right"));
    }
    if ( explode ) {
      label.append(createFontAwesomeIcon("burst"));
    }
    const icon = CONFIG.Dice.fulfillment.dice[term.denomination]?.icon;
    if ( icon ) {
      const div = document.createElement("div");
      div.innerHTML = icon;
      label.append(...div.childNodes);
    }
    const button = document.createElement("button");
    button.type = "button";
    button.classList.add("submit-result");
    button.dataset.tooltip = "DICE.SubmitRoll";
    button.ariaLabel = game.i18n.localize("DICE.SubmitRoll");
    button.innerHTML = '<i class="fas fa-arrow-right"></i>';
    fields.append(label, button);
    group.appendChild(fields);
    this.setPosition({ height: "auto" });
    return new Promise(resolve => {
      const button = fields.querySelector("button");
      const input = fields.querySelector("input");
      button.addEventListener("click", () => {
        if ( !input.validity.valid ) {
          input.form.reportValidity();
          return;
        }
        let value = input.valueAsNumber;
        if ( !value ) value = term.randomFace();
        input.value = `${value}`;
        input.disabled = true;
        button.remove();
        resolve(value);
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * Update the Roll instance with the fulfilled results.
   * @this {RollResolver}
   * @param {SubmitEvent} event          The originating form submission event.
   * @param {HTMLFormElement} form       The form element that was submitted.
   * @param {FormDataExtended} formData  Processed data for the submitted form.
   * @returns {Promise<void>}
   * @protected
   */
  static async _fulfillRoll(event, form, formData) {
    // Update the DiceTerms with the fulfilled values.
    for ( let [id, results] of Object.entries(formData.object) ) {
      const { term } = this.fulfillable.get(id);
      if ( !Array.isArray(results) ) results = [results];
      for ( const result of results ) {
        const roll = { result: undefined, active: true };
        // A null value indicates the user wishes to skip external fulfillment and fall back to the digital roll.
        if ( result === null ) roll.result = term.randomFace();
        else roll.result = result;
        term.results.push(roll);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Identify any of the given terms which should be fulfilled externally.
   * @param {RollTerm[]} terms               The terms.
   * @param {object} [options]
   * @param {boolean} [options.isNew=false]  Whether this term is a new addition to the already-rendered RollResolver.
   * @returns {Promise<DiceTerm[]>}
   */
  async #identifyFulfillableTerms(terms, { isNew=false }={}) {
    const config = game.settings.get("core", Roll.DICE_CONFIGURATION_SETTING);
    const fulfillable = Roll.defaultImplementation.identifyFulfillableTerms(terms);
    fulfillable.forEach(term => {
      if ( term._id ) return;
      const method = config[term.denomination] || CONFIG.Dice.fulfillment.defaultMethod;
      const id = foundry.utils.randomID();
      term._id = id;
      term.method = method;
      this.fulfillable.set(id, { id, term, method, isNew });
    });
    return fulfillable;
  }

  /* -------------------------------------------- */

  /**
   * Add a new term to the resolver.
   * @param {DiceTerm} term    The term.
   * @returns {Promise<void>}  Returns a Promise that resolves when the term's results have been externally fulfilled.
   */
  async addTerm(term) {
    if ( !(term instanceof foundry.dice.terms.DiceTerm) ) {
      throw new Error("Only DiceTerm instances may be added to the RollResolver.");
    }
    const fulfillable = await this.#identifyFulfillableTerms([term], { isNew: true });
    if ( !fulfillable.length ) return;
    this.render({ force: true, position: { height: "auto" } });
    return new Promise(resolve => this.#resolve = resolve);
  }

  /* -------------------------------------------- */

  /**
   * Check if all rolls have been fulfilled.
   * @protected
   */
  _checkDone() {
    // If the form has already in the submission state, we don't need to re-submit.
    const submitter = this.element.querySelector('button[type="submit"]');
    if ( submitter.disabled ) return;

    // If there are any manual inputs, or if there are any empty inputs, then fulfillment is not done.
    if ( this.element.querySelector("input:not([readonly], :disabled)") ) return;
    for ( const input of this.element.querySelectorAll("input[readonly]:not(:disabled)") ) {
      if ( input.value === "" ) return;
    }
    this.element.requestSubmit(submitter);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the state of the submit button.
   * @param {boolean} enabled  Whether the button is enabled.
   * @protected
   */
  _toggleSubmission(enabled) {
    const submit = this.element.querySelector("button[type=submit]");
    const icon = submit.querySelector("i");
    icon.className = `fa-solid ${enabled ? "fa-check" : "fa-spinner fa-pulse"}`;
    submit.disabled = !enabled;
  }
}

var _module$L = /*#__PURE__*/Object.freeze({
  __proto__: null,
  RollResolver: RollResolver
});

/**
 * An abstract custom HTMLElement designed for use with form inputs.
 * @abstract
 * @template {any} FormInputValueType
 *
 * @fires {Event} input           An "input" event when the value of the input changes
 * @fires {Event} change          A "change" event when the value of the element changes
 */
class AbstractFormInputElement extends HTMLElement {

  constructor() {
    super();
    this._internals = this.attachInternals();
  }

  /**
   * The HTML tag name used by this element.
   * @type {string}
   */
  static tagName;

  /**
   * Declare that this custom element provides form element functionality.
   * @type {boolean}
   */
  static formAssociated = true;

  /**
   * Attributes requiring change notifications
   * @type {string[]}
   */
  static observedAttributes = ["disabled"];

  /**
   * The AbortController instance used to manage event listener lifecycle.
   * @type {AbortController}
   */
  #abortController;

  /**
   * Attached ElementInternals which provides form handling functionality.
   * @type {ElementInternals}
   * @protected
   */
  _internals;

  /**
   * The primary input (if any). Used to determine what element should receive focus when an associated label is clicked
   * on.
   * @type {HTMLElement}
   * @protected
   */
  _primaryInput;

  /**
   * The form this element belongs to.
   * @type {HTMLFormElement}
   */
  get form() {
    return this._internals.form;
  }

  /* -------------------------------------------- */
  /*  Element Properties                          */
  /* -------------------------------------------- */

  /**
   * The input element name.
   * @type {string}
   */
  get name() {
    return this.getAttribute("name");
  }

  set name(value) {
    this.setAttribute("name", value);
  }

  /* -------------------------------------------- */

  /**
   * The value of the input element.
   * @type {FormInputValueType}
   */
  get value() {
    return this._getValue();
  }

  set value(value) {
    this._setValue(value);
    this.dispatchEvent(new Event("input", {bubbles: true, cancelable: true}));
    this.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
    this._refresh();
  }

  /**
   * The underlying value of the element.
   * @type {FormInputValueType}
   * @protected
   */
  _value;

  /* -------------------------------------------- */

  /**
   * Return the value of the input element which should be submitted to the form.
   * @returns {FormInputValueType}
   * @protected
   */
  _getValue() {
    return this._value;
  }

  /* -------------------------------------------- */

  /**
   * Translate user-provided input value into the format that should be stored.
   * @param {FormInputValueType} value  A new value to assign to the element
   * @throws {Error}        An error if the provided value is invalid
   * @protected
   */
  _setValue(value) {
    this._value = value;
  }

  /* -------------------------------------------- */

  /**
   * Is this element disabled?
   * @type {boolean}
   */
  get disabled() {
    return this.matches(":disabled");
  }

  set disabled(value) {
    this.toggleAttribute("disabled", value);
  }

  /* -------------------------------------------- */

  /**
   * Is this field editable? The field can be neither disabled nor readonly.
   * @type {boolean}
   */
  get editable() {
    return !(this.disabled || this.hasAttribute("readonly"));
  }

  /* -------------------------------------------- */

  /**
   * Special behaviors that the subclass should implement when toggling the disabled state of the input.
   * @param {boolean} disabled    The new disabled state
   * @protected
   */
  _toggleDisabled(disabled) {}

  /* -------------------------------------------- */

  /**
   * An AbortSignal that can be passed to event listeners registered in subclasses. The signal will ensure that the
   * listener is removed when the element is disconnected from the DOM. Not available in the constructor.
   * @type {AbortSignal}
   */
  get abortSignal() {
    return this.#abortController?.signal;
  }

  /* -------------------------------------------- */
  /*  Element Lifecycle                           */
  /* -------------------------------------------- */

  /**
   * Initialize the custom element, constructing its HTML.
   */
  connectedCallback() {
    this.#abortController = new AbortController();
    const elements = this._buildElements();
    this.replaceChildren(...elements);
    this._refresh();
    this._toggleDisabled(this.disabled);
    this.addEventListener("click", this._onClick.bind(this), { signal: this.abortSignal });
    this._activateListeners();
  }

  /* -------------------------------------------- */

  /** @override */
  disconnectedCallback() {
    this.#abortController.abort();
    this._disconnect();
  }

  /* -------------------------------------------- */

  /** @override */
  formDisabledCallback(disabled) {
    if ( !this.isConnected ) return; // Internal elements not yet created
    this._toggleDisabled(disabled);
  }

  /* -------------------------------------------- */

  /** @override */
  attributeChangedCallback(attrName, oldValue, newValue) {}

  /* -------------------------------------------- */

  /**
   * A method provided for subclasses to perform tear-down workflows as an alternative to overriding
   * disconnectedCallback.
   * @protected
   */
  _disconnect() {}

  /* -------------------------------------------- */

  /**
   * Create the HTML elements that should be included in this custom element.
   * Elements are returned as an array of ordered children.
   * @returns {HTMLElement[]}
   * @protected
   */
  _buildElements() {
    return [];
  }

  /* -------------------------------------------- */

  /**
   * Refresh the active state of the custom element.
   * @protected
   */
  _refresh() {}

  /* -------------------------------------------- */

  /**
   * Apply key attributes on the containing custom HTML element to input elements contained within it.
   * @param {HTMLElement} input
   * @internal
   */
  _applyInputAttributes(input) {
    input.toggleAttribute("required", this.hasAttribute("required"));
    input.toggleAttribute("disabled", this.hasAttribute("disabled"));
    input.toggleAttribute("readonly", this.hasAttribute("readonly"));
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Activate event listeners which add dynamic behavior to the custom element.
   * @protected
   */
  _activateListeners() {}

  /* -------------------------------------------- */

  /**
   * Special handling when the custom element is clicked. This should be implemented to transfer focus to an
   * appropriate internal element.
   * @param {PointerEvent} event
   * @protected
   */
  _onClick(event) {
    if ( event.target === this ) this._primaryInput?.focus?.();
  }
}

/**
 * @import {Diagnostic} from "@codemirror/lint";
 * @import {Extension} from "@codemirror/state";
 * @import {EditorView} from "@codemirror/view";
 * @import {CodeMirrorLanguage} from "@common/data/_types.mjs";
 */

/**
 * CodeMirror language extensions
 * @type {Record<Exclude<CodeMirrorLanguage, "">, Extension[]>}
 */
const LANGUAGES = {
  html: [basicSetup, html()],
  javascript: [
    basicSetup,
    javascript(),
    lintGutter(),
    linter(esLint(new Linter({configType: "flat"}), [
      {languageOptions: {ecmaVersion: 2023}},
      {
        processor: {
          preprocess: source => [`async () => {${source}\n}`], // Allow `await` and `return`
          postprocess: messages => messages.flat()
        }
      }
    ]))
  ],
  json: [basicSetup, json(), linter(jsonParseLinter())],
  markdown: [basicSetup, markdown({base: markdownLanguage, codeLanguages: name => {
    // "Fenced language" syntax highlighting; i.e., non-markdown languages in triple-backtick-delimited code blocks
    switch ( name ) {
      case "html":
        return htmlLanguage;
      case "js":
      case "javascript":
        return javascriptLanguage;
      case "json":
        return jsonLanguage;
      default:
        return null;
    }
  }})],
  plain: [basicSetup]
};

/**
 * CodeMirror HTML tag classes for parsed language tokens
 * @type {Extension}
 */
const HIGHLIGHT_STYLE = syntaxHighlighting(HighlightStyle.define([
  // JS(ON)
  {tag: tags.keyword, class: "stx-keyword" },
  {tag: tags.comment, class: "stx-comment"},
  {tag: [tags.keyword, tags.operatorKeyword], class: "stx-keyword"},
  {tag: tags.operator, class: "stx-operator"},
  {tag: tags.brace, class: "stx-brace"},
  {tag: tags.paren, class: "stx-parenthesis"},
  {tag: tags.squareBracket, class: "stx-square-bracket"},
  {tag: tags.separator, class: "stx-separator"},
  {tag: [tags.function(tags.variableName), tags.definition(tags.function(tags.variableName))], class: "stx-func-name"},
  {tag: tags.function(tags.propertyName), class: "stx-method-name"},
  {tag: tags.definition(tags.variableName), class: "stx-var-name" },
  {tag: tags.local(tags.variableName), class: "stx-local-var"},
  {tag: tags.className, class: "stx-class-name"},
  {tag: tags.propertyName, class: "stx-prop-name"},
  {tag: tags.definition(tags.propertyName), class: "stx-prop-def"},
  {tag: tags.string, class: "stx-string"},
  {tag: tags.regexp, class: "stx-regexp" },
  {tag: tags.number, class: "stx-number"},
  {tag: tags.bool, class: "stx-boolean"},
  {tag: tags.invalid, class: "stx-invalid"},
  // HTML
  {tag: tags.angleBracket, class: "stx-angle-bracket"},
  {tag: tags.tagName, class: "stx-tag-name"},
  {tag: tags.attributeName, class: "stx-attr-name"},
  {tag: tags.attributeValue, class: "stx-attr-value"},
  // Markdown
  {tag: tags.meta, class: "stx-meta"},
  {tag: tags.contentSeparator, class: "stx-separator"},
  {tag: tags.heading, class: "stx-heading"},
  {tag: tags.link, class: "stx-link"},
  {tag: tags.url, class: "stx-url"},
  {tag: tags.emphasis, class: "stx-emphasis"},
  {tag: tags.strong, class: "stx-strong"},
  {tag: tags.strikethrough, class: "stx-strikethrough"},
  {tag: tags.list, class: "stx-list"},
  {tag: tags.quote, class: "stx-blockquote"},
  {tag: tags.monospace, class: "stx-codeblock"}
]));

/**
 * Configure extensions for managing indentation via keypress.
 * @param {number} spaces The number of spaces added/removed per press of TAB/SHIFT-TAB
 * @returns {Extension[]}
 */
function configureIndentExtensions(spaces) {
  return spaces > 0 ? [indentUnit.of(" ".repeat(spaces)), keymap$1.of([indentWithTab])] : [];
}

/**
 * A CodeMirror EditorView that doesn't mount styles
 * @ignore
 */
class EditorViewFVTT extends EditorView$1 {
  /** @override */
  mountStyles() {}
}

/**
 * @import {CodeMirrorInputConfig, CodeMirrorLanguage, FormInputConfig} from "@common/data/_types.mjs";
 * @import {Point} from "@common/_types.mjs";
 */

/**
 * @typedef HTMLCodeMirrorOptions
 * @property {string} [value]  The initial editor contents.
 */

/**
 * A custom HTML element responsible for displaying a CodeMirror rich text editor.
 * @extends {AbstractFormInputElement<string>}
 */
class HTMLCodeMirrorElement extends AbstractFormInputElement {
  /**
   * @param {HTMLCodeMirrorOptions} [options]
   */
  constructor({ value }={}) {
    super();
    this._setValue(value ?? this.innerText);
    this.innerText = "";
  }

  /**
   * @override
   * @type {"code-mirror"}
   */
  static tagName = "code-mirror";

  /** @inheritDoc */
  static observedAttributes = super.observedAttributes.concat("language", "indent", "nowrap");

  /* -------------------------------------------- */

  /**
   * The CodeMirror view instance
   * @type {EditorView|null}
   */
  #view = null;

  /**
   * Compartment for dynamically swapping extensions
   * @type {Record<"language"|"indent"|"nowrap"|"disabled", Compartment>}
   */
  #compartments = {
    language: new Compartment(),
    indent: new Compartment(),
    nowrap: new Compartment(),
    disabled: new Compartment()
  };

  /**
   * The position of the cursor.
   * @type {number|null}
   */
  get cursor() {
    const cursor = this.#view?.state.selection.main.from;
    return Number.isFinite(cursor) ? cursor : null;
  }

  /** The "dirty" state of the editor (whether unreported changes have been made) */
  #dirty = false;

  /**
   * This element's language attribute or its default if no value is set
   * @type {CodeMirrorLanguage}
   */
  get language() {
    return this.getAttribute("language") ?? "";
  }

  /**
   * Set this element's language attribute.
   * @param {CodeMirrorLanguage} value
   */
  set language(value) {
    this.setAttribute("language", value);
  }

  /**
   * This element's indent attribute, which determines the number of spaces added upon pressing the TAB key.
   * A value of 0 disables this feature entirely.
   * @returns {number}
   */
  get indent() {
    return this.hasAttribute("indent")
      ? Number(this.getAttribute("indent")) || 0
      : 2;
  }

  /**
   * Set this element's indent attribute.
   * @param {number} value
   */
  set indent(value) {
    this.setAttribute("indent", String(Number(value) || 0));
  }

  /**
   * Whether the editor is externally managed by some other process that takes responsibility for its contents and for
   * firing events. If not set, the editor will fire its own events.
   * @type {boolean}
   */
  get managed() {
    return this.hasAttribute("managed");
  }

  /**
   * Set the editor's managed attribute.
   * @param {boolean} value
   */
  set managed(value) {
    this.toggleAttribute("managed", value);
  }

  /**
   * The element's nowrap attribute, which if present disables line-wrapping
   * @returns {boolean}
   */
  get nowrap() {
    return this.hasAttribute("nowrap");
  }

  /**
   * Set this element's nowrap attribute.
   * @param {boolean} value
   */
  set nowrap(value) {
    this.toggleAttribute("nowrap", value);
  }

  /* -------------------------------------------- */

  /**
   * @param {boolean} disabled
   * @protected
   * @override
   */
  _toggleDisabled(disabled) {
    const extension = EditorViewFVTT.editable.of(!disabled);
    this.#view?.dispatch({effects: this.#compartments.disabled.reconfigure(extension)});
  }

  /* -------------------------------------------- */

  /** @override */
  _getValue() {
    return this.#view?.state.doc.toString() ?? super._getValue();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _setValue(value) {
    super._setValue(value);
    this.#view?.dispatch({changes: {from: 0, to: this.#view.state.doc.length, insert: value}});
  }

  /* -------------------------------------------- */

  /**
   * Set the language for the view, swapping out its supporting extensions
   * @param {CodeMirrorLanguage} language
   */
  #setLanguage(language) {
    const extensions = LANGUAGES[language || "plain"];
    if ( !extensions ) throw new Error(`Language "${language}" is not supported.`);
    this.#view?.dispatch({effects: this.#compartments.language.reconfigure(extensions)});
  }

  /* -------------------------------------------- */

  /**
   * Set the number of columns added upon pressing the TAB key.
   * A value of 0 will disable this feature entirely.
   * @param {number} indent
   */
  #setIndent(indent) {
    const extensions = configureIndentExtensions(indent);
    this.#view?.dispatch({effects: this.#compartments.indent.reconfigure(extensions)});
  }

  /* -------------------------------------------- */

  /**
   * Set whether the editor view should not wrap lines.
   * @param {boolean} value
   */
  #setNowrap(value) {
    const extension = EditorViewFVTT.lineWrapping;
    this.#view?.dispatch({effects: this.#compartments.nowrap.reconfigure(value ? [] : extension)});
  }

  /* -------------------------------------------- */

  /**
   * Given screen co-ordinates, returns the position in the editor's text content at those co-ordinates.
   * @param {Point} coords  The screen co-ordinates.
   * @returns {number}
   */
  posAtCoords(coords) {
    return this.#view.posAtCoords(coords);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  scrollTo(x, y) {
    if ( typeof x === "object" ) y = x?.top;
    if ( !Number.isFinite(y) ) return;
    const selection = EditorSelection.create([EditorSelection.cursor(y)]);
    this.#view?.dispatch({ selection, scrollIntoView: true });
  }

  /* -------------------------------------------- */
  /*  Element Lifecycle                           */
  /* -------------------------------------------- */

  /** @inheritDoc */
  connectedCallback() {
    this.#view = new EditorViewFVTT({
      doc: this._getValue(),
      extensions: [
        this.#compartments.language.of(LANGUAGES[this.language || "plain"]),
        this.#compartments.indent.of(configureIndentExtensions(this.indent)),
        this.#compartments.nowrap.of(this.nowrap ? [] : EditorViewFVTT.lineWrapping),
        this.#compartments.disabled.of(EditorViewFVTT.editable.of(!this.disabled)),
        HIGHLIGHT_STYLE,
        // Set the editor to dirty on any change to it contents
        EditorViewFVTT.updateListener.of(update => {
          if ( update.docChanged ) this.#dirty = true;
        }),
        EditorViewFVTT.domEventHandlers({
          blur: this.#onBlur.bind(this),
          drop: this.#onDrop.bind(this)
        })
      ]
    });
    return super.connectedCallback();
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {
    this._primaryInput = this.#view.dom.querySelector("[contenteditable=true]");
    return [this.#view.dom];
  }

  /* -------------------------------------------- */

  /** Emulate a form change event if the editor is dirty. */
  #onBlur() {
    if ( !this.#dirty || this.managed ) return;
    const form = this.closest("form");
    const event = new Event("change");
    Object.defineProperties(event, {
      target: {value: this, enumerable: true},
      currentTarget: {value: form, enumerable: true}
    });
    form?.dispatchEvent(event);
    this.#dirty = false;
  }

  /* -------------------------------------------- */

  /**
   * Prevent default CodeMirror drop handling.
   */
  #onDrop() {
    return true;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  attributeChangedCallback(attrName, oldValue, newValue) {
    switch (attrName) {
      case "language":
        this.#setLanguage(newValue);
        break;
      case "indent":
        this.#setIndent(Number(newValue) || 0);
        break;
      case "nowrap":
        this.#setNowrap(newValue !== null);
        break;
      default:
        super.attributeChangedCallback(attrName, oldValue, newValue);
    }
  }

  /* -------------------------------------------- */

  /**
   * Call for garbage collection upon this element being removed from the DOM.
   */
  disconnectedCallback() {
    this.#view?.destroy();
    super.disconnectedCallback();
  }

  /* -------------------------------------------- */

  /**
   * Create an HTMLCodeMirrorElement element for a StringField (typically a JSONField or JavascriptField).
   * @param {FormInputConfig<string> & CodeMirrorInputConfig} config
   * @returns {HTMLCodeMirrorElement}
   */
  static create(config) {
    const element = new this({value: config.value});
    element.language = config.language ?? "";
    element.indent = config.indent ?? 2;
    if ( config.name ) element.name = config.name;
    element.innerHTML = foundry.utils.escapeHTML(config.value ?? "");
    setInputAttributes(element, config);
    return element;
  }
}

/**
 * @import {FormInputConfig} from "../../../common/data/_types.mjs";
 */

/**
 * @typedef StringTagsInputConfig
 * @property {boolean} slug               Automatically slugify provided strings?
 */

/**
 * @typedef HTMLStringTagsOptions
 * @property {boolean} [slug]     Whether to automatically slugify all strings provided to the element.
 * @property {string[]} [values]  An array of initial values.
 */

/**
 * A custom HTML element which allows for arbitrary assignment of a set of string tags.
 * This element may be used directly or subclassed to impose additional validation or functionality.
 * @extends {AbstractFormInputElement<Set<string>>}
 */
class HTMLStringTagsElement extends AbstractFormInputElement {
  /**
   * @param {HTMLStringTagsOptions} [options]
   */
  constructor({ slug, values }={}) {
    super();
    this.#slug = slug ?? this.hasAttribute("slug");
    this._value = new Set();
    this._initializeTags(values);
  }

  /** @override */
  static tagName = "string-tags";

  static icons = {
    add: "fa-solid fa-tag",
    remove: "fa-solid fa-xmark"
  };

  static labels = {
    add: "ELEMENTS.TAGS.Add",
    remove: "ELEMENTS.TAGS.Remove",
    placeholder: ""
  };

  /** @override */
  _value = new Set();

  /**
   * The button element to add a new tag.
   * @type {HTMLButtonElement}
   */
  #button;

  /**
   * The input element to enter a new tag.
   * @type {HTMLInputElement}
   */
  #input;

  /**
   * The tags list of assigned tags.
   * @type {HTMLDivElement}
   */
  #tags;

  /**
   * Automatically slugify all strings provided to the element?
   * @type {boolean}
   */
  #slug;

  /* -------------------------------------------- */

  /**
   * Initialize innerText or an initial value attribute of the element as a comma-separated list of currently assigned
   * string tags.
   * @param {string[]} [values]  An array of initial values.
   * @protected
   */
  _initializeTags(values) {
    let tags = [];
    if ( Array.isArray(values) ) tags = values;
    else {
      const initial = this.getAttribute("value") || this.innerText || "";
      if ( initial ) tags = initial.split(",");
    }
    for ( let tag of tags ) {
      tag = tag.trim();
      if ( tag ) {
        if ( this.#slug ) tag = tag.slugify({strict: true});
        try {
          this._validateTag(tag);
        } catch( err ) {
          console.warn(err.message);
          continue;
        }
        this._value.add(tag);
      }
    }
    this.innerText = "";
    this.removeAttribute("value");
  }

  /* -------------------------------------------- */

  /**
   * Subclasses may impose more strict validation on what tags are allowed.
   * @param {string} tag      A candidate tag
   * @throws {Error}          An error if the candidate tag is not allowed
   * @protected
   */
  _validateTag(tag) {
    if ( !tag ) throw new Error(game.i18n.localize("ELEMENTS.TAGS.ErrorBlank"));
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {

    // Create tags list
    const tags = document.createElement("div");
    tags.className = "tags input-element-tags";
    this.#tags = tags;

    // Create input element
    const input = document.createElement("input");
    input.type = "text";
    input.placeholder = this.getAttribute("placeholder") || game.i18n.localize(this.constructor.labels.placeholder);
    this.#input = this._primaryInput = input;

    // Create button
    const button = document.createElement("button");
    button.type = "button";
    button.className = `icon ${this.constructor.icons.add}`;
    button.dataset.tooltip = this.constructor.labels.add;
    button.ariaLabel = game.i18n.localize(this.constructor.labels.add);
    this.#button = button;
    return [this.#tags, this.#input, this.#button];
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    const tags = this.value.map(tag => this.constructor.renderTag(tag, tag, this.editable));
    this.#tags.replaceChildren(...tags);
  }

  /* -------------------------------------------- */

  /**
   * Render the tagged string as an HTML element.
   * @param {string} tag        The raw tag value
   * @param {string} [label]    An optional tag label
   * @param {boolean} [editable=true]  Is the tag editable?
   * @returns {HTMLDivElement}  A rendered HTML element for the tag
   */
  static renderTag(tag, label, editable=true) {
    const div = document.createElement("div");
    div.className = "tag";
    div.dataset.key = tag;
    const span = document.createElement("span");
    span.textContent = label ?? tag;
    div.append(span);
    if ( editable ) {
      const t = game.i18n.localize(this.labels.remove);
      const a = `<a class="remove ${this.icons.remove}" data-tooltip="${this.labels.remove}" aria-label="${t}"></a>`;
      div.insertAdjacentHTML("beforeend", a);
    }
    return div;
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    this.#button.addEventListener("click", this.#addTag.bind(this));
    this.#tags.addEventListener("click", this.#onClickTag.bind(this));
    this.#input.addEventListener("keydown", this.#onKeydown.bind(this));
    this.#input.addEventListener("change", event => event.stopPropagation());
  }

  /* -------------------------------------------- */

  /**
   * Remove a tag from the set when its removal button is clicked.
   * @param {PointerEvent} event
   */
  #onClickTag(event) {
    if ( !event.target.classList.contains("remove") || !this.editable ) return;
    const tag = event.target.closest(".tag");
    this._value.delete(tag.dataset.key);
    this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
    this._refresh();
  }

  /* -------------------------------------------- */

  /**
   * Add a tag to the set when the ENTER key is pressed in the text input.
   * @param {KeyboardEvent} event
   */
  #onKeydown(event) {
    if ( event.key !== "Enter" ) return;
    event.preventDefault();
    event.stopPropagation();
    this.#addTag();
  }

  /* -------------------------------------------- */

  /**
   * Add a new tag to the set upon user input.
   */
  #addTag() {
    if ( !this.editable ) return;
    let tag = this.#input.value.trim();
    if ( this.#slug ) tag = tag.slugify({strict: true});

    // Validate the proposed code
    try {
      this._validateTag(tag);
    } catch(err) {
      ui.notifications.error(err.message);
      this.#input.value = "";
      return;
    }

    // Ensure uniqueness
    if ( this._value.has(tag) ) {
      const message = game.i18n.format("ELEMENTS.TAGS.ErrorNonUnique", {tag});
      ui.notifications.error(message);
      this.#input.value = "";
      return;
    }

    // Add hex
    this._value.add(tag);
    this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
    this.#input.value = "";
    this._refresh();
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @override */
  _getValue() {
    return Array.from(this._value);
  }

  /* -------------------------------------------- */

  /** @override */
  _setValue(value) {
    this._value.clear();
    const toAdd = [];
    for ( let v of value ) {
      if ( this.#slug ) v = v.slugify({strict: true});
      this._validateTag(v);
      toAdd.push(v);
    }
    for ( const v of toAdd ) this._value.add(v);
  }

  /* -------------------------------------------- */

  /** @override */
  _toggleDisabled(disabled) {
    this.#input.disabled = disabled;
    this.#button.disabled = disabled;
  }

  /* -------------------------------------------- */

  /**
   * Create a HTMLStringTagsElement using provided configuration data.
   * @param {FormInputConfig & StringTagsInputConfig} config
   */
  static create(config) {
    const { slug, value } = config;
    const tags = new this({ slug, values: value });
    tags.name = config.name;
    tags.toggleAttribute("slug", !!config.slug);
    tags.setAttribute("value", Array.from(config.value || []).join(","));
    foundry.applications.fields.setInputAttributes(tags, config);
    return tags;
  }
}

/**
 * @import {FormInputConfig} from "@common/data/_types.mjs";
 */

/**
 * @typedef DocumentTagsInputConfig
 * @property {string} [type]      A specific document type in CONST.ALL_DOCUMENT_TYPES
 * @property {boolean} [single]   Only allow referencing a single document. In this case the submitted form value will
 *                                be a single UUID string rather than an array
 * @property {number} [max]       Only allow attaching a maximum number of documents
 */

/**
 * @typedef HTMLDocumentTagsOptions
 * @property {string[]} [values]  An array of Document UUIDs to initialize the element with.
 */

/**
 * A custom HTMLElement used to render a set of associated Documents referenced by UUID.
 * @extends {AbstractFormInputElement<string|string[]|null>}
 */
class HTMLDocumentTagsElement extends AbstractFormInputElement {
  /**
   * @param {HTMLDocumentTagsOptions} [options]
   */
  constructor({ values }={}) {
    super();
    this._initializeTags(values);
  }

  /** @override */
  static tagName = "document-tags";

  /* -------------------------------------------- */

  /**
   * @override
   * @type {Record<string, string>}
   * @protected
   */
  _value = {};

  /**
   * The button element to add a new document.
   * @type {HTMLButtonElement}
   */
  #button;

  /**
   * The input element to define a Document UUID.
   * @type {HTMLInputElement}
   */
  #input;

  /**
   * The list of tagged documents.
   * @type {HTMLDivElement}
   */
  #tags;

  /* -------------------------------------------- */

  /**
   * Restrict this element to documents of a particular type.
   * @type {string|null}
   */
  get type() {
    return this.getAttribute("type");
  }

  set type(value) {
    if ( !value ) {
      this.removeAttribute("type");
      return;
    }
    if ( !CONST.ALL_DOCUMENT_TYPES.includes(value) ) {
      throw new Error(`"${value}" is not a valid Document type in CONST.ALL_DOCUMENT_TYPES`);
    }
    this.setAttribute("type", value);
  }

  /* -------------------------------------------- */

  /**
   * Restrict to only allow referencing a single Document instead of an array of documents.
   * @type {boolean}
   */
  get single() {
    return this.hasAttribute("single");
  }

  set single(value) {
    this.toggleAttribute("single", value === true);
  }

  /* -------------------------------------------- */

  /**
   * Allow a maximum number of documents to be tagged to the element.
   * @type {number}
   */
  get max() {
    const max = parseInt(this.getAttribute("max"));
    return isNaN(max) ? Infinity : max;
  }

  set max(value) {
    if ( Number.isInteger(value) && (value > 0) ) this.setAttribute("max", String(value));
    else this.removeAttribute("max");
  }

  /* -------------------------------------------- */

  /**
   * Initialize innerText or an initial value attribute of the element as a serialized JSON array.
   * @param {string[]} [values]  An array of Document UUIDs to initialize the element with.
   * @protected
   */
  _initializeTags(values) {
    let tags = [];
    if ( Array.isArray(values) ) tags = values;
    else {
      const initial = this.getAttribute("value") || this.innerText || "";
      if ( initial ) tags = initial.split(",");
    }
    for ( const t of tags ) {
      try {
        this.#add(t);
      } catch(err) {
        this._value[t] = `${t} [INVALID]`; // Display invalid UUIDs as a raw string
      }
    }
    this.innerText = "";
    this.removeAttribute("value");
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {

    // Create tags list
    this.#tags = document.createElement("div");
    this.#tags.className = "tags input-element-tags";

    // Create input element
    this.#input = this._primaryInput = document.createElement("input");
    this.#input.type = "text";
    this.#input.placeholder = game.i18n.format("HTMLDocumentTagsElement.PLACEHOLDER", {
      type: game.i18n.localize(this.type ? getDocumentClass$1(this.type).metadata.label : "DOCUMENT.Document")});

    // Create button
    this.#button = document.createElement("button");
    this.#button.type = "button";
    this.#button.className = "icon fa-solid fa-file-plus";
    this.#button.dataset.tooltip = "ELEMENTS.DOCUMENT_TAGS.Add";
    this.#button.setAttribute("aria-label", this.#button.dataset.tooltip);
    return [this.#tags, this.#input, this.#button];
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    if ( !this.#tags ) return; // Not yet connected
    const tags = Object.entries(this._value).map(([k, v]) => this.constructor.renderTag(k, v, this.editable));
    this.#tags.replaceChildren(...tags);
  }

  /* -------------------------------------------- */

  /**
   * Create an HTML string fragment for a single document tag.
   * @param {string} uuid              The document UUID
   * @param {string} name              The document name
   * @param {boolean} [editable=true]  Is the tag editable?
   * @returns {HTMLDivElement}
   */
  static renderTag(uuid, name, editable=true) {
    const div = HTMLStringTagsElement.renderTag(uuid, TextEditor$1.implementation.truncateText(name, {maxLength: 32}),
      editable);
    div.classList.add("document-tag");
    div.querySelector("span").dataset.tooltipText = uuid;
    if ( editable ) {
      const t = game.i18n.localize("ELEMENTS.DOCUMENT_TAGS.Remove");
      const a = div.querySelector("a");
      a.dataset.tooltipText = t;
      a.ariaLabel = t;
    }
    return div;
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    this.#button.addEventListener("click", () => this.#tryAdd(this.#input.value));
    this.#tags.addEventListener("click", this.#onClickTag.bind(this));
    this.#input.addEventListener("keydown", this.#onKeydown.bind(this));
    this.#input.addEventListener("change", event => event.stopPropagation());
    this.addEventListener("drop", this.#onDrop.bind(this), { signal: this.abortSignal });
  }

  /* -------------------------------------------- */

  /**
   * Remove a single coefficient by clicking on its tag.
   * @param {PointerEvent} event
   */
  #onClickTag(event) {
    if ( !event.target.classList.contains("remove") || !this.editable ) return;
    const tag = event.target.closest(".tag");
    delete this._value[tag.dataset.key];
    this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
    this._refresh();
  }

  /* -------------------------------------------- */

  /**
   * Add a new document tag by pressing the ENTER key in the UUID input field.
   * @param {KeyboardEvent} event
   */
  #onKeydown(event) {
    if ( event.key !== "Enter" ) return;
    event.preventDefault();
    event.stopPropagation();
    this.#tryAdd(this.#input.value);
  }

  /* -------------------------------------------- */

  /**
   * Handle data dropped onto the form element.
   * @param {DragEvent} event
   */
  #onDrop(event) {
    event.preventDefault();
    const dropData = TextEditor$1.implementation.getDragEventData(event);
    if ( dropData.uuid ) this.#tryAdd(dropData.uuid);
  }

  /* -------------------------------------------- */

  /**
   * Add a Document to the tagged set using the value of the input field.
   * @param {string} uuid     The UUID to attempt to add
   */
  #tryAdd(uuid) {
    try {
      this.#add(uuid);
      this._refresh();
    } catch(err) {
      ui.notifications.error(err.message);
    }
    this.#input.value = "";
    this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
    this.#input.focus();
  }

  /* -------------------------------------------- */

  /**
   * Add a new UUID to the tagged set, throwing an error if the UUID is not valid.
   * @param {string} uuid   The UUID to add
   * @throws {Error}        If the UUID is not valid
   */
  #add(uuid) {
    if ( !this.editable ) return;
    const {type: requiredType, max} = this;

    // Require the UUID to be theoretically valid
    const {type, id, collection} = foundry.utils.parseUuid(uuid) ?? {};
    if ( !collection || !foundry.data.validators.isValidId(id) ) {
      throw new Error(`Provided UUID "${uuid}" does not contain a valid document ID "${id}"`);
    }

    // Require specific Document type
    if ( requiredType && (type !== requiredType) ) {
      throw new Error(`Incorrect document type "${type}" provided to document tag field which requires`
        + `"${requiredType}" documents.`);
    }

    // Restrict to a maximum number of tagged documents
    if ( max && (Object.keys(this._value).length >= max) ) {
      throw new Error(`You may only attach at most ${max} Documents to the "${this.name}" field`);
    }

    // Replace singleton
    if ( this.single ) {
      for ( const k of Object.keys(this._value) ) delete this._value[k];
    }

    // Display the document name if known
    const record = fromUuidSync(uuid);
    this._value[uuid] = record?.name || `${type}: ${id}`;
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @override */
  _getValue() {
    const uuids = Object.keys(this._value);
    if ( this.single ) return uuids[0] ?? null;
    else return uuids;
  }

  /** @override */
  _setValue(value) {
    this._value = {};
    if ( !value ) return;
    if ( typeof value === "string" ) value = [value];
    for ( const uuid of value ) this.#add(uuid);
  }

  /* -------------------------------------------- */

  /** @override */
  _toggleDisabled(disabled) {
    this.#input.disabled = disabled;
    this.#button.disabled = disabled;
  }

  /* -------------------------------------------- */

  /**
   * Create a HTMLDocumentTagsElement using provided configuration data.
   * @param {FormInputConfig & DocumentTagsInputConfig} config
   * @returns {HTMLDocumentTagsElement}
   */
  static create(config) {
    // Coerce value to an array
    let values;
    if ( config.value instanceof Set ) values = Array.from(config.value);
    else if ( !Array.isArray(config.value) ) values = [config.value];
    else values = config.value;

    const tags = new this({ values });
    tags.name = config.name;
    tags.setAttribute("value", values);
    tags.type = config.type;
    tags.max = config.max;
    tags.single = config.single;
    foundry.applications.fields.setInputAttributes(tags, config);
    return tags;
  }
}

/**
 * A custom HTMLElement that is used to wrap enriched content that requires additional interactivity.
 */
class HTMLDocumentEmbedElement extends HTMLElement {

  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "document-embed";

  /**
   * Invoke the Document#onEmbed callback when it is added to the DOM.
   */
  connectedCallback() {
    const uuid = this.getAttribute("uuid");
    if ( !uuid ) return;
    foundry.utils.fromUuid(uuid).then(doc => doc.onEmbed(this));
  }
}

/**
 * A custom HTMLElement that is used to wrap enriched content that requires additional interactivity.
 */
class HTMLEnrichedContentElement extends HTMLElement {

  /**
   * The HTML tag named used by this element.
   * @type {string}
   */
  static tagName = "enriched-content";

  /**
   * Attributes requiring change notifications
   * @type {string[]}
   */
  static observedAttributes = ["enricher"];

  /**
   * The enricher configuration that applies to this element.
   * @type {TextEditorEnricherConfig}
   */
  #enricher;

  /* -------------------------------------------- */
  /*  Element Lifecycle                           */
  /* -------------------------------------------- */

  /**
   * Invoke the enricher onRender callback when it is added to the DOM.
   */
  connectedCallback() {
    this.#enricher = CONFIG.TextEditor.enrichers.find(e => e.id === this.getAttribute("enricher"));
    if ( !(this.#enricher?.onRender instanceof Function) ) return;
    this.#enricher.onRender(this);
  }

  /* -------------------------------------------- */

  /**
   * Fire a callback on change to an observed attribute.
   * @param {string} attrName The name of the attribute
   * @param {string|null} oldValue The old value: null indicates the attribute was not present.
   * @param {string|null} newValue The new value: null indicates the attribute is removed.
   */
  attributeChangedCallback(attrName, oldValue, newValue) {
    if ( attrName === "enricher" ) {
      this.#enricher = CONFIG.TextEditor.enrichers.find(e => e.id === this.getAttribute("enricher"));
    }
  }
}

/**
 * @import {FormInputConfig} from "@common/data/_types.mjs";
 */

/**
 * @typedef FilePickerInputConfig
 * @property {FilePickerOptions.type} [type]
 * @property {string} [placeholder]
 * @property {boolean} [noupload]
 */

/**
 * A custom HTML element responsible for rendering a file input field and associated FilePicker button.
 * @extends {AbstractFormInputElement<string>}
 */
class HTMLFilePickerElement extends AbstractFormInputElement {

  /** @override */
  static tagName = "file-picker";

  /**
   * The file path selected.
   * @type {HTMLInputElement}
   */
  input;

  /**
   * A button to open the file picker interface.
   * @type {HTMLButtonElement}
   */
  button;

  /**
   * A reference to the FilePicker application instance originated by this element.
   * @type {FilePicker}
   */
  picker;

  /* -------------------------------------------- */

  /**
   * A type of file which can be selected in this field.
   * @see {@link foundry.applications.apps.FilePicker.FILE_TYPES}
   * @type {FilePickerOptions.type}
   */
  get type() {
    return this.getAttribute("type") ?? "any";
  }

  set type(value) {
    if ( !FilePicker.FILE_TYPES.includes(value) ) throw new Error(`Invalid type "${value}" provided which must be a `
      + "value in FilePicker.FILE_TYPES");
    this.setAttribute("type", value);
  }

  /* -------------------------------------------- */

  /**
   * Prevent uploading new files as part of this element's FilePicker dialog.
   * @type {boolean}
   */
  get noupload() {
    return this.hasAttribute("noupload");
  }

  set noupload(value) {
    this.toggleAttribute("noupload", value === true);
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {

    // Initialize existing value
    this._value ??= this.getAttribute("value") || this.innerText || "";
    this.removeAttribute("value");

    // Create an input field
    const elements = [];
    this.input = this._primaryInput = document.createElement("input");
    this.input.className = "image";
    this.input.type = "text";
    this.input.placeholder = this.getAttribute("placeholder") ?? "path/to/file.ext";
    elements.push(this.input);

    // Disallow browsing for some users
    if ( game.world && !game.user.can("FILES_BROWSE") ) return elements;

    // Create a FilePicker button
    this.button = document.createElement("button");
    this.button.className = "fa-solid fa-file-import fa-fw icon";
    this.button.type = "button";
    this.button.dataset.tooltip = "FILES.BrowseTooltip";
    this.button.setAttribute("aria-label", this.button.dataset.tooltip);
    this.button.tabIndex = -1;
    elements.push(this.button);
    return elements;
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    this.input.value = this._value;
  }

  /* -------------------------------------------- */

  /** @override */
  _toggleDisabled(disabled) {
    this.input.disabled = disabled;
    if ( this.button ) this.button.disabled = disabled;
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    this.input.addEventListener("input", () => this._value = this.input.value);
    this.button?.addEventListener("click", this.#onClickButton.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle clicks on the button element to render the FilePicker UI.
   * @param {PointerEvent} event      The initiating click event
   */
  #onClickButton(event) {
    event.preventDefault();
    this.picker = new FilePicker.implementation({
      type: this.type,
      current: this.value,
      allowUpload: !this.noupload,
      callback: src => this.value = src
    });
    return this.picker.browse();
  }

  /* -------------------------------------------- */

  /**
   * Create a HTMLFilePickerElement using provided configuration data.
   * @param {FormInputConfig<string> & FilePickerInputConfig} config
   */
  static create(config) {
    const picker = document.createElement(this.tagName);
    picker.name = config.name;
    picker.setAttribute("value", config.value || "");
    picker.type = config.type;
    picker.noupload = config.noupload;
    foundry.applications.fields.setInputAttributes(picker, config);
    return picker;
  }
}

/**
 * @import {FormInputConfig} from "@common/data/_types.mjs";
 */

/**
 * A class designed to standardize the behavior for a hue selector UI component.
 * @extends {AbstractFormInputElement<number>}
 */
class HTMLHueSelectorSlider extends AbstractFormInputElement {

  /** @override */
  static tagName = "hue-slider";

  /**
   * The color range associated with this element.
   * @type {HTMLInputElement|null}
   */
  #input;

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {

    // Initialize existing value
    this._setValue(this.getAttribute("value"));

    // Build elements
    this.#input = this._primaryInput = document.createElement("input");
    this.#input.className = "color-range";
    this.#input.type = "range";
    this.#input.min = "0";
    this.#input.max = "360";
    this.#input.step = "1";
    this.#input.value = this._value * 360;
    return [this.#input];
  }

  /* -------------------------------------------- */

  /**
   * Refresh the active state of the custom element.
   * @protected
   */
  _refresh() {
    if ( !this.#input ) return;
    this.#input.style.setProperty("--color-thumb", Color.fromHSL([this._value, 1, 0.5]).css);
  }

  /* -------------------------------------------- */

  /**
   * Activate event listeners which add dynamic behavior to the custom element.
   * @protected
   */
  _activateListeners() {
    this.#input.oninput = this.#onInputColorRange.bind(this);
  }

  /* -------------------------------------------- */

  /**
   * Update the thumb and the value.
   * @param {FormDataEvent} event
   */
  #onInputColorRange(event) {
    event.preventDefault();
    event.stopImmediatePropagation();
    this.value = this.#input.value / 360;
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @override */
  _setValue(value) {
    value = Number(value);
    if ( !value.between(0, 1) ) throw new Error("The value of a hue-slider must be on the range [0,1]");
    this._value = value;
    this.setAttribute("value", String(value));
  }

  /* -------------------------------------------- */

  /** @override */
  _toggleDisabled(disabled) {
    this.#input.disabled = disabled;
  }

  /* -------------------------------------------- */

  /**
   * Create a HTMLHueSelectorSlider using provided configuration data.
   * @param {FormInputConfig} config
   * @returns {HTMLHueSelectorSlider}
   */
  static create(config) {
    const slider = document.createElement(HTMLHueSelectorSlider.tagName);
    if ( config.name ) slider.name = config.name;
    if ( Number.isFinite(config.value) ) slider.value = config.value;
    foundry.applications.fields.setInputAttributes(slider, config);
    return slider;
  }
}

/**
 * @import {FormInputConfig} from "../../../common/data/_types.mjs";
 */

/**
 * An abstract base class designed to standardize the behavior for a multi-select UI component.
 * Multi-select components return an array of values as part of form submission.
 * Different implementations may provide different experiences around how inputs are presented to the user.
 * @extends {AbstractFormInputElement<Set<string>>}
 */
class AbstractMultiSelectElement extends AbstractFormInputElement {
  /**
   * Predefined <option> and <optgroup> elements which were defined in the original HTML.
   * @type {(HTMLOptionElement|HTMLOptGroupElement)[]}
   * @protected
   */
  _options = [];

  /**
   * An object which maps option values to displayed labels.
   * @type {Record<string, string>}
   * @protected
   */
  _choices = {};

  /** @override */
  _value = new Set();

  /* -------------------------------------------- */

  /** @inheritDoc */
  connectedCallback() {
    // Initialize existing value.
    if ( this.matches(":scope:has(> option, > optgroup)") ) this._initialize();
    super.connectedCallback();
  }

  /* -------------------------------------------- */

  /**
   * Preserve existing <option> and <optgroup> elements which are defined in the original HTML.
   * @protected
   */
  _initialize() {
    this._options = [...this.children];
    for ( const option of this.querySelectorAll("option") ) {
      if ( !option.value ) continue; // Skip predefined options which are already blank
      this._choices[option.value] = option.innerText;
      if ( option.selected ) {
        this._value.add(option.value);
        option.selected = false;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Mark a choice as selected.
   * @param {string} value      The value to add to the chosen set
   */
  select(value) {
    const exists = this._value.has(value);
    if ( !exists ) {
      if ( !(value in this._choices) ) {
        throw new Error(`"${value}" is not an option allowed by this multi-select element`);
      }
      this._value.add(value);
      this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
      this._refresh();
    }
  }

  /* -------------------------------------------- */

  /**
   * Mark a choice as un-selected.
   * @param {string} value      The value to delete from the chosen set
   */
  unselect(value) {
    const exists = this._value.has(value);
    if ( exists ) {
      this._value.delete(value);
      this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
      this._refresh();
    }
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @override */
  _getValue() {
    return Array.from(this._value);
  }

  /** @override */
  _setValue(value) {
    if ( !Array.isArray(value) ) {
      throw new Error("The value assigned to a multi-select element must be an array.");
    }
    if ( value.some(v => !(v in this._choices)) ) {
      throw new Error("The values assigned to a multi-select element must all be valid options.");
    }
    this._value.clear();
    for ( const v of value ) this._value.add(v);
  }
}

/* -------------------------------------------- */

/**
 * Provide a multi-select workflow using a select element as the input mechanism.
 *
 * @example Multi-Select HTML Markup
 * ```html
 * <multi-select name="select-many-things">
 *   <optgroup label="Basic Options">
 *     <option value="foo">Foo</option>
 *     <option value="bar">Bar</option>
 *     <option value="baz">Baz</option>
 *   </optgroup>
 *   <optgroup label="Advanced Options">
 *    <option value="fizz">Fizz</option>
 *     <option value="buzz">Buzz</option>
 *   </optgroup>
 * </multi-select>
 * ```
 */
class HTMLMultiSelectElement extends AbstractMultiSelectElement {

  /** @override */
  static tagName = "multi-select";

  /**
   * A select element used to choose options.
   * @type {HTMLSelectElement}
   */
  #select;

  /**
   * A display element which lists the chosen options.
   * @type {HTMLDivElement}
   */
  #tags;

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {

    // Create select element
    this.#select = this._primaryInput = document.createElement("select");
    this.#select.insertAdjacentHTML("afterbegin", '<option value=""></option>');
    this.#select.append(...this._options);

    // Create a div element for display
    this.#tags = document.createElement("div");
    this.#tags.className = "tags input-element-tags";
    return [this.#tags, this.#select];
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {

    // Update the displayed tags
    const tags = Array.from(this._value).map(id => {
      return HTMLStringTagsElement.renderTag(id, this._choices[id], this.editable);
    });
    this.#tags.replaceChildren(...tags);

    // Disable selected options
    for ( const option of this.#select.querySelectorAll("option") ) {
      option.disabled = this._value.has(option.value);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    this.#select.addEventListener("change", this.#onChangeSelect.bind(this));
    this.#tags.addEventListener("click", this.#onClickTag.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to the Select input, marking the selected option as a chosen value.
   * @param {Event} event         The change event on the select element
   */
  #onChangeSelect(event) {
    event.preventDefault();
    event.stopImmediatePropagation();
    const select = event.currentTarget;
    if ( !select.value ) return; // Ignore selection of the blank value
    this.select(select.value);
    select.value = "";
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on a tagged value, removing it from the chosen set.
   * @param {PointerEvent} event    The originating click event on a chosen tag
   */
  #onClickTag(event) {
    event.preventDefault();
    if ( !event.target.classList.contains("remove") || !this.editable ) return;
    const tag = event.target.closest(".tag");
    this.unselect(tag.dataset.key);
  }

  /* -------------------------------------------- */

  /** @override */
  _toggleDisabled(disabled) {
    this.#select.disabled = disabled;
  }

  /* -------------------------------------------- */

  /**
   * Create a HTMLMultiSelectElement using provided configuration data.
   * @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
   * @returns {HTMLMultiSelectElement}
   */
  static create(config) {
    return foundry.applications.fields.createMultiSelectInput(config);
  }
}

/* -------------------------------------------- */

/**
 * Provide a multi-select workflow as a grid of input checkbox elements.
 *
 * @example Multi-Checkbox HTML Markup
 * ```html
 * <multi-checkbox name="check-many-boxes">
 *   <optgroup label="Basic Options">
 *     <option value="foo">Foo</option>
 *     <option value="bar">Bar</option>
 *     <option value="baz">Baz</option>
 *   </optgroup>
 *   <optgroup label="Advanced Options">
 *    <option value="fizz">Fizz</option>
 *     <option value="buzz">Buzz</option>
 *   </optgroup>
 * </multi-checkbox>
 * ```
 */
class HTMLMultiCheckboxElement extends AbstractMultiSelectElement {

  /** @override */
  static tagName = "multi-checkbox";

  /**
   * The checkbox elements used to select inputs
   * @type {HTMLInputElement[]}
   */
  #checkboxes;

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {
    this.#checkboxes = [];
    const children = [];
    for ( const option of this._options ) {
      if ( option instanceof HTMLOptGroupElement ) children.push(this.#buildGroup(option));
      else children.push(this.#buildOption(option));
    }
    return children;
  }

  /* -------------------------------------------- */

  /**
   * Translate an input <optgroup> element into a <fieldset> of checkboxes.
   * @param {HTMLOptGroupElement} optgroup    The originally configured optgroup
   * @returns {HTMLFieldSetElement}           The created fieldset grouping
   */
  #buildGroup(optgroup) {

    // Create fieldset group
    const group = document.createElement("fieldset");
    group.classList.add("checkbox-group");
    const legend = document.createElement("legend");
    legend.innerText = optgroup.label;
    group.append(legend);

    // Add child options
    for ( const option of optgroup.children ) {
      if ( option instanceof HTMLOptionElement ) {
        group.append(this.#buildOption(option));
      }
    }
    return group;
  }

  /* -------------------------------------------- */

  /**
   * Build an input <option> element into a <label class="checkbox"> element.
   * @param {HTMLOptionElement} option      The originally configured option
   * @returns {HTMLLabelElement}            The created labeled checkbox element
   */
  #buildOption(option) {
    const label = document.createElement("label");
    label.classList.add("checkbox");
    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.value = option.value;
    checkbox.checked = this._value.has(option.value);
    checkbox.disabled = this.disabled;
    label.append(checkbox, option.innerText);
    this.#checkboxes.push(checkbox);
    return label;
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    for ( const checkbox of this.#checkboxes ) {
      checkbox.checked = this._value.has(checkbox.value);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    for ( const checkbox of this.#checkboxes ) {
      checkbox.addEventListener("change", this.#onChangeCheckbox.bind(this));
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to a checkbox input, marking the selected option as a chosen value.
   * @param {Event} event         The change event on the checkbox input element
   */
  #onChangeCheckbox(event) {
    event.preventDefault();
    event.stopImmediatePropagation();
    const checkbox = event.currentTarget;
    if ( checkbox.checked ) this.select(checkbox.value);
    else this.unselect(checkbox.value);
  }

  /* -------------------------------------------- */

  /** @override */
  _toggleDisabled(disabled) {
    for ( const checkbox of this.#checkboxes ) {
      checkbox.disabled = disabled;
    }
  }
}

/**
 * @import {FormInputConfig} from "../../../common/data/_types.mjs";
 */

/**
 * @typedef HTMLColorPickerOptions
 * @property {string} value  A hexadecimal string representation of the color.
 */

/**
 * A custom HTMLElement used to select a color using a linked pair of input fields.
 * @extends {AbstractFormInputElement<string>}
 */
class HTMLColorPickerElement extends AbstractFormInputElement {
  /**
   * @param {HTMLColorPickerOptions} [options]
   */
  constructor({ value }={}) {
    super();
    this._setValue(value || this.getAttribute("value")); // Initialize existing color value
  }

  /** @override */
  static tagName = "color-picker";

  /* -------------------------------------------- */

  /**
   * The button element to add a new document.
   * @type {HTMLInputElement}
   */
  #colorSelector;

  /**
   * The input element to define a Document UUID.
   * @type {HTMLInputElement}
   */
  #colorString;

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {

    // Create string input element
    this.#colorString = this._primaryInput = document.createElement("input");
    this.#colorString.type = "text";
    this.#colorString.placeholder = this.getAttribute("placeholder") || "";
    this._applyInputAttributes(this.#colorString);

    // Create color selector element
    this.#colorSelector = document.createElement("input");
    this.#colorSelector.type = "color";
    this._applyInputAttributes(this.#colorSelector);
    return [this.#colorString, this.#colorSelector];
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    if ( !this.#colorString ) return; // Not yet connected
    this.#colorString.value = this._value;
    this.#colorSelector.value = this._value || this.#colorString.placeholder || "#000000";
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    const onChange = this.#onChangeInput.bind(this);
    this.#colorString.addEventListener("change", onChange);
    this.#colorSelector.addEventListener("change", onChange);
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to one of the inputs of the color picker element.
   * @param {InputEvent} event     The originating input change event
   */
  #onChangeInput(event) {
    event.stopPropagation();
    this.value = event.currentTarget.value;
  }

  /* -------------------------------------------- */

  /** @override */
  _toggleDisabled(disabled) {
    this.#colorString.disabled = disabled;
    this.#colorSelector.disabled = disabled;
  }

  /* -------------------------------------------- */

  /**
   * Create a HTMLColorPickerElement using provided configuration data.
   * @param {FormInputConfig} config
   * @returns {HTMLColorPickerElement}
   */
  static create(config) {
    const { value } = config;
    const picker = new this({ value });
    picker.name = config.name;
    picker.setAttribute("value", config.value ?? "");
    foundry.applications.fields.setInputAttributes(picker, config);
    return picker;
  }
}

/**
 * @import {FormInputConfig} from "../../../common/data/_types.mjs";
 */

/**
 * @typedef RangePickerInputConfig
 * @property {number} min
 * @property {number} max
 * @property {number} [step]
 */

/**
 * @typedef HTMLRangePickerOptions
 * @property {number} [min]    The slider minimum value.
 * @property {number} [max]    The slider maximum value.
 * @property {number} [step]   The slider's discrete value increments.
 * @property {number} [value]  The slider's starting value.
 */

/**
 * A custom HTML element responsible selecting a value on a range slider with a linked number input field.
 * @extends {AbstractFormInputElement<number>}
 */
class HTMLRangePickerElement extends AbstractFormInputElement {
  /**
   * @param {HTMLRangePickerOptions} [options]
   */
  constructor({ value, step, min, max }={}) {
    super();
    this._internals.role = "slider";
    this.#min = Number(min ?? this.getAttribute("min"));
    if ( isNaN(this.#min) ) this.#min = 0;
    this.#max = Number(max ?? this.getAttribute("max"));
    if ( isNaN(this.#max) ) this.#max = 1;
    this.#step = Number(step ?? this.getAttribute("step")) || undefined;
    this._setValue(value ?? this.getAttribute("value")); // Initialize existing value
  }

  /** @override */
  static tagName = "range-picker";

  /**
   * The range input.
   * @type {HTMLInputElement}
   */
  #rangeInput;

  /**
   * The number input.
   * @type {HTMLInputElement}
   */
  #numberInput;

  /**
   * The minimum allowed value for the range.
   * @type {number}
   */
  #min;

  /**
   * The maximum allowed value for the range.
   * @type {number}
   */
  #max;

  /**
   * A required step size for the range.
   * @type {number}
   */
  #step;

  /* -------------------------------------------- */

  /**
   * The value of the input element.
   * @type {number}
   */
  get valueAsNumber() {
    return this._getValue();
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {

    // Create range input element
    const r = this.#rangeInput = document.createElement("input");
    r.type = "range";
    r.min = String(this.#min);
    r.max = String(this.#max);
    r.step = String(this.#step ?? 0.1);
    this._applyInputAttributes(r);

    // Create the number input element
    const n = this.#numberInput = this._primaryInput = document.createElement("input");
    n.type = "number";
    n.min = String(this.#min);
    n.max = String(this.#max);
    n.step = this.#step ?? "any";
    this._applyInputAttributes(n);
    return [this.#rangeInput, this.#numberInput];
  }

  /* -------------------------------------------- */

  /** @override */
  _setValue(value) {
    value = Number(value);
    let max = this.#max;
    if ( this.#step ) {
      value = value.toNearest(this.#step, "round", this.#min);
      max = max.toNearest(this.#step, "floor", this.#min);
    }
    this._value = Math.clamp(value, this.#min, max);
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    if ( !this.#rangeInput ) return; // Not yet connected
    this.#rangeInput.valueAsNumber = this.#numberInput.valueAsNumber = this._value;
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    const onChange = this.#onChangeInput.bind(this);
    this.#rangeInput.addEventListener("input", this.#onDragSlider.bind(this));
    this.#rangeInput.addEventListener("change", onChange);
    this.#numberInput.addEventListener("change", onChange);
  }

  /* -------------------------------------------- */

  /**
   * Update display of the number input as the range slider is actively changed.
   * @param {InputEvent} event     The originating input event
   */
  #onDragSlider(event) {
    event.preventDefault();
    this.#numberInput.valueAsNumber = this.#rangeInput.valueAsNumber;
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to one of the inputs of the range picker element.
   * @param {InputEvent} event     The originating input change event
   */
  #onChangeInput(event) {
    event.stopPropagation();
    this.value = event.currentTarget.valueAsNumber;
  }

  /* -------------------------------------------- */

  /** @override */
  _toggleDisabled(disabled) {
    this.#rangeInput.disabled = disabled;
    this.#numberInput.disabled = disabled;
  }

  /* -------------------------------------------- */

  /**
   * Create a HTMLRangePickerElement using provided configuration data.
   * @param {FormInputConfig & RangePickerInputConfig} config
   * @returns {HTMLRangePickerElement}
   */
  static create(config) {
    const { value, min, max, step } = config;
    const picker = new this({ value, min, max, step });
    picker.name = config.name;
    for ( const attr of ["value", "min", "max", "step"] ) {
      if ( attr in config ) picker.setAttribute(attr, config[attr]);
    }
    foundry.applications.fields.setInputAttributes(picker, config);
    return picker;
  }
}

/**
 * @import {FormInputConfig} from "../../../common/data/_types.mjs";
 * @import ProseMirrorEditor from "@client/applications/ux/prosemirror-editor.mjs";
 */

/**
 * @typedef ProseMirrorInputConfig
 * @property {boolean} toggled            Is this editor toggled (true) or always active (false)
 * @property {string} [enriched]          If the editor is toggled, provide the enrichedHTML which is displayed while
 *                                        the editor is not active.
 * @property {boolean} collaborate        Does this editor instance support collaborative editing?
 * @property {boolean} compact            Should the editor be presented in compact mode?
 * @property {string} documentUUID        A Document UUID. Required for collaborative editing
 * @property {number} [height]            The height of the editor in pixels
 */

/**
 * @typedef HTMLProseMirrorOptions
 * @property {boolean} [toggled]  Whether the editor's active state is toggled or always active.
 * @property {string} [enriched]  If the editor is toggled, provide enriched HTML which is displayed while the editor is
 *                                not active.
 * @property {string} value       The raw value to edit.
 */

/**
 * A custom HTML element responsible displaying a ProseMirror rich text editor.
 * @extends {AbstractFormInputElement<string>}
 * @fires {Event} open                       Fired when an editor is initialized in the DOM and ready.
 * @fires {Event} close                      Fired when a toggled editor is deactivated.
 * @fires {Event} save                       Fired when the editor is saved.
 * @fires {ProseMirrorPluginsEvent} plugins  Fired when an editor's plugins are being configured.
 */
class HTMLProseMirrorElement extends AbstractFormInputElement {
  constructor({ enriched, toggled, value }={}) {
    super();

    // Initialize raw content
    this._setValue(value || this.getAttribute("value") || "");
    this.removeAttribute("value");

    // Initialize enriched content
    this.#toggled = toggled ?? this.hasAttribute("toggled");
    this.#enriched = enriched || this.innerHTML;
  }

  /** @override */
  static tagName = "prose-mirror";

  /** @inheritDoc */
  static observedAttributes = super.observedAttributes.concat("open");

  /**
   * Is the editor in active edit mode?
   * @type {boolean}
   */
  #active = false;

  /**
   * The ProseMirror editor instance.
   * @type {ProseMirrorEditor}
   */
  #editor;

  /**
   * Current editor contents
   * @type {HTMLDivElement}
   */
  #content;

  /**
   * The child element that is currently the target of a pointerdown event.
   * @type {HTMLElement|null}
   */
  #pointerdown = null;

  /**
   * Does this editor function via a toggle button? Or is it always active?
   * @type {boolean}
   */
  #toggled;

  /**
   * Enriched content which is optionally used if the editor is toggled.
   * @type {string}
   */
  #enriched;

  /**
   * An optional edit button which activates edit mode for the editor
   * @type {HTMLButtonElement|null}
   */
  #button = null;

  /**
   * Whether the editor is currently open. Always true for non-toggled editors.
   * @type {boolean}
   */
  get open() {
    if ( !this.#toggled ) return true;
    return this.hasAttribute("open");
  }

  set open(open) {
    if ( !open && !this.#toggled ) return;
    this.toggleAttribute("open", open);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  attributeChangedCallback(attrName, oldValue, newValue) {
    switch ( attrName ) {
      case "open":
        if ( newValue === null ) {
          if ( this.#active ) this.#save();
        }
        else if ( !this.#active ) this.#activateEditor();
        break;
      default: super.attributeChangedCallback(attrName, oldValue, newValue);
    }
  }

  /* -------------------------------------------- */

  /**
   * Actions to take when the custom element is removed from the document.
   */
  disconnectedCallback() {
    if ( this.#active ) {
      this.#save();
      if ( !this.#toggled ) this.#editor?.destroy();
    }
    super.disconnectedCallback();
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {
    this.classList.add("editor", "prosemirror", "inactive");
    const elements = [];
    this.#content = document.createElement("div");
    this.#content.className = "editor-content";
    this._primaryInput = this.#content;
    elements.push(this.#content);
    if ( this.#toggled ) {
      this.#button = document.createElement("button");
      this.#button.type = "button";
      this.#button.className = "icon toggle";
      this.#button.innerHTML = '<i class="fa-solid fa-pen-to-square"></i>';
      elements.push(this.#button);
    }
    return elements;
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    if ( this.#active || !this.#content ) return; // It is not safe to replace the content while the editor is active
    if ( this.#toggled ) this.#content.innerHTML = this.#enriched ?? this._value;
    else this.#content.innerHTML = this._value;
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    if ( this.#toggled ) {
      const { abortSignal: signal } = this;
      this.#button.addEventListener("click", this.#onClickButton.bind(this), { signal });
      this.addEventListener("pointerdown", this.#onPointerDown.bind(this), { signal, passive: true });
      this.addEventListener("pointerup", this.#onPointerUp.bind(this), { signal, passive: true });
      this.addEventListener("dragend", this.#onDragEnd.bind(this), { signal, passive: true });
    }
    else this.open = true;
  }

  /* -------------------------------------------- */

  /** @override */
  _getValue() {
    if ( this.#active ) return ProseMirror.dom.serializeString(this.#editor.view.state.doc.content);
    return this._value;
  }

  /* -------------------------------------------- */

  /**
   * Activate the ProseMirror editor.
   * @returns {Promise<void>}
   */
  async #activateEditor() {

    // If the editor was toggled, replace with raw editable content
    if ( this.#toggled ) this.#content.innerHTML = this._value;

    // Create the TextEditor instance
    const document = await foundry.utils.fromUuid(this.dataset.documentUuid ?? this.dataset.documentUUID);
    this.#editor = await TextEditor$1.implementation.create({
      engine: "prosemirror",
      plugins: this._configurePlugins(),
      fieldName: this.name,
      collaborate: this.hasAttribute("collaborate"),
      target: this.#content,
      document,
      props: {editable: () => !this.disabled}
    }, this._getValue());

    // Toggle active state
    this.#active = true;
    if ( this.#button ) this.#button.disabled = true;
    this.classList.add("active");
    this.classList.remove("inactive");
    this.dispatchEvent(new Event("open"), { bubbles: true, cancelable: true });
  }

  /* -------------------------------------------- */

  /**
   * Configure ProseMirror editor plugins.
   * @returns {Record<string, ProseMirror.Plugin>}
   * @protected
   */
  _configurePlugins() {
    const plugins = {
      menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
        compact: this.hasAttribute("compact"),
        destroyOnSave: this.#toggled,
        onSave: this.#save.bind(this)
      }),
      keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema, {
        onSave: this.#save.bind(this)
      })
    };
    const event = new ProseMirrorPluginsEvent(plugins);
    this.dispatchEvent(event);
    if ( event.defaultPrevented ) return {};
    return plugins;
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking the editor activation button.
   * @param {PointerEvent} event  The triggering event.
   */
  #onClickButton(event) {
    event.preventDefault();
    this.open = true;
  }

  /* -------------------------------------------- */

  /**
   * Handle the conclusion of a drag event for some child.
   */
  #onDragEnd() {
    this.#pointerdown = null;
  }

  /* -------------------------------------------- */

  /**
   * Handle a pointerdown event on some child.
   * @param {PointerEvent} event  The triggering event.
   */
  #onPointerDown(event) {
    this.#pointerdown = event.target;
  }

  /* -------------------------------------------- */

  /**
   * Handle a pointerup event on some child.
   * @param {PointerEvent} event  The triggering event.
   */
  async #onPointerUp(event) {
    if ( !this.#pointerdown ) return;
    const inactiveEditor = this.disabled && !this.#active;
    const isClick = this.#pointerdown.contains(event.target);
    const isContentLink = this.#pointerdown.closest("a[data-link]");

    // If this editor is in an untoggled state and is disabled, simulate click events on child content links, as they
    // otherwise fail to bubble up to the global content link listener.
    if ( inactiveEditor && isClick && isContentLink ) {
      const doc = await foundry.utils.fromUuid(event.target.closest("a[data-link]").dataset.uuid);
      doc?._onClickDocumentLink(event);
    }

    this.#pointerdown = null;
  }

  /* -------------------------------------------- */

  /**
   * Handle saving the editor content.
   * Store new parsed HTML into the _value attribute of the element.
   * If the editor is toggled, also deactivate editing mode.
   */
  #save() {
    const save = new Event("save", { bubbles: true, cancelable: true });
    this.dispatchEvent(save);
    if ( save.defaultPrevented ) return;

    const value = ProseMirror.dom.serializeString(this.#editor.view.state.doc.content);
    if ( value !== this._value ) {
      this._setValue(value);
      this.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
    }

    // Deactivate a toggled editor
    if ( this.#toggled ) {
      this.#button.disabled = this.disabled;
      this.#active = false;
      this.open = false;
      this.#editor.destroy();
      this.classList.remove("active");
      this.classList.add("inactive");
      this.replaceChildren(this.#button, this.#content);
      this._refresh();
      this.dispatchEvent(new Event("close", {bubbles: true, cancelable: true}));
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _toggleDisabled(disabled) {
    if ( this.#toggled ) this.#button.disabled = disabled || this.#active;
    if ( this.#editor ) this.#editor.view.updateState(this.#editor.view.state);
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Determine if the editor has unsaved changes.
   * @returns {boolean}
   */
  isDirty() {
    return this.#editor?.isDirty() ?? false;
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Create a HTMLProseMirrorElement using provided configuration data.
   * @param {FormInputConfig & ProseMirrorInputConfig} config
   * @returns {HTMLProseMirrorElement}
   */
  static create(config) {
    const { enriched, toggled, value } = config;
    const editor = new this({ enriched, toggled, value });
    editor.name = config.name;

    // Configure editor properties
    foundry.applications.fields.setInputAttributes(editor, config);
    editor.toggleAttribute("collaborate", config.collaborate ?? false);
    editor.toggleAttribute("compact", config.compact ?? false);
    editor.toggleAttribute("toggled", config.toggled ?? false);
    if ( "documentUUID" in config ) Object.assign(editor.dataset, {
      documentUuid: config.documentUUID,
      documentUUID: config.documentUUID
    });
    if ( Number.isNumeric(config.height) ) editor.style.height = `${config.height}px`;

    // Un-enriched content gets temporarily assigned to the value property of the element
    editor.setAttribute("value", config.value ?? "");

    // Enriched content gets temporarily assigned as the innerHTML of the element
    if ( config.toggled && config.enriched ) editor.innerHTML = config.enriched;
    return editor;
  }
}

/**
 * A custom event class for configuring ProseMirror plugins.
 * @extends {CustomEvent}
 */
class ProseMirrorPluginsEvent extends CustomEvent {
  /**
   * @param {Record<string, ProseMirror.Plugin>} plugins  The plugins supplied to the ProseMirror instance.
   */
  constructor(plugins) {
    super("plugins", { detail: plugins, bubbles: true, cancelable: true });
  }

  /* -------------------------------------------- */

  /**
   * The currently configured plugins.
   * @type {Record<string, ProseMirror.Plugin>}
   */
  get plugins() {
    return this.detail;
  }
}

/**
 * Custom HTMLElement implementations for use in template rendering.
 * @module elements
 */


// Define custom elements
window.customElements.define(HTMLColorPickerElement.tagName, HTMLColorPickerElement);
window.customElements.define(HTMLCodeMirrorElement.tagName, HTMLCodeMirrorElement);
window.customElements.define(HTMLDocumentEmbedElement.tagName, HTMLDocumentEmbedElement);
window.customElements.define(HTMLDocumentTagsElement.tagName, HTMLDocumentTagsElement);
window.customElements.define(HTMLEnrichedContentElement.tagName, HTMLEnrichedContentElement);
window.customElements.define(HTMLFilePickerElement.tagName, HTMLFilePickerElement);
window.customElements.define(HTMLHueSelectorSlider.tagName, HTMLHueSelectorSlider);
window.customElements.define(HTMLMultiSelectElement.tagName, HTMLMultiSelectElement);
window.customElements.define(HTMLMultiCheckboxElement.tagName, HTMLMultiCheckboxElement);
window.customElements.define(HTMLRangePickerElement.tagName, HTMLRangePickerElement);
window.customElements.define(HTMLSecretBlockElement.tagName, HTMLSecretBlockElement);
window.customElements.define(HTMLStringTagsElement.tagName, HTMLStringTagsElement);
window.customElements.define(HTMLProseMirrorElement.tagName, HTMLProseMirrorElement);

var _module$K = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbstractFormInputElement: AbstractFormInputElement,
  AbstractMultiSelectElement: AbstractMultiSelectElement,
  HTMLCodeMirrorElement: HTMLCodeMirrorElement,
  HTMLColorPickerElement: HTMLColorPickerElement,
  HTMLDocumentEmbedElement: HTMLDocumentEmbedElement,
  HTMLDocumentTagsElement: HTMLDocumentTagsElement,
  HTMLEnrichedContentElement: HTMLEnrichedContentElement,
  HTMLFilePickerElement: HTMLFilePickerElement,
  HTMLHueSelectorSlider: HTMLHueSelectorSlider,
  HTMLMultiCheckboxElement: HTMLMultiCheckboxElement,
  HTMLMultiSelectElement: HTMLMultiSelectElement,
  HTMLProseMirrorElement: HTMLProseMirrorElement,
  HTMLRangePickerElement: HTMLRangePickerElement,
  HTMLSecretBlockElement: HTMLSecretBlockElement,
  HTMLStringTagsElement: HTMLStringTagsElement
});

/**
 * @import {DrawingHUD, TileHUD, TokenHUD} from "./_module.mjs";
 */

/**
 * The Heads-Up Display Container is a canvas-sized Application which renders HTML overtop of the game canvas.
 */
class HeadsUpDisplayContainer extends ApplicationV2 {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "hud",
    window: {
      frame: false,
      positioned: false
    },
    position: {
      zIndex: 100
    }
  };

  /* -------------------------------------------- */

  /**
   * 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();

  /* -------------------------------------------- */

  /** @override */
  async _renderHTML(_context, _options) {
    return `
    <template id="${this.tile.id}"></template>
    <template id="${this.drawing.id}"></template>
    <div id="chat-bubbles" class="themed theme-light"></div>
    <div id="measurement"></div>`;
  }

  /* -------------------------------------------- */

  /** @override */
  _replaceHTML(result, content, _options) {
    content.innerHTML = result;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.align();
    if ( this.options.classes.includes("themed") ) return;
    this.element.classList.remove("theme-light", "theme-dark");
    const {colorScheme} = game.settings.get("core", "uiConfig");
    if ( colorScheme.interface ) this.element.classList.add("themed", `theme-${colorScheme.interface}`);
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Align the position of the HUD layer to the current position of the canvas
   */
  align() {
    if ( !this.rendered ) return; // Not yet rendered
    const hud = this.element;
    const {x, y} = canvas.primary.getGlobalPosition();
    const {width, height} = canvas.dimensions;
    const scale = canvas.stage.scale.x;
    Object.assign(hud.style, {
      width: `${width}px`,
      height: `${height}px`,
      left: `${x}px`,
      top: `${y}px`,
      transform: `scale(${scale})`
    });
  }
}

/**
 * @import {CanvasDocument} from "../../documents/abstract/canvas-document.mjs";
 * @import {PlaceableObject} from "@client/canvas/placeables/_module.mjs";
 * @import {PlaceablesLayer} from "@client/canvas/layers/_module.mjs";
 */

/**
 * An abstract base class for displaying a heads-up-display interface bound to a Placeable Object on the Canvas.
 * @template {PlaceableObject} ActiveHUDObject
 * @template {CanvasDocument} ActiveHUDDocument
 * @template {PlaceablesLayer} ActiveHUDLayer
 */
class BasePlaceableHUD extends ApplicationV2 {

  /** @override */
  static DEFAULT_OPTIONS = {
    id: "placeable-hud-{id}",
    classes: ["placeable-hud"],
    tag: "form",
    window: {
      frame: false,
      positioned: true
    },
    form: {
      handler: BasePlaceableHUD.#onSubmit,
      submitOnChange: true,
      closeOnSubmit: false
    },
    actions: {
      config: BasePlaceableHUD.#onConfigure,
      visibility: BasePlaceableHUD.#onToggleVisibility,
      locked: BasePlaceableHUD.#onToggleLocked,
      sort: BasePlaceableHUD.#onSort,
      togglePalette: BasePlaceableHUD.#onTogglePalette
    },
    position: {}
  };

  /** @override */
  static BASE_APPLICATION = BasePlaceableHUD;

  /* -------------------------------------------- */

  /**
   * Reference a PlaceableObject this HUD is currently bound to.
   * @type {ActiveHUDObject}
   */
  get object() {
    return this.#object;
  }

  #object;

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * The palette that is currently expanded, if any.
   * @type {string|null}
   */
  get activePalette() {
    return this.#activePalette;
  }

  #activePalette = null;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(_options) {
    const documentData = this.#object.document?.toObject();
    const context = {
      id: this.id,
      classes: this.options.classes.join(" "),
      appId: this.appId,
      isGM: game.user.isGM,
      isGamePaused: game.paused,
      icons: CONFIG.controlIcons,
      visibilityClass: documentData.hidden ? "active" : "",
      lockedClass: documentData.locked ? "active" : ""
    };
    if ( documentData ) Object.assign(context, documentData);
    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  _updatePosition(position) {
    const s = canvas.dimensions.uiScale;
    const {x: left, y: top} = this.#object.position;
    const {width, height} = this.#object.bounds;
    Object.assign(position, {left, top, width: width / s, height: height / s});
    position.scale = s;
    return position;
  }

  /* -------------------------------------------- */

  /** @override */
  async _onRender(context, options) {

    // Auto-select attribute value on focus and prevent Enter keypresses from triggering form submission
    for ( const input of this.element.querySelectorAll(".attribute > input") ) {
      input.addEventListener("focus", ev => input.select());
      input.addEventListener("keypress", event => {
        if ( event.key === "Enter" ) {
          event.preventDefault();
          input.blur();
        }
      });
    }

    // Toggle active palette
    this.#toggleActivePalette(true);
  }

  /* -------------------------------------------- */

  /** @override */
  async _preClose(options) {
    options.animate = false; // Don't animate closing
  }

  /* -------------------------------------------- */

  /** @override */
  async _onClose(options) {
    this.#object = undefined;
    this.#activePalette = null;
  }

  /* -------------------------------------------- */

  /**
   * Insert the application HTML element into the DOM.
   * Subclasses may override this method to customize how the application is inserted.
   * @param {HTMLElement} element                 The element to insert
   * @protected
   */
  _insertElement(element) {
    const existing = document.getElementById(element.id);
    if ( existing ) existing.replaceWith(element);
    else {
      const parent = document.getElementById("hud");
      parent.append(element);
    }
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Bind the HUD to a new PlaceableObject and display it.
   * @param {ActiveHUDObject} object    A PlaceableObject instance to which the HUD should be bound
   * @returns {Promise<void>}
   */
  async bind(object) {
    await this.render({force: true, position: true, object});
  }

  /* -------------------------------------------- */

  /** @override */
  _canRender({object}) {
    if ( object !== undefined ) {
      if ( !(object instanceof foundry.canvas.placeables.PlaceableObject) || (object.scene !== canvas.scene) ) {
        throw new Error("You may only bind a HUD instance to a PlaceableObject in the currently viewed Scene.");
      }
    }
    else if ( !this.#object ) return false;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    const {object} = options;
    if ( !object ) return;
    this.#object = object;
    this.#activePalette = null;
  }

  /* -------------------------------------------- */

  /**
   * Toggle the expanded state of the given palette.
   * @param {string|null} palette    The palette to toggle or null to collapse of the currently expanded palette
   * @param {boolean} [active]       Force the palette to be active or inactive
   */
  togglePalette(palette, active) {
    if ( palette !== null ) {
      if ( active === undefined) palette = this.#activePalette === palette ? null : palette;
      else if ( active === false ) palette = null;
    }
    if ( this.#activePalette === palette ) return;
    this.#toggleActivePalette(false);
    this.#activePalette = palette;
    this.#toggleActivePalette(true);
    canvas.app.view.focus(); // Return focus to the canvas so keyboard movement is honored
  }

  /* -------------------------------------------- */

  /**
   * Toggle the expanded state of the active palette.
   * @param {boolean} active       Force the palette to be active or inactive
   */
  #toggleActivePalette(active) {
    if ( !this.#activePalette ) return;
    const button = this.element.querySelector(`.control-icon[data-palette="${this.#activePalette}"]`);
    button.classList.toggle("active", active);
    const palette = this.element.querySelector(`.palette[data-palette="${this.#activePalette}"]`);
    palette.classList.toggle("active", active);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle submission of the BasePlaceableHUD form.
   * Wrap a protected method that can be implemented by a subclass.
   * @this {BasePlaceableHUD}
   * @param {SubmitEvent} event
   * @param {HTMLFormElement} form
   * @param {FormDataExtended} formData
   * @returns {Promise<void>}
   */
  static async #onSubmit(event, form, formData) {
    return this._onSubmit(event, form, formData);
  }

  /* -------------------------------------------- */

  /**
   * Handle submission of the BasePlaceableHUD form.
   * @param {SubmitEvent} event
   * @param {HTMLFormElement} form
   * @param {FormDataExtended} formData
   * @returns {Promise<void>}
   * @protected
   */
  async _onSubmit(event, form, formData) {
    const submitData = {};
    for ( const [k, v] of Object.entries(formData.object) ) {
      if ( (event.type === "change") && (event.target.name !== k) ) continue;
      const current = foundry.utils.getProperty(this.document, k);
      submitData[k] = this._parseAttributeInput(k, current, v).value;
    }
    await this.document.update(submitData);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling palette.
   * @this {BasePlaceableHUD}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} target
   */
  static #onTogglePalette(event, target) {
    this.togglePalette(target.dataset.palette);
  }

  /* -------------------------------------------- */

  /**
   * Handle click actions to configure the placed object.
   * @this {BasePlaceableHUD}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} target
   */
  static #onConfigure(event, target) {
    this.#object.sheet.render(true); // TODO change to {force: true} once everything is ApplicationV2
  }

  /* -------------------------------------------- */

  /**
   * Handle click actions to toggle object visibility.
   * @this {BasePlaceableHUD}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} target
   */
  static #onToggleVisibility(event, target) {
    const isHidden = !!this.document?.hidden;
    const updates = this.layer.controlled.map(o => ({_id: o.id, hidden: !isHidden}));
    target.classList.toggle("active", !isHidden);
    return canvas.scene.updateEmbeddedDocuments(this.document.documentName, updates);
  }

  /* -------------------------------------------- */

  /**
   * Handle click actions to toggle object locked state.
   * @this {BasePlaceableHUD}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} target
   */
  static #onToggleLocked(event, target) {
    const isLocked = !!this.document?.locked;
    const updates = this.layer.controlled.map(o => ({_id: o.id, locked: !isLocked}));
    target.classList.toggle("active", !isLocked);
    return canvas.scene.updateEmbeddedDocuments(this.document.documentName, updates);
  }

  /* -------------------------------------------- */

  /**
   * Handle click actions to sort the object backwards or forwards within its layer.
   * @this {BasePlaceableHUD}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} target
   */
  static #onSort(event, target) {
    event.preventDefault();
    const up = target.dataset.direction === "up";
    this.layer._sendToBackOrBringToFront(up);
  }

  /* -------------------------------------------- */

  /**
   * 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 {attribute: name, value, delta, isDelta, isBar};
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  clear() {
    foundry.utils.logCompatibilityWarning("BasePlaceableHUD#clear is deprecated in favor of BasePlaceableHUD#close",
      {since: 13, until: 15});
    this.close();
  }
}

/**
 * @import Tile from "../../canvas/placeables/tile.mjs";
 * @import TileDocument from "../../documents/tile.mjs";
 * @import TilesLayer from "../../canvas/layers/tiles.mjs";
 */

/**
 * 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>}
 * @mixes HandlebarsApplication
 */
class TileHUD extends HandlebarsApplicationMixin(BasePlaceableHUD) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "tile-hud",
    actions: {
      video: TileHUD.#onControlVideo
    }
  };

  /** @override */
  static PARTS = {
    hud: {
      root: true,
      template: "templates/hud/tile-hud.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const {isVideo, sourceElement} = this.object;
    const isPlaying = isVideo && !sourceElement.paused && !sourceElement.ended;
    return Object.assign(context, {
      isVideo: isVideo,
      videoIcon: isPlaying ? "fa-solid fa-pause" : "fa-solid fa-play",
      videoTitle: game.i18n.localize(isPlaying ? "HUD.TilePause" : "HUD.TilePlay")
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Toggle playback of a video tile.
   * @this {TileHUD}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} target
   * @returns {Promise<void>}
   */
  static #onControlVideo(event, target) {
    const {sourceElement, document} = this.object;
    const icon = target.children[0];
    const isPlaying = !sourceElement.paused && !sourceElement.ended;

    // Intercepting state change if the source is not looping and not playing
    if ( !sourceElement.loop && !isPlaying ) {
      sourceElement.onpause = () => {
        if ( this.object?.sourceElement ) {
          icon.classList.replace("fa-pause", "fa-play");
        }
        sourceElement.onpause = null;
      };
    }

    // Update the video playing state
    return document.update({"video.autoplay": false}, {
      diff: false,
      playVideo: !isPlaying,
      offset: sourceElement.ended ? 0 : null
    });
  }
}

/**
 * @import Token from "../../canvas/placeables/token.mjs";
 * @import TokenLayer from "../../canvas/layers/tokens.mjs";
 */

/**
 * An implementation of the BasePlaceableHUD 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>}
 * @mixes HandlebarsApplication
 */
class TokenHUD extends HandlebarsApplicationMixin(BasePlaceableHUD) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "token-hud",
    actions: {
      combat: TokenHUD.#onToggleCombat,
      target: TokenHUD.#onToggleTarget,
      effect: {handler: TokenHUD.#onToggleEffect, buttons: [0, 2]},
      movementAction: TokenHUD.#onSelectMovementAction
    }
  };

  /** @override */
  static PARTS = {
    hud: {
      root: true,
      template: "templates/hud/token-hud.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * Convenience reference to the Actor modified by this TokenHUD.
   * @type {Actor}
   */
  get actor() {
    return this.document?.actor;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const bar1 = this.document.getBarAttribute("bar1");
    const bar2 = this.document.getBarAttribute("bar2");
    return foundry.utils.mergeObject(context, {
      canConfigure: game.user.can("TOKEN_CONFIGURE"),
      canToggleCombat: ui.combat !== null,
      displayBar1: bar1 && (bar1.type !== "none"),
      bar1Data: bar1,
      displayBar2: bar2 && (bar2.type !== "none"),
      bar2Data: bar2,
      combatClass: this.object.inCombat ? "active" : "",
      targetClass: this.object.targeted.has(game.user) ? "active" : "",
      statusEffects: this._getStatusEffectChoices(),
      movementActions: this._getMovementActionChoices(),
      movementActionsConfig: CONFIG.Token.movement.actions[this.document.movementAction]
    });
  }

  /* -------------------------------------------- */

  /**
   * Get the valid status effect choices.
   * @returns {{[id: string]: {
   *   id: string;
   *   _id: string;
   *   title: string;
   *   src: string;
   *   isActive: boolean;
   *   isOverlay: boolean;
   *   cssClass: string;
   * }}}
   * @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;
  }

  /* -------------------------------------------- */

  /**
   * Get the valid movement action choices.
   * @returns {{[id: string]: {
   *   id: string;
   *   label: string;
   *   [icon]: string;
   *   [img]: string;
   *   isActive: boolean;
   *   cssClass: string;
   * }}}
   * @protected
   */
  _getMovementActionChoices() {
    const currentAction = this.document._source.movementAction;
    const defaultAction = !currentAction ? this.document.movementAction : this.document._inferMovementAction();
    const choices = {
      // Default
      "": {
        id: "",
        label: `${game.i18n.localize("Default")} (${game.i18n.localize(CONFIG.Token.movement.actions[defaultAction].label)})`,
        isActive: currentAction === null,
        cssClass: currentAction === null ? "active" : ""
      }
    };
    for ( const [action, config] of Object.entries(CONFIG.Token.movement.actions) ) {
      if ( !config.canSelect(this.document) && (action !== currentAction) ) continue;
      const isActive = action === currentAction;
      choices[action] = {
        id: action,
        label: game.i18n.localize(config.label),
        icon: config.icon,
        img: config.img,
        isActive,
        cssClass: isActive ? "active" : ""
      };
    }
    return choices;
  }

  /* -------------------------------------------- */

  /** @override */
  _onPosition(position) {
    this.element.classList.toggle("large", this.document.height >= 2);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _parseAttributeInput(name, attr, input) {
    if ( (name === "bar1") || (name === "bar2") ) {
      attr = this.document.getBarAttribute(name);
      name = attr.attribute;
    }
    return super._parseAttributeInput(name, attr, input);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onSubmit(event, form, formData) {
    // Special submission process for token attribute changes
    if ( (event.type === "change") && ["bar1", "bar2"].includes(event.target.name) ) {
      return this.#onSubmitBar(event, form, formData);
    }

    // Special submission process for token elevation changes
    if ( (event.type === "change") && (event.target.name === "elevation") ) {
      return this.#onSubmitElevation(event, form, formData);
    }

    return super._onSubmit(event, form, formData);
  }

  /* -------------------------------------------- */

  /**
   * Special submission process for token attribute changes.
   * @param {SubmitEvent} event
   * @param {HTMLFormElement} form
   * @param {FormDataExtended} formData
   * @returns {Promise<void>}
   */
  async #onSubmitBar(event, form, formData) {
    const name = event.target.name;
    const input = event.target.value;
    const {attribute, value, delta, isDelta, isBar} = this._parseAttributeInput(name, undefined, input);
    await this.actor?.modifyTokenAttribute(attribute, isDelta ? delta : value, isDelta, isBar);
  }

  /* -------------------------------------------- */

  /**
   * Special submission process for token elevation changes.
   * @param {SubmitEvent} event
   * @param {HTMLFormElement} form
   * @param {FormDataExtended} formData
   * @returns {Promise<void>}
   */
  async #onSubmitElevation(event, form, formData) {
    const origin = this.document._source;
    const elevation = this._parseAttributeInput("elevation", origin.elevation, event.target.value).value;
    const destination = this.object._getHUDMovementPosition(elevation);
    destination.x = Math.round(destination.x ?? origin.x);
    destination.y = Math.round(destination.y ?? origin.y);
    destination.elevation ??= origin.elevation;
    destination.width ??= origin.width;
    destination.height ??= origin.height;
    destination.shape ??= origin.shape;
    destination.action = this.object._getHUDMovementAction();
    if ( canvas.grid.isGridless ) destination.snapped = false;

    // HUD movement is snapped if both the origin and the destination are snapped
    else {
      const snappedOrigin = this.document.getSnappedPosition(origin);
      const snappedDestination = this.document.getSnappedPosition(destination);
      destination.snapped = (origin.x === Math.round(snappedOrigin.x))
        && (origin.y === Math.round(snappedOrigin.y))
        && (origin.elevation.almostEqual(snappedOrigin.elevation))
        && (destination.x === Math.round(snappedDestination.x))
        && (destination.y === Math.round(snappedDestination.y))
        && (destination.elevation.almostEqual(snappedDestination.elevation));
    }
    destination.explicit = false;  // HUD movement does not explicitly place a waypoint
    destination.checkpoint = true;
    await this.document.move(destination);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the combat state of all controlled Tokens.
   * @this {TokenHUD}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} target
   * @returns {Promise<void>}
   */
  static async #onToggleCombat(event, target) {
    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 toggling a token status effect icon.
   * @this {TokenHUD}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} target
   * @returns {Promise<void>}
   */
  static async #onToggleEffect(event, target) {
    if ( !this.actor ) {
      ui.notifications.warn("HUD.WarningEffectNoActor", {localize: true});
      return;
    }
    const statusId = target.dataset.statusId;
    await this.actor.toggleStatusEffect(statusId, {
      active: !target.classList.contains("active"),
      overlay: event.button === 2
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle selecting a movement action.
   * @this {TokenHUD}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} target
   * @returns {Promise<void>}
   */
  static async #onSelectMovementAction(event, target) {
    await this.document.update({movementAction: target.dataset.movementAction || null});
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the target state for this Token
   * @this {TokenHUD}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} target
   */
  static #onToggleTarget(event, target) {
    const token = this.object;
    const targeted = !token.isTargeted;
    token.setTarget(targeted, {releaseOthers: false});
    target.classList.toggle("active", targeted);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  toggleStatusTray(active) {
    foundry.utils.logCompatibilityWarning('TokenHUD#toggleStatusTray has been deprecated in favor of TokenHUD#togglePalette("effects", active?)',
      {since: 13, until: 15, once: true});
    this.togglePalette("effects", active);
  }
}

/**
 * @import Drawing from "../../canvas/placeables/drawing.mjs";
 * @import DrawingDocument from "../../documents/drawing.mjs";
 * @import DrawingsLayer from "../../canvas/layers/drawings.mjs";
 */

/**
 * 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>}
 * @mixes HandlebarsApplication
 */
class DrawingHUD extends HandlebarsApplicationMixin(BasePlaceableHUD) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "drawing-hud"
  };

  /** @override */
  static PARTS = {
    hud: {
      root: true,
      template: "templates/hud/drawing-hud.hbs"
    }
  };
}

var _module$J = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BasePlaceableHUD: BasePlaceableHUD,
  DrawingHUD: DrawingHUD,
  HeadsUpDisplayContainer: HeadsUpDisplayContainer,
  TileHUD: TileHUD,
  TokenHUD: TokenHUD
});

/**
 * @import {ApplicationConfiguration} from "../_types.mjs";
 * @import {HandlebarsRenderOptions} from "../api/handlebars-application.mjs";
 */

/**
 * @typedef _DependencyResolutionAppConfiguration
 * @param {ModuleManagement} manager  The module management application.
 * @param {Module} root               The module that is the root of the dependency resolution.
 * @param {boolean} enabling          Whether the root dependency is being enabled or disabled.
 */

/**
 * @typedef {ApplicationConfiguration & _DependencyResolutionAppConfiguration} DependencyResolutionAppConfiguration
 */

/**
 * @typedef DependencyResolutionDescriptor
 * @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.
 */

/**
 * A class responsible for prompting the user about dependency resolution for their modules.
 * @extends {ApplicationV2<DependencyResolutionAppConfiguration, HandlebarsRenderOptions>}
 * @mixes HandlebarsApplication
 */
class DependencyResolution extends HandlebarsApplicationMixin(ApplicationV2) {
  /**
   * @param {DeepPartial<DependencyResolutionAppConfiguration>} [options={}]  Options to configure DependencyResolution
   *                                                                          behavior.
   */
  constructor(options={}) {
    super(options);
    this.#manager = options.manager;
    this.#root = options.root;

    // Always include the root module.
    this.#modules.set(options.root.id, options.root);

    // Determine initial state.
    if ( options.enabling ) this.#initializeEnabling();
    else this.#initializeDisabling();
  }

  /** @override */
  static DEFAULT_OPTIONS = {
    tag: "dialog",
    classes: ["dependency-resolution", "dialog"],
    window: {
      contentTag: "form",
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-sitemap",
      title: "MODMANAGE.DependencyResolution"
    },
    position: {
      width: 480
    },
    actions: {
      cancel: DependencyResolution.#onCancel
    },
    form: {
      closeOnSubmit: true,
      handler: DependencyResolution.#onSubmitForm
    },
    enabling: true
  };

  /** @override */
  static PARTS = {
    resolution: {
      classes: ["standard-form"],
      template: "templates/setup/impacted-dependencies.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * 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 game's modules that are currently active in the module manager.
   * @type {Map<string, Module>}
   */
  #modules = 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();
  }

  /**
   * Track the changes being made by the user as part of dependency resolution.
   * @type {Map<Module, DependencyResolutionDescriptor>}
   */
  #resolution = new Map();

  /**
   * The module that is the root of the dependency resolution.
   * @type {Module}
   */
  get root() {
    return this.#root;
  }

  #root;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _onFirstRender(_context, _options) {
    this.element.showModal();
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const required = [];
    const optional = [];
    let subtypes;

    if ( this.options.enabling ) this.#getDependencyContext(required, optional);
    else {
      this.#getUnusedContext(optional);
      subtypes = this.#getUnavailableSubtypes();
    }

    return Object.assign(context, {
      required, optional, subtypes,
      checkbox: new BooleanField(),
      enabling: this.options.enabling,
      buttons: [
        { type: "submit", icon: "fa-solid fa-check", label: this.options.enabling ? "Activate" : "Deactivate" },
        { type: "button", icon: "fa-solid fa-xmark", label: "Cancel", action: "cancel" }
      ]
    });
  }

  /* -------------------------------------------- */

  /**
   * 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.
   * @param {DependencyResolutionDescriptor[]} [required]  Required dependencies.
   * @param {DependencyResolutionDescriptor[]} [optional]  Optional dependencies.
   */
  #getDependencyContext(required=[], optional=[]) {
    const skip = this.#resolution.values().reduce((acc, { checked, module }) => {
      if ( checked === false ) acc.add(module);
      return acc;
    }, new Set());

    const dependencies = this.#getDependencies(skip);
    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);
    }
  }

  /* -------------------------------------------- */

  /**
   * The list of modules that the user currently has selected, including the root module.
   * @returns {Set<Module>}
   */
  #getSelectedModules() {
    const selected = new Set([this.root]);
    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 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.
   * @param {DependencyResolutionDescriptor[]} [optional]  Dependencies that are no longer required.
   */
  #getUnusedContext(optional=[]) {
    // Re-calculate unused modules after we remove those the user unchecked.
    const unused = this.#getUnused(this.#getSelectedModules());
    for ( const module of this.#candidates ) {
      if ( unused.has(module) ) optional.push(this.#resolution.get(module));
    }
  }

  /* -------------------------------------------- */

  /**
   * 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[`${module.id}.${subtype}`] = count;
        });
      });
    }
    return this.#manager._formatDocumentSummary(allCounts, true);
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle canceling dependency resolution.
   * @this {DependencyResolution}
   */
  static #onCancel() {
    return this.close();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    const { target } = event;
    const module = this.#modules.get(target.name);
    const checked = target.checked;
    const resolution = this.#resolution.get(module);
    resolution.checked = checked;
    this.render({ parts: ["resolution"] });
  }

  /* -------------------------------------------- */

  /**
   * Commit the dependency resolution result.
   * @this {DependencyResolution}
   * @param {SubmitEvent} event          The submission event.
   * @param {HTMLFormElement} form       The form that was submitted.
   */
  static #onSubmitForm(event, form) {
    const fd = new FormDataExtended(form, { disabled: true }).object;
    fd[this.root.id] = true;
    this.#manager._onSelectDependencies(fd, this.options.enabling);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * 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.root);
    for ( const dependent of (dependents ?? []) ) {
      if ( dependent.relationships.requires.find(({ id }) => id === this.root.id) && (dependent.id !== this.root.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, DependencyResolutionDescriptor>}
   */
  #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 += "\n";
        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 the 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.root, this.root.relationships.requires, true);
    addDependencies(this.root, this.root.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();
    const systemRequirements = game.system.relationships.requires.map(m => m.id);
    for ( const module of this.#modules.values() ) {
      const dependents = this.#dependents.get(module);
      if ( !dependents || systemRequirements.has(module.id) ) 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.root]);

    // 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);
  }
}

/**
 * @import {ApplicationClickAction, ApplicationConfiguration, ApplicationFormSubmission} from "../../_types.mjs";
 * @import {AVMaster} from "@client/av/master.mjs";
 */

/**
 * @typedef AVConfigConfiguration
 * @property {AVMaster} [webrtc] The AVMaster instance being configured
 */

/**
 * Audio/Video Conferencing Configuration Sheet
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class AVConfig extends HandlebarsApplicationMixin(ApplicationV2) {

  /**
   * @param {ApplicationConfiguration & AVConfigConfiguration} options
   */
  constructor(options) {
    super(options);

    /**
     * The AVMaster instance being configured
     * @type {AVMaster}
     */
    this.webrtc = this.options.webrtc ?? game.webrtc;
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    tag: "form",
    id: "av-config",
    window: {
      title: "WEBRTC.Title",
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-headset"
    },
    position: {
      width: 480
    },
    form: {
      closeOnSubmit: true,
      handler: AVConfig.#onSubmit
    }
  };

  /** @override */
  static PARTS = {
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    general: {template: "templates/settings/menus/av-config/general.hbs"},
    devices: {template: "templates/settings/menus/av-config/devices.hbs"},
    server: {template: "templates/settings/menus/av-config/server.hbs"},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /** @override */
  static TABS = {
    main: {
      tabs: [
        {id: "general", icon: "fa-solid fa-gear"},
        {id: "devices", icon: "fa-solid fa-microphone"},
        {id: "server", icon: "fa-solid fa-server"}
      ],
      initial: "general",
      labelPrefix: "WEBRTC.TABS"
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    if ( !game.user.isGM ) delete parts.server;
    return parts;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    if ( !game.user.isGM ) delete context.tabs.server;

    // If the currently chosen device is unavailable, display a separate option for 'unavailable device (use default)'
    const isSSL = window.location.protocol === "https:";
    return Object.assign(context, {
      rootId: this.id,
      settings: this.webrtc.settings,
      fields: {world: AVSettings.schemaFields.world.fields, client: AVSettings.schemaFields.client.fields},
      isSSL
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    const partContext = await super._preparePartContext(partId, context, options);
    if ( partId in context.tabs ) partContext.tab = partContext.tabs[partId];
    switch ( partId ) {
      case "general":
        partContext.canSelectMode = game.user.isGM && context.isSSL;
        partContext.modes = {
          [AVSettings.AV_MODES.DISABLED]: game.i18n.localize("WEBRTC.ModeDisabled"),
          [AVSettings.AV_MODES.AUDIO]: game.i18n.localize("WEBRTC.ModeAudioOnly"),
          [AVSettings.AV_MODES.VIDEO]: game.i18n.localize("WEBRTC.ModeVideoOnly"),
          [AVSettings.AV_MODES.AUDIO_VIDEO]: game.i18n.localize("WEBRTC.ModeAudioVideo")
        };
        partContext.voiceModes = Object.values(AVSettings.VOICE_MODES).reduce((modes, mode) => {
          modes[mode] = game.i18n.localize(`WEBRTC.VoiceMode${mode.titleCase()}`);
          return modes;
        }, {});
        partContext.dockPositions = Object.values(AVSettings.DOCK_POSITIONS).reduce((positions, position) => {
          positions[position] = game.i18n.localize(`WEBRTC.DockPosition${position.titleCase()}`);
          return positions;
        }, {});
        partContext.nameplates = {
          [AVSettings.NAMEPLATE_MODES.OFF]: game.i18n.localize("WEBRTC.NameplatesOff"),
          [AVSettings.NAMEPLATE_MODES.PLAYER_ONLY]: game.i18n.localize("WEBRTC.NameplatesPlayer"),
          [AVSettings.NAMEPLATE_MODES.CHAR_ONLY]: game.i18n.localize("WEBRTC.NameplatesCharacter"),
          [AVSettings.NAMEPLATE_MODES.BOTH]: game.i18n.localize("WEBRTC.NameplatesBoth")
        };
        break;
      case "devices": {
        const videoSources = await this.webrtc.client.getVideoSources();
        const audioSources = await this.webrtc.client.getAudioSources();
        const audioSinks = await this.webrtc.client.getAudioSinks();
        const {videoSrc, audioSrc, audioSink} = this.webrtc.settings.client;
        const videoSrcUnavailable = this.#isSourceUnavailable(videoSources, videoSrc);
        const audioSrcUnavailable = this.#isSourceUnavailable(audioSources, audioSrc);
        const audioSinkUnavailable = this.#isSourceUnavailable(audioSinks, audioSink);
        partContext.videoDevices = this.#getDevices(videoSources, videoSrcUnavailable, "WEBRTC.DisableVideoSource");
        partContext.audioDevices = this.#getDevices(audioSources, audioSrcUnavailable, "WEBRTC.DisableAudioSource");
        partContext.audioSinks = this.#getDevices(audioSinks, audioSinkUnavailable);
        break;
      }
      case "server":
        partContext.turnTypes = {
          server: game.i18n.localize("WEBRTC.TURNServerProvisioned"),
          custom: game.i18n.localize("WEBRTC.CustomTURNServer")
        };
        break;
      case "footer": {
        const disabled = !context.isSSL;
        partContext.buttons = [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "Save Changes", disabled}];
        break;
      }
    }
    return partContext;
  }

  /* -------------------------------------------- */

  /**
   * 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
   */
  #isSourceUnavailable(sources, source) {
    const specialValues = ["default", "disabled"];
    return source && !specialValues.includes(source) && !(source in sources);
  }

  /* -------------------------------------------- */

  /**
   * 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 = Object.entries(devices).map(([key, label]) => ({value: key, label}));
    const hasDefault = options.some(o => o.value === "default");
    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;
  }

  /* -------------------------------------------- */

  /** @override */
  async _onRender(context, options) {
    await super._onRender(context, options);

    // Activate or de-activate the custom server and turn configuration sections based on current settings
    this.element.querySelector('select[name="core.rtcWorldSettings.turn.type"]')?.addEventListener("change", event => {
      const fieldset = this.element.querySelector("fieldset[data-custom-turn-config]");
      fieldset.toggleAttribute("disabled", event.currentTarget.value !== "custom");
    });
  }

  /* -------------------------------------------- */

  /**
   * Update world and client settings.
   * @this {AVConfig}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmit(event, form, formData) {
    const settings = game.webrtc.settings;
    const submitData = expandObject(formData.object).core;

    // Update world settings
    const promises = [];
    if ( game.user.isGM ) {
      const worldUpdates = mergeObject(settings.world, submitData.rtcWorldSettings, {inplace: false});
      if ( settings.world.mode !== worldUpdates.mode ) SettingsConfig.reloadConfirm({world: true});
      promises.push(game.settings.set("core", "rtcWorldSettings", worldUpdates));
    }

    // Update client settings
    const clientUpdates = mergeObject(settings.client, submitData.rtcClientSettings, {inplace: false});
    promises.push(game.settings.set("core", "rtcClientSettings", clientUpdates));
    await Promise.all(promises);
  }
}

/**
 * @import {ApplicationClickAction, ApplicationFormSubmission} from "@client/applications/_types.mjs"
 */

class DefaultSheetsConfig extends CategoryBrowser {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "default-sheets-config",
    window: {
      title: "SETTINGS.DefaultSheetsN",
      icon: "fa-solid fa-scroll"
    },
    position: {
      width: 720,
      height: 600
    },
    form: {
      handler: DefaultSheetsConfig.#onSubmit
    },
    actions: {
      resetDefaults: DefaultSheetsConfig.#onResetDefaults
    },
    subtemplates: {
      category: "templates/settings/menus/default-sheets-category.hbs",
      sidebarFooter: "templates/category-browser/reset.hbs"
    }
  };

  /**
   * The Default Sheets setting name
   * @type {"sheetClasses"}
   */
  static SETTING = "sheetClasses";

  /* -------------------------------------------- */
  /**
   * All document types with configurable default sheets
   * @type {Set<string>}
   */
  static #DOCUMENT_TYPES = Array.from(new Set(ALL_DOCUMENT_TYPES).difference(new Set(["ActorDelta", "ChatMessage",
    "FogExploration", "JournalEntryCategory", "Setting"])));

  /* -------------------------------------------- */

  /**
   * The "sheetClasses" Setting field
   * @type {fields.SchemaField}
   */
  static get SCHEMA() {
    if ( DefaultSheetsConfig.#SCHEMA ) return DefaultSheetsConfig.#SCHEMA;
    const schema = DefaultSheetsConfig.#DOCUMENT_TYPES.reduce((schema, documentName) => {
      const Cls = getDocumentClass$1(documentName);
      const label = game.i18n.localize(Cls.metadata.labelPlural);
      schema[documentName] = new TypedObjectField(new StringField({required: true, nullable: true,
        blank: false, initial: null}), {label});
      return schema;
    }, {});
    return DefaultSheetsConfig.#SCHEMA = new SchemaField(schema);
  }

  /**
   * @type {fields.SchemaField}
   */
  static #SCHEMA;

  /* -------------------------------------------- */

  /**
   * Register the "sheetClasses" Setting and this menu application.
   */
  static registerSetting() {
    if ( !GAME_VIEWS.includes(game.view) ) return;
    game.settings.register("core", DefaultSheetsConfig.SETTING, {
      name: "Sheet Class Configuration",
      scope: "world",
      config: false,
      type: DefaultSheetsConfig.SCHEMA,
      onChange: setting => DocumentSheetConfig.updateDefaultSheets(setting)
    });
    game.settings.registerMenu("core", DefaultSheetsConfig.SETTING, {
      name: "SETTINGS.DefaultSheetsN",
      label: "SETTINGS.DefaultSheetsL",
      hint: "SETTINGS.DefaultSheetsH",
      icon: "fa-solid fa-scroll",
      type: DefaultSheetsConfig,
      restricted: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareCategoryData() {
    const setting = game.settings.get("core", DefaultSheetsConfig.SETTING);
    return Object.entries(DefaultSheetsConfig.SCHEMA.fields).reduce((categories, [documentName, field]) => {
      const documentClass = getDocumentClass$1(documentName);
      if ( !documentClass.hasTypeData ) return categories;
      const entries = game.documentTypes[documentName].flatMap(subtype => {
        if ( subtype === CONST.BASE_DOCUMENT_TYPE ) return [];
        const defaults = DocumentSheetConfig.getSheetClassesForSubType(documentName, subtype);
        const {defaultClasses: choices, defaultClass} = defaults;
        if ( !defaultClass ) return [];
        const currentClass = setting[documentName][subtype] ?? null;
        if ( currentClass === null ) { // Keep unaltered sheetClass values as `null`
          const defaultLabel = choices[defaultClass];
          delete choices[defaultClass];
          choices[""] = defaultLabel;
        }
        const id = `${documentName}-${subtype}`;
        const name = `${documentName}.${subtype}`;
        const typeLabel = CONFIG[documentName].typeLabels?.[subtype];
        const label = typeLabel ? game.i18n.localize(typeLabel) : subtype;
        const value = defaultClass in choices ? defaultClass : "";
        return Object.keys(choices).length ? {id, name, label, value, choices} : [];
      });
      if ( !entries.length ) return categories;
      categories[documentName] = {id: documentName, label: field.label, entries};
      return categories;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Handle button click to reset default settings
   * @this {DefaultSheetsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onResetDefaults() {
    const {SETTING, SCHEMA} = DefaultSheetsConfig;
    await game.settings.set("core", SETTING, SCHEMA.getInitialValue());
    return SettingsConfig.reloadConfirm({world: true});
  }

  /* -------------------------------------------- */

  /**
   * Update the default sheets setting.
   * @this {DefaultSheetsConfig}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmit(_event, _form, formData) {
    const submitData = expandObject(formData.object);
    const update = DefaultSheetsConfig.SCHEMA.clean(submitData);
    return game.settings.set("core", "sheetClasses", update);
  }
}

/**
 * @import {ApplicationFormSubmission} from "@client/applications/_types.mjs"
 */

/**
 * The application responsible for configuring methods of DiceTerm resolution.
 */
class DiceConfig extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "dice-config",
    tag: "form",
    window: {
      contentClasses: ["standard-form"],
      title: "DICE.CONFIG.Title",
      icon: "fa-solid fa-dice"
    },
    position: {
      width: 480
    },
    form: {
      closeOnSubmit: true,
      handler: DiceConfig.#onSubmit
    }
  };

  /** @override */
  static PARTS = {
    body: {
      template: "templates/settings/menus/dice-config.hbs",
      root: true
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /**
   * Dice Configuration setting name.
   * @type {"diceConfiguration"}
   * @deprecated since v13
   */
  static get SETTING() {
    const message = "DiceConfig.SETTING is deprecated: use Roll.DICE_CONFIGURATION_SETTING instead.";
    foundry.utils.logCompatibilityWarning(message, {since: 13, until: 14, once: true});
    return Roll.DICE_CONFIGURATION_SETTING;
  }

  /* -------------------------------------------- */

  /**
   * Register setting and menu.
   */
  static registerSetting() {
    game.settings.register("core", Roll.DICE_CONFIGURATION_SETTING, {
      config: false,
      default: {},
      type: Object,
      scope: "client"
    });

    game.settings.registerMenu("core", Roll.DICE_CONFIGURATION_SETTING, {
      name: "DICE.CONFIG.Title",
      label: "DICE.CONFIG.Label",
      hint: "DICE.CONFIG.Hint",
      icon: "fa-solid fa-dice-d20",
      type: DiceConfig,
      restricted: false
    });
  }

  /* -------------------------------------------- */
  /*  Application                                 */
  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(_options) {
    const {methods, dice} = CONFIG.Dice.fulfillment;
    if ( !game.user.hasPermission("MANUAL_ROLLS") ) delete methods.manual;
    const config = game.settings.get("core", Roll.DICE_CONFIGURATION_SETTING) || {};
    return {
      methods,
      dice: Object.entries(dice).map(([k, {label, icon}]) => {
        return {label, icon, denomination: k, method: config[k] || ""};
      }),
      buttons: [
        {type: "submit", icon: "fa-solid fa-floppy-disk", label: "SETTINGS.Save"}
      ]
    };
  }

  /* -------------------------------------------- */

  /**
   * Update dice settings.
   * @this {DiceConfig}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmit(_event, _form, formData) {
    const config = game.settings.get("core", Roll.DICE_CONFIGURATION_SETTING);
    foundry.utils.mergeObject(config, formData.object);
    await game.settings.set("core", Roll.DICE_CONFIGURATION_SETTING, config);
  }
}

/**
 * @import {FontFamilyDefinition, FontDefinition} from "@client/config.mjs";
 * @import {ApplicationClickAction, ApplicationConfiguration, ApplicationFormSubmission} from "../_types.mjs";
 */

/**
 * @typedef NewFontDefinition
 * @property {string} family
 * @property {number} [weight=400]
 * @property {string} [style="normal"]
 * @property {string} [src=""]
 * @property {string} [preview]
 */

/**
 * @typedef FontTypes
 * @property {string} FILE   Font is a file
 * @property {string} SYSTEM Font is from the system
 */

/**
 * A V2 application responsible for configuring custom fonts for the world.
 */
class FontConfig extends HandlebarsApplicationMixin(ApplicationV2) {
  /**
   * @param {DeepPartial<ApplicationConfiguration & NewFontDefinition>} [options={}] App config
   */
  constructor(options={}) {
    foundry.utils.mergeObject(options, {
      family: "",
      weight: 400,
      style: "normal",
      src: "",
      preview: game.i18n.localize("FONTS.FontPreview"),
      type: FontConfig.FONT_TYPES.FILE
    });
    super(options);
    this.object = options;
  }

  /**
   * Font types.
   * @type {FontTypes}
   * @readonly
   */
  static FONT_TYPES = Object.freeze({
    FILE: "file",
    SYSTEM: "system"
  });

  /**
   * The Foundry game setting key storing the world's fonts.
   * @type {string}
   */
  static SETTING = "fonts";

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "font-config",
    tag: "form",
    window: {
      contentClasses: ["standard-form"],
      title: "SETTINGS.FontConfigL",
      icon: "fa-solid fa-font"
    },
    position: {
      width: 600,
    },
    form: {
      closeOnSubmit: true
    }
  }

  /** @override */
  static PARTS = {
    body: {
      template: "templates/settings/menus/font-config.hbs",
      scrollable: [""]
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /**
   * Returns localized style choices.
   * @type {Array<{value: string, label: string}>}
   */
  static get #styleChoices() {
    return this.#STYLE_CHOICES ??= [
      {value: "normal", label: game.i18n.localize("FONTS.Styles.Normal")},
      {value: "italic", label: game.i18n.localize("FONTS.Styles.Italic")}
    ];
  }
  static #STYLE_CHOICES;

  /**
   * The new or in-progress font object we're editing.
   * @type {NewFontDefinition}
   */
  object;

  /**
   * Whether fonts have been modified since opening the application.
   * @type {boolean}
   */
  #fontsModified = false;

  /**
   * Which font is currently selected from the existing definitions.
   * @type {{family: string, index: number}|null}
   */
  #selected = null;

  /* -------------------------------------------- */
  /*  Static Font Management                      */
  /* -------------------------------------------- */

  /**
   * A private Set of successfully loaded font family names.
   * @type {Set<string>}
   */
  static #available = new Set();

  /* -------------------------------------------- */

  /**
   * Returns a list of loaded font families.
   * @returns {string[]}
   */
  static getAvailableFonts() {
    return Array.from(this.#available);
  }

  /* -------------------------------------------- */

  /**
   * Returns a record of loaded font families, formatted for selectOptions.
   * @returns {Record<string, string>}
   */
  static getAvailableFontChoices() {
    return this.getAvailableFonts().reduce((obj, fam) => {
      obj[fam] = fam;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Load a font definition for a given family.
   * @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 check = `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(check);
    }
    catch (err) {
      console.warn(`Font family "${family}" failed to load:`, err);
      return false;
    }

    if ( !document.fonts.check(check) ) {
      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, def] of Object.entries(definitions) ) {
        promises.push(this.loadFont(family, def));
      }
    }

    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(`${CONST.vtt} | Fonts loaded and ready.`)
    );
  }

  /* -------------------------------------------- */

  /**
   * Collect font definitions from both config and user settings.
   * @returns {Record<string, FontFamilyDefinition>[]}
   * @protected
   */
  static _collectDefinitions() {
    return [CONFIG.fontDefinitions, game.settings.get("core", this.SETTING) || {}];
  }

  /* -------------------------------------------- */

  /**
   * Create a FontFace from a definition.
   * @param {string} family               The font family name.
   * @param {FontDefinition} definition   The font definition.
   * @returns {FontFace}                  The new FontFace.
   * @protected
   */
  static _createFontFace(family, definition) {
    if ( !Array.isArray(definition.urls) ) return null;
    const urls = definition.urls.map(u => `url("${u}")`).join(", ");
    return new FontFace(family, urls, definition);
  }

  /* -------------------------------------------- */

  /**
   * Format a font definition for display.
   * @param {string} family               The font family name.
   * @param {FontDefinition} definition   The font definition.
   * @returns {string}                    The formatted definition.
   * @protected
   */
  static _formatFont(family, definition) {
    if ( foundry.utils.isEmpty(definition) ) return family;
    const w = definition.weight ?? 400;
    const s = definition.style ?? "normal";
    const byWeight = Object.fromEntries(Object.entries(CONST.FONT_WEIGHTS).map(([k, v]) => [v, k]));
    return `
      ${family},
      <span style="font-weight: ${w}">${byWeight[w]} ${w}</span>,
      <span style="font-style: ${s}">${s.toLowerCase()}</span>
    `;
  }

  /* -------------------------------------------- */
  /*  Application                                 */
  /* -------------------------------------------- */

  /** @override */
  async _onRender(context, options) {
    await super._onRender(context, options);
    const editable = this.element.querySelector("[contenteditable]");
    editable?.addEventListener("blur", this.#onPreviewBlur);
  }

  /* -------------------------------------------- */

  /**
   * Handles blur events on the preview text.
   * @param {FocusEvent} event
   */
  async #handlePreviewBlur(event) {
    this.object.preview = event.currentTarget?.textContent.trim() || game.i18n.localize("FONTS.FontPreview");
  }

  /**
   * A bound reference to the #handlePreviewBlur method.
   * @type {(event: FocusEvent) => Promise<void>}
   */
  #onPreviewBlur = this.#handlePreviewBlur.bind(this);

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(_options) {
    const definitions = game.settings.get("core", this.constructor.SETTING) ?? {};
    const fonts = Object.entries(definitions).flatMap(
      ([family, definition]) => this._getDataForDefinition(family, definition));

    if ( (this.#selected === null) && fonts.length ) {
      fonts[0].selected = true;
      this.#selected = {family: fonts[0].family, index: fonts[0].index};
    }

    // Reset font properties
    ["weight", "style", "src", "family"].forEach(k => this.object[k] = null);

    let selected = null;
    if ( this.#selected ) {
      const {family, index} = this.#selected;
      selected = definitions[family]?.fonts[index];

      // Always update the family
      this.object.family = family;

      if ( selected ) {
        this.object.weight = selected.weight ?? this.object.weight;
        this.object.style = selected.style ?? this.object.style;
        this.object.src = selected.urls?.[0] ?? "";
      }
    }

    const isSystemFont = this.object.type === FontConfig.FONT_TYPES.SYSTEM;
    const isFileFont = this.object.type === FontConfig.FONT_TYPES.FILE;

    return {
      fonts,
      selected,
      isSystemFont,
      isFileFont,
      font: this.object,
      fontWeights: this.#getWeightChoices(),
      preview: {
        family: selected?.family ?? this.object.family,
        weight: selected?.weight ?? this.object.weight,
        style: selected?.style ?? this.object.style,
        text: this.object.preview
      },
      fontStyles: FontConfig.#styleChoices,
      buttons: [
        {type: "button", label: "FONTS.AddFont", icon: "fa-solid fa-plus", action: "add"}
      ]
    };
  }

  /* -------------------------------------------- */

  /**
   * Build an array of font data objects for a specific font family definition.
   * @param {string} family                       The name of the font family.
   * @param {FontFamilyDefinition} definition     The font family definition, expected to have a `fonts` array.
   * @returns {{family: string, index: number, selected: boolean, font: string}[]} An array of font data objects.
   * @protected
   */
  _getDataForDefinition(family, definition) {
    const fonts = definition.fonts.length ? definition.fonts : [{}];
    return fonts.map((f, i) => {
      const data = {family, index: i};
      data.selected = this.#isSelected(data);
      data.font = this.constructor._formatFont(family, f);
      return data;
    });
  }

  /* -------------------------------------------- */

  /**
   * Determine whether a given font (by family and index) is currently selected.
   * @param {Object} selection             The font selection data.
   * @param {string} selection.family      The font family.
   * @param {number} selection.index       The index of the font within the family.
   * @returns {boolean}                    True if this font matches the currently selected one, otherwise false.
   */
  #isSelected({family, index}) {
    if ( !this.#selected ) return false;
    return (family === this.#selected.family) && (index === this.#selected.index);
  }

  /* -------------------------------------------- */

  /**
   * Provide a list of possible font weights as value/label pairs.
   * @returns {{value: number, label: string}[]} An array of weight choices.
   */
  #getWeightChoices() {
    return Object.entries(CONST.FONT_WEIGHTS).map(([k, v]) => ({
      value: v, label: `${k} ${v}`
    }));
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickAction(event, htmlElement) {
    const action = htmlElement.dataset.action;
    switch ( action ) {
      case "add":
        event.preventDefault();
        return this._onAddFont();
      case "delete":
        return this._onDeleteFont(event);
      case "select":
        return this._onSelectFont(event);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    const fd = new FormDataExtended(this.parts.body).object;
    foundry.utils.mergeObject(this.object, fd);
    if ( event.target.name === "type" ) this.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Add a new font definition.
   * @protected
   */
  async _onAddFont() {
    const fd = new FormDataExtended(this.parts.body);
    const family = (fd.get("family") || "").trim();
    const src = fd.get("src") || "";
    const weight = Number(fd.get("weight") || 400);
    const style = fd.get("style") || "normal";
    const type = fd.get("type") || FontConfig.FONT_TYPES.FILE;

    // Disallow empty family
    if ( !family ) {
      ui.notifications.warn(game.i18n.localize("FONTS.WarnNoFamily"));
      return;
    }

    // Disallow empty file path for file type font
    if ( (type === FontConfig.FONT_TYPES.FILE) && !src ) {
      ui.notifications.warn(game.i18n.localize("FONTS.WarnNoFile"));
      return;
    }

    // Retrieve current settings
    const defs = game.settings.get("core", FontConfig.SETTING) || {};

    // Re-use existing or create new
    defs[family] ??= {editor: true, fonts: []};
    const defn = defs[family];

    // If "file" type, push a new entry
    let count = 1;
    if ( type === FontConfig.FONT_TYPES.FILE ) {
      count = defn.fonts.push({urls: [src], weight, style});
    }

    await game.settings.set("core", FontConfig.SETTING, defs);
    await FontConfig.loadFont(family, defn);

    this.#selected = {family, index: count - 1};
    this.#fontsModified = true;

    foundry.utils.mergeObject(this.object, fd.object);
    this.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Delete a font from definitions.
   * @param {PointerEvent} event
   * @protected
   */
  async _onDeleteFont(event) {
    event.preventDefault();
    const btn = event.target.closest("[data-family]");
    if ( !btn ) return;
    const {family, index} = btn.dataset;

    const defs = game.settings.get("core", this.constructor.SETTING) || {};
    const defn = defs[family];
    if ( !defn ) return;

    defn.fonts.splice(Number(index), 1);
    if ( !defn.fonts.length ) delete defs[family];

    await game.settings.set("core", this.constructor.SETTING, defs);
    if ( this.#isSelected({family, index: Number(index)}) ) this.#selected = null;

    this.#fontsModified = true;
    this.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Select a font to preview/edit.
   * @param {PointerEvent} event
   * @protected
   */
  _onSelectFont(event) {
    event.preventDefault();
    const row = event.target.closest("[data-family]");
    if ( !row ) return;
    const {family, index} = row.dataset;
    this.#selected = {family, index: Number(index)};

    // Load the existing definition
    const defs = game.settings.get("core", this.constructor.SETTING) || {};
    const f = defs[family]?.fonts[this.#selected.index];
    if ( f ) {
      this.object.weight = f.weight ?? this.object.weight;
      this.object.style = f.style ?? this.object.style;
      this.object.src = f.urls?.[0] ?? "";
    }
    this.object.family = family;
    this.render(true);
  }

  /* -------------------------------------------- */

  /** @override */
  async close(options={}) {
    await super.close(options);
    if ( this.#fontsModified ) await SettingsConfig.reloadConfirm({world: true});
    return this;
  }
}

/**
 * @import {
 *   ApplicationClickAction,
 *   ApplicationFormSubmission,
 *   ApplicationTab,
 *   FormFooterButton
 * } from "../../_types.mjs";
 */

/**
 * A submenu for managing user overrides of PrototypeTokens
 */
class PrototypeOverridesConfig extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "prototype-token-overrides",
    tag: "form",
    window: {
      title: "SETTINGS.PrototypeTokenOverrides.Name",
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-circle-user"
    },
    position: {width: 560},
    form: {
      closeOnSubmit: true,
      handler: PrototypeOverridesConfig.#onSubmit
    },
    actions: {
      onResetDefaults: PrototypeOverridesConfig.#onResetDefaults
    }
  };

  /** @override */
  static PARTS = {
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    body: {template: "templates/settings/menus/prototype-overrides.hbs"},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /* -------------------------------------------- */

  /** Register this menu application and the setting it manages. */
  static registerSettings() {
    if ( (game.view !== "game") && (game.view !== "stream") ) return; // Bail if called by Setup
    game.settings.register("core", PrototypeTokenOverrides.SETTING, {
      name: "SETTINGS.PrototypeTokenOverrides.Name",
      hint: "SETTINGS.PrototypeTokenOverrides.Hint",
      scope: "world",
      type: PrototypeTokenOverrides,
      onChange: PrototypeTokenOverrides.applyAll.bind(PrototypeTokenOverrides)
    });
    game.settings.registerMenu("core", PrototypeTokenOverrides.SETTING, {
      name: "SETTINGS.PrototypeTokenOverrides.Name",
      label: "SETTINGS.PrototypeTokenOverrides.Label",
      hint: "SETTINGS.PrototypeTokenOverrides.Hint",
      icon: "fa-solid fa-circle-user",
      type: PrototypeOverridesConfig,
      restricted: true
    });
  }

  /* -------------------------------------------- */

  /** @override */
  tabGroups = {
    main: "base",
    ...Actor$1.TYPES.reduce((types, type) => {
      types[type] = "basics";
      return types;
    }, {})
  };

  /* -------------------------------------------- */

  /**
   * Prepare tabs for the global overrides and each actor type.
   * @returns {Record<string, ApplicationTab>}
   */
  #prepareTabs() {
    const data = PrototypeTokenOverrides.overrides;
    const tabLabelPrefix = "SETTINGS.PrototypeTokenOverrides.TABS";
    const subtabData = {
      basics: {id: "basics", label: game.i18n.localize(`${tabLabelPrefix}.Basics`), icon: "fa-solid fa-gear"},
      marker: {id: "marker", label: game.i18n.localize(`${tabLabelPrefix}.Marker`), icon: "fa-solid fa-scrubber"}
    };
    return Actor$1.TYPES.reduce((tabs, tabId) => {
      const active = this.tabGroups.main === tabId;
      tabs[tabId] = {
        id: tabId,
        group: "main",
        label: tabId === "base" ? `${tabLabelPrefix}.AllTypes.Label` : `TYPES.Actor.${tabId}`,
        active,
        cssClass: active ? "active" : "",
        fields: PrototypeTokenOverrides.schema.fields[tabId].fields,
        data: data[tabId],
        subtabs: ["basics", "marker"].reduce((subtabs, subtabId) => {
          const group = tabId;
          const active = this.tabGroups[group] === subtabId;
          const cssClass = active ? "active": "";
          subtabs[subtabId] = {...subtabData[subtabId], group, active, cssClass};
          return subtabs;
        }, {})
      };
      return tabs;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Configure footer buttons for the window.
   * @returns {FormFooterButton[]}
   */
  #prepareButtons() {
    return [
      {
        type: "button",
        action: "onResetDefaults",
        label: "SETTINGS.PrototypeTokenOverrides.Reset.Label",
        icon: "fa-solid fa-arrow-rotate-left"
      },
      {
        type: "submit",
        label: "SETTINGS.PrototypeTokenOverrides.Submit",
        icon: "fa-solid fa-floppy-disk"
      }
    ];
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(_options) {
    const TokenConfig = foundry.applications.sheets.TokenConfig;
    return {
      tabs: this.#prepareTabs(),
      verticalTabs: true,
      rootId: this.id,
      buttons: this.#prepareButtons(),
      booleanOptions: {true: game.i18n.localize("Yes"), false: game.i18n.localize("No")},
      displayModes: TokenConfig.DISPLAY_MODES,
      dispositions: TokenConfig.TOKEN_DISPOSITIONS,
      turnMarkerModes: TokenConfig.TURN_MARKER_MODES,
      turnMarkerAnimations: CONFIG.Combat.settings.turnMarkerAnimations
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preFirstRender(context, options) {
    PrototypeTokenOverrides.localizeFields();
    return super._preFirstRender(context, options);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Process form submission for the sheet
   * @this {PrototypeOverridesConfig}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmit(event, form, formData) {
    // Normalize values of nullable boolean fields
    const stringToBooleanOrNullish = {true: true, false: false, null: null, "": undefined};
    for (const [path, value] of Object.entries(formData.object)) {
      if ( value in stringToBooleanOrNullish ) formData.object[path] = stringToBooleanOrNullish[value];
    }
    const submitData = PrototypeTokenOverrides.schema.clean(foundry.utils.expandObject(formData.object));
    PrototypeTokenOverrides.schema.validate(submitData);
    await game.settings.set("core", PrototypeTokenOverrides.SETTING, submitData);
  }

  /* -------------------------------------------- */

  /**
   * Reset the setting to its initial state, with all overrides removed.
   * @this {PrototypeOverridesConfig}
   * @type {ApplicationClickAction}
   */
  static async #onResetDefaults() {
    const question = game.i18n.localize("AreYouSure");
    const warning = game.i18n.localize("SETTINGS.PrototypeTokenOverrides.Reset.Warning");
    await foundry.applications.api.DialogV2.confirm({
      window: {
        title: "SETTINGS.PrototypeTokenOverrides.Reset.Label"
      },
      content: `<p><strong>${question}</strong> ${warning}</p>`,
      yes: {
        callback: async () => {
          await game.settings.set("core", PrototypeTokenOverrides.SETTING, undefined);
          await this.render();
        }
      }
    });
  }
}

/**
 * @typedef GameUIConfiguration
 * @property {number} uiScale
 * @property {number} fontScale
 * @property {{applications: ""|"dark"|"light", interface: ""|"dark"|"light"}} colorScheme
 * @property {"cards"|"pip"} chatNotifications
 * @property {{opacity: number, speed: number}} fade
 */

/**
 * A submenu that provides UI configuration settings.
 */
class UIConfig extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "ui-config",
    tag: "form",
    window: {
      title: "SETTINGS.UI.MENU.name",
      contentClasses: ["standard-form"]
    },
    form: {
      closeOnSubmit: true,
      handler: UIConfig.#onSubmit
    },
    position: {width: 540},
    actions: {
      reset: UIConfig.#onReset
    }
  };

  /** @override */
  static PARTS = {
    form: {
      template: "templates/settings/menus/ui-config.hbs",
      scrollable: [""]
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /**
   * The data schema for the core.uiConfig setting.
   * @type {SchemaField}
   */
  static get schema() {
    return UIConfig.#schema;
  }

  static #schema = new SchemaField({
    uiScale: new NumberField({required: true, min: 0.5, max: 1.5, step: 0.05, initial: 1}),
    fontScale: new NumberField({required: true, min: 1, max: 10, step: 1, initial: 5}),
    colorScheme: new SchemaField({
      applications: new StringField({required: true, blank: true, initial: "dark", choices: {
        "": "SETTINGS.UI.FIELDS.colorScheme.choices.default",
        dark: "SETTINGS.UI.FIELDS.colorScheme.choices.dark",
        light: "SETTINGS.UI.FIELDS.colorScheme.choices.light"
      }}),
      interface: new StringField({required: true, blank: true, initial: "dark", choices: {
        "": "SETTINGS.UI.FIELDS.colorScheme.choices.default",
        dark: "SETTINGS.UI.FIELDS.colorScheme.choices.dark",
        light: "SETTINGS.UI.FIELDS.colorScheme.choices.light"
      }})
    }),
    chatNotifications: new StringField({required: true, blank: false, initial: "cards", choices: {
      cards: "SETTINGS.UI.FIELDS.chatNotifications.cards",
      pip: "SETTINGS.UI.FIELDS.chatNotifications.pip"
    }}),
    fade: new SchemaField({
      opacity: new AlphaField({initial: 0.4, min: 0.05, step: 0.05}),
      speed: new NumberField({min: 0, max: 1000, initial: 500, step: 50})
    })
  });

  /**
   * The current setting value
   * @type {GameUIConfiguration}
   */
  #setting;

  /**
   * Track whether the schema has already been localized.
   * @type {boolean}
   */
  static #localized = false;

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preFirstRender(_context, _options) {
    await super._preFirstRender(_context, _options);
    if ( !UIConfig.#localized ) {
      foundry.helpers.Localization.localizeDataModel({schema: UIConfig.#schema}, {
        prefixes: ["SETTINGS.UI"],
        prefixPath: "core.uiConfig."
      });
      UIConfig.#localized = true;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    if ( options.isFirstRender ) this.#setting = game.settings.get("core", "uiConfig");
    return {
      setting: this.#setting,
      fields: UIConfig.#schema.fields,
      buttons: [
        {type: "reset", label: "Reset", icon: "fa-solid fa-arrow-rotate-left", action: "reset"},
        {type: "submit", label: "Save Changes", icon: "fa-solid fa-floppy-disk"}
      ]
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    if ( !options.submitted ) game.configureUI(game.settings.get("core", "uiConfig"));
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeForm(_formConfig, _event) {
    const formData = new FormDataExtended(this.form);
    this.#setting = UIConfig.#cleanFormData(formData);
    game.configureUI(this.#setting);
  }

  /* -------------------------------------------- */

  /**
   * Clean the form data, accounting for the field names assigned by game.settings.register on the schema.
   * @param {FormDataExtended} formData
   * @returns {GameUIConfiguration}
   */
  static #cleanFormData(formData) {
    return UIConfig.#schema.clean(foundry.utils.expandObject(formData.object).core.uiConfig);
  }

  /* -------------------------------------------- */

  /**
   * Submit the configuration form.
   * @this {UIConfig}
   * @param {SubmitEvent} event
   * @param {HTMLFormElement} form
   * @param {FormDataExtended} formData
   * @returns {Promise<void>}
   */
  static async #onSubmit(event, form, formData) {
    this.#setting = UIConfig.#cleanFormData(formData);
    await game.settings.set("core", "uiConfig", this.#setting);
  }

  /* -------------------------------------------- */

  /**
   * Reset the form back to default values.
   * @this {UIConfig}
   * @param {InputEvent} event
   * @returns {Promise<void>}
   */
  static async #onReset(event) {
    this.#setting = UIConfig.#schema.clean({});
    game.configureUI(this.#setting);
    await this.render({force: false});
  }
}

var _module$I = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AVConfig: AVConfig,
  DefaultSheetsConfig: DefaultSheetsConfig,
  DiceConfig: DiceConfig,
  FontConfig: FontConfig,
  PrototypeOverridesConfig: PrototypeOverridesConfig,
  UIConfig: UIConfig
});

var _module$H = /*#__PURE__*/Object.freeze({
  __proto__: null,
  DependencyResolution: DependencyResolution,
  SettingsConfig: SettingsConfig,
  menus: _module$I
});

/**
 * @import JournalEntryCategory from "@client/documents/journal-entry-category.mjs"
 */

/**
 * An Application responsible for managing a journal entry's categories.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class JournalEntryCategoryConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "journal-category-config-{id}",
    classes: ["journal-category-config"],
    window: {
      icon: "fa-solid fa-chart-tree-map",
      contentClasses: ["standard-form"]
    },
    position: {
      width: 480
    },
    actions: {
      addCategory: JournalEntryCategoryConfig.#onAddCategory,
      removeCategory: JournalEntryCategoryConfig.#onRemoveCategory,
      sortDown: JournalEntryCategoryConfig.#onSort,
      sortUp: JournalEntryCategoryConfig.#onSort
    },
    form: {
      submitOnChange: true
    }
  };

  /** @override */
  static PARTS = {
    form: {
      template: "templates/journal/category-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.format("JOURNAL.ConfigureCategoriesTitle", { name: this.document.name });
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.categories = this.document.categories.contents.sort(JournalEntry$1.sortCategories).map((category, i) => {
      const field = new StringField({ blank: false });
      field.name = `${i}.name`;
      return {
        field,
        placeholder: game.i18n.localize("JOURNAL.CategoryName"),
        name: category._source.name,
        id: category.id,
        sort: category.sort
      };
    });
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Add a new category to the journal entry.
   * @this {JournalEntryCategoryConfig}
   * @returns {Promise<JournalEntryCategory>}
   */
  static #onAddCategory() {
    const categories = this.#prepareCategories();
    const cls = getDocumentClass("JournalEntryCategory");
    const parent = this.document;
    return cls.create({
      name: cls.defaultName({ parent, pack: this.document.pack }),
      sort: (categories.length + 1) * CONST.SORT_INTEGER_DENSITY
    }, { parent });
  }

  /* -------------------------------------------- */

  /**
   * Remove a category from the journal entry.
   * @this {JournalEntryCategoryConfig}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target element.
   * @returns {Promise<JournalEntryCategory>}
   */
  static #onRemoveCategory(event, target) {
    const { categoryId } = target.closest("[data-category-id]").dataset;
    return this.document.categories.get(categoryId)?.delete();
  }

  /* -------------------------------------------- */

  /**
   * Sort categories between each other.
   * @this {JournalEntryCategoryConfig}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target element.
   * @returns {Promise<JournalEntry>}
   */
  static #onSort(event, target) {
    const { action } = target.dataset;
    const category = target.closest("[data-category-id]");
    if ( (action === "sortUp") && category.previousElementSibling ) {
      category.previousElementSibling.insertAdjacentElement("beforebegin", category);
    } else if ( (action === "sortDown") && category.nextElementSibling ) {
      category.nextElementSibling.insertAdjacentElement("afterend", category);
    }
    return this.document.update({ categories: this.#prepareCategories() });
  }

  /* -------------------------------------------- */

  /** @override */
  async _processSubmitData(event, form, submitData, options) {
    this.document.update({ categories: this.#prepareCategories() }, options);
  }

  /* -------------------------------------------- */

  /**
   * Prepare category data for update.
   * @returns {JournalEntryCategory[]}
   */
  #prepareCategories() {
    this.element.querySelectorAll(".sort-field").forEach((el, i) => el.value = (i + 1) * CONST.SORT_INTEGER_DENSITY);
    return Object.values(foundry.utils.expandObject(new FormDataExtended(this.form).object));
  }
}

/**
 * @import {DocumentSheetConfiguration, DocumentSheetRenderOptions} from "../../api/document-sheet.mjs";
 * @import {ApplicationRenderContext} from "../../_types.mjs";
 * @import {JournalEntryPageHeading} from "@client/_types.mjs";
 */

/**
 * @typedef {DocumentSheetRenderOptions} JournalSheetRenderOptions
 * @property {number} [pageIndex]                   Render the journal sheet at this page index.
 * @property {string} [pageId]                      Render the journal sheet at the page with this ID.
 * @property {JournalEntrySheet.VIEW_MODES} [mode]  Render the journal sheet with the given page mode.
 * @property {string} [anchor]                      Scroll to the specified heading in the given page.
 */

/**
 * @typedef JournalSheetPageContext
 * @property {string} id              The page ID.
 * @property {boolean} editable       Whether the current user is allowed to edit the page.
 * @property {boolean} hidden         Whether the page is currently hidden due to a search filter.
 * @property {string} tocClass        The class name for the page entry in the table of contents.
 * @property {string} viewClass       The class name for the page entry in the pages view.
 * @property {string} name            The page title.
 * @property {number} number          The page number in the table of contents.
 * @property {string} icon            The ownership icon for the page entry in the table of contents.
 * @property {string} ownershipClass  The class name for the page's ownership level in the table of contents.
 * @property {string} [category]      The ID of the category this page belongs to, if any.
 * @property {number} sort            The numeric sort value which orders this page relative to other pages in its
 *                                    category.
 * @property {boolean} [uncategorized]  Whether the page has not been assigned a category.
 */

/**
 * @typedef JournalSheetCategoryContext
 * @property {string} id    The category ID.
 * @property {string} name  The category name.
 */

/**
 * The Application responsible for displaying and editing a single JournalEntry Document.
 * @extends {DocumentSheetV2<DocumentSheetConfiguration, JournalSheetRenderOptions>}
 * @mixes HandlebarsApplication
 */
class JournalEntrySheet extends HandlebarsApplicationMixin(DocumentSheetV2) {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["journal-sheet", "journal-entry"],
    viewPermission: DOCUMENT_OWNERSHIP_LEVELS.NONE,
    window: {
      resizable: true
    },
    position: {
      width: 960,
      height: 800
    },
    form: {
      submitOnChange: true
    },
    actions: {
      configCategories: JournalEntrySheet.#onConfigureCategories,
      createPage: this.prototype.createPageDialog,
      editPage: this.prototype._onEditPage,
      goToHeading: JournalEntrySheet.#onGoToHeading,
      nextPage: this.prototype.nextPage,
      previousPage: this.prototype.previousPage,
      showPlayers: this.prototype._onShowPlayers,
      toggleLock: JournalEntrySheet.#onToggleLock,
      toggleMode: JournalEntrySheet.#onToggleMode,
      toggleSearch: this.prototype.toggleSearchMode,
      toggleSidebar: this.prototype.toggleSidebar
    }
  };

  /** @override */
  static PARTS = {
    sidebar: {
      template: "templates/journal/sidebar.hbs",
      templates: ["templates/journal/toc.hbs"],
      scrollable: [".toc"]
    },
    pages: {
      template: "templates/journal/pages.hbs",
      scrollable: [".journal-entry-pages"]
    }
  };

  /**
   * The percentage of the journal sheet page viewport that must be filled by a page before that page is marked as in
   * view.
   * @type {number}
   */
  static #INTERSECTION_RATIO = .25;

  /**
   * Icons for page ownership.
   * @enum {string}
   */
  static OWNERSHIP_ICONS = {
    [DOCUMENT_OWNERSHIP_LEVELS.NONE]: "fa-solid fa-eye-slash",
    [DOCUMENT_OWNERSHIP_LEVELS.OBSERVER]: "fa-solid fa-eye",
    [DOCUMENT_OWNERSHIP_LEVELS.OWNER]: "fa-solid fa-feather-pointed"
  };

  /**
   * The available view modes for journal entries.
   * @enum {number}
   */
  static VIEW_MODES = {
    SINGLE: 1,
    MULTIPLE: 2
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The cached categorization structure.
   * @type {Record<string, string[]>}
   */
  #categorizedPages;

  /**
   * The JournalEntry for this sheet.
   * @type {JournalEntry}
   */
  get entry() {
    return this.document;
  }

  /**
   * Track which page IDs are currently displayed due to a search filter.
   * @type {Set<string>}
   */
  #filteredPages = new Set();

  /**
   * 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();

  /**
   * Whether the sheet is in multi-page mode.
   * @type {boolean}
   */
  get isMultiple() {
    return this.mode === this.constructor.VIEW_MODES.MULTIPLE;
  }

  /**
   * Whether the journal is locked and disallows modifications to the table of contents.
   * @type {boolean}
   */
  get locked() {
    return this.entry.getFlag("core", "locked") ?? false;
  }

  /**
   * Get the JournalEntry's current view mode.
   * @type {JournalEntrySheet.VIEW_MODES}
   */
  get mode() {
    return this.#mode ?? this.entry.getFlag("core", "viewMode") ?? this.constructor.VIEW_MODES.SINGLE;
  }

  #mode;

  /**
   * The currently active IntersectionObserver.
   * @type {IntersectionObserver}
   */
  get observer() {
    return this.#observer;
  }

  #observer;

  /**
   * The ID of the currently-viewed page.
   * @type {string}
   */
  get pageId() {
    return this.#pageId;
  }

  #pageId;

  /**
   * The index of the currently-viewed page in the list of available pages.
   * @type {number}
   */
  get pageIndex() {
    return Object.keys(this._pages).findIndex(id => id === this.pageId);
  }

  /**
   * The cached list of processed page entries.
   * @type {Record<string, JournalSheetPageContext>}
   * @protected
   */
  _pages;

  /**
   * The pages that are currently scrolled into view and marked as 'active' in the sidebar.
   * @type {HTMLElement[]}
   */
  get pagesInView() {
    return this.#pagesInView;
  }

  #pagesInView = [];

  /**
   * The currently active search filter.
   * @type {foundry.applications.ux.SearchFilter}
   */
  #search;

  /**
   * Get the JournalEntry's current search mode.
   * @type {string}
   */
  get searchMode() {
    return this.#searchMode ?? CONST.DIRECTORY_SEARCH_MODES.NAME;
  }

  #searchMode;

  /**
   * A mapping of page IDS to JournalPageSheet instances used for rendering the pages inside the journal entry.
   * @type {Record<string, JournalPageSheet>}
   */
  #sheets = {};

  /**
   * The expanded state of the sidebar.
   * @type {boolean}
   */
  get sidebarExpanded() {
    return this.#sidebarState.expanded;
  }

  /**
   * Store transient sidebar state so it can be restored after context menus are closed.
   * @type {{expanded: boolean, active: boolean, position: number}}
   */
  #sidebarState = {
    expanded: true,
    active: false,
    position: 0
  };

  /**
   * DOM synchronization state for the main journal content.
   * @type {[HTMLElement, HTMLElement, object]|null}
   */
  #syncState = null;

  /**
   * Has a user been granted temporary ownership of this journal entry or its pages?
   * @type {boolean}
   */
  #tempOwnership = false;

  /** @override */
  get title() {
    const { folder, name } = this.entry;
    return this.entry.permission ? `${folder ? `${folder.name}: ` : ""}${name}` : "";
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Highlights the currently-viewed page in the sidebar.
   * @protected
   */
  _activatePagesInView() {
    if ( !this.element ) return;
    const pageIds = new Set(this.pagesInView.map(p => p.dataset.pageId));
    // Update the pageId to the first page in view in case the mode is switched to single page view.
    if ( pageIds.size ) this.#pageId = pageIds.first();
    let activeChanged = false;
    this.element.querySelectorAll(".toc li[data-page-id]").forEach(el => {
      activeChanged ||= el.classList.contains("active") !== pageIds.has(el.dataset.pageId);
      el.classList.toggle("active", pageIds.has(el.dataset.pageId));
    });
    if ( activeChanged ) this._synchronizeSidebar();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    // Temporary ownership override.
    if ( "tempOwnership" in options ) this.#tempOwnership = options.tempOwnership;

    this._pages = this._preparePageData();

    // Mode changed
    options.modeChanged = ("mode" in options) && (options.mode !== this.mode);
    if ( options.modeChanged ) {
      if ( this.isMultiple ) this.#callCloseHooks();
      this.#mode = options.mode;
    }

    // Page changed
    this._setCurrentPage(options);

    // Adjust sidebar state
    if ( "expanded" in options ) this.#sidebarState.expanded = options.expanded;

    super._configureRenderOptions(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    if ( this.isMultiple ) {
      for ( const id of Object.keys(this._pages) ) {
        parts[id] = { template: "templates/journal/page.hbs" };
      }
    }
    else parts[this.pageId] = { template: "templates/journal/page.hbs" };
    return parts;
  }

  /* -------------------------------------------- */

  /**
   * Get the set of ContextMenu options which should be used for journal entry pages in the sidebar.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getEntryContextOptions() {
    const getPage = li => this.entry.pages.get(li.dataset.pageId);
    return [{
      name: "SIDEBAR.Edit",
      icon: '<i class="fa-solid fa-pen-to-square"></i>',
      condition: li => this.isEditable && getPage(li)?.canUserModify(game.user, "update"),
      callback: li => getPage(li).sheet.render(true)
    }, {
      name: "SIDEBAR.Delete",
      icon: '<i class="fa-solid fa-trash"></i>',
      condition: li => this.isEditable && getPage(li)?.canUserModify(game.user, "delete"),
      callback: li => {
        const { top, right } = li.getBoundingClientRect();
        return getPage(li).deleteDialog({ position: { top, left: right } });
      }
    }, {
      name: "SIDEBAR.Duplicate",
      icon: '<i class="fa-regular 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="fa-solid fa-lock"></i>',
      condition: game.user.isGM,
      callback: li => {
        const {top, right} = li.getBoundingClientRect();
        new DocumentOwnershipConfig({
          document: getPage(li),
          position: {top, left: right}
        }).render({force: true});
      }
    }, {
      name: "JOURNAL.ActionShow",
      icon: '<i class="fa-solid fa-eye"></i>',
      condition: li => getPage(li)?.isOwner,
      callback: li => Journal.showDialog(getPage(li))
    }, {
      name: "SIDEBAR.JumpPin",
      icon: '<i class="fa-solid fa-crosshairs"></i>',
      condition: li => !!getPage(li)?.sceneNote,
      callback: li => canvas.notes.panToNote(getPage(li).sceneNote)
    }];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    const applicationOptions = super._initializeApplicationOptions(options);
    applicationOptions.window.icon ??= CONFIG.JournalEntry.sidebarIcon;
    return applicationOptions;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getHeaderControls() {
    const controls = super._getHeaderControls();
    controls.push({
      icon: "fas fa-eye",
      label: "JOURNAL.ActionShow",
      visible: game.user.isGM,
      action: "showPlayers"
    }, {
      icon: "fa-solid fa-chart-tree-map",
      label: "JOURNAL.ConfigureCategories",
      visible: this.isEditable,
      action: "configCategories"
    });
    return controls;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    /** @fires {hookEvents:getJournalEntryPageContextOptions} */
    this._createContextMenu(this._getEntryContextOptions, ".toc .page", {
      hookName: "getJournalEntryPageContextOptions",
      parentClassHooks: false,
      onOpen: this._onContextMenuOpen.bind(this),
      onClose: this._onContextMenuClose.bind(this)
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    // Call setPosition early to prevent visible pop-in where page content renders at maximum size due to the
    // application window not having a fixed size yet.
    if ( "position" in options ) this.setPosition(options.position);
    await this._renderPageViews(context, options);
    if ( this.#syncState ) this._syncPartState("pages", ...this.#syncState);
    this.#syncState = null;

    if ( options.modeChanged || options.pageChanged ) {
      if ( this.isMultiple ) this.goToPage(this.pageId, options);
      else if ( options.anchor ) this.getPageSheet(this.pageId)?.toc[options.anchor]?.element?.scrollIntoView();
    }

    if ( !options.parts.includes("sidebar") ) return;

    // Drag-drop
    new foundry.applications.ux.DragDrop.implementation({
      dragSelector: ".toc :is([data-page-id], [data-anchor])",
      dropSelector: ".toc",
      permissions: {
        dragstart: this._canDragStart.bind(this),
        drop: this._canDragDrop.bind(this)
      },
      callbacks: {
        dragstart: this._onDragStart.bind(this),
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);

    // Search
    this.#search ??= new foundry.applications.ux.SearchFilter({
      inputSelector: "search input",
      contentSelector: ".toc",
      callback: this._onSearchFilter.bind(this)
    });
    this.#search.bind(this.element);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.mode = this.mode;
    context.viewMode = this.isMultiple
      ? { label: "JOURNAL.ModeMultiple", icon: "fas fa-notes", cls: "multi-page" }
      : { label: "JOURNAL.ModeSingle", icon: "fas fa-note", cls: "single-page" };
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare pages for display.
   * @returns {Record<string, JournalSheetPageContext>}
   * @protected
   */
  _preparePageData() {
    const hasFilterQuery = this.#search?.query;
    const levels = Object.entries(DOCUMENT_OWNERSHIP_LEVELS);
    const categoryMap = {};

    // Prepare pages.
    const uncategorized = this.entry.pages.contents.reduce((arr, page) => {
      if ( !this.isPageVisible(page) ) return arr;
      const { category, id, name, sort, title, type } = page;
      const hidden = hasFilterQuery && !this.#filteredPages.has(page.id);
      const sheet = this.getPageSheet(page);
      const cssClasses = [type, `level${title.level}`, "page"];
      const [ownership] = levels.find(([, level]) => level === page.ownership.default);
      let editable = sheet.isEditable;
      if ( !sheet.isV2 ) {
        editable = page.isOwner;
        if ( page.parent.pack ) editable &&= !game.packs.get(page.parent.pack)?.locked;
      }
      const descriptor = {
        category, id, editable, hidden, name, sort,
        tocClass: cssClasses.join(" "),
        viewClass: cssClasses.concat(sheet.options.viewClasses || []).join(" "),
        icon: this.constructor.OWNERSHIP_ICONS[page.ownership.default],
        ownershipClass: ownership.toLowerCase()
      };
      if ( category && this.entry.categories.has(category) ) {
        categoryMap[category] ??= [];
        categoryMap[category].push(descriptor);
      } else {
        descriptor.uncategorized = true;
        arr.push(descriptor);
      }
      return arr;
    }, []).sort((a, b) => a.sort - b.sort);

    // Order pages by category
    this.#categorizedPages = {};
    const categories = this.entry.categories.contents.sort(JournalEntry$1.sortCategories);
    const categorized = categories.flatMap(({ id: categoryId }) => {
      const pages = (categoryMap[categoryId] ?? []).sort((a, b) => a.sort - b.sort);
      this.#categorizedPages[categoryId] = pages.map(p => p.id);
      return pages;
    });

    return Object.fromEntries(categorized.concat(uncategorized).map((page, i) => {
      page.number = i;
      return [page.id, page];
    }));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "pages": await this._preparePagesContext(context, options); break;
      case "sidebar": await this._prepareSidebarContext(context, options); break;
    }
    if ( partId in this._pages ) foundry.utils.mergeObject(context, this._pages[partId]);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the pages part.
   * @param {ApplicationRenderContext} context
   * @param {JournalSheetRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _preparePagesContext(context, options) {
    if ( this.isMultiple ) context.pages = Object.values(this._pages);
    else context.pages = [this._pages[this.pageId]];
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the sidebar part.
   * @param {ApplicationRenderContext} context
   * @param {JournalSheetRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareSidebarContext(context, options) {
    context.toc = await this._prepareTableOfContents();
    context.expandMode = this.sidebarExpanded
      ? { label: "JOURNAL.ViewCollapse", icon: "fas fa-caret-right" }
      : { label: "JOURNAL.ViewExpand", icon: "fas fa-caret-left" };
    context.searchMode = this.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME
      ? { icon: "fa-solid fa-magnifying-glass", label: "SIDEBAR.SearchModeName" }
      : { icon: "fas fa-file-magnifying-glass", label: "SIDEBAR.SearchModeFull" };
    context.searchMode.placeholder = game.i18n.format("SIDEBAR.Search", {
      types: game.i18n.localize("DOCUMENT.JournalEntryPages")
    });
    if ( this.isEditable ) {
      context.lockMode = this.locked
        ? { icon: "fa-solid fa-lock", label: "JOURNAL.LockModeLocked" }
        : { icon: "fa-solid fa-unlock", label: "JOURNAL.LockModeUnlocked" };
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare the sidebar table of contents.
   * @returns {Promise<Array<JournalSheetPageContext & JournalSheetCategoryContext>>}
   * @protected
   */
  async _prepareTableOfContents() {
    if ( !this.entry.categories.size ) return Object.values(this._pages);
    const pages = { ...this._pages };
    const toc = [];
    for ( const [categoryId, pageIds] of Object.entries(this.#categorizedPages) ) {
      const { id, name } = this.entry.categories.get(categoryId);
      toc.push({ id, name, isCategory: true });
      for ( const pageId of pageIds ) {
        toc.push(pages[pageId]);
        delete pages[pageId];
      }
    }
    if ( !foundry.utils.isEmpty(pages) ) {
      toc.push({
        id: "uncategorized",
        name: game.i18n.localize("JOURNAL.Uncategorized"),
        isCategory: true
      });
      toc.push(...Object.values(pages));
    }
    return toc;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preSyncPartState(partId, newElement, priorElement, state) {
    super._preSyncPartState(partId, newElement, priorElement, state);
    if ( (partId === "pages") || (partId in this._pages) ) this.#syncState = [newElement, priorElement, state];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _replaceHTML(result, content, options) {
    super._replaceHTML(result, content, options);
    const pagesPart = result.pages ?? content.querySelector('[data-application-part="pages"]');
    const container = pagesPart.querySelector(".journal-entry-pages");

    // If in multi-page mode, re-append all the elements so that they match the ordering of _pages.
    if ( this.isMultiple ) {
      for ( const id of Object.keys(this._pages) ) container.append(this.parts[id]);
    }

    // Otherwise just append the single page into the container.
    else {
      for ( const id of options.parts ) {
        if ( id in this._pages ) container.append(result[id]);
      }
    }

    // Delete the elements of any pages that were deleted or are no longer visible to this user.
    for ( const id of Object.keys(this.parts) ) {
      if ( !(id in this._pages) && !(id in this.constructor.PARTS) ) {
        this.parts[id].remove();
        delete this.parts[id];
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Add headings to the table of contents for the given node.
   * @param {HTMLElement} pageNode                         The HTML node of the page's rendered contents.
   * @param {Record<string, JournalEntryPageHeading>} toc  The page's table of contents.
   * @returns {Promise<void>}
   * @protected
   */
  async _renderHeadings(pageNode, toc) {
    const pageId = pageNode.dataset.pageId;
    const page = this.entry.pages.get(pageId);
    const tocNode = this.element.querySelector(`.toc [data-page-id="${pageId}"]`);
    if ( !tocNode || !toc ) return;
    let 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();
    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;
    }, []);
    const html = await foundry.applications.handlebars.renderTemplate("templates/journal/toc.hbs", { headings });
    tocNode.insertAdjacentHTML("beforeend", html);
  }

  /* -------------------------------------------- */

  /**
   * Update child views inside the main sheet.
   * @param {ApplicationRenderContext} context
   * @param {JournalSheetRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _renderPageViews(context, options) {
    for ( const id of options.parts ) {
      if ( !(id in this._pages) ) continue;
      const element = this.parts[id];
      if ( !element ) {
        ui.notifications.warn(`Failed to render JournalEntryPage [${id}]. No render target.`);
        continue;
      }
      const { editable, hidden, viewClass } = this._pages[id];
      element.hidden = hidden;
      element.className = `journal-entry-page ${viewClass}`;
      const sheet = this.getPageSheet(id);
      if ( sheet.isV2 ) await this._renderPageView(element, sheet);
      else {
        /** @deprecated since v13 until v16 */
        await this._renderAppV1PageView(element, sheet);
      }
      if ( editable ) element.insertAdjacentHTML("beforeend", `
        <div class="edit-container">
          <button type="button" class="icon fa-solid fa-pen-to-square" data-tooltip="JOURNAL.EditPage" data-action="editPage"
                  aria-label="${game.i18n.localize("JOURNAL.EditPage")}"></button>
        </div>
      `);
      await this._renderHeadings(element, sheet.toc);
    }
    this._observePages();
    this._observeHeadings();
  }

  /* -------------------------------------------- */

  /**
   * Render the page view for a page sheet.
   * @param {HTMLElement} element          The existing page element in the journal entry view.
   * @param {JournalEntryPageSheet} sheet  The page sheet.
   * @returns {Promise<void>}
   * @protected
   */
  async _renderPageView(element, sheet) {
    await sheet.render({ force: true });
    sheet.element.removeAttribute("class");
    element.append(sheet.element);
  }

  /* -------------------------------------------- */

  /**
   * Update which page of the journal sheet should be currently rendered.
   * This can be controlled by options passed into the render method, or by subclass override.
   * @param {JournalSheetRenderOptions} [options]
   * @protected
   */
  _setCurrentPage(options={}) {
    let newPageId;
    options.pageChanged = ("pageIndex" in options) || ("pageId" in options);
    if ( typeof options.pageIndex === "number" ) newPageId = Object.keys(this._pages)[options.pageIndex];
    if ( options.pageId ) newPageId = options.pageId;
    if ( (newPageId != null) && (newPageId !== this.pageId) ) {
      if ( !this.isMultiple ) this.#callCloseHooks(this.pageId);
      this.#pageId = newPageId;
    }
    if ( !(this.pageId in this._pages) ) [this.#pageId] = Object.keys(this._pages);
  }

  /* -------------------------------------------- */

  /**
   * 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 ?? {};
      const anchor = entry.target.dataset.anchor;
      let toc = this.element.querySelector(`.toc [data-page-id="${pageId}"]`);
      if ( anchor ) toc = toc.querySelector(`li[data-anchor="${anchor}"]`);
      if ( toc ) {
        toc.scrollIntoView();
        break;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Update the disabled state of the previous and next page buttons.
   * @protected
   */
  _updateButtonState() {
    if ( !this.rendered ) return;
    this.element.querySelectorAll("search :is(input, button)").forEach(el => el.disabled = false);
    const previous = this.element.querySelector('[data-action="previousPage"]');
    const next = this.element.querySelector('[data-action="nextPage"]');
    if ( !next || !previous ) return;
    if ( this.isMultiple ) {
      previous.disabled = !this.pagesInView[0]?.previousElementSibling;
      next.disabled = this.pagesInView.length && !this.pagesInView.at(-1).nextElementSibling;
    } else {
      const index = this.pageIndex;
      previous.disabled = index < 1;
      next.disabled = index >= this._pages.length - 1;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _updateFrame(options) {
    super._updateFrame(options);
    this.element.classList.toggle("expanded", this.sidebarExpanded);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _tearDown(options) {
    super._tearDown(options);
    this.#search?.unbind();
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("click", this._onClickImage.bind(this), { passive: true });
  }

  /* -------------------------------------------- */

  /**
   * 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() {
    this.#headingIntersections = new Map();
    const observer = new IntersectionObserver(entries => entries.forEach(entry => {
      if ( entry.isIntersecting ) this.#headingIntersections.set(entry.target, entry);
      else this.#headingIntersections.delete(entry.target);
    }), {
      root: this.element.querySelector(".journal-entry-pages"),
      threshold: 1
    });
    const headings = Array.fromRange(6, 1).map(n => `h${n}`).join(",");
    this.element.querySelectorAll(`.journal-entry-page :is(${headings})`).forEach(observer.observe, observer);
  }

  /* -------------------------------------------- */

  /**
   * 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.querySelector(".journal-entry-pages"),
      threshold: [0, .25, .5, .75, 1]
    });
    this.element.querySelectorAll(".journal-entry-page").forEach(this.#observer.observe, this.#observer);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking an image to pop it out for fullscreen view.
   * @param {PointerEvent} event  The triggering click event.
   * @protected
   */
  _onClickImage(event) {
    if ( !event.target.matches("img:not(.nopopout)") ) return;
    const target = event.target;
    const imagePage = target.closest(".journal-entry-page.image");
    const page = this.entry.pages.get(imagePage?.dataset.pageId);
    const title = page?.name ?? target.title;
    const ip = new foundry.applications.apps.ImagePopout({
      src: target.getAttribute("src"),
      caption: page?.image.caption,
      window: { title }
    });
    if ( page ) ip.shareImage = () => Journal.showDialog(page);
    ip.render({ force: true });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    for ( const sheet of Object.values(this.#sheets) ) sheet.close({ animate: false });

    // Reset any temporarily-granted ownership.
    if ( !this.#tempOwnership ) return;
    this.entry.ownership = foundry.utils.deepClone(this.entry._source.ownership);
    this.entry.pages.forEach(p => p.ownership = foundry.utils.deepClone(p._source.ownership));
    this.#tempOwnership = false;
  }

  /* -------------------------------------------- */

  /**
   * Handle configuring the journal entry's categories.
   * @this {JournalEntrySheet}
   */
  static #onConfigureCategories() {
    new JournalEntryCategoryConfig({ document: this.entry }).render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * 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.querySelector(".toc").scrollTop = this.#sidebarState.position;
  }

  /* -------------------------------------------- */

  /**
   * 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.querySelector(".toc").scrollTop;
    this.#sidebarState.active = target.classList.contains("active");
    target.classList.remove("active");
  }

  /* -------------------------------------------- */

  /**
   * Handle editing one of the journal entry's pages.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @protected
   */
  _onEditPage(event, target) {
    const { pageId } = target.closest("[data-page-id]").dataset;
    const page = this.entry.pages.get(pageId);
    return page?.sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking on a page heading.
   * @this {JournalEntrySheet}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onGoToHeading(event, target) {
    const { pageId } = target.closest("[data-page-id]").dataset;
    const { anchor } = target.closest("[data-anchor]")?.dataset ?? {};
    this.goToPage(pageId, { anchor });
  }

  /* -------------------------------------------- */

  /**
   * Handle new pages scrolling into view.
   * @param {IntersectionObserverEntry[]} entries  An array of element 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.isMultiple ) {
      const entry = entries[0]; // There can be only one entry in single page mode.
      if ( entry.isIntersecting ) this.#pagesInView = [entry.target];
      return;
    }

    const minRatio = JournalEntrySheet.#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 = new Set(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.delete(el);
    }

    // Add pages that have scrolled into view.
    for ( const entry of intersecting ) inView.add(entry.target);

    this.#pagesInView = Array.from(inView).sort((a, b) => {
      const pageA = this.entry.pages.get(a.dataset.pageId);
      const pageB = this.entry.pages.get(b.dataset.pageId);
      return pageA.sort - pageB.sort;
    });
  }

  /* -------------------------------------------- */

  /** @override */
  _onRevealSecret(event) {
    const { pageId } = event.target.closest("[data-page-id]")?.dataset ?? {};
    const page = this.document.pages.get(pageId);
    if ( !page ) return;
    const content = page.text.content;
    const modified = event.target.toggleRevealed(content);
    page.update({ "text.content": modified });
  }

  /* -------------------------------------------- */

  /**
   * Handle journal entry search and filtering.
   * @param {KeyboardEvent} event  The keyboard input event.
   * @param {string} query         The input search string.
   * @param {RegExp} rgx           The regular expression query that should be matched against.
   * @param {HTMLElement} html     The container to filter items from.
   * @protected
   */
  _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.entry.pages.search({ query });
    for ( const el of html.querySelectorAll("[data-page-id]") ) {
      const page = this.entry.pages.get(el.dataset.pageId);
      let match = !query;
      if ( !match && nameOnlySearch ) match = foundry.applications.ux.SearchFilter.testQuery(rgx, page.name);
      else if ( !match ) match = results.find(r => r._id === page.id);
      if ( match ) this.#filteredPages.add(page.id);
      el.hidden = !match;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle a request to show the JournalEntry to other Users.
   * @protected
   */
  _onShowPlayers() {
    Journal.showDialog(this.entry);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the lock mode.
   * @this {JournalEntrySheet}
   */
  static #onToggleLock() {
    this.entry.setFlag("core", "locked", !this.locked);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the view mode.
   * @this {JournalEntrySheet}
   */
  static #onToggleMode() {
    const { MULTIPLE, SINGLE } = this.constructor.VIEW_MODES;
    return this.render({ mode: this.isMultiple ? SINGLE : MULTIPLE });
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Prompt the user with a Dialog for creation of a new JournalEntryPage.
   */
  createPageDialog() {
    const { bottom, left } = this.element.getBoundingClientRect();
    const sort = (Object.values(this._pages).at(-1)?.sort ?? 0) + CONST.SORT_INTEGER_DENSITY;
    const categories = [
      { value: "", label: "JOURNAL.Uncategorized", rule: true },
      ...this.document.categories.map(cat => {
        return { ...cat, label: cat.name, value: cat.id };
      }).sort(this.document.constructor.sortCategories)
    ];
    return JournalEntryPage$1.implementation.createDialog({ sort }, { parent: this.entry }, {
      template: "templates/journal/pages/create-dialog.hbs",
      context: {
        categories: {
          options: categories,
          show: this.document.categories.size
        },
        fields: {
          category: new StringField({ label: "JOURNALENTRYPAGE.Category" }, { name: "category" }),
          name: new StringField({ required: true, blank: false, label: "Name" }, { name: "name" }),
          type: new StringField({ required: true, blank: false, label: "Type" }, { name: "type" })
        }
      },
      position: {
        width: 320,
        top: bottom - 200,
        left: left + 10
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Retrieve the sheet instance for rendering this page inline.
   * @param {JournalEntryPage|string} page  The page instance or its ID.
   * @returns {JournalPageSheet}
   */
  getPageSheet(page) {
    if ( typeof page === "string" ) page = this.entry.pages.get(page);
    const sheetClass = page._getSheetClass();
    let sheet = this.#sheets[page.id];
    if ( sheet?.constructor !== sheetClass ) {
      if ( sheetClass.isV2 ) sheet = new sheetClass({
        id: "{id}-view",
        tag: "div",
        document: page,
        mode: "view",
        window: {
          frame: false,
          positioned: false
        }
      });
      else {
        /** @deprecated since v13 until v16. */
        sheet = new sheetClass(page, { editable: false });
      }
      this.#sheets[page.id] = sheet;
    }
    return sheet;
  }

  /* -------------------------------------------- */

  /**
   * Turn to a specific page.
   * @param {string} pageId            The ID of the page to turn to.
   * @param {object} [options]
   * @param {string} [options.anchor]  Optionally an anchor slug to focus within that page.
   */
  goToPage(pageId, { anchor }={}) {
    if ( !this.isMultiple && (pageId !== this.pageId) ) return this.render({ pageId, anchor });
    const page = this.element.querySelector(`.journal-entry-page[data-page-id="${pageId}"]`);
    if ( anchor ) {
      const { element } = this.getPageSheet(pageId)?.toc[anchor] ?? {};
      if ( element ) {
        element.scrollIntoView();
        return;
      }
    }
    page?.scrollIntoView();
  }

  /* -------------------------------------------- */

  /**
   * Determine whether a given page is visible to the current user.
   * @param {JournalEntryPage} page  The page.
   * @returns {boolean}
   */
  isPageVisible(page) {
    const sheet = this.getPageSheet(page);
    return sheet.isVisible ?? sheet._canUserView(game.user);
  }

  /* -------------------------------------------- */

  /**
   * Turn to the next page.
   */
  nextPage() {
    if ( !this.isMultiple ) return this.render({ pageIndex: this.pageIndex + 1 });
    if ( this.pagesInView.length ) this.pagesInView.at(-1).nextElementSibling?.scrollIntoView();
    else this.element.querySelector(".journal-entry-page")?.scrollIntoView();
  }

  /* -------------------------------------------- */

  /**
   * Turn to the previous page.
   */
  previousPage() {
    if ( !this.isMultiple ) return this.render({ pageIndex: this.pageIndex - 1 });
    this.pagesInView[0]?.previousElementSibling?.scrollIntoView();
  }

  /* -------------------------------------------- */

  /**
   * Toggle the search mode for this journal entry between name and full text search.
   */
  toggleSearchMode() {
    const { FULL, NAME } = CONST.DIRECTORY_SEARCH_MODES;
    this.#searchMode = this.searchMode === NAME ? FULL : NAME;
    return this.render({ parts: ["sidebar"] });
  }

  /* -------------------------------------------- */

  /**
   * Toggle the collapsed or expanded state of the sidebar.
   */
  toggleSidebar() {
    const sidebar = this.element.querySelector(".sidebar");
    const button = sidebar.querySelector(".collapse-toggle");
    this.#sidebarState.expanded = !this.sidebarExpanded;

    // Disable application interaction temporarily.
    this.element.style.pointerEvents = "none";

    // Remove min-width temporarily.
    const minWidth = this.element.style.minWidth || "";
    this.element.style.minWidth = "unset";

    // Configure CSS transitions.
    this.element.classList.add("collapsing");
    this._awaitTransition(this.element, 1000).then(() => {
      this.element.style.pointerEvents = "";
      this.element.style.minWidth = minWidth;
      this.element.classList.remove("collapsing");
    });

    // Determine the configured 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", ""));
    const delta = expandedWidth - collapsedWidth;

    // Adjust application size.
    this.setPosition({
      left: this.position.left + (this.sidebarExpanded ? -delta : delta),
      width: this.position.width + (this.sidebarExpanded ? delta : -delta)
    });

    // Toggle display of the sidebar.
    this.element.classList.toggle("expanded", this.sidebarExpanded);

    // Update icons and labels.
    button.dataset.tooltip = this.sidebarExpanded ? "JOURNAL.ViewCollapse" : "JOURNAL.ViewExpand";
    button.ariaLabel = game.i18n.localize(button.dataset.tooltip);
    button.classList.toggle("fa-caret-left", !this.sidebarExpanded);
    button.classList.toggle("fa-caret-right", this.sidebarExpanded);
    game.tooltip.deactivate();
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /**
   * Determine if drop operations are permitted.
   * @param {string} selector       The candidate HTML selector for dragging
   * @returns {boolean}             Can the current user drag this selector?
   * @protected
   */
  _canDragDrop(selector) {
    return this.isEditable;
  }

  /* -------------------------------------------- */

  /**
   * Determine if drag operations are permitted.
   * @param {string} selector       The candidate HTML selector for dragging
   * @returns {boolean}             Can the current user drag this selector?
   * @protected
   */
  _canDragStart(selector) {
    return this.entry.testUserPermission(game.user, "OBSERVER");
  }

  /* -------------------------------------------- */

  /**
   * Handle drag operations.
   * @param {DragEvent} event
   * @protected
   */
  _onDragStart(event) {
    ui.context?.close({ animate: false });
    const target = event.currentTarget;
    const { pageId } = target.closest("[data-page-id]").dataset;
    const { anchor } = target.closest("[data-anchor]")?.dataset ?? {};
    const page = this.entry.pages.get(pageId);
    const dragData = {
      ...page.toDragData(),
      anchor: { slug: anchor, name: target.innerText }
    };
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /**
   * Handle drop operations.
   * @param {DragEvent} event
   * @protected
   */
  async _onDrop(event) {
    // Retrieve the dropped Journal Entry Page.
    const data = TextEditor$1.implementation.getDragEventData(event);
    const page = await JournalEntryPage$1.implementation.fromDropData(data);
    if ( !page ) return;

    // Determine the target that was dropped.
    const target = event.target.closest("[data-page-id]");
    const sortTarget = target ? this.entry.pages.get(target?.dataset.pageId) : null;

    // Prevent dropping a page onto itself.
    if ( page === sortTarget ) return;

    // Case 1 - Sort Pages
    if ( page.parent === this.entry ) {
      if ( this.locked ) return;
      return page.sortRelative({
        sortKey: "sort",
        target: sortTarget,
        siblings: this.entry.pages.filter(p => p.id !== page.id)
      });
    }

    // Case 2 - Create Pages
    const pageData = page.toObject();
    if ( this.entry.pages.has(page.id) ) delete pageData._id;
    pageData.sort = sortTarget ? sortTarget.sort : this.entry.pages.reduce((max, p) => {
      return p.sort > max ? p.sort : max;
    }, -CONST.SORT_INTEGER_DENSITY);
    pageData.sort += CONST.SORT_INTEGER_DENSITY;
    return JournalEntryPage$1.implementation.create(pageData, { parent: this.entry, keepId: true });
  }

  /* -------------------------------------------- */
  /*  Private API                                 */
  /* -------------------------------------------- */

  /**
   * Call close hooks for individual pages.
   * @param {string} [pageId]  Calls the hook for the given page only, otherwise calls hooks for all pages.
   */
  #callCloseHooks(pageId) {
    if ( foundry.utils.isEmpty(this._pages) ) return;
    const pages = pageId ? [this._pages[pageId]] : Object.values(this._pages);
    for ( const page of pages ) {
      const sheet = this.getPageSheet(page.id);
      if ( sheet.isV2 ) sheet._doEvent(sheet._onCloseView, { eventName: "closeView", hookName: "closeView" });
      else {
        sheet._callHooks("close", sheet.element);
        sheet._closeView?.();
      }
    }
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13 until v16.
   * @ignore
   */
  async _renderAppV1PageView(element, sheet) {
    const data = await sheet.getData();
    const view = await sheet._renderInner(data);
    element.replaceChildren(...view.get());
    sheet._activateCoreListeners(view.parent());
    sheet.activateListeners(view);
    sheet._callHooks("render", view, data);
  }
}

/**
 * @import {DocumentSheetConfiguration, DocumentSheetRenderOptions} from "../../api/document-sheet.mjs";
 * @import {JournalEntryPageHeading} from "@client/_types.mjs";
 */

/**
 * @typedef {DocumentSheetConfiguration} JournalPageSheetConfiguration
 * @property {boolean} [includeTOC]  Whether the sheet includes additional table of contents elements besides its title.
 * @property {"edit"|"view"} [mode]  Whether the sheet is in edit or view mode.
 * @property {string} [viewClasses]  Classes appended to the page's root element when embedded in another sheet in view
 *                                   mode.
 */

/**
 * An abstract Application responsible for displaying and editing a single JournalEntryPage Document.
 * @extends {DocumentSheetV2<JournalPageSheetConfiguration, DocumentSheetRenderOptions>}
 * @mixes HandlebarsApplication
 */
class JournalEntryPageSheet extends DocumentSheetV2 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["journal-sheet", "journal-entry-page"],
    includeTOC: false,
    mode: "edit",
    viewPermission: DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
    viewClasses: [],
    window: {
      resizable: true
    },
    position: {
      width: 600,
      height: 680
    },
    form: {
      submitOnChange: true
    }
  };

  /** @inheritDoc */
  static emittedEvents = Object.freeze([...super.emittedEvents, "closeView"]);

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The table of contents for this text page.
   * @type {Record<string, JournalEntryPageHeading>}
   */
  toc;

  /**
   * Indicates that the sheet renders with App V2 rather than V1.
   * @type {boolean}
   */
  static isV2 = true;

  /**
   * Indicates that the sheet renders with App V2 rather than V1.
   * @type {boolean}
   */
  isV2 = this.constructor.isV2;

  /**
   * Whether the sheet is in view mode.
   * @returns {boolean}
   */
  get isView() {
    return this.options.mode === "view";
  }

  /**
   * The JournalEntryPage for this sheet.
   * @returns {JournalEntryPage}
   */
  get page() {
    return this.document;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _insertElement(element) {
    if ( this.options.window.frame ) super._insertElement(element);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const { name, title, uuid } = this.page;
    Object.assign(context, { name, title, uuid });
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare heading level choices.
   * @returns {Record<string, string>}
   * @protected
   */
  _prepareHeadingLevels() {
    return Array.fromRange(3, 1).reduce((obj, level) => {
      obj[level] = game.i18n.format("JOURNALENTRYPAGE.Level", { level });
      return obj;
    }, {});
  }

  /* -------------------------------------------- */
  /*  Events                                      */
  /* -------------------------------------------- */

  /**
   * Actions performed when this sheet is closed in some parent view.
   * @protected
   */
  _onCloseView() {}

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    if ( this.options.includeTOC ) this.toc = JournalEntryPage$1.implementation.buildTOC(this.element);
  }
}

/**
 * @import {ApplicationRenderContext} from "../../_types.mjs"
 * @import {HandlebarsTemplatePart, HandlebarsRenderOptions} from "../../api/handlebars-application.mjs"
 */

/**
 * An abstract subclass that contains specialised handlebars logic for JournalEntryPageSheets.
 * @extends JournalEntryPageSheet
 * @mixes HandlebarsApplication
 */
class JournalEntryPageHandlebarsSheet extends HandlebarsApplicationMixin(JournalEntryPageSheet) {
  /**
   * Handlebars parts to render in edit mode.
   * @type {Record<string, HandlebarsTemplatePart>}
   */
  static EDIT_PARTS = {
    header: {
      template: "templates/journal/parts/page-header.hbs"
    },
    footer: {
      template: "templates/journal/parts/page-footer.hbs",
      classes: ["journal-footer", "flexrow"]
    }
  };

  /**
   * Handlebars part to render in view mode.
   * @type {Record<string, HandlebarsTemplatePart>}
   */
  static VIEW_PARTS = {};

  /* -------------------------------------------- */

  /** @override */
  _configureRenderParts(options) {
    const parts = this.isView ? this.constructor.VIEW_PARTS : this.constructor.EDIT_PARTS;
    return foundry.utils.deepClone(parts);
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the content part.
   * @param {ApplicationRenderContext} context
   * @param {HandlebarsRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareContentContext(context, options) {}

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "header": await this._prepareHeaderContext(context, options); break;
      case "footer": await this._prepareFooterContext(context, options); break;
      case "content": await this._prepareContentContext(context, options); break;
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the footer part.
   * @param {ApplicationRenderContext} context
   * @param {HandlebarsRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareFooterContext(context, options) {
    context.buttons = [{
      type: "submit",
      cssClass: "",
      icon: "fa-solid fa-feather-pointed",
      label: "JOURNAL.Submit"
    }];
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the header part.
   * @param {ApplicationRenderContext} context
   * @param {HandlebarsRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareHeaderContext(context, options) {
    const categories = this.page.parent.categories.contents.sort(JournalEntry$1.sortCategories);
    if ( categories.length ) context.categories = [
      { value: "", label: game.i18n.localize("JOURNAL.Uncategorized") },
      ...categories.map(({ id, name }) => ({ value: id, label: name }))
    ];
    context.headingLevels = this._prepareHeadingLevels();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareSubmitData(event, form, formData, updateData) {
    if ( formData.object.category === "" ) formData.set("category", null);
    return super._prepareSubmitData(event, form, formData, updateData);
  }
}

/**
 * @import showdown from "showdown"
 * @import {ApplicationRenderContext} from "../../_types.mjs"
 */

/**
 * An abstract Application responsible for displaying and editing a single text-type JournalEntryPage Document.
 * @extends JournalEntryPageHandlebarsSheet
 */
class JournalEntryPageTextSheet extends JournalEntryPageHandlebarsSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["text"],
    includeTOC: true
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Bi-directional HTML <-> Markdown converter.
   * @type {showdown.Converter}
   * @protected
   */
  static _converter = (function() {
    Object.entries(SHOWDOWN_OPTIONS).forEach(([k, v]) => window.showdown.setOption(k, v));
    return new window.showdown.Converter();
  })();

  /**
   * The format used to edit text content in this sheet.
   * @type {number}
   */
  static format = JOURNAL_ENTRY_PAGE_FORMATS.HTML;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.text = { ...this.page.text };
    this.#convertFormats(context);
    return context;
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /**
   * Determine if any editors have unsaved changes.
   * @returns {boolean}
   * @abstract
   * @protected
   */
  _isEditorDirty() {
    return false;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareSubmitData(event, form, formData, updateData) {
    const submitData = super._prepareSubmitData(event, form, formData, updateData);
    if ( (this.constructor.format === JOURNAL_ENTRY_PAGE_FORMATS.HTML) && this._isEditorDirty() ) {
      // Clear any stored markdown so it can be re-converted.
      foundry.utils.mergeObject(submitData, {
        text: {
          format: JOURNAL_ENTRY_PAGE_FORMATS.HTML,
          markdown: ""
        }
      });
    }
    return submitData;
  }

  /* -------------------------------------------- */
  /*  Conversion                                  */
  /* -------------------------------------------- */

  /**
   * Lazily convert text formats if we detect the document being opened in a different format.
   * @param {ApplicationRenderContext} context
   */
  #convertFormats(context) {
    const formats = JOURNAL_ENTRY_PAGE_FORMATS;
    const text = this.page.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.
      context.text.markdown = this.constructor._converter.makeMarkdown(text.content.trim());
    }
  }
}

/**
 * An Application responsible for displaying and editing a single image-type JournalEntryPage Document.
 * @extends JournalEntryPageHandlebarsSheet
 */
class JournalEntryPageImageSheet extends JournalEntryPageHandlebarsSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["image"],
    window: {
      icon: "fa-solid fa-image"
    }
  };

  /** @inheritDoc */
  static EDIT_PARTS = {
    header: super.EDIT_PARTS.header,
    content: {
      template: "templates/journal/pages/image/edit.hbs",
      classes: ["standard-form"]
    },
    footer: super.EDIT_PARTS.footer
  };

  /** @override */
  static VIEW_PARTS = {
    content: {
      template: "templates/journal/pages/image/view.hbs",
      root: true
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const { image, src } = this.page;
    Object.assign(context, { src, caption: image.caption, srcInput: this.#createSourceInput.bind(this) });
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Create a FilePicker input for the image source field.
   * @param {DataField} field              The source field.
   * @param {FormInputConfig} inputConfig  The form input configuration.
   * @returns {HTMLFilePickerElement}
   */
  #createSourceInput(field, inputConfig) {
    return foundry.applications.elements.HTMLFilePickerElement.create({ type: "image", ...inputConfig });
  }
}

/**
 * An Application responsible for displaying a single text-type JournalEntryPage Document, and editing it with a
 * Markdown editor.
 * @extends JournalEntryPageTextSheet
 */
class JournalEntryPageMarkdownSheet extends JournalEntryPageTextSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      contentClasses: ["flexcol"],
      icon: "fa-brands fa-markdown"
    }
  };

  /** @inheritDoc */
  static EDIT_PARTS = {
    header: super.EDIT_PARTS.header,
    content: {
      classes: ["flex1", "flexcol"],
      template: "templates/journal/pages/markdown/edit.hbs"
    },
    footer: super.EDIT_PARTS.footer
  };

  /** @override */
  static VIEW_PARTS = {
    content: {
      root: true,
      template: "templates/journal/pages/text/view.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  static format = JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;

  /**
   * Store the dirty flag for this editor.
   * @type {boolean}
   */
  #isDirty = false;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContentContext(context, options) {
    if ( this.isView ) context.text.enriched = await TextEditor$1.implementation.enrichHTML(context.text.content, {
      relativeTo: this.page,
      secrets: this.page.isOwner
    });
    else context.markdownFormat = JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preSyncPartState(partId, newElement, priorElement, state) {
    super._preSyncPartState(partId, newElement, priorElement, state);
    if ( !this.isView && (partId === "content") ) {
      state.cursor = priorElement?.querySelector("code-mirror")?.cursor ?? null;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _syncPartState(partId, newElement, priorElement, state) {
    super._syncPartState(partId, newElement, priorElement, state);
    if ( !this.isView && (partId === "content") && Number.isFinite(state.cursor) ) {
      newElement.querySelector("code-mirror").scrollTo({ top: state.cursor });
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("keypress", this.#onEdit.bind(this), { passive: true });
    this.element.addEventListener("paste", this.#onEdit.bind(this), { passive: true });
    this.element.addEventListener("drop", this._onDrop.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping something onto the markdown editor.
   * @param {DragEvent} event  The triggering event.
   * @protected
   */
  _onDrop(event) {
    if ( !event.target.closest("code-mirror") ) return;
    event.preventDefault();
    const eventData = TextEditor$1.implementation.getDragEventData(event);
    return this._onDropContentLink(event, eventData);
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping a content link onto the markdown editor.
   * @param {DragEvent} event   The originating drop event.
   * @param {object} eventData  The parsed event data.
   * @protected
   * @returns {Promise<void>}
   */
  async _onDropContentLink(event, eventData) {
    const link = await TextEditor$1.implementation.getContentLink(eventData, { relativeTo: this.page });
    if ( !link ) return;
    const editor = this.form.elements["text.markdown"];
    const content = editor.value;
    const pos = editor.posAtCoords({ x: event.clientX, y: event.clientY });
    editor.value = content.substring(0, pos) + link + content.substring(pos);
    this.#isDirty = true;
  }

  /* -------------------------------------------- */

  /**
   * Handle an editing event.
   * @param {Event} event  The triggering event.
   */
  #onEdit(event) {
    // When pasting to a brand-new line of a CodeMirror editor, the event target seems to be an un-parented <br> tag.
    if ( event.target.closest("code-mirror") || (event.target.tagName === "BR") ) this.#isDirty = true;
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @override */
  _isEditorDirty() {
    return this.#isDirty;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareSubmitData(event, form, formData, updateData) {
    const submitData = super._prepareSubmitData(event, form, formData, updateData);
    // Do not persist the markdown conversion if the contents have not been edited.
    if ( !this._isEditorDirty() ) {
      delete submitData.text.markdown;
      delete submitData.text.format;
    }
    return submitData;
  }
}

/**
 * An Application responsible for displaying and editing a single pdf-type JournalEntryPage Document.
 * @extends JournalEntryPageHandlebarsSheet
 */
class JournalEntryPagePDFSheet extends JournalEntryPageHandlebarsSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["pdf"],
    window: {
      icon: "fa-solid fa-file-pdf"
    }
  };

  /** @inheritDoc */
  static EDIT_PARTS = {
    header: super.EDIT_PARTS.header,
    content: {
      template: "templates/journal/pages/pdf/edit.hbs",
      classes: ["standard-form"]
    },
    footer: super.EDIT_PARTS.footer
  };

  /** @inheritDoc */
  static VIEW_PARTS = {
    content: {
      template: "templates/journal/pages/pdf/view.hbs",
      root: true
    }
  };

  /**
   * Maintain a cache of PDF sizes to avoid making HEAD requests every render.
   * @type {Record<string, number>}
   * @protected
   */
  static _sizes = {};

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContentContext(context, options) {
    await super._prepareContentContext(context, options);
    Object.assign(context, {
      src: this.page.src,
      srcInput: this.#createSourceInput.bind(this),
      params: this._getViewerParams()
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    const loadPDFButton = this.element.querySelector('[data-action="loadPDF"]');
    if ( loadPDFButton ) {
      loadPDFButton.disabled = !this.page.testUserPermission(game.user, "OBSERVER");
      loadPDFButton.addEventListener("click", this._onLoadPDF.bind(this));
    }
    const pdfLoader = this.element.querySelector(".load-pdf");
    if ( !this.isView || !pdfLoader ) return;
    let size = this.constructor._sizes[this.page.src];
    if ( size === undefined ) {
      try {
        const res = await fetch(this.page.src, {method: "HEAD"});
        this.constructor._sizes[this.page.src] = size = Number(res.headers.get("content-length"));
      } catch {}
    }
    if ( !isNaN(size) ) {
      const span = document.createElement("span");
      span.classList.add("hint");
      span.append(` (${foundry.utils.formatFileSize(size)})`);
      pdfLoader.querySelector("button").append(span);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle a request to load a PDF.
   * @param {PointerEvent} 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);
  }

  /* -------------------------------------------- */

  /**
   * Marshall URL query parameters to pass to the PDF viewer.
   * @returns {URLSearchParams}
   * @protected
   */
  _getViewerParams() {
    const params = new URLSearchParams();
    if ( this.page.src ) {
      const src = URL.parseSafe(this.page.src) ? this.page.src : foundry.utils.getRoute(this.page.src);
      params.append("file", src);
    }
    return params;
  }

  /* -------------------------------------------- */

  /**
   * Create a FilePicker input for the PDF source field.
   * @param {DataField} field              The source field.
   * @param {FormInputConfig} inputConfig  The form input configuration.
   * @returns {HTMLFilePickerElement}
   */
  #createSourceInput(field, inputConfig) {
    return foundry.applications.elements.HTMLFilePickerElement.create({ type: "text", ...inputConfig });
  }
}

/**
 * @import {ProseMirrorPluginsEvent} from "../../elements/prosemirror-editor.mjs";
 */

/**
 * An Application responsible for displaying a single text-type JournalEntryPage Document, and editing it with a
 * ProseMirror editor.
 * @extends JournalEntryPageTextSheet
 */
class JournalEntryPageProseMirrorSheet extends JournalEntryPageTextSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      icon: "fa-solid fa-feather"
    }
  };

  /** @inheritDoc */
  static EDIT_PARTS = {
    header: super.EDIT_PARTS.header,
    content: {
      template: "templates/journal/pages/text/edit.hbs"
    },
    footer: super.EDIT_PARTS.footer
  };

  /** @override */
  static VIEW_PARTS = {
    content: {
      template: "templates/journal/pages/text/view.hbs",
      root: true
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _canRender(options) {
    if ( options.resync || !this.rendered || !this.options.window.frame ) return true;
    return !this._isEditorDirty();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContentContext(context, options) {
    if ( this.isView ) context.text.enriched = await TextEditor$1.implementation.enrichHTML(context.text.content, {
      relativeTo: this.page,
      secrets: this.page.isOwner
    });
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @override */
  _isEditorDirty() {
    return this.form.querySelector("prose-mirror")?.isDirty();
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("plugins", this._onConfigurePlugins.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Update the parent sheet if it is open when the server autosaves the contents of this editor.
   * @param {string} content  The updated editor contents.
   * @internal
   */
  _onAutosave(content) {
    this.page.parent.render(false);
  }

  /* -------------------------------------------- */

  /**
   * Configure plugins for the ProseMirror instance.
   * @param {ProseMirrorPluginsEvent} event
   * @protected
   */
  _onConfigurePlugins(event) {
    event.plugins.highlightDocumentMatches =
      ProseMirror.ProseMirrorHighlightMatchesPlugin.build(ProseMirror.defaultSchema);
  }

  /* -------------------------------------------- */

  /**
   * Update the UI appropriately when receiving new steps from another client.
   * @internal
   */
  _onNewSteps() {
    this.form.querySelectorAll('[data-action="save-html"]').forEach(el => el.disabled = true);
  }
}

/**
 * An Application responsible for displaying and editing a single video-type JournalEntryPage Document.
 * @extends JournalEntryPageHandlebarsSheet
 */
class JournalEntryPageVideoSheet extends JournalEntryPageHandlebarsSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["video"],
    window: {
      icon: "fa-solid fa-video"
    }
  };

  /** @inheritDoc */
  static EDIT_PARTS = {
    header: super.EDIT_PARTS.header,
    content: {
      template: "templates/journal/pages/video/edit.hbs",
      classes: ["standard-form"]
    },
    footer: super.EDIT_PARTS.footer
  };

  /** @inheritDoc */
  static VIEW_PARTS = {
    content: {
      template: "templates/journal/pages/video/view.hbs",
      root: true
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContentContext(context, options) {
    await super._prepareContentContext(context, options);
    const { src, video } = this.page;
    const { h, m, s } = this._timestampToTimeComponents(video.timestamp);
    Object.assign(context, {
      src,
      srcInput: this.#createSourceInput.bind(this),
      flexRatio: !video.width && !video.height,
      isYouTube: game.video.isYouTubeURL(src),
      timestamp: {
        h: {
          field: new NumberField({ integer: true, min: 0, step: 1 }),
          value: h
        },
        m: {
          field: new NumberField({ integer: true, min: 0, max: 59, step: 1 }),
          input: (field, config) => createTextInput(config),
          value: m
        },
        s: {
          field: new NumberField({ integer: true, min: 0, max: 59, step: 1 }),
          input: (field, config) => createTextInput(config),
          value: s
        }
      },
      yt: {
        id: `youtube-${foundry.utils.randomID()}`,
        url: game.video.getYouTubeEmbedURL(src, this._getYouTubeVars())
      }
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    if ( !this.isView ) return;
    const { video: config } = this.page;
    const iframe = this.element.querySelector("iframe[id]");
    if ( iframe ) game.video.getYouTubePlayer(iframe.id, {
      events: {
        onStateChange: event => {
          if ( event.data === YT.PlayerState.PLAYING ) event.target.setVolume(config.volume * 100);
        }
      }
    }).then(player => {
      if ( config.timestamp ) player.seekTo(config.timestamp, true);
    });
    const video = this.element.querySelector("video");
    video?.addEventListener("loadedmetadata", () => {
      video.volume = config.volume;
      if ( config.timestamp ) video.currentTime = config.timestamp;
    });
  }

  /* -------------------------------------------- */

  /**
   * Get the YouTube player parameters depending on whether the sheet is being viewed or edited.
   * @returns {object}
   * @protected
   */
  _getYouTubeVars() {
    const { video } = this.page;
    const vars = { playsinline: 1, modestbranding: 1 };
    if ( this.isView ) {
      Object.assign(vars, {
        controls: video.controls ? 1 : 0,
        autoplay: video.autoplay ? 1 : 0,
        loop: video.loop ? 1 : 0
      });
      if ( video.timestamp ) vars.start = video.timestamp;
    }
    return vars;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareSubmitData(event, form, formData, updateData) {
    const submitData = super._prepareSubmitData(event, form, formData, updateData);
    const { timestamp } = foundry.utils.expandObject(formData.object);
    foundry.utils.setProperty(submitData, "video.timestamp", this._timeComponentsToTimestamp(timestamp));
    return submitData;
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * Create a FilePicker input for the video source field.
   * @param {DataField} field              The source field.
   * @param {FormInputConfig} inputConfig  The form input configuration.
   * @returns {HTMLFilePickerElement}
   */
  #createSourceInput(field, inputConfig) {
    return foundry.applications.elements.HTMLFilePickerElement.create({ type: "video", ...inputConfig });
  }
}

/**
 * A dialog for configuring options when showing content to players.
 * @extends DialogV2
 * @mixes HandlebarsApplication
 */
class ShowToPlayersDialog extends HandlebarsApplicationMixin(DialogV2) {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["show-to-players"],
    modal: true,
    buttons: [{
      label: "JOURNAL.ActionShow",
      type: "submit",
      icon: "fa-solid fa-check"
    }],
    window: {
      contentTag: "form",
      contentClasses: ["standard-form"]
    },
    position: {
      width: 500
    },
    form: {
      handler: ShowToPlayersDialog.#onFormSubmit,
      closeOnSubmit: true
    }
  };

  /** @override */
  static PARTS = {
    body: {
      classes: ["standard-form"],
      template: "templates/journal/dialog-show.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /**
   * The Document that is being shown.
   * @type {JournalEntry|JournalEntryPage}
   */
  get document() {
    return this.options.document;
  }

  /**
   * Whether the Document that is being shown is an image-type JournalEntryPage.
   * @type {boolean}
   */
  get isImage() {
    return (this.document instanceof JournalEntryPage$1) && (this.document.type === "image");
  }

  /** @override */
  get title() {
    return game.i18n.format("JOURNAL.ShowEntry", { name: this.document.name });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const ownership = Object.entries(CONST.DOCUMENT_OWNERSHIP_LEVELS);
    if ( this.document.isEmbedded ) ownership.shift();
    Object.assign(context, {
      buttons: this.options.buttons,
      isImage: this.isImage,
      image: {
        only: new BooleanField({ label: "JOURNALENTRYPAGE.ShowImageOnly" }),
        title: new BooleanField({ label: "JOURNALENTRYPAGE.ShowImageTitle" }),
        caption: new BooleanField({ label: "JOURNALENTRYPAGE.ShowImageCaption" })
      },
      users: game.users.filter(u => !u.isSelf),
      ownership: new NumberField({ label: "OWNERSHIP.Configure", blank: false, required: true }),
      levels: [
        { value: CONST.DOCUMENT_META_OWNERSHIP_LEVELS.NOCHANGE, label: "OWNERSHIP.NOCHANGE" },
        ...ownership.map(([name, level]) => ({ value: level, label: `OWNERSHIP.${name}` }))
      ]
    });
    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeForm(formConfig, event) {
    if ( event.target.name !== "allPlayers" ) return;
    const checked = event.target.checked;
    this.form.querySelectorAll('[name="players"]').forEach(i => Object.assign(i, { checked, disabled: checked }));
  }

  /* -------------------------------------------- */

  /**
   * Handle submitting the dialog.
   * @this {ShowToPlayersDialog}
   * @param {SubmitEvent} event          The submission event.
   * @param {HTMLFormElement} form       The submitted form element.
   * @param {FormDataExtended} formData  The submitted form data.
   * @returns {Promise<void>}
   */
  static async #onFormSubmit(event, form, formData) {
    let { allPlayers, imageOnly, showImageCaption, showImageTitle, players } = formData.object;
    let users = game.users.filter(u => !u.isSelf).map(u => u.id);
    if ( !allPlayers ) {
      if ( !players ) return;
      if ( !Array.isArray(players) ) players = [players];
      users = players.reduce((arr, id) => {
        const u = game.users.get(id);
        if ( u && !u.isSelf ) arr.push(id);
        return arr;
      }, []);
    }
    if ( !users.length ) return;
    const document = this.document;
    if ( formData.object.ownership >= CONST.DOCUMENT_OWNERSHIP_LEVELS.INHERIT ) {
      const target = formData.object.ownership;
      const { ownership } = document;
      if ( allPlayers ) ownership.default = target;
      for ( const id of users ) {
        if ( allPlayers ) {
          if ( (id in ownership) && (ownership[id] <= target) ) delete ownership[id];
          continue;
        }
        if ( ownership[id] === CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE ) ownership[id] = target;
        ownership[id] = Math.max(ownership[id] ?? -Infinity, target);
      }
      await document.update({"==ownership": ownership}, {render: false});
    }
    if ( imageOnly ) return Journal.showImage(document.src, {
      users,
      title: document.name,
      caption: showImageCaption ? document.caption : undefined,
      showTitle: showImageTitle,
      uuid: document.uuid
    });
    return Journal.show(document, { users, force: true });
  }
}

var _module$G = /*#__PURE__*/Object.freeze({
  __proto__: null,
  JournalEntryCategoryConfig: JournalEntryCategoryConfig,
  JournalEntryPageHandlebarsSheet: JournalEntryPageHandlebarsSheet,
  JournalEntryPageImageSheet: JournalEntryPageImageSheet,
  JournalEntryPageMarkdownSheet: JournalEntryPageMarkdownSheet,
  JournalEntryPagePDFSheet: JournalEntryPagePDFSheet,
  JournalEntryPageProseMirrorSheet: JournalEntryPageProseMirrorSheet,
  JournalEntryPageSheet: JournalEntryPageSheet,
  JournalEntryPageTextSheet: JournalEntryPageTextSheet,
  JournalEntryPageVideoSheet: JournalEntryPageVideoSheet,
  JournalEntrySheet: JournalEntrySheet,
  ShowToPlayersDialog: ShowToPlayersDialog
});

/**
 * @import {ApplicationClickAction, ApplicationFormSubmission} from "../_types.mjs";
 */

/**
 * The Application responsible for configuring a single ActiveEffect document within a parent Actor or Item.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class ActiveEffectConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["active-effect-config"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-person-rays"
    },
    position: {width: 560},
    form: {closeOnSubmit: true},
    actions: {
      addChange: ActiveEffectConfig.#onAddChange,
      deleteChange: ActiveEffectConfig.#onDeleteChange
    }
  };

  /** @override */
  static PARTS = {
    header: {template: "templates/sheets/active-effect/header.hbs"},
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    details: {template: "templates/sheets/active-effect/details.hbs", scrollable: [""]},
    duration: {template: "templates/sheets/active-effect/duration.hbs"},
    changes: {template: "templates/sheets/active-effect/changes.hbs", scrollable: ["ol[data-changes]"]},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /** @override */
  static TABS = {
    sheet: {
      tabs: [
        {id: "details", icon: "fa-solid fa-book"},
        {id: "duration", icon: "fa-solid fa-clock"},
        {id: "changes", icon: "fa-solid fa-gears"}
      ],
      initial: "details",
      labelPrefix: "EFFECT.TABS"
    }
  };

  /**
   * The default priorities of the core change modes
   * @type {Record<number, number>}
   */
  static DEFAULT_PRIORITIES = Object.values(ACTIVE_EFFECT_MODES).reduce((priorities, mode) => {
    priorities[mode] = mode * 10;
    return priorities;
  }, {});

  /* ----------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.buttons = [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "EFFECT.Submit"}];
    return context;
  }

  /* ----------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context) {
    const partContext = await super._preparePartContext(partId, context);
    if ( partId in partContext.tabs ) partContext.tab = partContext.tabs[partId];
    const document = this.document;
    switch ( partId ) {
      case "details":
        partContext.isActorEffect = document.parent.documentName === "Actor";
        partContext.isItemEffect = document.parent.documentName === "Item";
        partContext.legacyTransfer = CONFIG.ActiveEffect.legacyTransferral
          ? {label: game.i18n.localize("EFFECT.TransferLegacy"), hint: game.i18n.localize("EFFECT.TransferHintLegacy")}
          : null;
        partContext.statuses = CONFIG.statusEffects.map(s => ({value: s.id, label: game.i18n.localize(s.name)}));
        break;
      case "changes":
        partContext.modes = Object.entries(CONST.ACTIVE_EFFECT_MODES).reduce((modes, [key, value]) => {
          modes[value] = game.i18n.localize(`EFFECT.MODE_${key}`);
          return modes;
        }, {});
        partContext.priorities = ActiveEffectConfig.DEFAULT_PRIORITIES;
    }
    return partContext;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    // Update the priority placeholder to match the mode selection
    if ( event.target instanceof HTMLSelectElement && event.target.name.endsWith(".mode") ) {
      const modeSelect = event.target;
      const selector = `input[name="${modeSelect.name.replace(".mode", ".priority")}"]`;
      const priorityInput = modeSelect.closest("li").querySelector(selector);
      priorityInput.placeholder = ActiveEffectConfig.DEFAULT_PRIORITIES[modeSelect.value] ?? "";
    }
  }

  /* ----------------------------------------- */

  /**
   * Add a new change to the effect's changes array.
   * @this {ActiveEffectConfig}
   * @type {ApplicationClickAction}
   */
  static async #onAddChange() {
    const submitData = this._processFormData(null, this.form, new FormDataExtended(this.form));
    const changes = Object.values(submitData.changes ?? {});
    changes.push({});
    return this.submit({updateData: {changes}});
  }

  /* ----------------------------------------- */

  /**
   * Delete a change from the effect's changes array.
   * @this {ActiveEffectConfig}
   * @type {ApplicationClickAction}
   */
  static async #onDeleteChange(event) {
    const submitData = this._processFormData(null, this.form, new FormDataExtended(this.form));
    const changes = Object.values(submitData.changes);
    const row = event.target.closest("li");
    const index = Number(row.dataset.index) || 0;
    changes.splice(index, 1);
    return this.submit({updateData: {changes}});
  }
}

/**
 * @import Actor from "../../documents/actor.mjs";
 * @import Folder from "../../documents/folder.mjs";
 * @import TokenDocument from "../../documents/token.mjs";
 */

/**
 * A base class for providing Actor Sheet behavior using ApplicationV2.
 */
class ActorSheetV2 extends DocumentSheetV2 {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    position: {width: 600},
    window: {
      controls: [
        {
          action: "configureToken",
          icon: "fa-regular fa-circle-user",
          label: "DOCUMENT.Token",
          ownership: "OWNER"
        },
        {
          action: "configurePrototypeToken",
          icon: "fa-solid fa-circle-user",
          label: "TOKEN.TitlePrototype",
          ownership: "OWNER"
        },
        {
          action: "showPortraitArtwork",
          icon: "fa-solid fa-image",
          label: "SIDEBAR.CharArt",
          ownership: "OWNER"
        },
        {
          action: "showTokenArtwork",
          icon: "fa-solid fa-image",
          label: "SIDEBAR.TokenArt",
          ownership: "OWNER"
        }
      ]
    },
    actions: {
      configurePrototypeToken: ActorSheetV2.#onConfigurePrototypeToken,
      configureToken: ActorSheetV2.#onConfigureToken,
      showPortraitArtwork: ActorSheetV2.#onShowPortraitArtwork,
      showTokenArtwork: ActorSheetV2.#onShowTokenArtwork
    }
  };

  /**
   * The Actor document managed by this sheet.
   * @type {Actor}
   */
  get actor() {
    return this.document;
  }

  /* -------------------------------------------- */

  /**
   * If this sheet manages the ActorDelta of an unlinked Token, reference that Token document.
   * @type {TokenDocument|null}
   */
  get token() {
    return this.document.token;
  }

  /* -------------------------------------------- */

  /** @override */
  _getHeaderControls() {
    const controls = super._getHeaderControls();
    const actor = this.actor;

    // Portrait image
    const img = actor.img;
    if ( img === CONST.DEFAULT_TOKEN ) controls.findSplice(c => c.action === "showPortraitArtwork");

    // Token image
    const prototypeToken = actor.prototypeToken;
    const tex = prototypeToken.texture.src;
    if ( prototypeToken.randomImg || [null, undefined, CONST.DEFAULT_TOKEN].includes(tex) ) {
      controls.findSplice(c => c.action === "showTokenArtwork");
    }

    // Prototype token
    if ( !this.isEditable || actor.isToken ) {
      controls.findSplice(c => c.action === "configurePrototypeToken");
    }

    // Token
    if ( !this.isEditable || !actor.isToken ) controls.findSplice(c => c.action === "configureToken");

    return controls;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    new DragDrop.implementation({
      dragSelector: ".draggable",
      permissions: {
        dragstart: this._canDragStart.bind(this),
        drop: this._canDragDrop.bind(this)
      },
      callbacks: {
        dragstart: this._onDragStart.bind(this),
        dragover: this._onDragOver.bind(this),
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle header control button clicks to render the Prototype Token configuration sheet.
   * @this {ActorSheetV2}
   * @param {PointerEvent} event
   */
  static #onConfigurePrototypeToken(event) {
    new CONFIG.Token.prototypeSheetClass({
      prototype: this.actor.prototypeToken,
      position: {
        left: Math.max(this.position.left - 560 - 10, 10),
        top: this.position.top
      }
    }).render({force: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle rendering the token's configuration sheet.
   * @this {ActorSheetV2}
   */
  static #onConfigureToken() {
    this.actor.token.sheet.render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle header control button clicks to display actor portrait artwork.
   * @this {ActorSheetV2}
   * @param {PointerEvent} event
   */
  static #onShowPortraitArtwork(event) {
    const {img, name, uuid} = this.actor;
    new ImagePopout({src: img, uuid, window: {title: name}}).render({force: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle header control button clicks to display actor portrait artwork.
   * @this {ActorSheetV2}
   * @param {PointerEvent} event
   */
  static #onShowTokenArtwork(event) {
    const {prototypeToken, name, uuid} = this.actor;
    new ImagePopout({src: prototypeToken.texture.src, uuid, window: {title: name}}).render({force: true});
  }

  /* -------------------------------------------- */
  /*  Drag and Drop                               */
  /* -------------------------------------------- */

  /**
   * 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 this.isEditable;
  }

  /* -------------------------------------------- */

  /**
   * 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 this.isEditable;
  }

  /* -------------------------------------------- */

  /**
   * An event that occurs when a drag workflow begins for a draggable item on the sheet.
   * @param {DragEvent} event       The initiating drag start event
   * @returns {Promise<void>}
   * @protected
   */
  async _onDragStart(event) {
    const target = event.currentTarget;
    if ( "link" in event.target.dataset ) return;
    let dragData;

    // Owned Items
    if ( target.dataset.itemId ) {
      const item = this.actor.items.get(target.dataset.itemId);
      dragData = item.toDragData();
    }

    // Active Effect
    if ( target.dataset.effectId ) {
      const effect = this.actor.effects.get(target.dataset.effectId);
      dragData = effect.toDragData();
    }

    // Set data transfer
    if ( !dragData ) return;
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /**
   * An event that occurs when a drag workflow moves over a drop target.
   * @param {DragEvent} event
   * @protected
   */
  _onDragOver(event) {}

  /* -------------------------------------------- */

  /**
   * An event that occurs when data is dropped into a drop target.
   * @param {DragEvent} event
   * @returns {Promise<void>}
   * @protected
   */
  async _onDrop(event) {
    const data = TextEditor$1.implementation.getDragEventData(event);
    const actor = this.actor;
    const allowed = Hooks$1.call("dropActorSheetData", actor, this, data);
    if ( allowed === false ) return;

    // Dropped Documents
    const documentClass = foundry.utils.getDocumentClass(data.type);
    if ( documentClass ) {
      const document = await documentClass.fromDropData(data);
      await this._onDropDocument(event, document);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle a dropped document on the ActorSheet
   * @template {Document} TDocument
   * @param {DragEvent} event         The initiating drop event
   * @param {TDocument} document       The resolved Document class
   * @returns {Promise<TDocument|null>} A Document of the same type as the dropped one in case of a successful result,
   *                                    or null in case of failure or no action being taken
   * @protected
   */
  async _onDropDocument(event, document) {
    switch ( document.documentName ) {
      case "ActiveEffect":
        return (await this._onDropActiveEffect(event, document)) ?? null;
      case "Actor":
        return (await this._onDropActor(event, document)) ?? null;
      case "Item":
        return (await this._onDropItem(event, document)) ?? null;
      case "Folder":
        return (await this._onDropFolder(event, document)) ?? null;
      default:
        return null;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle a dropped Active Effect on the Actor Sheet.
   * The default implementation creates an Active Effect embedded document on the Actor.
   * @param {DragEvent} event       The initiating drop event
   * @param {ActiveEffect} effect   The dropped ActiveEffect document
   * @returns {Promise<ActiveEffect|null|undefined>} A Promise resolving to a newly created ActiveEffect, if one was
   *                                                 created, or otherwise a nullish value
   * @protected
   */
  async _onDropActiveEffect(event, effect) {
    if ( !this.actor.isOwner ) return null;
    if ( !effect || (effect.target === this.actor) ) return null;
    const keepId = !this.actor.effects.has(effect.id);
    const result = await ActiveEffect$1.implementation.create(effect.toObject(), {parent: this.actor, keepId});
    return result ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Handle a dropped Actor on the Actor Sheet.
   * @param {DragEvent} event     The initiating drop event
   * @param {Actor} actor         The dropped Actor document
   * @returns {Promise<Actor|null|undefined>} A Promise resolving to an Actor identical or related to the dropped Actor
   *                                          to indicate success, or a nullish value to indicate failure or no action
   *                                          being taken
   * @protected
   */
  async _onDropActor(event, actor) {
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Handle a dropped Item on the Actor Sheet.
   * @param {DragEvent} event     The initiating drop event
   * @param {Item} item           The dropped Item document
   * @returns {Promise<Item|null|undefined>} A Promise resolving to the dropped Item (if sorting), a newly created Item,
   *                                         or a nullish value in case of failure or no action being taken
   * @protected
   */
  async _onDropItem(event, item) {
    if ( !this.actor.isOwner ) return null;
    if ( this.actor.uuid === item.parent?.uuid ) {
      const result = await this._onSortItem(event, item);
      return result?.length ? item : null;
    }
    const keepId = !this.actor.items.has(item.id);
    const result = await Item$1.implementation.create(item.toObject(), {parent: this.actor, keepId});
    return result ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Handle a dropped Folder on the Actor Sheet.
   * @param {DragEvent} event     The initiating drop event
   * @param {Folder} folder       The dropped Folder document
   * @returns {Promise<Folder|null|undefined>} A Promise resolving to the dropped Folder indicate success, or a nullish
   *                                           value to indicate failure or no action being taken
   * @protected
   */
  async _onDropFolder(event, folder) {
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Handle a drop event for an existing embedded Item to sort that Item relative to its siblings.
   * @param {DragEvent} event     The initiating drop event
   * @param {Item} item           The dropped Item document
   * @returns {Promise<Item[]>|void}
   * @protected
   */
  _onSortItem(event, item) {
    const items = this.actor.items;
    const source = items.get(item.id);

    // Confirm the drop target
    const dropTarget = event.target.closest("[data-item-id]");
    if ( !dropTarget ) return;
    const target = items.get(dropTarget.dataset.itemId);
    if ( source.id === target.id ) return;

    // Identify sibling items based on adjacent HTML elements
    const siblings = [];
    for ( const element of dropTarget.parentElement.children ) {
      const siblingId = element.dataset.itemId;
      if ( siblingId && (siblingId !== source.id) ) siblings.push(items.get(element.dataset.itemId));
    }

    // Perform the sort
    const sortUpdates = foundry.utils.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);
  }
}

/**
 * @import {AdventureImportData, AdventureImportOptions, AdventureImportResult} from "@client/documents/_types.mjs";
 * @import {ApplicationFormConfiguration} from "../_types.mjs";
 * @import {SchemaField} from "@common/data/fields.mjs";
 * @import Adventure from "@client/documents/adventure.mjs";
 */

/**
 * This Document Sheet is responsible for rendering an Adventure and providing an interface to import it.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplicationMixin
 */
class AdventureImporterV2 extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["adventure-importer"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-download"
    },
    position: {width: 920},
    form: {
      submitOnClose: false,
      closeOnSubmit: true
    }
  };

  /** @override */
  static PARTS = {
    body: {template: "templates/adventure/importer.hbs"},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /**
   * A convenience alias for AdventureImporter#document
   * @type {Adventure}
   */
  get adventure() {
    return this.document;
  }

  /** @override */
  get isEditable() {
    // We don't care for the purposes of import whether the compendium pack is locked
    return this.document.testUserPermission(game.user, this.options.editPermission);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const adventure = context.document;
    const description = await TextEditor$1.implementation.enrichHTML(adventure.description, {secrets: adventure.isOwner});
    return Object.assign(context, {
      adventure,
      description,
      contents: this._getContentList(),
      imported: !!game.settings.get("core", "adventureImports")?.[adventure.uuid],
      optionsSchema: this._prepareImportOptionsSchema(options),
      buttons: [{ type: "submit", icon: "fa-solid fa-download", label: "ADVENTURE.ImportSubmit" }]
    });
  }

  /* -------------------------------------------- */

  /**
   * Prepare import options schema.
   * Options are rendered using the DataField#toInput method.
   * @param {AdventureImportOptions} options
   * @returns {SchemaField|undefined}
   * @protected
   */
  _prepareImportOptionsSchema(options) {}

  /* -------------------------------------------- */

  /**
   * Prepare a list of content types provided by this adventure.
   * @returns {{icon: string, label: string, count: number}[]}
   * @protected
   */
  _getContentList() {
    return Object.entries(Adventure$1.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;
    }, []);
  }

  /* -------------------------------------------- */
  /*  Import Workflows                            */
  /* -------------------------------------------- */

  /**
   * Configure how adventures that use this sheet class are imported.
   * This can be implemented by subclasses to implement custom import workflows.
   * @param {AdventureImportOptions} importOptions
   * @returns {Promise<void>}
   * @internal
   */
  async _configureImport(importOptions) {}

  /* -------------------------------------------- */

  /**
   * Configure how adventures that use this sheet class are imported.
   * This can be implemented by subclasses to implement custom import workflows.
   * @param {AdventureImportData} importData
   * @param {AdventureImportOptions} importOptions
   * @returns {Promise<void>}
   * @internal
   */
  async _preImport(importData, importOptions) {}

  /* -------------------------------------------- */

  /**
   * Configure how adventures that use this sheet class are imported.
   * This can be implemented by subclasses to implement custom import workflows.
   * @param {AdventureImportResult} importResult
   * @param {AdventureImportOptions} importOptions
   * @returns {Promise<void>}
   * @internal
   */
  async _onImport(importResult, importOptions) {}

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  _prepareSubmitData(event, form, formData, _updateData) {
    // Unlike parent document sheets, the form data here is arbitrary
    return this._processFormData(event, form, formData);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    if ( (event.target.name === "importFields") && (event.target.value === "all") ) this._onToggleImportAll(event);
    super._onChangeForm(formConfig, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the import all checkbox.
   * @param {Event} event  The change event.
   * @protected
   */
  _onToggleImportAll(event) {
    const target = event.target;
    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;
    });
  }

  /* -------------------------------------------- */

  /** @override */
  async _processSubmitData(_event, _form, submitData, _options) {
    await this.adventure.import(submitData);
  }
}

/**
 * @typedef ShaderTechnique
 * @property {number} id                       The numeric identifier of the technique
 * @property {string} label                    The localization string that labels the technique
 * @property {string} [coloration]             The coloration shader fragment when the technique is used
 * @property {string} [illumination]           The illumination shader fragment when the technique is used
 * @property {string} [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 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) * (globalLight ? 1.0 : 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;
  }
}

/**
 * The AmbientLight configuration application.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class AmbientLightConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["ambient-light-config"],
    window: {
      contentClasses: ["standard-form"]
    },
    position: {width: 560},
    form: {
      closeOnSubmit: true
    },
    actions: {
      reset: this.#onReset
    }
  };

  /** @override */
  static PARTS = {
    tabs: {
      template: "templates/generic/tab-navigation.hbs"
    },
    basic: {
      template: "templates/scene/parts/light-basic.hbs"
    },
    animation: {
      template: "templates/scene/parts/light-animation.hbs"
    },
    advanced: {
      template: "templates/scene/parts/light-advanced.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /** @override */
  static TABS = {
    sheet: {
      tabs: [
        {id: "basic", icon: "fa-solid fa-lightbulb"},
        {id: "animation", icon: "fa-solid fa-play"},
        {id: "advanced", icon: "fa-solid fa-gears"}
      ],
      initial: "basic",
      labelPrefix: "AMBIENT_LIGHT.TABS"
    }
  };

  /* -------------------------------------------- */

  /**
   * Maintain a copy of the original to show a real-time preview of changes.
   * @type {AmbientLightDocument}
   */
  preview;

  /* -------------------------------------------- */
  /*  Application Rendering                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preRender(context, options) {
    await super._preRender(context, options);
    if ( this.preview?.rendered ) {
      await this.preview.object.draw();
      this.document.object.initializeLightSource({deleted: true});
      this.preview.object.layer._configPreview.addChild(this.preview.object);
      this._previewChanges();
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.#toggleReset();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    if ( this.preview ) this._resetPreview();
    if ( this.document.rendered ) this.document.object.initializeLightSource();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    // Create the preview on first render
    if ( options.isFirstRender && context.document.object ) {
      const clone = context.document.object.clone();
      this.preview = clone.document;
    }

    // Prepare context
    const document = this.preview ?? context.document;
    const isDarkness = document.config.negative;
    return Object.assign(context, {
      document,
      light: document,
      source: document._source,
      colorationTechniques: AdaptiveLightingShader.SHADER_TECHNIQUES,
      gridUnits: document.parent.grid.units || game.i18n.localize("GridUnits"),
      isDarkness,
      lightAnimations: isDarkness ? CONFIG.Canvas.darknessAnimations : CONFIG.Canvas.lightAnimations,
      buttons: [
        {
          type: "reset",
          action: "reset",
          icon: "fa-solid fa-arrow-rotate-left",
          label: "AMBIENT_LIGHT.ACTIONS.RESET"
        },
        {
          type: "submit",
          icon: "fa-solid fa-floppy-disk",
          label: `AMBIENT_LIGHT.ACTIONS.${document.collection?.has(document.id) ? "UPDATE" : "CREATE"}`
        }
      ]
    });
  }

  /* -------------------------------------------- */

  /**
   * Toggle visibility of the reset button which is only visible on the advanced tab.
   */
  #toggleReset() {
    const reset = this.element.querySelector("button[data-action=reset]");
    reset.classList.toggle("hidden", this.tabGroups.sheet !== "advanced");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  changeTab(...args) {
    super.changeTab(...args);
    this.#toggleReset();
  }

  /* -------------------------------------------- */
  /*  Real-Time Preview                           */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    const formData = new FormDataExtended(this.element);
    this._previewChanges(formData.object);

    // Special handling for darkness state change
    if ( event.target.name === "config.negative") this.render({parts: ["animation", "advanced"]});
  }

  /* -------------------------------------------- */

  /**
   * Preview changes to the AmbientLight document as if they were true document updates.
   * @param {object} [change]  A change to preview.
   * @protected
   */
  _previewChanges(change) {
    if ( !this.preview ) return;
    if ( change ) this.preview.updateSource(change);
    if ( this.preview?.rendered ) {
      this.preview.object.renderFlags.set({refresh: true});
      this.preview.object.initializeLightSource();
    }
  }

  /* -------------------------------------------- */

  /**
   * Restore the true data for the AmbientLight document when the form is submitted or closed.
   * @protected
   */
  _resetPreview() {
    if ( !this.preview ) return;
    if ( this.preview.rendered ) {
      this.preview.object.destroy({children: true});
    }
    this.preview = null;
    if ( this.document.rendered ) {
      const object = this.document.object;
      object.renderable = true;
      object.initializeLightSource();
      object.renderFlags.set({refresh: true});
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Process reset button click
   * @param {PointerEvent} event                  The originating button click
   * @this {AmbientLightConfig}
   * @returns {Promise<void>}
   */
  static async #onReset(event) {
    event.preventDefault();
    const defaults = AmbientLightDocument.cleanData();
    const keys = ["vision", "config"];
    const configKeys = ["coloration", "contrast", "attenuation", "luminosity", "saturation", "shadows"];
    for ( const k in defaults ) {
      if ( !keys.includes(k) ) delete defaults[k];
    }
    for ( const k in defaults.config ) {
      if ( !configKeys.includes(k) ) delete defaults.config[k];
    }
    this._previewChanges(defaults);
    await this.render();
  }
}

/**
 * The AmbientSound configuration application.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class AmbientSoundConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["ambient-sound-config"],
    window: {
      contentClasses: ["standard-form"]
    },
    position: {width: 560},
    form: {
      closeOnSubmit: true
    },
    canCreate: true
  };

  /** @override */
  static PARTS = {
    body: {
      template: "templates/scene/ambient-sound-config.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    if ( !this.document.id ) return game.i18n.localize("AMBIENT_SOUND.ACTIONS.CREATE");
    return super.title;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const document = context.document;
    return Object.assign(context, {
      sound: document,
      gridUnits: document.parent.grid.units || game.i18n.localize("GridUnits"),
      soundEffects: CONFIG.soundEffects,
      buttons: [{
        type: "submit",
        icon: "fa-solid fa-floppy-disk",
        label: `AMBIENT_SOUND.ACTIONS.${document.collection?.has(document.id) ? "UPDATE" : "CREATE"}`
      }]
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    this.#toggleDisabledFields();
    return super._onRender(context, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    this.#toggleDisabledFields();
    return super._onChangeForm(formConfig, event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    if ( this.document.object?.isPreview ) this.document.object.destroy();
  }

  /* -------------------------------------------- */

  /**
   * Special logic to toggle the disabled state of form fields depending on the values of other fields.
   */
  #toggleDisabledFields() {
    const form = this.element;
    form["effects.base.intensity"].disabled = !form["effects.base.type"].value;
    form["effects.muffled.type"].disabled = form.walls.checked;
    form["effects.muffled.intensity"].disabled = form.walls.checked || !form["effects.muffled.type"].value;
  }
}

/**
 * @import {ApplicationClickAction} from "../_types.mjs";
 */

/**
 * A DocumentSheet application responsible for displaying and editing a single embedded Card document.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class CardConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["card-config"],
    position: {width: 480},
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-card-diamond"
    },
    form: {
      closeOnSubmit: true
    },
    actions: {
      addFace: CardConfig.#onAddFace,
      deleteFace: CardConfig.#onDeleteFace
    }
  };

  /** @override */
  static PARTS = {
    header: {template: "templates/cards/card/header.hbs"},
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    details: {template: "templates/cards/card/details.hbs"},
    faces: {template: "templates/cards/card/faces.hbs", scrollable: [""]},
    back: {template: "templates/cards/card/back.hbs"},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /** @override */
  static TABS = {
    sheet: {
      tabs: [
        {id: "details", icon: "fa-solid fa-memo"},
        {id: "faces", icon: "fa-solid fa-image-portrait"},
        {id: "back", icon: "fa-solid fa-card-heart"}
      ],
      initial: "details",
      labelPrefix: "CARD.TABS"
    }
  };

  /**
   * Card types with pre-localized labels
   * @type {Record<string, string>}
   */
  static get TYPES() {
    return CardConfig.#TYPES ??= Object.entries(CONFIG.Card.typeLabels).reduce((types, [type, label]) => {
      types[type] = game.i18n.localize(label);
      return types;
    }, {});
  }

  static #TYPES;

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    const partContext = await super._preparePartContext(partId, context, options);
    if ( partId in partContext.tabs ) partContext.tab = partContext.tabs[partId];
    switch ( partId ) {
      case "details":
        partContext.types = CardConfig.TYPES;
        break;
      case "faces":
        partContext.faceFields = context.fields.faces.element.fields;
        break;
      case "footer":
        partContext.buttons = [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "CARD.Save"}];
    }
    return partContext;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Add a new face.
   * @this {CardConfig}
   * @type {ApplicationClickAction}
   */
  static async #onAddFace() {
    await this.submit({operation: {render: false}});
    const submitData = this._processFormData(null, this.form, new FormDataExtended(this.form));
    const faces = Object.values(submitData.faces ?? {});
    faces.push({});
    return this.submit({updateData: {faces}});
  }

  /* -------------------------------------------- */

  /**
   * Delete an existing face.
   * @this {CardConfig}
   * @type {ApplicationClickAction}
   */
  static async #onDeleteFace(event) {
    const question = game.i18n.localize("AreYouSure");
    const warning = game.i18n.localize("CARD.ACTIONS.DeleteFace.Warning");
    const submitData = this._processFormData(null, this.form, new FormDataExtended(this.form));
    const faceEl = event.target.closest("[data-face]");
    return foundry.applications.api.DialogV2.confirm({
      window: {title: "CARD.ACTIONS.DeleteFace.Title"},
      content: `<p><strong>${question}</strong> ${warning}</p>`,
      yes: {
        callback: () => {
          const faces = Object.values(submitData.faces ?? {});
          const index = Number(faceEl?.dataset.index) || 0;
          faces.splice(index, 1);
          return this.submit({updateData: {faces}});
        }
      }
    });
  }
}

/**
 * @import {ApplicationClickAction, FormFooterButton} from "../_types.mjs";
 */

/**
 * A DocumentSheet application responsible for displaying and editing a single Cards stack.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class CardsConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["cards-config"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-cards"
    },
    position: {
      width: 720
    },
    actions: {
      controlCard: CardsConfig.#onControlCard,
      reset: CardsConfig.#onReset,
      pass: CardsConfig.#onPass,
      shuffle: CardsConfig.#onShuffle,
      toggleSort: CardsConfig.#onToggleSort
    },
    viewPermission: DOCUMENT_OWNERSHIP_LEVELS.OBSERVER
  };

  /* -------------------------------------------- */

  /**
   * The current sorting mode for the list of cards
   * @type {"standard"|"shuffled"}
   */
  #sortMode = "shuffled";

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    const initialized = super._initializeApplicationOptions(options);
    initialized.classes.push(initialized.document.type);
    return initialized;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.inCompendium = !!context.document.pack;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    const partContext = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "cards":
        partContext.cards = this._prepareCards(options.sortMode);
        partContext.cardTypes = CONFIG.Card.typeLabels;
        partContext.sortModeIcon = this.#sortMode ==="shuffled" ? "fa-random" : "fa-arrow-up-1-9";
        break;
      case "footer":
        partContext.buttons = this._prepareButtons();
        break;
    }
    return partContext;
  }

  /* -------------------------------------------- */

  /**
   * Prepare a sorted array of cards for display in the sheet.
   * @param {"standard"|"shuffled"} sortMode
   * @returns {Card[]}
   * @protected
   */
  _prepareCards(sortMode=this.#sortMode) {
    const stack = this.document;
    const sortFn = {
      standard: stack.sortStandard,
      shuffled: stack.sortShuffled
    }[sortMode];
    this.#sortMode = sortMode;
    return stack.cards.contents.sort((a, b) => sortFn.call(stack, a, b));
  }

  /* -------------------------------------------- */

  /**
   * Configure footer buttons for the window.
   * @returns {FormFooterButton[]}
   * @protected
   */
  _prepareButtons() {
    if ( !this.document.testUserPermission(game.user, this.options.editPermission) ) return [];
    return [
      {type: "button", icon: "fa-solid fa-arrow-rotate-left", action: "reset", label: "CARDS.ACTIONS.Reset"},
      {type: "submit", icon: "fa-solid fa-floppy-disk", label: "CARDS.ACTIONS.Save"}
    ];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    if ( !options.parts.includes("cards") ) return;
    new foundry.applications.ux.DragDrop.implementation({
      dragSelector: "ol[data-cards] > li",
      dropSelector: "ol[data-cards]",
      permissions: {
        dragstart: () => this.isEditable,
        drop: () => this.isEditable
      },
      callbacks: {
        dragstart: this._onDragStart.bind(this),
        dragover: this._onDragOver.bind(this),
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Pass one or more Cards to a Hand or Pile.
   * @this {CardsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onPass(event) {
    await this.document.passDialog();
  }

  /* -------------------------------------------- */

  /**
   * Reset the Cards stack.
   * @this {CardsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onReset() {
    await this.submit({operation: {render: false}});
    await this.document.resetDialog();
  }

  /* -------------------------------------------- */

  /**
   * Shuffle all Cards.
   * @this {CardsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onShuffle() {
    await this.submit({operation: {render: false}});
    this.#sortMode = "shuffled";
    await this.document.shuffle();
  }

  /* -------------------------------------------- */

  /**
   * Shuffle all Cards.
   * @this {CardsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onToggleSort() {
    await this.submit({operation: {render: false}});
    const sortMode = {standard: "shuffled", shuffled: "standard"}[this.#sortMode];
    await this.render({parts: ["cards"], sortMode});
  }

  /* -------------------------------------------- */

  /**
   * Handle card control actions to modify single cards from the sheet.
   * @this {CardsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onControlCard(_event, button) {
    const li = button.closest("li[data-card-id]");
    const stack = this.document;
    const card = stack.cards.get(li?.dataset.cardId);

    // Save any pending change to the form
    await this.submit({operation: {render: false}});

    // Handle the control action
    switch ( button.dataset.control ) {
      case "create":
        await getDocumentClass$1("Card").createDialog({faces: [{}], face: 0}, {parent: this.document});
        break;
      case "edit":
        await card.sheet.render({force: true});
        break;
      case "delete":
        await card.deleteDialog();
        break;
      case "play":
        await stack.playDialog(card);
        break;
      case "nextFace":
        await card.update({face: card.face === null ? 0 : card.face+1});
        break;
      case "prevFace":
        await card.update({face: card.face === 0 ? null : card.face-1});
        break;
    }
  }

  /* -------------------------------------------- */

  /**
   * The "dragstart" event handler for individual cards
   * @param {DragEvent} event
   * @protected
   */
  async _onDragStart(event) {
    const li = event.currentTarget;
    const card = this.document.cards.get(li.dataset.cardId);
    if ( card ) event.dataTransfer.setData("text/plain", JSON.stringify(card.toDragData()));
  }

  /* -------------------------------------------- */

  /**
   * The "dragover" event handler for individual cards
   * @param {DragEvent} event
   * @protected
   */
  async _onDragOver(event) {}

  /* -------------------------------------------- */

  /**
   * The "dragdrop" event handler for individual cards
   * @param {DragEvent} event
   * @protected
   */
  async _onDrop(event) {
    const data = TextEditor$1.implementation.getDragEventData(event);
    if ( data.type !== "Card" ) return;
    const card = await getDocumentClass$1("Card").fromDropData(data);
    const stack = this.document;
    if ( card.parent.id === stack.id ) return this.#onSortCard(event, card);
    try {
      return await card.pass(stack);
    } catch(err) {
      Hooks$1.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
   */
  async #onSortCard(event, card) {
    const stack = this.document;
    const li = event.target.closest("[data-card-id]");
    const target = stack.cards.get(li?.dataset.cardId);
    if ( !target || card === target ) return;
    const siblings = stack.cards.filter(c => c.id !== card.id);
    const updateData = foundry.utils.performIntegerSort(card, {target, siblings}).map(u => {
      return {_id: u.target.id, sort: u.update.sort};
    });
    await stack.updateEmbeddedDocuments("Card", updateData);
  }
}

/**
 * A CardsConfig subclass providing a sheet representation for Cards documents with the "deck" type.
 */
class CardDeckConfig extends CardsConfig {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    actions: {
      deal: CardDeckConfig.#onDeal
    }
  };

  /** @override */
  static PARTS = {
    header: {template: "templates/cards/deck/header.hbs"},
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    details: {template: "templates/cards/deck/details.hbs"},
    cards: {template: "templates/cards/deck/cards.hbs", scrollable: ["ol[data-cards]"]},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /** @override */
  static TABS = {
    sheet: {
      tabs: [
        {id: "details", icon: "fa-solid fa-gears"},
        {id: "cards", icon: "fa-solid fa-id-badge"}
      ],
      initial: "cards",
      labelPrefix: "CARDS.TABS"
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    const partContext = await super._preparePartContext(partId, context, options);
    if ( partId in partContext.tabs ) partContext.tab = partContext.tabs[partId];
    return partContext;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareButtons() {
    const buttons = super._prepareButtons();
    if ( !this.document.testUserPermission(game.user, this.options.editPermission) ) return buttons;
    buttons.unshift(
      {type: "button", action: "shuffle", icon: "fa-solid fa-shuffle", label: "CARDS.ACTIONS.Shuffle"},
      {type: "button", action: "deal", icon: "fa-solid fa-share-from-square", label: "CARDS.ACTIONS.Deal"}
    );
    return buttons;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Deal a single Card.
   * @this {CardsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onDeal() {
    await this.submit({operation: {render: false}});
    await this.document.dealDialog();
  }
}


/**
 * A CardsConfig subclass providing a sheet representation for Cards documents with the "hand" type.
 */
class CardHandConfig extends CardsConfig {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    actions: {
      draw: CardHandConfig.#onDraw
    }
  };

  /** @override */
  static PARTS = {
    cards: {
      template: "templates/cards/hand-pile.hbs",
      root: true,
      scrollable: ["ol[data-cards]"]
    },
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareButtons() {
    const buttons = super._prepareButtons();
    if ( !buttons.length ) return buttons;
    const disabled = !!this.document.pack;
    buttons.unshift(
      {
        type: "button",
        action: "draw",
        icon: "fa-solid fa-plus",
        label: "CARDS.ACTIONS.Draw",
        disabled
      },
      {
        type: "button",
        action: "pass",
        icon: "fa-solid fa-share-from-square",
        label: "CARDS.ACTIONS.Pass",
        disabled
      }
    );
    return buttons;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Draw a single Card.
   * @this {CardsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onDraw() {
    await this.submit({operation: {render: false}});
    await this.document.drawDialog();
  }
}

/**
 * A subclass of CardsConfig providing a sheet representation for Cards documents with the "pile" type.
 */
class CardPileConfig extends CardsConfig {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["cards-config"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-cards"
    },
    position: {
      width: 720
    },
    viewPermission: DOCUMENT_OWNERSHIP_LEVELS.OBSERVER
  };

  /** @override */
  static PARTS = {
    cards: {
      template: "templates/cards/hand-pile.hbs",
      root: true,
      scrollable: ["ol[data-cards]"]
    },
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareButtons() {
    const buttons = super._prepareButtons();
    if ( !buttons.length ) return buttons;
    const disabled = !!this.document.pack;
    buttons.unshift(
      {
        type: "button",
        action: "shuffle",
        icon: "fa-solid fa-shuffle",
        label: "CARDS.ACTIONS.Shuffle",
        disabled
      },
      {
        type: "button",
        action: "pass",
        icon: "fa-solid fa-share-from-square",
        label: "CARDS.ACTIONS.Pass",
        disabled
      }
    );
    return buttons;
  }
}

/**
 * The Combatant configuration application.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class CombatantConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["combatant-config"],
    canCreate: true,
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-sword"
    },
    position: {width: 420},
    form: {
      closeOnSubmit: true
    }
  };

  /** @override */
  static PARTS = {
    body: {
      root: true,
      template: "templates/sheets/combatant-config.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  get title() {
    const document = this.document;
    const key = document.collection?.has(document.id) ? "COMBAT.CombatantUpdateNamed" : "COMBAT.CombatantCreate";
    return game.i18n.format(key, {name: document.name});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.title = this.title;
    return context;
  }
}

/**
 * @import {RenderFlag} from "../_types.mjs"
 */

/**
 * 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.
 * @extends {Set<string>}
 */
class RenderFlags extends Set {

  /**
   * @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 {"OBJECTS"|"PERCEPTION"} [config.priority] The ticker priority at which these render flags are handled
   */
  constructor(flags={}, {object, priority="OBJECTS"}={}) {
    super();
    for ( const cfg of Object.values(flags) ) {
      cfg.propagate ||= [];
      cfg.reset ||= [];
    }
    Object.defineProperties(this, {
      flags: {value: Object.freeze(flags), configurable: false, writable: false},
      object: {value: object, configurable: false, writable: false},
      priority: {value: priority, configurable: false, writable: false}
    });
  }

  /**
   * The flags tracked by this data structure.
   * @type {Readonly<Record<string, RenderFlag>>}
   * @readonly
   */
  flags;

  /**
   * The RenderFlagObject instance which owns this set of RenderFlags
   * @type {RenderFlagObject|undefined}
   * @readonly
   */
  object;

  /**
   * The update priority when these render flags are applied.
   * @type {"OBJECTS"|"PERCEPTION"}
   * @readonly
   */
  priority;

  /* -------------------------------------------- */

  /**
   * @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 {Function} [Base] The base class being mixed: defaults to an anonymous empty class.
 */
function RenderFlagsMixin(Base=class {}) {
  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();
    }
  }
  return RenderFlagObject;
}

/**
 * @import {ControlIcon} from "../containers/_module.mjs";
 */

/**
 * 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 {
  /**
   * @param {PIXI.DisplayObject} object              The Canvas object (e.g., a Token, Tile, or Drawing) to which
   *                                                 mouse events should be bound.
   * @param {PIXI.Container} layer                   The Canvas Layer that contains the object.
   * @param {object} [permissions={}]                An object of permission checks, keyed by action name, which return
   *                                                 a boolean or invoke a function for whether the action is allowed.
   * @param {object} [callbacks={}]                  An object of callback functions, keyed by action name, which will
   *                                                 be executed during the event workflow (e.g., hoverIn, clickLeft).
   * @param {object} [options={}]                    Additional options that configure interaction behavior.
   * @param {string} [options.target]                If provided, the property name on `object` which references a
   *                                                 {@link foundry.canvas.containers.ControlIcon}.
   *                                                 This is used to set {@link MouseInteractionManager#controlIcon}.
   * @param {number} [options.dragResistance=10]     A minimum number of pixels the mouse must move before a drag is
   *                                                 initiated.
   * @param {PIXI.Application} [options.application] A specific PIXI Application to use for pointer event handling
   *                                                 defaults to `canvas.app` if not provided.
   */
  constructor(object, layer, permissions={}, callbacks={}, options={}) {
    this.#app = options.application ?? canvas.app;
    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}
     * @internal
     */
    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.#app.view.id;
  }

  /**
   * The client position of the last left/right-click.
   * @type {PIXI.Point}
   */
  lastClick = new PIXI.Point();

  /**
   * The PIXI.Application which this manager is being used for.
   * @type {PIXI.Application}
   */
  #app;

  /**
   * 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 minimum distance, measured in screen-coordinate pixels, that a pointer must move to initiate a drag operation.
   * This default value can be overridden by specifying the `dragResistance` option when invoking the constructor.
   * @type {number}
   */
  static DEFAULT_DRAG_RESISTANCE_PX = 10;

  /**
   * 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 on the main game canvas.
   * This method must 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 = 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 ) {
      this.#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);
    this.#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 = this.#isDoubleClick(event, true);
    this.lcTime = isDouble ? 0 : event.timeStamp;
    this.lastClick.set(event.clientX, event.clientY);

    // Assign origin data
    this.#assignOriginData(event);

    // 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;
    if ( canvas.currentMouseManager === null ) canvas.currentMouseManager = this;
    if ( this.callback(action, event) === false ) {
      this.state = priorState;
      if ( (canvas.currentMouseManager === this) && (this.state <= this.states.HOVER) ) {
        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 = this.#isDoubleClick(event, false);
    this.rcTime = isDouble ? 0 : event.timeStamp;
    this.lastClick.set(event.clientX, event.clientY);

    // Assign origin data
    this.#assignOriginData(event);

    // 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;
    if ( canvas.currentMouseManager === null ) canvas.currentMouseManager = this;
    if ( this.callback(action, event) === false ) {
      this.state = priorState;
      if ( (canvas.currentMouseManager === this) && (this.state <= this.states.HOVER) ) {
        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) < this.#app.ticker.elapsedMS ) return;
    this.dragTime = now;

    // Update interaction data
    const data = this.interactionData;
    data.destination = event.getLocalPosition(this.layer, data.destination);

    // Begin a new drag event
    if ( this.state !== this.states.DRAG ) {
      const dx = event.global.x - data.screenOrigin.x;
      const dy = event.global.y - data.screenOrigin.y;
      const dz = Math.hypot(dx, dy);
      const r = this.options.dragResistance || MouseInteractionManager.DEFAULT_DRAG_RESISTANCE_PX;
      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 = this.#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;
      if ( canvas.currentMouseManager === this ) {
        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);
    }

    // Emulate an event to update the cursor
    if ( createEvent ) MouseInteractionManager.emulateMoveEvent();
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * Determine if the current click is a double-click based on timing and distance.
   * @param {PIXI.FederatedEvent} event The mouse event to evaluate.
   * @param {boolean} isLeftClick True for left-click, false for right-click.
   * @returns {boolean} True if the click qualifies as a double-click.
   */
  #isDoubleClick(event, isLeftClick) {
    const {clientX, clientY, timeStamp} = event;
    const lastTime = isLeftClick ? this.lcTime : this.rcTime;
    const maxTime = MouseInteractionManager.DOUBLE_CLICK_TIME_MS;

    // Check time first for an early exit
    if ( (timeStamp - lastTime) > maxTime ) return false;

    const maxDistance = MouseInteractionManager.DOUBLE_CLICK_DISTANCE_PX;
    const distance = Math.hypot(clientX - this.lastClick.x, clientY - this.lastClick.y);

    return distance <= maxDistance;
  }

  /* -------------------------------------------- */

  /**
   * Assign origin data from the layer position, in local position and in screen position.
   * @param {PIXI.FederatedEvent} event The mouse event to evaluate.
   */
  #assignOriginData(event) {
    // Set the origin point from layer local position
    this.interactionData.origin = event.getLocalPosition(this.layer);

    // Set screenOrigin as the screen coordinates of the origin
    this.interactionData.screenOrigin = new PIXI.Point(event.global.x, event.global.y);
  }

  /* -------------------------------------------- */

  /**
   * Assign the interaction data to the event.
   * @param {PIXI.FederatedEvent} event
   */
  #assignInteractionData(event) {
    this.interactionData.object = this.object;
    event.interactionData = this.interactionData;
  }
}

/**
 * @import DocumentSheetV2 from "../../applications/api/document-sheet.mjs";
 * @import BaseGrid from "@common/grid/base.mjs";
 * @import PlaceablesLayer from "../layers/base/placeables-layer.mjs";
 * @import ControlIcon from "../containers/elements/control-icon.mjs";
 * @import {Scene, User} from "../../documents/_module.mjs";
 */

/**
 * An Abstract Base Class which defines a Placeable Object which represents a Document placed on the Canvas
 * @category Canvas
 * @abstract
 */
class PlaceableObject extends RenderFlagsMixin(PIXI.Container) {
  /**
   * @param {CanvasDocument} document      The Document instance represented by this object
   */
  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 {CanvasDocument}
     */
    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;

  /* -------------------------------------------- */

  /**
   * Return a reference to the configured subclass of this base PlaceableObject type.
   * @type {typeof PlaceableObject}
   */
  static get implementation() {
    return getPlaceableObjectClass(this.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 document sheet used to configure the properties of this Placeable Object or the Document it represents.
   * @type {DocumentSheetV2}
   */
  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);
  }

  /* -------------------------------------------- */

  /**
   * Get the origin used for pasting the copied objects.
   * @param {PlaceableObject[]} copies    The objects that are copied
   * @returns {Point}                     The offset
   * @internal
   */
  static _getCopiedObjectsOrigin(copies) {

    // Get the center of all copies
    const center = {x: 0, y: 0};
    for ( const copy of copies ) {
      const c = copy.center;
      center.x += c.x;
      center.y += c.y;
    }
    center.x /= copies.length;
    center.y /= copies.length;

    // Offset of the destination position relative to the center
    return center;
  }

  /* -------------------------------------------- */

  /**
   * Get the data of the copied object pasted at the position given by the offset.
   * Called by {@link foundry.canvas.layers.PlaceablesLayer#pasteObjects} for each copied object.
   * @param {Point} offset                      The offset relative from the current position to the destination
   * @param {object} [options]                  Options of {@link foundry.canvas.layers.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
   * @internal
   */
  _pasteObject(offset, {hidden=false, snap=true}={}) {
    const {x, y} = this.document;
    let position = {x: x + offset.x, y: y + offset.y};
    const data = this.document.toObject();
    delete data._id;
    data.hidden ||= hidden;
    if ( snap ) {
      if ( this.scene === canvas.scene ) position = this.getSnappedPosition(position);
      else {
        const document = getDocumentClass$1(this.layer.constructor.documentName).fromSource(data, {parent: canvas.scene});
        const object = new this.layer.constructor.placeableClass(document);
        document._object = object;
        position = object.getSnappedPosition(position);
        object.destroy({children: true});
      }
    }
    const d = canvas.dimensions;
    data.x = Math.clamp(position.x, 0, d.width - 1);
    data.y = Math.clamp(position.y, 0, d.height - 1);
    return data;
  }

  /* -------------------------------------------- */
  /*  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$1.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 {this} 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$1.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$1.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
   */
  _overlapsSelection(rectangle) {
    const {x, y} = this.position;
    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();
    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=true]    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 ( const object of this.layer.controlled ) {
        if ( object !== this ) object.release();
      }
    }

    // Bail out if this object is already controlled, or not controllable
    if ( this.#controlled || !this.id ) return true;
    if ( !this._canControl(game.user) ) 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$1.callAll(`control${this.constructor.embeddedName}`, this, this.#controlled);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Additional events that 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$1.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.close();
    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.#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
   * @param {-1|0|1} dz         The number of grid units to shift along the Z-axis
   * @returns {object}          The shifted target coordinates
   * @internal
   */
  _getShiftedPosition(dx, dy, dz) {
    const snapped = this.getSnappedPosition();
    snapped.elevation = this.document.elevation.toNearest(canvas.grid.distance, "floor");
    return PlaceableObject._getShiftedPosition(dx, dy, dz, this.document, snapped, this.scene.grid);
  }

  /* -------------------------------------------- */

  /**
   * Obtain the shifted position.
   * @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
   * @param {-1|0|1} dz                 The number of grid units to shift along the Z-axis
   * @param {ElevatedPoint} position    The unsnapped position
   * @param {ElevatedPoint} snapped     The snapped position
   * @param {BaseGrid} grid             The grid
   * @returns {ElevatedPoint}           The shifted target coordinates
   * @internal
   */
  static _getShiftedPosition(dx, dy, dz, position, snapped, grid) {
    const D = CONST.MOVEMENT_DIRECTIONS;
    let direction = 0;
    if ( dx < 0 ) {
      if ( position.x <= snapped.x + 0.5 ) direction |= D.LEFT;
    } else if ( dx > 0 ) {
      if ( position.x >= snapped.x - 0.5 ) direction |= D.RIGHT;
    }
    if ( dy < 0 ) {
      if ( position.y <= snapped.y + 0.5 ) direction |= D.UP;
    } else if ( dy > 0 ) {
      if ( position.y >= snapped.y - 0.5 ) direction |= D.DOWN;
    }
    if ( dz < 0 ) {
      if ( position.elevation <= snapped.elevation + 1e-8 ) direction |= D.DESCEND;
    } else if ( dz > 0 ) {
      if ( position.elevation >= snapped.elevation - 1e-8 ) direction |= D.ASCEND;
    }
    let biasX = 0;
    let biasY = 0;
    if ( grid.isHexagonal ) {
      if ( grid.columns ) biasY = 1;
      else biasX = 1;
    }
    const shifted = grid.getShiftedPoint({
      x: snapped.x + biasX,
      y: snapped.y + biasY,
      elevation: snapped.elevation
    }, direction);
    shifted.x -= biasX;
    shifted.y -= biasY;
    shifted.x = Math.round(shifted.x);
    shifted.y = Math.round(shifted.y);
    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. Must be equal to `game.user`.
   * @param {"hover"|"control"|"drag"|"view"|"configure"|"HUD"|"create"|"update"|"delete"} 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()}`] ?? this[`_can${action}`];
    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. Always equal to `game.user`.
   * @param {PIXI.FederatedEvent} [event]    The pointer event if this function was called by
   *                                         {@link foundry.canvas.interaction.MouseInteractionManager}.
   * @returns {boolean}
   * @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. Always equal to `game.user`.
   * @param {PIXI.FederatedEvent} [event]    The pointer event if this function was called by
   *                                         {@link foundry.canvas.interaction.MouseInteractionManager}.
   * @returns {boolean}
   * @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. Always equal to `game.user`.
   * @param {PIXI.FederatedEvent} [event]    The pointer event if this function was called by
   *                                         {@link foundry.canvas.interaction.MouseInteractionManager}.
   * @returns {boolean}
   * @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. Always equal to `game.user`.
   * @param {PIXI.FederatedEvent} [event]    The pointer event if this function was called by
   *                                         {@link foundry.canvas.interaction.MouseInteractionManager}.
   * @returns {boolean}
   * @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. Always equal to `game.user`.
   * @param {PIXI.FederatedEvent} [event]    The pointer event if this function was called by
   *                                         {@link foundry.canvas.interaction.MouseInteractionManager}.
   * @returns {boolean}
   * @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. Always equal to `game.user`.
   * @param {PIXI.FederatedEvent} [event]    The pointer event if this function was called by
   *                                         {@link foundry.canvas.interaction.MouseInteractionManager}.
   * @returns {boolean}
   * @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. Always equal to `game.user`.
   * @param {PIXI.FederatedEvent} event      The pointer event
   * @param {{notify: boolean}} [options]    Options, used internally
   * @returns {boolean}
   * @protected
   */
  _canDragLeftStart(user, event, {notify=true}={}) {
    if ( game.paused && !game.user.isGM ) {
      if ( notify ) ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return false;
    }
    if ( this.document.schema.has("locked") && this.document.locked ) {
      if ( notify ) ui.notifications.warn("CONTROLS.ObjectIsLocked",
        {format: {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. Always equal to `game.user`.
   * @param {PIXI.FederatedEvent} [event]    The pointer event if this function was called by
   *                                         {@link foundry.canvas.interaction.MouseInteractionManager}.
   * @returns {boolean}
   * @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. Always equal to `game.user`.
   * @param {PIXI.FederatedEvent} [event]    The pointer event if this function was called by
   *                                         {@link foundry.canvas.interaction.MouseInteractionManager}.
   * @returns {boolean}
   * @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. Always equal to `game.user`.
   * @param {PIXI.FederatedEvent} [event]    The pointer event if this function was called by
   *                                         {@link foundry.canvas.interaction.MouseInteractionManager}.
   * @returns {boolean}
   * @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.
   * @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
   * @returns {boolean|void}
   * @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$1.callAll(`hover${this.constructor.embeddedName}`, this, this.hover);
  }

  /* -------------------------------------------- */

  /**
   * Actions that should be taken for this Placeable Object when a mouseout event occurs
   * @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$1.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
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @returns {boolean|void}
   * @protected
   */
  _onClickLeft(event) {
    this.layer.hud?.close();

    // 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
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _onClickLeft2(event) {
    this.sheet.render({force: 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
   * @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.close();
      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
   * @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.
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @returns {boolean|void}             If false, the start if prevented
   */
  #onDragLeftStart(event) {
    const allowed = this._onDragLeftStart(event);
    if ( allowed === false ) return false;
    this._initializeDragLeft(event);
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur when a mouse-drag action is first begun.
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @returns {boolean|void}             If false, the start if prevented
   * @protected
   */
  _onDragLeftStart(event) {}

  /* -------------------------------------------- */

  /**
   * Initialize the left-drag operation.
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _initializeDragLeft(event) {
    const objects = this.layer.options.controllableObjects ? this.layer.controlled : [this];
    const clones = [];
    for ( const o of objects ) {
      if ( !o._canDrag(game.user, event) || !o._canDragLeftStart(game.user, event, {notify: false})) 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.
   * @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.
   * @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
    let result = this._prepareDragLeftDropUpdates(event);
    if ( !result ) return;
    if ( !Array.isArray(result[0]) ) result = [result];
    // noinspection ES6MissingAwait
    this.#commitDragLeftDropUpdates(...result);
  }

  /* -------------------------------------------- */

  /**
   * 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 {[updates: object[], options?: object]|object[]|null}
   *   An array of database updates to perform for documents in this collection
   * @protected
   */
  _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
   * @param {object} [options]      Additional update options
   * @returns {Promise<void>}
   */
  async #commitDragLeftDropUpdates(updates, options) {
    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, options);
    this.layer.clearPreviewContainer();
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a mouse-move operation.
   * @param {PIXI.FederatedEvent} event  The triggering mouse click event
   * @returns {boolean|void}             If false, the cancellation is prevented
   */
  #onDragLeftCancel(event) {
    const allowed = this._onDragLeftCancel(event);
    if ( allowed === false ) return false;
    this._finalizeDragLeft(event);
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a mouse-move operation.
   * @param {PIXI.FederatedEvent} event  The triggering mouse click event
   * @returns {boolean|void}             If false, the cancellation is prevented
   * @protected
   */
  _onDragLeftCancel(event) {}

  /* -------------------------------------------- */

  /**
   * Finalize the left-drag operation.
   * @param {PIXI.FederatedEvent} event  The triggering mouse click event
   * @protected
   */
  _finalizeDragLeft(event) {
    if ( event.interactionData.clearPreviewContainer !== false ) {
      this.layer.clearPreviewContainer();
    }
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a right mouse-drag operation.
   * @param {PIXI.FederatedEvent} event  The triggering mouse click event
   * @returns {false|void} If false, the start if prevented
   * @protected
   */
  #onDragRightStart(event) {
    const allowed = this._onDragRightStart(event);
    if ( allowed === false ) return false;
    this._initializeDragRight(event);

  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a right mouse-drag operation.
   * @param {PIXI.FederatedEvent} event  The triggering mouse click event
   * @returns {false|void} If false, the start if prevented
   * @protected
   */
  _onDragRightStart(event) {
    return canvas._onDragRightStart(event);
  }

  /* -------------------------------------------- */

  /**
   * Initialize the right-drag operation.
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _initializeDragRight(event) {}

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a right mouse-drag operation.
   * @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.
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _onDragRightDrop(event) {
    return canvas._onDragRightDrop(event);
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a right mouse-drag operation.
   * @param {PIXI.FederatedEvent} event  The triggering mouse click event
   * @returns {boolean|void}             If false, the cancellation is prevented
   */
  #onDragRightCancel(event) {
    const allowed = this._onDragRightCancel(event);
    if ( allowed === false ) return false;
    this._finalizeDragRight(event);
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a right mouse-drag operation.
   * @param {PIXI.FederatedEvent} event  The triggering mouse click event
   * @returns {boolean|void}             If false, the cancellation is prevented
   * @protected
   */
  _onDragRightCancel(event) {
    return canvas._onDragRightCancel(event);
  }

  /* -------------------------------------------- */

  /**
   * Finalize the right-drag operation.
   * @param {PIXI.FederatedEvent} event  The triggering mouse click event
   * @protected
   */
  _finalizeDragRight(event) {}

  /* -------------------------------------------- */

  /**
   * Callback action which occurs on a long press.
   * @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);
  }
}

/**
 * @import {Rectangle} from "../_types.mjs";
 */

/**
 * A class based on PIXI.Graphics, that allows to create a resize handle in the desired area.
 * @extends PIXI.Graphics
 */
class ResizeHandle extends PIXI.smooth.SmoothGraphics {
  /**
   * @param {number[]} offset        A two-element array [xFactor, yFactor] which defines the normalized
   *                                 position of this handle relative to the bounding box.
   * @param {object} [handlers={}]   An object of optional handler functions.
   * @param {Function} [handlers.canDrag] A function determining if this handle can initiate a drag.
   */
  constructor(offset, handlers={}) {
    super();
    this.offset = offset;
    this.handlers = handlers;
    const s = canvas.dimensions.uiScale;
    this.lineStyle(4 * s, 0x000000, 1.0).beginFill(0xFF9829, 1.0).drawCircle(0, 0, 10 * s).endFill();
    this.cursor = "pointer";
  }

  /* -------------------------------------------- */

  /**
   * Track whether the handle is being actively used for a drag workflow
   * @type {boolean}
   */
  active = false;

  /* -------------------------------------------- */

  /**
   * Refresh the position and hit area of this handle based on the provided bounding box.
   * @param {Rectangle} bounds           The bounding box in which this handle operates.
   */
  refresh(bounds) {
    const s = canvas.dimensions.uiScale;
    this.position.set(bounds.x + (bounds.width * this.offset[0]), bounds.y + (bounds.height * this.offset[1]));
    this.hitArea = new PIXI.Rectangle(-16 * s, -16 * s, 32 * s, 32 * s); // Make the handle easier to grab
  }

  /* -------------------------------------------- */

  /**
   * Compute updated dimensions for an object being resized, respecting optional constraints.
   * @param {Rectangle} current                  The current geometric state of the object
   * @param {Rectangle} origin                   The original position and dimensions used for reference
   * @param {object} destination                 The mouse (or pointer) destination coordinates.
   * @param {number} destination.x               The x-coordinate where the pointer was released.
   * @param {number} destination.y               The y-coordinate where the pointer was released.
   * @param {object} [options={}]                Additional options.
   * @param {number|null} [options.aspectRatio]  If provided, a numeric aspect ratio to maintain (width/height).
   * @returns {object} An object containing the adjusted {x, y, width, height}.
   */
  updateDimensions(current, origin, destination, {aspectRatio=null}={}) {
    const s = canvas.dimensions.uiScale;

    // 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 * s);
    let height = Math.max(origin.height + dy, 24 * s);

    // 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                               */
  /* -------------------------------------------- */

  /**
   * Activate listeners for pointer events, enabling hover and mouse-down behavior on the resize handle.
   */
  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) {
    if ( event.nativeEvent && (event.nativeEvent.target.id !== canvas.app.view.id) ) return;
    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) {
    if ( event.nativeEvent && (event.nativeEvent.target.id !== canvas.app.view.id) ) return;
    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;
  }
}

/**
 * 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 ( const [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;
  }
}

/**
 * @import {QuadtreeObject} from "../_types.mjs"
 */

/**
 * A Quadtree implementation that supports collision detection for rectangles.
 *
 * @param {PIXI.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}={}) {
    /**
     * Bounding rectangle of the quadtree.
     * @type {PIXI.Rectangle|{x: number, y: number, width: number, height: number}}
     * @protected
     */
    this._bounds = new PIXI.Rectangle(bounds.x ?? 0, bounds.y ?? 0, bounds.width ?? 0, bounds.height ?? 0);

    /**
     * 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};

  /* -------------------------------------------- */
  /*  Getters/Setters                             */
  /* -------------------------------------------- */

  /**
   * The bounding rectangle of the region
   * @type {PIXI.Rectangle}
   */
  get bounds() {
    return this._bounds;
  }
  set bounds(newBounds) {
    // Replace our internal reference with a clone to avoid side effects
    this._bounds = newBounds.clone();
    // Rebuild Quadtree
    const all = this.all;
    this.clear();
    for ( const obj of all ) this.insert(obj);
  }

  /* -------------------------------------------- */

  /**
   * The width of the bounding rectangle
   * @type {number}
   */
  get width() {
    return this._bounds.width;
  }
  set width(w) {
    if ( w === this._bounds.width ) return;
    this.setDimensions(w, this._bounds.height);
  }

  /* -------------------------------------------- */

  /**
   * The height of the bounding rectangle
   * @type {number}
   */
  get height() {
    return this._bounds.height;
  }
  set height(h) {
    if ( h === this._bounds.height ) return;
    this.setDimensions(this._bounds.width, h);
  }

  /* -------------------------------------------- */

  /**
   * The x-coordinate of the bounding rectangle
   * @type {number}
   */
  get x() {
    return this._bounds.x;
  }
  set x(x) {
    if ( x === this._bounds.x ) return;
    this.setPosition(x, this._bounds.y);
  }

  /* -------------------------------------------- */

  /**
   * The y-coordinate of the bounding rectangle
   * @type {number}
   */
  get y() {
    return this._bounds.y;
  }
  set y(y) {
    if ( y === this._bounds.y ) return;
    this.setPosition(this._bounds.x, y);
  }

  /* -------------------------------------------- */

  /**
   * Return an array of all the objects in the Quadtree (recursive)
   * @returns {QuadtreeObject[]}
   */
  get all() {
    if ( this.nodes.length ) {
      return this.nodes.flatMap(n => n.all);
    }
    return this.objects;
  }

  /* -------------------------------------------- */
  /*  Bounds Management                           */
  /* -------------------------------------------- */

  /**
   * Re-position the bounding rectangle of this Quadtree, clear existing data, and re-insert all objects.
   * Useful if the Quadtree needs to move.
   * @param {number} x            The new x-coordinate of the bounding rectangle
   * @param {number} y            The new y-coordinate of the bounding rectangle
   * @returns {Quadtree}          This Quadtree for method chaining
   */
  setPosition(x, y) {
    this._bounds.x = x;
    this._bounds.y = y;
    const all = this.all;
    this.clear();
    for ( const obj of all ) this.insert(obj);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Re-dimension the bounding rectangle of this Quadtree, clear existing data, and re-insert all objects.
   * Useful if the underlying canvas or region is resized.
   * @param {number} width        The new width of the bounding rectangle
   * @param {number} height       The new height of the bounding rectangle
   * @returns {Quadtree}          This Quadtree for method chaining
   */
  setDimensions(width, height) {
    this._bounds.width = width;
    this._bounds.height = height;
    const all = this.all;
    this.clear();
    for ( const obj of all ) this.insert(obj);
    return this;
  }

  /* -------------------------------------------- */
  /*  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 ( const 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 ( const 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 ) {
      const 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 ( const 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 {PIXI.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 ( const n of nodes ) {
        n.getObjects(rect, {collisionTest, _s: objects});
      }
    }

    // Otherwise, retrieve from this node
    else {
      for ( const 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 {PIXI.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 {PIXI.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.
   */
  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 ( const 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 ( const 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 {
  /**
   * Create a CanvasQuadtree which references canvas.dimensions.rect.
   * We pass an empty object to the parent, then override _bounds.
   * @param {object} [options] Additional options passed to the parent Quadtree.
   */
  constructor(options={}) {
    super({}, options);

    // Define a dynamic property named _bounds which always returns canvas.dimensions.rect
    // If the canvas is not ready, return a zero rectangle to avoid errors.
    Object.defineProperty(this, "_bounds", {
      configurable: true,
      enumerable: true,
      get: () => {
        if ( canvas?.dimensions?.rect ) return canvas.dimensions.rect;
        return new PIXI.Rectangle(0, 0, 0, 0);
      },
      // Provide a setter to gracefully ignore or handle attempts to assign _bounds
      set: (newBounds) => {
        console.warn("CanvasQuadtree ignores direct assignment to _bounds; it always uses canvas.dimensions.rect.");
      }
    });
  }
}

/**
 * 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;

  /**
   * @typedef TextureCacheEntry
   * @property {string} src   The URL of the texture.
   * @property {number} time  The timestamp when the texture was last accessed.
   * @property {number} size  The approximate memory usage of the texture in bytes.
   */

  /**
   * @typedef {Map<PIXI.BaseTexture|PIXI.Spritesheet, TextureCacheEntry>} TextureCacheMap
   * A mapping from a BaseTexture or Spritesheet to its cache entry data.
   */

  /**
   * Record the timestamps and approximate memory usage when each asset path is retrieved from cache
   * @type {TextureCacheMap}
   */
  static #cacheTime = new Map();

  /**
   * A mapping of cached texture data
   * @type {WeakMap<PIXI.BaseTexture,Map<string, TextureAlphaData>>}
   */
  static #textureDataMap = new WeakMap();

  /**
   * To know if the basis transcoder has been initialized
   * @type {boolean}
   */
  static #basisTranscoderInitialized = false;

  /**
   * A helper dictionary to define approximate memory limits based on canvas.performance.mode.
   * The limit is in bytes. Each entry is reduced by 15% to give a higher safety margin.
   * @type {number[]}
   */
  static #MEMORY_LIMITS = [
    2 * 1024 * 1024 * 1024 * 0.85,  // ~1.7 GB (LOW)
    4 * 1024 * 1024 * 1024 * 0.85,  // ~3.4 GB (MED)
    8 * 1024 * 1024 * 1024 * 0.85,  // ~6.8 GB (HIGH)
    12 * 1024 * 1024 * 1024 * 0.85  // ~10.2 GB (MAX)
  ];

  /**
   * A set of pinned source URLs that must never be evicted.
   * @type {Set<string>}
   */
  static #pinnedSources = new Set();

  /* -------------------------------------------- */

  /**
   * Initialize the basis/ktx2 transcoder for PIXI.Assets
   *
   * @license
   * PixiBasisKTX2 is a IIFE bundle created from pixi-basis-ktx2 package source by Kristof Van Der Haeghen
   *
   * The MIT License
   * Copyright (c) 2022-2025 Kristof Van Der Haeghen
   *
   * Permission is hereby granted, free of charge, to any person obtaining a copy
   * of this software and associated documentation files (the "Software"), to deal
   * in the Software without restriction, including without limitation the rights
   * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
   * copies of the Software, and to permit persons to whom the Software is
   * furnished to do so, subject to the following conditions:
   *
   * The above copyright notice and this permission notice shall be included in
   * all copies or substantial portions of the Software.
   *
   * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
   * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
   * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
   * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
   * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
   * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
   * THE SOFTWARE.
   * Available here: https://github.com/Sparcks/pixi-basis-ktx2
   *
   * -----------------------------------------------
   *
   * The transcoder files are from Binomial LLC under Apache License Version 2.0, January 2004
   * Available here: https://github.com/BinomialLLC/basis_universal/tree/master/webgl/transcoder/build
   *
   * @returns {Promise<*>}
   */
  static async initializeBasisTranscoder() {
    if ( this.#basisTranscoderInitialized ) return;

    this.#basisTranscoderInitialized = true;

    const transcoderJSPath = "scripts/ktx2/basis_transcoder.js";
    const transcoderWASMPath = "scripts/ktx2/basis_transcoder.wasm";

    // Update PIXI.Assets with KTX2 and improved BASIS support
    PIXI.Assets.loader.parsers.push(PixiBasisKTX2.loadKTX2);
    PIXI.Assets.detections.push(PixiBasisKTX2.detectKTX2);
    PIXI.Assets.resolver.parsers.push(PixiBasisKTX2.resolveKTX2TextureUrl);
    PIXI.Assets.loader.parsers.push(PixiBasisKTX2.loadBasis);
    PIXI.Assets.detections.push(PixiBasisKTX2.detectBasis);

    // Loading the transcoders
    return await Promise.all([
      PIXI.TranscoderWorker.loadTranscoder(transcoderJSPath, transcoderWASMPath),
      PixiBasisKTX2.KTX2Parser.loadTranscoder(transcoderJSPath, transcoderWASMPath),
      PixiBasisKTX2.BasisParser.loadTranscoder(transcoderJSPath, transcoderWASMPath)
    ]);
  }

  /* -------------------------------------------- */

  /**
   * 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) {
    return HAS_TEXT_REGEXP.test(src); // TODO @deprecated in v14
  }

  /* -------------------------------------------- */

  /**
   * @typedef 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 {string[]} [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 async 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;
    }, []));

    // Door Textures
    for ( const wall of scene.walls ) {
      if ( wall.animation?.texture ) toLoad.push(wall.animation.texture);
    }

    // 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
    if ( scene.isView ) {
      for ( const t of Object.values(canvas.sceneTextures) ) {
        if ( typeof t === "string" ) toLoad.push(t);
      }
    }

    // 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, {
      localize: true,
      message: "SCENE.Loading",
      format: {name: loadName},
      expireCache,
      maxConcurrent
    });
  }

  /* -------------------------------------------- */

  /**
   * Load an Array of provided source URL paths.
   * Paths which begin with a special character "#" are ignored as texture references.
   * @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.localize=false]   Whether to localize the message content before displaying it
   * @param {boolean} [options.escape=true]      Whether to escape the values of `format`
   * @param {boolean} [options.clean=true]       Whether to clean the provided message string as untrusted user input.
   *                                             No cleaning is applied if `format` is passed and `escape` is true or
   *                                             `localize` is true and `format` is not passed.
   * @param {string} [options.format]            A mapping of formatting strings passed to Localization#format
   * @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="", localize=false, escape=true, clean=true, format, expireCache=false, maxConcurrent,
    displayProgress=true}={}) {

    // De-dupe sources to load
    const toLoad = new Set();
    for ( const s of sources ) {
      if ( s.startsWith("#") ) continue;
      toLoad.add(s);
    }

    // Create progress tracking
    console.groupCollapsed(`${CONST.vtt} | Loading ${toLoad.size} Assets`);
    let progress;
    if ( displayProgress ) {
      const bar = ui.notifications.info(message, {localize, escape, clean, format, console: false, progress: true});
      progress = {message, loaded: 0, failed: 0, total: toLoad.size, pct: 0, bar};
    }

    // Load individual texture
    const loadTexture = async src => {
      try {
        await this.loadTexture(src);
        if ( progress ) TextureLoader.#onProgress(src, progress);
      }
      catch(err) {
        if ( progress ) TextureLoader.#onError(src, progress, err);
      }
    };

    // Load all sources
    const promises = [];
    if ( maxConcurrent ) {
      const semaphore = new foundry.utils.Semaphore(maxConcurrent);
      for ( const src of toLoad ) promises.push(semaphore.add(loadTexture, src));
    }
    else {
      for ( const src of toLoad ) promises.push(loadTexture(src));
    }
    await Promise.allSettled(promises);
    console.groupEnd();

    // If we have expireCache set...
    if ( expireCache ) {
      // ...do the classic TTL-based cleaning first...
      await this.expireCache({exclude: toLoad});
      // ...then possibly evict old textures according to memory limits
      await TextureLoader.#enforceMemoryLimit({exclude: toLoad});
    }
  }

  /* -------------------------------------------- */

  /**
   * 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 = foundry.utils.getCacheBustURL(src);
      if ( !src ) return null;
      if ( CONFIG.debug.loader.load ) console.debug(`Texture Cache: Attempting to load texture from ${src}`);
      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 ( CONFIG.debug.loader.load ) console.debug(`Texture Load: Successfully loaded texture from ${src}`);
    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}={}) {
    return foundry.utils.fetchResource(src, {bustCache}); // TODO @deprecated in v14
  }

  /* -------------------------------------------- */

  /**
   * 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
   */
  static #onProgress(src, progress) {
    progress.loaded++;
    progress.pct = (progress.loaded + progress.failed) / progress.total;
    progress.bar.update({pct: progress.pct});
    console.log(`Loaded ${src} (${(progress.pct * 100).toFixed(2)}%)`);
  }

  /* -------------------------------------------- */

  /**
   * Log failed texture loading
   * @param {string} src          The source URL being loaded
   * @param {object} progress     Loading progress
   * @param {Error} error         The error which occurred
   */
  static #onError(src, progress, error) {
    progress.failed++;
    progress.pct = (progress.loaded + progress.failed) / progress.total;
    progress.bar.update({pct: progress.pct});
    console.warn(`Loading failed for ${src} (${(progress.pct * 100).toFixed(2)}%): ${error.message}`);
  }

  /* -------------------------------------------- */
  /*  Cache Controls                              */
  /* -------------------------------------------- */

  /**
   * Add an image or a sprite sheet url to the assets cache. Include an approximate memory size in the stored data.
   * @param {string} src                                 The source URL.
   * @param {PIXI.BaseTexture|PIXI.Spritesheet} asset    The asset
   */
  setCache(src, asset) {
    const now = Date.now();
    let baseTex = (asset instanceof PIXI.Spritesheet) ? asset.baseTexture : asset;

    // Calculate approximate memory usage
    const size = TextureLoader.#approximateTextureSize(baseTex);
    if ( CONFIG.debug.loader.cache ) {
      const smb = size / (1024 * 1024);
      console.debug(`Texture Cache: Caching ${src} with approx size ${smb.toFixed(2)} MB`);
    }

    TextureLoader.#cacheTime.set(asset, {src, time:now, size});
  }

  /* -------------------------------------------- */

  /**
   * A helper to approximate the memory usage for a given baseTexture. We handle compressed textures (if supported),
   * and do a fallback for standard RGBA. Additionally, if canvas.performance.mipmap === "ON" for non-compressed textures,
   * we increase the usage by ~33% (which is another approximation)
   * @param {PIXI.BaseTexture} baseTex             The base texture to evaluate
   * @returns {number} Approximate usage in bytes
   */
  static #approximateTextureSize(baseTex) {
    // If it's a compressed texture resource, sum the level buffers if available
    const resource = baseTex.resource;
    if ( resource instanceof PIXI.CompressedTextureResource ) {
      // If we have levelBuffers, sum them directly
      const buffers = resource._levelBuffers;
      if ( buffers && buffers.length ) {
        let totalCompressed = 0;
        for ( const buf of buffers ) {
          totalCompressed += buf.levelBuffer.byteLength;
        }
        return totalCompressed;
      }
      // Fallback if we can't see buffers
      return TextureLoader.#fallbackCompressedSize(baseTex, 2);
    }

    // For non-compressed textures, we use w*h*4
    const w = baseTex.realWidth;
    const h = baseTex.realHeight;
    let size = w * h * 4;

    // If mipmap is ON for non-compressed textures, we assume ~33% extra memory for mip levels
    if ( canvas?.performance?.mipmap === "ON" ) size = Math.round(size * 1.33);
    return size;
  }

  /* -------------------------------------------- */

  /**
   * Fallback memory calculation for a compressed texture whose buffers aren't visible.
   * @param {PIXI.BaseTexture} baseTex
   * @param {number} [bytesPerPixel=2]
   * @returns {number}
   */
  static #fallbackCompressedSize(baseTex, bytesPerPixel=2) {
    const w = baseTex.realWidth;
    const h = baseTex.realHeight;
    return w * h * bytesPerPixel;
  }

  /* -------------------------------------------- */

  /**
   * 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 null
   */
  getCache(src) {
    if ( !src ) return null;
    if ( !PIXI.Assets.cache.has(src) ) src = foundry.utils.getCacheBustURL(src) || src;
    let asset = PIXI.Assets.get(src);
    if ( !asset?.baseTexture?.valid ) return null;
    if ( asset instanceof PIXI.Texture ) asset = asset.baseTexture;
    if ( CONFIG.debug.loader.cache ) {
      if ( !PIXI.Assets.cache.has(src) ) console.debug(`Texture Cache: ${src} not found in cache (or invalid)`);
      else console.debug(`Texture Cache: ${src} retrieved from cache`);
    }
    this.setCache(src, asset);
    return asset;
  }

  /* -------------------------------------------- */

  /**
   * Expire and unload assets from the cache which have not been used for more than CACHE_TTL milliseconds.
   * @param {object} [options={}]
   * @param {Set<string>} [options.exclude]   A set of source URLs to *skip* from eviction checks.
   */
  async expireCache({exclude}={}) {
    const promises = [];
    const t = Date.now();
    if ( exclude ) {
      const excludeWithCacheBustURLs = new Set(exclude);
      for ( const src of exclude ) excludeWithCacheBustURLs.add(foundry.utils.getCacheBustURL(src));
      exclude = excludeWithCacheBustURLs;
    }
    for ( const [asset, {src, time}] of TextureLoader.#cacheTime.entries() ) {
      // Skip pinned or excluded source URLs
      if ( TextureLoader.#pinnedSources.has(src) || exclude?.has(src) ) {
        if ( CONFIG.debug.loader.cache ) console.debug(`Texture Cache: Exclude cached texture: ${src} from expiration`);
        continue;
      }

      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(`${CONST.vtt} | Expiring cached texture: ${src}`);
      promises.push(PIXI.Assets.unload(src));
      TextureLoader.#cacheTime.delete(asset);

      if ( CONFIG.debug.loader.cache ) {
        console.debug(`Texture Cache: Expiring cached texture: ${src} (unused for ${t - time}ms)`);
      }
    }
    await Promise.allSettled(promises);

    if ( CONFIG.debug.loader.memory || CONFIG.debug.loader.cache ) {
      const newTotalMB = (TextureLoader.approximateTotalMemoryUsage / (1024 * 1024)).toFixed(2);
      console.debug(
        `Memory Debug: TTL-based eviction done. Approximate memory usage now: ${newTotalMB} MB`
      );
    }
  }

  /* -------------------------------------------- */

  /**
   * 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) {
    return foundry.utils.getCacheBustURL(src); // TODO @deprecated in v14
  }

  /* -------------------------------------------- */
  /*  Memory Enforcement Logic                    */
  /* -------------------------------------------- */

  /**
   * We evict assets sorted by size descending, until we drop below the memory limit.
   * @param {object} [options={}]
   * @param {Set<string>} [options.exclude]   A set of source URLs to skip from eviction checks.
   */
  static async #enforceMemoryLimit({exclude}={}) {
    const limit = this.#getMemoryLimit();
    let total = this.#computeTotalMemory();

    if ( CONFIG.debug.loader.memory ) {
      const totalMB = (total / (1024 * 1024)).toFixed(2);
      const limitMB = (limit / (1024 * 1024)).toFixed(2);
      console.debug(
        `Memory Debug: Enforcing memory limit. ` +
        `Current usage: ${totalMB} MB, limit: ${limitMB} MB.`
      );
    }

    if ( total <= limit ) return;

    // Convert bytes to MB and GB
    const totalMB = total / (1024 * 1024);
    const totalGB = totalMB / 1024;
    const limitMB = limit / (1024 * 1024);
    const limitGB = limitMB / 1024;

    console.log(`${CONST.vtt} | Total estimated GPU memory usage ${totalMB.toFixed(1)} MB (~${totalGB.toFixed(
      2)} GB) ` + `exceeds limit ${limitMB.toFixed(1)} MB (~${limitGB.toFixed(2)} GB). Evicting old assets...`);

    // Sort assets by ascending time
    const entries = [...TextureLoader.#cacheTime.entries()].sort(([, a], [, b]) => a.time - b.time);

    if ( exclude ) {
      const excludeWithCacheBustURLs = new Set(exclude);
      for ( const src of exclude ) excludeWithCacheBustURLs.add(foundry.utils.getCacheBustURL(src));
      exclude = excludeWithCacheBustURLs;
    }

    const evictPromises = [];
    for ( const [asset, data] of entries ) {
      if ( total <= limit ) break;
      if ( this.#pinnedSources.has(data.src) || exclude?.has(data.src) ) {
        if ( CONFIG.debug.loader.eviction ) console.debug(`Texture Eviction: Exclude texture: ${data.src} from eviction`);
        continue;
      }

      try {
        console.log(`${CONST.vtt} | Evicting cached texture: ${data.src}`);
        evictPromises.push(PIXI.Assets.unload(data.src));
        TextureLoader.#cacheTime.delete(asset);
        total -= data.size;
      }
      catch(err) {
        console.warn(`Failed to unload old asset ${data.src}:`, err);
      }
    }
    await Promise.allSettled(evictPromises);

    if ( CONFIG.debug.loader.memory || CONFIG.debug.loader.eviction ) {
      const nau = (TextureLoader.#computeTotalMemory() / (1024 * 1024)).toFixed(2);
      console.debug(`Texture Eviction: Eviction complete. New approximate usage: ${nau} MB.`);
    }
  }

  /* -------------------------------------------- */

  /**
   * Compute total approximate memory usage for all currently cached assets.
   * @returns {number}  The total approximate usage in bytes.
   */
  static #computeTotalMemory() {
    let sum = 0;
    for ( const [asset, {size}] of this.#cacheTime.entries() ) {
      const baseTexture = (asset instanceof PIXI.Spritesheet) ? asset.baseTexture : asset;
      if ( !baseTexture?.destroyed ) sum += size || 0;
    }
    return sum;
  }

  /* -------------------------------------------- */

  /**
   * A public getter to expose the total approximate memory usage.
   * @returns {number}   The total usage in bytes.
   */
  static get approximateTotalMemoryUsage() {
    return this.#computeTotalMemory();
  }

  /* -------------------------------------------- */

  /**
   * Determine the memory limit in bytes based on canvas.performance.mode.
   * Defaults to HIGH if the mode is out of range or missing.
   * @returns {number}
   */
  static #getMemoryLimit() {
    const modes = CONST.CANVAS_PERFORMANCE_MODES;
    const defaultMode = modes.HIGH;   // fallback = 2 => 8GB * 0.85 => ~6.8 GB
    let mode = canvas?.performance?.mode ?? defaultMode;

    // Clamp the mode to a valid index [0..3]
    if ( !mode.between(modes.LOW, modes.MAX) ) mode = defaultMode;
    return this.#MEMORY_LIMITS[mode];
  }

  /* -------------------------------------------- */
  /*  Pinning Logic                               */
  /* -------------------------------------------- */

  /**
   * Pin a source URL so it cannot be evicted.
   * @param {string} src   The source URL to pin
   */
  static pinSource(src) {
    this.#pinnedSources.add(src);
    this.#pinnedSources.add(foundry.utils.getCacheBustURL(src));
  }

  /* -------------------------------------------- */

  /**
   * Unpin a source URL that was previously pinned.
   * @param {string} src   The source URL to unpin
   */
  static unpinSource(src) {
    this.#pinnedSources.delete(src);
    this.#pinnedSources.delete(foundry.utils.getCacheBustURL(src));
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @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.srcExists(src); // TODO @deprecated in v14
}

/* -------------------------------------------- */

/**
 * Get a single texture or sprite sheet from the cache.
 * @param {string} src                            The texture path to load.
 *                                                This may be a standard texture path or a "virtual texture" beginning
 *                                                with the "#" character that is retrieved from canvas.sceneTextures.
 * @returns {PIXI.Texture|PIXI.Spritesheet|null}  A texture, a sprite sheet or null if not found in cache.
 */
function getTexture(src) {
  if ( !src ) return null;

  // Virtual textures referenced from the sceneTextures record
  if ( src[0] === "#" ) {
    const texturePath = src.slice(1);
    const vt = canvas.sceneTextures[texturePath];
    return vt instanceof PIXI.Texture ? vt : null;
  }

  // Standard file paths retrieved from the TextureLoader cache
  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 texture source.
 *                                               This may be a standard texture path or a "virtual texture" beginning
 *                                               with the "#" character that is retrieved from canvas.sceneTextures.
 * @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 = src ? getTexture(src) : null;
  if ( asset?.valid ) return asset;
  let error;
  if ( !src ) error = new Error("Requested texture path is empty.");
  else if ( src[0] === "#" ) error = new Error(`Requested texture path "${src}" has not been loaded and registered.`);
  else {
    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 ( src && HAS_TEXT_REGEXP.test(src) ) return null; // No fallback for spritesheets
    return fallback ? loadTexture(fallback) : null;
  }
  if ( asset instanceof PIXI.Spritesheet ) return asset;
  return new PIXI.Texture(asset);
}

/* -------------------------------------------- */

/**
 * RegExp testing text file extensions
 * @type {RegExp}
 */
const HAS_TEXT_REGEXP = new RegExp(`(\\.${Object.keys(TEXT_FILE_EXTENSIONS).join("|\\.")})(\\?.*)?`, "i");

/**
 * @import SpriteMesh from "../elements/sprite-mesh.mjs";
 */

/**
 * 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, mipmap: PIXI.MIPMAP_MODES}}
   * @abstract
   */
  static textureConfiguration = {};

  /**
   * A bound resize function which fires on the renderer resize event.
   * @type {function(PIXI.Renderer)}
   */
  #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}}
   */
  #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;
  }

  #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 foundry.canvas.containers.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.");
    }
  }

  #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 {this}         A reference to the cleared container for chaining.
   */
  clear(destroy=true) {
    foundry.canvas.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.
   */
  #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.
   */
  #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.
   */
  #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);
  }
}

/**
 * A mixin which decorates any container with base canvas common properties.
 * @category Mixins
 * @param {typeof Container} ContainerClass  The parent Container class being mixed.
 */
function 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|undefined}
     * @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 ( const [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(`${CONST.vtt} | Drawing the ${this.hookName} canvas group`);
        await this.tearDown();
        await this._draw(options);
        Hooks$1.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$1.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 foundry.canvas.containers.CanvasGroupMixin";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return CanvasGroupMixin;
  }
});

/**
 * 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);
  }
}

/**
 * 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 vertex 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;

  /**
   * Returns default uniforms associated with the batched version of this sampler.
   * @type {object|((maxTextures: number) => 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;
  }
}

/**
 * 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
   */
  _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}
   * @internal
   */
  _transformID = -1;

  /**
   * The transform ID.
   * @type {number}
   * @internal
   */
  _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. A null value means that this SpriteMesh has no associated
   * plugin.
   * @type {string|null}
   */
  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 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 {PIXI.ImageSource|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);
  }

  /* ---------------------------------------- */

  /**
   *
   * Check to see if a point is contained within this SpriteMesh Quad.
   * @param {PIXI.Point} point          Point to check if it's contained.
   * @returns {boolean} `true` if the point is contained within geometry.
   */
  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 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);
  }
}

/**
 * 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
 */
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) && !(parent instanceof foundry.canvas.primary.PrimaryCanvasContainer) ) {
        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 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.DisplayObject} DisplayObject   The parent DisplayObject class being mixed
 */
function CanvasTransformMixin(DisplayObject) {
  class CanvasTransform 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;
    }
  }
  return CanvasTransform;
}

/**
 * @import {CanvasAnimationAttribute} from "./_types.mjs"
 * @import {CanvasAnimationOptions} from "./_types.mjs"
 * @import {CanvasAnimationData} from "./_types.mjs"
 */

/**
 * 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|symbol, 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
   *   }
   * ];
   * foundry.canvas.animation.CanvasAnimation.animate(attributes, {duration:500});
   * ```
   */
  static animate(attributes, {context=canvas.stage, name, time=0, 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);
    name ||= Symbol("CanvasAnimation");

    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;
    });
    const animation = {attributes, context, duration, easing, name, ontick, time: 0, wait,
      state: CanvasAnimation.STATES.WAITING};
    animation.fn = () => {
      const {lastTime, speed} = this.ticker;
      const elapsedMS = (performance.now() - lastTime) * speed;
      CanvasAnimation.#animateFrame(elapsedMS, 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;
          CanvasAnimation.#animateFrame(time * this.ticker.speed, animation);
          if ( animation.state === CanvasAnimation.STATES.RUNNING ) {
            await Promise.resolve(); // Don't add listener within the ticker's tick
            if ( 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 ( this.animations[name] === animation ) delete this.animations[name];
      });

    animation.promise = promise;
    this.animations[name] = animation;
    return promise;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve an animation currently in progress by its name
   * @param {string|symbol} 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 | symbol} name      The animation name to terminate
   */
  static terminateAnimation(name) {
    this.animations[name]?.resolve(false);
  }

  /* -------------------------------------------- */

  /**
   * Terminate all active animations in progress, forcibly resolving each one with `false`.
   * This method returns a Promise that resolves once all animations have been terminated and removed.
   * @returns {Promise<void>} A promise that resolves when all animations have been forcibly terminated.
   */
  static async terminateAll() {
    const promises = [];
    for ( const key of Reflect.ownKeys(this.animations) ) {
      const animation = this.animations[key];
      if ( animation?.promise ) promises.push(animation.promise);
      animation?.resolve(false);
    }
    await Promise.allSettled(promises);
  }

  /* -------------------------------------------- */

  /**
   * 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} elapsedMS                The incremental time in MS which has elapsed (uncapped)
   * @param {CanvasAnimationData} animation   The animation which is being performed
   */
  static #animateFrame(elapsedMS, animation) {
    const {attributes, duration, ontick} = animation;

    // Compute animation timing and progress
    animation.time += elapsedMS;                         // 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 ( const a of attributes ) CanvasAnimation.#updateAttribute(a, pa);
      if ( ontick ) ontick(elapsedMS, 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 mixin which decorates a DisplayObject with depth and/or occlusion properties.
 * @category Mixins
 * @param {typeof PIXI.DisplayObject} DisplayObject   The parent DisplayObject class being mixed
 */
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 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 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,
      _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}#restrictsLight and ${this.constructor.name}#restrictsWeather`;
      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}#restrictsLight and ${this.constructor.name}#restrictsWeather`;
      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);
    }
  }
  return PrimaryOccludableObject;
}

/**
 * @import {OccludableBatchData} from "../_types.mjs"
 */

/**
 * 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;
  }
}

/**
 * @import {DepthBatchData} from "../_types.mjs"
 */

/**
 * 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 = 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 base shader class of {@link foundry.canvas.primary.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) {}
}

/**
 * @typedef PrimarySpriteMeshConstructorOptions
 * @property {PIXI.Texture} [options.texture]                 Texture passed to the SpriteMesh.
 * @property {string|null} [options.name]                     The name of this sprite.
 * @property {*} [options.object]                             Any object that owns this sprite.
 * @param {typeof PrimaryBaseSamplerShader} [options.shaderClass] The shader class used to render this sprite
 */

/**
 * 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.
 */
class PrimarySpriteMesh extends PrimaryOccludableObjectMixin(SpriteMesh) {
  /**
   * @param {PrimarySpriteMeshConstructorOptions|PIXI.Texture} [options]    Constructor options or a Texture
   * @param {typeof PrimaryBaseSamplerShader} shaderClass                   A shader class for the sprite
   */
  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 !== PIXI.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;
  }
}

/**
 * A basic PCO which is handling drawings of any shape.
 * @extends {PIXI.smooth.SmoothGraphics}
 * @mixes PrimaryCanvasObject
 *
 * @param {object} [options]                               A config object
 * @param {PIXI.smooth.SmoothGraphicsGeometry} [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.smooth.SmoothGraphics) {
  constructor(options) {
    let geometry;
    if ( options instanceof PIXI.smooth.SmoothGraphicsGeometry ) {
      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);
  }
}

/**
 * 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});
  }
}

/**
 * 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);
  }
}

/**
 * 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
  };
}

/**
 * @import {PerceptionManagerFlags} from "../_types.mjs"
 */

/**
 * 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 foundry.canvas.Canvas#perception}.
 */
class PerceptionManager extends RenderFlagsMixin() {

  /** @override */
  static RENDER_FLAGS = {

    // Edges
    refreshEdges: {},

    // Light and Darkness Sources
    initializeLighting: {propagate: ["initializeLightSources"]},
    initializeLightSources: {propagate: ["refreshLighting", "refreshVision", "refreshEdges"]},
    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 flag is now obsolete and has no replacement.",
        since: 12, until: 14
      },
      alias: true
    },
    /** @deprecated since v13 */
    initializeDarknessSources: {
      propagate: ["initializeLightSources"],
      deprecated: {
        message: "The initializeDarknessSources flag is now obsolete. initializeLightSources flag must be used instead.",
        since: 13, until: 15
      }
    }
  };

  static #deprecatedFlags = ["refreshTiles", "identifyInteriorWalls"];

  /** @override */
  static RENDER_FLAG_PRIORITY = "PERCEPTION";

  /* -------------------------------------------- */

  /** @override */
  applyRenderFlags() {
    if ( !this.renderFlags.size ) return;
    const flags = this.renderFlags.clear();

    // Initialize sources with edges
    if ( flags.initializeLightSources ) canvas.effects.initializePriorityLightSources();

    // 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
    });
  }
}

/**
 * @import {PlaceableObject} from "@client/canvas/placeables/_module.mjs";
 * @import {WallThresholdData} from "@common/documents/_types.mjs";
 * @import {Point} from "@common/_types.mjs";
 * @import {WallDirection, WallRestrictionType, WallSenseType} from "@common/constants.mjs";
 * @import {LineIntersection} from "@common/utils/_types.mjs";
 * @import PolygonVertex from "./vertex.mjs";
 * @import {EdgeType} from "../_types.mjs";
 */

/**
 * A data structure used to represent potential edges used by the ClockwiseSweepPolygon.
 * Edges are not polygon-specific, meaning they can be reused across many polygon instances.
 */
class Edge {
  /**
   * Construct an Edge by providing the following information.
   * @param {Point} a                     The first endpoint of the edge
   * @param {Point} b                     The second endpoint of the edge
   * @param {object} [options]            Additional options which describe the edge
   * @param {string} [options.id]                 A string used to uniquely identify this edge
   * @param {PlaceableObject} [options.object]    A PlaceableObject that is responsible for this edge, if any
   * @param {EdgeType} [options.type]             The type of edge
   * @param {WallSenseType} [options.light]       How this edge restricts light
   * @param {WallSenseType} [options.move]        How this edge restricts movement
   * @param {WallSenseType} [options.sight]       How this edge restricts sight
   * @param {WallSenseType} [options.sound]       How this edge restricts sound
   * @param {WallDirection} [options.direction=0] A direction of effect for the edge
   * @param {WallThresholdData} [options.threshold] Configuration of threshold data for this edge
   * @param {number} [options.priority=0] A source priority for this edge. Typically zero unless this edge was
   *                                      contributed by a high-priority source.
   */
  constructor(a, b, {id, object, direction, type, light, move, sight, sound, threshold, priority}={}) {
    this.a = new PIXI.Point(a.x, a.y);
    this.b = new PIXI.Point(b.x, b.y);
    this.id = id ?? object?.id ?? undefined;
    this.object = object;
    this.type = type || "wall";
    this.direction = direction ?? CONST.WALL_DIRECTIONS.BOTH;
    this.light = light ?? CONST.WALL_SENSE_TYPES.NONE;
    this.move = move ?? CONST.WALL_SENSE_TYPES.NONE;
    this.sight = sight ?? CONST.WALL_SENSE_TYPES.NONE;
    this.sound = sound ?? CONST.WALL_SENSE_TYPES.NONE;
    this.threshold = threshold;
    this.priority = priority ?? 0;

    // Record the edge orientation arranged from top-left to bottom-right
    const isSE = b.x === a.x ? b.y > a.y : b.x > a.x;
    if ( isSE ) {
      this.nw = a;
      this.se = b;
    }
    else {
      this.nw = b;
      this.se = a;
    }
    this.bounds = new PIXI.Rectangle(this.nw.x, this.nw.y, this.se.x - this.nw.x, this.se.y - this.nw.y);
  }

  /* -------------------------------------------- */

  /**
   * The first endpoint of the edge.
   * @type {PIXI.Point}
   */
  a;

  /**
   * The second endpoint of the edge.
   * @type {PIXI.Point}
   */
  b;

  /**
   * The endpoint of the edge which is oriented towards the top-left.
   */
  nw;

  /**
   * The endpoint of the edge which is oriented towards the bottom-right.
   */
  se;

  /**
   * The rectangular bounds of the edge. Used by the quadtree.
   * @type {PIXI.Rectangle}
   */
  bounds;

  /**
   * The direction of effect for the edge.
   * @type {WallDirection}
   */
  direction;

  /**
   * A string used to uniquely identify this edge.
   * @type {string}
   */
  id;

  /**
   * How this edge restricts light.
   * @type {WallSenseType}
   */
  light;

  /**
   * How this edge restricts movement.
   * @type {WallSenseType}
   */
  move;

  /**
   * How this edge restricts sight.
   * @type {WallSenseType}
   */
  sight;

  /**
   * How this edge restricts sound.
   * @type {WallSenseType}
   */
  sound;

  /**
   * Specialized threshold data for this edge.
   * @type {WallThresholdData}
   */
  threshold;

  /**
   * Record other edges which this one intersects with.
   * @type {{edge: Edge, intersection: LineIntersection}[]}
   */
  intersections = [];

  /**
   * A PolygonVertex instance.
   * Used as part of ClockwiseSweepPolygon computation.
   * @type {PolygonVertex}
   */
  vertexA;

  /**
   * A PolygonVertex instance.
   * Used as part of ClockwiseSweepPolygon computation.
   * @type {PolygonVertex}
   */
  vertexB;

  /* -------------------------------------------- */

  /**
   * Is this edge limited for a particular type?
   * @param {WallRestrictionType} type
   * @returns {boolean}
   */
  isLimited(type) {
    return this[type] === CONST.WALL_SENSE_TYPES.LIMITED;
  }

  /* -------------------------------------------- */

  /**
   * Create a copy of the Edge which can be safely mutated.
   * @returns {Edge}
   */
  clone() {
    const clone = new this.constructor(this.a, this.b, this);
    clone.intersections = [...this.intersections];
    clone.vertexA = this.vertexA;
    clone.vertexB = this.vertexB;
    return clone;
  }

  /* -------------------------------------------- */

  /**
   * Get an intersection point between this Edge and another.
   * @param {Edge} other
   * @returns {LineIntersection|void}
   */
  getIntersection(other) {
    if ( this === other ) return;
    const {a: a0, b: b0} = this;
    const {a: a1, b: b1} = other;

    // Ignore edges which share an endpoint
    if ( a0.equals(a1) || a0.equals(b1) || b0.equals(a1) || b0.equals(b1) ) return;

    // Initial fast CCW test for intersection
    if ( !foundry.utils.lineSegmentIntersects(a0, b0, a1, b1) ) return;

    // Slower computation of intersection point
    const i = foundry.utils.lineLineIntersection(a0, b0, a1, b1, {t1: true});
    if ( !i ) return;  // Eliminates co-linear lines, theoretically should not be necessary but just in case
    return i;
  }

  /* -------------------------------------------- */

  /**
   * Test whether to apply a proximity threshold to this edge.
   * If the proximity threshold is met, this edge excluded from perception calculations.
   * @param {string} sourceType     Sense type for the source
   * @param {Point} sourceOrigin    The origin or position of the source on the canvas
   * @param {number} [externalRadius=0] The external radius of the source
   * @returns {boolean}             True if the edge has a threshold greater than 0 for the source type,
   *                                and the source type is within that distance.
   */
  applyThreshold(sourceType, sourceOrigin, externalRadius=0) {
    const d = this.threshold?.[sourceType];
    const t = this[sourceType];
    if ( !d || (t < CONST.WALL_SENSE_TYPES.PROXIMITY) ) return false; // Threshold behavior does not apply
    const proximity = t === CONST.WALL_SENSE_TYPES.PROXIMITY;
    const pt = foundry.utils.closestPointToSegment(sourceOrigin, this.a, this.b);
    const sourceDistance = Math.hypot(pt.x - sourceOrigin.x, pt.y - sourceOrigin.y);
    return proximity ? Math.max(sourceDistance - externalRadius, 0) < d : (sourceDistance + externalRadius) > d;
  }

  /* -------------------------------------------- */

  /**
   * Determine the orientation of this Edge with respect to a reference point.
   * @param {Point} point       Some reference point, relative to which orientation is determined
   * @returns {number}          An orientation in CONST.WALL_DIRECTIONS which indicates whether the Point is left,
   *                            right, or collinear (both) with the Edge
   */
  orientPoint(point) {
    const orientation = foundry.utils.orient2dFast(this.a, this.b, point);
    if ( orientation === 0 ) return CONST.WALL_DIRECTIONS.BOTH;
    return orientation < 0 ? CONST.WALL_DIRECTIONS.LEFT : CONST.WALL_DIRECTIONS.RIGHT;
  }

  /* -------------------------------------------- */
  /*  Intersection Management                     */
  /* -------------------------------------------- */

  /**
   * Identify intersections between a provided iterable of edges.
   * @param {Iterable<Edge>} edges    An iterable of edges
   */
  static identifyEdgeIntersections(edges) {

    // Sort edges by their north-west x value, breaking ties with the south-east x value
    const sorted = [];
    for ( const edge of edges ) {
      edge.intersections.length = 0; // Clear prior intersections
      sorted.push(edge);
    }
    sorted.sort((e1, e2) => (e1.nw.x - e2.nw.x) || (e1.se.x - e2.se.x));

    // Iterate over all known edges, identifying intersections
    const ln = sorted.length;
    for ( let i=0; i<ln; i++ ) {
      const e1 = sorted[i];
      for ( let j=i+1; j<ln; j++ ) {
        const e2 = sorted[j];
        if ( e2.nw.x > e1.se.x ) break; // Segment e2 is entirely right of segment e1
        e1.recordIntersections(e2);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Record the intersections between two edges.
   * @param {Edge} other          Another edge to test and record
   */
  recordIntersections(other) {
    if ( other === this ) return;
    const i = this.getIntersection(other);
    if ( !i ) return;
    this.intersections.push({edge: other, intersection: i});
    other.intersections.push({edge: this, intersection: {x: i.x, y: i.y, t0: i.t1, t1: i.t0}});
  }

  /* -------------------------------------------- */

  /**
   * Remove intersections of this edge with all other edges.
   */
  removeIntersections() {
    for ( const {edge: other} of this.intersections ) {
      other.intersections.findSplice(e => e.edge === this);
    }
    this.intersections.length = 0;
  }
}

/**
 * A specialized Map class that manages all edges used to restrict perception in a Scene.
 * Integrates with a Quadtree for efficient spatial queries.
 * @extends {Map<string, Edge>}
 */
class CanvasEdges extends Map {
  constructor() {
    super();
    this.#quadtree = new CanvasQuadtree({
      maxObjects: CanvasEdges.#QUADTREE_MAX_OBJECTS,
      maxDepth: CanvasEdges.#QUADTREE_MAX_DEPTH
    });
  }

  /**
   * Maximum number of objects per node in the Quadtree.
   * @type {number}
   */
  static #QUADTREE_MAX_OBJECTS = 100;

  /**
   * Maximum depth of the Quadtree.
   * @type {number}
   */
  static #QUADTREE_MAX_DEPTH = 6;

  /* -------------------------------------------- */

  /**
   * Edges representing the outer boundaries of the game canvas.
   * @type {Edge[]}
   */
  #outerBounds = [];

  /**
   * Edges representing the inner boundaries of the scene rectangle.
   * @type {Edge[]}
   */
  #innerBounds = [];

  /**
   * Internal Quadtree instance for spatial indexing of edges.
   * @type {Quadtree}
   */
  #quadtree;

  /* -------------------------------------------- */

  /**
   * Clear content and initializes the quadtree.
   */
  initialize() {
    this.clear();

    // Wall Documents
    for ( /** @type {Wall} */ const wall of canvas.walls.placeables ) wall.initializeEdge();

    // Canvas Boundaries
    this.#defineBoundaries();

    // Darkness Sources
    for ( const source of canvas.effects.darknessSources ) {
      for ( const edge of source.edges ) this.set(edge.id, edge);
    }

    // Initialize Programmatic Edges via Hooks
    Hooks$1.callAll("initializeEdges");
  }

  /* -------------------------------------------- */

  /**
   * @override
   */
  set(key, value) {
    // If the key already exists, remove the old Edge from the Quadtree
    if ( this.has(key) ) {
      const oldEdge = this.get(key);
      const inQuadtree = (oldEdge.type !== "outerBounds") && (oldEdge.type !== "innerBounds");
      if ( inQuadtree ) this.#quadtree.remove(oldEdge);
      this.#quadtree.remove(oldEdge);
    }
    super.set(key, value);

    // We need to normalize bounds (only if necessary)
    let bounds = value.bounds;
    if ( (bounds.width < 0) || (bounds.height < 0) ) bounds = bounds.getBounds();

    // Insert the new Edge into the Quadtree
    const inQuadtree = (value.type !== "outerBounds") && (value.type !== "innerBounds");
    if ( inQuadtree ) this.#quadtree.insert({t: value, r: bounds});
    return this;
  }

  /* -------------------------------------------- */

  /** @override */
  delete(key) {
    const edge = this.get(key);
    if ( edge ) {
      const inQuadtree = (edge.type !== "outerBounds") && (edge.type !== "innerBounds");
      if ( inQuadtree ) this.#quadtree.remove(edge);
      return super.delete(key);
    }
    return false;
  }

  /* -------------------------------------------- */

  /** @override */
  clear() {
    super.clear();
    this.#quadtree.clear();
    this.#outerBounds.length = 0;
    this.#innerBounds.length = 0;
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Incrementally refreshes edges by computing intersections between all registered edges.
   * Utilizes the Quadtree to optimize the intersection detection process.
   */
  refresh() {
    Edge.identifyEdgeIntersections(this.values());
  }

  /* -------------------------------------------- */

  /**
   * Defines Edge instances for the outer and inner canvas boundary rectangles.
   * These special edges are not added to the quadtree and the map.
   */
  #defineBoundaries() {
    const d = canvas.dimensions;

    /**
     * Creates boundary edges for a given rectangle.
     * @param {string} type The type of boundary ("outerBounds" or "innerBounds").
     * @param {PIXI.Rectangle} r The rectangle defining the boundary.
     * @returns {Edge[]} An array of four Edge instances representing the boundary.
     */
    const define = (type, r) => {
      const top = new Edge({x: r.x, y: r.y}, {x: r.right, y: r.y}, {id: `${type}.top`, type});
      const right = new Edge({x: r.right, y: r.y}, {x: r.right, y: r.bottom}, {id: `${type}.right`, type});
      const bottom = new Edge({x: r.right, y: r.bottom}, {x: r.x, y: r.bottom}, {id: `${type}.bottom`, type});
      const left = new Edge({x: r.x, y: r.bottom}, {x: r.x, y: r.y}, {id: `${type}.left`, type});
      this.set(top.id, top);
      this.set(right.id, right);
      this.set(bottom.id, bottom);
      this.set(left.id, left);
      return [top, right, bottom, left];
    };

    // Define Outer Canvas Bounds
    this.#outerBounds = define("outerBounds", d.rect);

    // Define Inner Canvas Bounds if there is padding
    if ( d.rect.x === d.sceneRect.x ) this.#innerBounds = this.#outerBounds;
    else this.#innerBounds = define("innerBounds", d.sceneRect);
  }

  /* -------------------------------------------- */

  /**
   * Retrieves edges that intersect with a given rectangle.
   * Utilizes the Quadtree for efficient spatial querying.
   * @param {PIXI.Rectangle} rect The rectangle to query against.
   * @param {object} options
   * @param {boolean} [options.includeInnerBounds=false] Should inner bounds be added?
   * @param {boolean} [options.includeOuterBounds=true] Should outer bounds be added?
   * @param {Function} [options.collisionTest] Collision function to test edge inclusion.
   * @returns {Set<Edge>} A set of Edge instances that intersect with the provided rectangle.
   */
  getEdges(rect, {includeInnerBounds=false, includeOuterBounds=true, collisionTest}={}) {
    const edges = this.#quadtree.getObjects(rect, collisionTest ? {collisionTest: o => collisionTest(o.t)} : {});
    if ( includeInnerBounds ) {
      for ( const inner of this.#innerBounds ) edges.add(inner);
    }
    if ( includeOuterBounds ) {
      for ( const outer of this.#outerBounds ) edges.add(outer);
    }
    return edges;
  }
}

/**
 * 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(`${CONST.vtt} | Drawing the ${this.constructor.name} canvas layer`);
      await this.tearDown();
      await this._draw(options);
      Hooks$1.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$1.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}));
  }
}

/**
 * @import {SMAAFilterConfig} from "./_types.mjs";
 */

/**
 * The edge detection filter for {@link foundry.canvas.rendering.filters.SMAAFilter}.
 */
class SMAAEdgeDetectionFilter extends PIXI.Filter {
  /**
   * @param {Pick<SMAAFilterConfig, "threshold"|"localContrastAdaptionFactor">} config
   */
  constructor(config) {
    super(VERTEX_SOURCE$1, generateFragmentSource$1(config));
  }
}

/* -------------------------------------------- */

/**
 * The vertex shader source of {@link SMAAEdgeDetectionFilter}.
 * @type {string}
 */
const VERTEX_SOURCE$1 = `\
#define mad(a, b, c) (a * b + c)

attribute vec2 aVertexPosition;

uniform mat3 projectionMatrix;
uniform vec4 inputSize;
uniform vec4 inputPixel;
uniform vec4 outputFrame;

#define resolution (inputPixel.xy)
#define SMAA_RT_METRICS (inputPixel.zwxy)

varying vec2 vTexCoord0;
varying vec4 vOffset[3];

void main() {
    vTexCoord0 = aVertexPosition * (outputFrame.zw * inputSize.zw);

    vOffset[0] = mad(SMAA_RT_METRICS.xyxy, vec4(-1.0, 0.0, 0.0, -1.0), vTexCoord0.xyxy);
    vOffset[1] = mad(SMAA_RT_METRICS.xyxy, vec4( 1.0, 0.0, 0.0,  1.0), vTexCoord0.xyxy);
    vOffset[2] = mad(SMAA_RT_METRICS.xyxy, vec4(-2.0, 0.0, 0.0, -2.0), vTexCoord0.xyxy);

    vec3 position = vec3(aVertexPosition * max(outputFrame.zw, vec2(0.0)) + outputFrame.xy, 1.0);
    gl_Position = vec4((projectionMatrix * position).xy, 0.0, 1.0);
}
`;

/* -------------------------------------------- */

/**
 * The fragment shader source of {@link SMAAEdgeDetectionFilter}.
 * @param {Pick<SMAAFilterConfig, "threshold"|"localContrastAdaptionFactor">} config
 * @returns {string}
 */
function generateFragmentSource$1(config) {
  return `\
precision highp float;

/**
 * Color Edge Detection
 *
 * IMPORTANT NOTICE: color edge detection requires gamma-corrected colors, and
 * thus 'colorTex' should be a non-sRGB texture.
 */

#define SMAA_THRESHOLD ${config.threshold.toFixed(8)}
#define SMAA_LOCAL_CONTRAST_ADAPTATION_FACTOR ${config.localContrastAdaptionFactor.toFixed(8)}

uniform sampler2D uSampler; // colorTex

#define colorTex uSampler

varying vec2 vTexCoord0;
varying vec4 vOffset[3];

void main() {
    // Calculate the threshold:
    vec2 threshold = vec2(SMAA_THRESHOLD);

    // Calculate color deltas:
    vec4 delta;
    vec3 c = texture2D(colorTex, vTexCoord0).rgb;

    vec3 cLeft = texture2D(colorTex, vOffset[0].xy).rgb;
    vec3 t = abs(c - cLeft);
    delta.x = max(max(t.r, t.g), t.b);

    vec3 cTop  = texture2D(colorTex, vOffset[0].zw).rgb;
    t = abs(c - cTop);
    delta.y = max(max(t.r, t.g), t.b);

    // We do the usual threshold:
    vec2 edges = step(threshold, delta.xy);

    // Then discard if there is no edge:
    if (dot(edges, vec2(1.0, 1.0)) == 0.0)
        discard;

    // Calculate right and bottom deltas:
    vec3 cRight = texture2D(colorTex, vOffset[1].xy).rgb;
    t = abs(c - cRight);
    delta.z = max(max(t.r, t.g), t.b);

    vec3 cBottom  = texture2D(colorTex, vOffset[1].zw).rgb;
    t = abs(c - cBottom);
    delta.w = max(max(t.r, t.g), t.b);

    // Calculate the maximum delta in the direct neighborhood:
    vec2 maxDelta = max(delta.xy, delta.zw);

    // Calculate left-left and top-top deltas:
    vec3 cLeftLeft  = texture2D(colorTex, vOffset[2].xy).rgb;
    t = abs(c - cLeftLeft);
    delta.z = max(max(t.r, t.g), t.b);

    vec3 cTopTop = texture2D(colorTex, vOffset[2].zw).rgb;
    t = abs(c - cTopTop);
    delta.w = max(max(t.r, t.g), t.b);

    // Calculate the final maximum delta:
    maxDelta = max(maxDelta.xy, delta.zw);
    float finalDelta = max(maxDelta.x, maxDelta.y);

    // Local contrast adaptation:
    edges.xy *= step(finalDelta, SMAA_LOCAL_CONTRAST_ADAPTATION_FACTOR * delta.xy);

    gl_FragColor = vec4(edges, 0.0, 1.0);
}
`;
}

/**
 * @import {SMAAFilterConfig} from "./_types.mjs";
 */

/**
 * The blending weight calculation filter for {@link foundry.canvas.rendering.filters.SMAAFilter}.
 */
class SMAABlendingWeightCalculationFilter extends PIXI.Filter {
  /**
   * @param {Omit<SMAAFilterConfig, "localContrastAdaptionFactor">} config
   */
  constructor(config) {
    super(generateVertexSource(config), generateFragmentSource(config), {areaTex, searchTex});
  }
}

/* -------------------------------------------- */

/**
 * The fragment shader source of {@link SMAABlendingWeightCalculationFilter}.
 * @param {Omit<SMAAFilterConfig, "localContrastAdaptionFactor">} config
 * @returns {string}
 */
function generateVertexSource(config) {
  return `\
#define mad(a, b, c) (a * b + c)

#define SMAA_MAX_SEARCH_STEPS ${config.maxSearchSteps}

attribute vec2 aVertexPosition;

uniform mat3 projectionMatrix;
uniform vec4 inputSize;
uniform vec4 inputPixel;
uniform vec4 outputFrame;

#define resolution (inputPixel.xy)
#define SMAA_RT_METRICS (inputPixel.zwxy)

varying vec2 vTexCoord0;
varying vec2 vPixCoord;
varying vec4 vOffset[3];

void main() {
    vTexCoord0 = aVertexPosition * (outputFrame.zw * inputSize.zw);

    vPixCoord = vTexCoord0 * SMAA_RT_METRICS.zw;

    // We will use these offsets for the searches later on (see @PSEUDO_GATHER4):
    vOffset[0] = mad(SMAA_RT_METRICS.xyxy, vec4(-0.25, -0.125,  1.25, -0.125), vTexCoord0.xyxy);
    vOffset[1] = mad(SMAA_RT_METRICS.xyxy, vec4(-0.125, -0.25, -0.125,  1.25), vTexCoord0.xyxy);

    // And these for the searches, they indicate the ends of the loops:
    vOffset[2] = mad(
      SMAA_RT_METRICS.xxyy,
      vec4(-2.0, 2.0, -2.0, 2.0) * float(SMAA_MAX_SEARCH_STEPS),
      vec4(vOffset[0].xz, vOffset[1].yw)
    );

    vec3 position = vec3(aVertexPosition * max(outputFrame.zw, vec2(0.0)) + outputFrame.xy, 1.0);
    gl_Position = vec4((projectionMatrix * position).xy, 0.0, 1.0);
}
`;
}

/* -------------------------------------------- */

/**
 * The fragment shader source of {@link SMAABlendingWeightCalculationFilter}.
 * @param {SMAAFilterConfig} config
 * @returns {string}
 */
function generateFragmentSource(config) {
  return `\
precision highp float;
precision highp int;

#define SMAA_THRESHOLD ${config.threshold.toFixed(8)}
#define SMAA_MAX_SEARCH_STEPS ${config.maxSearchSteps}
#define SMAA_MAX_SEARCH_STEPS_DIAG ${config.maxSearchStepsDiag}
#define SMAA_CORNER_ROUNDING ${config.cornerRounding}
${config.disableDiagDetection ? "#define SMAA_DISABLE_DIAG_DETECTION" : ""}
${config.disableCornerDetection ? "#define SMAA_DISABLE_CORNER_DETECTION" : ""}

// Non-Configurable Defines
#define SMAA_AREATEX_MAX_DISTANCE 16
#define SMAA_AREATEX_MAX_DISTANCE_DIAG 20
#define SMAA_AREATEX_PIXEL_SIZE (1.0 / vec2(160.0, 560.0))
#define SMAA_AREATEX_SUBTEX_SIZE (1.0 / 7.0)
#define SMAA_SEARCHTEX_SIZE vec2(66.0, 33.0)
#define SMAA_SEARCHTEX_PACKED_SIZE vec2(64.0, 16.0)
#define SMAA_CORNER_ROUNDING_NORM (float(SMAA_CORNER_ROUNDING) / 100.0)

// Texture Access Defines
#ifndef SMAA_AREATEX_SELECT
#define SMAA_AREATEX_SELECT(sample) sample.rg
#endif

#ifndef SMAA_SEARCHTEX_SELECT
#define SMAA_SEARCHTEX_SELECT(sample) sample.r
#endif

uniform sampler2D uSampler; // edgesTex
uniform sampler2D areaTex;
uniform sampler2D searchTex;
uniform vec4 inputPixel;

#define edgesTex uSampler
#define resolution (inputPixel.xy)
#define SMAA_RT_METRICS (inputPixel.zwxy)

varying vec2 vTexCoord0;
varying vec4 vOffset[3];
varying vec2 vPixCoord;

#define mad(a, b, c) (a * b + c)
#define saturate(a) clamp(a, 0.0, 1.0)
#define round(v) floor(v + 0.5)
#define SMAASampleLevelZeroOffset(tex, coord, offset) texture2D(tex, coord + offset * SMAA_RT_METRICS.xy)

/**
 * Conditional move:
 */
void SMAAMovc(bvec2 cond, inout vec2 variable, vec2 value) {
  if (cond.x) variable.x = value.x;
  if (cond.y) variable.y = value.y;
}

void SMAAMovc(bvec4 cond, inout vec4 variable, vec4 value) {
  SMAAMovc(cond.xy, variable.xy, value.xy);
  SMAAMovc(cond.zw, variable.zw, value.zw);
}

/**
 * Allows to decode two binary values from a bilinear-filtered access.
 */
vec2 SMAADecodeDiagBilinearAccess(vec2 e) {
  // Bilinear access for fetching 'e' have a 0.25 offset, and we are
  // interested in the R and G edges:
  //
  // +---G---+-------+
  // |   x o R   x   |
  // +-------+-------+
  //
  // Then, if one of these edge is enabled:
  //   Red:   (0.75 * X + 0.25 * 1) => 0.25 or 1.0
  //   Green: (0.75 * 1 + 0.25 * X) => 0.75 or 1.0
  //
  // This function will unpack the values (mad + mul + round):
  // wolframalpha.com: round(x * abs(5 * x - 5 * 0.75)) plot 0 to 1
  e.r = e.r * abs(5.0 * e.r - 5.0 * 0.75);
  return round(e);
}

vec4 SMAADecodeDiagBilinearAccess(vec4 e) {
  e.rb = e.rb * abs(5.0 * e.rb - 5.0 * 0.75);
  return round(e);
}

/**
 * These functions allows to perform diagonal pattern searches.
 */
vec2 SMAASearchDiag1(sampler2D edgesTex, vec2 texcoord, vec2 dir, out vec2 e) {
  vec4 coord = vec4(texcoord, -1.0, 1.0);
  vec3 t = vec3(SMAA_RT_METRICS.xy, 1.0);

  for (int i = 0; i < SMAA_MAX_SEARCH_STEPS; i++) {
    if (!(coord.z < float(SMAA_MAX_SEARCH_STEPS_DIAG - 1) && coord.w > 0.9)) break;
    coord.xyz = mad(t, vec3(dir, 1.0), coord.xyz);
    e = texture2D(edgesTex, coord.xy).rg; // LinearSampler
    coord.w = dot(e, vec2(0.5, 0.5));
  }
  return coord.zw;
}

vec2 SMAASearchDiag2(sampler2D edgesTex, vec2 texcoord, vec2 dir, out vec2 e) {
  vec4 coord = vec4(texcoord, -1.0, 1.0);
  coord.x += 0.25 * SMAA_RT_METRICS.x; // See @SearchDiag2Optimization
  vec3 t = vec3(SMAA_RT_METRICS.xy, 1.0);

  for (int i = 0; i < SMAA_MAX_SEARCH_STEPS; i++) {
    if (!(coord.z < float(SMAA_MAX_SEARCH_STEPS_DIAG - 1) && coord.w > 0.9)) break;
    coord.xyz = mad(t, vec3(dir, 1.0), coord.xyz);

    // @SearchDiag2Optimization
    // Fetch both edges at once using bilinear filtering:
    e = texture2D(edgesTex, coord.xy).rg; // LinearSampler
    e = SMAADecodeDiagBilinearAccess(e);

    // Non-optimized version:
    // e.g = texture2D(edgesTex, coord.xy).g; // LinearSampler
    // e.r = SMAASampleLevelZeroOffset(edgesTex, coord.xy, vec2(1, 0)).r;

    coord.w = dot(e, vec2(0.5, 0.5));
  }
  return coord.zw;
}

/**
 * Similar to SMAAArea, this calculates the area corresponding to a certain
 * diagonal distance and crossing edges 'e'.
 */
vec2 SMAAAreaDiag(sampler2D areaTex, vec2 dist, vec2 e, float offset) {
  vec2 texcoord = mad(vec2(SMAA_AREATEX_MAX_DISTANCE_DIAG, SMAA_AREATEX_MAX_DISTANCE_DIAG), e, dist);

  // We do a scale and bias for mapping to texel space:
  texcoord = mad(SMAA_AREATEX_PIXEL_SIZE, texcoord, 0.5 * SMAA_AREATEX_PIXEL_SIZE);

  // Diagonal areas are on the second half of the texture:
  texcoord.x += 0.5;

  // Move to proper place, according to the subpixel offset:
  texcoord.y += SMAA_AREATEX_SUBTEX_SIZE * offset;

  // Do it!
  return SMAA_AREATEX_SELECT(texture2D(areaTex, texcoord)); // LinearSampler
}

/**
 * This searches for diagonal patterns and returns the corresponding weights.
 */
vec2 SMAACalculateDiagWeights(sampler2D edgesTex, sampler2D areaTex, vec2 texcoord, vec2 e, vec4 subsampleIndices) {
  vec2 weights = vec2(0.0, 0.0);

  // Search for the line ends:
  vec4 d;
  vec2 end;
  if (e.r > 0.0) {
      d.xz = SMAASearchDiag1(edgesTex, texcoord, vec2(-1.0,  1.0), end);
      d.x += float(end.y > 0.9);
  } else
      d.xz = vec2(0.0, 0.0);
  d.yw = SMAASearchDiag1(edgesTex, texcoord, vec2(1.0, -1.0), end);

  if (d.x + d.y > 2.0) { // d.x + d.y + 1 > 3
    // Fetch the crossing edges:
    vec4 coords = mad(vec4(-d.x + 0.25, d.x, d.y, -d.y - 0.25), SMAA_RT_METRICS.xyxy, texcoord.xyxy);
    vec4 c;
    c.xy = SMAASampleLevelZeroOffset(edgesTex, coords.xy, vec2(-1,  0)).rg;
    c.zw = SMAASampleLevelZeroOffset(edgesTex, coords.zw, vec2( 1,  0)).rg;
    c.yxwz = SMAADecodeDiagBilinearAccess(c.xyzw);

    // Non-optimized version:
    // vec4 coords = mad(vec4(-d.x, d.x, d.y, -d.y), SMAA_RT_METRICS.xyxy, texcoord.xyxy);
    // vec4 c;
    // c.x = SMAASampleLevelZeroOffset(edgesTex, coords.xy, vec2(-1,  0)).g;
    // c.y = SMAASampleLevelZeroOffset(edgesTex, coords.xy, vec2( 0,  0)).r;
    // c.z = SMAASampleLevelZeroOffset(edgesTex, coords.zw, vec2( 1,  0)).g;
    // c.w = SMAASampleLevelZeroOffset(edgesTex, coords.zw, vec2( 1, -1)).r;

    // Merge crossing edges at each side into a single value:
    vec2 cc = mad(vec2(2.0, 2.0), c.xz, c.yw);

    // Remove the crossing edge if we didn't found the end of the line:
    SMAAMovc(bvec2(step(0.9, d.zw)), cc, vec2(0.0, 0.0));

    // Fetch the areas for this line:
    weights += SMAAAreaDiag(areaTex, d.xy, cc, subsampleIndices.z);
  }

  // Search for the line ends:
  d.xz = SMAASearchDiag2(edgesTex, texcoord, vec2(-1.0, -1.0), end);
  if (SMAASampleLevelZeroOffset(edgesTex, texcoord, vec2(1, 0)).r > 0.0) {
    d.yw = SMAASearchDiag2(edgesTex, texcoord, vec2(1.0, 1.0), end);
    d.y += float(end.y > 0.9);
  } else {
    d.yw = vec2(0.0, 0.0);
  }

  if (d.x + d.y > 2.0) { // d.x + d.y + 1 > 3
    // Fetch the crossing edges:
    vec4 coords = mad(vec4(-d.x, -d.x, d.y, d.y), SMAA_RT_METRICS.xyxy, texcoord.xyxy);
    vec4 c;
    c.x  = SMAASampleLevelZeroOffset(edgesTex, coords.xy, vec2(-1,  0)).g;
    c.y  = SMAASampleLevelZeroOffset(edgesTex, coords.xy, vec2( 0, -1)).r;
    c.zw = SMAASampleLevelZeroOffset(edgesTex, coords.zw, vec2( 1,  0)).gr;
    vec2 cc = mad(vec2(2.0, 2.0), c.xz, c.yw);

    // Remove the crossing edge if we didn't found the end of the line:
    SMAAMovc(bvec2(step(0.9, d.zw)), cc, vec2(0.0, 0.0));

    // Fetch the areas for this line:
    weights += SMAAAreaDiag(areaTex, d.xy, cc, subsampleIndices.w).gr;
  }

  return weights;
}

/**
 * This allows to determine how much length should we add in the last step
 * of the searches. It takes the bilinearly interpolated edge (see
 * @PSEUDO_GATHER4), and adds 0, 1 or 2, depending on which edges and
 * crossing edges are active.
 */
float SMAASearchLength(sampler2D searchTex, vec2 e, float offset) {
  // The texture is flipped vertically, with left and right cases taking half
  // of the space horizontally:
  vec2 scale = SMAA_SEARCHTEX_SIZE * vec2(0.5, -1.0);
  vec2 bias = SMAA_SEARCHTEX_SIZE * vec2(offset, 1.0);

  // Scale and bias to access texel centers:
  scale += vec2(-1.0,  1.0);
  bias  += vec2( 0.5, -0.5);

  // Convert from pixel coordinates to texcoords:
  // (We use SMAA_SEARCHTEX_PACKED_SIZE because the texture is cropped)
  scale *= 1.0 / SMAA_SEARCHTEX_PACKED_SIZE;
  bias *= 1.0 / SMAA_SEARCHTEX_PACKED_SIZE;

  // Lookup the search texture:
  return SMAA_SEARCHTEX_SELECT(texture2D(searchTex, mad(scale, e, bias))); // LinearSampler
}

/**
 * Horizontal/vertical search functions for the 2nd pass.
 */
float SMAASearchXLeft(sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end) {
  /**
    * @PSEUDO_GATHER4
    * This texcoord has been offset by (-0.25, -0.125) in the vertex shader to
    * sample between edge, thus fetching four edges in a row.
    * Sampling with different offsets in each direction allows to disambiguate
    * which edges are active from the four fetched ones.
    */
  vec2 e = vec2(0.0, 1.0);
  for (int i = 0; i < SMAA_MAX_SEARCH_STEPS; i++) {
    if (!(texcoord.x > end && e.g > 0.8281 && e.r == 0.0)) break;
    e = texture2D(edgesTex, texcoord).rg; // LinearSampler
    texcoord = mad(-vec2(2.0, 0.0), SMAA_RT_METRICS.xy, texcoord);
  }

  float offset = mad(-(255.0 / 127.0), SMAASearchLength(searchTex, e, 0.0), 3.25);
  return mad(SMAA_RT_METRICS.x, offset, texcoord.x);

  // Non-optimized version:
  // We correct the previous (-0.25, -0.125) offset we applied:
  // texcoord.x += 0.25 * SMAA_RT_METRICS.x;

  // The searches are bias by 1, so adjust the coords accordingly:
  // texcoord.x += SMAA_RT_METRICS.x;

  // Disambiguate the length added by the last step:
  // texcoord.x += 2.0 * SMAA_RT_METRICS.x; // Undo last step
  // texcoord.x -= SMAA_RT_METRICS.x * (255.0 / 127.0) * SMAASearchLength(searchTex, e, 0.0);
  // return mad(SMAA_RT_METRICS.x, offset, texcoord.x);
}

float SMAASearchXRight(sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end) {
  vec2 e = vec2(0.0, 1.0);
  for (int i = 0; i < SMAA_MAX_SEARCH_STEPS; i++) { if (!(texcoord.x < end && e.g > 0.8281 && e.r == 0.0)) break;
    e = texture2D(edgesTex, texcoord).rg; // LinearSampler
    texcoord = mad(vec2(2.0, 0.0), SMAA_RT_METRICS.xy, texcoord);
  }
  float offset = mad(-(255.0 / 127.0), SMAASearchLength(searchTex, e, 0.5), 3.25);
  return mad(-SMAA_RT_METRICS.x, offset, texcoord.x);
}

float SMAASearchYUp(sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end) {
  vec2 e = vec2(1.0, 0.0);
  for (int i = 0; i < SMAA_MAX_SEARCH_STEPS; i++) { if (!(texcoord.y > end && e.r > 0.8281 && e.g == 0.0)) break;
    e = texture2D(edgesTex, texcoord).rg; // LinearSampler
    texcoord = mad(-vec2(0.0, 2.0), SMAA_RT_METRICS.xy, texcoord);
  }
  float offset = mad(-(255.0 / 127.0), SMAASearchLength(searchTex, e.gr, 0.0), 3.25);
  return mad(SMAA_RT_METRICS.y, offset, texcoord.y);
}

float SMAASearchYDown(sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end) {
  vec2 e = vec2(1.0, 0.0);
  for (int i = 0; i < SMAA_MAX_SEARCH_STEPS; i++) { if (!(texcoord.y < end && e.r > 0.8281 && e.g == 0.0)) break;
    e = texture2D(edgesTex, texcoord).rg; // LinearSampler
    texcoord = mad(vec2(0.0, 2.0), SMAA_RT_METRICS.xy, texcoord);
  }
  float offset = mad(-(255.0 / 127.0), SMAASearchLength(searchTex, e.gr, 0.5), 3.25);
  return mad(-SMAA_RT_METRICS.y, offset, texcoord.y);
}

/**
 * Ok, we have the distance and both crossing edges. So, what are the areas
 * at each side of current edge?
 */
vec2 SMAAArea(sampler2D areaTex, vec2 dist, float e1, float e2, float offset) {
  // Rounding prevents precision errors of bilinear filtering:
  vec2 texcoord = mad(vec2(SMAA_AREATEX_MAX_DISTANCE, SMAA_AREATEX_MAX_DISTANCE), round(4.0 * vec2(e1, e2)), dist);

  // We do a scale and bias for mapping to texel space:
  texcoord = mad(SMAA_AREATEX_PIXEL_SIZE, texcoord, 0.5 * SMAA_AREATEX_PIXEL_SIZE);

  // Move to proper place, according to the subpixel offset:
  texcoord.y = mad(SMAA_AREATEX_SUBTEX_SIZE, offset, texcoord.y);

  // Do it!
  return SMAA_AREATEX_SELECT(texture2D(areaTex, texcoord)); // LinearSampler
}

// Corner Detection Functions
void SMAADetectHorizontalCornerPattern(sampler2D edgesTex, inout vec2 weights, vec4 texcoord, vec2 d) {
  #if !defined(SMAA_DISABLE_CORNER_DETECTION)
  vec2 leftRight = step(d.xy, d.yx);
  vec2 rounding = (1.0 - SMAA_CORNER_ROUNDING_NORM) * leftRight;

  rounding /= leftRight.x + leftRight.y; // Reduce blending for pixels in the center of a line.

  vec2 factor = vec2(1.0, 1.0);
  factor.x -= rounding.x * SMAASampleLevelZeroOffset(edgesTex, texcoord.xy, vec2(0,  1)).r;
  factor.x -= rounding.y * SMAASampleLevelZeroOffset(edgesTex, texcoord.zw, vec2(1,  1)).r;
  factor.y -= rounding.x * SMAASampleLevelZeroOffset(edgesTex, texcoord.xy, vec2(0, -2)).r;
  factor.y -= rounding.y * SMAASampleLevelZeroOffset(edgesTex, texcoord.zw, vec2(1, -2)).r;

  weights *= saturate(factor);
  #endif
}

void SMAADetectVerticalCornerPattern(sampler2D edgesTex, inout vec2 weights, vec4 texcoord, vec2 d) {
  #if !defined(SMAA_DISABLE_CORNER_DETECTION)
  vec2 leftRight = step(d.xy, d.yx);
  vec2 rounding = (1.0 - SMAA_CORNER_ROUNDING_NORM) * leftRight;

  rounding /= leftRight.x + leftRight.y;

  vec2 factor = vec2(1.0, 1.0);
  factor.x -= rounding.x * SMAASampleLevelZeroOffset(edgesTex, texcoord.xy, vec2( 1, 0)).g;
  factor.x -= rounding.y * SMAASampleLevelZeroOffset(edgesTex, texcoord.zw, vec2( 1, 1)).g;
  factor.y -= rounding.x * SMAASampleLevelZeroOffset(edgesTex, texcoord.xy, vec2(-2, 0)).g;
  factor.y -= rounding.y * SMAASampleLevelZeroOffset(edgesTex, texcoord.zw, vec2(-2, 1)).g;

  weights *= saturate(factor);
  #endif
}

void main() {
  vec4 subsampleIndices = vec4(0.0); // Just pass zero for SMAA 1x, see @SUBSAMPLE_INDICES.
  // subsampleIndices = vec4(1.0, 1.0, 1.0, 0.0);
  vec4 weights = vec4(0.0, 0.0, 0.0, 0.0);
  vec2 e = texture2D(edgesTex, vTexCoord0).rg;

  if (e.g > 0.0) { // Edge at north

    #if !defined(SMAA_DISABLE_DIAG_DETECTION)
    // Diagonals have both north and west edges, so searching for them in
    // one of the boundaries is enough.
    weights.rg = SMAACalculateDiagWeights(edgesTex, areaTex, vTexCoord0, e, subsampleIndices);

    // We give priority to diagonals, so if we find a diagonal we skip
    // horizontal/vertical processing.
    if (weights.r == -weights.g) { // weights.r + weights.g == 0.0
    #endif

    vec2 d;

    // Find the distance to the left:
    vec3 coords;
    coords.x = SMAASearchXLeft(edgesTex, searchTex, vOffset[0].xy, vOffset[2].x);
    coords.y = vOffset[1].y; // vOffset[1].y = vTexCoord0.y - 0.25 * SMAA_RT_METRICS.y (@CROSSING_OFFSET)
    d.x = coords.x;

    // Now fetch the left crossing edges, two at a time using bilinear
    // filtering. Sampling at -0.25 (see @CROSSING_OFFSET) enables to
    // discern what value each edge has:
    float e1 = texture2D(edgesTex, coords.xy).r; // LinearSampler

    // Find the distance to the right:
    coords.z = SMAASearchXRight(edgesTex, searchTex, vOffset[0].zw, vOffset[2].y);
    d.y = coords.z;

    // We want the distances to be in pixel units (doing this here allow to
    // better interleave arithmetic and memory accesses):
    d = abs(round(mad(SMAA_RT_METRICS.zz, d, -vPixCoord.xx)));

    // SMAAArea below needs a sqrt, as the areas texture is compressed
    // quadratically:
    vec2 sqrt_d = sqrt(d);

    // Fetch the right crossing edges:
    float e2 = SMAASampleLevelZeroOffset(edgesTex, coords.zy, vec2(1, 0)).r;

    // Ok, we know how this pattern looks like, now it is time for getting
    // the actual area:
    weights.rg = SMAAArea(areaTex, sqrt_d, e1, e2, subsampleIndices.y);

    // Fix corners:
    coords.y = vTexCoord0.y;
    SMAADetectHorizontalCornerPattern(edgesTex, weights.rg, coords.xyzy, d);

    #if !defined(SMAA_DISABLE_DIAG_DETECTION)
    } else
    e.r = 0.0; // Skip vertical processing.
    #endif
  }

  if (e.r > 0.0) { // Edge at west
    vec2 d;

    // Find the distance to the top:
    vec3 coords;
    coords.y = SMAASearchYUp(edgesTex, searchTex, vOffset[1].xy, vOffset[2].z);
    coords.x = vOffset[0].x; // vOffset[1].x = vTexCoord0.x - 0.25 * SMAA_RT_METRICS.x;
    d.x = coords.y;

    // Fetch the top crossing edges:
    float e1 = texture2D(edgesTex, coords.xy).g; // LinearSampler

    // Find the distance to the bottom:
    coords.z = SMAASearchYDown(edgesTex, searchTex, vOffset[1].zw, vOffset[2].w);
    d.y = coords.z;

    // We want the distances to be in pixel units:
    d = abs(round(mad(SMAA_RT_METRICS.ww, d, -vPixCoord.yy)));

    // SMAAArea below needs a sqrt, as the areas texture is compressed
    // quadratically:
    vec2 sqrt_d = sqrt(d);

    // Fetch the bottom crossing edges:
    float e2 = SMAASampleLevelZeroOffset(edgesTex, coords.xz, vec2(0, 1)).g;

    // Get the area for this direction:
    weights.ba = SMAAArea(areaTex, sqrt_d, e1, e2, subsampleIndices.x);

    // Fix corners:
    coords.x = vTexCoord0.x;
    SMAADetectVerticalCornerPattern(edgesTex, weights.ba, coords.xyxz, d);
  }

  gl_FragColor = weights;
}
`;
}

/* -------------------------------------------- */

/**
 * The area texture of {@link SMAABlendingWeightCalculationFilter}.
 * @type {PIXI.Texture}
 */
const areaTex = new PIXI.Texture(new PIXI.BaseTexture(
  "data:image/webp;base64, ",
  {
    mipmap: PIXI.MIPMAP_MODES.OFF,
    anisotropicLevel: 0,
    wrapMode: PIXI.WRAP_MODES.CLAMP,
    scaleMode: PIXI.SCALE_MODES.LINEAR,
    format: PIXI.FORMATS.RG,
    type: PIXI.TYPES.UNSIGNED_BYTE
  }
));

/* -------------------------------------------- */

/**
 * The search texture of {@link SMAABlendingWeightCalculationFilter}.
 * @type {PIXI.Texture}
 */
const searchTex = new PIXI.Texture(new PIXI.BaseTexture(
  "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAAQCAYAAACm53kpAAAAAXNSR0IArs4c6QAAAIBJREFUWEftl1ELgCAMhD/749Ift1pNEBlRvfRw82XisQ3mbqelwQawANU2wOq2uY0wh0IzxpljRpidWc6edww6+9/l/YIV+QIcF392gOrKDkgKqFNAfgZU7wCToCeSFUnVPEDfymD3/0UG5QuQFEgKXA8h2RkgT4EsQD6EtD9DO7PRXAFvjEKRAAAAAElFTkSuQmCC",
  {
    mipmap: PIXI.MIPMAP_MODES.OFF,
    anisotropicLevel: 0,
    wrapMode: PIXI.WRAP_MODES.CLAMP,
    scaleMode: PIXI.SCALE_MODES.LINEAR,
    format: PIXI.FORMATS.RED,
    type: PIXI.TYPES.UNSIGNED_BYTE
  }
));

/**
 * The neighborhood blending filter for {@link foundry.canvas.rendering.filters.SMAAFilter}.
 */
class SMAANeighborhoodBlendingFilter extends PIXI.Filter {
  constructor() {
    super(VERTEX_SOURCE, FRAGMENT_SOURCE);
  }
}

/* -------------------------------------------- */

/**
 * The vertex shader source of {@link SMAANeighborhoodBlendingFilter}.
 * @type {string}
 */
const VERTEX_SOURCE = `\
#define mad(a, b, c) (a * b + c)

attribute vec2 aVertexPosition;

uniform mat3 projectionMatrix;
uniform vec4 inputSize;
uniform vec4 inputPixel;
uniform vec4 outputFrame;

#define resolution (inputPixel.xy)
#define SMAA_RT_METRICS (inputPixel.zwxy)

varying vec2 vTexCoord0;
varying vec4 vOffset;

void main() {
    vTexCoord0 = aVertexPosition * (outputFrame.zw * inputSize.zw);
    vOffset = mad(SMAA_RT_METRICS.xyxy, vec4(1.0, 0.0, 0.0,  1.0), vTexCoord0.xyxy);

    vec3 position = vec3(aVertexPosition * max(outputFrame.zw, vec2(0.0)) + outputFrame.xy, 1.0);
    gl_Position = vec4((projectionMatrix * position).xy, 0.0, 1.0);
}
`;

/* -------------------------------------------- */

/**
 * The fragment shader source of {@link SMAANeighborhoodBlendingFilter}.
 * @type {string}
 */
const FRAGMENT_SOURCE = `\
precision highp float;

#define mad(a, b, c) (a * b + c)

uniform sampler2D blendTex;
uniform sampler2D uSampler; // colorTex
uniform vec4 inputPixel;

#define colorTex uSampler
#define resolution (inputPixel.xy)
#define SMAA_RT_METRICS (inputPixel.zwxy)

varying vec2 vTexCoord0;
varying vec4 vOffset;

/**
 * Conditional move:
 */
void SMAAMovc(bvec2 cond, inout vec2 variable, vec2 value) {
  if (cond.x) variable.x = value.x;
  if (cond.y) variable.y = value.y;
}

void SMAAMovc(bvec4 cond, inout vec4 variable, vec4 value) {
  SMAAMovc(cond.xy, variable.xy, value.xy);
  SMAAMovc(cond.zw, variable.zw, value.zw);
}

void main() {
  vec4 color;

  // Fetch the blending weights for current pixel:
  vec4 a;
  a.x = texture2D(blendTex, vOffset.xy).a; // Right
  a.y = texture2D(blendTex, vOffset.zw).g; // Top
  a.wz = texture2D(blendTex, vTexCoord0).xz; // Bottom / Left

  // Is there any blending weight with a value greater than 0.0?
  if (dot(a, vec4(1.0, 1.0, 1.0, 1.0)) <= 1e-5) {
    color = texture2D(colorTex, vTexCoord0); // LinearSampler
  } else {
    bool h = max(a.x, a.z) > max(a.y, a.w); // max(horizontal) > max(vertical)

    // Calculate the blending offsets:
    vec4 blendingOffset = vec4(0.0, a.y, 0.0, a.w);
    vec2 blendingWeight = a.yw;
    SMAAMovc(bvec4(h, h, h, h), blendingOffset, vec4(a.x, 0.0, a.z, 0.0));
    SMAAMovc(bvec2(h, h), blendingWeight, a.xz);
    blendingWeight /= dot(blendingWeight, vec2(1.0, 1.0));

    // Calculate the texture coordinates:
    vec4 blendingCoord = mad(blendingOffset, vec4(SMAA_RT_METRICS.xy, -SMAA_RT_METRICS.xy), vTexCoord0.xyxy);

    // We exploit bilinear filtering to mix current pixel with the chosen
    // neighbor:
    color = blendingWeight.x * texture2D(colorTex, blendingCoord.xy); // LinearSampler
    color += blendingWeight.y * texture2D(colorTex, blendingCoord.zw); // LinearSampler
  }

  gl_FragColor = color;
}
`;

/**
 * @import {DeepReadonly} from "@common/_types.mjs";
 * @import {SMAAFilterConfig} from "./_types.mjs";
 */

/**
 * The SMAA filter.
 * @see {@link foundry.canvas.rendering.filters.SMAAEdgeDetectionFilter}
 * @see {@link foundry.canvas.rendering.filters.SMAABlendingWeightCalculationFilter}
 * @see {@link foundry.canvas.rendering.filters.SMAANeighborhoodBlendingFilter}
 */
class SMAAFilter extends PIXI.Filter {
  /**
   * @param {Partial<SMAAFilterConfig>} [config]
   */
  constructor({threshold=0.1, localContrastAdaptionFactor=2.0, maxSearchSteps=16, maxSearchStepsDiag=8,
    cornerRounding=25, disableDiagDetection=false, disableCornerDetection=false}={}) {
    super();
    const config = {threshold, localContrastAdaptionFactor, maxSearchSteps, maxSearchStepsDiag, cornerRounding,
      disableDiagDetection, disableCornerDetection};
    this.#edgesFilter = new SMAAEdgeDetectionFilter(config);
    this.#weightsFilter = new SMAABlendingWeightCalculationFilter(config);
    this.#blendFilter = new SMAANeighborhoodBlendingFilter();
  }

  /* -------------------------------------------- */

  /**
   * The presets.
   * @type {DeepReadonly<Record<"LOW"|"MEDIUM"|"HIGH"|"ULTRA", SMAAFilterConfig>>}
   */
  static get PRESETS() {
    return SMAAFilter.#PRESETS;
  }

  static #PRESETS = deepFreeze({
    LOW: {
      threshold: 0.15,
      localContrastAdaptionFactor: 2.0,
      maxSearchSteps: 4,
      maxSearchStepsDiag: 0,
      cornerRounding: 0,
      disableDiagDetection: true,
      disableCornerDetection: true
    },
    MEDIUM: {
      threshold: 0.1,
      localContrastAdaptionFactor: 2.0,
      maxSearchSteps: 8,
      maxSearchStepsDiag: 0,
      cornerRounding: 0,
      disableDiagDetection: true,
      disableCornerDetection: true
    },
    HIGH: {
      threshold: 0.1,
      localContrastAdaptionFactor: 2.0,
      maxSearchSteps: 16,
      maxSearchStepsDiag: 8,
      cornerRounding: 25,
      disableDiagDetection: false,
      disableCornerDetection: false
    },
    ULTRA: {
      threshold: 0.05,
      localContrastAdaptionFactor: 2.0,
      maxSearchSteps: 32,
      maxSearchStepsDiag: 16,
      cornerRounding: 25,
      disableDiagDetection: false,
      disableCornerDetection: false
    }
  });

  /* -------------------------------------------- */

  /**
   * The edge detection filter.
   * @type {SMAAEdgeDetectionFilter}
   */
  #edgesFilter;

  /* -------------------------------------------- */

  /**
   * The blending weight calculation filter.
   * @type {SMAABlendingWeightCalculationFilter}
   */
  #weightsFilter;

  /* -------------------------------------------- */

  /**
   * The neighborhood blending filter.
   * @type {SMAANeighborhoodBlendingFilter}
   */
  #blendFilter;

  /* -------------------------------------------- */

  /** @override */
  apply(filterManager, input, output, clearMode, currentState) {
    const edgesTex = filterManager.getFilterTexture();
    const blendTex = filterManager.getFilterTexture();
    this.#edgesFilter.apply(filterManager, input, edgesTex, PIXI.CLEAR_MODES.CLEAR, currentState);
    this.#weightsFilter.apply(filterManager, edgesTex, blendTex, PIXI.CLEAR_MODES.CLEAR, currentState);
    this.#blendFilter.uniforms.blendTex = blendTex;
    this.#blendFilter.apply(filterManager, input, output, clearMode, currentState);
    filterManager.returnFilterTexture(edgesTex);
    filterManager.returnFilterTexture(blendTex);
  }
}

/**
 * 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.constructor.layers) ) {
      const layer = canvas[name];
      if ( (layer !== this) && (layer instanceof InteractionLayer) ) layer.deactivate();
    }

    // Activate SceneControls if necessary
    const control = this.constructor.layerOptions.name;
    if ( (control !== ui.controls.control.name) || (tool && (tool !== ui.controls.tool.name)) ) {
      ui.controls.activate({control, 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$1.callAll(`activate${this.hookName}`, this);
    Hooks$1.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$1.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;
  }

  /* -------------------------------------------- */

  /**
   * Prepare data used by SceneControls to register tools used by this layer.
   * @returns {SceneControl|null}
   */
  static prepareSceneControls() {
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Highlight the objects of this layer.
   * @param {boolean} active    Should the objects of this layer be highlighted?
   * @protected
   */
  _highlightObjects(active) {}

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle left mouse-click events which originate from the Canvas stage.
   * @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.
   * @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.
   * @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.
   * @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.
   * @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.
   * @param {PIXI.FederatedEvent} event      The PIXI InteractionEvent which wraps a PointerEvent
   * @protected
   */
  _onDragLeftCancel(event) {}

  /* -------------------------------------------- */

  /**
   * Handle right mouse-click events which originate from the Canvas stage.
   * @param {PIXI.FederatedEvent} event      The PIXI InteractionEvent which wraps a PointerEvent
   * @protected
   */
  _onClickRight(event) {}

  /* -------------------------------------------- */

  /**
   * Handle double right mouse-click events which originate from the Canvas stage.
   * @param {PIXI.FederatedEvent} event      The PIXI InteractionEvent which wraps a PointerEvent
   * @protected
   */
  _onClickRight2(event) {}

  /* -------------------------------------------- */

  /**
   * Handle mouse-wheel events which occur for this active layer.
   * @param {WheelEvent} event                The WheelEvent initiated on the document
   * @protected
   */
  _onMouseWheel(event) {}

  /* -------------------------------------------- */

  /**
   * Handle a Cycle View keypress while this layer is active.
   * @param {KeyboardEvent} event             The cycle-view key press event
   * @returns {boolean}                       Was the event handled?
   * @protected
   */
  _onCycleViewKey(event) {
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle a Delete keypress while this layer is active.
   * @param {KeyboardEvent} event             The delete key press event
   * @returns {boolean}                       Was the event handled?
   * @protected
   */
  _onDeleteKey(event) {
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle a Select All keypress while this layer is active.
   * @param {KeyboardEvent} event             The select-all key press event
   * @returns {boolean}                       Was the event handled?
   * @protected
   */
  _onSelectAllKey(event) {
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle a Dismiss keypress while this layer is active.
   * @param {KeyboardEvent} event             The dismiss key press event
   * @returns {boolean}                       Was the event handled?
   * @protected
   */
  _onDismissKey(event) {
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle a Undo keypress while this layer is active.
   * @param {KeyboardEvent} event             The undo key press event
   * @returns {boolean}                       Was the event handled?
   * @protected
   */
  _onUndoKey(event) {
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle a Cut keypress while this layer is active.
   * @param {KeyboardEvent} event             The cut key press event
   * @returns {boolean}                       Was the event handled?
   * @protected
   */
  _onCutKey(event) {
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle a Copy keypress while this layer is active.
   * @param {KeyboardEvent} event             The copy key press event
   * @returns {boolean}                       Was the event handled?
   * @protected
   */
  _onCopyKey(event) {
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle a Paste keypress while this layer is active.
   * @param {KeyboardEvent} event             The paste key press event
   * @returns {boolean}                       Was the event handled?
   * @protected
   */
  _onPasteKey(event) {
    return false;
  }
}

/* -------------------------------------------- */

/**
 * @import {RayIntersection} from "../_types.mjs"
 * @import {LineIntersection} from "@common/utils/_types.mjs";
 */

/**
 * 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}
   */
  #angle;

  /**
   * The cached distance, computed lazily in Ray#distance
   * @type {number}
   */
  #distance;

  /* -------------------------------------------- */

  /**
   * 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 {@link foundry.utils.lineSegmentIntersection}
   *
   * @param {[number, number, number, number]} coords An array of four coordinates `[x1, y1, x2, y2]`.
   * @returns {LineIntersection|null}   The intersection result from foundry.utils.lineSegmentIntersection
   *   or `null` if no intersection was found.
   */
  intersectSegment(coords) {
    return foundry.utils.lineSegmentIntersection(this.A, this.B, {x: coords[0], y: coords[1]}, {x: coords[2], y: coords[3]});
  }
}

/**
 * @import Quadtree from "../../geometry/quad-tree.mjs";
 * @import {CanvasHistoryEvent} from "../_types.mjs"
 * @import {PlaceablesLayerOptions} from "../_types.mjs"
 * @import DocumentCollection from "@client/documents/abstract/document-collection.mjs";
 * @import BasePlaceableHUD from "@client/applications/hud/placeable-hud.mjs";
 */

/**
 * 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 container for config previews
   * @type {PIXI.Container|null}
   * @internal
   */
  _configPreview = null;

  /**
   * Preview Object Placement
   * @type {PIXI.Container|null}
   */
  preview = null;

  /**
   * Keep track of history so that CTRL+Z can undo changes.
   * @type {CanvasHistoryEvent[]}
   */
  history = [];

  /**
   * Keep track of objects copied with CTRL+C/X which can be pasted later.
   * @type {{objects: PlaceableObject[]; cut: boolean}}
   */
  clipboard = {objects: [], cut: false};

  /**
   * 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 {DocumentCollection|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 {typeof PlaceableObject}
   */
  static get placeableClass() {
    return getPlaceableObjectClass(this.documentName);
  }

  /* -------------------------------------------- */

  /**
   * To know wheter this layer has a preview object or not.
   * @returns {boolean}
   */
  get hasPreview() {
    return !!this.preview?.children.length;
  }

  /* -------------------------------------------- */

  /**
   * 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 && placeable.renderable ) yield placeable;
    }
  }

  /* -------------------------------------------- */

  /**
   * Track the set of PlaceableObjects on this layer which are currently controlled.
   * @type {Map<string,PlaceableObject>}
   */
  get controlledObjects() {
    return this.#controlledObjects;
  }

  #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({x: point.x, y: point.y}, {
      mode: grid.isHexagonal && !this.options.controllableObjects
        ? M.CENTER | M.VERTEX | M.EDGE_MIDPOINT
        : M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT,
      resolution: 1
    });
  }

  /* -------------------------------------------- */
  /*  Rendering
  /* -------------------------------------------- */

  /** @override */
  _highlightObjects(active) {
    if ( !this.objects || !this.interactiveChildren ) return;
    this.highlightObjects = active;
    for ( const object of this.placeables ) object.renderFlags.set({refreshState: true});
  }

  /* -------------------------------------------- */

  /**
   * Obtain an iterable of objects which should be added to this PlaceablesLayer
   * @returns {DocumentCollection|[]}
   */
  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$1(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 config preview container which is always above objects
    this._configPreview = this.addChild(new PIXI.Container());
    this._configPreview.eventMode = "none";

    // Create preview container which is always above objects
    this.preview = this.addChild(new PIXI.Container());
    this.preview.eventMode = "none";

    // 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) {
    const object = new this.constructor.placeableClass(document);
    document._object = object;
    return object;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {
    this.history = [];
    if ( this.options.controllableObjects ) {
      this.controlledObjects.clear();
    }
    if ( this.hud ) this.hud.close();
    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.setAllRenderFlags({refreshState: true});
  }

  /* -------------------------------------------- */

  /** @override */
  _deactivate() {
    this.objects.visible = false;
    this.releaseAll();
    this.setAllRenderFlags({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 ( const 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?.close();

    // 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 {-1|0|1} [options.dz=0]             Movement direction along the z-axis (elevation)
   * @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, dz=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 ( ![-1, 0, 1].includes(dz) ) throw new Error("Invalid argument: dz must be -1, 0, or 1");
    if ( !dx && !dy && !dz ) 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;

    // Conceal any active HUD
    this.hud?.close();

    // Commit updates to the Scene
    const [updateData, updateOptions={}] = rotate
      ? this._prepareKeyboardRotationUpdates(objects, dx, dy, dz)
      : this._prepareKeyboardMovementUpdates(objects, dx, dy, dz);
    await canvas.scene.updateEmbeddedDocuments(this.constructor.documentName, updateData, updateOptions);
    return objects;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the updates and update options for moving the given placeable objects via keyboard.
   * @param {PlaceableObject[]} objects
   * @param {-1|0|1} dx
   * @param {-1|0|1} dy
   * @param {-1|0|1} dz
   * @returns {[updates: object[], options?: object]}
   * @see {@link PlaceablesLayer#moveMany}
   * @internal
   */
  _prepareKeyboardMovementUpdates(objects, dx, dy, dz) {
    return [objects.map(obj => ({_id: obj.id, ...obj._getShiftedPosition(dx, dy, dz)}))];
  }

  /* -------------------------------------------- */

  /**
   * Prepare the updates and update options for rotating the given placeable objects via keyboard.
   * @param {PlaceableObject[]} objects
   * @param {-1|0|1} dx
   * @param {-1|0|1} dy
   * @param {-1|0|1} dz
   * @returns {[updates: object[], options?: object]}
   * @see {@link PlaceablesLayer#moveMany}
   * @internal
   */
  _prepareKeyboardRotationUpdates(objects, dx, dy, dz) {

    // Define rotation angles
    let angles;
    if ( !canvas.grid.isHexagonal ) angles = [45, 135, 225, 315];
    else if ( canvas.grid.columns ) angles = [60, 120, 240, 300];
    else angles = [30, 150, 210, 330];

    // Determine the rotation angle
    let rotation;
    const offsets = [dx, dy];
    if ( offsets.equals([0, 1]) ) rotation = 0;
    else if ( offsets.equals([-1, 1]) ) rotation = angles[0];
    else if ( offsets.equals([-1, 0]) ) rotation = 90;
    else if ( offsets.equals([-1, -1]) ) rotation = angles[1];
    else if ( offsets.equals([0, -1]) ) rotation = 180;
    else if ( offsets.equals([1, -1]) ) rotation = angles[2];
    else if ( offsets.equals([1, 0]) ) rotation = 270;
    else if ( offsets.equals([1, 1]) ) rotation = angles[3];
    else rotation = 0;

    return [objects.map(obj => ({_id: obj.id, rotation}))];
  }

  /* -------------------------------------------- */

  /**
   * Assign a set of render flags to all placeables in this layer.
   * @param {Record<string, boolean>} flags     The flags to set
   */
  setAllRenderFlags(flags) {
    for ( const placeable of this.placeables ) placeable.renderFlags.set(flags);
  }

  /* -------------------------------------------- */

  /**
   * An internal helper method to identify the array of PlaceableObjects which can be moved or rotated.
   * @param {string[]|undefined} 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 {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);
  }

  /* -------------------------------------------- */

  /**
   * An internal helper method to identify the array of PlaceableObjects which can be copied/cut.
   * @param {object} options         Additional options
   * @param {boolean} options.cut    Cut instead of copy?
   * @returns {PlaceableObject[]}    An array of objects which can be copied/cut
   * @internal
   */
  _getCopyableObjects(options) {
    if ( this.options.controllableObjects ) return this.controlled.filter(object => object.isOwner);
    if ( this.hover?.isOwner ) return [this.hover];
    return [];
  }

  /* -------------------------------------------- */

  /**
   * 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("CONTROLS.EmptyUndoHistory", {format: {
        type: game.i18n.localize(getDocumentClass$1(type).metadata.label)}});
      return [];
    }
    const event = this.history.pop();

    // Routing event to the correct undo method
    switch ( event.type ) {
      case "create":
        return this._onUndoCreate(event);
      case "update":
        return this._onUndoUpdate(event);
      case "delete":
        return this._onUndoDelete(event);
      default:
        return [];
    }
  }

  /* -------------------------------------------- */

  /**
   * Undo creation with deletion workflow
   * @param {Event} event
   * @returns {Promise<Document[]>}     An array of documents which were modified by the undo operation
   * @protected
   */
  async _onUndoCreate(event) {
    const type = this.constructor.documentName;
    const ids = event.data.map(d => d._id);
    const deleted = await canvas.scene.deleteEmbeddedDocuments(type, ids, {...event.options, isUndo: true});
    if ( deleted.length !== 1 ) ui.notifications.info("CONTROLS.UndoCreateObjects", {format: {
        count: deleted.length, type: game.i18n.localize(getDocumentClass$1(type).metadata.label)}});
    return deleted;
  }

  /* -------------------------------------------- */

  /**
   * Undo updates with update workflow.
   * @param {Event} event
   * @returns {Promise<Document[]>}     An array of documents which were modified by the undo operation
   * @protected
   */
  async _onUndoUpdate(event) {
    const type = this.constructor.documentName;
    return canvas.scene.updateEmbeddedDocuments(type, event.data, {...event.options, isUndo: true});
  }

  /* -------------------------------------------- */

  /**
   * Undo deletion with creation workflow.
   * @param {Event} event
   * @returns {Promise<Document[]>}     An array of documents which were modified by the undo operation
   * @protected
   */
  async _onUndoDelete(event) {
    const type = this.constructor.documentName;
    const created = await canvas.scene.createEmbeddedDocuments(type, event.data,
      {...event.options, isUndo: true, keepId: true});
    if ( created.length !== 1 ) ui.notifications.info("CONTROLS.UndoDeleteObjects", {format: {
        count: created.length, type: game.i18n.localize(getDocumentClass$1(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$1(type).metadata.label);
    return DialogV2.confirm({
      window: {title: "CONTROLS.ClearAll"},
      content: `<p>${game.i18n.format("CONTROLS.ClearAllHint", {type: typeLabel})}</p>`,
      yes: {
        callback: async () => {
          const deleted = await canvas.scene.deleteEmbeddedDocuments(type, [], {deleteAll: true});
          ui.notifications.info("CONTROLS.DeletedObjects", {format: {count: deleted.length, type: typeLabel}});
        }
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Record a new CRUD event in the history log so that it can be undone later.
   * The base implemenation calls {@link PlaceablesLayer#_storeHistory} without
   * passing the given options. Subclasses may override this function and can call
   * {@link PlaceablesLayer#_storeHistory} themselves to pass options as needed.
   * @param {"create"|"update"|"delete"} type    The event type
   * @param {object[]} data                      The create/update/delete data
   * @param {object} [options]                   The create/update/delete options
   */
  storeHistory(type, data, options) {
    this._storeHistory(type, data);
  }

  /* -------------------------------------------- */

  /**
   * Record a new CRUD event in the history log so that it can be undone later.
   * Updates without changes are filtered out unless the `diff` option is set to false.
   * This function may not be overridden.
   * @param {"create"|"update"|"delete"} type    The event type
   * @param {object[]} data                      The create/update/delete data
   * @param {object} [options]                   The options of the undo operation
   * @protected
   */
  _storeHistory(type, data, options={}) {
    if ( data.some(d => !("_id" in d)) ) throw new Error("The data entries must contain the _id key");

    // Filter entries without changes
    if ( (type === "update") && (options.diff !== false) ) data = data.filter(d => Object.keys(d).length > 1);

    // Don't store empty history data
    if ( data.length === 0 ) return;

    // Drop old history entries
    if ( this.history.length >= 100 ) this.history.shift();

    // Add entry to history
    this.history.push({type, data, options});
  }

  /* -------------------------------------------- */

  /**
   * Copy (or cut) currently controlled PlaceableObjects, ready to paste back into the Scene later.
   * @param {object} [options]                    Additional options
   * @param {boolean} [options.cut=false]         Cut instead of copy?
   * @returns {ReadonlyArray<PlaceableObject>}    The Array of copied PlaceableObject instances
   */
  copyObjects({cut=false}={}) {
    const objects = this._getCopyableObjects({cut});
    this.clipboard = {objects, cut};
    if ( objects.length ) {
      const typeLabel = game.i18n.localize(getDocumentClass$1(this.constructor.documentName).metadata.label);
      ui.notifications.info(cut ? "CONTROLS.CutObjects" : "CONTROLS.CopiedObjects", {format: {count:
        objects.length, type: typeLabel}});
    }
    return objects;
  }

  /* -------------------------------------------- */

  /**
   * 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}={}) {
    const {objects, cut} = this.clipboard;
    if ( !objects.length ) return [];

    // Offset of the destination position relative to the center
    const origin = this.constructor.placeableClass._getCopiedObjectsOrigin(objects);
    const offset = {x: position.x - origin.x, y: position.y - origin.y};

    // Iterate over objects
    const data = [];
    for ( const object of objects ) {
      data.push(object._pasteObject(offset, {hidden, snap}));
    }

    // Call hooks
    const allowed = Hooks$1.call(`paste${this.constructor.documentName}`, objects, data, {cut});
    if ( !allowed ) return [];

    const sourceScene = objects[0].document.parent;
    const targetScene = canvas.scene;
    let pasted;

    // Copy & Paste
    if ( !cut ) pasted = await canvas.scene.createEmbeddedDocuments(this.constructor.documentName, data);

    // Cut & Paste in the same Scene
    else if ( sourceScene === targetScene ) {

      // Set document IDs
      for ( let i = 0; i < objects.length; i++ ) data[i]._id = objects[i].document.id;

      // Clear the clipboard
      this.clipboard.objects = [];

      // Update the cut objects
      pasted = await canvas.scene.updateEmbeddedDocuments(this.constructor.documentName, data, {isPaste: true});
    }

    // Cut & Paste across Scenes
    else {

      // Reuse the document ID if it isn't used in the target scene; otherwise generate a new one
      const cutIds = {};
      for ( let i = 0; i < objects.length; i++ ) {
        const object = objects[i].document;
        let id = object.id;
        while ( this.documentCollection.has(id) ) id = foundry.utils.randomID();
        data[i]._id = id;
        cutIds[id] = object.id;
      }

      // Clear the clipboard
      this.clipboard.objects = [];

      // Create pasted objects in the target scene
      pasted = await targetScene.createEmbeddedDocuments(this.constructor.documentName, data, {keepId: true});

      // Create the mapping of cut IDs to pasted UUIDs
      const replacements = {};
      for ( const document of pasted ) replacements[cutIds[document.id]] = document.uuid;

      // Delete cut objects from the source scene
      await sourceScene.deleteEmbeddedDocuments(this.constructor.documentName, Object.keys(replacements),
        {replacements});
    }

    ui.notifications.info("CONTROLS.PastedObjects", {format: {count: pasted.length,
      type: game.i18n.localize(getDocumentClass$1(this.constructor.documentName).metadata.label)}});

    return pasted;
  }

  /* -------------------------------------------- */

  /**
   * 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$1(documentName);
    const document = new cls(createData, {parent: canvas.scene});
    if ( !document.canUserModify(game.user, "create") ) {
      return ui.notifications.warn("PERMISSION.WarningNoCreate", {format: {document: documentName}});
    }

    const object = new CONFIG[documentName].objectClass(document);
    document._object = object;
    this.activate();
    this.preview.addChild(object);
    await object.draw();

    if ( renderSheet ) object.sheet.render({force: true, position: {top, left}});
    return object;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  _onClickLeft(event) {
    if ( !event.target.hasActiveHUD ) this.hud?.close();
    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$1(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?.close();
  }

  /* -------------------------------------------- */

  /** @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 */
  _onDeleteKey(event) {

    // Identify objects which are candidates for deletion
    const objects = this.options.controllableObjects ? this.controlled : (this.hover ? [this.hover] : []);
    if ( !objects.length ) return false;

    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return true;
    }

    // Restrict to objects which can be deleted
    const toDelete = objects.reduce((docs, o) => {
      const isDragged = o.interactionState === MouseInteractionManager.INTERACTION_STATES.DRAG;
      if ( isDragged || o.document.locked || !o.document.canUserModify(game.user, "delete") ) return docs;
      if ( this.hover === o ) this.hover = null;
      docs.push(o.document);
      return docs;
    }, []);
    if ( toDelete.length ) this.#onDeleteKey(toDelete);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Handle the deletion of the given documents.
   * @param {Document} toDelete    The documents that are to be deleted
   * @returns {Promise<void>}
   */
  async #onDeleteKey(toDelete) {
    if ( this.options.confirmDeleteKey ) {
      const confirmed = await this._confirmDeleteKey(toDelete);
      if ( !confirmed ) return;
    }
    const deleted = await canvas.scene.deleteEmbeddedDocuments(this.constructor.documentName, toDelete.map(d => d.id));
    if ( deleted.length > 1 ) {
      ui.notifications.info("CONTROLS.DeletedObjects", {format: {count: deleted.length,
          type: game.i18n.localize(getDocumentClass$1(this.constructor.documentName).metadata.label)}});
    }
  }

  /* -------------------------------------------- */

  /**
   * Confirm deletion via the delete key.
   * Called only if {@link foundry.canvas.layers.types.PlaceablesLayerOptions#confirmDeleteKey} is true.
   * @param {Document} documents    The documents that will be deleted on confirmation.
   * @returns {Promise<boolean>}    True if the deletion is confirmed to proceed.
   * @protected
   */
  async _confirmDeleteKey(documents) {
    return DialogV2.confirm({
      window: {
        title: game.i18n.format("DOCUMENT.Delete", {
          type: game.i18n.localize(getDocumentClass$1(this.constructor.documentName).metadata.label)
        }) // FIXME: double localization
      },
      content: `<p>${game.i18n.localize("AreYouSure")}</p>`
    });
  }

  /* -------------------------------------------- */

  /** @override */
  _onSelectAllKey(event) {
    this.controlAll();
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _onDismissKey(event) {
    if ( !game.user.isGM || !this.controlled.length ) return false;
    if ( !this.preview?.children.length ) this.releaseAll();
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _onUndoKey(event) {
    this.undoHistory();
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _onCutKey(event) {
    this.copyObjects({cut: true});
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _onCopyKey(event) {
    this.copyObjects();
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _onPasteKey(event) {
    if ( !this.clipboard.objects.length ) return false;
    this.pasteObjects(canvas.mousePosition, {hidden: event.altKey, snap: !event.shiftKey});
    return true;
  }

  /* -------------------------------------------- */
  /*  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
  }
}

/**
 * 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;
  }
}

/**
 * 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
    });
  }
}

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
];

/**
 * @import {CanvasDimensions, CanvasPerformanceSettings, CanvasSupportedComponents, CanvasViewPosition,
 *   Point} from "@client/_types.mjs";
 * @import {PingOptions} from "./interaction/_types.mjs";
 * @import RenderedCanvasGroup from "./groups/rendered.mjs";
 * @import FogManager from "./perception/fog.mjs";
 * @import EnvironmentCanvasGroup from "./groups/environment.mjs";
 * @import PrimaryCanvasGroup from "./groups/primary.mjs";
 * @import EffectsCanvasGroup from "./groups/effects.mjs";
 * @import CanvasVisibility from "./groups/visibility.mjs";
 * @import InterfaceCanvasGroup from "./groups/interface.mjs";
 * @import OverlayCanvasGroup from "./groups/overlay.mjs";
 * @import HeadsUpDisplayContainer from "@client/applications/hud/container.mjs";
 * @import SceneManager from "./scene-manager.mjs";
 * @import BaseGrid from "@common/grid/base.mjs";
 * @import {Color} from "@common/utils/_module.mjs";
 */

/**
 * 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}
 *
 * @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.
 * ```
 */
let Canvas$1 = class Canvas {
  constructor() {
    Object.defineProperty(this, "edges", {value: new CanvasEdges()});
    Object.defineProperty(this, "perception", {value: new PerceptionManager()});
  }

  /**
   * Mouse move handler priorities.
   * @enum number
   */
  static MOUSE_MOVE_HANDLER_PRIORITIES = Object.freeze({
    HIGH: 75,
    MEDIUM: 50,
    LOW: 25
  });

  /**
   * A set of blur filter instances which are modified by the zoom level and the "soft shadows" setting
   * @type {Set<PIXI.Filter>}
   */
  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: typeof PIXI.Filter; 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.
   * To be loaded, a texture must be added to this record before or during the "canvasInit" hook.
   *
   * After textures are loaded for the Scene, the values of this record are replaced with direct references to the
   * PIXI.Textures that were loaded.
   *
   * @type {Record<string, string|PIXI.Texture|PIXI.Spritesheet>}
   * @property {string|PIXI.Texture} [background]     A specific background texture used for the Scene
   * @property {string|PIXI.Texture} [foreground]     A specific foreground texture used for the Scene
   * @property {string|PIXI.Texture} [fogOverlay]     A specific fog overlay texture used for the Scene
   */
  sceneTextures = {};

  /**
   * Record framerate performance data.
   * @type {{values: number[]; render: number}}
   */
  fps = {values: [], renderTime: 0};

  /**
   * The singleton interaction manager instance which handles mouse interaction on the Canvas.
   * @type {MouseInteractionManager}
   */
  mouseInteractionManager;

  /**
   * Configured performance settings which affect the behavior of the Canvas and its renderer.
   * @type {CanvasPerformanceSettings}
   */
  performance;

  /**
   * 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];

  /**
   * The framenbuffer snapshot.
   * @type {FramebufferSnapshot}
   */
  snapshot;

  /**
   * 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 {@link Canvas#environment}
   * @see {@link Canvas#interface}
   * @type {RenderedCanvasGroup}
   */
  rendered;

  /**
   * A singleton CanvasEdges instance.
   * @type {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 {@link Canvas#primary}
   * @see {@link Canvas#effects}
   * @type {EnvironmentCanvasGroup}
   */
  environment;

  /**
   * The primary Canvas group which generally contains tangible physical objects which exist within the Scene.
   * This group is a {@link foundry.canvas.containers.CachedContainer}
   * which is rendered to the Scene as a {@link foundry.canvas.containers.SpriteMesh}.
   * This allows the rendered result of the Primary Canvas Group to be affected by a
   * {@link foundry.canvas.rendering.shaders.BaseSamplerShader}.
   * @type {PrimaryCanvasGroup}
   */
  primary;

  /**
   * The effects Canvas group which modifies the result of the {@link foundry.canvas.groups.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 foundry.canvas.layers.InteractionLayer} instances of the interface group provide different
   * control sets for interacting with different types of {@link foundry.abstract.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 {HeadsUpDisplayContainer}
   */
  hud;

  /**
   * Position of the mouse on stage.
   * @type {PIXI.Point}
   */
  mousePosition = new PIXI.Point();

  /**
   * Previous position of the mouse on stage.
   * @type {PIXI.Point}
   */
  previousMousePosition = new PIXI.Point();

  /**
   * Registered mouse move handlers.
   * @type {Array[{Function, number, Function, boolean}]}
   */
  #mouseMoveHandlers = [];

  /**
   * The DragDrop instance which handles interactivity resulting from DragTransfer events.
   * @type {DragDrop}
   */
  // eslint-disable-next-line no-unused-private-class-members
  #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[]}}
   */
  #reload = {};

  /**
   * Track the last automatic pan time to throttle
   * @type {number}
   */
  #panTime = 0;

  /* -------------------------------------------- */

  /**
   * Force snapping to grid vertices?
   * @type {boolean}
   */
  get forceSnapVertices() {
    return this.#forceSnapVertices;
  }

  set forceSnapVertices(value) {
    if ( this.#forceSnapVertices === value ) return;
    this.#forceSnapVertices = value;
    for ( const control of Object.values(ui.controls.controls) ) {
      if ( control.tools.snap ) control.tools.snap.active = value;
    }
    ui.controls.render();
  }

  #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;

  /* -------------------------------------------- */

  /**
   * 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 {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                              */
  /* -------------------------------------------- */

  /**
   * Get the dimensions of the displayed Scene, or null if the Canvas is blank.
   * @returns {CanvasDimensions|null}
   */
  #getDimensions() {
    if ( !this.#scene ) return null;
    const d = this.#scene.getDimensions();
    const s = this.#scene._source.grid.size / 100;
    d.uiScale = s * game.settings.get("core", "uiConfig").uiScale;
    const {innerWidth, innerHeight} = window;
    let minScale = CONFIG.Canvas.minZoom;
    if ( minScale === undefined ) {
      const padding = d.size;
      const paddedSceneWidth = d.width + (2 * padding);
      const paddedSceneHeight = d.height + (2 * padding);
      minScale = Math.min(innerWidth / paddedSceneWidth, innerHeight / paddedSceneHeight, 1);
    }
    let maxScale = CONFIG.Canvas.maxZoom;
    if ( maxScale === undefined ) {
      const grid = this.#scene.grid;
      const factor = 3 * (this.#scene._source.grid.size / grid.size);
      maxScale = Math.max(Math.min(innerWidth / grid.sizeX, innerHeight / grid.sizeY) / factor, minScale);
    }
    d.scale = {min: minScale, max: maxScale, default: Math.clamp(0.5 / s, minScale, maxScale)};
    return d;
  }

  /* -------------------------------------------- */

  /**
   * 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 foundry.applications.ux.DragDrop.implementation({
      callbacks: {
        drop: this.#onDrop.bind(this)
      }
    }).bind(canvas);

    // Create heads up display
    Object.defineProperty(this, "hud", {value: new foundry.applications.hud.HeadsUpDisplayContainer()});

    // 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$1.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();

    // Disable AccessibilityManager, which interferes with the TAB keybinding
    PIXI.extensions.remove(PIXI.AccessibilityManager);

    // 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();

    // Configure cursors
    this.#configureCursors();

    // 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
    Object.defineProperty(this, "snapshot", {value: new FramebufferSnapshot()});
  }

  /* -------------------------------------------- */

  /**
   * Attach specific behaviors to the PIXI runners.
   * - contextChange => Remap all the blend modes
   */
  #attachToRunners() {
    const contextChange = {
      contextChange: () => {
        console.debug(`${CONST.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 ( const [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 {object} [options]            Blur options.
   */
  #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();
  }

  /* -------------------------------------------- */

  /**
   * Initialize the fog manager.
   */
  #initializeFogManager() {
    if ( this.fog && (this.fog.constructor !== CONFIG.Canvas.fogManager) ) {
      this.fog.destroy();
      this.fog = undefined;
    }
    this.fog ??= new CONFIG.Canvas.fogManager();
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * Configure custom cursors.
   */
  #configureCursors() {
    const { cursorStyles } = this.app.renderer.events;
    for ( const cursor in CONFIG.cursors ) {
      cursorStyles[cursor === "grab-down" ? "grabbing" : cursor] = `var(--cursor-${cursor})`;
    }
    Object.defineProperty(this.app.renderer.events, "setCursor", {
      value: function(mode) {
        mode ||= "default";
        const S = MouseInteractionManager.INTERACTION_STATES;
        const m = canvas.currentMouseManager;
        if ( m && (m !== canvas.mouseInteractionManager) ) {
          if ( m.state >= S.GRABBED ) mode = "grabbing";
          else if ( m.state === S.CLICKED ) {
            if ( mode === "default" ) mode = "default-down";
            else if ( mode === "pointer" ) mode = "pointer-down";
          }
        } else if ( canvas.mouseInteractionManager?.state >= S.CLICKED ) {
          if ( mode === "default" ) mode = "default-down";
          else if ( mode === "pointer" ) mode = "pointer-down";
          else if ( mode === "grab" ) mode = "grabbing";
        }
        if ( this.currentCursor === mode ) return;
        this.currentCursor = mode;
        const style = this.cursorStyles[mode];
        if ( style ) {
          switch ( typeof style ) {
            case "string": this.domElement.style.cursor = style; break;
            case "function": style(mode); break;
            case "object": Object.assign(this.domElement.style, style); break;
          }
        } else if ( (typeof mode === "string") && !Object.hasOwn(this.cursorStyles, mode) ) {
          this.domElement.style.cursor = mode;
        }
      },
      configurable: true,
      writable: true
    });
  }

  /* -------------------------------------------- */
  /*  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$1) || (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
    this.#dimensions = this.#getDimensions();
    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 and KTX2 transcoders, loaders and parsers
    await TextureLoader.initializeBasisTranscoder();

    // Call Canvas initialization hooks
    this.loadTexturesOptions = {expireCache: true, additionalSources: []};
    this.visibilityOptions = {persistentVision: false};
    console.log(`${CONST.vtt} | Drawing game canvas for scene ${this.#scene.name}`);
    await this.#callManagerEvent("_onInit");
    await this.#callManagerEvent("_registerHooks");
    Hooks$1.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;
    this.stage.filterArea = canvas.app.screen;


    // Initialize the camera view position (although the canvas is hidden)
    this.initializeCanvasPosition();

    // Initialize blur parameters
    this.#initializeBlur(this.blurOptions);

    // Initialize fog manager
    this.#initializeFogManager();

    // Load required textures
    try {
      await this.#loadTextures();
    } catch(err) {
      Hooks$1.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 SMAAFilter()];

    // Configure TokenRing
    CONFIG.Token.ring.ringClass.createAssetsUVs();

    // Activate ticker render workflows
    this.#activateTicker();

    // Draw canvas groups
    await this.#callManagerEvent("_onDraw");
    Hooks$1.callAll("canvasDraw", this);
    for ( const name of Object.keys(CONFIG.Canvas.groups) ) {
      const group = this[name];
      try {
        await group.draw();
      } catch(err) {
        Hooks$1.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$1.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)
    };

    // Terminate all ongoing animations
    await CanvasAnimation.terminateAll();

    // Deactivate ticker workflows
    this.#deactivateTicker();
    this.deactivateFPSMeter();

    // Deactivate every layer before teardown
    for ( const layer of this.layers.reverse() ) {
      if ( layer instanceof InteractionLayer ) layer.deactivate();
    }

    // Trigger Region status events
    await this.#handleRegionBehaviorStatusEvents(false);

    // Call tear-down hooks
    await this.#callManagerEvent("_deactivateHooks");
    await this.#callManagerEvent("_onTearDown");
    Hooks$1.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 ( const layer of this.layers.reverse() ) {
      await layer.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_(UN)VIEW events that are triggered when the Scene is (un)viewed.
   * @param {boolean} viewed    Is the scene viewed or not?
   */
  async #handleRegionBehaviorStatusEvents(viewed) {
    const eventName = viewed ? CONST.REGION_EVENTS.BEHAVIOR_VIEWED : CONST.REGION_EVENTS.BEHAVIOR_UNVIEWED;
    const results = await Promise.allSettled(this.scene.regions.map(region => [
      region._handleEvent({name: eventName, data: {}, region, user: game.user}),
      /** @deprecated since v13 */
      region._handleEvent({name: "behaviorStatus", data: {viewed}, region, user: game.user})
    ]).flat());
    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 {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(`${CONST.vtt} | Skipping game canvas - no active scene.`);
    canvas.app.view.style.display = "none";
    ui.controls.render({reset: true});
    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
    await 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: [], plannedMovements: null,
      targets: []});
    game.socket.emit("getUserActivity");

    // Re-render specific UI elements
    await ui.nav.render(); // Re-render scene navigation since this doesn't happen in activity handling for yourself
    await ui.controls.render({reset: true}); // Rebuild Scene-specific controls

    // 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 ??= this.dimensions.scale.default;
      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;

    // Initial tokens based on reload data
    const isReload = this.#reload.scene === this.#scene.id;
    let controlledTokens = [];
    if ( isReload ) {
      controlledTokens = this.#reload.controlledTokens.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 ( const 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});
    }

    // Reinitialize Token targets
    if ( isReload ) game.user._onUpdateTokenTargets(this.#reload.targetedTokens);

    // 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.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.values.length = 0;
    ui.players.refreshFPS({deactivate: true});
  }

  /* -------------------------------------------- */

  /**
   * Measure average framerate per second over the past 30 frames
   */
  #measureFPS() {
    const lastTime = this.app.ticker.lastTime;
    this.fps.values.push(1000 / this.app.ticker.elapsedMS);
    if ( this.fps.values.length > 60 ) this.fps.values.shift();
    if ( (lastTime - this.fps.renderTime) < 250 ) return;
    this.fps.renderTime = lastTime;
    ui.players.refreshFPS();
  }

  /* -------------------------------------------- */

  /**
   * Pan the canvas to a certain position and a certain zoom level.
   * @param {Partial<CanvasViewPosition>} [position]    The canvas position to pan to
   */
  pan({x, y, scale}={}) {

    // 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$1.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();
  }

  /* -------------------------------------------- */

  /**
   * @typedef CanvasAnimationPanOptions
   * @param {number} [duration=250]  The total duration of the animation in milliseconds; used if speed is not set
   * @param {number} [speed]         The speed of animation in pixels per second; overrides duration if set
   * @param {Function} [easing]      An easing function passed to CanvasAnimation animate
   */

  /**
   * 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 & CanvasAnimationPanOptions} view    The desired view parameters
   * @returns {Promise<boolean>}    A Promise which resolves once the animation has been completed
   */
  async animatePan({x, y, scale, duration=250, speed, easing}={}) {

    // Constrain the resulting dimensions and construct animation attributes
    const position = {...this.scene._viewPosition};
    const constrained = this._constrainView({x, y, scale});

    // Determine the animation duration to reach the target
    if ( speed ) {
      const ray = new Ray(this.stage.pivot, constrained);
      duration = Math.round(ray.distance * 1000 / speed);
    }

    // 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 ( const layer of this.layers ) {
      if ( !(layer instanceof InteractionLayer) ) continue;
      layer._highlightObjects(active);
    }
    Hooks$1.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("SHIFT");
    const isAlert = game.keyboard.isModifierActive("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 {Partial<CanvasViewPosition>} position    The uncontrained camera 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
    scale = Math.clamp(scale, d.scale.min, d.scale.max);

    // 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 if it has the `blur` property.
   * @param {PIXI.Filter} filter    The filter instance to add
   * @returns {PIXI.Filter}         The filter that was passed to this function
   */
  addBlurFilter(filter) {
    if ( filter.blur === undefined ) return filter;
    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
   */
  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 = {
      dragLeftStart: this.#canDragLeftStart.bind(this)
    };
    const mgr = new MouseInteractionManager(this.stage, this.stage, permissions, callbacks);
    this.mouseInteractionManager = mgr.activate();

    // Debug average FPS
    this.activateFPSMeter();
    this.dt = 0;

    // Add a listener for cursor movement
    this.stage.on("pointermove", event => {
      event.getLocalPosition(this.stage, this.mousePosition);
      this.#throttleOnMouseMove();
    });
  }

  /* -------------------------------------------- */

  /**
   * Register a new onMouseMove handler with an optional priority.
   * @param {Function} handler The function to call on mouse move.
   * @param {number} [priority=0] Optional priority. Higher values are called earlier.
   * @param {object} [context=this] The context in which the handler should be executed.
   * @param {boolean} [strict=false] To know if the handler should be called on real pointer move only (not simulated)
   */
  registerMouseMoveHandler(handler, priority=0, context=this, strict=false) {
    if ( typeof handler !== "function" ) {
      throw new Error("Attempted to register a non-function as a mouse move handler");
    }

    // Check if the handler is already registered
    const existing = this.#mouseMoveHandlers.find(h => h.handler === handler);
    if ( existing ) return;

    // Add the handler with the given priority
    this.#mouseMoveHandlers.push({handler, priority, context, strict});

    // Sort handlers by priority, highest priority first
    this.#mouseMoveHandlers.sort((a, b) => b.priority - a.priority);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse movement on the game canvas.
   */
  #onMouseMove() {
    const currentPos = this.mousePosition;
    const hasMouseMoved = (currentPos.x !== this.previousMousePosition.x)
      || (currentPos.y !== this.previousMousePosition.y);
    this.previousMousePosition.x = currentPos.x;
    this.previousMousePosition.y = currentPos.y;

    // Fast loop to call each registered mouse move handler in order of priority
    const handlers = this.#mouseMoveHandlers;
    const hl = handlers.length;
    for ( let i = 0; i < hl; i++ ) {
      const handlerInfo = handlers[i];
      if ( handlerInfo.strict && !hasMouseMoved ) continue;
      handlerInfo.handler.call(handlerInfo.context, currentPos, hasMouseMoved);
    }
  }

  /* -------------------------------------------- */

  /**
   * 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;
    const isTokenLayer = layer instanceof foundry.canvas.layers.TokenLayer;
    if ( isTokenLayer && 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
    const isTokenLayer = layer instanceof foundry.canvas.layers.TokenLayer;
    if ( isTokenLayer && 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 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 ruler measurement potentially
    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;

    // Cancel ruler measurement potentially
    if ( event.interactionData.ruler ) return canvas.controls.ruler._onDragCancel(event);

    // Clear selection
    if ( canvas.controls.select.active ) {
      canvas.controls.select.active = false;
      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.active ) 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
    const 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
   * @internal
   */
  _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
   * @internal
   */
  _onResize() {
    if ( !this.#ready ) return false;

    // Update dimensions
    this.#dimensions = this.#getDimensions();

    // 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
   * @internal
   */
  _onMouseWheel(event) {
    const 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
   */
  #onDrop(event) {
    event.preventDefault();
    const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);

    // 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;

    const allowed = Hooks$1.call("dropCanvasData", this, data, event);
    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.
   * @type {{OBJECTS: Set<RenderFlagObject>; PERCEPTION: Set<RenderFlagObject>}}
   */
  pendingRenderFlags;

  /**
   * Cached references to bound ticker functions which can be removed later.
   * @type {Record<string, Function>}
   */
  #tickerFunctions = {};

  /* -------------------------------------------- */

  /**
   * Load all textures that are initially declared as required for rendering the Scene.
   * Populate the sceneTextures record with loaded textures.
   * @returns {Promise<void>}
   */
  async #loadTextures() {
    await TextureLoader.loadSceneTextures(this.#scene, this.loadTexturesOptions);
    for ( const k in this.sceneTextures ) {
      if ( typeof k === "string" ) {
        const tex = foundry.canvas.getTexture(k);
        if ( tex ) this.sceneTextures[k] = tex;
        else delete this.sceneTextures[k];
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * 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;

    // Test support for reading pixels in RED/UNSIGNED_BYTE format
    const 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;
    }

    // Query some important gl parameters
    supported.maxVertexVectors = gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS);
    supported.maxFragmentVectors = gl.getParameter(gl.MAX_FRAGMENT_UNIFORM_VECTORS);
    supported.maxVertexAttributes = gl.getParameter(gl.MAX_VERTEX_ATTRIBS);
    supported.maxVaryingVectors = gl.getParameter(gl.MAX_VARYING_VECTORS);
    supported.maxTextureUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS);
    supported.maxVertexTextureUnits = gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS);

    return supported;
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */
  /**
   * @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;
  }
};

/**
 * The primary Canvas group which generally contains tangible physical objects which exist within the Scene.
 * This group is a {@link foundry.canvas.containers.CachedContainer}
 * which is rendered to the Scene as a {@link foundry.canvas.containers.SpriteMesh}.
 * This allows the rendered result of the Primary Canvas Group to be affected by a
 * {@link foundry.canvas.rendering.shaders.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);
    canvas.registerMouseMoveHandler(this._onMouseMove, Canvas$1.MOUSE_MOVE_HANDLER_PRIORITIES.LOW, this);
  }

  /**
   * 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<PrimarySpriteMesh>}
   */
  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 {PrimarySpriteMesh}
   */
  background;

  /**
   * The primary foreground image configured for the Scene, rendered as a SpriteMesh.
   * @type {PrimarySpriteMesh}
   */
  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, PrimaryGraphics>}
   */
  drawings = new foundry.utils.Collection();

  /**
   * The collection of SpriteMesh objects which are rendered in the Scene.
   * @type {Collection<string, PrimarySpriteMesh>}
   */
  tokens = new foundry.utils.Collection();

  /**
   * The collection of SpriteMesh objects which are rendered in the Scene.
   * @type {Collection<string, PrimarySpriteMesh>}
   */
  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|null}
   */
  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|null}
   */
  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}
   * @internal
   */
  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.
   * @param {PIXI.Point} currentPos   Current mouse position
   * @param {boolean} hasMouseMoved   Has the mouse been moved (or it is a simulated mouse move event)?
   * @internal
   */
  _onMouseMove(currentPos, hasMouseMoved) {
    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 = object._hoverFadeState.hoveredTime;
      object._hoverFadeState.hoveredTime = time;
    }

    this.#updateHoveredObjects(currentPos);
    // 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;
      if ( hasMouseMoved ) object._hoverFadeState.hoveredTime = time;
      // If the mouse position didn't change and the object was already hovered, ...
      else if ( object._hoverFadeState.hoveredTime === time ) {
        // ... restore the previous hovered timestamp
        object._hoverFadeState.hoveredTime = object._hoverFadeState._hoveredTime;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Update the hovered objects. Returns the hovered objects.
   * @param {PIXI.Point} currentPos
   */
  #updateHoveredObjects(currentPos) {
    this.#hoveredObjects.length = 0;

    // Get all PCOs that contain the mouse position
    const position = currentPos;
    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;
      const {Drawing, Tile, Token} = foundry.canvas.placeables;
      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);
  }
}

/**
 * The Drawing object is an implementation of the PlaceableObject container.
 * Each Drawing is a placeable object in the DrawingsLayer.
 * @category Canvas
 * @see {@link foundry.documents.DrawingDocument}
 * @see {@link foundry.canvas.layers.DrawingsLayer}
 */
let Drawing$1 = class Drawing extends PlaceableObject {

  /**
   * The texture that is used to fill this Drawing, if any.
   * @type {PIXI.Texture|null}
   */
  texture = null;

  /**
   * 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[]}
   * @internal
   */
  _fixedPoints;

  /* -------------------------------------------- */

  /** @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 = 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 ?? 0, shape.height ?? 0).normalize()
      : PIXI.Rectangle.fromRotation(x, y, shape.width ?? 0, shape.height ?? 0, 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();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  clear() {
    this.text?.destroy({children: true});
    return super.clear();
  }

  /* -------------------------------------------- */

  /** @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() ?? null;
    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;
  }

  /* -------------------------------------------- */

  /**
   * 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 || CONFIG.defaultFontFamily,
      fontSize: fontSize,
      fill: textColor,
      strokeThickness: stroke,
      dropShadowBlur: Math.max(Math.round(fontSize / 16), 2),
      align: "center",
      wordWrap: true,
      wordWrapWidth: shape.width ?? 0,
      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;
    this.cursor = this.document.isOwner ? "pointer" : null;
    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 * canvas.dimensions.uiScale;

    // 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 ?? 0;
    bounds.height = height ?? 0;
    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.position.x,
      rectangle.y - this.position.y,
      rectangle.width,
      rectangle.height
    );
    return localRectangle.overlaps(this.frame.bounds);
  }

  /* -------------------------------------------- */

  /**
   * Enable text editing for this drawing.
   * @param {object} [options]
   */
  enableTextEditing(options={}) {
    if ( this._onkeydown ) return;
    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) || ("author" 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 strokeWidth = this.document.strokeWidth;
        let dx = position.x - origin.x;
        let dy = position.y - origin.y;
        if ( Math.abs(dx) <= strokeWidth ) dx = (strokeWidth + 1) * (Math.sign(shape.width) || 1);
        if ( Math.abs(dy) <= strokeWidth ) dy = (strokeWidth + 1) * (Math.sign(shape.height) || 1);
        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) {
    if ( event.nativeEvent && (event.nativeEvent.target.id !== canvas.app.view.id) ) return;
    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) {
    if ( event.nativeEvent && (event.nativeEvent.target.id !== canvas.app.view.id) ) return;
    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);
    let dx = handleDestination.x - handleOrigin.x;
    let dy = handleDestination.y - handleOrigin.y;
    const normalized = Drawing.rescaleDimensions(originalData, dx, dy);
    if ( ["r", "e"].includes(this.document.shape.type) ) {
      const strokeWidth = this.document.strokeWidth;
      if ( Math.abs(dx) <= strokeWidth ) dx = (strokeWidth + 1) * (Math.sign(destination.x - origin.x) || 1);
      if ( Math.abs(dy) <= strokeWidth ) dy = (strokeWidth + 1) * (Math.sign(destination.y - origin.y) || 1);
    }

    // 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;
  }
};

/**
 * @import {ApplicationConfiguration, FormFooterButton} from "../_types.mjs";
 * @import {DocumentSheetConfiguration} from "../api/document-sheet.mjs";
 */

/**
 * @typedef DrawingConfigConfiguration
 * @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 DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class DrawingConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /**
   * @inheritDoc
   * @type {ApplicationConfiguration & DocumentSheetConfiguration & DrawingConfigConfiguration}
   */
  static DEFAULT_OPTIONS = {
    classes: ["drawing-config"],
    canCreate: true,
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-pencil"
    },
    position: {width: 480},
    actions: {resetDefaults: DrawingConfig.#onResetDefaults},
    form: {closeOnSubmit: true}
  };

  /** @override */
  static PARTS = {
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    position: {template: "templates/scene/drawing/position.hbs"},
    lines: {template: "templates/scene/drawing/lines.hbs"},
    fill: {template: "templates/scene/drawing/fill.hbs"},
    text: {template: "templates/scene/drawing/text.hbs"},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /** @override */
  static TABS = {
    sheet: {
      tabs: [
        {id: "position", icon: "fa-solid fa-location-dot"},
        {id: "lines", icon: "fa-solid fa-paintbrush"},
        {id: "fill", icon: "fa-regular fa-fill-drip"},
        {id: "text", icon: "fa-solid fa-font"}
      ],
      initial: "position",
      labelPrefix: "DRAWING.TABS"
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  get id() {
    return this.options.configureDefault ? "drawing-config" : super.id;
  }

  /** @inheritDoc */
  get title() {
    return this.options.configureDefault ? game.i18n.localize("DRAWING.ConfigDefaultTitle") : super.title;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( this.options.configureDefault ) {
      options.parts.splice(options.parts.indexOf("position"), 1);
      if ( options.isFirstRender ) this.tabGroups.sheet = "lines";
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const {document, source} = context;
    return Object.assign(context, {
      author: document.author?.name || "",
      scaledBezierFactor: source.bezierFactor * 2,
      drawingRoles: {false: "DRAWING.Object", true: "DRAWING.Information"},
      fillDisabled: source.fillType === CONST.DRAWING_FILL_TYPES.NONE,
      fontFamilies: FontConfig.getAvailableFontChoices(),
      gridUnits: document.parent?.grid.units || canvas.scene.grid.units || game.i18n.localize("GridUnits"),
      userColor: game.user.color,
      buttons: this.#prepareButtons()
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareTabs(group) {
    const tabs = super._prepareTabs(group);
    if ( this.options.configureDefault ) delete tabs.position;
    return tabs;
  }

  /* -------------------------------------------- */

  /**
   * Configure footer buttons for this sheet.
   * @returns {FormFooterButton[]}
   */
  #prepareButtons() {
    const submitAction = this.options.configureDefault ? "Default" : this.document.id ? "Update" : "Create";
    const buttons = [{type: "submit", icon: "fa-solid fa-floppy-disk", label: `DRAWING.Submit${submitAction}`}];
    if ( this.options.configureDefault ) {
      buttons.unshift({
        type: "button",
        action: "resetDefaults",
        icon: "fa-solid fa-arrow-rotate-left",
        label: "DRAWING.SubmitReset"
      });
    }
    return buttons;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    const partContext = await super._preparePartContext(partId, context, options);
    if ( partId in partContext.tabs ) partContext.tab = partContext.tabs[partId];
    return partContext;
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeForm(_formConfig, event) {
    // Toggle the disabled attribute of certain fill fields depending on the fillType.
    if (event.target.name === "fillType") {
      const disabled = Number(event.target.value) === CONST.DRAWING_FILL_TYPES.NONE;
      for (const field of this.element.querySelectorAll("[name=fillColor], [name=fillAlpha]")) {
        field.disabled = disabled;
      }
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);

    // Un-scale the bezier factor
    submitData.bezierFactor /= 2;

    return submitData;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _processSubmitData(event, form, submitData, options) {

    // Configure the default Drawing settings
    if ( this.options.configureDefault ) {
      await game.settings.set("core", foundry.canvas.layers.DrawingsLayer.DEFAULT_CONFIG_SETTING, submitData);
      return;
    }

    // Rescale dimensions if needed
    const currentShape = this.document._source.shape;
    const newShape = submitData.shape;
    if ( (newShape.width !== currentShape.width) || (newShape.height !== currentShape.height) ) {
      const dx = newShape.width - currentShape.width;
      const dy = newShape.height - currentShape.height;
      foundry.utils.mergeObject(submitData, Drawing$1.rescaleDimensions(this.document._source, dx, dy));
    }

    return super._processSubmitData(event, form, submitData, options);
  }

  /* -------------------------------------------- */

  /**
   * Reset the client Drawing configuration settings to their default values
   * @this {DrawingConfig}
   * @returns {Promise<void>}
   */
  static async #onResetDefaults() {
    const settingKey = foundry.canvas.layers.DrawingsLayer.DEFAULT_CONFIG_SETTING;
    await game.settings.set("core", settingKey, undefined);
    this.document.updateSource(game.settings.get("core", settingKey));
    await this.render();
  }
}

/**
 * The Application responsible for configuring a single Folder document.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class FolderConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
  static DEFAULT_OPTIONS = {
    classes: ["folder-config"],
    canCreate: true,
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-folder"
    },
    position: {width: 480},
    form: {
      closeOnSubmit: true
    }
  };

  /** @override */
  static PARTS = {
    body: {template: "templates/sheets/folder-config.hbs"},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const folder = context.document;
    const folderExists = !!folder.collection?.has(folder.id) || folder.inCompendium;
    if ( folderExists ) context.name = context.namePlaceholder = folder._source.name;
    else {
      context.name = "";
      context.namePlaceholder = folder.constructor.defaultName({pack: folder.pack});
    }
    const submitText = folderExists ? "FOLDER.Update" : "SIDEBAR.ACTIONS.CREATE.Folder";
    context.buttons = [{type: "submit", icon: "fa-solid fa-floppy-disk", label: submitText}];
    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeForm(_formConfig, event) {

    // Update the window title to reflect the new name
    if ( event.target.name === "name" ) {
      const newValue = event.target.value.trim() || event.target.placeholder;
      const windowTitle = `${game.i18n.localize("DOCUMENT.Folder")}: ${newValue}`;
      this.element.querySelector(":scope > header > h1").innerText = windowTitle;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    if ( !submitData.name.trim() ) {
      const folder = this.document;
      const folderExists = !!folder.collection?.has(folder.id) || folder.inCompendium;
      if ( folderExists ) delete submitData.name;
      else submitData.name = form.name.placeholder;
    }
    return submitData;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _processSubmitData(event, form, submitData, options) {
    const folder = this.document;
    if ( folder.inCompendium ) {
      await folder.update(submitData, options);
      return;
    }
    return super._processSubmitData(event, form, submitData, options);
  }
}

/**
 * @import {Item, Actor} from "../../documents/_module.mjs";
 */

/**
 * A base class for providing Item Sheet behavior using ApplicationV2.
 */
class ItemSheetV2 extends DocumentSheetV2 {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    position: {width: 480}
  };

  /**
   * The Item document managed by this sheet.
   * @type {Item}
   */
  get item() {
    return this.document;
  }

  /**
   * The Actor instance which owns this Item, if any.
   * @type {Actor|null}
   */
  get actor() {
    return this.document.actor;
  }
}

/**
 * @import {ApplicationClickAction, FormFooterButton} from "../_types.mjs";
 */

/**
 * A Macro configuration sheet
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class MacroConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["macro-config"],
    canCreate: true,
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-code",
      resizable: true
    },
    position: {
      width: 720,
      height: 600
    },
    actions: {execute: MacroConfig.#onExecute},
    form: {closeOnSubmit: true}
  };

  /** @override */
  static PARTS = {
    body: {template: "templates/sheets/macro-config.hbs", root: true},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /**
   * The macro's assigned hotbar slot, if any
   * @type {number|null}
   */
  #hotbarSlot = null;

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.typeChoices = Object.values(CONST.MACRO_TYPES).reduce((choices, type) => {
      choices[type] = `TYPES.Macro.${type}`;
      return choices;
    }, {});
    context.editorLang = context.source.type === "script" ? "javascript" : "";
    context.buttons = this.#prepareButtons();
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Configure the sheet's pair of footer buttons.
   * @returns {FormFooterButton[]}
   */
  #prepareButtons() {
    return [
      {type: "submit", icon: "fa-solid fa-floppy-disk", label: "MACRO.Save"},
      {
        type: "button",
        icon: "fa-solid fa-dice-d20",
        label: "MACRO.Execute",
        action: "execute",
        disabled: !this.document.canExecute
      }
    ];
  }

  /* -------------------------------------------- */

  /**
   * Allow execution even if the document is locked.
   * @override
   */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.#hotbarSlot ??= Number(options.hotbarSlot) || null;
    const executeButton = this.element.querySelector("button[data-action=execute]");
    executeButton.disabled = !this.document.canExecute;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    if ( event.target.name !== "type" ) return;
    const type = event.target.value;

    // Set the CodeMirror elements's lang attribute
    const editorEl = this.element.querySelector("code-mirror[name=command]");
    editorEl.language = type === "script" ? "javascript" : "";

    // Update the state of the execute button and command editor
    const executeButton = this.element.querySelector("button[data-action=execute]");
    const disabled = (type === "script") && !game.user.can("MACRO_SCRIPT");
    executeButton.disabled = editorEl.disabled = disabled;
  }

  /* -------------------------------------------- */

  /**
   * Save the macro if any changes are pending and then execute.
   * @this {MacroConfig}
   * @type {ApplicationClickAction}
   */
  static async #onExecute() {
    const isCreate = !this.document.collection.has(this.document.id);
    const options = isCreate ? {renderSheet: true} : {};
    await this.submit(options);
    await this.document.execute();
    if ( isCreate ) await this.close({submit: true, animate: false});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _processSubmitData(event, form, submitData, options) {
    const macro = this.document;
    if ( macro.collection.has(macro.id) ) macro.updateSource(submitData, {dryRun: true, fallback: false});
    else options.hotbarSlot = this.#hotbarSlot;
    return super._processSubmitData(event, form, submitData, options);
  }
}

/**
 * The Application responsible for configuring a single Note document within a parent Scene.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class NoteConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["note-config"],
    canCreate: true,
    position: {width: 480},
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-bookmark"
    },
    form: {
      closeOnSubmit: true
    }
  };

  /** @override */
  static PARTS = {
    body: {template: "templates/scene/note/config.hbs"},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /** @inheritDoc */
  get title() {
    if ( !this.document.id ) return game.i18n.localize("NOTE.Create");
    const textLabel = this.document.text;
    return textLabel ? `${game.i18n.localize("DOCUMENT.Note")}: ${textLabel}` : super.title;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const {document, source} = context;
    const entry = game.journal.get(source.entryId) ?? null;
    const {icons, icon} = this.#prepareIcons(source.texture.src);
    const textAnchors = Object.entries(CONST.TEXT_ANCHOR_POINTS).reduce((anchors, e) => {
      anchors[e[1]] = game.i18n.localize(`JOURNAL.Anchor${e[0].titleCase()}`);
      return anchors;
    }, {});
    const isCreated = !!document.collection?.has(document.id);
    const submitText = isCreated ? "NOTE.Update" : "NOTE.Create";
    return Object.assign(context, {
      canCreate: game.user.hasPermission("NOTE_CREATE"),
      entries: game.journal
        .filter(e => e.testUserPermission(game.user, "OBSERVER"))
        .map(e => ({value: e.id, label: e.name}))
        .sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang)),
      entry,
      pages: this.#preparePages(entry),
      global: isCreated ? source.global : !document.parent?.tokenVision,
      icons,
      icon,
      fontFamilies: FontConfig.getAvailableFontChoices(),
      textAnchors,
      gridUnits: document.parent?.grid.units || game.i18n.localize("GridUnits"),
      buttons: [{type: "submit", icon: "fa-solid fa-floppy-disk", label: submitText}]
    });
  }

  /* -------------------------------------------- */

  /**
   * Get all pages from a journal entry in a form suitable for passing to a selectOptions helper.
   * @param {JournalEntry|null} entry
   * @returns {Record<string, string>}
   */
  #preparePages(entry) {
    return entry?.pages.contents.sort((a, b) => a.sort - b.sort).reduce((obj, page) => {
      if ( !page.testUserPermission(game.user, "OBSERVER") ) return obj;
      obj[page.id] = page.name;
      return obj;
    }, {}) ?? {};
  }

  /* -------------------------------------------- */

  /**
   * Localize and sort available icon options and determine whether a custom icon is in use.
   * @returns {{icons: Record<string, string>; icon: {selected: string; custom: string}}}
   */
  #prepareIcons() {
    const icons = Object.entries(CONFIG.JournalEntry.noteIcons)
      .sort(([a], [b]) => a.localeCompare(b, game.i18n.lang))
      .reduce((obj, [label, src]) => {
        obj[src] = label;
        return obj;
      }, {"": game.i18n.localize("NOTE.Custom")});
    const currentIcon = this.document._source.texture.src;
    const usingCustom = (currentIcon === "") || !(currentIcon in icons);
    const icon = {
      selected: usingCustom ? "" : currentIcon,
      custom: usingCustom ? currentIcon : ""
    };
    return {icons, icon};
  }

  /* -------------------------------------------- */

  /**
   * @param {Event} event
   * @override
   */
  _onChangeForm(_formConfig, event) {
    switch (event.target.name) {
      // Replace pageId selectOptions with list from the newly selected journal entry
      case "entryId": {
        const entry = game.journal.get(event.target.value) ?? null;
        const newOptions = this.#preparePages(entry);
        this.form["pageId"].innerHTML = foundry.applications.handlebars.selectOptions(newOptions, {hash: {blank: ""}});
        break;
      }
      // Show/hide the Custom Icon field depending on whether the Entry Icon "Custom" option is selected
      case "icon.selected":
        this.element.querySelector("[data-icon-custom]").hidden = event.target.value !== "";
        break;
      // Change the window title to reflect the changed text label
      case "text": {
        const newValue = event.target.value.trim();
        const windowTitle = newValue ? `${game.i18n.localize("DOCUMENT.Note")}: ${newValue}` : this.title;
        this.element.querySelector(":scope > header > h1").innerText = windowTitle;
      }
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const fields = formData.object;
    fields["texture.src"] = fields["icon.selected"] || fields["icon.custom"];
    delete fields["icon.selected"];
    delete fields["icon.custom"];
    return super._processFormData(event, form, formData);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    const obj = this.document.object;
    if ( obj?.isPreview ) obj.destroy({children: true});
  }
}

/**
 * @import {FormFooterButton} from "../_types.mjs";
 */

/**
 * The Application responsible for configuring a single Playlist document
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class PlaylistConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["playlist-config"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-list-music"
    },
    position: {width: 480},
    form: {closeOnSubmit: true}
  };

  /** @override */
  static PARTS = {
    sheet: {template: "templates/sheets/playlist/playlist-config.hbs", root: true},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    return Object.assign(context, {
      modes: Object.entries(CONST.PLAYLIST_MODES).reduce((modes, [key, value]) => {
        modes[value] = game.i18n.localize(`PLAYLIST.Mode${key.titleCase()}`);
        return modes;
      }, {}),
      sortModes: Object.entries(CONST.PLAYLIST_SORT_MODES).reduce((sortings, [key, value]) => {
        sortings[value] = game.i18n.localize(`PLAYLIST.Sort${key.titleCase()}`);
        return sortings;
      }, {}),
      channels: Object.entries(CONST.AUDIO_CHANNELS).reduce((channels, [value, locPath]) => {
        channels[value] = game.i18n.localize(locPath);
        return channels;
      }, {}),
      milliseconds: game.i18n.localize("TIME.Millisecond.abbr"),
      buttons: [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "PLAYLIST.Update"}]
    });
  }
}

/**
 * The Application responsible for configuring a single PlaylistSound document within a parent Playlist.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class PlaylistSoundConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["playlist-sound-config"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-music"
    },
    position: {width: 480},
    form: {closeOnSubmit: true},
    canCreate: true
  };

  /** @override */
  static PARTS = {
    sheet: {template: "templates/sheets/playlist/sound-config.hbs", root: true},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    return Object.assign(context, {
      lvolume: foundry.audio.AudioHelper.volumeToInput(this.document._source.volume),
      channels: Object.entries(CONST.AUDIO_CHANNELS).reduce((channels, [key, value]) => {
        channels[key] = game.i18n.localize(value);
        return channels;
      }, {}),
      defaultChannel: game.i18n.localize("PLAYLIST.DefaultChannel"),
      milliseconds: game.i18n.localize("TIME.Millisecond.abbr"),
      buttons: [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "PLAYLIST_SOUND.Update"}]
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    if ( event.target === this.form.elements.path ) {
      this.form.elements.name.value = foundry.audio.AudioHelper.getDefaultSoundName(event.target.value);
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _processSubmitData(event, form, submitData, options) {
    submitData.volume = foundry.audio.AudioHelper.inputToVolume(submitData.volume);
    return super._processSubmitData(event, form, submitData, options);
  }
}

/**
 * @import {FormNode, FormFooterButton} from "../_types.mjs";
 * @import {DataSchema} from "@common/abstract/_types.mjs";
 */

/**
 * The Scene Region configuration application.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class RegionBehaviorConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
  constructor(options) {
    super(options);
    this.options.window.icon = CONFIG.RegionBehavior.typeIcons[this.document.type];
  }

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["region-behavior-config"],
    window: {
      contentClasses: ["standard-form"],
      icon: undefined // Defined in constructor
    },
    position: {width: 480},
    form: {
      closeOnSubmit: true
    }
  };

  /** @override */
  static PARTS = {
    form: {
      template: "templates/generic/form-fields.hbs",
      scrollable: [""]
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Context Preparation                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    return Object.assign(context, {
      region: context.document,
      fields: this._getFields(),
      buttons: this._getButtons()
    });
  }

  /* -------------------------------------------- */

  /**
   * Prepare form field structure for rendering.
   * @returns {FormNode[]}
   * @protected
   */
  _getFields() {
    const doc = this.document;
    const source = doc._source;
    const fields = doc.schema.fields;
    const {events, ...systemFields} = CONFIG.RegionBehavior.dataModels[doc.type].schema.fields;
    const fieldsets = [];

    // Identity
    fieldsets.push({
      fieldset: true,
      legend: "BEHAVIOR.SECTIONS.identity",
      fields: [
        {field: fields.name, value: source.name}
      ]
    });

    // Status
    fieldsets.push({
      fieldset: true,
      legend: "BEHAVIOR.SECTIONS.status",
      fields: [
        {field: fields.disabled, value: source.disabled}
      ]
    });

    // Subscribed events
    if ( events ) {
      fieldsets.push({
        fieldset: true,
        legend: "BEHAVIOR.TYPES.base.SECTIONS.events",
        fields: [
          {field: events, value: source.system.events}
        ]
      });
    }

    // Other system fields
    const sf = {fieldset: true, legend: CONFIG.RegionBehavior.typeLabels[doc.type], fields: []};
    this.#addSystemFields(sf, systemFields, source);
    if ( sf.fields.length ) fieldsets.push(sf);
    return fieldsets;
  }

  /* -------------------------------------------- */

  /**
   * Recursively add system model fields to the fieldset.
   * @param {boolean} fieldset
   * @param {DataSchema} schema
   * @param {object} source
   * @param {string} [_path]
   */
  #addSystemFields(fieldset, schema, source, _path="system") {
    for ( const field of Object.values(schema) ) {
      const path = `${_path}.${field.name}`;
      if ( field.constructor.hasFormSupport ) {
        fieldset.fields.push({field, value: foundry.utils.getProperty(source, path)});
      }
      else if ( field instanceof foundry.data.fields.SchemaField ) {
        this.#addSystemFields(fieldset, field.fields, source, path);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Get footer buttons for this behavior config sheet.
   * @returns {FormFooterButton[]}
   * @protected
   */
  _getButtons() {
    return [
      {type: "submit", icon: "fa-solid fa-floppy-disk", label: "BEHAVIOR.ACTIONS.update"}
    ];
  }
}

/**
 * @import {ApplicationClickAction, FormFooterButton} from "../_types.mjs";
 */


/**
 * The Scene Region configuration application.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class RegionConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["region-config"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-regular fa-game-board"
    },
    position: {width: 480},
    form: {
      closeOnSubmit: true
    },
    viewPermission: DOCUMENT_OWNERSHIP_LEVELS.OWNER,
    actions: {
      shapeCreateFromWalls: RegionConfig.#onShapeCreateFromWalls,
      shapeToggleHole: RegionConfig.#onShapeToggleHole,
      shapeMoveUp: RegionConfig.#onShapeMoveUp,
      shapeMoveDown: RegionConfig.#onShapeMoveDown,
      shapeRemove: RegionConfig.#onShapeRemove,
      behaviorCreate: RegionConfig.#onBehaviorCreate,
      behaviorDelete: RegionConfig.#onBehaviorDelete,
      behaviorEdit: RegionConfig.#onBehaviorEdit,
      behaviorToggle: RegionConfig.#onBehaviorToggle
    }
  };

  /** @override */
  static PARTS = {
    tabs: {
      template: "templates/generic/tab-navigation.hbs"
    },
    identity: {
      template: "templates/scene/parts/region-identity.hbs"
    },
    shapes: {
      template: "templates/scene/parts/region-shapes.hbs",
      scrollable: [".scrollable"]
    },
    behaviors: {
      template: "templates/scene/parts/region-behaviors.hbs",
      scrollable: [".scrollable"]
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /** @override */
  static TABS = {
    sheet: {
      tabs: [
        {id: "identity", icon: "fa-solid fa-tag"},
        {id: "shapes", icon: "fa-solid fa-shapes"},
        {id: "behaviors", icon: "fa-solid fa-child-reaching"}
      ],
      initial: "identity",
      labelPrefix: "REGION.TABS"
    }
  };

  /* -------------------------------------------- */
  /*  Context Preparation                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    switch ( options.renderContext ) {
      case "updateRegion":
        if ( options.renderData ) {
          const changed = Object.keys(options.renderData).filter(k => k !== "_id");
          if ( new Set(changed).isSubsetOf(new Set(["behaviors", "shapes"])) ) options.parts = changed;
        }
        break;
      case "createbehaviors": case "deletebehaviors": options.parts = ["behaviors"]; break;
      case "updatebehaviors":
        if ( options.renderData?.some(d => ["name", "disabled"].some(k => k in d)) ) options.parts = ["behaviors"];
        else options.parts = [];
        break;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _preparePartContext(partId, context) {
    const doc = context.document;
    switch ( partId ) {
      case "footer":
        context.buttons = this.#getFooterButtons();
        break;
      case "behaviors":
        context.behaviors = doc.behaviors.map(b => ({
          id: b.id,
          name: b.name,
          typeLabel: game.i18n.localize(CONFIG.RegionBehavior.typeLabels[b.type]),
          typeIcon: CONFIG.RegionBehavior.typeIcons[b.type] || "fa-regular fa-notdef",
          disabled: b.disabled
        })).sort((a, b) => (a.disabled - b.disabled) || a.name.localeCompare(b.name, game.i18n.lang));
        break;
      case "identity":
        context.visibilities = Object.entries(CONST.REGION_VISIBILITY).reduce((arr, [key, value]) => {
          arr.push({value, label: `REGION.VISIBILITY.${key}.label`});
          return arr;
        }, []);
        break;
    }
    if ( partId in context.tabs ) context.tab = context.tabs[partId];
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    new DragDrop.implementation({
      dragSelector: ".draggable",
      permissions: {
        dragstart: this._canDragStart.bind(this),
        drop: this._canDragDrop.bind(this)
      },
      callbacks: {
        dragstart: this._onDragStart.bind(this),
        dragover: this._onDragOver.bind(this),
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);
    this.element.querySelectorAll(".region-shape").forEach(e => {
      e.addEventListener("mouseover", this.#onShapeHoverIn.bind(this));
      e.addEventListener("mouseout", this.#onShapeHoverOut.bind(this));
    });
    this.document.object?.renderFlags.set({refreshState: true});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    this.document.object?.renderFlags.set({refreshState: true});
  }

  /* -------------------------------------------- */

  /**
   * Prepare an array of form footer buttons.
   * @returns {Partial<FormFooterButton>[]}
   */
  #getFooterButtons() {
    return [
      {type: "submit", icon: "fa-solid fa-floppy-disk", label: "REGION.ACTIONS.update"}
    ];
  }

  /* -------------------------------------------- */
  /*  Drag and Drop                               */
  /* -------------------------------------------- */

  /**
   * 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 true;
  }

  /* -------------------------------------------- */

  /**
   * 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 false;
  }

  /* -------------------------------------------- */

  /**
   * An event that occurs when a drag workflow begins.
   * @param {DragEvent} event      The initiating drag start event
   * @returns {Promise<void>}
   * @protected
   */
  async _onDragStart(event) {
    const target = event.currentTarget;
    if ( "link" in event.target.dataset ) return;
    let dragData;

    // Region Behavior
    if ( target.dataset.behaviorId ) {
      const effect = this.document.behaviors.get(target.dataset.behaviorId);
      dragData = effect.toDragData();
    }

    // Set data transfer
    if ( !dragData ) return;
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /**
   * An event that occurs when a drag workflow moves over a drop target.
   * @param {DragEvent} event
   * @protected
   */
  _onDragOver(event) {}

  /* -------------------------------------------- */

  /**
   * An event that occurs when data is dropped into a drop target.
   * @param {DragEvent} event
   * @returns {Promise<void>}
   * @protected
   */
  async _onDrop(event) {}

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */


  /**
   * Handle mouse-hover events on a shape.
   * @param {MouseEvent} event
   */
  #onShapeHoverIn(event) {
    event.preventDefault();
    if ( !this.document.parent.isView ) return;
    const index = this.#getControlShapeIndex(event);
    canvas.regions._highlightShape(this.document.shapes[index]);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-unhover events for shape.
   * @param {MouseEvent} event
   */
  #onShapeHoverOut(event) {
    event.preventDefault();
    if ( !this.document.parent.isView ) return;
    canvas.regions._highlightShape(null);
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to move the shape up.
   * @param {PointerEvent} event
   * @this {RegionConfig}
   */
  static async #onShapeMoveUp(event) {
    if ( this.document.shapes.length <= 1 ) return;
    const index = this.#getControlShapeIndex(event);
    if ( index === 0 ) return;
    const shapes = [...this.document.shapes];
    [shapes[index - 1], shapes[index]] = [shapes[index], shapes[index - 1]];
    await this.document.update({shapes});
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to move the shape down.
   * @param {PointerEvent} event
   * @this {RegionConfig}
   */
  static async #onShapeMoveDown(event) {
    if ( this.document.shapes.length <= 1 ) return;
    const index = this.#getControlShapeIndex(event);
    if ( index === this.document.shapes.length - 1 ) return;
    const shapes = [...this.document.shapes];
    [shapes[index], shapes[index + 1]] = [shapes[index + 1], shapes[index]];
    await this.document.update({shapes});
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to create shapes from the controlled walls.
   * @param {PointerEvent} event
   * @this {RegionConfig}
   */
  static async #onShapeCreateFromWalls(event) {
    event.preventDefault(); // Don't open context menu
    event.stopPropagation(); // Don't trigger other events
    if ( !canvas.ready || (event.detail > 1) ) return; // Ignore repeated clicks

    // If no walls are controlled, inform the user they need to control walls
    if ( !canvas.walls.controlled.length ) {
      if ( canvas.walls.active ) {
        ui.notifications.error("REGION.NOTIFICATIONS.NoControlledWalls", {localize: true});
      }
      else {
        canvas.walls.activate({tool: "select"});
        ui.notifications.info("REGION.NOTIFICATIONS.ControlWalls", {localize: true});
      }
      return;
    }

    // Create the shape
    const polygons = canvas.walls.identifyInteriorArea(canvas.walls.controlled);
    if ( polygons.length === 0 ) {
      ui.notifications.error("REGION.NOTIFICATIONS.EmptyEnclosedArea", {localize: true});
      return;
    }
    const shapes = polygons.map(p => new foundry.data.PolygonShapeData({points: p.points}));

    // Merge the new shape with form submission data
    const form = this.element;
    const formData = new FormDataExtended(form);
    const submitData = this._prepareSubmitData(event, form, formData, {
      shapes: [...this.document._source.shapes, ...shapes]
    });

    // Update the region
    await this.document.update(submitData);
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to toggle the hold field of a shape.
   * @param {PointerEvent} event
   * @this {RegionConfig}
   */
  static async #onShapeToggleHole(event) {
    const index = this.#getControlShapeIndex(event);
    const shapes = this.document.shapes.map(s => s.toObject());
    shapes[index].hole = !shapes[index].hole;
    await this.document.update({shapes});
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to remove a shape.
   * @param {PointerEvent} event
   * @this {RegionConfig}
   */
  static async #onShapeRemove(event) {
    const index = this.#getControlShapeIndex(event);
    let shapes = this.document.shapes;
    return foundry.applications.api.DialogV2.confirm({
      window: {title: "REGION.ACTIONS.shapeRemove"},
      content: `<p>${game.i18n.localize("AreYouSure")}</p>`,
      yes: {
        callback: () => {
          // Test that there haven't been any changes to the shapes since the dialog the button was clicked
          if ( this.document.shapes !== shapes ) return false;
          shapes = [...shapes];
          shapes.splice(index, 1);
          this.document.update({shapes});
          return true;
        }
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Get the shape index from a control button click.
   * @param {PointerEvent} event    The button-click event
   * @returns {number}              The shape index
   */
  #getControlShapeIndex(event) {
    const button = event.target;
    const li = button.closest(".region-shape");
    return Number(li.dataset.shapeIndex);
  }

  /* -------------------------------------------- */


  /**
   * Create a new region behavior.
   * @this {RegionConfig}
   * @type {ApplicationClickAction}
   */
  static async #onBehaviorCreate() {
    await RegionBehavior$1.implementation.createDialog({}, {parent: this.document});
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to delete a behavior.
   * @param {PointerEvent} event
   * @this {RegionConfig}
   */
  static async #onBehaviorDelete(event) {
    const behavior = this.#getControlBehavior(event);
    await behavior.deleteDialog();
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to edit a behavior.
   * @param {PointerEvent} event
   * @this {RegionConfig}
   */
  static async #onBehaviorEdit(event) {
    const target = event.target;
    if ( target.closest(".region-element-name") && (event.detail !== 2) ) return; // Double-click on name
    const behavior = this.#getControlBehavior(event);
    await behavior.sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to toggle a behavior.
   * @param {PointerEvent} event
   * @this {RegionConfig}
   */
  static async #onBehaviorToggle(event) {
    const behavior = this.#getControlBehavior(event);
    await behavior.update({disabled: !behavior.disabled});
  }

  /* -------------------------------------------- */

  /**
   * Get the RegionBehavior document from a control button click.
   * @param {PointerEvent} event    The button-click event
   * @returns {RegionBehavior}      The region behavior document
   */
  #getControlBehavior(event) {
    const button = event.target;
    const li = button.closest(".region-behavior");
    return this.document.behaviors.get(li.dataset.behaviorId);
  }
}

/**
 * @import {DeepPartial, TableResultData} from "@common/documents/_types.mjs";
 */

/**
 * The Application responsible for configuring a single TableResult document within a parent RollTable.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class TableResultConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["table-result-config"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-table-rows"
    },
    position: {width: 560},
    form: {
      closeOnSubmit: true
    }
  };

  /** @override */
  static PARTS = {
    sheet: {template: "templates/sheets/table-result-config.hbs", root: true},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /**
   * TableResult types with localized labels
   * @returns {{value: string; label: string}[]}
   */
  static get RESULT_TYPES() {
    return TableResultConfig.#RESULT_TYPES ??= TableResult$1.TYPES.map(value => ({
      value,
      label: game.i18n.localize(`TABLE_RESULT.TYPES.${value}`)
    }));
  }

  /** @type {{value: string; label: string}[]|undefined} */
  static #RESULT_TYPES;

  /* -------------------------------------------- */

  /**
   * Prepare the update data of a single TableResult document to ensure joint validation.
   * @param {DeepPartial<TableResultData>} data The TableResult update data
   */
  static prepareResultUpdateData(data) {
    if ( data.type === "text" ) {
      data.documentUuid = null;
    }
    else if ( data.documentUuid ) {
      const {id, collection} = foundry.utils.parseUuid(data.documentUuid) ?? {};
      if (!id || !collection) return;

      // Get the original document: if the name still matches, take no action
      const docsOrIndex = collection instanceof CompendiumCollection ? collection.index : collection;
      const original = docsOrIndex.get(id);
      if (original?.name === data.name) return;

      // Otherwise, find the document by name
      const document = original ?? collection.getName(data.name);
      if ( !document ) return;
      data.documentUuid = document.uuid ?? foundry.utils.buildUuid({...document, id: document._id});
      data.name = document.name ?? "";
      data.img = document.thumb ?? document.img ?? null;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const source = context.source;
    context.types = TableResultConfig.RESULT_TYPES;
    context.resultDocument = await foundry.utils.fromUuid(source.documentUuid);
    context.buttons = [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "TABLE_RESULT.ACTIONS.Submit"}];
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareSubmitData(event, form, formData, updateData) {
    const submitData = super._prepareSubmitData(event, form, formData, updateData);
    TableResultConfig.prepareResultUpdateData(submitData);
    return submitData;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    const elements = this.form.elements;
    if ( event.target === elements.type ) {
      const isText = event.target.value === "text";
      elements.documentUuid.closest(".form-group").hidden = isText;
    }
    else if ( event.target === elements.description ) {
      this.submit();
    }
  }
}

/**
 * @import {ApplicationClickAction} from "../_types.mjs";
 * @import RollTable from "@client/documents/roll-table.mjs";
 */

/**
 * The Application responsible for editing, displaying, and using a single {@link RollTable} document.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class RollTableSheet extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /**
   * The operational mode in which a newly created instance of this sheet starts
   * @type {"edit"|"view"}
   */
  static #DEFAULT_MODE = "view";

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["roll-table-sheet"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-table-list",
      resizable: true
    },
    position: {width: 720},
    form: {
      closeOnSubmit: false
    },
    actions: {
      // Edit mode:
      normalizeResults: RollTableSheet.#onNormalizeResults,
      createResult: RollTableSheet.#onCreateResult,
      openResultSheet: RollTableSheet.#onOpenResultSheet,
      deleteResult: RollTableSheet.#onDeleteResult,
      // View mode:
      drawSpecificResult: RollTableSheet.#onDrawSpecificResult,
      // Shared:
      changeMode: RollTableSheet.#onChangeMode,
      lockResult: RollTableSheet.#onLockResult,
      drawResult: RollTableSheet.#onDrawResult,
      resetResults: RollTableSheet.#onResetResults
    }
  };

  /** @override */
  static PARTS = {
    sheet: {
      template: "templates/sheets/roll-table/view.hbs",
      templates: ["templates/sheets/roll-table/result-details.hbs"],
      scrollable: ["table[data-results] tbody"],
      root: true
    },
    header: {template: "templates/sheets/roll-table/edit/header.hbs"},
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    results: {
      template: "templates/sheets/roll-table/edit/results.hbs",
      templates: ["templates/sheets/roll-table/result-details.hbs"],
      scrollable: ["table[data-results] tbody"]
    },
    summary: {template: "templates/sheets/roll-table/edit/summary.hbs"},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /**
   * Parts for each view
   */
  static MODE_PARTS = {
    edit: ["header", "tabs", "summary", "results", "footer"],
    view: ["sheet", "footer"]
  };

  /** @override */
  static TABS = {
    sheet: {
      tabs: [
        {id: "results", icon: "fa-solid fa-table-rows"},
        {id: "summary", icon: "fa-solid fa-memo-pad"}
      ],
      initial: "results",
      labelPrefix: "TABLE.TABS"
    }
  };

  /* -------------------------------------------- */

  /**
   * The operational mode of this sheet
   * @type {"edit"|"view"}
   */
  get mode() {
    return this.#mode;
  }

  /**
   * Change the operational mode of this sheet. Changing this value will also change the mode in which subsequent
   * RollTableSheet instances first render.
   * @param {"edit"|"view"} value
   */
  set mode(value) {
    this.#mode = RollTableSheet.#DEFAULT_MODE = value;
  }

  #mode = RollTableSheet.#DEFAULT_MODE;

  /**
   * Is the sheet in edit mode?
   * @type {boolean}
   */
  get isEditMode() {
    return this.#mode === "edit";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    if ( !this.isEditable ) this.mode = "view";
    else if ( options.isFirstRender && !this.document.results.size ) this.mode = "edit";
    return super._configureRenderOptions(options);
  }

  /* -------------------------------------------- */

  /** @override */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    const allowedParts = this.constructor.MODE_PARTS[this.mode];
    for ( const partId in parts ) {
      if ( !allowedParts.includes(partId) ) delete parts[partId];
    }
    return parts;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareTabs(group) {
    return this.isEditMode ? super._prepareTabs(group): {tabs: {}};
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const {description, results, isOwner} = context.document;
    const getSortedResults = () => results.contents.sort(this._sortResults.bind(this));
    switch ( partId ) {
      case "results":
        context.tab = context.tabs.results;
        context.results = await Promise.all(getSortedResults().map(this._prepareResult.bind(this)));
        break;
      case "summary":
        context.tab = context.tabs.summary;
        context.descriptionHTML = await TextEditor$1.implementation.enrichHTML(description, {secrets: isOwner});
        context.formulaPlaceholder = `1d${results.size || 20}`;
        break;
      case "sheet": // Lone view-mode part
        context.descriptionHTML = await TextEditor$1.implementation.enrichHTML(description, {secrets: isOwner});
        context.formula = context.source.formula || `1d${results.size || 20}`;
        context.results = await Promise.all(getSortedResults().map(this._prepareResult.bind(this)));
        break;
      case "footer":
        context.buttons = [
          {
            type: "button",
            action: "resetResults",
            icon: "fa-solid fa-arrow-rotate-left",
            label: "TABLE.ACTIONS.ResetResults"
          },
          {
            type: "button",
            action: "drawResult",
            icon: "fa-solid fa-dice-d20",
            label: "TABLE.ACTIONS.DrawResult"
          }
        ];
        if ( this.isEditMode ) {
          context.buttons.unshift({
            type: "submit",
            icon: "fa-solid fa-floppy-disk",
            label: "TABLE.ACTIONS.Submit"
          });
        }
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare sheet data for a single TableResult.
   * @param {TableResult} result    The result from which to prepare
   * @returns {Promise<object>}     The sheet data for this result
   * @protected
   */
  async _prepareResult(result) {

    // Show a single numeric value in view mode for zero-interval ranges
    const range = this.isEditMode
      ? [...result.range]
      : result.range[0] === result.range[1] ? result.range[0] : `${result.range[0]}–${result.range[1]}`;

    return {
      id: result.id,
      img: result.icon,
      name: result.name,
      description: await TextEditor$1.implementation.enrichHTML(result.description, {relativeTo: result,
        secrets: result.isOwner}),
      documentLink: result.documentToAnchor()?.outerHTML,
      weight: result.weight,
      range,
      drawn: result.drawn
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare the details HTML for a single result.
   * @param {TableResult} result
   * @returns {Promise<string>}
   * @protected
   */
  #getDetailsDisplay(result) {
    if ( !this.isEditMode ) return result.getHTML({collapsed: true});
    const name = result.name ? `<strong>${foundry.utils.escapeHTML(result.name)}</strong>` : "";
    const uuid = result.documentUuid ? `<div class="uuid">${result.documentUuid}</div>` : "";
    return [name, uuid].join(" ").trim();
  }

  /* -------------------------------------------- */

  /**
   * Compare a pair of results for sorted display in this sheet.
   * @param {object} resultA Sheet data for a result
   * @param {object} resultB Sheet data for a different result
   * @returns {number} A comparator return value expected by `Array#sort`
   * @protected
   */
  _sortResults(resultA, resultB) {
    return resultA.range[0] - resultB.range[0];
  }

  /* -------------------------------------------- */

  /**
   * Create a Table Result from initial data and with reasonable defaults.
   * @param {DeepPartial<TableResultData>} initialData
   * @protected
   */
  async _createResult(initialData={}) {
    // Get existing results
    const document = this.document;
    const results = foundry.utils.deepClone(document._source.results);
    const last = results.at(-1);

    // Get weight and range data
    const lastWeight = last?.weight ?? 1;
    const totalWeight = results.reduce((sum, r) => sum + r.weight, 0) || 1;
    const minRoll = results.length > 0 ? Math.min(...results.map(r => r.range[0])) : 0;
    const maxRoll = results.length > 0 ? 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, lastWeight * perW)];

    // Create the new Result
    const resultData = {
      type: last?.type ?? CONST.TABLE_RESULT_TYPES.TEXT,
      weight: lastWeight,
      range,
      ...initialData
    };
    await document.createEmbeddedDocuments("TableResult", [resultData], {renderSheet: false});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareSubmitData(event, form, formData, updateData) {
    const submitData = super._prepareSubmitData(event, form, formData, updateData);
    for (const result of submitData.results ?? []) {
      TableResultConfig.prepareResultUpdateData(result);
    }
    return submitData;
  }

  /** @inheritDoc */
  async submit(options) {
    if ( !this.isEditMode ) return;
    return super.submit(options);
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preRender(context, options) {
    await super._preRender(context, options);

    // Wipe the window content after the first render and swap the mode CSS class
    if ( !options.isFirstRender && !this.element.classList.contains(`${this.mode}-mode`) ) {
      this.element.classList.toggle("edit-mode", this.isEditMode);
      this.element.classList.toggle("view-mode", !this.isEditMode);
      this.element.querySelector(".window-content").innerHTML = "";
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    this.element.classList.add(`${this.mode}-mode`);
    return super._onFirstRender(context, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    // Drag and Drop
    new foundry.applications.ux.DragDrop.implementation({
      dropSelector: ".window-content",
      permissions: {
        dragstart: () => false,
        drop: () => this.isEditMode
      },
      callbacks: {
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);

    // Allow draws with replacement by observers even if the Table is not editable
    if ( !options.parts.includes("footer") ) return;
    const table = context.document;
    const drawButton = this.element.querySelector("button[data-action=drawResult]");
    if ( table.replacement && table.testUserPermission(game.user, "OBSERVER") ) {
      drawButton.disabled = false;
    }
    // Disallow draws without replacement from compendium Tables
    else if ( !table.replacement && table.pack ) {
      drawButton.disabled = true;
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  _onRevealSecret(event) {
    const resultId = event.target.closest("[data-result-id]")?.dataset.resultId;
    if ( resultId ) {
      const result = this.document.results.get(resultId);
      if ( !result ) return;
      const modified = event.target.toggleRevealed(result.description);
      result.update({description: modified});
    } else {
      const modified = event.target.toggleRevealed(this.document.description);
      this.document.update({description: modified});
    }
  }

  /* -------------------------------------------- */

  /**
   * Create a Compendium or Document result from a dropped document.
   * @param {DragEvent} event The triggering drop event
   * @protected
   */
  async _onDrop(event) {
    const data = TextEditor$1.implementation.getDragEventData(event);
    const allowed = Hooks$1.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 = foundry.utils.getDocumentClass(data.type);
    const document = await cls.fromDropData(data);
    if ( !document || document.isEmbedded ) return;
    if ( document === this.document ) {
      throw new Error(`${this.document.name} cannot have a result of itself.`);
    }

    // Delegate to the onCreate handler
    return this._createResult({
      name: document.name,
      img: document.img,
      type: CONST.TABLE_RESULT_TYPES.DOCUMENT,
      documentUuid: document.uuid
    });
  }

  /* -------------------------------------------- */

  /**
   * Alternate between view and edit modes.
   * @this {RollTableSheet}
   * @type {ApplicationClickAction}
   */
  static async #onChangeMode() {
    this.mode = this.isEditMode ? "view" : "edit";
    await this.render();
  }

  /* -------------------------------------------- */

  /**
   * Roll and draw a TableResult.
   * @this {RollTableSheet}
   * @type {ApplicationClickAction}
   */
  static async #onDrawResult(_event, button) {
    if ( this.form ) await this.submit({operation: {render: false}});
    button.disabled = true;
    const table = this.document;
    const tableRoll = await table.roll();
    const draws = table.getResultsForRoll(tableRoll.roll.total);
    if ( draws.length > 0 ) {
      if ( game.settings.get("core", "animateRollTable") ) await this._animateRoll(draws);
      await table.draw(tableRoll);
    }

    // Reenable the button if drawing with replacement since the draw won't trigger a sheet re-render
    if ( table.replacement ) button.disabled = false;
  }

  /* -------------------------------------------- */

  /**
   * Draw a single result without rolling.
   * @this {RollTableSheet}
   * @type {ApplicationClickAction}
   */
  static async #onDrawSpecificResult(event) {
    const resultId = event.target.closest("tr")?.dataset.resultId;
    const table = this.document;
    const result = table.results.get(resultId, {strict: true});
    await table.draw({results: [result]});
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the drawn status of the result in the table
   * @this {RollTableSheet}
   * @type {ApplicationClickAction}
   */
  static async #onLockResult(event) {
    if ( this.form ) await this.submit();
    const resultId = event.target.closest("tr")?.dataset.resultId;
    const result = this.document.results.get(resultId, {strict: true});
    await result.update({drawn: !result.drawn});
  }

  /* -------------------------------------------- */

  /**
   * Reset the drawn status of all TableResults.
   * @this {RollTableSheet}
   * @type {ApplicationClickAction}
   */
  static async #onResetResults() {
    await this.document.resetResults();
  }

  /* -------------------------------------------- */

  /**
   * Handle a button click to re-normalize dice result ranges across all RollTable results
   * @this {RollTableConfig}
   * @type {ApplicationClickAction}
   */
  static async #onNormalizeResults() {
    await this.submit();
    return this.document.normalize();
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a TableResult in the RollTable document.
   * @this {RollTableConfig}
   * @type {ApplicationClickAction}
   */
  static async #onCreateResult() {
    await this.submit();
    await this._createResult();
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the drawn status of the result in the table
   * @this {RollTableConfig}
   * @type {ApplicationClickAction}
   */
  static async #onOpenResultSheet(event) {
    await this.submit();
    const resultId = event.target.closest("tr")?.dataset.resultId;
    const result = this.document.results.get(resultId, {strict: true});
    await result.sheet.render({force: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle deletion of a TableResult in the RollTable document.
   * @this {RollTableConfig}
   * @type {ApplicationClickAction}
   */
  static async #onDeleteResult(event) {
    const resultId = event.target.closest("tr")?.dataset.resultId;
    await this.submit();
    const result = this.document.results.get(resultId, {strict: true});
    await result.delete();
  }

  /* -------------------------------------------- */
  /*  Result-Draw Animation                       */
  /* -------------------------------------------- */

  /**
   * 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<void>} A Promise that resolves once the animation is complete
   * @protected
   */
  async _animateRoll(results) {

    // Get the list of results and their indices
    const resultsList = this.element.querySelector("table[data-results] tbody");
    const drawnIds = new Set(results.map(r => r.id));
    const drawnItems = Array.from(resultsList.children).filter(li => drawnIds.has(li.dataset.resultId));

    // Set the animation timing
    const maxTime = 2000;
    const nResults = this.document.results.size;
    const nLoops = Math.min(Math.ceil(maxTime / (50 * nResults)), 4);
    const animTime = nLoops === 1 ? maxTime / nResults : 50;
    const animOffset = Math.round(resultsList.offsetHeight / (resultsList.children[0].offsetHeight * 2));

    // Animate the roulette
    await this._animateRoulette(resultsList, drawnIds, nLoops, animTime, animOffset);

    // Flash the results
    const flashes = drawnItems.map(li => this._flashResult(li));
    await Promise.all(flashes);
  }

  /* -------------------------------------------- */

  /**
   * Animate a "roulette" through the table until arriving at the final loop and a drawn result
   * @param {HTMLElement} resultsTable 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<void>} A Promise that resolves once the animation is complete
   * @protected
   */
  async _animateRoulette(resultsTable, drawnIds, nLoops, animTime, animOffset) {
    let loop = 0;
    let idx = 0;
    let item = null;
    return new Promise(resolve => {
      const animId = setInterval(() => {
        if (idx === 0) loop++;
        if (item) item.classList.remove("roulette");

        // Scroll to the next item
        item = resultsTable.children[idx];
        resultsTable.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 < resultsTable.children.length - 1 ? idx + 1 : 0;
      }, animTime);
    });
  }

  /* -------------------------------------------- */

  /**
   * Display a flashing animation on the selected result to emphasize the draw
   * @param {HTMLElement} item The HTML li item of the winning result
   * @returns {Promise<void>} A Promise that resolves once the animation is complete
   * @protected
   */
  async _flashResult(item) {
    return new Promise(resolve => {
      let count = 0;
      const animId = setInterval(() => {
        if (count % 2) item.classList.remove("roulette");
        else item.classList.add("roulette");
        if (count === 7) {
          clearInterval(animId);
          resolve();
        }
        count++;
      }, 50);
    });
  }
}

/**
 * @import {ApplicationConfiguration, FormFooterButton} from "../_types.mjs";
 * @import {DocumentSheetConfiguration} from "../api/document-sheet.mjs";
 */

/**
 * The Application responsible for configuring a single MeasuredTemplate document within a parent Scene.
 * @param {MeasuredTemplateDocument} object The document being configured.
 * @param {DocumentSheetConfiguration & ApplicationConfiguration} [options] Application configuration options.
 */
class MeasuredTemplateConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["template-config"],
    canCreate: true,
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-ruler-combined",
      resizable: true
    },
    position: {width: 480},
    form: {closeOnSubmit: true}
  };

  /** @override */
  static PARTS = {
    main: {template: "templates/scene/template-config.hbs", scrollable: [""]},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const document = context.document;
    const templateTypes = Object.values(CONST.MEASURED_TEMPLATE_TYPES).reduce((types, type) => {
      types[type] = game.i18n.localize(`TEMPLATE.TYPES.${type}`);
      return types;
    }, {});
    const units = {
      degrees: game.i18n.localize("Degrees"),
      gridUnits: document.parent.grid.units || game.i18n.localize("GridUnits"),
      pixels: game.i18n.localize("Pixels")
    };
    const submitText = document.collection?.has(document.id) ? "TEMPLATE.SubmitUpdate" : "TEMPLATE.SubmitCreate";
    return Object.assign(context, {
      templateTypes,
      units,
      userColor: game.user.color,
      buttons: [{type: "submit", icon: "fa-solid fa-floppy-disk", label: submitText}]
    });
  }
}

/**
 * The Application responsible for configuring a single Tile document within a parent Scene.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class TileConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["tile-config"],
    canCreate: true,
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-cubes"
    },
    position: {width: 480},
    form: {
      closeOnSubmit: true
    }
  };

  /** @override */
  static PARTS = {
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    position: {template: "templates/scene/tile/position.hbs"},
    appearance: {template: "templates/scene/tile/appearance.hbs"},
    overhead: {template: "templates/scene/tile/overhead.hbs"},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /** @override */
  static TABS = {
    sheet: {
      tabs: [
        {id: "position", icon: "fa-solid fa-location-dot"},
        {id: "appearance", icon: "fa-solid fa-image"},
        {id: "overhead", icon: "fa-solid fa-house"}
      ],
      initial: "position",
      labelPrefix: "TILE.TABS"
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    if ( !this.document.id ) return game.i18n.localize("TILE.ACTIONS.CREATE");
    return super.title;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    if (options.isFirstRender && !this.document._source.texture.src) {
      this.tabGroups.sheet = "appearance";
    }
    return super._configureRenderOptions(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const document = context.document;
    const submitText = `TILE.ACTIONS.${document.collection?.has(document.id) ? "UPDATE" : "CREATE"}`;
    return Object.assign(context, {
      gridUnits: document.parent.grid.units || game.i18n.localize("GridUnits"),
      buttons: [{type: "submit", icon: "fa-solid fa-floppy-disk", label: submitText}]
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    const partContext = await super._preparePartContext(partId, context, options);
    if ( partId === "appearance" ) {
      partContext.hasVideo = foundry.helpers.media.VideoHelper.hasVideoExtension(this.document._source.texture.src);
    }
    const tab = partContext.tabs[partId];
    if ( tab ) partContext.tab = tab;
    return partContext;
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeForm() {
    const fdo = new FormDataExtended(this.form).object;

    // Show/hide video options
    const hasVideo = foundry.helpers.media.VideoHelper.hasVideoExtension(fdo["texture.src"]);
    this.element.querySelector("fieldset[data-video-options]").hidden = !hasVideo;

    // To allow a preview without glitches
    fdo.width = Math.abs(fdo.width);
    fdo.height = Math.abs(fdo.height);

    // Handle tint exception
    const 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, fdo);
    this.document.object.refresh();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    if ( this.document.object?.isPreview ) this.document.object.destroy();
  }
}

/**
 * @import ApplicationV2 from "../../api/application.mjs";
 * @import {DataSchema} from "@common/abstract/_types.mjs";
 * @import {PrototypeToken} from "@common/data/data.mjs";
 * @import TokenDocument from "@client/documents/token.mjs";
 * @import {NumberField} from "@common/data/fields.mjs";
 * @import {HTMLMultiCheckboxElement} from "../../elements/multi-select.mjs";
 * @import {ApplicationClickAction, ApplicationTab, FormFooterButton} from "../../_types.mjs";
 */

/**
 * A mixin for UI shared between TokenDocument and PrototypeToken sheets
 * @param {typeof ApplicationV2} Base
 */
function TokenApplicationMixin(Base) {
  class TokenApplication extends HandlebarsApplicationMixin(Base) {

    /** @inheritDoc */
    static DEFAULT_OPTIONS = {
      classes: ["token-config"],
      window: {
        contentClasses: ["standard-form"],
        icon: "fa-solid fa-circle-user"
      },
      position: {width: 560},
      form: {closeOnSubmit: true},
      actions: {
        addDetectionMode: TokenApplication.#onAddDetectionMode,
        removeDetectionMode: TokenApplication.#onRemoveDetectionMode
      }
    };

    /** @override */
    static PARTS = {
      tabs: {template: "templates/generic/tab-navigation.hbs"},
      identity: {template: "templates/scene/token/identity.hbs", scrollable: [""]},
      appearance: {template: "templates/scene/token/appearance.hbs", scrollable: [""]},
      vision: {template: "templates/scene/token/vision.hbs", scrollable: [""]},
      light: {template: "templates/scene/token/light.hbs", scrollable: [""]},
      resources: {template: "templates/scene/token/resources.hbs", scrollable: [""]},
      footer: {template: "templates/generic/form-footer.hbs"}
    };

    /** @override */
    static TABS = {
      sheet: {
        tabs: [
          {id: "identity", icon: "fa-solid fa-memo-pad"},
          {id: "appearance", icon: "fa-solid fa-square-user"},
          {id: "vision", icon: "fa-solid fa-eye"},
          {id: "light", icon: "fa-solid fa-lightbulb"},
          {id: "resources", icon: "fa-solid fa-heart"}
        ],
        initial: "identity",
        labelPrefix: "TOKEN.TABS"
      }
    };

    /**
     * Localized Token Display Modes
     * @returns {Record<string, string>}
     */
    static get DISPLAY_MODES() {
      TokenApplication.#DISPLAY_MODES ??= Object.entries(CONST.TOKEN_DISPLAY_MODES).reduce((modes, [key, value]) => {
        modes[value] = game.i18n.localize(`TOKEN.DISPLAY_${key}`);
        return modes;
      }, {});
      return TokenApplication.#DISPLAY_MODES;
    }

    static #DISPLAY_MODES;

    /**
     * Localized Token Dispositions
     * @returns {Record<string, string>}
     */
    static get TOKEN_DISPOSITIONS() {
      TokenApplication.#TOKEN_DISPOSITIONS ??= Object.entries(CONST.TOKEN_DISPOSITIONS)
        .reduce((dispositions, [key, value]) => {
          dispositions[value] = game.i18n.localize(`TOKEN.DISPOSITION.${key}`);
          return dispositions;
        }, {});
      return TokenApplication.#TOKEN_DISPOSITIONS;
    }

    static #TOKEN_DISPOSITIONS;

    /**
     * Localized Token Turn Marker modes
     * @returns {Record<string, string>}
     */
    static get TURN_MARKER_MODES() {
      TokenApplication.#TURN_MARKER_MODES ??= Object.entries(CONST.TOKEN_TURN_MARKER_MODES)
        .reduce((modes, [key, value]) => {
          modes[value] = game.i18n.localize(`TOKEN.TURNMARKER.MODES.${key}`);
          return modes;
        }, {});
      return TokenApplication.#TURN_MARKER_MODES;
    }

    static #TURN_MARKER_MODES;

    /**
     * Localized Token Shapes
     * @returns {Record<string, string>}
     */
    static get TOKEN_SHAPES() {
      TokenApplication.#TOKEN_SHAPES ??= Object.entries(CONST.TOKEN_SHAPES)
        .reduce((shapes, [key, value]) => {
          shapes[value] = game.i18n.localize(`TOKEN.SHAPES.${key}.label`);
          return shapes;
        }, {});
      return TokenApplication.#TOKEN_SHAPES;
    }

    static #TOKEN_SHAPES;

    /* -------------------------------------------- */

    /**
     * Maintain a copy of the original to show a real-time preview of changes.
     * @type {TokenDocument|PrototypeToken|null}
     * @protected
     */
    _preview = null;

    /**
     * Is the token a PrototypeToken?
     * @type {boolean}
     * @abstract
     */
    isPrototype;

    /**
     * A reference to the Actor the token depicts
     * @returns {Actor|null}
     * @abstract
     */
    get actor() {
      throw new Error("The Base class must implement this getter.");
    }

    /**
     * The TokenDocument or PrototypeToken
     * @returns {TokenDocument|PrototypeToken}
     * @abstract
     */
    get token() {
      throw new Error("The Base class must implement this getter.");
    }

    /**
     * The schema fields for this token DataModel
     * @returns {DataSchema}
     * @protected
     * @abstract
     */
    get _fields() {
      throw new Error("The Base class must implement this getter.");
    }

    /* -------------------------------------------- */

    /**
     * Get an Object of image paths and filenames to display in the Token sheet
     * @returns {Promise<Record<string, string>>}
     */
    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;
      }, {});
    }

    /* -------------------------------------------- */

    /**
     * Assign a preview clone for propagating form changes across the sheet and (if editing a TokenDocument) the canvas.
     * @returns {Promise<void>}
     * @protected
     * @abstract
     */
    async _initializeTokenPreview() {
      throw new Error("The Base class must implement this method.");
    }

    /* -------------------------------------------- */

    /**
     * Render the Token ring effects input using a multi-checkbox element.
     * @param {NumberField} field                   The ring effects field
     * @param {FormInputConfig<string[]>} 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);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _preFirstRender(context, options) {
      await super._preFirstRender(context, options);
      await this._initializeTokenPreview();
    }

    /* -------------------------------------------- */

    /**
     * Mimic changes to the Token document as if they were true document updates.
     * @param {object} [changes]  The changes to preview.
     * @returns {void}
     * @protected
     */
    _previewChanges(changes) {
      if ( !changes || !this._preview ) return;
      const deletions = {"-=actorId": null, "-=actorLink": null};
      const mergeOptions = {inplace: false, performDeletions: true};
      this._preview.updateSource(mergeObject(changes, deletions, mergeOptions));
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _prepareContext(options) {
      const context = await super._prepareContext(options);
      if ( options.resetPreview ) this._initializeTokenPreview();
      return Object.assign(context, {
        rootId: this.id,
        source: this.token._source,
        fields: this._fields,
        gridUnits: game.i18n.localize("GridUnits"),
        isPrototype: this.isPrototype,
        displayModes: TokenApplication.DISPLAY_MODES,
        buttons: this._prepareButtons()
      });
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _preparePartContext(partId, context, options) {
      context = await super._preparePartContext(partId, context, options);
      switch ( partId ) {
        case "footer":
          context.buttons = this._prepareButtons();
          break;
        default: {
          const tab = context.tabs[partId];
          if ( tab ) {
            context.tab = tab;
            const tabContext = await this[`_prepare${partId.titleCase()}Tab`]?.();
            Object.assign(context, tabContext ?? {});
          }
        }
      }
      return context;
    }

    /* -------------------------------------------- */

    /**
     * Prepare data to be displayed in the Identity tab.
     * @protected
     */
    _prepareIdentityTab() {
      const currentAction = this.token.movementAction;
      return {
        isGM: game.user.isGM,
        actors: game.actors
          .filter(a => a.isOwner)
          .map(a => ({value: a.id, label: a.name}))
          .sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang)),
        movementActions: Object.entries(CONFIG.Token.movement.actions).reduce(
          (choices, [action, {label, canSelect}]) => {
            if ( canSelect(this.token) || (action === currentAction) ) choices[action] = label;
            return choices;
          }, {}),
        dispositions: TokenApplication.TOKEN_DISPOSITIONS
      };
    }

    /* -------------------------------------------- */

    /**
     * Prepare data to be displayed in the Appearance tab.
     * @returns {Promise<object>}
     * @protected
     */
    async _prepareAppearanceTab() {
      const token = this.token;
      const source = token._source;
      const alternateImages = await this.#getAlternateTokenImages();
      const canBrowseFiles = game.user.hasPermission("FILES_BROWSE");
      return {
        shapes: this.isPrototype ? undefined : TokenApplication.TOKEN_SHAPES,
        hasAlternates: !foundry.utils.isEmpty(alternateImages),
        alternateImages,
        colorationTechniques: foundry.canvas.rendering.shaders.AdaptiveLightingShader.SHADER_TECHNIQUES,
        randomImgEnabled: this.isPrototype && (canBrowseFiles || source.randomImg),
        scale: Math.abs(source.texture.scaleX),
        mirrorX: source.texture.scaleX < 0,
        mirrorY: source.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)
      };
    }

    /* -------------------------------------------- */

    /**
     * Prepare data to be displayed in the Vision tab.
     * @returns {Promise<object>}
     * @protected
     */
    async _prepareVisionTab() {
      const token = this.token;
      const sourceDetectionModes = new Set(token._source.detectionModes.map(m => m.id));
      const visionModes = Object.values(CONFIG.Canvas.visionModes)
        .filter(f => f.tokenConfig)
        .reduce((modes, mode) => {
          modes[mode.id] = mode.label;
          return modes;
        }, {});
      const compareDetectionModes = (mode1, mode2) => {
        if ( mode1.id === "" ) return mode2.id === "" ? 0 : 1;
        if ( mode2.id === "" ) return -1;
        let label1 = CONFIG.Canvas.detectionModes[mode1.id]?.label;
        if ( label1 ) label1 = game.i18n.localize(label1);
        else label1 = mode1.id;
        let label2 = CONFIG.Canvas.detectionModes[mode2.id]?.label;
        if ( label2 ) label2 = game.i18n.localize(label2);
        else label2 = mode2.id;
        return label1.localeCompare(label2, game.i18n.lang);
      };
      return {
        sightFields: this._fields.sight.fields,
        visionModes,
        detectionModes: Object.values(CONFIG.Canvas.detectionModes).filter(f => f.tokenConfig),
        sourceDetectionModes: token._source.detectionModes.toSorted(compareDetectionModes),
        preparedDetectionModes: token.detectionModes.filter(m => !sourceDetectionModes.has(m.id)
          && ((m.range ?? Infinity) > 0) && m.enabled).map(({id, range, enabled}) => ({id, range:
          range === Infinity ? null : range, enabled})).sort(compareDetectionModes)
      };
    }

    /* -------------------------------------------- */

    /**
     * Prepare data to be displayed in the Vision tab.
     * @returns {Promise<object>}
     * @protected
     */
    async _prepareLightTab() {
      const doc = this._preview ?? this.document;
      return {
        lightFields: this._fields.light.fields,
        lightAnimations: doc.light.negative ? CONFIG.Canvas.darknessAnimations : CONFIG.Canvas.lightAnimations
      };
    }

    /* -------------------------------------------- */

    /**
     * Prepare data to be displayed in the Resources tab.
     * @returns {Promise<object>}
     * @protected
     */
    async _prepareResourcesTab() {
      const token = this.token;
      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 TokenDocument = foundry.utils.getDocumentClass("Token");
      const attributes = TokenDocument.getTrackedAttributes(attributeSource);
      return {
        barAttributes: TokenDocument.getTrackedAttributeChoices(attributes),
        bar1: token.getBarAttribute?.("bar1"),
        bar2: token.getBarAttribute?.("bar2"),
        turnMarkerModes: TokenApplication.TURN_MARKER_MODES,
        turnMarkerAnimations: CONFIG.Combat.settings.turnMarkerAnimations
      };
    }

    /* -------------------------------------------- */

    /**
     * Prepare form submission buttons.
     * @returns {FormFooterButton[]}
     * @protected
     */
    _prepareButtons() {
      return [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "TOKEN.Update"}];
    }

    /* -------------------------------------------- */
    /*  Event Listeners and Handlers                */
    /* -------------------------------------------- */

    /** @inheritDoc */
    _onChangeForm(formConfig, event) {
      super._onChangeForm(formConfig, event);

      // Pre-populate vision mode defaults
      if ( event.target.name === "sight.visionMode" ) {
        const defaults = CONFIG.Canvas.visionModes[event.target.value]?.vision?.defaults || {};
        for ( const fieldName of ["color", "attenuation", "brightness", "saturation", "contrast"] ) {
          const value = defaults[fieldName];
          if ( value === undefined ) continue;
          const field = this.form.querySelector(`[name="sight.${fieldName}"]`);
          if ( !field ) continue;
          if ( field.type === "checkbox" ) field.checked = value;
          else field.value = value;
        }
      }

      // Update texture input when alternate image is selected
      else if ( event.target.name === "alternateImages" ) {
        event.target.form.elements["texture.src"].value = event.target.value;
      }
    }

    /* -------------------------------------------- */

    /**
     * Add a new detection mode to the Token preview.
     * @this {TokenApplication}
     * @type {ApplicationClickAction}
     */
    static async #onAddDetectionMode() {
      const formData = new FormDataExtended(this.form);
      const modes = Object.values(this._processFormData(event, this.form, formData).detectionModes ?? {});
      modes.push({id: "", range: 0, enabled: true});
      this._previewChanges({detectionModes: modes});
      await this.render({parts: ["vision"], resetPreview: false});
    }

    /* -------------------------------------------- */

    /**
     * Remove a detection mode from the Token preview.
     * @this {TokenApplication}
     * @type {ApplicationClickAction}
     */
    static async #onRemoveDetectionMode(_event, target) {
      const formData = new FormDataExtended(this.form);
      const modes = Object.values(this._processFormData(event, this.form, formData).detectionModes ?? {});
      const index = Number(target.closest("[data-index]")?.dataset.index);
      modes.splice(index, 1);
      this._previewChanges({detectionModes: modes});
      await this.render({parts: ["vision"], resetPreview: false});
    }

    /* -------------------------------------------- */
    /*  Form Submission                             */
    /* -------------------------------------------- */

    /**
     * Process several fields from form submission data into proper model changes.
     * @param {object} submitData Form submission data passed through {@link foundry.applications.ux.FormDataExtended}
     * @protected
     */
    _processChanges(submitData) {

      // Empty string bar attributes need to be converted to null, because "" is cleaned to the initial value
      if ( !submitData.bar1.attribute ) submitData.bar1.attribute = null;
      if ( !submitData.bar2.attribute ) submitData.bar2.attribute = null;

      // Convert scale and mirror data from the form submission to TextureData changes
      if ( typeof submitData.scale === "number" ) {
        submitData.texture.scaleX = submitData.scale * (submitData.mirrorX ? -1 : 1);
        submitData.texture.scaleY = submitData.scale * (submitData.mirrorY ? -1 : 1);
      }
      for ( const key of ["scale", "mirrorX", "mirrorY"] ) delete submitData[key];

      // Process token ring effects from the form submission
      if ( Array.isArray(submitData.ring?.effects) ) {
        const TRE = CONFIG.Token.ring.ringClass.effects;
        let effects = submitData.ring.enabled ? TRE.ENABLED : TRE.DISABLED;
        for ( const effectName of submitData.ring.effects ) {
          const v = TRE[effectName] ?? 0;
          effects |= v;
        }
        submitData.ring.effects = effects;
      }
    }
  }
  return TokenApplication;
}

/**
 * @import {ApplicationClickAction, ApplicationFormSubmission} from "../../_types.mjs";
 * @import DocumentSheetV2 from "../../api/document-sheet.mjs";
 */

/**
 * The Application responsible for configuring an actor's PrototypeToken
 * @extends ApplicationV2
 * @mixes TokenApplication
 */
class PrototypeTokenConfig extends TokenApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  constructor(options) {
    super(options);
    this.#prototype = this.options.prototype;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    tag: "form",
    classes: ["prototype-token-config"],
    actions: {
      assignToken: PrototypeTokenConfig.#onAssignToken
    },
    form: {
      handler: PrototypeTokenConfig.#onSubmit
    }
  };

  /* -------------------------------------------- */

  /**
   * The prototype token being edited
   * @type {PrototypeToken}
   */
  #prototype;

  /** @override */
  isPrototype = true;

  /** @override */
  get title() {
    return `${game.i18n.localize("TOKEN.TitlePrototype")}: ${this.actor.name}`;
  }

  /** @override */
  get token() {
    return this._preview ?? this.#prototype;
  }

  /** @override */
  get actor() {
    return this.#prototype.parent;
  }

  /** @override */
  get _fields() {
    return PrototypeToken.schema.fields;
  }

  /**
   * Is this sheet visible to the user?
   * @returns {boolean}
   */
  get isVisible() {
    const ownsActor = this.actor?.isOwner ?? false;
    return ownsActor && game.user.can("TOKEN_CONFIGURE");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _canRender(options) {
    if ( !this.isVisible ) throw new Error(game.i18n.format("SHEETS.DocumentSheetPrivate", {type: "DOCUMENT.Token"}));
    return super._canRender(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    const initialized = super._initializeApplicationOptions(options);
    if ( options.prototype.parent?.uuid ) {
      const uuid = options.prototype.parent.uuid;
      initialized.id = `${this.constructor.name}-${uuid.replaceAll(".", "-")}`;
    }
    else throw new Error(`Prototype token ${options.prototype.name} lacks an identifiable parent.`);
    return initialized;
  }

  /* -------------------------------------------- */

  /** @override */
  async _initializeTokenPreview() {
    this._preview = this.#prototype.clone();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    options.resetPreview ??= !options.isFirstRender; // Reset the preview unless requested otherwise
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareButtons() {
    const buttons = super._prepareButtons();
    buttons.unshift({type: "button", icon: "fa-solid fa-user", label: "TOKEN.Assign", action: "assignToken"});
    return buttons;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    this.#prototype.actor.apps[this.id] = this;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle Token assignment requests to update the default prototype Token
   * @this {PrototypeTokenConfig}
   * @type {ApplicationClickAction}
   */
  static async #onAssignToken() {
    // Get controlled Token data
    const 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("TOKEN.AssignSuccess", {format: {name: this.actor.name}});
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /**
   * Customize how form data is extracted into an expanded object.
   * @param {SubmitEvent|null} event    The originating form submission event
   * @param {HTMLFormElement} form      The form element that was submitted
   * @param {FormDataExtended} formData Processed data for the submitted form
   * @returns {object} An expanded object of processed form data
   * @throws {Error}   Subclasses may throw validation errors here to prevent form submission
   * @protected
   */
  _processFormData(event, form, formData) {
    return foundry.utils.expandObject(formData.object);
  }

  /* -------------------------------------------- */

  /**
   * Process form submission for the sheet
   * @this {PrototypeTokenConfig}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmit(event, form, formData) {
    const submitData = this._processFormData(event, form, formData);
    submitData.detectionModes ??= []; // Clear detection modes array
    this._processChanges(submitData);
    const changes = {prototypeToken: submitData};
    this.actor.validate({changes, clean: true, fallback: false});
    await this.actor.update(changes);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _tearDown(options) {
    super._tearDown(options);
    delete this.#prototype.actor.apps[this.id];
  }
}

/**
 * The Application responsible for configuring a single token document within a parent Scene
 * @extends ApplicationSheetV2
 * @mixes TokenApplication
 */
class TokenConfig extends TokenApplicationMixin(DocumentSheetV2) {

  /** @override */
  isPrototype = false;

  /** @override */
  get token() {
    return this._preview ?? this.document;
  }

  /** @override */
  get actor() {
    return this.document.actor;
  }

  /** @override */
  get _fields() {
    return BaseToken.schema.fields;
  }

  /** @inheritDoc */
  get isVisible() {
    return super.isVisible && game.user.can("TOKEN_CONFIGURE");
  }

  /* -------------------------------------------- */

  /** @override */
  async _initializeTokenPreview() {
    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._configPreview.addChild(this._preview.object);
    this._previewChanges();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.gridUnits = this.token.parent?.grid.units || game.i18n.localize("GridUnits");
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareAppearanceTab(options) {
    const context = await super._prepareAppearanceTab(options);
    const gridType = this.token.parent?.grid.type;
    if ( gridType === CONST.GRID_TYPES.SQUARE ) {
      context.shapes = {
        [CONST.TOKEN_SHAPES.RECTANGLE_1]: game.i18n.localize("TOKEN.SHAPES.RECTANGLE.label")
      };
    }
    else if ( gridType === CONST.GRID_TYPES.GRIDLESS ) {
      context.shapes = {
        [CONST.TOKEN_SHAPES.ELLIPSE_1]: game.i18n.localize("TOKEN.SHAPES.ELLIPSE.label"),
        [CONST.TOKEN_SHAPES.RECTANGLE_1]: game.i18n.localize("TOKEN.SHAPES.RECTANGLE.label")
      };
    }
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _toggleDisabled(disabled) {
    super._toggleDisabled(disabled);
    const anchor = this.element.querySelector("a[data-action=addDetectionMode]");
    anchor.classList.toggle("disabled", disabled);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _previewChanges(changes) {
    super._previewChanges(changes);
    if ( 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._preview.object?.destroyed === false ) {
      this._preview.object.destroy({children: true});
    }
    this._preview.baseActor?._unregisterDependentToken(this._preview);
    this._preview = null;
    const object = this.document.object;
    if ( object?.destroyed === false ) {
      object.renderable = true;
      object.initializeSources();
      object.control();
      object.renderFlags.set({refresh: true});
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    this.#toggleTurnMarkerDisabledFields();
    return super._onRender(context, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    if ( ["bar1", "bar2"].includes(event.target.name) ) {
      this._onChangeBar(event);
    }
    const formData = new FormDataExtended(this.form);
    const submitData = this._prepareSubmitData(event, this.form, formData);
    this._previewChanges(submitData);

    // Turn Marker Disabled/Enabled
    if ( event.target.name === "turnMarker.mode" ) this.#toggleTurnMarkerDisabledFields();

    // Special handling for darkness state change
    if ( event.target.name === "light.negative") this.render({parts: ["light"]});
  }

  /* -------------------------------------------- */

  /**
   * Toggle the disabled state for certain turn marker fields depending on the mode.
   */
  #toggleTurnMarkerDisabledFields() {
    const notCustom = this.form["turnMarker.mode"].value !== "2";
    for ( const field of Object.values(this.document.schema.fields.turnMarker.fields) ) {
      if ( field.name === "mode" ) continue;
      const input = this.form[field.fieldPath];
      if ( input ) input.disabled = notCustom;
    }
  }

  /* -------------------------------------------- */

  /**
   * 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
   * @protected
   */
  _onChangeBar(event) {
    const form = this.form;
    const attr = this.token.getBarAttribute("", {alternative: event.target.value});
    const barName = event.target.name;
    form.querySelector(`input[data-${barName}-value]`).value = attr !== null ? attr.value : "";
    form.querySelector(`input[data-${barName}-max]`).value = ((attr !== null) && (attr.type === "bar")) ? attr.max : "";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    this.#resetPreview();
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    submitData.detectionModes ??= []; // Clear detection modes array
    this._processChanges(submitData);
    return submitData;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _processSubmitData(event, form, submitData, options) {
    let waypoint = {};
    for ( const k of TokenDocument.MOVEMENT_FIELDS ) {
      if ( k in submitData ) {
        if ( submitData[k] !== this.document._source[k] ) waypoint[k] = submitData[k];
        delete submitData[k];
      }
    }
    await super._processSubmitData(event, form, submitData, options);
    this.#resetPreview();
    if ( foundry.utils.isEmpty(waypoint) ) return;
    if ( this.document.rendered ) waypoint = this.document.object._getConfigMovementPosition(waypoint);
    waypoint.action = "displace";
    waypoint.snapped = false;
    waypoint.explicit = true;
    waypoint.checkpoint = true;
    await this.document.move(waypoint, {method: "config", autoRotate: false, showRuler: false,
      constrainOptions: {ignoreWalls: true, ignoreCost: true}});
  }
}

/**
 * The User configuration application.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class UserConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["user-config"],
    position: {width: 480},
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-user"
    },
    actions: {
      releaseCharacter: UserConfig.#onReleaseCharacter
    },
    form: {
      closeOnSubmit: true
    }
  };

  /** @override */
  static PARTS = {
    form: {
      id: "form",
      template: "templates/sheets/user-config.hbs",
      scrollable: [""]
    },
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    return `${game.i18n.localize("PLAYERS.ConfigTitle")}: ${this.document.name}`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.user = context.document;
    context.characterWidget = this.#characterChoiceWidget.bind(this);
    context.buttons = [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "USER.SHEET.BUTTONS.SAVE"}];
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Render the Character field as a choice between observed Actors.
   * @returns {HTMLDivElement}
   */
  #characterChoiceWidget(field, _groupConfig, inputConfig) {

    // Create the form field
    const fg = document.createElement("div");
    fg.className = "form-group stacked character";
    const ff = fg.appendChild(document.createElement("div"));
    ff.className = "form-fields";
    fg.insertAdjacentHTML("beforeend", `<p class="hint">${field.hint}</p>`);

    // Actor select
    const others = game.users.reduce((s, u) => {
      if ( u.character && !u.isSelf ) s.add(u.character.id);
      return s;
    }, new Set());

    const options = [];
    const ownerGroup = game.i18n.localize("OWNERSHIP.OWNER");
    const observerGroup = game.i18n.localize("OWNERSHIP.OBSERVER");
    for ( const actor of game.actors ) {
      if ( !actor.testUserPermission(this.document, "OBSERVER") ) continue;
      const a = {value: actor.id, label: actor.name, disabled: others.has(actor.id)};
      options.push({group: actor.isOwner ? ownerGroup : observerGroup, ...a});
    }

    const input = foundry.applications.fields.createSelectInput({...inputConfig,
      name: field.fieldPath,
      options,
      blank: "",
      sort: true
    });
    ff.appendChild(input);

    // Player character
    const c = this.document.character;
    if ( c ) {
      ff.insertAdjacentHTML("afterbegin", `<img class="avatar" src="${foundry.utils.escapeHTML(c.img)}" alt="${foundry.utils.escapeHTML(c.name)}">`);
      const release = `<button type="button" class="icon fa-solid fa-ban" data-action="releaseCharacter"
                               data-tooltip="USER.SHEET.BUTTONS.RELEASE"></button>`;
      ff.insertAdjacentHTML("beforeend", release);
    }
    return fg;
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to release the currently selected character.
   * @param {PointerEvent} event
   */
  static #onReleaseCharacter(event) {
    event.preventDefault();
    const button = event.target;
    const fields = button.parentElement;
    fields.querySelector("select[name=character]").value = "";
    fields.querySelector("img.avatar").remove();
    button.remove();
    this.setPosition({height: "auto"});
  }
}

/**
 * The Application responsible for configuring a single Wall document within a parent Scene.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class WallConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["wall-config"],
    position: {width: 480},
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-block-brick"
    },
    form: {closeOnSubmit: true},
    actions: {previewSound: WallConfig.#onPreviewSound}
  };

  /** @override */
  static PARTS = {
    body: {template: "templates/scene/wall-config.hbs"},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /** Wall sense types affected by proximity threshold attenuation */
  static #PROXIMITY_SENSE_TYPES = [WALL_SENSE_TYPES.PROXIMITY, WALL_SENSE_TYPES.DISTANCE];

  /**
   * The set of Wall documents that should all be edited when changes to this config form are submitted.
   * @type {ReadonlySet<WallDocument>}
   */
  get editTargets() {
    return this.#editTargets;
  }

  #editTargets = new Set([this.document]);

  /**
   * A tracking value corresponding with the index of an entry in CONFIG.Wall.doorSounds: each click of the preview
   * button plays the next sound in an entry.
   * @type {number}
   */
  #audioPreviewState = 0;

  /** @inheritDoc */
  get title() {
    return this.#editTargets.size > 1 ? game.i18n.localize("WALL.TitleMany") : super.title;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    if ( options.isFirstRender ) {
      this.#editTargets = Array.isArray(options.walls)
        ? new Set([this.document, ...options.walls])
        : new Set([this.document]);
    }
    super._configureRenderOptions(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const {document, source, fields} = context;
    const c = source.c;
    const coordinates = this.#editTargets.size > 1
      ? game.i18n.format("WALL.NWalls", {n: this.#editTargets.size})
      : game.i18n.format("WALL.CoordinateRange", {p0X: c[0], p0Y: c[1], p1X: c[2], p1Y: c[3]});
    const thresholdFields = ["light", "sight", "sound"].map(k => ({
      name: k,
      label: fields[k].label,
      choices: fields[k].choices,
      disabled: !WallConfig.#PROXIMITY_SENSE_TYPES.includes(source[k])
    }));
    const animationDirections = [
      {value: -1, label: game.i18n.localize("WALL.ANIMATION_DIRECTIONS.REVERSE")},
      {value: 1, label: game.i18n.localize("WALL.ANIMATION_DIRECTIONS.DEFAULT")}
    ];
    return Object.assign(context, {
      coordinates,
      thresholdFields,
      animation: source.animation || fields.animation.clean({}),
      animationDirections,
      animationTypes: CONFIG.Wall.animationTypes,
      animationFieldsetClass: (document.door > 0) && document.animation?.type ? "" : "hidden",
      editingMany: this.#editTargets.length > 1,
      rootId: this.#editTargets.length > 1 ? foundry.utils.randomID() : this.id,
      gridUnits: document.parent.grid.units || game.i18n.localize("GridUnits"),
      doorSounds: CONFIG.Wall.doorSounds,
      buttons: [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "WALL.Submit"}]
    });
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeForm(_formConfig, event) {
    switch (event.target.name) {
      case "door":
        this.#toggleDoorOptions(Number(event.target.value) > CONST.WALL_DOOR_TYPES.NONE);
        this.#toggleAnimationOptions();
        break;
      case "doorSound":
        // Reset the audio preview state
        this.#audioPreviewState = 0;
        break;
      case "light":
      case "sight":
      case "sound":
        this.#toggleThresholdInputVisibility();
        break;
      case "animation.type":
        this.#toggleAnimationOptions();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle previewing a sound file for a Wall setting
   * @this {WallConfig}
   * @returns {Promise<void>}
   */
  static async #onPreviewSound() {
    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)];
    await game.audio.play(src, {context: game.audio.interface});
  }

  /* -------------------------------------------- */

  /**
   * Toggle the disabled attributes of the door options and show/hide their containing fieldset.
   * @param {boolean} isDoor
   * @returns {void}
   */
  #toggleDoorOptions(isDoor) {
    for ( const name of ["ds", "doorSound", "animation.type"] ) {
      const select = this.form[name];
      select.disabled = !isDoor;
      select.closest(".form-group").hidden = !isDoor;
    }
    this.setPosition(); // Form height changed
  }

  /* -------------------------------------------- */

  /**
   * Toggle the display of the advanced Door Animation section.
   */
  #toggleAnimationOptions() {
    const showOptions = (Number(this.form.door.value) > 0) && !!this.form["animation.type"].value;
    const fieldset = this.element.querySelector("fieldset.door-animation");
    fieldset.classList.toggle("hidden", !showOptions);
    this.setPosition(); // Form height changed
  }

  /* -------------------------------------------- */

  /**
   * Toggle the disabled and hidden attributes of proximity input fields.
   * @returns {void}
   */
  #toggleThresholdInputVisibility() {
    for ( const sense of ["light", "sight", "sound"] ) {
      const type = Number(this.form[sense].value);
      const input = this.form[`threshold.${sense}`];
      input.disabled = input.hidden = !WallConfig.#PROXIMITY_SENSE_TYPES.includes(type);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareSubmitData(event, form, formData, updateData) {
    const submitData = super._prepareSubmitData(event, form, formData, updateData);
    const thresholds = (submitData.threshold ??= {});
    for ( const sense of ["light", "sight", "sound"] ) {
      if ( !WallConfig.#PROXIMITY_SENSE_TYPES.includes(submitData[sense]) ) thresholds[sense] = null;
    }
    if ( submitData.door === CONST.WALL_DOOR_TYPES.NONE ) submitData.animation = null; // Purge animation data
    return submitData;
  }

  /* -------------------------------------------- */

  /** @override */
  async _processSubmitData(_event, _form, submitData, options) {
    if ( !this.document.id ) throw new Error("WallDocument creation from WallConfig is not currently supported.");
    const scene = this.document.parent;
    if ( !scene ) throw new Error("A WallDocument must have a parent Scene.");
    const updates = Array.from(this.#editTargets)
      .filter((w) => scene.walls.has(w.id))
      .map((w) => ({_id: w.id, ...submitData}));
    await scene.updateEmbeddedDocuments("Wall", updates, options);
  }
}

/**
 * @import {ApplicationV1Options} from "../api/application-v1.mjs";
 * @import {DocumentSheetV1Options} from "../api/document-sheet-v1.mjs";
 */

/**
 * The Application responsible for displaying and editing a single JournalEntryPage document.
 * @deprecated since v13
 * @param {JournalEntryPage} object         The JournalEntryPage instance which is being edited.
 * @param {ApplicationV1Options & DocumentSheetV1Options} [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: DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
      includeTOC: true
    });
  }

  /**
   * Indicates that the sheet renders with App V2 rather than V1.
   * @type {boolean}
   */
  static isV2 = false;

  /**
   * Indicates that the sheet renders with App V2 rather than V1.
   * @type {boolean}
   */
  isV2 = this.constructor.isV2;

  /* -------------------------------------------- */

  /** @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={}) {
    const comparator = foundry.documents.JournalEntry.implementation.sortCategories;
    const categories = this.object.parent.categories.contents.sort(comparator);
    const context = foundry.utils.mergeObject(super.getData(options), {
      headingLevels: Object.fromEntries(Array.fromRange(3, 1).map(level => {
        return [level, game.i18n.format("JOURNALENTRYPAGE.Level", {level})];
      }))
    });
    if ( categories.length ) context.categories = [
      { value: "", label: game.i18n.localize("JOURNAL.Uncategorized") },
      ...categories.map(({ id, name }) => ({ value: id, label: name }))
    ];
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _renderInner(...args) {
    await foundry.applications.handlebars.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$1.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.
   * @internal
   */
  _onAutosave(html) {
    this.object.parent?.sheet?.render(false);
  }

  /* -------------------------------------------- */

  /**
   * Update the UI appropriately when receiving new steps from another client.
   * @internal
   */
  _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(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 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$1.implementation.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 !== DocumentSheet.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 === JOURNAL_ENTRY_PAGE_FORMATS.HTML) && this.isEditorDirty() ) {
      // Clear any stored markdown so it can be re-converted.
      formData["text.markdown"] = "";
      formData["text.format"] = 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 = 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());
    }
  }
}

/* -------------------------------------------- */

/**
 * A subclass of {@link foundry.appv1.sheets.JournalTextPageSheet} that implements a TinyMCE editor.
 * @extends {JournalTextPageSheet}
 * @deprecated since v13 until v14
 */
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);
  }
}

/**
 * @import {ApplicationClickAction} from "../_types.mjs";
 */

/**
 * @typedef 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 {boolean} cleared        Has the section been tentatively cleared of its contents?
 * @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
 */

/**
 * An interface for packaging Adventure content and loading it to a compendium pack.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class AdventureExporter extends HandlebarsApplicationMixin(DocumentSheetV2) {

  constructor(options={}) {
    super(options);
    if ( !options.document.pack ) {
      throw new Error("You may not export an Adventure that does not belong to a Compendium pack");
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "adventure-exporter",
    classes: ["adventure-exporter"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-upload"
    },
    position: {width: 560},
    form: {
      closeOnSubmit: true
    },
    actions: {
      clearSection: AdventureExporter.#onClearSection,
      collapseSection: AdventureExporter.#onCollapseSection,
      removeContent: AdventureExporter.#onRemoveContent
    },
    canCreate: true
  };

  /** @override */
  static PARTS = {
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    summary: {
      template: "templates/adventure/exporter/summary.hbs",
      scrollable: [""]
    },
    contents: {
      template: "templates/adventure/exporter/contents.hbs",
      scrollable: [""]
    },
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  static TABS = {
    sheet: {
      tabs: [
        {id: "summary", icon: "fa-solid fa-feather-pointed"},
        {id: "contents", icon: "fa-solid fa-folder-tree"}
      ],
      initial: "summary",
      labelPrefix: "ADVENTURE.TABS"
    }
  };

  /* -------------------------------------------- */

  /**
   * 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$1.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$1.contentFields).reduce((obj, f) => {
    obj[f] = new Set();
    return obj;
  }, {});

  /**
   * Track which sections of the contents are collapsed.
   * @type {Set<string>}
   */
  #collapsedSections = new Set();

  /* -------------------------------------------- */
  /*  Application Rendering                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.contentTree = this.contentTree = this.#organizeContentTree();
    context.adventure = this.document;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "footer":
        context.buttons = [{type: "submit", icon: "fa-solid fa-upload", label: "ADVENTURE.ExportSubmit"}];
        break;
      default: {
        const tab = context.tabs[partId];
        if ( tab ) context.tab = tab;
      }
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Organize content in the adventure into a tree structure which is displayed in the UI.
   * @returns {Record<string, AdventureContentTreeRoot>}
   */
  #organizeContentTree() {
    const content = {};
    const adventure = this.document;
    let remainingFolders = [...adventure.folders, ...(this.#addedContent.folders ?? [])];

    // Prepare each content section
    for ( const [name, cls] of Object.entries(Adventure$1.contentFields) ) {
      if ( name === "folders" ) continue;

      // Partition content for the section
      const currentContent = adventure[name];
      let documents = [...currentContent, ...(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 removedContent = this.#removedContent[name];
      const section = content[name] = {
        documentName: cls.documentName,
        collection: cls.collectionName,
        id: null,
        name: game.i18n.localize(cls.metadata.labelPlural),
        icon: CONFIG[cls.documentName].sidebarIcon,
        cssClass: [cls.collectionName, collapsed ? "collapsed" : ""].filterJoin(" "),
        documentCount: documents.length - removedContent.size,
        cleared: currentContent.size > 0 && currentContent.every(d => removedContent.has(d.id)),
        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 document of documents ) {
        const state = this.#getDocumentState(document);
        section.documents.push({
          document,
          id: document.id,
          name: document.name,
          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 doc of documents ) {
      const state = this.#getDocumentState(doc);
      node.documents.push({
        document: doc,
        id: doc.id,
        name: doc.name,
        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,
        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];
  }

  /* -------------------------------------------- */

  /**
   * 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$1(documentName);
    const cn = cls.collectionName;
    const existing = this.document[cn].find(d => d.id === documentId);
    if ( existing ) return existing;
    const added = this.#addedContent[cn].find(d => d.id === documentId);
    return added ?? null;
  }

  /* -------------------------------------------- */

  /**
   * 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";
  }

  /* -------------------------------------------- */

  /** @override */
  async _processSubmitData(event, form, submitData, options={}) {
    const adventure = this.document;

    // Build the adventure data content
    for ( const [name, cls] of Object.entries(Adventure$1.contentFields) ) {
      const collection = game.collections.get(cls.documentName);
      submitData[name] = [];
      const addDoc = id => {
        if ( this.#removedContent[name].has(id) ) return;
        let data;

        // Prepare world document data
        const doc = collection.get(id);
        if ( doc ) {
          data = doc.toCompendium(adventure.collection, {
            clearSort: false,
            clearFolder: false,
            clearFlags: false,
            clearSource: false,
            clearOwnership: true,
            clearState: true,
            keepId: true
          });
        }

        // Fall-back to preexisting adventure data
        data ??= adventure[name].find(d => d.id === id);
        if ( data ) submitData[name].push(data);
      };
      for ( const d of adventure[name] ) addDoc(d.id);
      for ( const d of this.#addedContent[name] ) addDoc(d.id);
    }

    const pack = adventure.collection;
    const restrictedDocuments = submitData.actors?.length || submitData.items?.length
      || submitData.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});
    }

    // Clear all pending additions and removals in case this Application stays in memory after submitting.
    Object.values(this.#addedContent).forEach(s => s.clear());
    Object.values(this.#removedContent).forEach(s => s.clear());

    // Create/update the document or save progress to the clone
    const operation = Object.assign({
      pack: adventure.pack,
      keepId: true,
      keepEmbeddedIds: true,
      diff: false,
      recursive: false
    }, options);
    await super._processSubmitData(event, form, submitData, operation);

    const locKey = pack.has(adventure.id) ? "ADVENTURE.UpdateSuccess": "ADVENTURE.CreateSuccess";
    ui.notifications.info(locKey, {format: {name: adventure.name}});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    new foundry.applications.ux.DragDrop.implementation({
      dropSelector: ".window-content",
      permissions: {
        dragstart: () => false,
        drop: () => this.isEditable
      },
      callbacks: {
        drop: this.#onDrop.bind(this)
      }
    }).bind(this.element);
    return super._onRender(context, options);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Clear all content from a particular document-type section.
   * @this {AdventureExporter}
   * @type {ApplicationClickAction}
   */
  static async #onClearSection(event) {
    const section = event.target.closest("[data-document-name]");
    const documentName = section.dataset.documentName;
    const cls = getDocumentClass$1(documentName);
    const collectionName = cls.collectionName;
    const currentContent = this.document[collectionName];
    const removedContent = this.#removedContent[collectionName];
    if ( (currentContent.size > 0) && currentContent.every(d => removedContent.has(d.id)) ) {
      this.#restoreNode(this.contentTree[collectionName]);
    }
    else this.#removeNode(this.contentTree[collectionName], {force: true});
    this.render({parts: ["contents"]});
  }

  /* -------------------------------------------- */

  /**
   * Collapse the content section of a particular Document type.
   * @this {AdventureExporter}
   * @type {ApplicationClickAction}
   */
  static async #onCollapseSection(event) {
    const section = event.target.closest("[data-document-name]");
    const documentName = section.dataset.documentName;
    if ( this.#collapsedSections.has(documentName) ) {
      this.#collapsedSections.delete(documentName);
      section.classList.remove("collapsed");
    }
    else {
      this.#collapsedSections.add(documentName);
      section.classList.add("collapsed");
    }
  }

  /* -------------------------------------------- */

  /**
   * Remove a single piece of content.
   * @this {AdventureExporter}
   * @type {ApplicationClickAction}
   */
  static async #onRemoveContent(event) {
    const node = event.target.closest("[data-document-id]");
    const isFolder = "folder" in node.dataset;
    const documentName = isFolder ? "Folder" : event.target.closest("[data-document-name]").dataset.documentName;
    const document = this.#getDocument(documentName, node.dataset.documentId);
    if ( document ) this.removeContent(document);
  }

  /* -------------------------------------------- */

  /**
   * Handle drop of a new content Document.
   * @param {DragEvent} event
   */
  async #onDrop(event) {
    const data = TextEditor$1.implementation.getDragEventData(event);
    const cls = getDocumentClass$1(data?.type);
    if ( !cls || !(cls.collectionName in Adventure$1.contentFields) ) return;
    const document = await cls.fromDropData(data);
    const isPackFolder = (data.type === "Folder") && (document.type === "Compendium");
    if ( document.pack || document.isEmbedded || isPackFolder ) {
      return ui.notifications.error("ADVENTURE.ExportPrimaryDocumentsOnly", {localize: true});
    }
    const pack = this.document.collection;
    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);
  }

  /* -------------------------------------------- */
  /*  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);
    this.render({parts: ["contents"]});
  }

  /* -------------------------------------------- */

  /**
   * Remove or restore 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) ) this.#restoreNode(node);
      else this.#removeNode(node, {force: true});
    }
    else this.#removeDocument(document);
    this.render({parts: ["contents"]});
  }

  /* -------------------------------------------- */

  /**
   * Remove or restore a node of Documents from the content tree.
   * @param {AdventureContentTreeNode} node The node to remove
   * @param {object} [options]
   * @param {boolean} [options.force] If true, only ever remove Documents and never restore.
   */
  #removeNode(node, {force=false}={}) {
    for ( const child of node.children ) this.#removeNode(child, {force});
    for ( const d of node.documents ) this.#removeDocument(d.document, {force});
    if ( node.folder ) this.#removeDocument(node.folder, {force});
  }

  /* -------------------------------------------- */

  /**
   * 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, or restore it if already pending removal.
   * @param {ClientDocument} document The Document to remove
   * @param {object} [options]
   * @param {boolean} [options.force] If true, only remove the Document.
   */
  #removeDocument(document, {force=false}={}) {
    const cn = document.collectionName;

    // If the Document was already removed, re-add it
    if ( !force && this.#removedContent[cn].delete(document.id) ) return;

    // If the content was temporarily added, remove it
    if ( this.#addedContent[cn].delete(document) ) return;

    // Otherwise, mark the content as removed
    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
   */
  #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
   */
  #addDocument(document) {
    const cn = document.collectionName;

    // If the document was previously removed, restore it
    if ( this.#removedContent[cn].delete(document.id) ) return;

    // Otherwise, add documents that don't yet exist
    if ( !this.document[cn].some(d => d.id === document.id) ) {
      this.#addedContent[cn].add(document);
    }
  }
}

/**
 * The Application responsible for displaying a basic sheet for any Document sub-types that do not have a sheet
 * registered.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class BaseSheet extends HandlebarsApplicationMixin(DocumentSheetV2) {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["base-sheet"],
    position: {width: 480},
    window: {
      contentClasses: ["standard-form"],
      resizable: true
    },
    form: {
      submitOnChange: true
    }
  };

  static PARTS = {
    sheet: {
      template: "templates/sheets/base-sheet.hbs",
      root: true
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const {document, fields} = context;
    const hasDescription = ("description" in fields) && (fields.description instanceof StringField);
    if ( hasDescription ) {
      context.descriptionHTML = await TextEditor$1.implementation.enrichHTML(document.description, {
        secrets: document.isOwner,
        relativeTo: document
      });
    }
    context.hasNothing = !hasDescription && !("img" in fields) && !("name" in fields);
    return context;
  }
}

/**
 * Initialize default sheet configurations for all Document types.
 * @internal
 */
function _registerDefaultSheets() {
  const defaultSheets = {
    // Documents
    Folder: FolderConfig,
    JournalEntry: JournalEntrySheet,
    Macro: MacroConfig,
    Playlist: PlaylistConfig,
    RollTable: RollTableSheet,
    Scene: SceneConfig,
    User: UserConfig,
    // Embedded Documents
    ActiveEffect: ActiveEffectConfig,
    AmbientLight: AmbientLightConfig,
    AmbientSound: AmbientSoundConfig,
    Card: CardConfig,
    Combatant: CombatantConfig,
    Drawing: DrawingConfig,
    MeasuredTemplate: MeasuredTemplateConfig,
    Note: NoteConfig,
    PlaylistSound: PlaylistSoundConfig,
    Region: RegionConfig,
    RegionBehavior: RegionBehaviorConfig,
    TableResult: TableResultConfig,
    Tile: TileConfig,
    Token: TokenConfig,
    Wall: WallConfig
  };

  for ( const documentName of CONST.ALL_DOCUMENT_TYPES ) {
    const cfg = CONFIG[documentName];
    cfg.sheetClasses = {};
    const defaultSheet = defaultSheets[documentName];
    if ( !defaultSheet ) continue;
    DocumentSheetConfig.registerSheet(cfg.documentClass, "core", defaultSheet, {
      makeDefault: true,
      label: () => game.i18n.format("SHEETS.DefaultDocumentSheet", {
        document: game.i18n.localize(`DOCUMENT.${documentName}`)
      })
    });
  }
  DocumentSheetConfig.registerSheet(Adventure$1, "core", foundry.appv1.sheets.AdventureImporter, {
    label: "ADVENTURE.Importer.V1",
    makeDefault: true
  });
  DocumentSheetConfig.registerSheet(Adventure$1, "core", AdventureImporterV2, {
    label: "ADVENTURE.Importer.V2",
    canBeDefault: false
  });
  DocumentSheetConfig.registerSheet(Cards$1, "core", CardDeckConfig, {
    label: "CARDS.CardsDeck",
    types: ["deck"],
    makeDefault: true
  });
  DocumentSheetConfig.registerSheet(Cards$1, "core", CardHandConfig, {
    label: "CARDS.CardsHand",
    types: ["hand"],
    makeDefault: true
  });
  DocumentSheetConfig.registerSheet(Cards$1, "core", CardPileConfig, {
    label: "CARDS.CardsPile",
    types: ["pile"],
    makeDefault: true
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage$1, "core", JournalTextTinyMCESheet, {
    types: ["text"],
    label: () => game.i18n.localize("EDITOR.TinyMCE")
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage$1, "core", JournalEntryPageImageSheet, {
    types: ["image"],
    makeDefault: true,
    label: () => {
      return game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {
        page: game.i18n.localize("JOURNALENTRYPAGE.TypeImage")
      });
    }
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage$1, "core", JournalEntryPageVideoSheet, {
    types: ["video"],
    makeDefault: true,
    label: () => {
      return game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {
        page: game.i18n.localize("JOURNALENTRYPAGE.TypeVideo")
      });
    }
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage$1, "core", JournalEntryPagePDFSheet, {
    types: ["pdf"],
    makeDefault: true,
    label: () => {
      return game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {
        page: game.i18n.localize("JOURNALENTRYPAGE.TypePDF")
      });
    }
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage$1, "core", JournalEntryPageProseMirrorSheet, {
    types: ["text"],
    makeDefault: true,
    label: () => {
      return game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {
        page: game.i18n.localize("JOURNALENTRYPAGE.TypeText")
      });
    }
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage$1, "core", JournalEntryPageMarkdownSheet, {
    types: ["text"],
    label: () => game.i18n.localize("EDITOR.Markdown")
  });
}

var _module$F = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActiveEffectConfig: ActiveEffectConfig,
  ActorSheet: ActorSheetV2,
  ActorSheetV2: ActorSheetV2,
  AdventureExporter: AdventureExporter,
  AdventureImporter: AdventureImporterV2,
  AdventureImporterV2: AdventureImporterV2,
  AmbientLightConfig: AmbientLightConfig,
  AmbientSoundConfig: AmbientSoundConfig,
  BaseSheet: BaseSheet,
  CardConfig: CardConfig,
  CardDeckConfig: CardDeckConfig,
  CardHandConfig: CardHandConfig,
  CardPileConfig: CardPileConfig,
  CardsConfig: CardsConfig,
  CombatantConfig: CombatantConfig,
  DrawingConfig: DrawingConfig,
  FolderConfig: FolderConfig,
  ItemSheet: ItemSheetV2,
  ItemSheetV2: ItemSheetV2,
  MacroConfig: MacroConfig,
  MeasuredTemplateConfig: MeasuredTemplateConfig,
  NoteConfig: NoteConfig,
  PlaylistConfig: PlaylistConfig,
  PlaylistSoundConfig: PlaylistSoundConfig,
  PrototypeTokenConfig: PrototypeTokenConfig,
  RegionBehaviorConfig: RegionBehaviorConfig,
  RegionConfig: RegionConfig,
  RollTableSheet: RollTableSheet,
  SceneConfig: SceneConfig,
  TableResultConfig: TableResultConfig,
  TileConfig: TileConfig,
  TokenConfig: TokenConfig,
  UserConfig: UserConfig,
  WallConfig: WallConfig,
  _registerDefaultSheets: _registerDefaultSheets,
  journal: _module$G
});

/**
 * @import {ApplicationConfiguration, ApplicationRenderOptions} from "../_types.mjs"
 */

/**
 * The sidebar tab interface that allows any sidebar tab to also be rendered as a popout.
 * @template {ApplicationConfiguration} [Configuration=ApplicationConfiguration]
 * @template {ApplicationRenderOptions} [RenderOptions=ApplicationRenderOptions]
 * @extends {ApplicationV2<Configuration, RenderOptions>}
 */
class AbstractSidebarTab extends ApplicationV2 {

  /**
   * The base name of the sidebar tab.
   * @type {string}
   * @abstract
   */
  static tabName;

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    tag: "section",
    classes: ["tab", "sidebar-tab"],
    window: {
      frame: false,
      positioned: false
    }
  };

  /** @override */
  static emittedEvents = Object.freeze(["render", "close", "position", "activate", "deactivate"]);

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Whether this tab is currently active in the sidebar.
   * @type {boolean}
   */
  get active() {
    return ui.sidebar?.tabGroups.primary === this.tabName;
  }

  /**
   * Whether this is the popped-out tab or the in-sidebar one.
   * @type {boolean}
   */
  get isPopout() {
    return this.options.window.frame;
  }

  /**
   * A reference to the popped-out version of this tab, if one exists.
   * @type {AbstractSidebarTab|void}
   */
  get popout() {
    return this.#popout?.deref();
  }

  /**
   * A weak reference to the popped-out version of this tab, allowing it to be garbage-collected when closed.
   * @type {WeakRef<AbstractSidebarTab>}
   */
  #popout;

  /**
   * The base name of the sidebar tab.
   * @type {string}
   */
  get tabName() {
    return this.constructor.tabName;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options.id ??= this.tabName;
    const applicationOptions = super._initializeApplicationOptions(options);
    if ( !applicationOptions.window.frame ) {
      applicationOptions.classes.push(`${this.tabName}-sidebar`);
      if ( this.active ) applicationOptions.classes.push("active");
    }
    return applicationOptions;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.user = game.user;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderFrame(options) {
    const frame = await super._renderFrame(options);
    if ( !this.options.window?.frame ) Object.assign(frame.dataset, { tab: this.tabName, group: "primary" });
    return frame;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async render(options, _options) {
    if ( this.popout?.rendered ) this.popout.render(options, _options);
    return super.render(options, _options);
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Activate this tab in the sidebar.
   */
  activate() {
    if ( this.isPopout ) this.bringToFront();
    else {
      ui.sidebar.changeTab(this.tabName, "primary");
      if ( !ui.sidebar.expanded ) ui.sidebar.expand();
    }
  }

  /* -------------------------------------------- */

  /**
   * Pop-out this sidebar tab as a new application.
   * @returns {Promise<AbstractSidebarTab>}
   */
  renderPopout() {
    if ( this.popout ) return this.popout.render({ force: true });
    const options = foundry.utils.mergeObject(this.options, {
      id: `${this.tabName}-popout`,
      window: {
        frame: true,
        positioned: true,
        minimizable: true
      }
    }, { inplace: false });
    options.classes.push("sidebar-popout");
    this.#popout = new WeakRef(new this.constructor(options));
    return this.popout.render({ force: true });
  }

  /* -------------------------------------------- */
  /*  Events                                      */
  /* -------------------------------------------- */

  /**
   * Actions performed when this tab is activated in the sidebar.
   * @protected
   */
  _onActivate() {}

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    if ( this.isPopout ) delete ui.sidebar.popouts[this.tabName];
  }

  /* -------------------------------------------- */

  /**
   * Actions performed when this tab is deactivated in the sidebar.
   * @protected
   */
  _onDeactivate() {}

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    if ( this.isPopout ) ui.sidebar.popouts[this.tabName] = this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    if ( !this.isPopout || this.options.classes.includes("themed") ) return;
    this.element.classList.remove("theme-light", "theme-dark");
    const { colorScheme } = game.settings.get("core", "uiConfig");
    if ( colorScheme.interface ) this.element.classList.add("themed", `theme-${colorScheme.interface}`);
  }
}

/**
 * @import {ApplicationRenderContext, ApplicationRenderOptions, ApplicationClickAction} from "../../_types.mjs"
 */

/**
 * The sidebar chat tab.
 * @extends {AbstractSidebarTab}
 * @mixes HandlebarsApplication
 */
class ChatLog extends HandlebarsApplicationMixin(AbstractSidebarTab) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["flexcol"],
    window: {
      title: "CHAT.Title"
    },
    actions: {
      deleteMessage: this.#onDeleteMessage,
      dismissMessage: this.#onDismissNotification,
      expandRoll: this.#onExpandRoll,
      export: this.#onExportLog,
      flush: this.#onFlushLog,
      jumpToBottom: this.#onJumpToBottom,
      rollMode: this.#onChangeRollMode
    }
  };

  /** @override */
  static tabName = "chat";

  /** @override */
  static PARTS = {
    log: {
      template: "templates/sidebar/tabs/chat/log.hbs",
      templates: ["templates/sidebar/tabs/chat/notifications.hbs"]
    },
    input: {
      template: "templates/sidebar/tabs/chat/input.hbs"
    }
  };

  /**
   * 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"]);

  /**
   * The maximum number of messages to retain in the history in a given session.
   * @type {number}
   */
  static MAX_MESSAGE_HISTORY = 16;

  /**
   * The number of milliseconds to keep a chat card notification until it is automatically dismissed.
   * @type {number}
   */
  static NOTIFY_DURATION = 5000;

  /**
   * The notification ticker frequency.
   * @type {number}
   */
  static NOTIFY_TICKER = 500;

  /**
   * The number of milliseconds to wait before unpausing the notification queue.
   * @type {number}
   */
  static NOTIFY_UNPAUSE = 2000;

  /**
   * The number of milliseconds to display the chat notification pip.
   * @type {number}
   */
  static PIP_DURATION = 3000;

  /**
   * How often, in milliseconds, to update timestamps.
   * @type {number}
   */
  static UPDATE_TIMESTAMP_FREQUENCY = 1000 * 15;

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A reference to the Messages collection that the chat log displays.
   * @type {Messages}
   */
  get collection() {
    return game.messages;
  }

  /**
   * Message history management.
   * @type {{ queue: string[], index: number, pending: string }}
   */
  get history() {
    return this.#history;
  }

  #history = {
    index: -1,
    pending: "",
    queue: []
  };

  /**
   * The chat input element.
   * @type {HTMLTextAreaElement}
   */
  #inputElement;

  /**
   * A flag for whether the chat log is currently scrolled to the bottom.
   * @type {boolean}
   */
  get isAtBottom() {
    return this.#isAtBottom;
  }

  #isAtBottom = true;

  /**
   * The jump to bottom button.
   * @type {HTMLButtonElement}
   */
  #jumpToBottomElement;

  /**
   * Track the ID of the oldest message displayed in the log.
   * @type {string|null}
   */
  #lastId;

  /**
   * Store the last whisper this user received for replying.
   * @type {ChatMessage}
   */
  #lastWhisper;

  /**
   * The chat notifications container.
   * @type {HTMLDivElement}
   */
  #notificationsElement;

  /**
   * The debounced function for returning the chat notification queue to an inactive state.
   * @type {function(): void}
   */
  #notifyAlertDebounce =
    foundry.utils.debounce(this.#onNotifyInactive.bind(this), this.constructor.NOTIFY_DURATION);

  /**
   * The debounced function for unpausing the notification queue.
   * @type {function(): void}
   */
  #notifyUnpauseDebounce =
    foundry.utils.debounce(this.#onUnpauseNotifications.bind(this), this.constructor.NOTIFY_UNPAUSE);

  /**
   * Debounced function for setting the overflowing state of the chat scrollback.
   * @type {function(): void}
   */
  #overflowingDebounce = foundry.utils.debounce(this.#setOverflowing.bind(this), 100);

  /**
   * The debounced function for hiding the notification pip.
   * @type {function(): void}
   */
  #pipHideDebounce = foundry.utils.debounce(this.#onHidePip.bind(this), this.constructor.PIP_DURATION);

  /**
   * Chat controls containing roll-privacy buttons and log actions
   * @type {HTMLDivElement}
   */
  #chatControls;

  /**
   * A semaphore to queue rendering of Chat Messages.
   * @type {Semaphore}
   */
  #renderingQueue = new foundry.utils.Semaphore(1);

  /**
   * Whether batch rendering is currently in-progress.
   * @type {boolean}
   */
  #renderingBatch = false;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    // If the log has already been rendered once, prevent it from being re-rendered.
    if ( this.rendered ) options.parts = options.parts.filter(p => (p !== "log") && (p !== "input"));
  }

  /* -------------------------------------------- */

  /**
   * Render a batch of additional messages, prepending them to the top of the log.
   * @param {number} size  The batch size.
   * @returns {Promise<void>}
   */
  async #doRenderBatch(size) {
    if ( !this.rendered ) {
      this.#renderingBatch = false;
      return;
    }

    const messages = game.messages.contents;
    const log = this.element.querySelector(".chat-log");

    // Get the index of the last rendered chat message
    let lastIdx = messages.findIndex(m => m.id === this.#lastId);
    lastIdx = lastIdx > -1 ? lastIdx : messages.length;
    if ( !lastIdx ) {
      this.#renderingBatch = false;
      return;
    }

    // Get the next batch to render
    const targetIdx = Math.max(lastIdx - size, 0);
    const elements = [];
    for ( let i = targetIdx; i < lastIdx; i++ ) {
      const message = messages[i];
      if ( !message.visible ) continue;
      message.logged = true;
      try {
        elements.push(await this.constructor.renderMessage(message));
      } catch(err) {
        Hooks$1.onError("ChatLog##doRenderBatch", err, {
          msg: `Chat message ${message.id} failed to render`,
          log: "error"
        });
      }
    }

    // Prepend the HTML
    log.prepend(...elements);
    this.#lastId = messages[targetIdx].id;
    this.#renderingBatch = false;
    if ( !this.isPopout ) this.#overflowingDebounce();
  }

  /* -------------------------------------------- */

  /**
   * Get context menu entries for chat messages in the log.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getEntryContextOptions() {
    return [{
      name: "CHAT.PopoutMessage",
      icon: '<i class="fa-solid fa-up-right-from-square fa-rotate-180"></i>',
      condition: li => {
        const message = game.messages.get(li.dataset.messageId);
        return message.getFlag("core", "canPopout") === true;
      },
      callback: li => {
        const message = game.messages.get(li.dataset.messageId);
        new CONFIG.ChatMessage.popoutClass({ message }).render({ force: true });
      }
    }, {
      name: "CHAT.RevealMessage",
      icon: '<i class="fa-solid fa-eye"></i>',
      condition: li => {
        const message = game.messages.get(li.dataset.messageId);
        const isLimited = message.whisper.length || message.blind;
        return isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
      },
      callback: li => {
        const message = game.messages.get(li.dataset.messageId);
        return message.update({whisper: [], blind: false});
      }
    }, {
      name: "CHAT.ConcealMessage",
      icon: '<i class="fa-solid fa-eye-slash"></i>',
      condition: li => {
        const message = game.messages.get(li.dataset.messageId);
        const isLimited = message.whisper.length || message.blind;
        return !isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
      },
      callback: li => {
        const message = game.messages.get(li.dataset.messageId);
        return message.update({whisper: ChatMessage$1.getWhisperRecipients("gm").map(u => u.id), blind: false});
      }
    }, {
      name: "SIDEBAR.Delete",
      icon: '<i class="fa-solid fa-trash"></i>',
      condition: li => {
        const message = game.messages.get(li.dataset.messageId);
        return message.canUserModify(game.user, "delete");
      },
      callback: li => {
        const message = game.messages.get(li.dataset.messageId);
        return message ? message.delete() : this.deleteMessage(li.dataset.messageId);
      }
    }];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    if ( this.isPopout ) ui.chat._toggleNotifications();
    else {
      setInterval(this.updateTimestamps.bind(this), this.constructor.UPDATE_TIMESTAMP_FREQUENCY);
      await this.#renderNotifications();
    }
    await this.renderBatch(CONFIG.ChatMessage.batchSize);
    /** @fires {hookEvents:getChatMessageContextOptions} */
    this._createContextMenu(this._getEntryContextOptions, ".message[data-message-id]", {
      hookName: "getChatMessageContextOptions",
      parentClassHooks: false
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this._toggleNotifications();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _postRender(context, options) {
    await super._postRender(context, options);
    if ( options.isFirstRender ) await this.scrollBottom({ waitImages: true });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "input": await this._prepareInputContext(context, options); break;
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the chat panel's message input component.
   * @param {ApplicationRenderContext} context
   * @param {ApplicationRenderOptions} options
   * @protected
   */
  async _prepareInputContext(context, options) {
    context.isAtBottom = this.isAtBottom;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderHTML(context, options) {
    const parts = await super._renderHTML(context, options);
    if ( parts.input ) this.#jumpToBottomElement = parts.input.querySelector(".jump-to-bottom");
    return parts;
  }


  /* -------------------------------------------- */

  /**
   * Render chat notifications framework.
   * @returns {Promise<void>}
   */
  async #renderNotifications() {
    const right = document.getElementById("ui-right-column-1") ?? document.body;
    const rollMode = game.settings.get("core", "rollMode");
    const rollModes = Object.entries(CONFIG.Dice.rollModes).map(([action, { label, icon }]) => {
      return {
        icon, label, action,
        active: action === rollMode
      };
    });

    const html = await renderTemplate("templates/sidebar/tabs/chat/notifications.hbs", {user: game.user, rollModes});
    [this.#notificationsElement, this.#inputElement, this.#chatControls] = foundry.utils.parseHTML(html);
    this.#notificationsElement.addEventListener("click", this._onClickNotification.bind(this));
    this.#inputElement.addEventListener("keydown", this._onKeyDown.bind(this));
    this.#inputElement.addEventListener("drop", this.#onDropTextAreaData.bind(this));

    right.append(this.#notificationsElement);
    this.#notificationsElement.append(this.#inputElement, this.#chatControls);
    this._toggleNotifications();
    window.addEventListener("blur", this.#onBlurWindow.bind(this));
    setInterval(this.#checkNotificationLifeSpan.bind(this), this.constructor.NOTIFY_TICKER);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preSyncPartState(partId, newElement, priorElement, state) {
    super._preSyncPartState(partId, newElement, priorElement, state);
    switch ( partId ) {
      case "input": this._preSyncInputState(newElement, priorElement, state); break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare data used to synchronize the state of the chat input.
   * @param {HTMLElement} newElement    The newly-rendered element.
   * @param {HTMLElement} priorElement  The existing element.
   * @param {object} state              A state object which is used to synchronize after replacement.
   * @protected
   */
  _preSyncInputState(newElement, priorElement, state) {
    const textarea = priorElement.querySelector(".chat-input");
    state.message = textarea?.value;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _syncPartState(partId, newElement, priorElement, state) {
    super._syncPartState(partId, newElement, priorElement, state);
    switch ( partId ) {
      case "input": this._syncInputState(newElement, priorElement, state); break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Synchronize the state of the chat input.
   * @param {HTMLElement} newElement    The newly-rendered element.
   * @param {HTMLElement} priorElement  The element being replaced.
   * @param {object} state              The state object used to synchronize the pre- and post-render states.
   * @protected
   */
  _syncInputState(newElement, priorElement, state) {
    const textarea = newElement.querySelector(".chat-input");
    if ( textarea ) textarea.value = state.message;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachPartListeners(partId, element, options) {
    super._attachPartListeners(partId, element, options);
    switch ( partId ) {
      case "log": this._attachLogListeners(element, options); break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Attach listeners to the chat log.
   * @param {HTMLElement} element  The log element.
   * @param {ApplicationRenderOptions} options
   * @protected
   */
  _attachLogListeners(element, options) {
    element.addEventListener("scroll", this.#onScrollLog.bind(this), { passive: true });
    element.addEventListener("change", this.#onChangeLog.bind(this));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onActivate() {
    super._onActivate();
    this._toggleNotifications();
  }

  /* -------------------------------------------- */

  /**
   * Handle unpausing the notification queue when the window loses focus.
   */
  #onBlurWindow() {
    this.#notificationsElement.querySelectorAll(".message.hovered").forEach(el => el.classList.remove("hovered"));
    this.#notifyUnpauseDebounce();
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the roll mode.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onChangeRollMode(event) {
    const mode = event.target.dataset.rollMode;
    game.settings.set("core", "rollMode", mode);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking a chat card notification.
   * Treat action button clicks within the Notifications UI as action clicks on the ChatLog instance itself.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onClickNotification(event) {
    const target = event.target.closest("[data-action]");
    if ( !target ) return;
    const { action } = target.dataset;
    let handler = this.options.actions[action];
    let buttons = [0];
    if ( typeof handler === "object" ) {
      buttons = handler.buttons;
      handler = handler.handler;
    }
    if ( buttons.includes(event.button) ) handler?.call(this, event, target);
    else this._onClickAction(event, target);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    this.#lastId = null;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDeactivate() {
    super._onDeactivate();
    this._toggleNotifications();
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting a single message from the log.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static async #onDeleteMessage(event) {
    event.preventDefault();
    const { messageId } = event.target.closest("[data-message-id]")?.dataset ?? {};
    const message = game.messages.get(messageId);
    if ( message ) await message.delete();
    else await this.deleteMessage(messageId);
  }

  /* -------------------------------------------- */

  /**
   * Handle dismissing a chat card notification.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static async #onDismissNotification(_event, target) {
    const message = target.closest(".chat-message");
    await this.#dismissNotification(message);
  }

  /* -------------------------------------------- */

  /**
   * Handle the dropping of transferred data onto the chat input.
   * @param {DragEvent} event  The originating drop event.
   */
  async #onDropTextAreaData(event) {
    event.preventDefault();
    const textarea = event.currentTarget;

    // Drop cross-linked content
    const eventData = TextEditor$1.implementation.getDragEventData(event);
    const link = await TextEditor$1.implementation.getContentLink(eventData);
    if ( link ) textarea.value += link;

    // Record pending text
    this.#history.pending = textarea.value;
    textarea.focus();
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the expanded state of a roll breakdown.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onExpandRoll(event, target) {
    event.preventDefault();
    target.classList.toggle("expanded");
  }

  /* -------------------------------------------- */

  /**
   * Handle exporting the chat log.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onExportLog() {
    game.messages.export();
  }

  /* -------------------------------------------- */

  /**
   * Handle flushing the chat log.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onFlushLog() {
    game.messages.flush();
  }

  /* -------------------------------------------- */

  /**
   * Handle hiding the chat tab notification pip.
   */
  #onHidePip() {
    document.querySelector('#sidebar .tabs [data-tab="chat"] + .notification-pip')?.classList.remove("active");
  }

  /* -------------------------------------------- */

  /**
   * Handle pausing the chat notification log when interacting with a chat notification card.
   * @param {PointerEvent} event  The triggering event.
   */
  #onHoverNotification(event) {
    event.currentTarget.classList.toggle("hovered", event.type === "pointerenter");
    if ( event.type === "pointerleave" ) this.#notifyUnpauseDebounce();
  }

  /* -------------------------------------------- */

  /**
   * Handle jumping to the bottom of the chat log.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onJumpToBottom() {
    this.scrollBottom();
  }

  /* -------------------------------------------- */

  /**
   * Handle keydown events in the chat message entry textarea.
   * @param {KeyboardEvent} event  The triggering event.
   * @protected
   */
  _onKeyDown(event) {
    if ( event.isComposing ) return; // Ignore IME composition.

    const inputOptions = { recordPending: true };
    if ( Hooks$1.call("chatInput", event, inputOptions) === false ) {
      if ( inputOptions.recordPending ) this.#recordPendingHistory(event);
      return;
    }

    switch ( event.key ) {
      case "ArrowUp": case "ArrowDown":
        this.#recallMessage(event);
        return;

      case "Enter":
        this.#sendMessage(event);
        return;
    }

    this.#recordPendingHistory(event);
  }

  /* -------------------------------------------- */

  /**
   * Return the chat notification queue to the inactive state.
   */
  #onNotifyInactive() {
    this.#notificationsElement.classList.remove("active");
  }

  /* -------------------------------------------- */

  /**
   * Handle scroll events within the chat log.
   * @param {UIEvent} [event]  A triggering scroll event.
   */
  #onScrollLog(event) {
    if ( !this.rendered ) return;
    const log = event?.currentTarget ?? this.element.querySelector(".chat-scroll");
    const pct = log.scrollTop / (log.scrollHeight - log.clientHeight);
    this.#isAtBottom = (pct > 0.99) || Number.isNaN(pct);
    this.#jumpToBottomElement.toggleAttribute("hidden", this.#isAtBottom);
    log.classList.toggle("scrolled", !this.#isAtBottom);
    const top = log.querySelector("li.message");
    if ( pct < 0.01 ) return this.renderBatch(CONFIG.ChatMessage.batchSize).then(() => {
      // Retain the scroll position at the top-most element before the extra messages were prepended to the log.
      if ( top ) log.scrollTop = top.offsetTop;
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to an input element within the log.
   * @param {Event} event
   */
  #onChangeLog(event) {
    if ( !(event.target instanceof HTMLSecretBlockElement) ) return;
    const messageId = event.target.closest("[data-message-id]")?.dataset.messageId;
    if ( !messageId ) return;
    const message = game.messages.get(messageId);
    if ( !message ) return;
    const modified = event.target.toggleRevealed(message.content);
    message.update({ content: modified });
  }

  /* -------------------------------------------- */

  /**
   * Handle unpausing the notification queue and scrolling back down to the bottom.
   */
  #onUnpauseNotifications() {
    if ( this.#notificationsElement.querySelector(".message.hovered") ) return;

    // Now the queue is unpaused, clear any messages that have already expired.
    this.#checkNotificationLifeSpan();

    // Scroll to the bottom.
    this.#notificationsElement.querySelector(":scope > .overflow").scrollTop = 0;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preClose(options) {
    if ( this.isPopout ) this._toggleNotifications({ closing: true });
  }

  /* -------------------------------------------- */

  /**
   * Update internal record of the pending chat message.
   * @param {KeyboardEvent} event  The triggering event.
   */
  #recordPendingHistory(event) {
    if ( (event.key === "Backspace") || (event.key === "Delete") ) {
      // Remove pending text, after the keypress default behavior has executed.
      requestAnimationFrame(() => this.#history.pending = event.target.value);
    }
    else if ( event.key.length === 1 ) this.#history.pending = event.target.value + event.key;
  }

  /* -------------------------------------------- */
  /*  Message Input                               */
  /* -------------------------------------------- */

  /**
   * Parse a chat string to identify the chat command (if any) which was used.
   * @param {string} message                                            The message to parse.
   * @returns {[string, string[]|RegExpMatchArray|RegExpMatchArray[]]}  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]];
  }

  /* -------------------------------------------- */

  /**
   * Prepare the data object of chat message data depending on the type of message being posted.
   * @param {string} message                      The original string of the message content
   * @param {object} [options]                    Additional options
   * @param {ChatSpeakerData} [options.speaker]   The speaker data
   * @returns {Promise<ChatMessage|void>}         The created ChatMessage Document, or void if we were executing a
   *                                              macro instead.
   * @throws {Error}                              If an invalid command is found.
   */
  async processMessage(message, {speaker}={}) {
    message = message.trim();
    if ( !message ) return;
    const cls = ChatMessage$1.implementation;
    speaker ??= cls.getSpeaker();

    // Set up basic chat data
    const chatData = {speaker, user: game.user.id};

    if ( Hooks$1.call("chatMessage", this, message, chatData) === false ) return;

    // Parse the message to determine the matching handler
    const parsed = this.constructor.parse(message);
    let command = parsed[0];
    const match = parsed[1];

    // 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);
        break;
      case "ic": case "emote": case "ooc":
        this.#processChatCommand(command, match, chatData, createOptions);
        break;
      case "macro":
        this.#processMacroCommand(match);
        return;
    }

    // Create the message using provided data and options
    return cls.create(chatData, createOptions);
  }

  /* -------------------------------------------- */

  /**
   * Process non-whispered messages.
   * @param {string} command          The chat command type
   * @param {RegExpMatchArray} match  The matched RegExp expressions
   * @param {Object} chatData         The initial chat data
   * @param {Object} createOptions    Options used to create the message
   * @throws {Error}                  If attempting to emote or chat in-character without a speaker.
   */
  #processChatCommand(command, match, chatData, createOptions) {
    if ( ["ic", "emote"].includes(command) && !(chatData.speaker.actor || chatData.speaker.token) ) {
      throw new Error("You cannot chat in-character without an identified speaker");
    }
    chatData.content = match[2].replace(/\n/g, "<br>");

    // Augment chat data
    if ( command === "ic" ) {
      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 dice roll commands.
   * @param {string} command              The chat command type
   * @param {RegExpMatchArray[]} matches  Multi-line matched roll expressions
   * @param {Object} chatData             The initial chat data
   * @param {Object} createOptions        Options used to create the message
   */
  async #processDiceCommand(command, matches, chatData, createOptions) {
    const actor = ChatMessage$1.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 execute a macro.
   * @param {RegExpMatchArray} match  The RegExp matches.
   * @throws {Error}                  If the macro could not be found.
   */
  #processMacroCommand(match) {

    // Parse the macro command with the form /macro {macroName} [param1=val1] [param2=val2] ...
    let [macroName, ...params] = match[2].split(" ");
    let expandName = true;
    const scope = {};
    let k = undefined;
    for ( const p of params ) {
      const kv = p.split("=");
      if ( kv.length === 2 ) {
        k = kv[0];
        scope[k] = kv[1];
        expandName = false;
      }
      else if ( expandName ) macroName += ` ${p}`; // Macro names may contain spaces
      else if ( k ) scope[k] += ` ${p}`;  // Expand prior argument value
    }
    macroName = macroName.trimEnd(); // Eliminate trailing spaces

    // Get the target macro by number or by name
    let macro;
    if ( Number.isNumeric(macroName) ) {
      const macroID = game.user.hotbar[macroName];
      macro = game.macros.get(macroID);
    }
    if ( !macro ) macro = game.macros.getName(macroName);
    if ( !macro ) throw new Error(`Requested Macro "${macroName}" was not found as a named macro or hotbar position`);

    // Execute the Macro with provided scope
    macro.execute(scope);
  }

  /* -------------------------------------------- */

  /**
   * Process whispered messages.
   * @param {string} command          The chat command type
   * @param {RegExpMatchArray} match  The matched RegExp expressions
   * @param {Object} chatData         The initial chat data
   * @throws {Error}                  If this user does not have permission to whisper, or there are no valid whisper
   *                                  recipients.
   */
  #processWhisperCommand(command, match, chatData) {
    delete chatData.speaker;

    // Determine the recipient users
    let users = [];
    let message = "";
    switch ( command ) {
      case "whisper": {
        message = match[3];
        const names = match[2].replace(/[[\]]/g, "").split(",").map(n => n.trim());
        users = names.reduce((arr, n) => arr.concat(ChatMessage$1.getWhisperRecipients(n)), []);
        break;
      }
      case "reply": {
        message = match[2];
        const w = this.#lastWhisper;
        if ( w ) {
          const group = new Set(w.whisper);
          group.delete(game.user.id);
          group.add(w.author.id);
          users = Array.from(group).map(id => game.users.get(id)).filter(_ => _);
        }
        break;
      }
      case "gm":
        message = match[2];
        users = ChatMessage$1.getWhisperRecipients("gm");
        break;
      case "players":
        message = match[2];
        users = ChatMessage$1.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;
  }

  /* -------------------------------------------- */

  /**
   * Handle recalling message history.
   * @param {KeyboardEvent} event  The triggering event.
   */
  #recallMessage(event) {
    let {index } = this.#history;
    const {pending, queue} = this.#history;
    if ( pending || !queue.length ) return;
    const direction = event.key === "ArrowUp" ? 1 : -1;
    index = Math.clamp(index + direction, -1, queue.length - 1);
    this.#history.index = index;
    event.target.value = queue[index] || "";
  }

  /* -------------------------------------------- */

  /**
   * Update message history.
   * @param {string} message  The sent message.
   */
  #rememberMessage(message) {
    const { queue } = this.#history;
    if ( queue.length >= this.constructor.MAX_MESSAGE_HISTORY ) queue.pop();
    queue.unshift(message);
    this.#history.index = -1;
  }

  /* -------------------------------------------- */

  /**
   * Handle sending a chat message.
   * @param {KeyboardEvent} event  The triggering event.
   */
  async #sendMessage(event) {
    if ( event.shiftKey ) return;
    event.preventDefault();
    const message = event.target.value;
    if ( !message ) return;
    event.stopPropagation();

    try {
      await this.processMessage(message);
      this.#history.pending = event.target.value = "";
      this.#rememberMessage(message);
    } catch(e) {
      Hooks$1.onError("ChatLog##sendMessage", e, { notify: "error", log: "error", message });
    }
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Delete a single message from the chat log.
   * @param {string} messageId                   The ID of the ChatMessage Document to remove from the log.
   * @param {object} [options]
   * @param {boolean} [options.deleteAll=false]  Delete all messages from the log.
   * @returns {Promise<void>}
   */
  deleteMessage(messageId, options={}) {
    return this.#renderingQueue.add(this.#deleteMessage.bind(this), messageId, options);
  }

  /* -------------------------------------------- */

  /**
   * Trigger a notification that alerts the user visually and audibly of new chat activity.
   * @param {ChatMessage} message             The created or updated message.
   * @param {object} [options]
   * @param {HTMLElement} [options.existing]  The existing rendered chat card, if it exists.
   * @param {boolean} [options.newMessage]    Whether this is a new message.
   */
  notify(message, { existing, newMessage }={}) {
    if ( !this.rendered ) return;

    // Post a chat card notification if the setting is enabled and this is a new message.
    if ( newMessage && this._shouldShowNotifications() ) this.#postNotification(message, { existing });

    // Otherwise show a notification pip on the chat tab.
    else {
      document.querySelector('#sidebar .tabs [data-tab="chat"] + .notification-pip')?.classList.add("active");
      this.#pipHideDebounce();
    }

    // Play a notification sound effect.
    if ( message.sound ) game.audio.play(message.sound, { context: game.audio.interface });
  }

  /* -------------------------------------------- */

  /**
   * Post a single chat message to the log.
   * @param {ChatMessage} message             The chat message.
   * @param {object} [options]
   * @param {string} [options.before]         An existing message ID to prepend the posted message to, by default the
   *                                          new message is appended to the end of the log.
   * @param {boolean} [options.notify=false]  Trigger a notification which shows the log as having a new unread message.
   * @returns {Promise<void>}                 A Promise which resolves once the message has been posted.
   */
  async postOne(message, options={}) {
    if ( !message.visible ) return;
    return this.#renderingQueue.add(this.#postOne.bind(this), message, options);
  }

  /* -------------------------------------------- */

  /**
   * Render a batch of additional messages, prepending them to the top of the log.
   * @param {number} size  The batch size.
   * @returns {Promise<void>}
   */
  async renderBatch(size) {
    if ( this.#renderingBatch ) return;
    this.#renderingBatch = true;
    return this.#renderingQueue.add(this.#doRenderBatch.bind(this), size);
  }

  /* -------------------------------------------- */

  /**
   * Re-render a message in the chat log, keeping its ephemeral state synchronized.
   * @param {ChatMessage} message   The ChatMessage Document.
   * @param {HTMLElement} existing  The existing rendered element.
   * @param {object} [options]      Options forwarded to {@link foundry.documents.ChatMessage#renderHTML}.
   */
  async #rerenderMessage(message, existing, options={}) {
    const expanded = Array.from(existing.querySelectorAll('[data-action="expandRoll"]')).map(el => {
      return el.classList.contains("expanded");
    });
    const replacement = await this.constructor.renderMessage(message, options);
    const rolls = replacement.querySelectorAll('[data-action="expandRoll"]');
    for ( let i = 0; i < rolls.length; i++ ) rolls[i].classList.toggle("expanded", expanded[i]);
    replacement.hidden = existing.hidden;
    replacement.style.opacity = existing.style.opacity;
    existing.replaceWith(replacement);
    if ( "_lifeSpan" in existing ) {
      replacement._lifeSpan = existing._lifeSpan;
      replacement.addEventListener("pointerenter", this.#onHoverNotification.bind(this));
      replacement.addEventListener("pointerleave", this.#onHoverNotification.bind(this));
    }
  }

  /* -------------------------------------------- */

  /**
   * Scroll the chat log to the bottom.
   * @param {object} [options]
   * @param {boolean} [options.popout=false]                 If a popout exists, scroll it to the bottom too.
   * @param {boolean} [options.waitImages=false]             Wait for any images embedded in the chat log to load first
   *                                                         before scrolling.
   * @param {ScrollIntoViewOptions} [options.scrollOptions]  Options to configure scrolling behavior.
   */
  async scrollBottom({popout=false, waitImages=false, scrollOptions={}}={}) {
    if ( !this.rendered ) return;
    const scroll = this.element.querySelector(".chat-scroll");
    if ( waitImages ) await this.constructor.waitForImages(scroll);
    scroll.scrollTop = 0x7fffffbf;
    if ( popout ) this.popout?.scrollBottom({waitImages, scrollOptions});
  }

  /* -------------------------------------------- */

  /**
   * Update the contents of a previously-posted message.
   * @param {ChatMessage} message  The ChatMessage instance to update.
   * @param {object} options
   * @param {boolean} [options.notify=false]  Trigger a notification which shows the log as having a new unread message.
   * @returns {Promise<void>}
   */
  async updateMessage(message, options={}) {
    return this.#renderingQueue.add(this.#updateMessage.bind(this), message, options);
  }

  /* -------------------------------------------- */

  /**
   * Update displayed timestamps for every displayed message in the chat log.
   * Timestamps are displayed in a humanized "time-since" format.
   */
  updateTimestamps() {
    for ( const li of document.querySelectorAll(".chat-message[data-message-id]") ) {
      const message = game.messages.get(li.dataset.messageId);
      if ( !message?.timestamp ) return;
      const stamp = li.querySelector(".message-timestamp");
      if ( stamp ) stamp.textContent = foundry.utils.timeSince(message.timestamp);
    }
  }

  /* -------------------------------------------- */
  /*  Private API                                 */
  /* -------------------------------------------- */

  /**
   * Check for notifications that should be automatically dismissed.
   */
  #checkNotificationLifeSpan() {
    let isPaused = false;
    const { NOTIFY_DURATION, NOTIFY_TICKER } = this.constructor;
    for ( const element of this.#notificationsElement.querySelectorAll(".message") ) {
      element._lifeSpan += NOTIFY_TICKER;
      if ( element.classList.contains("hovered") ) isPaused = true;
      if ( !isPaused && (element._lifeSpan >= NOTIFY_DURATION) ) this.#dismissNotification(element);
    }
  }

  /* -------------------------------------------- */

  /**
   * Delete a single message from the chat log.
   * @param {string} messageId                   The ID of the ChatMessage Document to remove from the log.
   * @param {object} [options]
   * @param {boolean} [options.deleteAll=false]  Delete all messages from the log.
   */
  #deleteMessage(messageId, {deleteAll=false}={}) {
    if ( !this.rendered ) return;

    // Get the chat message being removed from the log
    const message = game.messages.get(messageId);
    if ( message ) message.logged = false;

    // Get the message's element
    const li = this.element.querySelector(`.message[data-message-id="${messageId}"]`);
    if ( !li ) return;

    // Update the last index
    if ( deleteAll ) this.#lastId = null;
    else if ( messageId === this.#lastId ) this.#lastId = li.nextElementSibling?.dataset.messageId ?? null;

    // Remove the deleted message
    li.classList.add("deleting");
    li.animate(
      { height: [`${li.getBoundingClientRect().height}px`, "0"] },
      { duration: 100, easing: "ease" }
    ).finished.then(() => {
      li.remove();
      this.#onScrollLog();
    });

    if ( !this.isPopout ) {
      this.#notificationsElement.querySelector(`.message[data-message-id="${messageId}"]`)?.remove();
    }

    // Delete from the popout tab
    this.popout?.deleteMessage(messageId, {deleteAll});
    if ( this.isPopout ) this.setPosition();
    else this.#overflowingDebounce();
  }

  /* -------------------------------------------- */

  /**
   * Dismiss a notification from the ChatLog.
   * @param {HTMLElement} element
   * @returns {Promise<void>}
   */
  async #dismissNotification(element) {
    element.classList.add("deleting");
    await element.animate({
      opacity: [1, 0],
      transform: ["translateY(0)", "translateY(-35px)"]
    }, { duration: 250, easing: "ease", fill: "forwards" }).finished;
    await element.animate({
      height: [`${element.getBoundingClientRect().height}px`, "0"]
    }, { duration: 100, easing: "ease" }).finished;
    element.remove();
  }

  /* -------------------------------------------- */

  /**
   * Post a chat card to the notification queue.
   * @param {ChatMessage} message             The message to render.
   * @param {object} [options]
   * @param {HTMLElement} [options.existing]  The corresponding element in the chat log, if it exists.
   * @returns {Promise<void>}
   */
  async #postNotification(message, { existing }={}) {
    const log = this.#notificationsElement.querySelector(".chat-log");
    const paused = log.querySelector(".message.hovered");
    this.#notificationsElement.classList.add("active");
    this.#notifyAlertDebounce();

    const dummy = document.createElement("li");
    dummy.classList.add("chat-message", "message");
    dummy.dataset.messageId = message.id;
    dummy.hidden = true;
    log.append(dummy);

    let element = await this.constructor.renderMessage(message, { canDelete: false, canClose: true });
    element.addEventListener("pointerenter", this.#onHoverNotification.bind(this));
    element.addEventListener("pointerleave", this.#onHoverNotification.bind(this));
    element.hidden = true;
    dummy.replaceWith(element);

    // Insert spacer element to animate gap for card to be inserted.
    const { height=130 } = existing?.getBoundingClientRect() ?? {};
    const spacer = document.createElement("li");
    spacer.style.height = "0";
    spacer.classList.add("spacer");
    log.append(spacer);
    if ( !paused ) spacer.scrollIntoView();
    await spacer.animate(
      { height: ["0", `${height}px`] },
      { duration: 100, easing: "ease", fill: "forwards" }
    ).finished;

    // Insert the new card.
    spacer.remove();
    element = this.#notificationsElement.querySelector(`.message[data-message-id="${message.id}"]`);
    element.hidden = false;
    element.animate({
      opacity: [0, 1],
      transform: ["translateY(-35px)", "translateY(0)"]
    }, { duration: 250, easing: "ease", fill: "forwards" });
    element._lifeSpan = 0;
  }

  /* -------------------------------------------- */

  /**
   * Post a single chat message to the log.
   * @param {ChatMessage} message             The chat message.
   * @param {object} [options]
   * @param {string} [options.before]         An existing message ID to prepend the posted message to, by default the
   *                                          new message is appended to the end of the log.
   * @param {boolean} [options.notify=false]  Trigger a notification which shows the log as having a new unread message.
   * @returns {Promise<void>}                 A Promise which resolves once the message has been posted.
   */
  async #postOne(message, {before, notify=false}={}) {
    if ( !this.rendered ) return;
    message.logged = true;

    // Track internal flags
    if ( !this.#lastId ) this.#lastId = message.id; // Ensure that new messages don't result in batched scrolling.
    if ( (message.whisper || []).includes(game.user.id) && !message.isRoll ) this.#lastWhisper = message;

    // Render the message to the log
    const log = this.element.querySelector(".chat-log");
    const html = await this.constructor.renderMessage(message);

    // Append the message after some other one
    const existing = before ? this.element.querySelector(`.message[data-message-id="${before}"]`) : null;
    if ( existing ) existing.insertAdjacentElement("beforebegin", html);

    // Otherwise, append the message to the bottom of the log
    else {
      log.append(html);
      if ( this.isAtBottom || (message.author.id === game.user.id) ) this.scrollBottom({waitImages: true});
    }

    // Append to notifications.
    if ( notify ) this.notify(message, {existing: html, newMessage: true});

    // Update popout tab
    await this.popout?.postOne(message, {before, notify: false});
    if ( this.isPopout ) this.setPosition();
    else this.#overflowingDebounce();
  }

  /* -------------------------------------------- */

  /**
   * Update the contents of a previously-posted message.
   * @param {ChatMessage} message  The ChatMessage instance to update.
   * @param {object} options
   * @param {boolean} [options.notify=false]  Trigger a notification which shows the log as having a new unread message.
   * @returns {Promise<void>}
   */
  async #updateMessage(message, {notify=false}={}) {
    const li = this.element.querySelector(`.message[data-message-id="${message.id}"]`);
    if ( li ) await this.#rerenderMessage(message, li);
    // A previously invisible message has become visible to this user.
    else {
      const messages = game.messages.contents;
      const messageIndex = messages.findIndex(m => m === message);
      let nextMessage;
      for ( let i = messageIndex + 1; i < messages.length; i++ ) {
        if ( messages[i].visible ) {
          nextMessage = messages[i];
          break;
        }
      }
      await this.#postOne(message, {before: nextMessage?.id, notify: false});
    }

    if ( !this.isPopout ) {
      const existing = this.#notificationsElement.querySelector(`.message[data-message-id="${message.id}"]`);
      if ( existing ) await this.#rerenderMessage(message, existing, { canDelete: false, canClose: true });
    }

    if ( notify ) this.notify(message);

    // Update the popout tab
    await this.popout?.updateMessage(message, {notify: false});
    if ( this.isPopout ) this.setPosition();
    else this.#overflowingDebounce();
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Handles chat message rendering during the ChatMessage#getHTML deprecation period. After that period ends, calls to
   * this method can be replaced by ChatMessage#renderHTML.
   * @param {ChatMessage} message  The chat message to render.
   * @param {object} [options]     Options forwarded to the render function.
   * @returns {Promise<HTMLElement>}
   * @throws {Error}               If the message's render methods do not return a usable result.
   */
  static async renderMessage(message, options) {
    const hasGetHTML = foundry.utils.getDefiningClass(message, "getHTML") !== ChatMessage$1;
    const hasRenderHTML = foundry.utils.getDefiningClass(message, "renderHTML") !== ChatMessage$1;
    /** @deprecated since v13 */
    if ( hasGetHTML && !hasRenderHTML ) {
      const html = await message.getHTML(options);
      if ( html instanceof HTMLElement ) return html;
      if ( html[0] instanceof HTMLElement ) return html[0];
      throw new Error(`Unable to render ChatMessage [${message.id}] as it did not return an HTMLElement or jQuery.`);
    }
    return message.renderHTML(options);
  }

  /* -------------------------------------------- */

  /**
   * Update the overflowing state of the chat scroll container.
   */
  #setOverflowing() {
    const scroll = this.element.querySelector(".chat-scroll");
    scroll.classList.toggle("overflowed", scroll.scrollHeight > scroll.offsetHeight);
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the notifications pane should be visible.
   * @param {object} [options]
   * @param {boolean} [options.closing=false]  Whether the chat popout is closing.
   * @returns {boolean}
   * @protected
   */
  _shouldShowNotifications({ closing=false }={}) {
    const { chatNotifications, uiScale } = game.settings.get("core", "uiConfig");

    // Case 1 - Chat notifications disabled.
    if ( (chatNotifications === "pip") || this.options.stream ) return false;

    // Case 2 - Chat tab visible in sidebar.
    if ( ui.sidebar.expanded && ui.chat.active ) return false;

    // Case 3 - Chat popout visible.
    if ( ui.chat.popout?.rendered && (!this.isPopout || !closing) ) return false;

    // Case 4 - Not enough viewport width available.
    const cameraDock = ui.webrtc.isVertical && !ui.webrtc.hidden;
    const viewportWidth = window.innerWidth / uiScale;
    const spaceRequired = 1024 + (ui.sidebar.expanded * 300) + (cameraDock * 264);
    return viewportWidth >= spaceRequired;
  }

  /* -------------------------------------------- */

  /**
   * Update notification display, based on interface state.
   * If the chat log is popped-out, embed chat input into it. Otherwise,
   * if the sidebar is expanded, and the chat log is the active tab, embed chat input into it. Otherwise,
   * embed chat input into the notifications area.
   * If the sidebar is expanded, and the chat log is the active tab, do not display notifications.
   * If the chat log is popped out, do not display notifications.
   * @param {object} [options]
   * @param {boolean} [options.closing=false]  Whether this method has been triggered by the chat popout closing.
   * @fires {hookEvents:renderChatInput}
   * @internal
   */
  _toggleNotifications({ closing=false }={}) {
    if ( ui.chat.popout?.rendered && !this.isPopout ) return;
    const notifications = document.getElementById("chat-notifications");
    const inputElement = document.getElementById("chat-message");
    const chatControls = document.getElementById("chat-controls");
    const privacyButtons = document.getElementById("roll-privacy");
    const log = notifications.querySelector(".chat-log");
    const embedInput = !this._shouldShowNotifications({ closing });
    log.hidden = embedInput;
    privacyButtons.classList.toggle("vertical", !embedInput);
    const previousParent = inputElement.parentElement;
    if ( game.user.isGM ) chatControls.querySelector(".control-buttons").hidden = !embedInput;
    if ( embedInput ) {
      const target = ui.chat.popout?.rendered && !closing ? ui.chat.popout : ui.chat;
      target.element.querySelector(".chat-form").append(chatControls, inputElement);
      inputElement.focus();
    }
    else notifications.append(inputElement, chatControls);
    Hooks$1.callAll("renderChatInput", this, {
      "#chat-message": inputElement,
      "#chat-controls": chatControls,
      "#roll-privacy": privacyButtons
    }, {previousParent});
    this.#offsetHotbar(!embedInput);
    if ( this.#isAtBottom ) this.scrollBottom();
  }

  /* -------------------------------------------- */

  /**
   * Handle updating the roll mode display.
   * @internal
   */
  _updateRollMode() {
    if ( this.isPopout ) return;
    const rollMode = game.settings.get("core", "rollMode");
    for ( const button of document.getElementById("roll-privacy").querySelectorAll("button") ) {
      button.ariaPressed = `${rollMode === button.dataset.rollMode}`;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle offsetting the hotbar based on the chat notification position.
   * @param {boolean} notifications  Whether the chat notifications are active.
   */
  #offsetHotbar(notifications) {
    if ( this.options.stream ) return;
    const hotbar = document.getElementById("hotbar");
    const currentOffset = Number(hotbar.style.getPropertyValue("--offset").replace(/px$/, "")) || 0;
    hotbar.style.transition = "none";
    hotbar.classList.remove("offset");
    hotbar.style.removeProperty("--offset");
    hotbar.style.transition = "";
    if ( !notifications ) return;
    const { uiScale } = game.settings.get("core", "uiConfig");
    // Calculate an offset to keep the hotbar visually centered.
    const bb = hotbar.getBoundingClientRect();
    // Hard-code this number for now since the sidebar is mid-animation so we can't get an accurate width.
    const rightWidth = (380 + (300 * ui.sidebar.expanded)) / uiScale;
    const offset = currentOffset + (window.innerWidth / uiScale) - bb.right - rightWidth;
    if ( offset > 0 ) return; // Don't move closer to the sidebar.
    hotbar.classList.add("offset");
    hotbar.style.setProperty("--offset", `${offset}px`);
  }
}

/**
 * @import {ApplicationConfiguration, ApplicationRenderOptions} from "../_types.mjs"
 */

/**
 * @typedef {ApplicationConfiguration} ChatPopoutConfiguration
 * @property {ChatMessage} message  The message being rendered.
 */

/**
 * A simple application for rendering a single chat message in its own frame.
 * @extends {ApplicationV2<ChatPopoutConfiguration, ApplicationRenderOptions>}
 */
class ChatPopout extends ApplicationV2 {
  constructor(options={}) {
    super(options);
    if ( !(options.message instanceof ChatMessage$1) ) throw new Error("The ChatPopout application must be provided "
      + "a ChatMessage Document to render.");
    this.#message = options.message;
  }

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["chat-popout", "themed", "theme-light"],
    position: {
      width: 300
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The message being rendered.
   * @type {ChatMessage}
   */
  get message() {
    return this.#message;
  }

  /**
   * @type {ChatMessage}
   */
  #message;

  /** @override */
  get title() {
    const message = this.#message;
    if ( !message.isContentVisible ) return "";
    if ( message.title !== undefined ) return message.title;
    if ( message.flavor !== undefined ) {
      return new DOMParser().parseFromString(message.flavor, "text/html").body.textContent;
    }
    if ( message.speaker.alias !== undefined ) return message.speaker.alias;
    return "";
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    const applicationOptions = super._initializeApplicationOptions(options);
    applicationOptions.uniqueId = `chat-popout-${options.message.id}`;
    return applicationOptions;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    delete this.message.apps[this.id];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    this.message.apps[this.id] = this;
  }

  /* -------------------------------------------- */

  /** @override */
  async _renderHTML(context, options) {
    return ChatLog.renderMessage(this.message, { canDelete: false });
  }

  /* -------------------------------------------- */

  /** @override */
  _replaceHTML(result, content, options) {
    content.replaceChildren(result);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("change", this.#onChange.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to an input element within the chat popout.
   * @param {Event} event
   */
  #onChange(event) {
    if ( !(event.target instanceof HTMLSecretBlockElement) ) return;
    const modified = event.target.toggleRevealed(this.message.content);
    this.message.update({ content: modified });
  }
}

/**
 * @import {HandlebarsRenderOptions} from "../api/handlebars-application.mjs"
 * @import {ApplicationConfiguration, ApplicationRenderContext} from "../_types.mjs"
 * @import {ContextMenuEntry} from "../ux/context-menu.mjs";
 * @import {Constructor} from "@common/_types.mjs";
 */

/**
 * @typedef _DocumentDirectoryConfiguration
 * @property {DirectoryCollection} collection  The Document collection that this directory represents.
 * @property {string[]} renderUpdateKeys       Updating one of these properties of a displayed Document will trigger a
 *                                             re-render of the tab.
 */

/**
 * @typedef {ApplicationConfiguration & _DocumentDirectoryConfiguration} DocumentDirectoryConfiguration
 */

/**
 * An abstract class for rendering a foldered directory of Documents.
 * @extends {AbstractSidebarTab<DocumentDirectoryConfiguration, HandlebarsRenderOptions>}
 * @template {ClientDocument} [TDocument=ClientDocument]
 * @mixes HandlebarsApplication
 */
class DocumentDirectory extends HandlebarsApplicationMixin(AbstractSidebarTab) {
  constructor(options) {
    super(options);
    /** @deprecated since v13 */
    ["entryPartial", "folderPartial"].forEach(prop => {
      const warning = `${this.constructor.name}.${prop} is deprecated and no longer publicly available. `
        + `Subclasses should instead override ${this.constructor.name}._${prop}.`;
      if ( this.constructor[prop] ) {
        foundry.utils.logCompatibilityWarning(warning, { since: 13, until: 15, once: true });
        this.constructor[`_${prop}`] = this.constructor[prop];
      }
    });
  }

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["directory", "flexcol"],
    collection: null,
    renderUpdateKeys: ["name", "img", "ownership", "==ownership", "sort", "folder"],
    actions: {
      activateEntry: DocumentDirectory.#onClickEntry,
      collapseFolders: DocumentDirectory.#onCollapseFolders,
      createEntry: DocumentDirectory.#onCreateEntry,
      createFolder: DocumentDirectory.#onCreateFolder,
      showIssues: DocumentDirectory.#onShowIssues,
      toggleFolder: DocumentDirectory.#onToggleFolder,
      toggleSearch: DocumentDirectory.#onToggleSearch,
      toggleSort: DocumentDirectory.#onToggleSort
    }
  };

  /** @override */
  static PARTS = {
    header: {
      template: "templates/sidebar/directory/header.hbs"
    },
    directory: {
      template: "templates/sidebar/directory/directory.hbs",
      scrollable: [""]
    },
    footer: {
      template: "templates/sidebar/directory/footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The path to the template used to render a single entry within the directory.
   * @type {string}
   * @protected
   */
  static _entryPartial = "templates/sidebar/partials/document-partial.hbs";

  /**
   * The path to the template used to render a single folder within the directory.
   * @type {string}
   * @protected
   */
  static _folderPartial = "templates/sidebar/partials/folder-partial.hbs";

  /**
   * The Document collection that this directory represents.
   * @type {DirectoryCollection}
   */
  get collection() {
    return this.options.collection;
  }

  /**
   * The implementation of the Document type that this directory represents.
   * @returns {Constructor<TDocument>}
   */
  get documentClass() {
    return this.collection.documentClass;
  }

  /**
   * The named Document type that this directory represents.
   * @type {string}
   */
  get documentName() {
    return this.collection.documentName;
  }

  /** @override */
  get title() {
    return game.i18n.format("SIDEBAR.DirectoryTitle", { type: game.i18n.localize(this.documentClass.metadata.label) });
  }

  /**
   * Search-filter handling
   * @type {foundry.applications.ux.SearchFilter}
   */
  #searchFilter = new foundry.applications.ux.SearchFilter({
    inputSelector: "search input",
    contentSelector: ".directory-list",
    callback: this._onSearchFilter.bind(this)
  });

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options = super._initializeApplicationOptions(options);
    if ( typeof options.collection === "string" ) options.collection = game.collections.get(options.collection);
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Determine if the current user has permission to create directory entries.
   * @returns {boolean}
   * @protected
   */
  _canCreateEntry() {
    /** @deprecated since v13 */
    if ( foundry.utils.getDefiningClass(this, "canCreateEntry") ) {
      foundry.utils.logCompatibilityWarning(`${this.constructor.name}#canCreateEntry is deprecated and no longer `
        + `publicly available. Subclasses should override ${this.constructor.name}#_canCreateEntry instead.`,
      { since: 13, until: 15, once: true });
      return this.canCreateEntry;
    }
    return this.documentClass.canUserCreate(game.user);
  }

  /* -------------------------------------------- */

  /**
   * Determine if the current user has permission to create folders in this directory.
   * @returns {boolean}
   * @protected
   */
  _canCreateFolder() {
    /** @deprecated since v13 */
    if ( foundry.utils.getDefiningClass(this, "canCreateFolder") ) {
      foundry.utils.logCompatibilityWarning(`${this.constructor.name}#canCreateFolder is deprecated and no longer `
        + `publicly available. Subclasses should override ${this.constructor.name}#_canCreateFolder instead.`,
      { since: 13, until: 15, once: true });
      return this.canCreateFolder;
    }
    return game.user.isGM;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _canRender(options) {
    const { renderContext, renderData } = options;
    if ( renderContext === `update${this.documentName}` ) {
      if ( !renderData?.some(d => this.options.renderUpdateKeys.some(k => foundry.utils.hasProperty(d, k))) ) {
        return false;
      }
    }
    return super._canRender(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    parts.directory.templates ??= [];
    parts.directory.templates.push(this.constructor._entryPartial, this.constructor._folderPartial);
    return parts;
  }

  /* -------------------------------------------- */

  /**
   * Register context menu entries and fire hooks.
   * @protected
   */
  _createContextMenus() {
    /** @fires {hookEvents:getFolderContextOptions} */
    this._createContextMenu(this._getFolderContextOptions, ".folder .folder-header", {
      fixed: true,
      hookName: "getFolderContextOptions",
      parentClassHooks: false
    });
    /** @fires {hookEvents:getDocumentContextOptions} */
    this._createContextMenu(this._getEntryContextOptions, ".directory-item[data-entry-id]", {
      fixed: true,
      hookName: `get${this.documentName}ContextOptions`,
      parentClassHooks: false
    });
  }

  /* -------------------------------------------- */

  /**
   * Get context menu entries for entries in this directory.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getEntryContextOptions() {
    const getDocument = li => this.collection.get(li.closest("[data-entry-id]").dataset.entryId);
    return [{
      name: "OWNERSHIP.Configure",
      icon: '<i class="fa-solid fa-lock"></i>',
      condition: game.user.isGM,
      callback: li => new DocumentOwnershipConfig({
        document: getDocument(li),
        position: {
          top: Math.min(li.offsetTop, window.innerHeight - 350),
          left: window.innerWidth - 720
        }
      }).render({force: true})
    }, {
      name: "SIDEBAR.Export",
      icon: '<i class="fa-solid fa-file-export"></i>',
      condition: li => getDocument(li).isOwner,
      callback: li => getDocument(li).exportToJSON()
    }, {
      name: "SIDEBAR.Import",
      icon: '<i class="fa-solid fa-file-import"></i>',
      condition: li => getDocument(li).isOwner,
      callback: li => getDocument(li).importFromJSONDialog()
    }, {
      name: "FOLDER.Clear",
      icon: '<i class="fa-solid fa-folder"></i>',
      condition: header => {
        const li = header.closest(".directory-item");
        return game.user.isGM && !!getDocument(li).folder;
      },
      callback: li => getDocument(li).update({ folder: null })
    }, {
      name: "SIDEBAR.Delete",
      icon: '<i class="fa-solid fa-trash"></i>',
      condition: li => getDocument(li).canUserModify(game.user, "delete"),
      callback: li => getDocument(li).deleteDialog({
        position: {
          top: Math.min(li.offsetTop, window.innerHeight - 350),
          left: window.innerWidth - 770,
          width: 450
        }
      })
    }, {
      name: "SIDEBAR.Duplicate",
      icon: '<i class="fa-regular fa-copy"></i>',
      condition: li => getDocument(li).isOwner && this.documentClass.canUserCreate(game.user),
      callback: li => {
        const original = getDocument(li);
        return original.clone(this._prepareDuplicateData(original), {save: true, addSource: true});
      }
    }];
  }

  /* -------------------------------------------- */

  /**
   * Prepares the data for a duplicated Document.
   * @param {Document} document    The Document that is duplicated
   * @returns {object}             The partial data of the duplicate that overrides the original data
   * @protected
   */
  _prepareDuplicateData(document) {
    return {name: game.i18n.format("DOCUMENT.CopyOf", {name: document._source.name})};
  }

  /* -------------------------------------------- */

  /**
   * Get context menu entries for folders in this directory.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getFolderContextOptions() {
    return this.constructor._getFolderContextOptions();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    this._createContextMenus();
    if ( !this.isPopout && !this.collection.apps.includes(this) ) this.collection.apps.push(this);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    // Search
    if ( ["header", "directory"].some(p => options.parts.includes(p)) ) {
      this.#searchFilter.bind(this.element);
    }

    // Drag-drop
    if ( options.parts.includes("directory") ) {
      new foundry.applications.ux.DragDrop.implementation({
        dragSelector: ".directory-item",
        dropSelector: ".directory-list",
        permissions: {
          dragstart: this._canDragStart.bind(this),
          drop: this._canDragDrop.bind(this)
        },
        callbacks: {
          dragover: this._onDragOver.bind(this),
          dragstart: this._onDragStart.bind(this),
          drop: this._onDrop.bind(this)
        }
      }).bind(this.element);
      this.element.querySelectorAll(".directory-item.folder").forEach(folder => {
        folder.addEventListener("dragenter", this._onDragHighlight.bind(this));
        folder.addEventListener("dragleave", this._onDragHighlight.bind(this));
      });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    return Object.assign(context, {
      documentName: this.documentName,
      folderIcon: CONFIG.Folder.sidebarIcon,
      sidebarIcon: CONFIG[this.documentName].sidebarIcon,
      canCreateEntry: this._canCreateEntry(),
      canCreateFolder: this._canCreateFolder()
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "directory": await this._prepareDirectoryContext(context, options); break;
      case "footer": await this._prepareFooterContext(context, options); break;
      case "header": await this._prepareHeaderContext(context, options); break;
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the directory part.
   * @param {ApplicationRenderContext} context
   * @param {HandlebarsRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareDirectoryContext(context, options) {
    Object.assign(context, {
      documentCls: this.documentName.toLowerCase(),
      entryPartial: this.constructor._entryPartial,
      folderPartial: this.constructor._folderPartial,
      maxFolderDepth: this.collection.maxFolderDepth,
      tree: this.collection.tree
    });
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the footer part.
   * @param {ApplicationRenderContext} context
   * @param {HandlebarsRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareFooterContext(context, options) {
    context.buttons = [];
    const unavailable = game.user.isGM ? this.collection.invalidDocumentIds.size : 0;
    if ( unavailable ) {
      const plurals = new Intl.PluralRules(game.i18n.lang);
      const locPath = `SUPPORT.UnavailableDocuments.${plurals.select(unavailable)}`;
      const docLabel = game.i18n.localize(this.documentClass.metadata.label);
      const label = game.i18n.format(locPath, {count: unavailable, document: docLabel});
      context.buttons.push({type: "button", cssClass: "plain", icon: "fa-solid fa-triangle-exclamation", label,
        action: "showIssues"});
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the header part.
   * @param {ApplicationRenderContext} context
   * @param {HandlebarsRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareHeaderContext(context, options) {
    Object.assign(context, {
      searchMode: this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME
        ? { icon: "fa-solid fa-magnifying-glass", label: "SIDEBAR.SearchModeName" }
        : { icon: "fa-solid fa-file-magnifying-glass", label: "SIDEBAR.SearchModeFull" },
      sortMode: this.collection.sortingMode === "a"
        ? { icon: "fa-solid fa-arrow-down-a-z", label: "SIDEBAR.SortModeAlpha" }
        : { icon: "fa-solid fa-arrow-down-short-wide", label: "SIDEBAR.SortModeManual" }
    });
    const types = game.i18n.localize(this.documentClass.metadata.labelPlural);
    context.searchMode.placeholder = game.i18n.format("SIDEBAR.Search", {types});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preSyncPartState(partId, newElement, priorElement, state) {
    super._preSyncPartState(partId, newElement, priorElement, state);
    if ( partId === "header" ) state.query = priorElement.querySelector("search input").value;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _syncPartState(partId, newElement, priorElement, state) {
    super._syncPartState(partId, newElement, priorElement, state);
    if ( (partId === "header") && state.query ) newElement.querySelector("search input").value = state.query;
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Collapse all open folders in this directory.
   */
  collapseAll() {
    for ( const el of this.element.querySelectorAll(".directory-item.folder") ) {
      el.classList.remove("expanded");
      delete game.folders._expanded[el.dataset.uuid];
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle activating a directory entry.
   * @this {DocumentDirectory}
   * @param {...any} args
   * @returns {Promise<void>}
   */
  static #onClickEntry(...args) {
    return this._onClickEntry(...args);
  }

  /* -------------------------------------------- */

  /**
   * Handle activating a directory entry.
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The action target element.
   * @param {object} [options]
   * @param {boolean} [options._skipDeprecation] Internal use only.
   * @returns {Promise<void>}
   * @protected
   */
  async _onClickEntry(event, target, { _skipDeprecation=false }={}) {
    /** @deprecated since v13 */
    if ( !_skipDeprecation && (foundry.utils.getDefiningClass(this, "_onClickEntryName") !== DocumentDirectory) ) {
      foundry.utils.logCompatibilityWarning(`${this.constructor.name}#_onClickEntryName is deprecated. `
        + `Please use ${this.constructor.name}#_onClickEntry instead.`,
      { since: 13, until: 15, once: true });
      return this._onClickEntryName(event);
    }

    event.preventDefault();
    const { entryId } = target.closest("[data-entry-id]").dataset;
    const document = this.collection.get(entryId) ?? await this.collection.getDocument(entryId);
    document.sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Collapse open folders in this directory.
   * @this {DocumentDirectory}
   */
  static #onCollapseFolders() {
    return this.collapseAll();
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a new entry in this directory.
   * @this {DocumentDirectory}
   * @param {...any} args
   */
  static #onCreateEntry(...args) {
    return this._onCreateEntry(...args);
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a new entry in this directory.
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The action target element.
   * @protected
   */
  _onCreateEntry(event, target) {
    event.stopPropagation();
    const { folderId } = target.closest(".directory-item")?.dataset ?? {};
    const options = {
      position: { width: 320, left: window.innerWidth - 630, top: target.offsetTop }
    };
    const operation = {};
    if ( this.collection instanceof CompendiumCollection ) operation.pack = this.collection.collection;
    return this.documentClass.createDialog({ folder: folderId ?? null }, operation, options);
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a new folder in this directory.
   * @this {DocumentDirectory}
   * @param {...any} args
   */
  static #onCreateFolder(...args) {
    return this._onCreateFolder(...args);
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a new folder in this directory.
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The action target element.
   * @protected
   */
  _onCreateFolder(event, target) {
    event.stopPropagation();
    const { folderId } = target.closest(".directory-item")?.dataset ?? {};
    const data = { folder: folderId ?? null, type: this.documentName };
    const sheetWidth = foundry.applications.sheets.FolderConfig.DEFAULT_OPTIONS.position.width;
    const options = {
      position: { top: target.offsetTop, left: window.innerWidth - 310 - sheetWidth }
    };
    const operation = {};
    if ( this.collection instanceof CompendiumCollection ) operation.pack = this.collection.collection;
    Folder$1.implementation.createDialog(data, operation, options);
  }

  /* -------------------------------------------- */

  /**
   * Handle showing the client issues dialog.
   * @this {DocumentDirectory}
   */
  static #onShowIssues() {
    new foundry.applications.sidebar.apps.SupportDetails().render({ force: true, tab: "documents" });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a folder's expanded state.
   * @this {DocumentDirectory}
   * @param {...any} args
   */
  static #onToggleFolder(...args) {
    return this._onToggleFolder(...args);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a folder's expanded state.
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The action target element.
   * @param {object} [options]
   * @param {boolean} [options._skipDeprecation] Internal use only.
   * @protected
   */
  _onToggleFolder(event, target, { _skipDeprecation=false }={}) {
    /** @deprecated since v13 */
    if ( !_skipDeprecation && (foundry.utils.getDefiningClass(this, "_toggleFolder") !== DocumentDirectory) ) {
      foundry.utils.logCompatibilityWarning(`${this.constructor.name}#_toggleFolder is deprecated. `
        + `Please use ${this.constructor.name}#_onToggleFolder instead.`,
      { since: 13, until: 15, once: true });
      return this._toggleFolder(event);
    }

    const folder = target.closest(".directory-item");
    folder.classList.toggle("expanded");
    const expanded = folder.classList.contains("expanded");
    const { uuid } = folder.dataset;
    if ( expanded ) game.folders._expanded[uuid] = true;
    else delete game.folders._expanded[uuid];

    if ( !expanded ) {
      for ( const subfolder of folder.querySelectorAll(".directory-item.folder") ) {
        subfolder.classList.remove("expanded");
        delete game.folders._expanded[subfolder.dataset.uuid];
      }
    }

    if ( this.isPopout ) this.setPosition();
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the search mode.
   * @this {DocumentDirectory}
   */
  static #onToggleSearch() {
    this.collection.toggleSearchMode();
    this.render({ parts: ["header"] });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the sort mode.
   * @this {DocumentDirectory}
   */
  static #onToggleSort() {
    this.collection.toggleSortingMode();
    this.render();
  }

  /* -------------------------------------------- */
  /*  Search & Filter                             */
  /* -------------------------------------------- */

  /**
   * Handle matching a given directory entry with the search filter.
   * @param {string} query          The input search string.
   * @param {Set<string>} entryIds  The matched directory entry IDs.
   * @param {HTMLElement} element   The candidate entry element.
   * @param {object} [options]      Additional options for subclass-specific behavior.
   * @protected
   */
  _onMatchSearchEntry(query, entryIds, element, options={}) {
    element.style.display = !query || entryIds.has(element.dataset.entryId) ? "flex" : "none";
  }

  /* -------------------------------------------- */

  /**
   * Handle directory searching and filtering.
   * @param {KeyboardEvent} event  The keyboard input event.
   * @param {string} query         The input search string.
   * @param {RegExp} rgx           The regular expression query that should be matched against.
   * @param {HTMLElement} html     The container to filter entries from.
   * @protected
   */
  _onSearchFilter(event, query, rgx, html) {
    const entryIds = new Set();
    const folderIds = new Set();
    const autoExpandIds = new Set();
    const options = {};

    // Match entries and folders.
    if ( query ) {
      // First match folders.
      this._matchSearchFolders(rgx, folderIds, autoExpandIds, options);

      // Next match entries.
      this._matchSearchEntries(rgx, entryIds, folderIds, autoExpandIds, options);
    }

    // Toggle each directory entry.
    for ( const el of html.querySelectorAll(".directory-item") ) {
      if ( el.hidden ) continue;
      if ( el.classList.contains("folder") ) {
        const { folderId, uuid } = el.dataset;
        const match = folderIds.has(folderId);
        el.style.display = !query || match ? "flex" : "none";
        if ( autoExpandIds.has(folderId) ) {
          if ( query && match ) el.classList.add("expanded");
        }
        else el.classList.toggle("expanded", uuid in game.folders._expanded);
      }
      else this._onMatchSearchEntry(query, entryIds, el, options);
    }
  }

  /* -------------------------------------------- */

  /**
   * Include the matched folder in search results, and recursively auto-expand its parent folders.
   * @param {Folder|string} folder               The folder document or its ID.
   * @param {Set<string>} folderIds              The set of matched folder IDs.
   * @param {Set<string>} autoExpandIds          The set of folder IDs that should be auto-expanded.
   * @param {object} [options]
   * @param {boolean} [options.autoExpand=true]  Add the folder to the auto-expand list.
   */
  #onMatchFolder(folder, folderIds, autoExpandIds, { autoExpand=true }={}) {
    if ( typeof folder === "string" ) folder = this.collection.folders.get(folder);
    if ( !folder ) return;
    const folderId = folder._id;
    const visited = folderIds.has(folderId);
    folderIds.add(folderId);
    if ( autoExpand ) autoExpandIds.add(folderId);
    if ( !visited && folder.folder ) this.#onMatchFolder(folder.folder, folderIds, autoExpandIds);
  }

  /* -------------------------------------------- */

  /**
   * Identify entries in the collection which match a provided search query.
   * @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 {Set<string>} autoExpandIds  The set of folder IDs that should be auto-expanded.
   * @param {object} [options]           Additional options for subclass-specific behavior.
   * @protected
   */
  _matchSearchEntries(query, entryIds, folderIds, autoExpandIds, options={}) {
    const nameOnlySearch = this.collection.searchMode === DIRECTORY_SEARCH_MODES.NAME;
    const entries = this.collection.index ?? this.collection.contents;

    // Copy the folderIds to a new set, so that we can add to the original set without incorrectly adding child entries.
    const matchedFolderIds = new Set(folderIds);

    for ( const entry of entries ) {
      const entryId = entry._id;

      // If we matched a folder, add its child entries.
      if ( matchedFolderIds.has(entry.folder?._id ?? entry.folder) ) entryIds.add(entryId);

      // Otherwise, if we are searching by name, match the entry name.
      if ( nameOnlySearch && query.test(foundry.applications.ux.SearchFilter.cleanQuery(entry.name)) ) {
        entryIds.add(entryId);
        this.#onMatchFolder(entry.folder, folderIds, autoExpandIds);
      }
    }

    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);
      this.#onMatchFolder(match.folder, folderIds, autoExpandIds);
    }
  }

  /* -------------------------------------------- */

  /**
   * Identify folders in the collection which match a provided search query.
   * @param {RegExp} query               The search query.
   * @param {Set<string>} folderIds      The set of matched folder IDs.
   * @param {Set<string>} autoExpandIds  The set of folder IDs that should be auto-expanded.
   * @param {object} [options]           Additional options for subclass-specific behavior.
   * @protected
   */
  _matchSearchFolders(query, folderIds, autoExpandIds, options={}) {
    for ( const folder of this.collection.folders ) {
      if ( query.test(foundry.applications.ux.SearchFilter.cleanQuery(folder.name)) ) {
        this.#onMatchFolder(folder, folderIds, autoExpandIds, { autoExpand: false });
      }
    }
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /**
   * Determine if drop operations are permitted.
   * @param {string} selector       The candidate HTML selector for dragging
   * @returns {boolean}             Can the current user drag this selector?
   * @protected
   */
  _canDragDrop(selector) {
    return this.documentClass.canUserCreate(game.user);
  }

  /* -------------------------------------------- */

  /**
   * Determine if drag operations are permitted.
   * @param {string} selector       The candidate HTML selector for dragging
   * @returns {boolean}             Can the current user drag this selector?
   * @protected
   */
  _canDragStart(selector) {
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Create a new entry in this directory from one that was dropped on it.
   * @param {DirectoryMixinEntry} entry  The dropped entry.
   * @param {object} [updates]           Modifications to the creation data.
   * @returns {Promise<TDocument>}
   * @protected
   */
  _createDroppedEntry(entry, updates={}) {
    const data = foundry.utils.mergeObject(entry.toObject(), updates, { performDeletions: true });
    return this.documentClass.create(data, { fromCompendium: entry.inCompendium });
  }

  /* -------------------------------------------- */

  /**
   * Import a dropped folder and its children into this collection if they do not already exist.
   * @param {Folder} folder          The folder being dropped.
   * @param {Folder} [targetFolder]  A folder to import into if not the directory root.
   * @returns {Promise<Folder[]>}
   * @protected
   */
  async _createDroppedFolderContent(folder, targetFolder) {
    const {foldersToCreate, documentsToCreate} = await this._organizeDroppedFoldersAndDocuments(folder, targetFolder);

    // Create folders.
    let createdFolders;
    try {
      createdFolders = await Folder$1.implementation.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;
  }

  /* -------------------------------------------- */

  /**
   * Create a set of documents in a dropped folder.
   * @param {Folder} folder  The dropped folder.
   * @param {TDocument[]|object[]} documents  The documents to create, or their indices.
   * @returns {Promise<void>}
   * @protected
   */
  async _createDroppedFolderDocuments(folder, documents) {
    if ( folder.pack ) {
      const pack = game.packs.get(folder.pack);
      if ( pack ) documents = await pack.getDocuments({ _id__in: documents.map(d => d._id) });
    }
    try {
      await this.documentClass.createDocuments(documents, { pack: this.collection.collection, keepId: true });
    } catch(err) {
      ui.notifications.error(err.message);
      throw err;
    }
  }

  /* -------------------------------------------- */

  /**
   * Test if the given entry is already present in this directory.
   * @param {ClientDocument} entry  The directory entry.
   * @returns {boolean}
   * @protected
   */
  _entryAlreadyExists(entry) {
    return this.collection.has(entry.id);
  }

  /* -------------------------------------------- */

  /**
   * Determine whether a given directory entry belongs to the given folder.
   * @param {DirectoryMixinEntry} entry  The entry.
   * @param {string} folder              The target folder ID.
   * @returns {boolean}
   * @protected
   */
  _entryBelongsToFolder(entry, folder) {
    if ( !entry.folder && !folder ) return true;
    if ( entry.folder instanceof Folder$1 ) return entry.folder.id === folder;
    return entry.folder === folder;
  }

  /* -------------------------------------------- */

  /**
   * Get the entry instance from its dropped data.
   * @param {object} data  The drag data.
   * @returns {Promise<ClientDocument>}
   * @throws {Error}       If the correct instance type could not be retrieved.
   * @protected
   */
  _getDroppedEntryFromData(data) {
    return this.documentClass.fromDropData(data);
  }

  /* -------------------------------------------- */

  /**
   * Get drag data for an entry in this directory.
   * @param {string} entryId  The entry's ID.
   * @protected
   */
  _getEntryDragData(entryId) {
    return this.collection.get(entryId).toDragData();
  }

  /* -------------------------------------------- */

  /**
   * Get drag data for a folder in this directory.
   * @param {string} folderId  The folder ID.
   * @protected
   */
  _getFolderDragData(folderId) {
    return this.collection.folders.get(folderId).toDragData();
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping a new entry into this directory.
   * @param {HTMLElement} target  The drop target element.
   * @param {object} data         The drop data.
   * @returns {Promise<void>}
   * @protected
   */
  async _handleDroppedEntry(target, data) {
    const closestFolder = target?.closest(".directory-item.folder");
    closestFolder?.classList.remove("droptarget");
    let folder = await foundry.utils.fromUuid(closestFolder?.dataset.uuid);
    let entry = await this._getDroppedEntryFromData(data);
    if ( !entry ) return;

    // Sort relative to another entry.
    const collection = this.collection.index ?? this.collection;
    const sortData = { sortKey: "sort" };
    const relativeEntryId = target?.dataset.entryId;
    if ( relativeEntryId ) {
      if ( entry.id === relativeEntryId ) return; // Don't drop on yourself.
      const targetEntry = collection.get(relativeEntryId);
      sortData.target = targetEntry;
      folder = targetEntry?.folder;
    }

    // Sort within the closest folder.
    else sortData.target = null;

    // Determine siblings.
    if ( folder instanceof Folder$1 ) folder = folder.id;
    sortData.siblings = collection.filter(d => (d._id !== entry.id) && this._entryBelongsToFolder(d, folder));

    if ( !this._entryAlreadyExists(entry) ) {
      // Try to predetermine the sort order.
      const sorted = foundry.utils.performIntegerSort(entry, sortData);
      const updates = { folder: folder || null };
      if ( sorted.length === 1 ) updates.sort = sorted[0].update[sortData.sortKey];
      entry = await this._createDroppedEntry(entry, updates);

      // No need to resort other entries if this one was created with a specific sort order.
      if ( sorted.length === 1 ) return;
    }

    // Resort the collection.
    sortData.updateData = { folder: folder || null };
    return entry.sortRelative(sortData);
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping a folder onto the directory.
   * @param {HTMLElement} target  The drop target element.
   * @param {object} data         The drop data.
   * @returns {Promise<void>}
   * @protected
   */
  async _handleDroppedFolder(target, data) {
    let { closestFolderId, folder, sortData, foreign } = (await this.constructor._handleDroppedFolder(target, data, {
      folders: this.collection.folders,
      maxFolderDepth: this.collection.maxFolderDepth,
      type: this.documentName,
      label: this.documentClass.metadata.label
    })) ?? {};

    if ( !folder ) return;

    if ( foreign ) {
      const dropped = await this._handleDroppedForeignFolder(folder, closestFolderId, sortData);
      if ( !dropped?.sortNeeded ) return;
      folder = dropped.folder;
    }

    sortData.updateData = { folder: sortData.parentId };
    return folder.sortRelative(sortData);
  }

  /* -------------------------------------------- */

  /**
   * Handle importing a new folder's into the directory.
   * @param {Folder} folder           The dropped folder.
   * @param {string} closestFolderId  The ID of the closest folder to the drop target.
   * @param {object} sortData         Sort data for the folder.
   * @returns {Promise<{ folder: Folder, sortNeeded: boolean }|null>}
   * @protected
   */
  async _handleDroppedForeignFolder(folder, closestFolderId, sortData) {
    const closestFolder = this.collection.folders.get(closestFolderId);
    const [created] = await this._createDroppedFolderContent(folder, closestFolder) ?? [];
    return created ? { folder: created, sortNeeded: true } : null;
  }

  /* -------------------------------------------- */

  /**
   * Highlight folders as drop targets when a drag event enters or exits their area.
   * @param {DragEvent} event  The in-progress drag event.
   * @protected
   */
  _onDragHighlight(event) {
    event.stopPropagation();
    if ( event.type === "dragenter" ) {
      for ( const el of this.element.querySelectorAll(".droptarget") ) el.classList.remove("droptarget");
    }
    else if ( event.type === "dragleave" ) {

      // Look up the hovered element (event.target is the element that was left)
      const el = document.elementFromPoint(event.clientX, event.clientY);
      const parent = el.closest(".folder");
      if ( parent === event.currentTarget ) return;
    }
    event.currentTarget.classList.toggle("droptarget", event.type === "dragenter");
  }

  /* -------------------------------------------- */

  /**
   * Handle drag events over the directory.
   * @param {DragEvent} event
   * @protected
   */
  _onDragOver(event) {}

  /* -------------------------------------------- */

  /** @override */
  _onDragStart(event) {
    ui.context?.close({ animate: false });
    const { entryId, folderId } = event.currentTarget.dataset;
    const dragData = folderId ? this._getFolderDragData(folderId) : this._getEntryDragData(entryId);
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /** @override */
  _onDrop(event) {
    const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);
    if ( !data.type ) return;
    const target = event.target.closest(".directory-item") ?? null;
    if ( data.type === "Folder" ) return this._handleDroppedFolder(target, data);
    else if ( data.type === this.documentName ) return this._handleDroppedEntry(target, data);
  }

  /* -------------------------------------------- */

  /**
   * Organize a dropped folder and its children into a list of folders and documents to create.
   * @param {Folder} folder          The dropped folder.
   * @param {Folder} [targetFolder]  A folder to import into if not the directory root.
   * @returns {Promise<{ foldersToCreate: Folder[], documentsToCreate: TDocument[]|object[] }>}
   * @protected
   */
  _organizeDroppedFoldersAndDocuments(folder, targetFolder) {
    const foldersToCreate = [];
    const documentsToCreate = [];
    let exceededMaxDepth = false;

    const addFolder = (f, depth) => {
      if ( !f ) return;

      // If the folder does not already exist, add it to the list of folders to create.
      if ( this.collection.folders.get(f.id) !== f ) {
        const createData = f.toObject();
        if ( targetFolder ) {
          createData.folder = targetFolder.id;
          targetFolder = null;
        }
        if ( depth > this.collection.maxFolderDepth ) {
          exceededMaxDepth = true;
          return;
        }
        createData.pack = this.collection.collection;
        foldersToCreate.push(createData);
      }

      // If the folder has documents, check those as well.
      for ( const d of f.contents ?? [] ) documentsToCreate.push(d.toObject?.() ?? foundry.utils.deepClone(d));

      // Recursively check child folders.
      for ( const child of f.children ) addFolder(child.folder, depth + 1);
    };

    const currentDepth = (targetFolder?.ancestors.length ?? 0) + 1;
    addFolder(folder, currentDepth);
    if ( exceededMaxDepth ) {
      ui.notifications.error("FOLDER.ExceededMaxDepth", {
        console: false,
        format: { depth: this.collection.maxFolderDepth }
      });
      foldersToCreate.length = documentsToCreate.length = 0;
    }

    return { foldersToCreate, documentsToCreate };
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Get context menu entries for folders in a directory.
   * @returns {ContextMenuEntry[]}
   * @internal
   */
  static _getFolderContextOptions() {
    return [{
      name: "FOLDER.Edit",
      icon: '<i class="fa-solid fa-pen-to-square"></i>',
      condition: game.user.isGM,
      callback: async header => {
        const li = header.closest(".directory-item");
        const folder = await fromUuid(li.dataset.uuid);
        const {top, left} = li.getBoundingClientRect();
        return folder.sheet.render({
          force: true,
          position: {top, left: left - FolderConfig.DEFAULT_OPTIONS.position.width - 10}
        });
      }
    }, {
      name: "FOLDER.CreateTable",
      icon: `<i class="${CONFIG.RollTable.sidebarIcon}"></i>`,
      condition: header => {
        const li = header.closest(".directory-item");
        const folder = fromUuidSync(li.dataset.uuid);
        return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type);
      },
      callback: async header => {
        const li = header.closest(".directory-item");
        const folder = await fromUuid(li.dataset.uuid);
        const title = game.i18n.format("FOLDER.CreateTableConfirm.Title", { folder: folder.name });
        return DialogV2.confirm({
          window: { title }, // FIXME: double localization
          position: {
            top: Math.min(li.offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 740,
            width: 420
          },
          content: `<p>${game.i18n.localize("FOLDER.CreateTableConfirm.Question")}</p>`,
          yes: { callback: () => RollTable$1.implementation.fromFolder(folder), default: true }
        });
      }
    }, {
      name: "FOLDER.Remove",
      icon: '<i class="fa-solid fa-trash"></i>',
      condition: game.user.isGM,
      callback: async header => {
        const li = header.closest(".directory-item");
        const folder = await fromUuid(li.dataset.uuid);
        const question = game.i18n.localize("AreYouSure");
        const warning = game.i18n.localize("FOLDER.RemoveWarning");
        const title = game.i18n.format("FOLDER.RemoveName", { name: folder.name });
        return folder.deleteDialog({
          content: `<p><strong>${question}</strong> ${warning}</p>`,
          window: { title, icon: "fas fa-trash" }, // FIXME: double localization
          position: {
            top: Math.min(li.offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 770,
            width: 450
          }
        });
      }
    }, {
      name: "FOLDER.Delete",
      icon: '<i class="fa-solid fa-dumpster"></i>',
      condition: game.user.isGM,
      callback: async header => {
        const li = header.closest(".directory-item");
        const folder = await fromUuid(li.dataset.uuid);
        const question = game.i18n.localize("AreYouSure");
        const warning = game.i18n.localize("FOLDER.DeleteWarning");
        const title = game.i18n.format("FOLDER.DeleteName", { name: folder.name });
        return folder.deleteDialog({
          window: { title, icon: "fas fa-dumpster" }, // FIXME: double localization
          position: {
            top: Math.min(li.offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 770,
            width: 450
          },
          content: `<p><strong>${question}</strong> ${warning}</p>`,
          yes: {
            callback: () => folder.delete({ deleteSubfolders: true, deleteContents: true })
          }
        });
      }
    }, {
      name: "OWNERSHIP.Configure",
      icon: '<i class="fa-solid fa-lock"></i>',
      condition: game.user.isGM,
      callback: async header => {
        const li = header.closest(".directory-item");
        const folder = await fromUuid(li.dataset.uuid);
        return new DocumentOwnershipConfig({
          document: folder,
          position: {
            top: Math.min(li.offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720
          }
        }).render({force: true});
      }
    }, {
      name: "FOLDER.Export",
      icon: '<i class="fa-solid fa-book-atlas"></i>',
      condition: header => {
        const li = header.closest(".directory-item");
        const folder = fromUuidSync(li.dataset.uuid);
        return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type);
      },
      callback: async header => {
        const li = header.closest(".directory-item");
        const folder = await fromUuid(li.dataset.uuid);
        return folder.exportDialog(null, {}, {
          position: {
            top: Math.min(li.offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720,
            width: 400
          }
        });
      }
    }];
  }

  /* -------------------------------------------- */

  /**
   * Helper method to handle dropping a folder onto the directory.
   * @param {HTMLElement} target            The drop target element.
   * @param {object} data                   The drop data.
   * @param {object} config
   * @param {Folder[]} config.folders       The sibling folders.
   * @param {string} config.label           The label for entries in the directory.
   * @param {number} config.maxFolderDepth  The maximum folder depth in this directory.
   * @param {string} config.type            The type of entries in the directory.
   * @returns {Promise<{[closestFolderId]: string, folder: Folder, sortData: object, [foreign]: boolean}|void>}
   * @internal
   */
  static async _handleDroppedFolder(target, data, { folders, label, maxFolderDepth, type }) {
    const closestFolder = target?.closest(".directory-item.folder");
    closestFolder?.classList.remove("droptarget");
    const closestFolderId = closestFolder?.dataset.folderId;
    const folder = await fromUuid(data.uuid);
    if ( !folder ) return;
    if ( folder.type !== type ) {
      const typeLabel = game.i18n.localize(label);
      ui.notifications.warn("FOLDER.InvalidDocumentType", { format: { type: typeLabel } });
      return;
    }

    // Sort into another folder.
    const sortData = { sortKey: "sort", sortBefore: true };
    const relativeFolderId = target?.dataset.folderId;
    if ( relativeFolderId ) {
      const targetFolder = await fromUuid(target.dataset.uuid);

      // Drop into an expanded folder.
      if ( target.classList.contains("expanded") ) {
        Object.assign(sortData, {
          target: null,
          parentId: targetFolder.id,
          parentUuid: targetFolder.uuid
        });
      }

      // Sort relative to a collapsed folder.
      else {
        Object.assign(sortData, {
          target: targetFolder,
          parentId: targetFolder.folder?.id,
          parentUuid: targetFolder.folder?.uuid
        });
      }
    }

    // Sort relative to an existing folder's contents.
    else {
      Object.assign(sortData, {
        parentId: closestFolderId,
        parentUuid: closestFolder?.dataset.uuid,
        target: closestFolder && !closestFolder.classList.contains("expanded") ? closestFolder : null
      });
    }

    if ( sortData.parentUuid ) {
      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)) > maxFolderDepth ) {
        ui.notifications.error("FOLDER.ExceededMaxDepth", {
          console: false,
          format: { depth: maxFolderDepth }
        });
        return;
      }
    }

    // Determine siblings.
    sortData.siblings = 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 ( folders.get(folder.id) !== folder ) return { closestFolderId, folder, sortData, foreign: true };
    return { folder, sortData };
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13 until v15.
   * @ignore
   */
  _onClickEntryName(event) {
    foundry.utils.logCompatibilityWarning(`${this.constructor.name}#_onClickEntryName is deprecated. `
      + `Please use ${this.constructor.name}#_onClickEntry instead.`,
    { since: 13, until: 15, once: true });
    return this._onClickEntry(event, event.target, { _skipDeprecation: true });
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13 until v15.
   * @ignore
   */
  _toggleFolder(event) {
    foundry.utils.logCompatibilityWarning(`${this.constructor.name}#_toggleFolder is deprecated. `
      + `Please use ${this.constructor.name}#_onToggleFolder instead.`,
    { since: 13, until: 15, once: true });
    return this._onToggleFolder(event, event.target, { _skipDeprecation: true });
  }
}

/**
 * An Application that displays the indexed contents of a Compendium pack.
 * @template {ClientDocument} TDocument
 * @extends {DocumentDirectory<TDocument>}
 */
class Compendium extends DocumentDirectory {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["compendium-directory", "sidebar-popout"],
    window: {
      frame: true,
      positioned: true
    },
    position: {
      top: 70,
      left: 120,
      width: 350,
      height: window.innerHeight - 100
    }
  };

  /** @override */
  static PARTS = {
    header: {
      template: "templates/sidebar/apps/compendium/header.hbs"
    },
    directory: {
      template: "templates/sidebar/apps/compendium/directory.hbs",
      templates: ["templates/sidebar/directory/directory.hbs"],
      scrollable: [".directory-list"]
    },
    footer: super.PARTS.footer
  };

  /** @override */
  static _entryPartial = "templates/sidebar/apps/compendium/index-partial.hbs";

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get isPopout() {
    return false;
  }

  /** @override */
  get title() {
    return game.i18n.localize(this.collection.title);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options = super._initializeApplicationOptions(options);
    options.id = `compendium-${options.collection.collection.replaceAll(".", "_")}`;
    return options;
  }

  /* -------------------------------------------- */

  /** @override */
  _canCreateEntry() {
    const isOwner = this.collection.testUserPermission(game.user, "OWNER");
    return !this.collection.locked && isOwner && this.documentClass.canUserCreate(game.user);
  }

  /* -------------------------------------------- */

  /** @override */
  _canCreateFolder() {
    return this._canCreateEntry();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _canRender(options) {
    if ( !this.collection.visible ) {
      if ( options.force ) ui.notifications.warn("COMPENDIUM.CannotViewWarning", { localize: true });
      return false;
    }
    return super._canRender(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    options.window ||= {};
    options.window.icon ||= `fa-solid fa-lock${this.collection.locked ? "" : "-open"}`;
  }

  /* -------------------------------------------- */

  /** @override */
  _getEntryContextOptions() {
    const isAdventure = this.documentName === "Adventure";
    const isScene = this.documentName === "Scene";
    return [{
      name: "COMPENDIUM.ImportEntry",
      icon: '<i class="fa-solid fa-download"></i>',
      condition: () => !isAdventure && this.documentClass.canUserCreate(game.user),
      callback: li => {
        const collection = game.collections.get(this.documentName);
        return collection.importFromCompendium(this.collection, li.dataset.entryId, {}, {renderSheet: true});
      }
    }, {
      name: "ADVENTURE.ExportEdit",
      icon: '<i class="fa-solid fa-pen-to-square"></i>',
      condition: () => isAdventure && game.user.isGM && !this.collection.locked,
      callback: async li => {
        const document = await this.collection.getDocument(li.dataset.entryId);
        return new CONFIG.Adventure.exporterClass({
          document: document.clone({}, {keepId: true})
        }).render({force: true});
      }
    }, {
      name: "SCENE.GenerateThumb",
      icon: '<i class="fa-solid fa-image"></i>',
      condition: () => !this.collection.locked && isScene,
      callback: async li => {
        const scene = await this.collection.getDocument(li.dataset.entryId);
        try {
          const { thumb } = await scene?.createThumbnail() ?? {};
          if ( thumb ) await scene.update({thumb}, {diff: false});
          ui.notifications.info("SCENE.GenerateThumbSuccess", {format: {name: scene.name}});
        } catch(err) {
          ui.notifications.error(err.message);
        }
      }
    }, {
      name: "COMPENDIUM.DeleteEntry",
      icon: '<i class="fa-solid fa-trash"></i>',
      condition: () => game.user.isGM && !this.collection.locked,
      callback: async li => {
        const document = await this.collection.getDocument(li.dataset.entryId);
        return document?.deleteDialog();
      }
    }];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getFolderContextOptions() {
    return super._getFolderContextOptions().filter(({ name }) => {
      return (name !== "OWNERSHIP.Configure") && (name !== "FOLDER.Export");
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareHeaderContext(context, options) {
    await super._prepareHeaderContext(context, options);
    Object.assign(context, {collection: this.collection, title: this.title});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    if ( this.options.classes.includes("themed") ) return;
    this.element.classList.remove("theme-light", "theme-dark");
    const { colorScheme } = game.settings.get("core", "uiConfig");
    if ( colorScheme.interface ) this.element.classList.add("themed", `theme-${colorScheme.interface}`);
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreateEntry(event, target) {
    // If this is an Adventure, use the Adventure Exporter application.
    if ( this.documentName === "Adventure" ) {
      const pack = this.collection.collection;
      const name = Adventure$1.implementation.defaultName({ pack });
      const adventure = new Adventure$1.implementation({ name }, { pack });
      return new CONFIG.Adventure.exporterClass({document: adventure}).render({force: true});
    }
    return super._onCreateEntry(event, target);
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @override */
  _canDragDrop(selector) {
    return this.collection.testUserPermission(game.user, DOCUMENT_OWNERSHIP_LEVELS.OWNER);
  }

  /* -------------------------------------------- */


  /** @override */
  _createDroppedEntry(entry, updates={}) {
    const doc = entry.clone(updates, {keepId: true});
    return this.collection.importDocument(doc);
  }

  /* -------------------------------------------- */

  /** @override */
  _entryAlreadyExists(entry) {
    return (entry.collection === this.collection) && this.collection.index.has(entry.id);
  }

  /* -------------------------------------------- */

  /** @override */
  _getEntryDragData(entryId) {
    return {
      type: this.documentName,
      uuid: this.collection.getUuid(entryId)
    };
  }
}

/**
 * A Dialog subclass that allows the user to configure export options for a Folder
 */
class FolderExport extends DialogV2 {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "folder-export",
    position: {width: 485}
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.element.querySelector("select[name=pack]").addEventListener("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.querySelector("select[name=folder]");
    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 = foundry.applications.handlebars.selectOptions(folders, {hash: {
      blank: "",
      valueAttr: "id",
      labelAttr: "name"
    }});
  }
}

/** @import {ApplicationConfiguration} from "../../_types.mjs"; */

/**
 * @typedef FrameViewerConfiguration
 * @property {string} url The initial URL to navigate to
 */

/**
 * A simple window application which shows the built documentation pages within an iframe
 * @extends ApplicationV2<ApplicationConfiguration & FrameViewerConfiguration>
 * @deprecated since V13
 */
class FrameViewer extends ApplicationV2 {

  constructor(...args) {
    super(...args);
    const warning = "FrameViewer has been deprecated with no replacement.";
    foundry.utils.logCompatibilityWarning(warning, {since: 13, until: 15, once: true});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "frame-viewer",
    classes: ["theme-dark"],
    window: {icon: "fa-solid fa-browser"},
    url: undefined
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    const position = options.position;
    position.height = window.innerHeight * 0.9;
    position.width = Math.min(window.innerWidth * 0.9, 1200);
    position.top = (window.innerHeight - position.height) / 2;
    position.left = (window.innerWidth - position.width) / 2;
  }

  /* -------------------------------------------- */

  /**
   * Create the iframe and set its `src`.
   * @returns {HTMLIFrameElement}
   * @override
   */
  _renderHTML(_context, options) {
    const iframe = document.createElement("iframe");
    iframe.src = this.options.url;
    return iframe;
  }

  /* -------------------------------------------- */

  /** @override */
  _replaceHTML(iframe, content) {
    content.replaceChildren(iframe);
  }
}

/**
 * @import {ApplicationClickAction} from "@client/applications/_types.mjs";
 */

class InvitationLinks extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "invitation-links",
    position: {width: 420},
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-wifi",
      title: "INVITATIONS.Title"
    },
    actions: {
      copyLink: InvitationLinks.#onCopyLink,
      recheckInternet: InvitationLinks.#onRecheckInternet,
      showLink: InvitationLinks.#onShowLink,
      hideLink: InvitationLinks.#onHideLink
    }
  };

  /** @override */
  static PARTS = {
    body: {template: "templates/sidebar/apps/invitation-links.hbs", root: true}
  };

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext() {
    const 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, rootId: this.id};
  }

  /* -------------------------------------------- */

  /**
   * Copy the local or public URL to the server
   * @this {InvitationLinks}
   * @type {ApplicationClickAction}
   */
  static #onCopyLink(_event, target) {
    target.select();
    game.clipboard.copyPlainText(target.value);
    ui.notifications.info("INVITATIONS.Copied", {localize: true});
  }

  /* -------------------------------------------- */

  /**
   * Hide the local or public URL to the server
   * @this {InvitationLinks}
   * @type {ApplicationClickAction}
   */
  static #onRecheckInternet(event, target) {
    event.preventDefault();
    const icon = target.querySelector("i");
    icon.className = "fa-solid fa-arrows-rotate fa-pulse";
    setTimeout(() => {
      game.socket.emit("refreshAddresses", addresses => {
        game.data.addresses = addresses;
        this.render();
      });
    }, 250);
  }

  /* -------------------------------------------- */

  /**
   * Show the local or public URL to the server
   * @this {InvitationLinks}
   * @type {ApplicationClickAction}
   */
  static #onShowLink(_event, target) {
    const icon = target.querySelector("i");
    const input = this.element.querySelector(`#${this.id}-internet`);
    target.dataset.action = "hideLink";
    icon.classList.replace("fa-eye", "fa-eye-slash");
    input.type = "text";
  }

  /* -------------------------------------------- */

  /**
   * Hide the local or public URL to the server
   * @this {InvitationLinks}
   * @type {ApplicationClickAction}
   */
  static #onHideLink(_event, target) {
    const icon = target.querySelector("i");
    const input = this.element.querySelector(`#${this.id}-internet`);
    target.dataset.action = "showLink";
    icon.classList.replace("fa-eye-slash", "fa-eye");
    input.type = "password";
  }
}

/**
 * @import {KeybindingAction, KeyboardEventContext} from "@client/_types.mjs";
 */

/**
 * A set of helpers and management functions for dealing with user input from keyboard events.
 * {@link https://keycode.info/}
 * @see {@link foundry.Game#keyboard}
 */
class KeyboardManager {
  constructor() {
    if ( game.keyboard ) throw new Error("You may not re-construct the singleton game.keyboard manager");
    this.#reset();
  }

  /* -------------------------------------------- */

  /**
   * Begin listening to keyboard events.
   * @internal
   */
  _activateListeners() {
    KeyboardManager.#universalMode = game.settings.get("core", "universalKeybindings");
    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();

  /* -------------------------------------------- */

  /**
   * Is logical keybindings active?
   * @type {boolean}
   */
  static get isUniversalMode() {
    return this.#universalMode;
  }
  static #universalMode;

  /* -------------------------------------------- */

  /**
   * 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: "⬅",
      ArrowRight: "➡",
      ArrowUp: "⬆",
      ArrowDown: "⬇",
      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: "/"
    };
  })();

  /**
   * Matches any single graphic Unicode code-point (letters, digits, punctuation, symbols, including emoji).
   * Non-printable identifiers like *ArrowLeft*, *ShiftLeft*, *Dead* never match.
   * @type {RegExp}
   */
  static PRINTABLE_CHAR_REGEX = new RegExp('^[\\p{L}\\p{N}\\p{P}\\p{S}]$', 'u');

  /* -------------------------------------------- */

  /**
   * Canonical identifier for a key press.
   * @param {KeyboardEvent} event
   * @returns {string}
   */
  static translateKey(event) {
    const {key="", code} = event;

    // Physical to Logical
    if ( this.isUniversalMode && (key.length === 1) && KeyboardManager.PRINTABLE_CHAR_REGEX.test(key) ) {
      return key.toUpperCase();
    }
    return code;
  }

  /* -------------------------------------------- */

  /**
   * Determines whether an `HTMLElement` currently has focus, which may influence keybinding actions.
   *
   * An element is considered to have focus if:
   * 1. It has a `dataset.keyboardFocus` attribute explicitly set to `"true"` or an empty string (`""`).
   * 2. It is an `<input>`, `<select>`, or `<textarea>` element, all of which inherently accept keyboard input.
   * 3. It has the `isContentEditable` property set to `true`, meaning it is an editable element.
   * 4. It is a `<button>` element inside a `<form>`, which suggests interactive use.
   *
   * An element is considered **not** focused if:
   * 1. There is no currently active element (`document.activeElement` is not an `HTMLElement`).
   * 2. It has a `dataset.keyboardFocus` attribute explicitly set to `"false"`.
   *
   * If none of these conditions are met, the element is assumed to be unfocused.
   * @type {boolean}
   */
  get hasFocus() {
    const focused = document.activeElement;
    if ( !(focused instanceof HTMLElement) ) return false;                           // No focused element
    if ( ["", "true"].includes(focused.dataset.keyboardFocus) ) return true;         // Explicit true
    if ( focused.dataset.keyboardFocus === "false" ) return false;                   // Explicit false
    if ( ["INPUT", "SELECT", "TEXTAREA"].includes(focused.tagName) ) return true;    // Text input elements
    if ( focused.isContentEditable ) return true;                                    // Text input elements
    if ( focused.tagName === "BUTTON" ) return !!focused.form;                       // Buttons in forms
    return false;
  }

  /* -------------------------------------------- */
  /*  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
   * @returns {KeyboardEventContext} The standardized context of the event
   */
  static getKeyboardEventContext(event, up=false) {
    const context = {
      event: event,
      key: event.code,
      logicalKey: KeyboardManager.translateKey(event),
      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) {
    if ( modifier in KeyboardManager.MODIFIER_KEYS ) modifier = KeyboardManager.MODIFIER_KEYS[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));
  }

  /* ----------------------------------------- */

  /**
   * Given a keyboard-event context, return every registered keybinding that matches it (may be empty).
   * @param {KeyboardEventContext} context
   * @returns {KeybindingAction[]}
   * @internal
   */
  static _getMatchingActions(context) {
    const activeKeys = game.keybindings.activeKeys;
    const debug = CONFIG.debug.keybindings;

    // Helper: perform one Map lookup, filter with #testContext, log the result
    const lookup = (key, label) => {
      const list = (activeKeys.get(key) ?? []).filter(a => KeyboardManager.#testContext(a, context));
      if ( debug ) {
        console.log(`[Keybinds] ${label}: "${key}" → ${list.length} hit(s)`);
        console.dir(list);
      }
      return list;
    };

    if ( KeyboardManager.isUniversalMode ) {
      // Logical mode
      const k = context.logicalKey;
      if ( (k.length === 1) && KeyboardManager.PRINTABLE_CHAR_REGEX.test(k) ) {
        // Test Digit first
        if ( "0123456789".includes(k) ) {
          const hits = lookup(`Digit${k}`, "char=>Digit");
          if ( hits.length ) return hits;
        }
        // Then Key
        const hits = lookup(`Key${k.toUpperCase()}`, "char=>Key");
        if ( hits.length ) return hits;
      }
    }

    // Physical mode (legacy)
    return lookup(context.key, "direct");
  }

  /* -------------------------------------------- */

  /**
   * 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?
   */
  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 ( const [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
   * @returns {boolean}                        Returns true if the keybind was consumed
   */
  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
   */
  #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("CONTROL"),
        shiftKey: this.isModifierActive("SHIFT"),
        altKey: this.isModifierActive("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
   */
  #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.
    const 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();
  }
}

/**
 * @import ControlsConfig from "@client/applications/sidebar/apps/controls-config.mjs";
 * @import InteractionLayer from "../../canvas/layers/base/interaction-layer.mjs";
 * @import {
 *   KeybindingAction,
 *   KeybindingActionBinding,
 *   KeybindingActionConfig,
 *   KeyboardEventContext
 * } from "@client/_types.mjs";
 */

/**
 * 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 foundry.Game#keybindings}
 * @see {@link ControlsConfig}
 */
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;
  }

  static MOVEMENT_DIRECTIONS = {
    UP: "up",
    LEFT: "left",
    DOWN: "down",
    RIGHT: "right",
    DESCEND: "descend",
    ASCEND: "ascend"
  };

  static ZOOM_DIRECTIONS = {
    IN: "in",
    OUT: "out"
  };

  /* -------------------------------------------- */

  /**
   * A count of how many registered keybindings there are
   * @type {number}
   */
  #registered = 0;

  /* -------------------------------------------- */

  /**
   * A timestamp which tracks the last time a pan operation was performed
   * @type {number}
   */
  #moveTime = 0;

  /* -------------------------------------------- */

  /**
   * 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 ( const k of Array.from(this.bindings.keys()) ) {
      if ( !this.actions.has(k) ) this.bindings.delete(k);
    }

    // Register bindings for all actions
    for ( const [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 ( const [key, action] of this.actions ) {
      const bindings = this.bindings.get(key);
      for ( const binding of bindings ) {
        if ( !binding ) continue;
        if ( !this.activeKeys.has(binding.key) ) this.activeKeys.set(binding.key, []);
        const 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 = ClientKeybindings.#validateBindings(data.uneditable ?? []);
    data.editable = ClientKeybindings.#validateBindings(data.editable ?? []);
    data.repeat = data.repeat ?? false;
    data.reservedModifiers = ClientKeybindings.#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 = ClientKeybindings.#validateBindings(bindings);

    // Verify no reserved Modifiers were set as Keys
    for ( const 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
   */
  static #validateBindings(values) {
    if ( !(values instanceof Array) ) throw new Error(game.i18n.localize("KEYBINDINGS.MustBeArray"));
    for ( const 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
   */
  static #validateModifiers(keys) {
    const modifiers = [];
    for ( let key of keys ) {
      if ( key in KeyboardManager.MODIFIER_KEYS ) key = KeyboardManager.MODIFIER_KEYS[key]; // Backwards-compatiblity
      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 {Pick<KeybindingAction, "precedence"|"order">} a The first Keybinding Action
   * @param {Pick<KeybindingAction, "precedence"|"order">} 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", "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.#onSelectAll
    });
    game.keybindings.register("core", "undo", {
      name: "KEYBINDINGS.Undo",
      uneditable: [
        {key: "KeyZ", modifiers: [CONTROL]}
      ],
      onDown: ClientKeybindings.#onUndo
    });
    game.keybindings.register("core", "cut", {
      name: "KEYBINDINGS.Cut",
      uneditable: [
        {key: "KeyX", modifiers: [CONTROL]}
      ],
      onDown: ClientKeybindings.#onCut
    });
    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", "ruler", {
      name: "KEYBINDINGS.Ruler",
      editable: [
        {key: "KeyR"}
      ],
      onDown: ClientKeybindings.#onToggleRuler
    });
    game.keybindings.register("core", "unconstrainedMovement", {
      name: "KEYBINDINGS.UnconstrainedMovement",
      editable: [
        {key: "KeyU"}
      ],
      onDown: ClientKeybindings.#onToggleUnconstrainedMovement
    });
    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: "KeyE"}],
      onUp: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.ASCEND]),
      onDown: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.ASCEND]),
      reservedModifiers: [CONTROL, SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "zoomOut", {
      name: "KEYBINDINGS.ZoomOut",
      uneditable: [
        {key: "NumpadSubtract"}
      ],
      editable: [{key: "KeyQ"}],
      onUp: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DESCEND]),
      onDown: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DESCEND]),
      reservedModifiers: [CONTROL, SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "rulerWaypoint", {
      name: "KEYBINDINGS.RulerWaypoint",
      editable: [{key: "KeyF"}],
      onDown: context => this.#onRulerWaypoint(context),
      reservedModifiers: [SHIFT]
    });
    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
   */
  static #onSelectAll(context) {
    if ( !canvas.ready ) return false;
    const layer = canvas.activeLayer;
    if ( !(layer instanceof foundry.canvas.layers.InteractionLayer) ) return false;
    return layer._onSelectAllKey(context.event);
  }

  /* -------------------------------------------- */

  /**
   * Handle Cycle View actions
   * @param {KeyboardEventContext} context    The context data of the event
   */
  static #onCycleView(context) {
    if ( !canvas.ready ) return false;
    const layer = canvas.activeLayer;
    if ( !(layer instanceof foundry.canvas.layers.InteractionLayer) ) return false;
    return layer._onCycleViewKey(context.event);
  }

  /* -------------------------------------------- */

  /**
   * Handle Dismiss actions
   * @param {KeyboardEventContext} context    The context data of the event
   * @returns {Promise<boolean>}
   */
  static async #onDismiss(context) {

    // Cancel current drag workflow
    if ( canvas.currentMouseManager ) {
      canvas.currentMouseManager.interactionData.cancelled = true;
      canvas.currentMouseManager.cancel();
      return true;
    }

    // Save fog of war if there are pending changes
    if ( canvas.ready ) canvas.fog.commit();

    // Case 1 - close the main menu
    if ( ui.menu?.rendered ) {
      await ui.menu.toggle();
      return true;
    }

    // Case 2 - dismiss an open context menu
    if ( ui.context?.element ) {
      await ui.context.close();
      return true;
    }

    // Case 3 - dismiss an open Tour
    if ( foundry.nue.Tour.tourInProgress ) {
      foundry.nue.Tour.activeTour.exit();
      return true;
    }

    // Case 4 - 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 5 (GM) - release controlled objects (if not in a preview)
    if ( game.view !== "game" ) return false;
    const layer = canvas.activeLayer;
    if ( layer instanceof foundry.canvas.layers.InteractionLayer ) {
      if ( layer._onDismissKey(context.event) ) return true;
    }

    // Case 6 - open 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
   */
  static #onToggleCharacterSheet(context) {
    return game.toggleCharacterSheet();
  }

  /* -------------------------------------------- */

  /**
   * Handle action to target the currently hovered token.
   * @param {KeyboardEventContext} context    The context data of the event
   */
  static #onTarget(context) {
    if ( !canvas.ready ) return false;
    const layer = canvas.activeLayer;
    if ( !(layer instanceof foundry.canvas.layers.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 toggle the ruler tool.
   * @param {KeyboardEventContext} context    The context data of the event
   */
  static #onToggleRuler(context) {
    if ( !canvas.ready ) return false;
    const layer = canvas.activeLayer;
    if ( !(layer instanceof foundry.canvas.layers.TokenLayer) ) return false;
    ui.controls.activate({tool: game.activeTool !== "ruler" ? "ruler" : "select"});
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Handle action to toggle Unconstrained Movement.
   * @param {KeyboardEventContext} context    The context data of the event
   */
  static #onToggleUnconstrainedMovement(context) {
    const active = game.settings.get("core", "unconstrainedMovement");
    game.settings.set("core", "unconstrainedMovement", !active);
    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;
    const layer = canvas.activeLayer;
    if ( !(layer instanceof foundry.canvas.layers.PlaceablesLayer) ) return false;
    return layer._sendToBackOrBringToFront(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;
    const layer = canvas.activeLayer;
    if ( !(layer instanceof foundry.canvas.layers.PlaceablesLayer) ) return false;
    return layer._sendToBackOrBringToFront(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle DELETE Keypress Events
   * @param {KeyboardEventContext} context    The context data of the event
   */
  static #onDelete(context) {
    // Remove hotbar Macro
    if ( ui.hotbar._hover ) {
      game.user.assignHotbarMacro(null, ui.hotbar._hover);
      return true;
    }

    // Delete placeables from Canvas layer
    if ( !canvas.ready ) return false;
    const layer = canvas.activeLayer;
    if ( !(layer instanceof foundry.canvas.layers.InteractionLayer) ) return false;
    return layer._onDeleteKey(context.event);
  }

  /* -------------------------------------------- */

  /**
   * 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
   */
  #handleMovement(context, layer) {
    if ( !this.moveKeys.size ) 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;
    let dz = 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;
    if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DESCEND) ) dz -= 1;
    if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.ASCEND) ) dz += 1;

    // If measuring distance, change elevation of ruler
    const ruler = canvas.controls.ruler;
    if ( ruler.active ) {
      if ( dz ) ruler._changeDragElevation(dz, {precise: context.isShift});
      return;
    }

    // If dragging token, change elevation of dragged token
    if ( (layer instanceof foundry.canvas.layers.TokenLayer) && layer._draggedToken ) {
      if ( dz ) layer._draggedToken._changeDragElevation(dz, {precise: context.isShift});
      return;
    }

    // Perform the shift or rotation
    layer.moveMany({dx, dy, dz, rotate: context.isShift});
  }

  /* -------------------------------------------- */

  /**
   * Handle panning the canvas using CTRL + directional keys
   * @param {KeyboardEventContext} context        The context data of the event
   */
  #handleCanvasPan(context) {

    // Determine movement offsets
    let dx = 0;
    let dy = 0;
    let dz = 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.DESCEND)) dz -= 1;
    if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT)) dx += 1;
    if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN)) dy += 1;
    if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.ASCEND)) dz += 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),
      scale: canvas.stage.scale.x * (1 + ((context.isShift ? 0.05 : 0.25) * dz)),
      duration: 100
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle Pause Action.
   * @param {KeyboardEventContext} context    The context data of the event
   */
  static #onPause(context) {
    game.togglePause(!game.paused, {broadcast: true});
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Handle Highlight action
   * @param {KeyboardEventContext} context    The context data of the event
   */
  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
   */
  #onPan(context, movementDirections) {

    // Case 1: Check for Tour
    if ( foundry.nue.Tour.tourInProgress && !context.repeat && !context.up ) {
      foundry.nue.Tour.onMovementAction(movementDirections);
      return true;
    }

    // Case 2: Check for Canvas
    if ( !canvas.ready ) return false;

    // Remove Keys on Up
    if ( context.up ) {
      for ( const 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 ( const d of movementDirections ) {
      this.moveKeys.add(d);
    }

    // Handle canvas pan using CTRL
    if ( context.isControl ) {

      // Only the uneditable keybindings allow panning/zooming
      if ( !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Numpad1", "Numpad2", "Numpad3", "Numpad4", "Numpad5", "Numpad6",
        "Numpad7", "Numpad8", "Numpad9", "NumpadAdd", "NumpadSubtract"].includes(context.key) ) return false;

      this.#handleCanvasPan(context);
      return true;
    }

    // Delay 50ms before shifting tokens in order to capture diagonal movements
    const layer = canvas.activeLayer;
    if ( (layer === canvas.tokens) || (layer === canvas.tiles) || (layer === canvas.drawings) ) {
      if ( delta < 100 ) return true; // Throttle keyboard movement once per 100ms
      setTimeout(() => this.#handleMovement(context, layer), 50);
    }
    this.#moveTime = now;
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Handle Waypoint action
   * @param {KeyboardEventContext} context  The context data of the event
   */
  #onRulerWaypoint(context) {
    if ( !canvas.ready ) return false;
    const ruler = canvas.controls.ruler;
    if ( ruler.active ) {
      ruler._addDragWaypoint(canvas.mousePosition, {snap: !context.isShift});
      return true;
    }
    const layer = canvas.activeLayer;
    if ( !(layer instanceof foundry.canvas.layers.TokenLayer) ) return false;
    const token = layer._draggedToken;
    if ( !token ) return false;
    if ( token.ruler ) token._addDragWaypoint(canvas.mousePosition, {snap: !context.isShift});
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Handle Macro executions
   * @param {KeyboardEventContext} context  The context data of the event
   * @param {number} number                 The numbered macro slot to execute
   */
  static #onMacroExecute(context, number) {
    const slot = ui.hotbar.slots.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
   */
  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
   */
  static #onCopy(context) {
    if ( window.getSelection().toString() !== "" ) return false;
    if ( !canvas.ready ) return false;
    const layer = canvas.activeLayer;
    if ( !(layer instanceof foundry.canvas.layers.InteractionLayer) ) return false;
    return layer._onCopyKey(context.event);
  }

  /* -------------------------------------------- */

  /**
   * Handle action to cut data to clipboard
   * @param {KeyboardEventContext} context    The context data of the event
   */
  static #onCut(context) {
    if ( window.getSelection().toString() !== "" ) return false;
    if ( !canvas.ready ) return false;
    const layer = canvas.activeLayer;
    if ( !(layer instanceof foundry.canvas.layers.InteractionLayer) ) return false;
    return layer._onCutKey(context.event);
  }

  /* -------------------------------------------- */

  /**
   * Handle Paste action
   * @param {KeyboardEventContext} context    The context data of the event
   */
  static #onPaste(context) {
    if ( !canvas.ready ) return false;
    const layer = canvas.activeLayer;
    if ( !(layer instanceof foundry.canvas.layers.InteractionLayer) ) return false;
    return layer._onPasteKey(context.event);
  }

  /* -------------------------------------------- */

  /**
   * Handle Undo action
   * @param {KeyboardEventContext} context    The context data of the event
   */
  static #onUndo(context) {
    if ( !canvas.ready ) return false;
    const layer = canvas.activeLayer;
    if ( !(layer instanceof foundry.canvas.layers.InteractionLayer) ) return false;
    return layer._onUndoKey(context.event);
  }

  /* -------------------------------------------- */

  /**
   * Bring the chat window into view and focus the input
   * @param {KeyboardEventContext} context    The context data of the event
   * @returns {boolean}
   */
  static #onFocusChat(context) {
    ui.chat.activate();
    document.getElementById("chat-message")?.focus();
    return true;
  }
}

/**
 * A singleton helper class to manage requesting clipboard permissions.
 * Provoides common functionality for working with the clipboard.
 * @see {@link foundry.Game#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");
  }
}

/**
 * @import {ConnectedGamepad} from "@client/_types.mjs";
 */

/**
 * Management class for Gamepad events.
 */
class GamepadManager {

  /**
   * The connected Gamepads
   * @type {Map<string, ConnectedGamepad>}
   */
  #connectedGamepads = new Map();

  /**
   * A bound polling function.
   */
  #gamepadPoller;

  /**
   * 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
   */
  #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()});
    this.#gamepadPoller ||= setInterval(() => this.#pollGamepads(), GamepadManager.GAMEPAD_POLLER_INTERVAL_MS);
    this.#pollGamepads(); // Immediately poll to try and capture the action that connected the Gamepad
  }

  /* -------------------------------------------- */

  /**
   * Handles a Gamepad Disconnect event, removing it from consideration for polling
   * @param {GamepadEvent} event    The originating Event
   */
  #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.
   */
  #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 ( const gamepad of navigator.getGamepads() ) {
      if ( !gamepad || !this.#connectedGamepads.has(gamepad?.id) ) continue;
      const id = gamepad.id;
      const 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
   */
  #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);
    document.querySelector(".binding-input:focus")?.dispatchEvent(event);
  }
}

/**
 * Management class for Mouse events.
 * @see {@link foundry.Game#mouse}
 */
class MouseManager {
  constructor() {
    if ( game.mouse ) throw new Error("You may not re-construct the singleton MouseManager instance.");
  }

  /**
   * The timestamp of the last mousewheel event.
   * @type {number}
   */
  #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
   */
  #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 = game.keyboard.isModifierActive("CONTROL"); // We cannot trust event.ctrlKey because of touchpads
    const isShift = event.shiftKey;

    // Case 1 - active Ruler
    const ruler = canvas.controls.ruler;
    if ( ruler.active && (isCtrl || isShift) ) return ruler._onMouseWheel(event);

    // Case 2 - Token is dragged
    const draggedToken = canvas.tokens._draggedToken;
    if ( draggedToken && (isCtrl || isShift) ) return draggedToken._onDragMouseWheel(event);

    // Case 3 - rotate placeable objects
    const layer = canvas.activeLayer;
    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 4 - zoom the canvas
    canvas._onMouseWheel(event);
  }
}

/**
 * @import {TooltipDirection} from "@client/helpers/interaction/tooltip-manager.mjs";
 */

/**
 * @typedef 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 {TooltipDirection} [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.
 * @property {string} [sidebarTab]            Activates a particular sidebar tab. Usable in `SidebarTour` instances.
 * @property {string} [layer]                 Activates a particular canvas layer and its respective control group.
 *                                            Usable in `CanvasTour` instances.
 * @property {string} [tool]                  Activates a particular tool. Usable in `CanvasTour` instances.
 */

/**
 * @typedef 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.
 */
class Tour {
  /**
   * Construct a Tour by providing a configuration.
   * @param {TourConfig} config           The configuration of the Tour
   * @param {object} [options]            Additional options for configuring the tour
   * @param {string} [options.id]           A tour ID that supercedes TourConfig#id
   * @param {string} [options.namespace]    A tour namespace that supercedes TourConfig#namespace
   */
  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;

  static STATUS = Object.freeze({
    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 {string[]} movementDirections           The Directions being moved in
   * @returns {true|void}
   */
  static onMovementAction(movementDirections) {
    const ClientKeybindings = foundry.helpers.interaction.ClientKeybindings;
    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;
  }

  #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 {TourStatus}
   */
  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 ( Number.isFinite(this.#stepIndex) && (this.#stepIndex !== stepIndex) ) {
      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;
    await this.#saveProgress();

    // If the TourManager is active, update the UI
    await foundry.applications.instances.get("tours-management")?.render();

    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 foundry.applications.api.DialogV2.confirm({
        window: {title: "TOURS.SuggestedTitle"},
        content: game.i18n.format("TOURS.SuggestedDescription", {
          currentTitle: this.title,
          nextTitle: suggestedTour.title
        }),
        yes: {
          callback: () => suggestedTour.start(),
          default: true
        },
        no: {
          callback: () => foundry.applications.instances.get("tours-management")?.maximize()
        }
      });
    }

    // 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 route = foundry.utils.getRoute(filepath, {prefix: foundry.ROUTE_PREFIX});
    const json = await foundry.utils.fetchJsonWithTimeout(route);
    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 foundry.applications.handlebars.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, {
        html: content,
        cssClass: "tour themed theme-dark",
        direction: step.tooltipDirection
      });
    }
    else {
      // Display a general mid-screen Step
      const wrapper = document.createElement("aside");
      wrapper.innerHTML = content;
      wrapper.classList.add("tour-center-step", "tour", "themed", "theme-dark");
      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 ( const 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
   */
  #onButtonClick(event, buttons) {
    event.preventDefault();

    // Disable all the buttons to prevent double-clicks
    for ( const 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.
   * @returns {Promise<void>}
   */
  async #saveProgress() {
    const progress = game.settings.get("core", "tourProgress");
    if ( !(this.namespace in progress) ) progress[this.namespace] = {};
    progress[this.namespace][this.id] = this.#stepIndex;
    await game.settings.set("core", "tourProgress", progress);
  }

  /* -------------------------------------------- */

  /**
   * Returns the User's current progress of this Tour
   * @returns {null|number}
   */
  #loadProgress() {
    const 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();
  }
}

/**
 * @typedef {(typeof Tour.STATUS)[keyof typeof Tour.STATUS]} TourStatus
 */

/**
 * A singleton Tooltip Manager class responsible for rendering and positioning a dynamic tooltip element which is
 * accessible as `game.tooltip`.
 *
 * @see {@link foundry.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 {
  constructor() {
    if ( game.tooltip ) throw new Error("You may not re-construct the singleton 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.
    const dataset = element.dataset;
    const tooltip = dataset.tooltipHtml || dataset.tooltipText || dataset.tooltip;
    if ( (tooltip === undefined) || ((dataset.tooltip === "") && !element.ariaLabel) ) {
      // 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
    const options = tooltip ? {} :  {text: element.ariaLabel};
    if ( this.#active ) {
      this.activate(element, options);
      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, options);
    }, 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 element's `data-tooltip-text` attribute if present and
   *                                   otherwise from its `data-tooltip` attribute. The `data-tooltip` text will be
   *                                   automatically localized. If `data-tooltip` is not a localization string, the
   *                                   text is rendered as HTML (cleaned). Both `options.text` and `data-tooltip-text`
   *                                   do not support HTML. It is not recommended to use `data-tooltip` for plain text
   *                                   and HTML as it could cause an unintentional localization. Instead use
   *                                   `data-tooltip-text` and `data-tooltip-html`, respectively.
   * @param {TooltipDirection} [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=false]  An optional boolean to lock the tooltip after creation. Defaults to false.
   * @param {HTMLElement|string} [options.html]     Explicit HTML to inject into the tooltip rather than using
   *                                                tooltip text. If passed as a string, the HTML string is cleaned with
   *                                                {@link foundry.utils.cleanHTML}. An explicit HTML string may also
   *                                                be set with the `data-tooltip-html` attribute on the element.
   */
  activate(element, options={}) {
    let {text, direction, cssClass, locked=false, html, content} = options;
    if ( content && !html ) {
      foundry.utils.logCompatibilityWarning("The content option has been deprecated in favor of the html option",
        {since: 13, until: 15, once: true});
      html = content;
    }
    if ( text && html ) throw new Error("Cannot provide both text and html 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");
    html ||= element.dataset.tooltipHtml;
    if ( html ) {
      if ( typeof html === "string" ) this.tooltip.innerHTML = foundry.utils.cleanHTML(html);
      else {
        this.tooltip.innerHTML = ""; // Clear existing HTML
        this.tooltip.appendChild(html);
      }
    }
    else {
      text ||= element.dataset.tooltipText;
      if ( text ) this.tooltip.textContent = text;
      else {
        text = element.dataset.tooltip;
        // Localized message should be safe
        if ( game.i18n.has(text) ) this.tooltip.innerHTML = game.i18n.localize(text);
        else this.tooltip.innerHTML = foundry.utils.cleanHTML(text);
      }
    }

    // Activate display of the tooltip
    this.tooltip.removeAttribute("class");
    this.tooltip.classList.add("active", "themed", "theme-dark");
    this.tooltip.showPopover();
    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");
    this.tooltip.addEventListener("transitionend", () => {
      if ( !this.#active ) this.tooltip.hidePopover();
    }, { once: true });

    // 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);
    clone.showPopover();
    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 {string} [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 {TooltipDirection} 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();
    const 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 ( const 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}`);
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Retrieve the configured TooltipManager implementation.
   * @type {typeof TooltipManager}
   */
  static get implementation() {
    let Class = CONFIG.ux.TooltipManager;
    if ( !foundry.utils.isSubclass(Class, TooltipManager) ) {
      console.warn("Configured TooltipManager override must be a subclass of TooltipManager.");
      Class = TooltipManager;
    }
    return Class;
  }
}

/**
 * @typedef {keyof typeof TooltipManager.TOOLTIP_DIRECTIONS} TooltipDirection
 */

var _module$E = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ClientKeybindings: ClientKeybindings,
  ClipboardHelper: ClipboardHelper,
  GamepadManager: GamepadManager,
  KeyboardManager: KeyboardManager,
  MouseManager: MouseManager,
  TooltipManager: TooltipManager
});

/**
 * @import {ApplicationClickAction} from "@client/applications/_types.mjs"
 * @import {KeybindingAction, KeybindingActionBinding, KeybindingActionConfig} from "@client/_types.mjs"
 */

/**
 * View and edit keybinding and (readonly) mouse actions.
 */
class ControlsConfig extends CategoryBrowser {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "controls-config",
    window: {
      title: "KEYBINDINGS.Title",
      icon: "fa-solid fa-gamepad",
      resizable: true
    },
    position: {
      width: 780,
      height: 680
    },
    actions: {
      addBinding: ControlsConfig.#onAddBinding,
      cancelEdit: ControlsConfig.#onCancelEdit,
      deleteBinding: ControlsConfig.#onDeleteBinding,
      editBinding: ControlsConfig.#onEditBinding,
      resetDefaults: ControlsConfig.#onResetDefaults,
      saveBinding: ControlsConfig.#onSaveBinding
    },
    subtemplates: {
      category: "templates/sidebar/apps/controls/category.hbs",
      sidebarFooter: "templates/category-browser/reset.hbs"
    }
  };

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    bindingInput: {template: "templates/sidebar/apps/controls/binding-input.hbs"}
  };

  /**
   * Faux "pointer bindings" for displaying as a readonly category
   * @type {readonly [id: string, name: string, parts: string[], gmOnly?: boolean][]}
   */
  static POINTER_CONTROLS = Object.freeze([
    ["canvas-select", "CONTROLS.CanvasSelect", ["LeftClick"]],
    ["canvas-select-many", "CONTROLS.CanvasSelectMany", ["Shift", "LeftClick"]],
    ["canvas-drag", "CONTROLS.CanvasLeftDrag", ["LeftClick", "Drag"]],
    ["canvas-select-cancel", "CONTROLS.CanvasSelectCancel", ["RightClick"]],
    ["canvas-pan-mouse", "CONTROLS.CanvasPan", ["RightClick", "Drag"]],
    ["canvas-zoom", "CONTROLS.CanvasZoom", ["MouseWheel"]],
    ["ruler-place-waypoint", "CONTROLS.RulerPlaceWaypoint", [KeyboardManager.CONTROL_KEY_STRING, "LeftClick"]],
    ["ruler-remove-waypoint", "CONTROLS.RulerRemoveWaypoint", ["RightClick"]],
    ["object-sheet", "CONTROLS.ObjectSheet", ["LeftClick2"]],
    ["object-hud", "CONTROLS.ObjectHUD", ["RightClick"]],
    ["object-config", "CONTROLS.ObjectConfig", ["RightClick2"]],
    ["object-drag", "CONTROLS.ObjectDrag", ["LeftClick", "Drag"]],
    ["object-no-snap", "CONTROLS.ObjectNoSnap", ["Drag", "Shift", "Drop"]],
    ["object-drag-cancel", "CONTROLS.ObjectDragCancel", ["RightClickDuringDrag"]],
    ["object-rotate-slow", "CONTROLS.ObjectRotateSlow", [KeyboardManager.CONTROL_KEY_STRING, "MouseWheel"]],
    ["object-rotate-fast", "CONTROLS.ObjectRotateFast", ["Shift", "MouseWheel"]],
    ["place-hidden-token", "CONTROLS.TokenPlaceHidden", ["Alt", "Drop"], true],
    ["token-target-mouse", "CONTROLS.TokenTarget", ["RightClick2"]],
    ["canvas-ping", "CONTROLS.CanvasPing", ["LongPress"]],
    ["canvas-ping-alert", "CONTROLS.CanvasPingAlert", ["Alt", "LongPress"]],
    ["canvas-ping-pull", "CONTROLS.CanvasPingPull", ["Shift", "LongPress"], true],
    ["tooltip-lock", "CONTROLS.TooltipLock", ["MiddleClick"]],
    ["tooltip-dismiss", "CONTROLS.TooltipDismiss", ["RightClick"]]
  ]);

  /**
   * A reference record of possible categories
   * @type {Record<string, {id: string, label: string}}
   */
  static #ENTRY_CATEGORIES = {
    core: {id: "core", label: "KEYBINDINGS.CoreKeybindings"},
    "core-pointer": {id: "core-pointer", label: "KEYBINDINGS.CoreMouse"}
  };

  /* -------------------------------------------- */

  /**
   * Transform an action binding into a human-readable string representation.
   * @param {KeybindingActionBinding} binding
   * @returns {string}
   */
  static humanizeBinding(binding) {
    const key = binding.logicalKey ?? binding.key;
    const stringParts = binding.modifiers?.reduce((parts, part) => {
      if ( KeyboardManager.MODIFIER_CODES[part]?.includes(key) ) return parts;
      const display = KeyboardManager.getKeycodeDisplayString(part);
      parts.unshift(display);
      return parts;
    }, [KeyboardManager.getKeycodeDisplayString(key)]);
    return stringParts.filterJoin(" + ");
  }

  /* -------------------------------------------- */

  /**
   * A cache of categories and their entries
   * @type {Record<string, {id: string, label: string, entries: object[]}|null>}
   */
  #cachedData = null;

  /**
   * A Map of pending Edits. The Keys are bindingIds
   * @type {Map<string, KeybindingActionBinding[]>}
   */
  #pendingEdits = new Map();

  /**
   * A list formatter used for showing keybinding conflicts in tooltips
   * @type {Intl.ListFormat}
   */
  #listFormatter = game.i18n.getListFormatter();

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( !options.isFirstRender ) options.parts.findSplice(p => p === "bindingInput"); // Only needs to be rendered once
  }

  /* -------------------------------------------- */

  /**
   * @returns {Record<string, {id: string, label: string, entries: object[]}>}
   * @protected
   * @override
   */
  _prepareCategoryData() {
    if ( this.#cachedData ) return this.#cachedData;
    ControlsConfig.#ENTRY_CATEGORIES[game.system.id] ??= {id: "system", label: game.system.title};
    const keybindings = game.keybindings.actions.entries().toArray();
    this.#cachedData = [...keybindings, ...this.#getPointerControls()].reduce((categories, [actionId, action]) => {
      if ( action.restricted && !game.user.isGM ) return categories;

      // Carry over bindings for future rendering
      const ctrlString = KeyboardManager.CONTROL_KEY_STRING;
      const reservedModifiers = this.#listFormatter.format(action.reservedModifiers?.map(m => {
        return m === "Control" ? ctrlString : m.titleCase();
      }) ?? []);
      const entry = {
        id: actionId,
        precedence: action.precedence ?? 0,
        order: action.order ?? 0,
        label: game.i18n.localize(action.name),
        hint: [
          reservedModifiers ? game.i18n.format("KEYBINDINGS.ReservedModifiers", {modifiers: reservedModifiers}) : "",
          action.restricted ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
          action.hint ? foundry.utils.escapeHTML(game.i18n.localize(action.hint)) : ""
        ].filterJoin("<br>\n"),
        uneditable: action.uneditable,
        bindings: (action.bindings ?? game.keybindings.bindings.get(actionId) ?? []).map((binding, index) => {
          const uneditable = action.uneditable?.includes(binding);
          const conflicts = this.#listFormatter.format(
            this.#detectConflictingActions(actionId, action, binding).map(c => game.i18n.localize(c.name))
          );
          return {
            id: `${actionId}.binding.${index}`,
            display: ControlsConfig.humanizeBinding(binding),
            editable: !uneditable,
            conflicts: conflicts ? game.i18n.format("KEYBINDINGS.Conflict", {conflicts}) : null
          };
        })
      };
      const category = this.#categorizeEntry(action.namespace);
      categories[category.id] ??= {id: category.id, label: game.i18n.localize(category.label), entries: []};
      categories[category.id].entries.push(entry);
      return categories;
    }, {});
    for ( const entries of Object.values(this.#cachedData).map(c => c.entries) ) {
      entries.sort(ClientKeybindings._compareActions);
    }
    return this.#cachedData;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _sortCategories(a, b) {
    const categoryOrder = {core: 0, "core-pointer": 1, system: 2};
    const indexOfA = categoryOrder[a.id] ?? 3;
    const indexOfB = categoryOrder[b.id] ?? 3;
    return (indexOfA - indexOfB) || super._sortCategories(a, b);
  }

  /* -------------------------------------------- */

  /**
   * Categorize a keybinding action config according to its source (core(-mouse), system, module, or unmapped).
   * @param {string|undefined} namespace
   * @returns {{id: string, label: string}}
   */
  #categorizeEntry(namespace) {
    const CATEGORIES = ControlsConfig.#ENTRY_CATEGORIES;
    const category = CATEGORIES[namespace];
    if ( category ) return category;
    const module = game.modules.get(namespace);
    return CATEGORIES[namespace] = {id: module?.id ?? "unmapped", label: module?.title ?? "PACKAGECONFIG.Unmapped"};
  }

  /* -------------------------------------------- */

  /**
   * Add non-configurable actions representing pointer controls
   * @returns {[string, Pick<KeybindingActionConfig, "namespace"|"name"|"restricted"|"bindings"|"uneditable">][]}
   */
  #getPointerControls() {
    const namespace = "core-pointer";
    const localize = s => game.i18n.localize(`CONTROLS.${s}`);
    return ControlsConfig.POINTER_CONTROLS.map(([id, name, parts, restricted=false]) => {
      const bindings = [{key: parts.map(p => localize(p)).join(" + "), modifiers: []}];
      return [`core-mouse.${id}`, {namespace, name, restricted, bindings, uneditable: bindings}];
    });
  }

  /* -------------------------------------------- */

  /**
   * Given a Binding and its parent Action, detects other Actions that might conflict with that binding
   * @param {string} actionId The Action's fully-qualified identifier
   * @param {KeybindingActionConfig} action
   * @param {KeybindingActionBinding} binding
   * @returns {KeybindingAction[]}
   */
  #detectConflictingActions(actionId, action, binding) {

    // Uneditable Core bindings never count as conflicting
    if ( ["core", "core-pointer"].includes(action.namespace) && action.uneditable.includes(binding) ) return [];

    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 KeyboardManager._getMatchingActions(context).filter(a => a.action !== actionId);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    this.element.querySelector(".window-content").addEventListener("dblclick", this.#onDoubleClick.bind(this));
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Add a new keybinding for an action.
   * @this {ControlsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onAddBinding(event) {
    const bindingEl = event.target.closest("[data-binding-id]");
    const newBinding = this.#createBindingInput(bindingEl, {incrementId: true});
    newBinding.querySelector("button[data-action=cancelEdit]").remove();
    bindingEl.closest("ul").append(newBinding);
    newBinding.querySelector("input").focus();

    // If this is an empty binding, delete it
    if ( bindingEl.dataset.bindingId === "empty" ) bindingEl.remove();
  }

  /* -------------------------------------------- */

  /**
   * Toggle visibility of the Edit / Save UI
   * @this {ControlsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onEditBinding(event) {
    const button = event.target;
    const bindingRow = button.closest("li[data-binding-id]");
    const editingRow = this.#createBindingInput(bindingRow);
    const input = editingRow.querySelector("input");
    input.value = bindingRow.querySelector("kbd").innerText.trim();
    bindingRow.after(editingRow);
    bindingRow.hidden = true;
    input.focus();
  }

  /* -------------------------------------------- */

  /**
   * Save the new Binding value and update the display of the UI
   * @this {ControlsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onSaveBinding(event) {
    return this.#savePendingEdits();
  }

  /* -------------------------------------------- */

  /**
   * Cancel editing a keybinding.
   * @this {ControlsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onCancelEdit(event) {
    const actionId = event.target.closest("[data-action-id]").dataset.actionId;
    const bindingId = event.target.closest("[data-binding-id]").dataset.bindingId;
    const bindingIndex = Number(bindingId.split(".")[3] ?? NaN);
    this.#pendingEdits.get(actionId)?.findSplice(b => b.index === bindingIndex);
    return this.#savePendingEdits();
  }

  /**
   * Delete a keybinding for an action.
   * @this {ControlsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onDeleteBinding(event) {
    const bindingId = event.target.closest("[data-binding-id]").dataset.bindingId;
    const actionId = event.target.closest("[data-action-id]").dataset.actionId;
    const bindingIndex = Number(bindingId.split(".")[3] ?? NaN);
    this.#addPendingEdit(actionId, bindingIndex, {index: bindingIndex, key: null});
    return this.#savePendingEdits();
  }

  /* -------------------------------------------- */

  /**
   * Create an action keybinding row with an input element.
   * @param {HTMLElement} bindingEl
   * @param {object} [options]
   * @param {boolean} [options.incrementId] Whether the binding identifier's index component should be incremented
   * @returns {HTMLElement} A modified clone of the binding-input template content
   */
  #createBindingInput(bindingEl, {incrementId=false}={}) {
    const actionId = bindingEl.closest("[data-action-id]").dataset.actionId;
    const inputRow = document.getElementById(`${this.id}-binding-input`).content.firstElementChild.cloneNode(true);
    const bindings = game.keybindings.bindings.get(actionId);
    inputRow.dataset.bindingId = incrementId ? `${actionId}.binding.${bindings.length}` : bindingEl.dataset.bindingId;
    const input = inputRow.querySelector("input");
    input.name = inputRow.dataset.bindingId;
    if ( !incrementId ) {
      const kbdEl = bindingEl.querySelector("kbd");
      input.ariaLabel = kbdEl.ariaLabel;
      const warningIcon = kbdEl.querySelector("i");
      if ( warningIcon ) { // Copy over the warning
        inputRow.querySelector("i").className = warningIcon.className;
        input.dataset.tooltip = ""; // Make the aria-label additionally a tooltip
      }
    }
    input.placeholder = ControlsConfig.humanizeBinding({key: "1", modifiers: [KeyboardManager.CONTROL_KEY_STRING]});
    input.addEventListener("keydown", this.#onKeydownBindingInput.bind(this));
    return inputRow;
  }

  /* -------------------------------------------- */

  /**
   * Inserts a Binding into the Pending Edits object, creating a new Map entry as needed
   * @param {string} actionId
   * @param {number} bindingIndex
   * @param {KeybindingActionBinding} binding
   */
  #addPendingEdit(actionId, bindingIndex, binding) {
    // Save pending edits
    if ( this.#pendingEdits.has(actionId) ) {
      // Filter out any existing pending edits for this Binding so we don't add each Key in "Shift + A"
      const currentBindings = this.#pendingEdits.get(actionId).filter(x => x.index !== bindingIndex);
      currentBindings.push(binding);
      this.#pendingEdits.set(actionId, currentBindings);
    } else {
      this.#pendingEdits.set(actionId, [binding]);
    }
  }

  /* -------------------------------------------- */

  /**
   * If a `kbd` element was double-clicked, toggle visibility of a keybinding edit input
   * @param {PointerEvent} event
   */
  async #onDoubleClick(event) {
    if ( event.target.closest("kbd") ) return ControlsConfig.#onEditBinding.call(this, event, event.target);
  }

  /* -------------------------------------------- */

  /**
   * Reset setting defaults.
   * @this {ControlsConfig}
   * @type {ApplicationClickAction}
   */
  static async #onResetDefaults() {
    const question = game.i18n.localize("AreYouSure");
    const warning = game.i18n.localize("KEYBINDINGS.ResetWarning");
    const confirmed = await foundry.applications.api.DialogV2.confirm({
      window: {title: "KEYBINDINGS.ResetTitle"},
      content: `<p><strong>${question}</strong> ${warning}</p>`
    });
    if ( !confirmed ) return;
    await game.keybindings.resetDefaults();
    this.#cachedData = null;
    this.#pendingEdits.clear();
    await this.render();
    ui.notifications.info("KEYBINDINGS.ResetSuccess", {localize: true});
  }

  /* -------------------------------------------- */

  /**
   * Iterate over all Pending edits, merging them in with unedited Bindings and then saving and resetting the UI
   */
  async #savePendingEdits() {
    for ( const [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(error) {
        ui.notifications.error(error);
      }
    }

    // Reset and rerender
    this.#cachedData = null;
    this.#pendingEdits.clear();
    await this.render();
  }

  /* -------------------------------------------- */

  /**
   * Processes input from the keyboard to form a list of pending Binding edits
   * @param {KeyboardEvent} event   The keyboard event
   */
  #onKeydownBindingInput(event) {
    event.preventDefault();
    event.stopImmediatePropagation();
    const input = event.target;
    const context = KeyboardManager.getKeyboardEventContext(event);
    const bindingEl = input.closest("[data-binding-id]");
    const bindingId = bindingEl.dataset.bindingId;

    // Build pending Binding
    const bindingIdParts = bindingId.split(".");
    const bindingIndex = Number(bindingIdParts[bindingIdParts.length - 1] ?? NaN);
    const {MODIFIER_KEYS, MODIFIER_CODES} = KeyboardManager;
    const binding = {index: bindingIndex, key: event.code, logicalKey: KeyboardManager.translateKey(event), 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);
    }
    event.target.value = ControlsConfig.humanizeBinding(binding);

    // Save pending edits
    const actionId = bindingEl.closest("[data-action-id]").dataset.actionId;
    this.#addPendingEdit(actionId, bindingIndex, binding);

    // Alert of potential conflicts
    const action = game.keybindings.actions.get(actionId);
    const conflicts = this.#detectConflictingActions(actionId, action, binding);
    const icon = bindingEl.querySelector(".binding-input > i");
    if ( conflicts.length ) {
      icon.className = "conflict fa-duotone fa-triangle-exclamation";
      const listString = this.#listFormatter.format(conflicts.map(c => game.i18n.localize(c.name)));
      input.dataset.tooltip = "";
      input.ariaLabel = game.i18n.format("KEYBINDINGS.Conflict", {conflicts: listString});
    }
    else {
      icon.className = "fa-regular fa-keyboard";
      delete input.dataset.tooltip;
      input.ariaLabel = game.i18n.localize("KEYBINDINGS.BoundKey");
    }
  }
}

/**
 * @import {ApplicationFormSubmission} from "../../_types.mjs";
 * @import {SearchFilterCallback} from "../../ux/search-filter.mjs";
 */

/**
 * 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.
 *
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class ModuleManagement extends HandlebarsApplicationMixin(ApplicationV2) {

  /**
   * The named game setting which persists module configuration.
   * @type {string}
   * @readonly
   */
  static SETTING = "moduleConfiguration";

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    id: "module-management",
    tag: "form",
    window: {
      title: "MODMANAGE.Title",
      icon: "fa-solid fa-cube",
      contentClasses: ["standard-form"]
    },
    position: {width: 680},
    form: {
      handler: this.#onSubmitForm,
      submitOnClose: false,
      closeOnSubmit: true
    },
    actions: {
      changeFilter: this.#onChangeFilter,
      deactivateAll: this.#onDeactivateAll,
      toggleExpanded: this.#onToggleExpanded
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    body: {
      template: "templates/sidebar/apps/module-management.hbs",
      templates: ["templates/setup/parts/package-tags.hbs"],
      root: true
    },
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /* -------------------------------------------- */

  /**
   * The search-filter.
   * @type {SearchFilter}
   */
  #search = new SearchFilter({
    inputSelector: "input[type=search]",
    contentSelector: ".package-list",
    callback: this.#onSearchFilter.bind(this)
  });

  /* -------------------------------------------- */

  /**
   * Expand the description of the modules?
   * @type {boolean}
   */
  #expanded = false;

  /* -------------------------------------------- */

  /**
   * The currently selected filter.
   * @type {"all"|"active"|"inactive"}
   */
  #filter = this.isEditable ? "all" : "active";

  /* -------------------------------------------- */

  /**
   * Can the current User manage modules?
   * @type {boolean}
   */
  get isEditable() {
    return game.user.can("SETTINGS_MODIFY");
  }

  /* -------------------------------------------- */

  /**
   * 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.
   */
  #evaluateDependencies(module) {
    let hasDependencyIssue = false;

    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 ) {
        hasDependencyIssue = true;
        required.class = "error";
        required.tooltip = 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) ) {
        hasDependencyIssue = true;
        required.class = "error";
        required.tooltip = game.i18n.format("SETUP.CompatibilityRequireUpdate", {version: required.compatibility.minimum});
        continue;
      }
      if ( c.maximum && foundry.utils.isNewerVersion(dependencyVersion, c.maximum) ) {
        hasDependencyIssue = true;
        required.class = "error";
        required.tooltip = game.i18n.format("SETUP.CompatibilityRequireDowngrade", {version: required.compatibility.maximum});
        continue;
      }
      if ( c.verified && !foundry.utils.isNewerVersion(dependencyVersion, c.verified) ) {
        required.class = "warning";
        required.tooltip = game.i18n.format("SETUP.CompatibilityRiskWithVersion", {version: required.compatibility.verified});
      }
    }

    // Record that a module may not be able to be enabled
    if ( hasDependencyIssue ) {
      if ( !module.active ) module.enableable = false;
      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.
   */
  #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});
    }
  }

  /* -------------------------------------------- */

  /**
   * Format a document count collection for display.
   * @param {ModuleSubTypeCounts} counts  An object of sub-type counts.
   * @param {boolean} verbose             Detailed breakdown of by sub-type?
   * @param {Module} [module]             Are sub-types relative to a module?
   * @returns {string}                    The formatted document count
   * @internal
   */
  _formatDocumentSummary(counts, verbose, module) {
    if ( !verbose ) {
      const list = [];
      for ( const [documentName, types] of Object.entries(counts) ) {
        let total = 0;
        for ( const count of Object.values(types) ) total += count;
        if ( !total ) continue;
        const cls = foundry.utils.getDocumentClass(documentName);
        const label = game.i18n.localize(total === 1 ? cls.metadata.label : cls.metadata.labelPlural);
        list.push(`${total} ${label}`);
      }
      if ( !list.length ) return "";
      return game.i18n.getListFormatter().format(list);
    }
    const ul = document.createElement("ul");
    for ( const [documentName, types] of Object.entries(counts) ) {
      const list = [];
      for ( const [subType, count] of Object.entries(types) ) {
        if ( !count ) continue;
        let label;
        const typeLabel = CONFIG[documentName].typeLabels?.[module ? `${module.id}.${subType}` : subType];
        if ( typeLabel && game.i18n.has(typeLabel) ) label = game.i18n.localize(typeLabel);
        else {
          label = document.createElement("samp");
          label.textContent = subType;
        }
        const span = document.createElement("span");
        span.append(count, " ", label);
        list.push(span.innerHTML);
      }
      if ( !list.length ) continue;
      const strong = document.createElement("strong");
      const cls = foundry.utils.getDocumentClass(documentName);
      strong.textContent = `${game.i18n.localize(cls.metadata.labelPlural)}:`;
      const li = document.createElement("li");
      li.append(strong, " ");
      li.innerHTML += game.i18n.getListFormatter().format(list);
      ul.append(li);
    }
    if ( !ul.childElementCount ) return "";
    return ul.outerHTML;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.editable = this.isEditable;
    context.expanded = this.#expanded;

    // Modules
    const counts = {all: game.modules.size, active: 0, inactive: 0};
    context.modules = game.modules.reduce((arr, module) => {
      const isActive = module.active;
      if ( isActive ) counts.active++;
      else if ( !context.editable ) return arr;
      else counts.inactive++;

      const mod = module.toObject();
      mod.active = isActive;
      mod.hidden = ((this.#filter === "active") && !isActive) || ((this.#filter === "inactive") && isActive);
      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(author => {
        if ( author.url ) {
          const a = document.createElement("a");
          a.href = author.url;
          a.target = "_blank";
          a.textContent = author.name;
          return a.outerHTML;
        }
        return author.name;
      }).join(", ");
      mod.tooltip = ""; // 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, false, mod);
        mod.documentsVerbose = this._formatDocumentSummary(subTypeCounts, true, mod);
      }

      // 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
    context.filters = context.editable ? ["all", "active", "inactive"].map(f => ({
      id: f,
      label: game.i18n.localize(`MODMANAGE.Filter${f.titleCase()}`),
      count: counts[f] ?? 0,
      active: f === this.#filter
    })) : [];

    // Buttons
    context.buttons = context.editable ? [
      {type: "submit", icon: "fa-regular fa-floppy-disk", label: "MODMANAGE.Submit"},
      {type: "button", icon: "fa-solid fa-ban", label: "MODMANAGE.DeactivateAll", action: "deactivateAll"}
    ] : [];

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.#search.bind(this.element);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _tearDown(options) {
    super._tearDown(options);
    this.#search.unbind();
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * 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;
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachPartListeners(partId, element, options) {
    super._attachPartListeners(partId, element, options);
    if ( partId === "body" ) {
      this.element.querySelectorAll(".package input[type=checkbox]").forEach(
        e => e.addEventListener("change", this.#onChangeCheckbox.bind(this)));
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to a module checkbox to prompt for whether to enable dependencies.
   * @param {Event} event  The change event.
   */
  #onChangeCheckbox(event) {
    const input = event.target;
    const module = game.modules.get(input.name);
    const enabling = input.checked;
    const resolver = new DependencyResolution({ root: module, manager: this, 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("MODMANAGE.RequiredDepError", {format: {dependents}, console: false});
      }
      else resolver.render({force: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle search inputs.
   * @type {SearchFilterCallback}
   */
  #onSearchFilter(_event, query, rgx, html) {
    const settings = game.settings.get("core", ModuleManagement.SETTING);
    for ( const 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.hidden = false;
        continue;
      }
      const title = (li.querySelector(".package-title")?.textContent || "").trim();
      const author = (li.querySelector(".author")?.textContent || "").trim();
      const match = foundry.applications.ux.SearchFilter.testQuery(rgx, name)
        || foundry.applications.ux.SearchFilter.testQuery(rgx, title)
        || foundry.applications.ux.SearchFilter.testQuery(rgx, author);
      li.hidden = !match;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle a button-click to deactivate all modules.
   * @this {ModuleManagement}
   * @param {PointerEvent} event
   */
  static #onDeactivateAll(event) {
    for ( const input of this.element.querySelectorAll('.package input[type="checkbox"]') ) {
      if ( !input.disabled ) input.checked = false;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle switching the module list filter.
   * @this {ModuleManagement}
   * @param {PointerEvent} _event
   * @param {HTMLButtonElement} target
   */
  static #onChangeFilter(_event, target) {
    this.#filter = target.dataset.filter;

    // Toggle the activity state of all filters.
    this.element.querySelectorAll("search [data-filter]").forEach(
      e => e.classList.toggle("active", e.dataset.filter === this.#filter));

    // Iterate over modules and toggle their hidden states based on the chosen filter.
    for ( const li of this.element.querySelectorAll(".package") ) {
      const isActive = li.classList.contains("active");
      li.hidden = ((this.#filter === "active") && !isActive) || ((this.#filter === "inactive") && isActive);
    }

    // Re-apply any search filter query.
    this.#search.filter(null, this.#search._input.value);
  }

  /* -------------------------------------------- */

  /**
   * Handle a button-click to deactivate all modules.
   * @this {ModuleManagement}
   */
  static #onToggleExpanded() {
    this.#expanded = !this.#expanded;
    const button = this.element.querySelector("[data-action=toggleExpanded]");
    button.classList.toggle("fa-angle-double-down", this.#expanded);
    button.classList.toggle("fa-angle-double-up", !this.#expanded);
    button.dataset.tooltip = this.#expanded ? "Collapse" : "Expand";
    game.tooltip.deactivate();
    this.element.querySelector(".package-list").classList.toggle("expanded", this.#expanded);
  }

  /* -------------------------------------------- */

  /**
   * Process form submission for the sheet.
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmitForm(_event, _form, formData) {
    const newSettings = formData.object;

    // Ensure all relationships are satisfied
    for ( const [moduleId, active] of Object.entries(newSettings) ) {
      const module = game.modules.get(moduleId);
      if ( !module ) {
        delete newSettings[moduleId];
        continue;
      }
      if ( !active ) continue;
      if ( !module.relationships?.requires?.size ) continue;
      const missing = module.relationships.requires.reduce((arr, d) => {
        if ( d.type && (d.type !== "module") ) return arr;
        const requiredModuleActive = newSettings[d.id] ?? game.modules.get(d.id)?.active;
        if ( !requiredModuleActive ) arr.push(game.modules.get(d.id)?.title ?? d.id);
        return arr;
      }, []);
      if ( missing.length ) {
        const listFormatter = game.i18n.getListFormatter();
        const message = game.i18n.format("MODMANAGE.DepMissing", {module: module.title, missing: listFormatter.format(missing)});
        throw new Error(message);
      }
    }

    // Apply the setting
    const oldSettings = game.settings.get("core", ModuleManagement.SETTING);
    const requiresReload = !foundry.utils.isEmpty(foundry.utils.diffObject(oldSettings, newSettings));
    if ( requiresReload ) foundry.applications.settings.SettingsConfig.reloadConfirm({world: true});
    await game.settings.set("core", ModuleManagement.SETTING, newSettings);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  static get CONFIG_SETTING() {
    const message = "ModuleManagement.CONFIG_SETTING is deprecated in favor of ModuleManagement.SETTING";
    foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15});
    return this.SETTING;
  }
}

/**
 * @import {ApplicationClickAction} from "../../_types.mjs";
 */

/**
 * @typedef SupportReportData
 * A bundle of metrics for Support
 * @property {string} coreVersion
 * @property {string} systemVersion
 * @property {number} activeModuleCount
 * @property {string} performanceMode
 * @property {string} screen
 * @property {string} viewport
 * @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 {boolean} hasViewedScene
 * @property {string[]} worldScripts
 * @property {{width: number; height: number; src?: string}} largestTexture
 */

class SupportDetails extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "support-details",
    position: {
      height: 735,
      width: 780
    },
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-handshake-angle",
      title: "SUPPORT.Title"
    },
    actions: {
      fullReport: SupportDetails.#onGenerateFullReport,
      copyReport: SupportDetails.#onCopyReport
    }
  };

  /** @override */
  static PARTS = {
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    support: {
      template: "templates/sidebar/apps/support-details/support.hbs",
      templates: ["templates/sidebar/apps/support-details/report.hbs"]
    },
    documents: {template: "templates/sidebar/apps/support-details/documents.hbs"},
    client: {template: "templates/sidebar/apps/support-details/client.hbs"},
    modules: {template: "templates/sidebar/apps/support-details/modules.hbs"}
  };

  /** @override */
  static TABS = {
    main: {
      tabs: [
        {id: "support", icon: "fa-solid fa-handshake-angle"},
        {id: "documents", icon: "fa-solid file-circle-exclamation"},
        {id: "client", icon: "fa-regular fa-window"},
        {id: "modules", icon: "fa-solid box-open"}
      ],
      initial: "support",
      labelPrefix: "SUPPORT.TABS"
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    const partContext = await super._preparePartContext(partId, context, options);
    const tab = partContext.tabs[partId];
    if ( tab ) partContext.tab = tab;
    switch ( partId ) {
      case "support":
        partContext.report = await SupportDetails.generateSupportReport();
        break;
      case "documents":
        partContext.documentIssues = this._getDocumentValidationErrors();
        break;
      case "modules":
        partContext.moduleIssues = this._getModuleIssues();
        break;
      case "client":
        partContext.clientIssues = Object.values(game.issues.usabilityIssues).map(({message, severity, params}) => ({
          severity,
          message: game.i18n.format(message, params ?? {})
        }));
    }
    return partContext;
  }

  /* -------------------------------------------- */

  /**
   * Copy the support details report to clipboard.
   * @this {SupportDetails}
   * @type {ApplicationClickAction}
   */
  static #onCopyReport() {
    const report = this.element.querySelector("pre[data-report]");
    game.clipboard.copyPlainText(report.innerText);
    ui.notifications.info("SUPPORT.ReportCopied", {localize: true});
  }

  /* -------------------------------------------- */

  /**
   * Generate a more detailed support report and append it to the basic report.
   * @this {SupportDetails}
   * @type {ApplicationClickAction}
   */
  static async #onGenerateFullReport(_event, button) {
    let fullReport = "\n";
    const reportEl = this.element.querySelector("pre[data-report]");
    const icon = button.querySelector("i");
    button.disabled = true;
    icon.className = "fa-solid 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 = "fa-solid fa-check";
    reportEl.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}`;
  }

  /* -------------------------------------------- */

  /**
   * 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 = foundry.utils.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;
  }

  /* -------------------------------------------- */

  /**
   * Collects a number of metrics that is useful for Support
   * @returns {Promise<SupportReportData>}
   */
  static async generateSupportReport() {
    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);
    const worldScripts = Array.from(game.world.esmodules)
      .concat(...game.world.scripts)
      .map(s => `"${s}"`)
      .join(", ") || game.i18n.localize("None");
    const performanceModes = game.settings.settings.get("core.performanceMode").type.choices;

    /** @type {Partial<SupportReportData>} **/
    const report = {
      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.i18n.localize(performanceModes[game.settings.get("core", "performanceMode")]),
      screen: game.i18n.format("SUPPORT.Dimensions", window.screen),
      viewport: game.i18n.format("SUPPORT.Dimensions", {width: window.innerWidth, height: window.innerHeight}),
      os, client,
      gpu: rendererInfo,
      maxTextureSize: gl && gl.getParameter ? gl.getParameter(gl.MAX_TEXTURE_SIZE) : "Could not detect",
      hasViewedScene: !!viewedScene,
      packs: game.packs.size,
      worldScripts
    };

    // Attach Document Collection counts
    const reportCollections = ["actors", "items", "journal", "tables", "playlists", "messages"];
    for ( const 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();
    }
    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 foundry.canvas.containers.SpriteMesh)
        || (obj instanceof foundry.canvas.primary.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);
    }
  }
}

/**
 * @import World from "@client/packages/world.mjs";
 * @import {ApplicationConfiguration, ApplicationFormSubmission} from "../../_types.mjs";
 */

/**
 * @typedef WorldConfigOptions
 * @property {World} world The World being managed
 * @property {boolean} [tour] Is this World being shown as part of a Tour?
 */

/**
 * The World Management setup application
 */
class WorldConfig extends HandlebarsApplicationMixin(ApplicationV2) {

  /**
   * @param {Partial<ApplicationConfiguration> & WorldConfigOptions} options Application configuration options
   */
  constructor(options) {
    super(options);
    this.world = options.world;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    tag: "form",
    id: "world-config",
    window: {
      icon: "fa-solid fa-globe",
      contentClasses: ["standard-form"]
    },
    position: {
      width: 600
    },
    form: {
      handler: WorldConfig.#onSubmit,
      closeOnSubmit: true
    }
  };

  /** @override */
  static PARTS = {
    config: {
      template: "templates/sidebar/apps/world-config.hbs",
      scrollable: [""]
    },
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /**
   * The website knowledge base URL.
   * @type {string}
   */
  static #WORLD_KB_URL = "https://foundryvtt.com/article/game-worlds/";

  /* -------------------------------------------- */


  /**
   * The World being configured.
   * @type {World}
   */
  world;

  /** @override */
  get title() {
    return this.isCreate
      ? game.i18n.localize("WORLD.Title.Create")
      : game.i18n.format("WORLD.Title.Update", {world: this.world.title});
  }

  /**
   * Is this World to be created?
   * @type {boolean}
   */
  get isCreate() {
    return (game.view === "setup") && !game.worlds.has(this.world.id);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    if ( event.target.name === "title" ) {
      const slug = event.target.value.slugify({strict: true}) || "world-name";
      this.form.elements.id.placeholder = slug;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options={}) {
    const ac = CONST.PACKAGE_AVAILABILITY_CODES;
    const {world, isCreate} = this;
    const themes = Object.entries(CONST.WORLD_JOIN_THEMES).reduce((themes, [key, label]) => {
      themes[key] = game.i18n.localize(label);
      return themes;
    }, {});
    const context = {
      source: world._source,
      fields: world.schema.fields,
      rootId: this.id,
      isCreate, themes,
      worldId: isCreate && !this.options.tour ? "" : world._source.id,
      worldTitle: isCreate && !this.options.tour ? "" : world._source.title,
      worldKbUrl: WorldConfig.#WORLD_KB_URL,
      nextSession: this.#formatNextSession(),
      inWorld: !!game.world,
      buttons: [{
        type: "submit",
        icon: "fa-solid fa-floppy-disk",
        label: isCreate ? "WORLD.Title.Create" : "WORLD.SubmitUpdate"
      }]
    };
    context.showEditFields = !context.isCreate && !context.inWorld;
    if ( game.systems ) {
      context.systems = game.systems
        .filter(system => {
          if ( world.system === system.id ) return true;
          return ( system.availability <= ac.UNVERIFIED_GENERATION );
        })
        .sort((a, b) => a.title.localeCompare(b.title, game.i18n.lang))
        .reduce((systems, system) => {
          systems[system.id] = system.title;
          return systems;
        }, {});
    }
    else {
      context.systems = {[game.system.id]: game.system.title};
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Format the nextSession isoString to a datetime-local input string
   * @returns {string}
   */
  #formatNextSession() {
    const dt = new Date(this.world._source.nextSession ?? "");
    return dt.isValid() ? dt.toLocaleString("sv-SE") : ""; // Same as ISO 8601 (RFC 3339 profile) sans time zone
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /**
   * Handle form submission.
   * @this {WorldConfig}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmit(event, form, formData) {
    const submitData = foundry.utils.expandObject(formData.object);

    // Augment submission actions
    if ( this.isCreate ) {
      if ( !submitData.id.length ) submitData.id = submitData.title.slugify({strict: true});
    }
    else {
      submitData.id = this.world.id;
      if ( !submitData.resetKeys ) delete submitData.resetKeys;
      if ( !submitData.safeMode ) delete submitData.safeMode;
    }

    // Handle next session schedule fields
    const date = new Date(submitData.nextSession);
    submitData.nextSession = date.isValid() ? date.toISOString() : null;

    if ( submitData.joinTheme === CONST.WORLD_JOIN_THEMES.default ) delete submitData.joinTheme;
    const action = this.isCreate ? "createWorld" : "editWorld";
    return this.#processSubmitData(submitData, action);
  }

  /* -------------------------------------------- */

  /**
   * Create or update the World.
   * @param {object} submitData
   * @param {"createWorld"|"editWorld"} action
   */
  async #processSubmitData(submitData, action) {
    this.form.disable = true;

    // Validate the submission data
    try {
      this.world.validate({changes: submitData, clean: true});
    } catch(error) {
      ui.notifications.error(error.message.replace("\n", ". "));
      this.form.disabled = false;
      throw error;
    }

    // Dispatch the POST request
    let response;
    try {
      submitData.action = action;
      response = await foundry.utils.fetchJsonWithTimeout(foundry.utils.getRoute("setup"), {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify(submitData)
      });
      this.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 ( action === "createWorld" ) {
      const world = new this.world.constructor(response);
      game.worlds.set(world.id, world);
      this.world = world;
    }
    else this.world.updateSource(response);
    if ( game.view === "setup" ) ui.setupPackages.render();
  }
}

/**
 * @import {ApplicationClickAction} from "../../_types.mjs";
 */

/**
 * A management app for configuring which Tours are available or have been completed.
 */
class ToursManagement extends CategoryBrowser {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "tours-management",
    window: {
      title: "SIDEBAR.SETTINGS.ACTIONS.Tours",
      icon: "fa-solid fa-person-hiking",
      resizable: true
    },
    position: {
      width: 780,
      height: 680
    },
    actions: {
      resetDefaults: ToursManagement.#onResetDefaults,
      play: ToursManagement.#onPlayTour,
      reset: ToursManagement.#onResetTour
    },
    initialCategory: "core",
    subtemplates: {
      category: "templates/sidebar/apps/tours-management-category.hbs",
      sidebarFooter: "templates/category-browser/reset.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Application Overrides                       */
  /* -------------------------------------------- */

  /** @override */
  _prepareCategoryData() {
    const categories = {};
    const getCategory = namespace => {
      const {id, label} = this.#categorizeEntry(namespace);
      return categories[id] ??= {id, label, entries: []};
    };

    for ( const tour of game.tours ) {
      if ( !tour.config.display || (tour.config.restricted && !game.user.isGM) ) continue;

      // Determine what category the action belongs to
      const category = getCategory(tour.namespace);

      // Convert Tour to render data
      const data = {
        id: `${tour.namespace}.${tour.id}`,
        label: tour.title,
        completed: tour.status === Tour.STATUS.COMPLETED,
        hint: [
          tour.config.restricted ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
          tour.description
        ].filterJoin("<br>")
      };
      switch ( tour.status ) {
        case Tour.STATUS.UNSTARTED: {
          data.status = game.i18n.localize("TOURS.NotStarted");
          data.canPlay = tour.canStart;
          data.startOrResume = game.i18n.localize("TOURS.Start");
          break;
        }
        case Tour.STATUS.IN_PROGRESS: {
          data.status = game.i18n.format("TOURS.InProgress", {
            current: (tour.stepIndex + 1) || 0,
            total: tour.steps.length ?? 0
          });
          data.canPlay = tour.canStart;
          data.canReset = true;
          data.startOrResume = game.i18n.localize(`TOURS.${tour.config.canBeResumed ? "Resume" : "Restart"}`);
          break;
        }
        case Tour.STATUS.COMPLETED: {
          data.status = game.i18n.localize("TOURS.Completed");
          data.canReset = true;
          break;
        }
      }
      category.entries.push(data);
    }
    return categories;
  }

  /* -------------------------------------------- */

  /** @override */
  _sortCategories(a, b) {
    const categoryOrder = {core: 0, system: 1};
    const indexOfA = categoryOrder[a.id] ?? 2;
    const indexOfB = categoryOrder[b.id] ?? 2;
    return (indexOfA - indexOfB) || a.label.localeCompare(b.label, game.i18n.lang);
  }

  /* -------------------------------------------- */
  /*  Private Application Methods                 */
  /* -------------------------------------------- */

  /**
   * Classify what Category an Action belongs to
   * @param {string} namespace The entry to classify
   * @returns {{id: string; label: string}} The category the entry belongs to
   */
  #categorizeEntry(namespace) {
    switch ( namespace ) {
      case "core":
        return {id: "core", label: game.i18n.localize("PACKAGECONFIG.TABS.core")};
      case game.system.id:
        return {id: "system", label: game.system.title};
      default: {
        const module = game.modules.get(namespace);
        return module
          ? {id: module.id, label: module.title}
          : {id: "unmapped", label: game.i18n.localize("PACKAGECONFIG.TABS.unmapped")};
      }
    }
  }

  /* -------------------------------------------- */
  /*  Application Actions Handlers                */
  /* -------------------------------------------- */

  /**
   * Play the selected tour. Close the application window.
   * @this {ToursManagement}
   * @type {ApplicationClickAction}
   */
  static async #onPlayTour(_event, form) {
    const tour = game.tours.get(form.dataset.tour);
    if ( tour ) this.minimize();
    await tour?.start();
  }

  /* -------------------------------------------- */

  /**
   * Reset the selected tour.
   * @this {ToursManagement}
   * @type {ApplicationClickAction}
   */
  static async #onResetTour(_event, form) {
    const tour = game.tours.get(form.dataset.tour);
    await tour?.reset();
    await this.render();
  }

  /* -------------------------------------------- */

  /**
   * Reset all tours.
   * @this {ToursManagement}
   * @type {ApplicationClickAction}
   */
  static async #onResetDefaults() {
    return DialogV2.confirm({
      window: {
        title: "TOURS.ResetTitle",
        icon: "fa-solid fa-arrow-rotate-left"
      },
      content: `<p>${game.i18n.localize("TOURS.ResetWarning")}</p>`,
      yes: {
        callback: async () => {
          await Promise.all(game.tours.contents.map(tour => tour.reset()));
          ui.notifications.info("TOURS.ResetSuccess", {localize: true});
          await this.render();
        }
      }
    });
  }
}

var _module$D = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ChatPopout: ChatPopout,
  Compendium: Compendium,
  ControlsConfig: ControlsConfig,
  FolderExport: FolderExport,
  FrameViewer: FrameViewer,
  InvitationLinks: InvitationLinks,
  ModuleManagement: ModuleManagement,
  SupportDetails: SupportDetails,
  ToursManagement: ToursManagement,
  WorldConfig: WorldConfig
});

/**
 * @import Actor from "@client/documents/actor.mjs";
 */

/**
 * The World Actor directory listing.
 * @extends {DocumentDirectory<Actor>}
 */
class ActorDirectory extends DocumentDirectory {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    collection: "Actor"
  };

  /** @override */
  static tabName = "actors";

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _getEntryContextOptions() {
    const options = super._getEntryContextOptions();
    return [{
      name: "SIDEBAR.CharArt",
      icon: '<i class="fa-solid fa-image"></i>',
      condition: li => {
        const actor = this.collection.get(li.dataset.entryId);
        const { img } = actor.constructor.getDefaultArtwork(actor._source);
        return actor.img !== img;
      },
      callback: li => {
        const actor = this.collection.get(li.dataset.entryId);
        new foundry.applications.apps.ImagePopout({
          src: actor.img,
          uuid: actor.uuid,
          window: { title: actor.name }
        }).render({ force: true });
      }
    }, {
      name: "SIDEBAR.TokenArt",
      icon: '<i class="fa-solid fa-image"></i>',
      condition: li => {
        const actor = this.collection.get(li.dataset.entryId);
        if ( actor.prototypeToken.randomImg ) return false;
        const { texture } = actor.constructor.getDefaultArtwork(actor._source);
        return ![null, undefined, texture.src].includes(actor.prototypeToken.texture.src);
      },
      callback: li => {
        const actor = this.collection.get(li.dataset.entryId);
        new foundry.applications.apps.ImagePopout({
          src: actor.prototypeToken.texture.src,
          uuid: actor.uuid,
          window: { title: actor.name }
        }).render({ force: true });
      }
    }].concat(options);
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @override */
  _canDragStart(selector) {
    return game.user.can("TOKEN_CREATE");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragStart(event) {
    let actor;
    const { entryId } = event.currentTarget.dataset;
    if ( entryId ) {
      actor = this.collection.get(entryId);
      if ( !actor?.visible ) return false;
    }
    super._onDragStart(event);

    // Create the drag preview.
    if ( actor && canvas.ready ) {
      const img = event.currentTarget.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 = foundry.applications.ux.DragDrop.implementation.createDragImage(img, w, h);
      event.dataTransfer.setDragImage(preview, w / 2, h / 2);
    }
  }
}

/**
 * @import Cards from "@client/documents/cards.mjs";
 */

/**
 * The World Cards directory listing.
 * @extends {DocumentDirectory<Cards>}
 */
class CardsDirectory extends DocumentDirectory {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    collection: "Cards"
  };

  /** @override */
  static tabName = "cards";

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @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.collection.get(li.dataset.entryId);
      return cards.canClone;
    };
    return options;
  }
}

/**
 * @import {ApplicationRenderContext} from "../../_types.mjs";
 * @import {HandlebarsRenderOptions} from "../../api/handlebars-application.mjs";
 * @import {ContextMenuEntry} from "../../ux/context-menu.mjs";
 * @import Scene from "@client/documents/scene.mjs";
 */

/**
 * An Application that manages switching between Combats and tracking the Combatants in those Combats.
 * @extends {AbstractSidebarTab}
 * @mixes HandlebarsApplication
 */
class CombatTracker extends HandlebarsApplicationMixin(AbstractSidebarTab) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    window: {
      title: "COMBAT.SidebarTitle"
    },
    actions: {
      activateCombatant: CombatTracker.#onCombatantMouseDown,
      cycleCombat: CombatTracker.#onCombatCycle,
      createCombat: CombatTracker.#onCombatCreate,
      panToCombatant: CombatTracker.#onCombatantControl,
      pingCombatant: CombatTracker.#onCombatantControl,
      rollInitiative: CombatTracker.#onCombatantControl,
      toggleDefeated: CombatTracker.#onCombatantControl,
      toggleHidden: CombatTracker.#onCombatantControl,
      trackerSettings: CombatTracker.#onConfigure
    }
  };

  /** @override */
  static tabName = "combat";

  /** @override */
  static PARTS = {
    header: {
      template: "templates/sidebar/tabs/combat/header.hbs"
    },
    tracker: {
      template: "templates/sidebar/tabs/combat/tracker.hbs",
      scrollable: [""]
    },
    footer: {
      template: "templates/sidebar/tabs/combat/footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The list combats applicable to the active Scene.
   * @type {Combat[]}
   */
  get combats() {
    return game.combats.combats;
  }

  /**
   * Record a reference to the currently highlighted Token.
   * @type {Token|null}
   */
  #highlighted = null;

  /**
   * Record the currently tracked combat encounter.
   * @type {Combat|null}
   */
  get viewed() {
    if ( this.isPopout ) return ui.combat.viewed;
    return this.#viewed;
  }

  set viewed(combat) {
    if ( this.isPopout ) ui.combat.viewed = combat;
    else {
      if ( combat !== this.#viewed ) this.#highlighted = null;
      this.#viewed = combat || null;
    }
  }

  #viewed = null;

  /**
   * The Scene linked to the currently viewed Combat, if any
   * @type {Scene|null}
   */
  get scene() {
    return this.viewed?.scene ?? null;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    let combat = "combat" in options ? options.combat : this.viewed;
    if ( !this.isPopout && (combat === null) ) {
      combat = this.#inferCombat();
      combat?.updateCombatantActors();
    }
    this.viewed = combat;
  }

  /* -------------------------------------------- */

  /**
   * Format a tooltip for displaying overflowing effects.
   * @param {{ img: string, name: string }[]} effects  The effect names and icons.
   * @returns {string}
   * @protected
   */
  _formatEffectsTooltip(effects) {
    if ( !effects.length ) return "";
    const ul = document.createElement("ul");
    ul.classList.add("effects-tooltip", "plain");
    for ( const effect of effects ) {
      const img = document.createElement("img");
      img.src = effect.img;
      img.alt = effect.name;
      const span = document.createElement("span");
      span.textContent = effect.name;
      const li = document.createElement("li");
      li.append(img, span);
      ul.append(li);
    }
    return ul.outerHTML;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a source image for a combatant. If it is a video, use the first frame.
   * @param {Combatant} combatant  The Combatant.
   * @returns {Promise<string>}    The image URL.
   * @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 */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    if ( !this.isPopout ) game.combats.apps.push(this);

    // Combatant context menu
    /** @fires {hookEvents:getCombatantContextOptions} */
    this._createContextMenu(this._getEntryContextOptions, ".combatant", {fixed: true});

    // Combat controls menu
    if ( game.user.isGM ) {
      /** @fires {hookEvents:getCombatContextOptions} */
      this._createContextMenu(this._getCombatContextOptions, ".encounter-context-menu", {
        eventName: "click",
        fixed: true,
        hookName: "getCombatContextOptions",
        parentClassHooks: false
      });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    const { parts, renderContext, renderData } = options;
    const data = (renderData ?? []).find(d => d._id === this.viewed?.id);
    // If the turn changes and the active combatant is out of view, scroll it into view.
    if ( parts.includes("tracker") && (renderContext === "updateCombat") && ("turn" in (data ?? {})) ) {
      const tracker = this.element.querySelector(".combat-tracker");
      const active = tracker.querySelector(".combatant.active");
      if ( ((active?.offsetTop ?? 0) - tracker.offsetTop) <= tracker.scrollTop ) active?.scrollIntoView(true);
      if ( active?.offsetTop >= (tracker.offsetHeight + tracker.scrollTop) ) active?.scrollIntoView(false);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "footer": case "header": await this._prepareCombatContext(context, options); break;
      case "tracker": await this._prepareTrackerContext(context, options); break;
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the footer part.
   * @param {ApplicationRenderContext} context
   * @param {HandlebarsRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareCombatContext(context, options) {
    const combat = this.viewed;
    const hasCombat = combat !== null;
    const combats = this.combats;
    const currentIdx = combats.indexOf(combat);
    const previousId = combats[currentIdx - 1]?.id;
    const nextId = combats[currentIdx + 1]?.id;
    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 });

    Object.assign(context, {
      combat, hasCombat, nextId, previousId,
      combats: combats.map(({ id }, i) => ({ id, label: i + 1, active: i === currentIdx })),
      control: isPlayerTurn && canControl,
      css: combats.length > 7 ? "cycle" : combats.length ? "tabbed" : "",
      currentIndex: currentIdx + 1,
      displayCycle: combats.length > 7,
      initiativeIcon: CONFIG.Combat.initiativeIcon,
      linked: combat?.scene !== null,
      labels: {
        scope: game.i18n.localize(`COMBAT.${combat?.scene ? "Linked" : "Unlinked"}`)
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the tracker part.
   * @param {ApplicationRenderContext} context
   * @param {HandlebarsRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareTrackerContext(context, options) {
    const combat = this.viewed;
    if ( !combat ) return;
    let hasDecimals = false;
    const turns = context.turns = [];
    for ( const [i, combatant] of combat.turns.entries() ) {
      if ( !combatant.visible ) continue;
      const turn = await this._prepareTurnContext(combat, combatant, i);
      if ( turn.hasDecimals ) hasDecimals = true;
      turns.push(turn);
    }

    // Format initiative numeric precision.
    const precision = CONFIG.Combat.initiative.decimals;
    turns.forEach(t => {
      if ( Number.isFinite(t.initiative) ) t.initiative = t.initiative.toFixed(hasDecimals ? precision : 0);
    });
    context.hasDecimals = hasDecimals;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for a single entry in the combat tracker.
   * @param {Combat} combat        The active combat.
   * @param {Combatant} combatant  The Combatant whose turn is being prepared.
   * @param {number} index         The index of this entry in the turn order.
   * @returns {Promise<object>}
   * @protected
   */
  async _prepareTurnContext(combat, combatant, index) {
    const { id, name, isOwner, isDefeated, hidden, initiative, permission } = combatant;
    const resource = permission >= CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER ? combatant.resource : null;
    const hasDecimals = Number.isFinite(initiative) && !Number.isInteger(initiative);
    const turn = {
      hasDecimals, hidden, id, isDefeated, initiative, isOwner, name, resource,
      active: index === combat.turn,
      canPing: (combatant.sceneId === canvas.scene?.id) && game.user.hasPermission("PING_CANVAS"),
      img: await this._getCombatantThumbnail(combatant)
    };
    turn.css = [
      turn.active ? "active" : null,
      hidden ? "hide" : null,
      isDefeated ? "defeated" : null
    ].filterJoin(" ");
    const effects = [];
    for ( const effect of combatant.actor?.temporaryEffects ?? [] ) {
      if ( effect.statuses.has(CONFIG.specialStatusEffects.DEFEATED) ) turn.isDefeated = true;
      else if ( effect.img ) effects.push({ img: effect.img, name: effect.name });
    }
    turn.effects = {
      icons: effects,
      tooltip: this._formatEffectsTooltip(effects)
    };
    return turn;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("pointerover", this._onCombatantHoverIn.bind(this), { passive: true });
    this.element.addEventListener("pointerout", this._onCombatantHoverOut.bind(this), { passive: true });
    this.element.addEventListener("dblclick", event => {
      const target = event.target.closest("[data-combatant-id]");
      if ( target ) this._onCombatantMouseDown(event, target);
    }, { passive: true });
    this.element.addEventListener("change", this._onChangeInput.bind(this), { passive: true });
    this.element.addEventListener("focusin", event => {
      if ( event.target instanceof HTMLInputElement ) event.target.select();
    }, { passive: true });
  }

  /* -------------------------------------------- */

  /**
   * Get context menu entries for Combatants in the tracker.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getEntryContextOptions() {
    const getCombatant = li => this.viewed.combatants.get(li.dataset.combatantId);
    return [{
      name: "COMBAT.CombatantUpdate",
      icon: '<i class="fa-solid fa-pen-to-square"></i>',
      condition: () => game.user.isGM,
      callback: li => getCombatant(li)?.sheet.render({
        force: true,
        position: {
          top: Math.min(li.offsetTop, window.innerHeight - 350),
          left: window.innerWidth - 720
        }
      })
    }, {
      name: "COMBAT.CombatantClear",
      icon: '<i class="fa-solid fa-arrow-rotate-left"></i>',
      condition: li => game.user.isGM && Number.isFinite(getCombatant(li)?.initiative),
      callback: li => getCombatant(li)?.update({ initiative: null })
    }, {
      name: "COMBAT.CombatantReroll",
      icon: '<i class="fa-solid fa-dice-d20"></i>',
      condition: () => game.user.isGM,
      callback: li => {
        const combatant = getCombatant(li);
        if ( combatant ) return this.viewed.rollInitiative([combatant.id]);
      }
    }, {
      name: "COMBAT.CombatantClearMovementHistory",
      icon: '<i class="fa-solid fa-shoe-prints"></i>',
      condition: li => game.user.isGM && (getCombatant(li)?.token?.movementHistory.length > 0),
      callback: async li => {
        const combatant = getCombatant(li);
        if ( !combatant ) return;
        await combatant.clearMovementHistory();
        ui.notifications.info("COMBAT.CombatantMovementHistoryCleared", {format: {name: combatant.token.name}});
      }
    }, {
      name: "COMBAT.CombatantRemove",
      icon: '<i class="fa-solid fa-trash"></i>',
      condition: () => game.user.isGM,
      callback: li => getCombatant(li)?.delete()
    }];
  }

  /* -------------------------------------------- */

  /**
   * Get context menu entries for Combat in the tracker.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getCombatContextOptions() {
    return [{
      name: "COMBAT.InitiativeReset",
      icon: '<i class="fa-solid fa-arrow-rotate-left"></i>',
      condition: () => game.user.isGM && (this.viewed?.turns.length > 0),
      callback: () => this.viewed.resetAll()
    }, {
      name: "COMBAT.ClearMovementHistories",
      icon: '<i class="fa-solid fa-shoe-prints"></i>',
      condition: () => game.user.isGM && (this.viewed?.combatants.size > 0),
      callback: () => this.viewed.clearMovementHistories()
    }, {
      name: "COMBAT.ACTIONS.LinkToScene",
      icon: '<i class="fa-solid fa-link"></i>',
      condition: () => game.user.isGM && !this.scene,
      callback: () => this.viewed.toggleSceneLink()
    }, {
      name: "COMBAT.ACTIONS.UnlinkFromScene",
      icon: '<i class="fa-solid fa-unlink"></i>',
      condition: () => game.user.isGM && !!this.scene,
      callback: () => this.viewed.toggleSceneLink()
    }, {
      name: "COMBAT.Delete",
      icon: '<i class="fa-solid fa-trash"></i>',
      condition: () => game.user.isGM && !!this.viewed,
      callback: () => this.viewed.endCombat()
    }];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onClickAction(event, target) {
    if ( !event.target.closest(".combat-control") ) return;
    const combat = this.viewed;
    target.disabled = true;
    try { await combat[target.dataset.action]?.(); }
    finally { target.disabled = false; }
  }

  /* -------------------------------------------- */

  /**
   * Cycle to a different combat encounter in the tracker.
   * @this {CombatTracker}
   * @param {...any} args
   */
  static #onCombatCycle(...args) {
    return this._onCombatCycle(...args);
  }

  /* -------------------------------------------- */

  /**
   * Cycle to a different combat encounter in the tracker.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target element.
   * @protected
   */
  _onCombatCycle(event, target) {
    const { combatId } = target.dataset;
    return game.combats.get(combatId)?.activate({ render: false });
  }

  /* -------------------------------------------- */

  /**
   * Create a new combat.
   * @this {CombatTracker}
   * @param {...any} args
   * @returns {Promise<void>}
   */
  static #onCombatCreate(...args) {
    return this._onCombatCreate(...args);
  }

  /* -------------------------------------------- */

  /**
   * Create a new combat.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target element.
   * @returns {Promise<void>}
   * @protected
   */
  async _onCombatCreate(event, target) {
    const combat = await Combat$1.implementation.create();
    combat.activate({ render: false });
  }

  /* -------------------------------------------- */

  /**
   * Spawn the combat tracker settings dialog.
   * @this {CombatTracker}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target element.
   */
  static #onConfigure(event, target) {
    return new foundry.applications.apps.CombatTrackerConfig().render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle performing some action for an individual combatant.
   * @this {CombatTracker}
   * @param {...any} args
   */
  static #onCombatantControl(...args) {
    return this._onCombatantControl(...args);
  }

  /* -------------------------------------------- */

  /**
   * Handle performing some action for an individual combatant.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target element.
   * @protected
   */
  _onCombatantControl(event, target) {
    const { combatantId } = target.closest("[data-combatant-id]")?.dataset ?? {};
    const combatant = this.viewed?.combatants.get(combatantId);
    if ( !combatant ) return;

    switch ( target.dataset.action ) {
      case "pingCombatant": return this._onPingCombatant(combatant);
      case "panToCombatant": return this._onPanToCombatant(combatant);
      case "rollInitiative": return this._onRollInitiative(combatant);
      case "toggleDefeated": return this._onToggleDefeatedStatus(combatant);
      case "toggleHidden": return this._onToggleHidden(combatant);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering over a combatant in the tracker.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onCombatantHoverIn(event) {
    const { combatantId } = event.target.closest(".combatant[data-combatant-id]")?.dataset ?? {};
    if ( !canvas.ready || !combatantId ) return;
    const combatant = this.viewed.combatants.get(combatantId);
    const token = combatant.token?.object;
    if ( token && token._canHover(game.user, event) && this._isTokenVisible(token) ) {
      token._onHoverIn(event, { hoverOutOthers: true });
      this.#highlighted = token;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering out a combatant in the tracker.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onCombatantHoverOut(event) {
    this.#highlighted?._onHoverOut(event);
    this.#highlighted = null;
  }

  /* -------------------------------------------- */

  /**
   * Handle activating a combatant in the tracker.
   * @this {CombatTracker}
   * @param {...any} args
   */
  static #onCombatantMouseDown(...args) {
    return this._onCombatantMouseDown(...args);
  }

  /* -------------------------------------------- */

  /**
   * Handle activating a combatant in the tracker.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target element.
   * @protected
   */
  _onCombatantMouseDown(event, target) {
    if ( (event.target instanceof HTMLInputElement) || (event.target instanceof HTMLButtonElement) ) return;
    const { combatantId } = target?.dataset ?? {};
    const combatant = this.viewed.combatants.get(combatantId);
    if ( !combatant ) return;
    if ( event.type === "dblclick" ) {
      if ( combatant.actor?.testUserPermission(game.user, "OBSERVER") ) combatant.actor?.sheet.render(true);
      return;
    }
    const token = combatant.token?.object;
    if ( !token ) return;
    const controlled = token.control({ releaseOthers: true });
    if ( controlled ) canvas.animatePan(token.center);
  }

  /* -------------------------------------------- */

  /**
   * Handle panning to a combatant's token.
   * @param {Combatant} combatant  The combatant.
   * @protected
   */
  _onPanToCombatant(combatant) {
    if ( !canvas.ready || (combatant.sceneId !== canvas.scene.id) ) return;
    const token = combatant.token?.object;
    if ( !token || !this._isTokenVisible(token) ) {
      ui.notifications.warn("COMBAT.WarnNonVisibleToken", { localize: true });
      return;
    }
    const { x, y } = token.center;
    return canvas.animatePan({ x, y, scale: Math.max(canvas.stage.scale.x, canvas.dimensions.scale.default) });
  }

  /* -------------------------------------------- */

  /**
   * Handle pinging a combatant's token.
   * @param {Combatant} combatant  The combatant.
   * @protected
   */
  _onPingCombatant(combatant) {
    if ( !canvas.ready || (combatant.sceneId !== canvas.scene.id) ) return;
    const token = combatant.token?.object;
    if ( !token || !this._isTokenVisible(token) ) {
      ui.notifications.warn("COMBAT.WarnNonVisibleToken", { localize: true });
      return;
    }
    return canvas.ping(token.center);
  }

  /* -------------------------------------------- */

  /**
   * Handle rolling initiative for a single combatant.
   * @param {Combatant} combatant  The combatant.
   * @protected
   */
  _onRollInitiative(combatant) {
    return this.viewed.rollInitiative([combatant.id]);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the defeated status effect on a combatant token.
   * @param {Combatant} combatant  The combatant.
   * @returns {Promise<void>}
   * @protected
   */
  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 });
  }

  /* -------------------------------------------- */

  /**
   * Toggle a combatant's hidden state in the tracker.
   * @param {Combatant} combatant  The combatant.
   * @protected
   */
  _onToggleHidden(combatant) {
    return combatant.update({ hidden: !combatant.hidden });
  }

  /* -------------------------------------------- */

  /**
   * The CombatTracker application is not a <form> element by default, but it does handle specific input events.
   * @param {Event} event  The triggering change event.
   * @protected
   */
  _onChangeInput(event) {
    const input = event.target;
    if ( input.classList.contains("initiative-input") ) {
      return this._onUpdateInitiative(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle updating a combatant's initiative in-sheet.
   * @param {Event} event  The triggering change event.
   * @protected
   */
  _onUpdateInitiative(event) {
    const { combatantId } = event.target.closest("[data-combatant-id]")?.dataset ?? {};
    const combatant = this.viewed.combatants.get(combatantId);
    if ( !combatant ) return;
    const raw = event.target.value;
    const isDelta = /^[+-]/.test(raw);
    if ( !isDelta || (raw[0] === "=") ) {
      return combatant.update({ initiative: raw ? Number(raw.replace(/^=/, "")) : null });
    }
    const delta = parseInt(raw);
    if ( !isNaN(delta) ) return combatant.update({ initiative: combatant.initiative + delta });
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * 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];
    if ( this.popout?.rendered ) trackers.push(this.popout.element);
    for ( const tracker of trackers ) {
      const li = tracker.querySelector(`.combatant[data-combatant-id="${combatant.id}"]`);
      if ( !li ) break;
      if ( hover ) li.classList.add("hover");
      else li.classList.remove("hover");
    }
  }

  /* -------------------------------------------- */

  /**
   * Is the token of the combatant visible?
   * @param {Token} token    The token of the combatant
   * @returns {boolean}      Is the token visible?
   * @protected
   */
  _isTokenVisible(token) {
    return token.visible;
  }

  /* -------------------------------------------- */

  /**
   * Infer which Combat document should be viewed on the tracker, if any.
   * If the active combat is available for the current scene, prioritize it.
   * Otherwise, choose the most recently modified Combat encounter as the one we should view.
   * @returns {Combat|null}
   */
  #inferCombat() {
    const sceneCombats = [];
    for ( const c of game.combats ) {
      if ( c.isActive ) return c;
      else if ( !c.scene || (c.scene === game.scenes.current) ) sceneCombats.push(c);
    }
    sceneCombats.sort((a, b) => b._stats.modifiedTime - a._stats.modifiedTime); // Most recent
    return sceneCombats[0] || null;
  }

  /* -------------------------------------------- */

  /**
   * Scroll to the current combatant in the combat log.
   */
  scrollToTurn() {
    this.element.querySelector(".active")?.scrollIntoView();
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  initialize({combat=null, render=true}={}) {
    foundry.utils.logCompatibilityWarning("CombatTracker#initialize is deprecated. "
      + "The currently viewed combat can be changed by assigning to ui.combat.viewed directly, "
      + "passed as an option to ui.combat.render, or by setting a Combat as active.", { since: 13, until: 15 });
    if ( this.isPopout ) return;
    if ( render ) this.render({ combat });
    else this.viewed = combat;
  }
}

/**
 * @import {ApplicationConfiguration} from "../../_types.mjs";
 * @import {HandlebarsRenderOptions} from "../../api/handlebars-application.mjs";
 */

/**
 * @typedef CompendiumPackDirectoryContext
 * @property {boolean} locked           Whether the pack is locked.
 * @property {boolean} customOwnership  Whether the pack has custom ownership configured.
 * @property {string} collection        The pack's collection ID.
 * @property {string} package           The name of the package the pack belongs to.
 * @property {string} title             The pack title.
 * @property {string} icon              An icon representing the pack's contents.
 * @property {boolean} hidden           Whether the pack is currently hidden.
 * @property {string} banner            The pack's banner image.
 * @property {string} sourceIcon        An icon representing the pack's source (World, System, or Module).
 * @property {string} css               CSS class names.
 */

/**
 * The listing of compendiums available in the World.
 * @extends {AbstractSidebarTab<ApplicationConfiguration, HandlebarsRenderOptions>}
 * @mixes HandlebarsApplication
 */
class CompendiumDirectory extends HandlebarsApplicationMixin(AbstractSidebarTab) {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["directory", "flexcol"],
    window: {
      title: "SIDEBAR.TabCompendium"
    },
    actions: {
      activateEntry: CompendiumDirectory.#onClickEntry,
      collapseFolders: CompendiumDirectory.#onCollapseFolders,
      createEntry: CompendiumDirectory.#onCreateEntry,
      createFolder: CompendiumDirectory.#onCreateFolder,
      toggleFolder: CompendiumDirectory.#onToggleFolder,
      toggleSort: CompendiumDirectory.#onToggleSort
    }
  };

  /** @override */
  static PARTS = {
    header: {
      template: "templates/sidebar/tabs/compendiums.hbs"
    },
    directory: {
      template: "templates/sidebar/directory/directory.hbs",
      templates: ["templates/sidebar/partials/folder-partial.hbs", "templates/sidebar/partials/pack-partial.hbs"],
      scrollable: [""]
    },
    footer: {
      template: "templates/sidebar/directory/footer.hbs"
    }
  };

  /** @override */
  static tabName = "compendium";

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The set of active document type filters.
   * @type {Set<string>}
   */
  get activeFilters() {
    return this.#activeFilters;
  }

  #activeFilters = new Set();

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Get context menu entries for entries in this directory.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getEntryContextOptions() {
    if ( !game.user.isGM ) return [];
    return [{
      name: "OWNERSHIP.Configure",
      icon: '<i class="fa-solid fa-user-lock"></i>',
      callback: li => game.packs.get(li.dataset.pack)?.configureOwnershipDialog()
    }, {
      name: "FOLDER.Clear",
      icon: '<i class="fa-solid fa-folder"></i>',
      condition: header => game.packs.get(header.closest(".directory-item")?.dataset.entryId)?.folder,
      callback: header => game.packs.get(header.closest(".directory-item")?.dataset.entryId)?.setFolder(null)
    }, {
      name: "COMPENDIUM.ToggleLocked.Option",
      icon: '<i class="fa-solid fa-lock"></i>',
      callback: this._onToggleLock.bind(this)
    }, {
      name: "COMPENDIUM.Duplicate.Option",
      icon: '<i class="fa-solid fa-copy"></i>',
      callback: this._onDuplicateCompendium.bind(this)
    }, {
      name: "COMPENDIUM.ImportAll.Option",
      icon: '<i class="fa-solid fa-download"></i>',
      condition: li => game.packs.get(li.dataset.pack)?.documentName !== "Adventure",
      callback: li => game.packs.get(li.dataset.pack)?.importDialog({
        position: {
          top: Math.min(li.offsetTop, window.innerHeight - 350),
          left: window.innerWidth - 740,
          width: 420
        }
      })
    }, {
      name: "COMPENDIUM.Delete.Option",
      icon: '<i class="fa-solid fa-trash"></i>',
      condition: li => game.packs.get(li.dataset.pack)?.metadata.packageType === "world",
      callback: this._onDeleteCompendium.bind(this)
    }];
  }

  /* -------------------------------------------- */

  /**
   * Get options for filtering the directory by document type.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getFilterContextOptions() {
    return [
      {
        name: game.i18n.localize("COMPENDIUM.ClearFilters"),
        icon: '<i class="fa-solid fa-xmark"></i>',
        callback: this._onToggleCompendiumFilterType.bind(this)
      },
      ...CONST.COMPENDIUM_DOCUMENT_TYPES.map(t => ({
        name: game.i18n.localize(getDocumentClass$1(t).metadata.label),
        icon: `<i class="${CONFIG[t]?.sidebarIcon}"></i>`,
        callback: event => this._onToggleCompendiumFilterType(event, t)
      }))
    ];
  }

  /* -------------------------------------------- */

  /**
   * Get context menu entries for folders in this directory.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getFolderContextOptions() {
    return DocumentDirectory._getFolderContextOptions().filter(({ name }) => {
      return ["Edit", "Remove"].includes(name.substring(7));
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    /** @fires {hookEvents:getFolderContextOptions} */
    this._createContextMenu(this._getFolderContextOptions, ".folder .folder-header", {
      fixed: true,
      hookName: "getFolderContextOptions",
      parentClassHooks: false
    });
    /** @fires {hookEvents:getDocumentContextOptions} */
    this._createContextMenu(this._getEntryContextOptions, ".directory-item[data-pack]", {
      fixed: true,
      hookName: "getCompendiumContextOptions",
      parentClassHooks: false
    });
    new ContextMenu.implementation(this.element, "button.filter", this._getFilterContextOptions(), {
      jQuery: false,
      fixed: true,
      eventName: "click"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    // Search
    if ( options.parts.includes("header") ) {
      new foundry.applications.ux.SearchFilter({
        inputSelector: "search input",
        contentSelector: ".directory-list",
        callback: this._onSearchFilter.bind(this),
        initial: this.element.querySelector("search input").value
      }).bind(this.element);
    }

    // Drag-drop
    if ( options.parts.includes("directory") ) {
      new foundry.applications.ux.DragDrop.implementation({
        dragSelector: ".directory-item",
        dropSelector: ".directory-list",
        permissions: {
          dragstart: this._canDragStart.bind(this),
          drop: this._canDragDrop.bind(this)
        },
        callbacks: {
          dragover: this._onDragOver.bind(this),
          dragstart: this._onDragStart.bind(this),
          drop: this._onDrop.bind(this)
        }
      }).bind(this.element);
      this.element.querySelectorAll(".directory-item.folder").forEach(folder => {
        folder.addEventListener("dragenter", this._onDragHighlight.bind(this));
        folder.addEventListener("dragleave", this._onDragHighlight.bind(this));
      });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    Object.assign(context, {
      folderIcon: CONFIG.Folder.sidebarIcon,
      label: game.i18n.localize("PACKAGE.TagCompendium"),
      labelPlural: game.i18n.localize("SIDEBAR.TabCompendium"),
      documentName: "Compendium", // Honorary documentName for use in `DocumentDirectory` templates
      sidebarIcon: "fa-solid fa-book-atlas"
    });
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "directory": await this._prepareDirectoryContext(context, options); break;
      case "header": await this._prepareHeaderContext(context, options); break;
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the directory part.
   * @param {ApplicationRenderContext} context
   * @param {HandlebarsRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareDirectoryContext(context, options) {
    Object.assign(context, {
      canCreateEntry: game.user.isGM,
      canCreateFolder: game.user.isGM,
      entryPartial: "templates/sidebar/partials/pack-partial.hbs",
      folderPartial: "templates/sidebar/partials/folder-partial.hbs",
      packContext: game.packs.reduce((obj, pack) => {
        obj[pack.collection] = this._preparePackContext(pack);
        return obj;
      }, {}),
      maxFolderDepth: CONST.FOLDER_MAX_DEPTH,
      tree: game.packs.tree
    });
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the header part.
   * @param {ApplicationRenderContext} context
   * @param {HandlebarsRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareHeaderContext(context, options) {
    Object.assign(context, {
      filtersActive: this.#activeFilters.size,
      searchMode: game.packs.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME
        ? { icon: "fa-solid fa-magnifying-glass", label: "SIDEBAR.SearchModeName" }
        : { icon: "fa-solid fa-file-magnifying-glass", label: "SIDEBAR.SearchModeFull" },
      sortMode: game.packs.sortingMode === "a"
        ? { icon: "fa-solid fa-arrow-down-a-z", label: "SIDEBAR.SortModeAlpha" }
        : { icon: "fa-solid fa-arrow-down-short-wide", label: "SIDEBAR.SortModeManual" }
    });
    context.searchMode.placeholder = game.i18n.format("SIDEBAR.Search", {
      types: game.i18n.localize("SIDEBAR.TabCompendium")
    });
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for an individual compendium pack.
   * @param {CompendiumCollection} pack  The compendium pack.
   * @returns {CompendiumPackDirectoryContext}
   * @protected
   */
  _preparePackContext(pack) {
    const { locked, config, collection, metadata, title, banner, documentName } = pack;
    return {
      locked,
      collection,
      title: game.i18n.localize(title),
      banner,
      customOwnership: !!config.ownership,
      package: metadata.packageName,
      icon: CONFIG[documentName]?.sidebarIcon,
      hidden: this.#activeFilters.size && !this.#activeFilters.has(documentName),
      sourceIcon: foundry.packages.PACKAGE_TYPES[metadata.packageType]?.icon,
      css: documentName.toLowerCase()
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preSyncPartState(partId, newElement, priorElement, state) {
    super._preSyncPartState(partId, newElement, priorElement, state);
    if ( partId === "header" ) state.query = priorElement.querySelector("search input").value;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _syncPartState(partId, newElement, priorElement, state) {
    super._syncPartState(partId, newElement, priorElement, state);
    if ( (partId === "header") && state.query ) newElement.querySelector("search input").value = state.query;
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  collapseAll() {
    for ( const el of this.element.querySelectorAll(".directory-item.folder") ) {
      el.classList.remove("expanded");
      delete game.folders._expanded[el.dataset.uuid];
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Collapse all open folders in this directory.
   * @this {CompendiumDirectory}
   */
  static #onCollapseFolders() {
    return this.collapseAll();
  }

  /**
   * Handle clicking on a compendium entry.
   * @this {CompendiumDirectory}
   * @param {...any} args
   */
  static #onClickEntry(...args) {
    return this._onClickEntry(...args);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking on a compendium entry.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @protected
   */
  _onClickEntry(event, target) {
    event.preventDefault();
    const { pack } = target.closest("[data-pack]")?.dataset ?? {};
    game.packs.get(pack)?.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a new compendium pack.
   * @this {CompendiumDirectory}
   * @param {...any} args
   */
  static #onCreateEntry(...args) {
    return this._onCreateEntry(...args);
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a new compendium pack.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @returns {Promise<void>}
   * @protected
   */
  async _onCreateEntry(event, target) {
    const { folderId } = target.closest(".directory-item")?.dataset ?? {};
    const types = CONST.COMPENDIUM_DOCUMENT_TYPES.map(documentName => {
      return { value: documentName, label: game.i18n.localize(getDocumentClass$1(documentName).metadata.label) };
    });
    game.i18n.sortObjects(types, "label");
    const folders = game.packs._formatFolderSelectOptions();
    const html = await foundry.applications.handlebars.renderTemplate("templates/sidebar/compendium-create.hbs", {
      types, folders,
      folder: folderId,
      hasFolders: folders.length
    });
    const content = document.createElement("div");
    content.innerHTML = html;
    const metadata = await DialogV2.prompt({
      content,
      id: "create-compendium",
      window: { title: "COMPENDIUM.Create" },
      position: { width: 480 },
      ok: {
        label: "COMPENDIUM.Create",
        callback: (_event, button) => new FormDataExtended(button.form).object
      }
    });
    if ( !metadata ) return;
    const targetFolderId = metadata.folder;
    delete metadata.folder;
    if ( !metadata.label ) {
      const count = game.packs.size;
      metadata.label = game.i18n.format(count ? "DOCUMENT.NewCount" : "DOCUMENT.New", {
        count: count + 1,
        type: game.i18n.localize("PACKAGE.TagCompendium")
      });
    }
    const pack = await CompendiumCollection.createCompendium(metadata);
    if ( targetFolderId ) await pack.setFolder(targetFolderId);
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a new folder in this directory.
   * @this {CompendiumDirectory}
   * @param {...any} args
   */
  static #onCreateFolder(...args) {
    return this._onCreateFolder(...args);
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a new folder in this directory.
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The action target element.
   * @protected
   */
  _onCreateFolder(event, target) {
    event.stopPropagation();
    const { folderId } = target.closest(".directory-item")?.dataset ?? {};
    const data = { folder: folderId ?? null, type: "Compendium" };
    const sheetWidth = FolderConfig.DEFAULT_OPTIONS.position.width;
    const options = {
      position: { top: target.offsetTop, left: window.innerWidth - 310 - sheetWidth }
    };
    Folder$1.implementation.createDialog(data, {}, options);
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting a compendium pack.
   * @param {HTMLElement} li  The compendium target element.
   * @returns {Promise<void>}
   * @protected
   */
  async _onDeleteCompendium(li) {
    const pack = game.packs.get(li.dataset.pack);
    const question = game.i18n.localize("AreYouSure");
    const warning = game.i18n.localize("COMPENDIUM.Delete.Warning");
    const result = await DialogV2.confirm({
      window: {
        title: game.i18n.format("COMPENDIUM.Delete.Title", { compendium: game.i18n.localize(pack.title) }), // FIXME: double localization
        icon: "fa-solid fa-trash"
      },
      position: {
        top: Math.min(li.offsetTop, window.innerHeight - 350),
        left: window.innerWidth - 720,
        width: 480
      },
      content: `<p><strong>${question}</strong> ${warning}</p>`
    });
    if ( result ) pack.deleteCompendium();
  }

  /* -------------------------------------------- */

  /**
   * Handle duplicating a compendium.
   * @param {HTMLElement} li  The compendium target element.
   * @returns {Promise<CompendiumCollection|void>}
   * @protected
   */
  async _onDuplicateCompendium(li) {
    const pack = game.packs.get(li.dataset.pack);
    const field = new StringField({
      label: "COMPENDIUM.Duplicate.Label",
      hint: "COMPENDIUM.Duplicate.Hint"
    });
    const { label } = await DialogV2.confirm({
      content: field._toInput({
        value: game.i18n.format("DOCUMENT.CopyOf", { name: pack.title }),
        name: "label"
      }).outerHTML,
      window: {
        title: game.i18n.format("COMPENDIUM.Duplicate.Title", { compendium: game.i18n.localize(pack.title) }), // FIXME: double localization
        icon: "fa-solid fa-copy"
      },
      position: {
        top: Math.min(li.offsetTop, window.innerHeight - 350),
        left: window.innerWidth - 720,
        width: 480
      },
      yes: {
        label: "COMPENDIUM.Duplicate.Submit",
        callback: (event, button) => new FormDataExtended(button.form).object,
        default: true
      },
      no: { label: "Cancel" }
    }) || {};
    if ( label ) return pack.duplicateCompendium({ label });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a compendium type filter.
   * @param {PointerEvent} event  The triggering event.
   * @param {string} [type]       The compendium type to filter by. If omitted, clear all filters.
   * @protected
   */
  _onToggleCompendiumFilterType(event, type) {
    if ( type ) {
      if ( this.#activeFilters.has(type) ) this.#activeFilters.delete(type);
      else this.#activeFilters.add(type);
    }
    else this.#activeFilters.clear();
    return this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a folder's expanded state.
   * @this {CompendiumDirectory}
   * @param {...any} args
   */
  static #onToggleFolder(...args) {
    return this._onToggleFolder(...args);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a folder's expanded state.
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The action target element.
   * @protected
   */
  _onToggleFolder(event, target) {
    const folder = target.closest(".directory-item");
    folder.classList.toggle("expanded");
    const expanded = folder.classList.contains("expanded");
    const { uuid } = folder.dataset;
    if ( expanded ) game.folders._expanded[uuid] = true;
    else delete game.folders._expanded[uuid];

    if ( !expanded ) {
      for ( const subfolder of folder.querySelectorAll(".directory-item.folder") ) {
        subfolder.classList.remove("expanded");
        delete game.folders._expanded[subfolder.dataset.uuid];
      }
    }

    if ( this.isPopout ) this.setPosition();
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling locked state on a compendium.
   * @param {HTMLElement} li  The compendium target element.
   * @returns {Promise<boolean|void>}
   * @protected
   */
  async _onToggleLock(li) {
    const pack = game.packs.get(li.dataset.pack);
    if ( !pack.locked ) return pack.configure({ locked: true });
    const { packageType, packageName } = pack.metadata;
    // Module compendiums without a manifest or download link are not in danger of being updated, and are probably user-
    // created.
    const module = game.modules.get(packageName);
    const localPack = (packageType === "module") && module && !module.download && !module.manifest;
    const skipConfirmation = (packageType === "world") || localPack;
    const alert = game.i18n.localize("Warning");
    const warning = game.i18n.localize("COMPENDIUM.ToggleLocked.Warning");
    const title = game.i18n.localize(pack.title);
    const result = skipConfirmation ? "unlock" : await DialogV2.wait({
      window: {
        title: game.i18n.format("COMPENDIUM.ToggleLocked.Title", { compendium: title }), // FIXME: double localization
        icon: "fa-solid fa-lock"
      },
      position: {
        top: Math.min(li.offsetTop, window.innerHeight - 350),
        left: window.innerWidth - 800,
        width: 480
      },
      content: `<p><strong>${alert}:</strong> ${warning}</p>`,
      buttons: [{
        action: "duplicate",
        label: "SIDEBAR.Duplicate",
        icon: "fa-solid fa-copy fa-fw"
      }, {
        action: "unlock",
        label: "COMPENDIUM.ToggleLocked.Unlock",
        icon: "fa-solid fa-lock-open fa-fw"
      }, {
        action: "cancel",
        label: "Cancel",
        icon: "fa-solid fa-xmark fa-fw"
      }]
    });
    switch ( result ) {
      case "duplicate": return this._onDuplicateCompendium(li);
      case "unlock": return pack.configure({ locked: false });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the sort mode.
   * @this {CompendiumDirectory}
   */
  static #onToggleSort() {
    game.packs.toggleSortingMode();
    return this.render();
  }

  /* -------------------------------------------- */
  /*  Search & Filter                             */
  /* -------------------------------------------- */

  /**
   * Handle matching a given directory entry with the search filter.
   * @param {string} query          The input search string.
   * @param {Set<string>} packs     The matched pack IDs.
   * @param {HTMLElement} element   The candidate entry element.
   * @param {object} [options]      Additional options for subclass-specific behavior.
   * @protected
   */
  _onMatchSearchEntry(query, packs, element, options={}) {
    element.style.display = !query || packs.has(element.dataset.pack) ? "flex" : "none";
  }

  /* -------------------------------------------- */

  /**
   * Handle directory searching and filtering.
   * @param {KeyboardEvent} event  The keyboard input event.
   * @param {string} query         The input search string.
   * @param {RegExp} rgx           The regular expression query that should be matched against.
   * @param {HTMLElement} html     The container to filter entries from.
   * @protected
   */
  _onSearchFilter(event, query, rgx, html) {
    const packs = new Set();
    const folderIds = new Set();
    const autoExpandIds = new Set();
    const options = {};

    // Match entries and folders.
    if ( query ) {
      // First match folders.
      this._matchSearchFolders(rgx, folderIds, autoExpandIds, options);

      // Next match entries.
      this._matchSearchEntries(rgx, packs, folderIds, autoExpandIds, options);
    }

    // Toggle each directory entry.
    for ( const el of html.querySelectorAll(".directory-item") ) {
      if ( el.hidden ) continue;
      if ( el.classList.contains("folder") ) {
        const { folderId, uuid } = el.dataset;
        const match = folderIds.has(folderId);
        el.style.display = !query || match ? "flex" : "none";
        if ( autoExpandIds.has(folderId) ) {
          if ( query && match ) el.classList.add("expanded");
        }
        else el.classList.toggle("expanded", uuid in game.folders._expanded);
      }
      else this._onMatchSearchEntry(query, packs, el, options);
    }
  }

  /* -------------------------------------------- */

  /**
   * Include the matched folder in search results, and recursively auto-expand its parent folders.
   * @param {Folder|string} folder               The folder document or its ID.
   * @param {Set<string>} folderIds              The set of matched folder IDs.
   * @param {Set<string>} autoExpandIds          The set of folder IDs that should be auto-expanded.
   * @param {object} [options]
   * @param {boolean} [options.autoExpand=true]  Add the folder to the auto-expand list.
   */
  #onMatchFolder(folder, folderIds, autoExpandIds, { autoExpand=true }={}) {
    if ( typeof folder === "string" ) folder = game.packs.folders.get(folder);
    if ( !folder ) return;
    const folderId = folder._id;
    const visited = folderIds.has(folderId);
    folderIds.add(folderId);
    if ( autoExpand ) autoExpandIds.add(folderId);
    if ( !visited && folder.folder ) this.#onMatchFolder(folder.folder, folderIds, autoExpandIds);
  }

  /* -------------------------------------------- */

  /**
   * Identify entries in the collection which match a provided search query.
   * @param {RegExp} query               The search query.
   * @param {Set<string>} packs          The set of matched pack IDs.
   * @param {Set<string>} folderIds      The set of matched folder IDs.
   * @param {Set<string>} autoExpandIds  The set of folder IDs that should be auto-expanded.
   * @param {object} [options]           Additional options for subclass-specific behavior.
   * @protected
   */
  _matchSearchEntries(query, packs, folderIds, autoExpandIds, options={}) {
    // Copy the folderIds to a new set, so that we can add to the original set without incorrectly adding child entries.
    const matchedFolderIds = new Set(folderIds);

    for ( const pack of game.packs ) {
      const { collection, folder, title } = pack;

      // If we matched a folder, add its child entries.
      if ( matchedFolderIds.has(folder?.id) ) packs.add(collection);

      // Otherwise, if we are searching by name, match the entry name.
      else if ( query.test(foundry.applications.ux.SearchFilter.cleanQuery(title)) ) {
        packs.add(collection);
        this.#onMatchFolder(folder, folderIds, autoExpandIds);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Identify folders in the collection which match a provided search query.
   * @param {RegExp} query               The search query.
   * @param {Set<string>} folderIds      The set of matched folder IDs.
   * @param {Set<string>} autoExpandIds  The set of folder IDs that should be auto-expanded.
   * @param {object} [options]           Additional options for subclass-specific behavior.
   * @protected
   */
  _matchSearchFolders(query, folderIds, autoExpandIds, options={}) {
    for ( const folder of game.packs.folders ) {
      if ( query.test(foundry.applications.ux.SearchFilter.cleanQuery(folder.name)) ) {
        this.#onMatchFolder(folder, folderIds, autoExpandIds, { autoExpand: false });
      }
    }
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /**
   * Determine if the given user has permission to drop entries into the compendium directory.
   * @param {string} selector  The CSS selector of the dragged element.
   * @returns {boolean}
   * @protected
   */
  _canDragDrop(selector) {
    return game.user.isGM;
  }

  /* -------------------------------------------- */

  /**
   * Determine if the given user has permission to drag packs and folders in the directory.
   * @param {string} selector  The CSS selector of the target element.
   * @returns {boolean}
   * @protected
   */
  _canDragStart(selector) {
    return game.user.isGM;
  }

  /* -------------------------------------------- */

  /**
   * Test if the given pack is already present in this directory.
   * @param {CompendiumCollection} pack  The compendium pack.
   * @returns {boolean}
   * @protected
   */
  _entryAlreadyExists(pack) {
    return game.packs.has(pack.collection);
  }

  /* -------------------------------------------- */

  /**
   * Determine whether a given directory entry belongs to the given folder.
   * @param {CompendiumCollection} pack  The compendium pack.
   * @param {string|undefined} folder    The target folder ID.
   * @returns {boolean}
   * @protected
   */
  _entryBelongsToFolder(pack, folder) {
    if ( !pack.folder && !folder ) return true;
    return !!folder && (pack.folder?.id === folder);
  }

  /* -------------------------------------------- */

  /**
   * Get the pack instance from its dropped data.
   * @param {object} data  The drag data.
   * @returns {Promise<CompendiumCollection>}
   * @protected
   */
  _getDroppedEntryFromData(data) {
    return game.packs.get(data.collection);
  }

  /* -------------------------------------------- */

  /**
   * Get drag data for a compendium in this directory.
   * @param {string} collection  The pack's collection ID.
   * @protected
   */
  _getEntryDragData(collection) {
    return { collection, type: "Compendium" };
  }

  /* -------------------------------------------- */

  /**
   * Get drag data for a folder in this directory.
   * @param {string} folderId  The folder ID.
   * @protected
   */
  _getFolderDragData(folderId) {
    return game.packs.folders.get(folderId).toDragData();
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping a new pack into this directory.
   * @param {HTMLElement} target  The drop target element.
   * @param {object} data         The drop data.
   * @returns {Promise<void>}
   * @protected
   */
  async _handleDroppedEntry(target, data) {
    const closestFolder = target?.closest(".directory-item.folder");
    closestFolder?.classList.remove("droptarget");
    let folder = (await fromUuid(closestFolder?.dataset.uuid))?.id;
    const pack = await this._getDroppedEntryFromData(data);
    if ( !pack ) return;

    // Sort relative to another entry.
    const sortData = { sortKey: "sort" };
    const relativePack = target?.dataset.pack;
    if ( relativePack ) {
      if ( pack.collection === relativePack ) return; // Don't drop on yourself.
      const targetPack = game.packs.get(relativePack);
      sortData.target = targetPack;
      folder = targetPack?.folder?.id;
    }

    // Sort within the closest folder.
    else sortData.target = null;

    // Determine siblings.
    sortData.siblings = game.packs.filter(p => {
      return (p.collection !== pack.collection) && this._entryBelongsToFolder(p, folder);
    });

    if ( !this._entryAlreadyExists(pack) ) return;

    // Resort the collection.
    sortData.updateData = { folder: folder || null };
    return this._sortRelative(pack, sortData);
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping a folder onto the directory.
   * @param {HTMLElement} target  The drop target element.
   * @param {object} data         The drop data.
   * @returns {Promise<void>}
   * @protected
   */
  async _handleDroppedFolder(target, data) {
    const { folder, sortData, foreign } = (await DocumentDirectory._handleDroppedFolder(target, data, {
      folders: game.packs.folders,
      label: "PACKAGE.TabCompendium",
      maxFolderDepth: CONST.FOLDER_MAX_DEPTH,
      type: "Compendium"
    })) ?? {};

    if ( !folder || foreign ) return;
    sortData.updateData = { folder: sortData.parentId };
    return folder.sortRelative(sortData);
  }

  /* -------------------------------------------- */

  /**
   * Highlight folders as drop targets when a drag event enters or exits their area.
   * @param {DragEvent} event  The in-progress drag event.
   * @protected
   */
  _onDragHighlight(event) {
    event.stopPropagation();
    if ( event.type === "dragenter" ) {
      for ( const el of this.element.querySelectorAll(".droptarget") ) el.classList.remove("droptarget");
    }
    else if ( event.type === "dragleave" ) {

      // Look up the hovered element (event.target is the element that was left)
      const el = document.elementFromPoint(event.clientX, event.clientY);
      const parent = el.closest(".folder");
      if ( parent === event.currentTarget ) return;
    }
    event.currentTarget.classList.toggle("droptarget", event.type === "dragenter");
  }

  /* -------------------------------------------- */

  /**
   * Handle drag events over the directory.
   * @param {DragEvent} event
   * @protected
   */
  _onDragOver(event) {}

  /* -------------------------------------------- */

  /** @override */
  _onDragStart(event) {
    ui.context?.close({ animate: false });
    const { pack, folderId } = event.currentTarget.dataset;
    const dragData = folderId ? this._getFolderDragData(folderId) : this._getEntryDragData(pack);
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /** @override */
  _onDrop(event) {
    const data = TextEditor$1.implementation.getDragEventData(event);
    if ( !data.type ) return;
    const target = event.target.closest(".directory-item") ?? null;
    if ( data.type === "Folder" ) return this._handleDroppedFolder(target, data);
    else if ( data.type === "Compendium" ) return this._handleDroppedEntry(target, data);
  }

  /* -------------------------------------------- */

  /**
   * Handle sorting a compendium pack relative to others in the directory.
   * @param {CompendiumCollection} pack  The compendium pack.
   * @param {object} sortData            Sort data.
   * @protected
   */
  _sortRelative(pack, sortData) {
    const packConfig = game.settings.get("core", CompendiumCollection.CONFIG_SETTING);
    const { folder } = sortData.updateData;
    packConfig[pack.collection] = foundry.utils.mergeObject(packConfig[pack.collection] || {}, { folder });
    const sorting = foundry.utils.performIntegerSort(pack, sortData);
    for ( const { target, update } of sorting ) {
      const config = packConfig[target.collection] ??= {};
      config.sort = update.sort;
    }
    game.settings.set("core", CompendiumCollection.CONFIG_SETTING, packConfig);
  }
}

/**
 * @import Item from "@client/documents/item.mjs";
 */

/**
 * The World Item directory listing.
 * @extends {DocumentDirectory<Item>}
 */
class ItemDirectory extends DocumentDirectory {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    collection: "Item"
  };

  /** @override */
  static tabName = "items";

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _getEntryContextOptions() {
    const options = super._getEntryContextOptions();
    return [{
      name: "ITEM.ViewArt",
      icon: '<i class="fa-solid fa-image"></i>',
      condition: li => {
        const item = game.items.get(li.dataset.entryId);
        const { img } = item.constructor.getDefaultArtwork(item._source);
        return item.img !== img;
      },
      callback: li => {
        const item = game.items.get(li.dataset.entryId);
        new foundry.applications.apps.ImagePopout({
          src: item.img,
          uuid: item.uuid,
          window: { title: item.name }
        }).render({ force: true });
      }
    }].concat(options);
  }
}

/**
 * @import JournalEntry from "@client/documents/journal-entry.mjs";
 */

/**
 * The World Journal.
 * @extends {DocumentDirectory<JournalEntry>}
 */
class JournalDirectory extends DocumentDirectory {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    collection: "JournalEntry"
  };

  /** @override */
  static tabName = "journal";

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _getEntryContextOptions() {
    return [
      ...super._getEntryContextOptions(),
      {
        name: "SIDEBAR.JumpPin",
        icon: '<i class="fa-solid fa-crosshairs"></i>',
        condition: li => !!this.collection.get(li.dataset.entryId)?.sceneNote,
        callback: li => this.collection.get(li.dataset.entryId)?.panToNote()
      }
    ];
  }
}

/**
 * @import Macro from "@client/documents/macro.mjs";
 */

/**
 * The World Macro directory listing.
 * @extends {DocumentDirectory<Macro>}
 */
class MacroDirectory extends DocumentDirectory {
  /** @override */
  static DEFAULT_OPTIONS = {
    collection: "Macro"
  };

  /** @override */
  static tabName = "macros";
}

/**
 * @import {HandlebarsRenderOptions} from "../../api/handlebars-application.mjs"
 * @import {ApplicationRenderContext} from "../../_types.mjs"
 * @import {ContextMenuEntry} from "../../ux/context-menu.mjs";
 * @import Playlist from "@client/documents/playlist.mjs";
 */

/**
 * @typedef _PlaylistDirectoryRenderContext
 * @property {object} controls                                      Volume control context.
 * @property {boolean} controls.expanded                            The expanded state of the volume controls.
 * @property {PlaylistDirectoryVolumeContext} controls.music        Music volume context.
 * @property {PlaylistDirectoryVolumeContext} controls.environment  Environment volume context.
 * @property {PlaylistDirectoryVolumeContext} controls.interface    Interface volume context.
 * @property {object} currentlyPlaying                              Currently playing context.
 * @property {string} currentlyPlaying.class                        The CSS class of the currently playing widget.
 * @property {object} currentlyPlaying.location                     Location information for the currently playing
 *                                                                  widget.
 * @property {boolean} currentlyPlaying.location.top                The widget is affixed to the top of the directory.
 * @property {boolean} currentlyPlaying.location.bottom             The widget is affixed to the bottom of the
 *                                                                  directory.
 * @property {object} currentlyPlaying.pin                          Render context for the currently playing pin icon.
 * @property {string} currentlyPlaying.pin.label                    The icon tooltip.
 * @property {string} currentlyPlaying.pin.caret                    The icon class.
 * @property {PlaylistSoundRenderContext[]} currentlyPlaying.sounds Render context for the currently playing
 *                                                                  PlaylistSound documents.
 * @property {PlaylistDirectoryTreeContext} tree                    Render context for the directory tree.
 */

/**
 * @typedef {ApplicationRenderContext & _PlaylistDirectoryRenderContext} PlaylistDirectoryRenderContext
 */

/**
 * @typedef PlaylistDirectoryVolumeContext
 * @property {number} modifier                 The volume modifier in the interval [0, 1].
 * @property {NumberField} field               The DataField specification for the form input.
 * @property {string} [name]                   The form input name.
 * @property {Record<string, string>} dataset  HTML dataset attributes.
 * @property {Record<string, string>} aria     HTML ARIA attributes.
 */

/**
 * @typedef PlaylistDirectoryTreeContext
 * @property {PlaylistRenderContext[]} entries          Render context for the Playlist documents at this node.
 * @property {PlaylistDirectoryTreeContext[]} children  Render context for this node's children.
 * @property {Folder} folder                            The Folder document that represents this node.
 * @property {number} depth                             The node's depth in the tree.
 */

/**
 * @typedef PlaylistDirectoryControlContext
 * @property {string} icon   The button icon.
 * @property {string} label  The button label.
 */

/**
 * @typedef PlaylistRenderContext
 * @property {string} id                            The Playlist ID.
 * @property {string} name                          The Playlist name.
 * @property {boolean} expanded                     Whether the Playlist is expanded in the sidebar.
 * @property {boolean} isOwner                      Whether the current user has ownership of this Playlist.
 * @property {PlaylistSoundRenderContext[]} sounds  Render context for this Playlist's PlaylistSounds.
 * @property {PlaylistDirectoryControlContext} mode The mode icon context.
 * @property {boolean} disabled                     Whether the Playlist is currently disabled.
 * @property {string} css                           The CSS class.
 */

/**
 * @typedef PlaylistSoundRenderContext
 * @property {string} id                              The PlaylistSound ID.
 * @property {string} name                            The track name.
 * @property {boolean} playing                        Whether the PlaylistSound is currently playing.
 * @property {boolean} repeat                         Whether the track is set to loop.
 * @property {boolean} isOwner                        Whether the current user has ownership of this PlaylistSound.
 * @property {string} playlistId                      The parent Playlist ID.
 * @property {string} css                             The CSS class.
 * @property {PlaylistDirectoryControlContext} play   The play button context.
 * @property {object} pause                           PlaylistSound pause context.
 * @property {boolean} pause.paused                   Whether the PlaylistSound is currently paused.
 * @property {string} pause.icon                      The pause icon.
 * @property {boolean} pause.disabled                 Whether the pause button is disabled.
 * @property {PlaylistDirectoryVolumeContext} volume  PlaylistSound volume context.
 * @property {string} currentTime                     The current playing timestamp.
 * @property {string} durationTime                    The duration timestamp.
 */

/**
 * The World Playlist directory listing.
 * @extends {DocumentDirectory<Playlist>}
 */
class PlaylistDirectory extends DocumentDirectory {
  /** @override */
  static DEFAULT_OPTIONS = {
    collection: "Playlist",
    renderUpdateKeys: ["playing", "mode", "sounds", "sorting"],
    actions: {
      pinCurrentlyPlaying: PlaylistDirectory.#onPinCurrentlyPlaying,
      playlistBackward: PlaylistDirectory.#onPlaylistSkip,
      playlistForward: PlaylistDirectory.#onPlaylistSkip,
      playlistMode: PlaylistDirectory.#onPlaylistCycleMode,
      playlistPlay: PlaylistDirectory.#onPlaylistPlayback,
      playlistStop: PlaylistDirectory.#onPlaylistPlayback,
      soundCreate: PlaylistDirectory.#onSoundCreate,
      soundPause: PlaylistDirectory.#onSoundPlayback,
      soundPlay: PlaylistDirectory.#onSoundPlayback,
      soundRepeat: PlaylistDirectory.#onSoundToggleMode,
      soundStop: PlaylistDirectory.#onSoundPlayback,
      volumeExpand: PlaylistDirectory.#onVolumeExpand
    }
  };

  /** @override */
  static tabName = "playlists";

  /** @override */
  static PARTS = {
    header: super.PARTS.header,
    controls: {
      template: "templates/sidebar/tabs/playlist/controls.hbs"
    },
    directory: super.PARTS.directory,
    playing: {
      template: "templates/sidebar/tabs/playlist/playing.hbs",
      templates: ["templates/sidebar/tabs/playlist/sound-partial.hbs"]
    },
    footer: super.PARTS.footer
  };

  /**
   * Playlist mode button descriptors.
   * @type {Record<PLAYLIST_MODES, PlaylistDirectoryControlContext>}
   */
  static PLAYLIST_MODES = {
    [PLAYLIST_MODES.DISABLED]: {
      icon: "fa-solid fa-ban",
      label: "PLAYLIST.ModeDisabled"
    },
    [PLAYLIST_MODES.SEQUENTIAL]: {
      icon: "fa-regular fa-circle-right",
      label: "PLAYLIST.ModeSequential"
    },
    [PLAYLIST_MODES.SHUFFLE]: {
      icon: "fa-solid fa-shuffle",
      label: "PLAYLIST.ModeShuffle"
    },
    [PLAYLIST_MODES.SIMULTANEOUS]: {
      icon: "fa-solid fa-minimize",
      label: "PLAYLIST.ModeSimultaneous"
    }
  };

  /** @override */
  static _entryPartial = "templates/sidebar/tabs/playlist/playlist-partial.hbs";

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Track the playlist IDs which are currently expanded in the display.
   * @type {Set<string>}
   * @protected
   */
  _expanded = this.collection.reduce((set, {id, playing}) => {
    if ( playing ) set.add(id);
    return set;
  }, new Set());

  /**
   * Cache the set of Playlist and PlaylistSound documents that are displayed as playing when the directory is rendered.
   * @type {{context: PlaylistSoundRenderContext[], playlists: Playlist[], sounds: PlaylistSound[]}}
   * @protected
   */
  _playing = {
    context: [],
    playlists: [],
    sounds: []
  };

  /**
   * Whether the global volume controls are currently expanded.
   * @type {boolean}
   * @protected
   */
  _volumeExpanded = true;

  /**
   * The location of the currently-playing widget.
   * @type {"top"|"bottom"}
   */
  get currentlyPlayingLocation() {
    return game.settings.get("core", "playlist.playingLocation");
  }

  /**
   * The Playlist documents that are currently playing.
   * @returns {Playlist[]}
   */
  get playing() {
    return this._playing.playlists;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _createContextMenus() {
    /** @fires {hookEvents:getFolderContextOptions} */
    this._createContextMenu(this._getFolderContextOptions, ".folder .folder-header", {
      fixed: true,
      hookName: "getFolderContextOptions",
      parentClassHooks: false
    });
    /** @fires {hookEvents:getPlaylistContextOptions} */
    this._createContextMenu(this._getEntryContextOptions, ".playlist > header", {
      fixed: true,
      hookName: "getPlaylistContextOptions",
      parentClassHooks: false
    });
    /** @fires {hookEvents:getPlaylistSoundContextOptions} */
    this._createContextMenu(this._getSoundContextOptions, ".playlist .sound", {
      fixed: true,
      hookName: "getPlaylistSoundContextOptions",
      parentClassHooks: false
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getEntryContextOptions() {
    return [{
      name: "PLAYLIST.Edit",
      icon: '<i class="fa-solid fa-pen-to-square"></i>',
      callback: header => {
        const entry = header.closest("[data-entry-id]");
        const sheet = game.playlists.get(entry.dataset.entryId)?.sheet;
        if ( !sheet ) return;
        const options = { force: true };
        if ( !this.isPopout ) options.position = {
          top: entry.offsetTop - 24,
          left: window.innerWidth - ui.sidebar.element.offsetWidth - sheet.options.position.width - 10
        };
        return sheet.render(options);
      }
    }, {
      name: "PLAYLIST.BulkImport.Title",
      icon: '<i class="fa-solid fa-files"></i>',
      callback: async header => {
        const { entryId } = header.closest("[data-entry-id]")?.dataset ?? {};
        const playlist = game.playlists.get(entryId);
        if ( await playlist?.bulkImportDialog() ) this._expanded.add(playlist.id);
      }
    }].concat(super._getEntryContextOptions());
  }

  /* -------------------------------------------- */

  /**
   * Context menu options for individual PlaylistSounds.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getSoundContextOptions() {
    return [{
      name: "PLAYLIST.SoundEdit",
      icon: '<i class="fa-solid fa-pen-to-square"></i>',
      callback: li => {
        const {playlistId, soundId} = li.dataset;
        const sheet = game.playlists.get(playlistId)?.sounds.get(soundId)?.sheet;
        if ( !sheet ) return;
        const options = { force: true };
        if ( !this.isPopout ) options.position = {
          top: li.offsetTop - 24,
          left: window.innerWidth - ui.sidebar.element.offsetWidth - sheet.options.position.width - 10
        };
        return sheet.render(options);
      }
    }, {
      name: "PLAYLIST.SoundPreload",
      icon: '<i class="fa-solid fa-download"></i>',
      callback: li => {
        const {playlistId, soundId} = li.dataset;
        const sound = game.playlists.get(playlistId)?.sounds.get(soundId);
        if ( sound ) return game.audio.preload(sound.path);
      }
    }, {
      name: "PLAYLIST.SoundDelete",
      icon: '<i class="fa-solid fa-trash"></i>',
      callback: li => {
        const {playlistId, soundId} = li.dataset;
        return game.playlists.get(playlistId)?.sounds.get(soundId)?.deleteDialog({
          position: {
            top: Math.min(li.offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720
          }
        });
      }
    }];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    if ( !this.isPopout ) setInterval(this.updateTimestamps.bind(this), 1000);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    if ( options.parts.includes("playing") ) {
      const playing = this.element.querySelector(".currently-playing");
      playing.hidden = !this._playing.sounds.length;
      const directory = this.element.querySelector(".directory-list");
      directory.insertAdjacentElement(context.currentlyPlaying.location.top ? "beforebegin" : "afterend", playing);
    }

    if ( options.parts.includes("directory") ) {
      new foundry.applications.ux.DragDrop.implementation({
        dragSelector: ".directory-list .sound",
        dropSelector: ".directory-list",
        permissions: {
          dragstart: this._canDragStart.bind(this),
          drop: this._canDragDrop.bind(this)
        },
        callbacks: {
          dragstart: this._onDragStart.bind(this),
          drop: this._onDrop.bind(this)
        }
      }).bind(this.element);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareDirectoryContext(context, options) {
    await super._prepareDirectoryContext(context, options);
    Object.assign(this._playing, { context: [], playlists: [], sounds: [] });
    context.tree = this._prepareTreeContext(context, this.collection.tree);
  }

  /* -------------------------------------------- */

  /**
   * Augment the tree directory structure with playlist-level data objects for rendering.
   * @param {PlaylistDirectoryRenderContext} root   The root render context.
   * @param {object} node                           The tree node being prepared.
   * @returns {PlaylistDirectoryTreeContext}
   * @protected
   */
  _prepareTreeContext(root, node) {
    const { folder, depth } = node;
    return {
      folder, depth,
      entries: node.entries.map(p => this._preparePlaylistContext(root, p)),
      children: node.children.map(child => this._prepareTreeContext(root, child))
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for a playlist.
   * @param {PlaylistDirectoryRenderContext} root  The root render context.
   * @param {Playlist} playlist                    The Playlist document.
   * @returns {PlaylistRenderContext}
   * @protected
   */
  _preparePlaylistContext(root, playlist) {
    const { volumeToInput, volumeToPercentage } = foundry.audio.AudioHelper;
    if ( playlist.playing ) this._playing.playlists.push(playlist);

    // Playlist sounds
    const sounds = [];
    for ( const soundId of playlist.playbackOrder ) {
      const sound = playlist.sounds.get(soundId);
      if ( !sound.isOwner && !sound.playing ) continue;
      const { id, uuid, isOwner, name, pausedTime, playing, repeat, volume } = sound;
      const s = {
        id, uuid, isOwner, name, playing, repeat,
        playlistId: playlist.id,
        css: playing ? "playing" : "",
        play: {
          icon: `fa-solid ${playing ? "fa-square" : pausedTime ? "fa-play-circle" : "fa-play"}`,
          label: pausedTime ? "PLAYLIST.SoundResume" : "PLAYLIST.SoundPlay"
        }
      };
      if ( sound.sound && !sound.sound.failed && (playing || pausedTime) ) {
        const paused = !playing && pausedTime;
        const modifier = volumeToInput(volume);
        s.pause = {
          paused,
          disabled: !isOwner || paused,
          icon: `fa-solid ${playing && !sound.sound?.loaded ? "fa-spinner fa-spin" : "fa-pause"}`
        };
        s.volume = {
          modifier,
          field: new NumberField({ min: 0, max: 1, step: .05 }),
          dataset: {
            tooltip: volumeToPercentage(modifier)
          },
          aria: {
            label: game.i18n.localize("PLAYLIST_SOUND.FIELDS.volume.label"),
            valuetext: volumeToPercentage(modifier, { label: true })
          }
        };
        s.currentTime = this.constructor.formatTimestamp(playing ? sound.sound.currentTime : pausedTime);
        s.durationTime = this.constructor.formatTimestamp(sound.sound.duration);
        this._playing.sounds.push(sound);
        this._playing.context.push(s);
      }

      sounds.push(s);
    }

    // Playlist configuration
    const { id, isOwner, mode, name, playing } = playlist;
    const expanded = this._expanded.has(id);
    return {
      expanded, id, isOwner, name, playing, sounds,
      mode: this.constructor.PLAYLIST_MODES[mode],
      disabled: !isOwner || (mode === PLAYLIST_MODES.DISABLED),
      css: [expanded ? "expanded" : "", playing ? "playing": ""].filterJoin(" ")
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "controls": await this._prepareControlsContext(context, options); break;
      case "playing": await this._preparePlayingContext(context, options); break;
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the volume controls part.
   * @param {PlaylistDirectoryRenderContext} context
   * @param {HandlebarsRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareControlsContext(context, options) {
    const { volumeToInput, volumeToPercentage } = foundry.audio.AudioHelper;
    const settings = ["globalPlaylistVolume", "globalAmbientVolume", "globalInterfaceVolume"];
    const [music, environment, iface] = settings.map(setting => volumeToInput(game.settings.get("core", setting)));
    context.controls = {
      expanded: this._volumeExpanded,
      icon: game.audio.globalMute ? "fa-volume-xmark" : "fa-volume-low",
      music: {
        modifier: music,
        name: "globalPlaylistVolume",
        field: new NumberField({ min: 0, max: 1, step: .05 }),
        dataset: {
          tooltip: volumeToPercentage(music)
        },
        aria: {
          label: game.i18n.localize("AUDIO.CHANNELS.MUSIC.label"),
          valuetext: volumeToPercentage(music, { label: true })
        }
      },
      environment: {
        modifier: environment,
        name: "globalAmbientVolume",
        field: new NumberField({ min: 0, max: 1, step: .05 }),
        dataset: {
          tooltip: volumeToPercentage(environment)
        },
        aria: {
          label: game.i18n.localize("AUDIO.CHANNELS.ENVIRONMENT.label"),
          valuetext: volumeToPercentage(environment, { label: true })
        }
      },
      interface: {
        modifier: iface,
        name: "globalInterfaceVolume",
        field: new NumberField({ min: 0, max: 1, step: .05 }),
        dataset: {
          tooltip: volumeToPercentage(iface)
        },
        aria: {
          label: game.i18n.localize("AUDIO.CHANNELS.INTERFACE.label"),
          valuetext: volumeToPercentage(iface, { label: true })
        }
      }
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the currently playing part.
   * @param {PlaylistDirectoryRenderContext} context
   * @param {HandlebarsRenderOptions} options
   * @returns {Promise<void>}
   * @protected
   */
  async _preparePlayingContext(context, options) {
    const top = this.currentlyPlayingLocation === "top";
    context.currentlyPlaying = {
      class: `location-${top ? "top" : "bottom"}`,
      location: { top, bottom: !top },
      pin: {
        label: `PLAYLIST.PinTo${top ? "Bottom" : "Top"}`,
        caret: top ? "down" : "up"
      },
      sounds: this._playing.context
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareDuplicateData(document) {

    // Ensure playback is disabled for playlists and their sounds
    const data = super._prepareDuplicateData(document);
    data.playing = false;
    data.sounds = document.sounds.toObject();
    for ( const sound of data.sounds ) sound.playing = false;
    return data;
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  collapseAll() {
    super.collapseAll();
    this._expanded.clear();
    this._volumeExpanded = false;
    this.element.querySelectorAll(".playlist.document").forEach(p => p.classList.remove("expanded"));
    this.element.querySelector(".global-volume").classList.remove("expanded");
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("change", this.#onVolumeChange.bind(this));
  }

  /* -------------------------------------------- */

  /** @override */
  async _onClickEntry(event, target) {
    const entry = target.closest("[data-entry-id]");
    const { entryId } = entry.dataset;
    entry.classList.toggle("expanded");
    if ( entry.classList.contains("expanded") ) this._expanded.add(entryId);
    else this._expanded.delete(entryId);
  }

  /* -------------------------------------------- */

  /**
   * Handle modifying a global volume slider.
   * @param {HTMLRangePickerElement} slider  The slider.
   * @protected
   */
  _onGlobalVolume(slider) {
    const { inputToVolume, volumeToPercentage } = foundry.audio.AudioHelper;
    const volume = inputToVolume(slider.value);
    const tooltip = volumeToPercentage(slider.value);
    const label = volumeToPercentage(slider.value, { label: true });
    slider.dataset.tooltipText = tooltip;
    slider.ariaValueText = label;
    game.tooltip.activate(slider, { text: tooltip });
    game.settings.set("core", slider.name, volume);
    if ( game.audio.globalMute ) {
      game.audio.globalMute = false;
      ui.hotbar._updateToggles();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the location of the currently playing widget.
   * @this {PlaylistDirectory}
   */
  static #onPinCurrentlyPlaying() {
    game.settings.set("core", "playlist.playingLocation", this.currentlyPlayingLocation === "top" ? "bottom" : "top");
  }

  /* -------------------------------------------- */

  /**
   * Handle cycling the playlist's playback mode.
   * @this {PlaylistDirectory}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onPlaylistCycleMode(event, target) {
    const playlist = game.playlists.get(target.closest("[data-entry-id]")?.dataset.entryId);
    return playlist?.cycleMode();
  }

  /* -------------------------------------------- */

  /**
   * Handle starting or stopping a playlist.
   * @this {PlaylistDirectory}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onPlaylistPlayback(event, target) {
    const playing = target.dataset.action === "playlistPlay";
    const playlist = game.playlists.get(target.closest("[data-entry-id]")?.dataset.entryId);
    if ( playing ) return playlist?.playAll();
    return playlist?.stopAll();
  }

  /* -------------------------------------------- */

  /**
   * Handle advancing the playlist to the next or previous sound.
   * @this {PlaylistDirectory}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onPlaylistSkip(event, target) {
    const direction = target.dataset.action === "playlistForward" ? 1 : -1;
    const playlist = game.playlists.get(target.closest("[data-entry-id]")?.dataset.entryId);
    return playlist?.playNext(null, { direction });
  }

  /* -------------------------------------------- */

  /**
   * Handle adding a new track to a playlist.
   * @this {PlaylistDirectory}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onSoundCreate(event, target) {
    const playlist = game.playlists.get(target.closest("[data-entry-id]")?.dataset.entryId);
    if ( !playlist ) return;
    const sound = new PlaylistSound$1.implementation({ name: game.i18n.localize("SOUND.New") }, { parent: playlist });
    sound.sheet.render({ force: true, position: { top: target.offsetTop, left: window.innerWidth - 670 } });
  }

  /* -------------------------------------------- */

  /**
   * Handle starting, stopping, or pausing a track.
   * @this {PlaylistDirectory}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onSoundPlayback(event, target) {
    const {playlistId, soundId} = target.closest(".sound")?.dataset ?? {};
    const playlist = game.playlists.get(playlistId);
    const sound = playlist?.sounds.get(soundId);
    switch ( target.dataset.action ) {
      case "soundPause": return sound.update({ playing: false, pausedTime: sound.sound.currentTime });
      case "soundPlay": return playlist.playSound(sound);
      case "soundStop": return playlist.stopSound(sound);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the sound's repeat mode.
   * @this {PlaylistDirectory}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onSoundToggleMode(event, target) {
    const {playlistId, soundId} = target.closest(".sound")?.dataset ?? {};
    const sound = game.playlists.get(playlistId)?.sounds.get(soundId);
    return sound?.update({repeat: !sound?.repeat});
  }

  /* -------------------------------------------- */

  /**
   * Handle modifying a playing PlaylistSound's volume.
   * @param {HTMLRangePickerElement} slider  The volume slider.
   * @protected
   */
  _onSoundVolume(slider) {
    const {inputToVolume, volumeToPercentage} = foundry.audio.AudioHelper;
    const li = slider.closest(".sound");
    const playlist = game.playlists.get(li.dataset.playlistId);
    const sound = playlist.sounds.get(li.dataset.soundId);

    // Get the desired target volume.
    const volume = inputToVolume(slider.value);
    if ( volume === sound.volume ) return;

    // Immediately apply a local adjustment.
    sound.updateSource({volume});
    sound.sound?.fade(volume, { duration: PlaylistSound$1.VOLUME_DEBOUNCE_MS });
    const tooltip = volumeToPercentage(slider.value);
    const label = volumeToPercentage(slider.value, {label: true});
    slider.dataset.tooltipText = tooltip;
    slider.ariaValueText = label;
    game.tooltip.activate(slider, {text: tooltip});

    // Debounce a change to the database.
    if ( sound.isOwner ) sound.debounceVolume(volume);
  }

  /* -------------------------------------------- */

  /**
   * Handle a sound or global volume change.
   * @param {ChangeEvent} event  The triggering event.
   */
  #onVolumeChange(event) {
    const target = event.target.closest(".global-volume-slider, .sound-volume");
    if ( !target ) return;
    if ( target.matches(".global-volume-slider") ) this._onGlobalVolume(target);
    else this._onSoundVolume(target);
  }

  /* -------------------------------------------- */

  /**
   * Handle global volume control expand and collapse.
   * @this {PlaylistDirectory}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target element.
   */
  static #onVolumeExpand(event, target) {
    const entry = target.closest(".global-volume");
    entry.classList.toggle("expanded");
    this._volumeExpanded = entry.classList.contains("expanded");
  }

  /* -------------------------------------------- */

  /**
   * Update the displayed timestamps for all currently playing audio sources every second.
   */
  updateTimestamps() {
    const playing = document.querySelectorAll(".playlists-sidebar .currently-playing");
    if ( !playing.length || !this._playing.sounds.length ) return;
    for ( const el of playing ) {
      for ( const sound of this._playing.sounds ) {
        const li = el.querySelector(`.sound[data-sound-uuid="${sound.uuid}"]`);
        if ( !li ) continue;

        // Update current and max playback time.
        const current = li.querySelector(".current");
        const ct = sound.playing ? sound.sound.currentTime : sound.pausedTime;
        if ( current ) current.textContent = this.constructor.formatTimestamp(ct);
        const max = li.querySelector(".duration");
        if ( max ) max.textContent = this.constructor.formatTimestamp(sound.sound.duration);

        // Remove the loading spinner.
        const play = li.querySelector(".pause");
        if ( play.classList.contains("fa-spinner") ) {
          play.classList.remove("fa-spin");
          play.classList.replace("fa-spinner", "fa-pause");
        }
      }
    }
  }

  /* -------------------------------------------- */
  /*  Search & Filter                             */
  /* -------------------------------------------- */

  /**
   * Recursively marks a folder and all of its descendants as visible.
   * @param {Folder} folder                  The folder whose subtree must be shown.
   * @param {Set<string>} entryIds           IDs of playlists to keep visible.
   * @param {Set<string>} soundIds           IDs of tracks to keep visible.
   * @param {Set<string>} folderIds          IDs of folders to keep visible.
   * @param {Set<string>} autoExpandIds      IDs of folders to auto-expand.
   */
  #addFolderSubtree(folder, entryIds, soundIds, folderIds, autoExpandIds) {
    folderIds.add(folder.id);
    autoExpandIds.add(folder.id);

    // Playlists directly under this folder
    for ( const p of folder.contents ) {
      entryIds.add(p.id);
      p.sounds.forEach(s => soundIds.add(s.id));
    }

    // Recurse into sub-folders
    for ( const child of folder.children ) {
      this.#addFolderSubtree(child, entryIds, soundIds, folderIds, autoExpandIds);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onMatchSearchEntry(query, entryIds, element, {soundIds=new Set(), plNameHits=new Set()}={}) {
    const entryId = element.dataset.entryId;
    const playlistVisible = !query || entryIds.has(entryId);
    const playlistNameHit = plNameHits.has(entryId);

    // Playlist row
    element.style.display = playlistVisible ? "flex" : "none";

    // Track rows
    for ( const tr of element.querySelectorAll(".sound") ) {
      const showTrack = !query || playlistNameHit || soundIds.has(tr.dataset.soundId);
      tr.style.display = showTrack ? "flex" : "none";
    }

    // Expansion state
    element.classList.toggle("expanded", this._expanded.has(entryId) || (query && playlistVisible));
  }

  /* -------------------------------------------- */

  /** @override */
  _matchSearchEntries(query, entryIds, folderIds, autoExpandIds, options={}) {
    const clean = foundry.applications.ux.SearchFilter.cleanQuery;
    const soundIds = options.soundIds ??= new Set();
    const plNameHits = options.plNameHits ??= new Set();

    // Folder name matches
    if ( query ) {
      for ( const folder of this.collection.folders ) {
        if ( query.test(clean(folder.name)) ) {
          this.#addFolderSubtree(folder, entryIds, soundIds, folderIds, autoExpandIds);
        }
      }
    }

    // Playlists and tracks
    for ( const pl of this.collection ) {
      const nameHit = query && query.test(clean(pl.name));
      if ( nameHit ) plNameHits.add(pl.id);

      let trackHit = false;
      for ( const s of pl.sounds ) {
        if ( nameHit || (query && query.test(clean(s.name))) ) {
          soundIds.add(s.id);
          trackHit = true;
        }
      }

      if ( nameHit || trackHit ) {
        entryIds.add(pl.id);
        for ( let f = pl.folder; f; f = f.folder ) {
          folderIds.add(f.id);
          autoExpandIds.add(f.id);
        }
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _matchSearchFolders(query, folderIds, autoExpandIds) {
    // Do not attempt to match folder names.
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragStart(event) {
    if ( event.target.classList.contains("sound") ) {
      const {playlistId, soundId} = event.target.dataset;
      const sound = game.playlists.get(playlistId)?.sounds.get(soundId);
      if ( sound ) event.dataTransfer.setData("text/plain", JSON.stringify(sound.toDragData()));
      return;
    }
    super._onDragStart(event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    const data = TextEditor$1.implementation.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;
    const sound = await PlaylistSound$1.implementation.fromDropData(data);
    const playlist = sound.parent;
    const targetPlaylistId = target.dataset.entryId || target.dataset.playlistId;

    // Copying to another playlist.
    if ( targetPlaylistId !== playlist?.id ) {
      const targetPlaylist = game.playlists.get(targetPlaylistId);
      await PlaylistSound$1.implementation.create(sound, {parent: targetPlaylist});
      return;
    }

    // If there's nothing to sort relative to, or the sound was dropped onto itself, do nothing.
    const targetId = target.dataset.soundId;
    if ( !targetId || !playlist || (targetId === sound.id) ) return;
    await sound.sortRelative({
      target: playlist.sounds.get(targetId),
      siblings: playlist.sounds.filter(s => s.id !== sound.id)
    });
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * 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.
   * @protected
   */
  static formatTimestamp(seconds) {
    if ( !Number.isFinite(seconds) ) return "∞";
    seconds ??= 0;
    const minutes = Math.floor(seconds / 60);
    seconds = Math.round(seconds % 60);
    return `${minutes}:${seconds.paddedString(2)}`;
  }

  /* -------------------------------------------- */

  /**
   * Register playlist directory specific settings.
   * @internal
   */
  static _registerSettings() {
    game.settings.register("core", "playlist.playingLocation", {
      scope: "client",
      config: false,
      type: new StringField({choices: ["top", "bottom"], initial: "top"}),
      onChange: () => ui.playlists.render({parts: ["playing"]})
    });
  }
}

/**
 * @import RollTable from "@client/documents/roll-table.mjs";
 */

/**
 * The World RollTable directory listing.
 * @extends {DocumentDirectory<RollTable>}
 */
class RollTableDirectory extends DocumentDirectory {
  /** @override */
  static DEFAULT_OPTIONS = {
    collection: "RollTable"
  };

  /** @override */
  static tabName = "tables";

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _getEntryContextOptions() {
    return [
      {
        name: "TABLE.ACTIONS.DrawResult",
        icon: '<i class="fa-solid fa-dice-d20"></i>',
        callback: li => {
          const table = this.collection.get(li.dataset.entryId);
          table.draw({ roll: true, displayChat: true });
        }
      },
      ...super._getEntryContextOptions()
    ];
  }
}

/**
 * @import Scene from "@client/documents/scene.mjs";
 */

/**
 * The World Scene directory listing.
 * @extends {DocumentDirectory<Scene>}
 */
class SceneDirectory extends DocumentDirectory {
  /** @override */
  static DEFAULT_OPTIONS = {
    renderUpdateKeys: ["background", "thumb"],
    collection: "Scene"
  };

  /** @override */
  static tabName = "scenes";

  /** @override */
  static _entryPartial = "templates/sidebar/partials/scene-partial.hbs";

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _canRender(options) {
    if ( !game.user.isGM ) return false;
    return super._canRender(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getEntryContextOptions() {
    const options = super._getEntryContextOptions();
    return [{
      name: "SCENE.View",
      icon: '<i class="fa-solid fa-eye"></i>',
      condition: li => !canvas.ready || (li.dataset.entryId !== canvas.scene.id),
      callback: li => game.scenes.get(li.dataset.entryId)?.view()
    }, {
      name: "SCENE.Activate",
      icon: '<i class="fa-solid fa-bullseye"></i>',
      condition: li => game.user.isGM && !game.scenes.get(li.dataset.entryId)?.active,
      callback: li => game.scenes.get(li.dataset.entryId)?.activate()
    }, {
      name: "SCENE.Configure",
      icon: '<i class="fa-solid fa-gears"></i>',
      callback: li => game.scenes.get(li.dataset.entryId)?.sheet.render({ force: true })
    }, {
      name: "SCENE.Notes",
      icon: '<i class="fa-solid fa-scroll"></i>',
      condition: li => game.scenes.get(li.dataset.entryId)?.journal,
      callback: li => {
        const scene = game.scenes.get(li.dataset.entryId);
        scene?.journal?.sheet?.render(true, { pageId: scene.journalEntryPage });
      }
    }, {
      name: "SCENE.ToggleNav",
      icon: '<i class="fa-solid fa-compass"></i>',
      condition: li => game.user.isGM && !game.scenes.get(li.dataset.entryId)?.active,
      callback: li => {
        const scene = game.scenes.get(li.dataset.entryId);
        scene?.update({ navigation: !scene.navigation });
      }
    }, {
      name: "SCENE.GenerateThumb",
      icon: '<i class="fa-solid fa-image"></i>',
      condition: li => {
        const scene = game.scenes.get(li.dataset.entryId);
        return (scene?.background.src || scene?.tiles.size) && !game.settings.get("core", "noCanvas");
      },
      callback: async li => {
        const scene = game.scenes.get(li.dataset.entryId);
        try {
          const { thumb } = await scene?.createThumbnail() ?? {};
          if ( thumb ) await scene.update({ thumb }, { diff: false });
          ui.notifications.info("SCENE.GenerateThumbSuccess", { format: { name: scene.name } });
        } catch(err) {
          ui.notifications.error(err.message);
        }
      }
    }].concat(options.filter(o => o.name !== "OWNERSHIP.Configure"));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getFolderContextOptions() {
    return super._getFolderContextOptions().filter(o => o.name !== "OWNERSHIP.Configure");
  }
}

/**
 * The sidebar settings tab.
 * @extends {AbstractSidebarTab}
 * @mixes HandlebarsApplication
 */
class Settings extends HandlebarsApplicationMixin(AbstractSidebarTab) {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      title: "SIDEBAR.TabSettings"
    },
    actions: {
      openApp: Settings.#onOpenApp,
      notifyUpdate: Settings.#onNotifyUpdate
    }
  };

  /** @override */
  static tabName = "settings";

  /** @override */
  static PARTS = {
    settings: {
      template: "templates/sidebar/tabs/settings.hbs",
      root: true
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const coreUpdate = game.user.isGM && game.data.coreUpdate.hasUpdate
      ? game.i18n.format("SETUP.UpdateAvailable", {
        type: game.i18n.localize("Software"),
        channel: game.data.coreUpdate.channel,
        version: game.data.coreUpdate.version
      })
      : null;
    const systemUpdate = game.user.isGM && game.data.systemUpdate.hasUpdate
      ? game.i18n.format("SETUP.UpdateAvailable", {
        type: game.i18n.localize("System"),
        channel: game.data.system.title,
        version: game.data.systemUpdate.version
      })
      : null;
    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;
    const isDemo = game.data.demoMode;
    return Object.assign(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
    });
  }

  /* -------------------------------------------- */

  /**
   * Open an application.
   * @this {Settings}
   * @type {ApplicationClickAction}
   */
  static async #onOpenApp(event) {
    switch (event.target.dataset.app) {
      case "configure":
        game.settings.sheet.render({force: true});
        break;
      case "modules":
        new foundry.applications.sidebar.apps.ModuleManagement().render({force: true});
        break;
      case "world":
        new foundry.applications.sidebar.apps.WorldConfig({world: game.world}).render({force: true});
        break;
      case "players":
        return ui.menu.items.players.onClick();
      case "setup":
        return game.shutDown();
      case "support":
        new foundry.applications.sidebar.apps.SupportDetails().render({force: true});
        break;
      case "controls":
        new foundry.applications.sidebar.apps.ControlsConfig().render({force: true});
        break;
      case "tours":
        new foundry.applications.sidebar.apps.ToursManagement().render({force: true});
        break;
      case "invitations":
        new foundry.applications.sidebar.apps.InvitationLinks().render({force: true});
        break;
      case "logout":
        return game.logOut();
    }
  }

  /* -------------------------------------------- */

  static async #onNotifyUpdate(event) {
    const key = event.target.dataset.update === "core" ? "CoreUpdateInstructions" : "SystemUpdateInstructions";
    ui.notifications.info(`SETUP.${key}`, {localize: true});
  }
}

var _module$C = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActorDirectory: ActorDirectory,
  CardsDirectory: CardsDirectory,
  ChatLog: ChatLog,
  CombatTracker: CombatTracker,
  CompendiumDirectory: CompendiumDirectory,
  ItemDirectory: ItemDirectory,
  JournalDirectory: JournalDirectory,
  MacroDirectory: MacroDirectory,
  PlaylistDirectory: PlaylistDirectory,
  RollTableDirectory: RollTableDirectory,
  SceneDirectory: SceneDirectory,
  Settings: Settings
});

/**
 * @import {ApplicationConfiguration, ApplicationRenderContext} from "../_types.mjs"
 * @import {HandlebarsRenderOptions} from "../api/handlebars-application.mjs"
 */

/**
 * @typedef SidebarTabDescriptor
 * @property {string} [tooltip]       The tab's tooltip.
 * @property {string} [icon]          The tab's Font Awesome icon class.
 * @property {string} [documentName]  A Document name to retrieve tooltip and icon information from automatically.
 * @property {boolean} [gmOnly]       Whether the tab is only rendered for GM users.
 */

/**
 * The main sidebar application.
 * @extends {ApplicationV2<ApplicationConfiguration, HandlebarsRenderOptions>}
 * @mixes HandlebarsApplication
 */
class Sidebar extends HandlebarsApplicationMixin(ApplicationV2) {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "sidebar",
    tag: "aside",
    window: {
      frame: false,
      positioned: false
    },
    actions: {
      toggleState: Sidebar.#onToggleState
    }
  };

  /**
   * Tab configuration.
   * @type {Record<string, SidebarTabDescriptor>}
   */
  static TABS = {
    chat: {
      documentName: "ChatMessage"
    },
    combat: {
      documentName: "Combat"
    },
    scenes: {
      documentName: "Scene",
      gmOnly: true
    },
    actors: {
      documentName: "Actor"
    },
    items: {
      documentName: "Item"
    },
    journal: {
      documentName: "JournalEntry",
      tooltip: "SIDEBAR.TabJournal"
    },
    tables: {
      documentName: "RollTable"
    },
    cards: {
      documentName: "Cards"
    },
    macros: {
      documentName: "Macro"
    },
    playlists: {
      documentName: "Playlist"
    },
    compendium: {
      tooltip: "SIDEBAR.TabCompendium",
      icon: "fa-solid fa-book-atlas"
    },
    settings: {
      tooltip: "SIDEBAR.TabSettings",
      icon: "fa-solid fa-gears"
    }
  };

  /** @override */
  tabGroups = {primary: "chat"};

  /** @override */
  static PARTS = {
    tabs: {
      id: "tabs",
      template: "templates/sidebar/tabs.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The sidebar content.
   * @type {HTMLElement}
   */
  #content;

  /**
   * Whether the sidebar is currently expanded.
   * @type {boolean}
   */
  get expanded() {
    return this.#content.classList.contains("expanded");
  }

  /**
   * The currently popped-out sidebar tabs.
   * @type {Record<string, SidebarTab|AbstractSidebarTab>}
   */
  popouts = {};

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    for ( const id of Object.keys(this.constructor.TABS) ) {
      this.constructor.PARTS[id] = { template: "templates/sidebar/tab.hbs" };
    }
    super._configureRenderOptions(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    const content = this.#content = document.createElement("div");
    content.id = "sidebar-content";
    content.classList.add("flexcol", "active-chat");
    this.element.append(content);
    content.append(...this.element.querySelectorAll(":scope > template"));
    await this.#renderTabs(Object.keys(this.constructor.TABS));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    if ( partId === "tabs" ) await this._prepareTabContext(context, options);
    context.partId = partId;
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the tabs.
   * @param {ApplicationRenderContext} context  Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options   Options for configuring rendering behavior.
   * @protected
   */
  async _prepareTabContext(context, options) {
    context.tabs = Object.entries(this.constructor.TABS).reduce((obj, [k, v]) => {
      let { documentName, gmOnly, tooltip, icon } = v;
      if ( gmOnly && !game.user.isGM ) return obj;
      if ( documentName ) {
        tooltip ??= getDocumentClass(documentName).metadata.labelPlural;
        icon ??= CONFIG[documentName]?.sidebarIcon;
      }
      obj[k] = { tooltip, icon };
      obj[k].active = this.tabGroups.primary === k;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderHTML(context, options) {
    // If this is the first render, generate stub elements that each tab may render itself into.
    if ( options.isFirstRender ) return super._renderHTML(context, options);

    // Otherwise re-render the tabs that were requested.
    await this.#renderTabs(options.parts);
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Render the requested sidebar tabs.
   * @param {string[]} tabs  The IDs of the tabs to render.
   */
  async #renderTabs(tabs) {
    const promises = [];
    for ( const id of tabs ) {
      const tab = ui[id];
      if ( !tab ) continue;
      // TODO: When all tabs are converted to App V2 we can remove this line.
      const fn = tab._render ?? tab.render;
      promises.push(fn.call(tab, true).catch(err => Hooks$1.onError("Sidebar#render", err, {
        msg: `Failed to render Sidebar tab ${id}`,
        log: "error",
        name: id
      })));
    }
    return Promise.allSettled(promises);
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickTab(event) {
    const target = event.target.closest("[data-tab]");
    const { tab } = target?.dataset ?? {};
    const app = ui[tab];
    if ( app && (event.button === 2) ) app.renderPopout();
    else {
      const wasActive = target?.ariaPressed === "true";
      super._onClickTab(event);
      if ( this.expanded && wasActive ) this.collapse();
      else if ( !this.expanded ) this.expand();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle collapsing or expanding the sidebar.
   * @this {Sidebar}
   */
  static #onToggleState() {
    this.toggleExpanded();
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  changeTab(tab, group, options={}) {
    const prev = ui[this.tabGroups[group]];
    if ( prev === ui[tab] ) return;
    super.changeTab(tab, group, options);
    prev?._doEvent?.(prev._onDeactivate, { eventName: "deactivate", hookName: "deactivate" });
    const next = ui[tab];
    next?._doEvent?.(next._onActivate, { eventName: "activate", hookName: "activate" });
    Hooks$1.callAll("changeSidebarTab", ui[tab]);
    this.#content.className = this.#content.className.replace(/active-\w+/, "");
    this.#content.classList.add(`active-${tab}`);
  }

  /* -------------------------------------------- */

  /**
   * Collapse the sidebar.
   */
  collapse() {
    this.toggleExpanded(false);
  }

  /* -------------------------------------------- */

  /**
   * Expand the sidebar.
   */
  expand() {
    this.toggleExpanded(true);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the expanded state of the sidebar.
   * @param {boolean} [expanded]  Force the expanded state to the provided value, otherwise toggle the state.
   * @fires {hookEvents:collapseSidebar}
   */
  toggleExpanded(expanded) {
    expanded ??= !this.expanded;
    const expander = this.element.querySelector('.tabs [data-action="toggleState"]');
    expander.classList.remove("fa-caret-left", "fa-caret-right");
    expander.classList.add(`fa-caret-${expanded ? "right" : "left"}`);
    expander.dataset.tooltip = expanded ? "Collapse" : "Expand";
    expander.ariaLabel = game.i18n.localize(expander.dataset.tooltip);
    this.#content.classList.toggle("expanded", expanded);
    ui.chat._toggleNotifications();
    Hooks$1.callAll("collapseSidebar", this, !expanded);
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  activateTab(tabName) {
    foundry.utils.logCompatibilityWarning("Sidebar#activateTab is deprecated. Please use Sidebar#changeTab instead.", {
      since: 13, until: 15
    });
    this.changeTab(tabName, "primary");
  }
}

var _module$B = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbstractSidebarTab: AbstractSidebarTab,
  DocumentDirectory: DocumentDirectory,
  Sidebar: Sidebar,
  apps: _module$D,
  tabs: _module$C
});

/**
 * @import {DocumentOwnershipLevel} from "@common/constants.mjs"
 * @import FormDataExtended from "./ux/form-data-extended.mjs"
 */

/**
 * @typedef ApplicationConfiguration
 * @property {string} id                        An HTML element identifier used for this Application instance
 * @property {string} uniqueId                  An string discriminator substituted for {id} in the default
 *                                              HTML element identifier for the class
 * @property {string[]} classes                 An array of CSS classes to apply to the Application
 * @property {string} tag                       The HTMLElement tag type used for the outer Application frame
 * @property {ApplicationWindowConfiguration} window  Configuration of the window behaviors for this Application
 * @property {Record<string, ApplicationClickAction|{handler: ApplicationClickAction, buttons: number[]}>} actions
 *                                              Click actions supported by the Application and their event handler
 *                                              functions. A handler function can be defined directly which only
 *                                              responds to left-click events. Otherwise, an object can be declared
 *                                              containing both a handler function and an array of buttons which are
 *                                              matched against the PointerEvent#button property.
 * @property {ApplicationFormConfiguration} [form] Configuration used if the application top-level element is a form or
 *                                                 dialog
 * @property {Partial<ApplicationPosition>} position  Default positioning data for the application
 */

/**
 * @typedef ApplicationPosition
 * @property {number} top                       Window offset pixels from top
 * @property {number} left                      Window offset pixels from left
 * @property {number|"auto"} width              Un-scaled pixels in width or "auto"
 * @property {number|"auto"} height             Un-scaled pixels in height or "auto"
 * @property {number} scale                     A numeric scaling factor applied to application dimensions
 * @property {number} zIndex                    A z-index of the application relative to siblings
 */

/**
 * @typedef ApplicationWindowConfiguration
 * @property {boolean} [frame=true]             Is this Application rendered inside a window frame?
 * @property {boolean} [positioned=true]        Can this Application be positioned via JavaScript or only by CSS
 * @property {string} [title]                   The window title. Displayed only if the application is framed
 * @property {string|false} [icon]              An optional Font Awesome icon class displayed left of the window title
 * @property {ApplicationHeaderControlsEntry[]} [controls]  An array of window control entries
 * @property {boolean} [minimizable=true]       Can the window app be minimized by double-clicking on the title
 * @property {boolean} [resizable=false]        Is this window resizable?
 * @property {string} [contentTag="section"]    A specific tag name to use for the .window-content element
 * @property {string[]} [contentClasses]        Additional CSS classes to apply to the .window-content element
 */

/**
 * @typedef ApplicationFormConfiguration
 * @property {ApplicationFormSubmission} handler
 * @property {boolean} submitOnChange
 * @property {boolean} closeOnSubmit
 */

/**
 * @typedef ApplicationHeaderControlsEntry
 * @property {string} icon                      A font-awesome icon class which denotes the control button
 * @property {string} label                     The text label for the control button. This label will be automatically
 *                                              localized when the button is rendered
 * @property {string} action                    The action name triggered by clicking the control button
 * @property {boolean|(() => boolean)} [visible] Is the control button visible for the current client?
 * @property {DocumentOwnershipLevel} [ownership] A key or value in {@link CONST.DOCUMENT_OWNERSHIP_LEVELS} that
 *                                                restricts visibility of this option for the current user. This option
 *                                                only applies to DocumentSheetV2 instances.
 * @property {(event: PointerEvent) => void|Promise<void>} [onClick] A custom click handler function. Asynchronous
 *                                                                   functions are not awaited.
 */

/**
 * @typedef ApplicationConstructorParams
 * @property {ApplicationPosition} position
 */

/**
 * @typedef ApplicationRenderOptions
 * @property {boolean} [force=false]            Force application rendering. If true, an application which does not
 *                                              yet exist in the DOM is added. If false, only applications which
 *                                              already exist are rendered.
 * @property {ApplicationPosition} [position]   A specific position at which to render the Application
 * @property {ApplicationWindowRenderOptions} [window]  Updates to the Application window frame
 * @property {string[]} [parts]                 Some Application classes, for example the HandlebarsApplication,
 *                                              support re-rendering a subset of application parts instead of the full
 *                                              Application HTML.
 * @property {boolean} [isFirstRender]          Is this render the first one for the application? This property is
 *                                              populated automatically.
 * @property {Record<string, string>|string} [tab]  A tab to activate. Either the tab's ID for applications with only
 *                                                  one tab group, or an object of tab groups to tab IDs. Re-rendering
 *                                                  an Application with this option will not trigger changeTab.
 */

/**
 * @typedef ApplicationWindowRenderOptions
 * @property {string} title                     Update the window title with a new value?
 * @property {string|false} icon                Update the window icon with a new value?
 * @property {boolean} controls                 Re-render the window controls menu?
 */

/**
 * @typedef ApplicationRenderContext   Context data provided to the renderer
 * @property {Record<string, ApplicationTab>} [tabs]    Tab data prepared from an entry in
 *   {@link foundry.applications.api.ApplicationV2.TABS}
 */

/**
 * @typedef ApplicationClosingOptions
 * @property {boolean} animate                  Whether to animate the close, or perform it instantaneously
 * @property {boolean} closeKey                 Whether the application was closed via keypress.
 * @property {boolean} submitted                Is the application being closed because a form was submitted?
 */

/**
 * @callback ApplicationClickAction             An on-click action supported by the Application. Run in the context of
 *                                              a {@link foundry.applications.api.HandlebarsApplicationMixin}.
 * @param {PointerEvent} event                  The originating click event
 * @param {HTMLElement} target                  The capturing HTML element which defines the [data-action]
 * @returns {void|Promise<void>}
 */

/**
 * @callback ApplicationFormSubmission          A form submission handler method. Run in the context of a
 *                                              {@link foundry.applications.api.HandlebarsApplicationMixin}.
 * @param {SubmitEvent|Event} event             The originating form submission or input change event
 * @param {HTMLFormElement} form                The form element that was submitted
 * @param {FormDataExtended} formData           Processed data for the submitted form
 * @returns {Promise<any>}
 */

/**
 * @typedef ApplicationTabsConfiguration
 * @property {{id: string; icon?: string; label?: string; tooltip?: string, cssClass?: string}[]} tabs
 *                                  An array of tab configuration data
 * @property {string} [initial]     The tab in this group that will be active on first render
 * @property {string} [labelPrefix] A localization path prefix for all tabs in the group: if set, a label is generated
 *                                  for each tab using a full path of `${labelPrefix}.${tabId}`.
 */

/**
 * @typedef ApplicationTab
 * @property {string}  id
 * @property {string}  group
 * @property {boolean} active
 * @property {string}  cssClass
 * @property {string}  [label]
 * @property {string}  [icon]
 * @property {string}  [tooltip]
 */

/**
 * @typedef FormNode
 * @property {boolean} fieldset
 * @property {string} [legend]
 * @property {FormNode[]} [fields]
 * @property {DataField} [field]
 * @property {any} [value]
 */

/**
 * @typedef FormFooterButton
 * @property {string} type
 * @property {string} [name]
 * @property {string} [icon]
 * @property {string} [label]
 * @property {string} [action]
 * @property {string} [cssClass]
 * @property {boolean} [disabled=false]
 */

var _types$b = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * The Game Paused banner.
 * @extends {ApplicationV2}
 */
class GamePause extends ApplicationV2 {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "pause",
    tag: "figure",
    window: {
      frame: false,
      positioned: false
    }
  };

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(_options) {
    return {
      cssClass: game.paused ? "paused" : "",
      icon: "ui/pause.svg",
      text: game.i18n.localize("GAME.Paused"),
      spin: true
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async _renderHTML(context, options) {
    const img = document.createElement("img");
    img.src = context.icon;
    if ( context.spin ) img.classList.add("fa-spin");
    const caption = document.createElement("figcaption");
    caption.innerText = context.text;
    return [img, caption];
  }

  /* -------------------------------------------- */

  /** @override */
  _replaceHTML(result, content, _options) {
    content.classList.toggle("paused", game.paused);
    content.replaceChildren(...result);
  }
}

/**
 * An action bar displayed at the bottom of the game view which contains Macros as interactive buttons.
 * The Hotbar supports 5 pages of 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.
 *
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class Hotbar extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "hotbar",
    classes: ["faded-ui", "flexrow"],
    tag: "aside",
    window: {
      frame: false,
      positioned: false
    },
    actions: {
      execute: Hotbar.#onExecute,
      lock: Hotbar.#onToggleLock,
      mute: Hotbar.#onToggleMute,
      menu: Hotbar.#onToggleMenu,
      clear: Hotbar.#onClear,
      page: Hotbar.#onPage
    }
  };

  /** @override */
  static PARTS = {
    hotbar: {
      root: true,
      template: "templates/ui/hotbar.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * An internal helper data structure that makes it easier to track button swap states.
   * @type {Record<string, {
   *  readonly state: boolean,
   *  active: {icon: string, tooltip: string},
   *  inactive: {icon: string, tooltip: string}
   * }>}
   */
  #toggles = {
    mute: {
      get state() {
        return game.audio.globalMute;
      },
      active: {icon: "fa-volume-xmark", tooltip: "HOTBAR.UNMUTE"},
      inactive: {icon: "fa-volume", tooltip: "HOTBAR.MUTE"}
    },
    lock: {
      get state() {
        return game.settings.get("core", "hotbarLock");
      },
      active: {icon: "fa-lock", tooltip: "HOTBAR.UNLOCK"},
      inactive: {icon: "fa-unlock", tooltip: "HOTBAR.LOCK"}
    }
  };

  /* -------------------------------------------- */

  /**
   * The current hotbar page number.
   * @type {number}
   */
  get page() {
    return this.#page;
  }

  #page = 1;


  /**
   * The currently rendered macro data.
   * @type {HotbarSlotData[]}
   */
  get slots() {
    return this.#slots;
  }

  #slots;

  /**
   * Whether the hotbar is locked.
   * @returns {boolean}
   */
  get locked() {
    return game.settings.get("core", "hotbarLock");
  }

  /**
   * If we are dragging a Macro, remember which slot it originated from.
   * @type {string}
   */
  #dragSlot;

  /**
   * Track the hotbar slot that is the current drop target.
   */
  #dropTarget;

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(_options) {
    this.#slots = this.#prepareSlots(this.#page);
    return {
      slots: this.#slots,
      page: this.#page
    };
  }

  /* -------------------------------------------- */

  /**
   * @typedef HotbarSlotData
   * @property {number} slot
   * @property {Macro|null} macro
   * @property {number} key
   * @property {string} tooltip
   * @property {string} ariaLabel
   * @property {string} style
   */

  /**
   * Prepare data for the macro slots present on the viewed page.
   * @param {number} page     The currently viewed page number
   * @returns {HotbarSlotData[]}
   */
  #prepareSlots(page) {
    return game.user.getHotbarMacros(page).map((m, i) => {
      return Object.assign(m, {
        key: i<9 ? i+1 : 0,
        img: m.macro?.img ?? null,
        cssClass: m.macro ? "full" : "open",
        tooltip: m.macro?.name ?? null,
        ariaLabel: m.macro?.name ?? game.i18n.localize("HOTBAR.EMPTY")
      });
    });
  }

  /* -------------------------------------------- */

  /** @override */
  async _onFirstRender(_context, _options) {
    game.macros.apps.push(this);
    this.element.setAttribute("aria-roledescription", game.i18n.localize("HOTBAR.LABEL"));
    this._onResize();
    /** @fires {hookEvents:getMacroContextOptions} */
    this._createContextMenu(this._getContextMenuOptions, ".slot.full", {
      fixed: true,
      hookName: "getMacroContextOptions",
      parentClassHooks: false
    });
  }

  /* -------------------------------------------- */

  /** @override */
  async _onRender(_context, _options) {
    this._updateToggles();

    // Drag and Drop
    new foundry.applications.ux.DragDrop.implementation({
      dragSelector: ".slot.full",
      dropSelector: ".slot",
      callbacks: {
        dragstart: this.#onDragStart.bind(this),
        dragend: this.#onDragEnd.bind(this),
        dragover: this.#onDragOver.bind(this),
        drop: this.#onDragDrop.bind(this)
      }
    }).bind(this.element);
  }

  /* -------------------------------------------- */

  /**
   * Get the set of ContextMenu options which should be applied for Scenes in the menu.
   * @returns {ContextMenuEntry[]}   The Array of context options passed to the ContextMenu instance
   * @protected
   */
  _getContextMenuOptions() {
    return [
      {
        name: "MACRO.Edit",
        icon: '<i class="fa-solid fa-pen-to-square"></i>',
        condition: li => {
          const macro = this.#getMacroForSlot(li);
          return macro?.isOwner ?? false;
        },
        callback: li => {
          const macro = this.#getMacroForSlot(li);
          const hotbarSlot = li.dataset.slot;
          macro.sheet.render({force: true, hotbarSlot});
        }
      },
      {
        name: "MACRO.Remove",
        icon: '<i class="fa-solid fa-xmark"></i>',
        callback: li => game.user.assignHotbarMacro(null, Number(li.dataset.slot))
      },
      {
        name: "MACRO.Delete",
        icon: '<i class="fa-solid fa-trash"></i>',
        condition: li => {
          const macro = this.#getMacroForSlot(li);
          return macro?.isOwner ?? false;
        },
        callback: li => {
          const macro = this.#getMacroForSlot(li);
          macro?.deleteDialog();
        }
      }
    ];
  }

  /* -------------------------------------------- */

  /**
   * Update the presented state of toggle buttons.
   * @internal
   */
  _updateToggles() {
    for ( const [action, config] of Object.entries(this.#toggles) ) {
      const button = this.element.querySelector(`button[data-action="${action}"]`);
      const remove = config.state ? config.inactive : config.active;
      const add = config.state ? config.active : config.inactive;
      button.classList.remove(remove.icon);
      button.classList.add(add.icon);
      button.dataset.tooltip = add.tooltip;
      button.setAttribute("aria-label", game.i18n.localize(add.tooltip));
    }
  }

  /* -------------------------------------------- */
  /*  Public API                                 */
  /* -------------------------------------------- */

  /**
   * Change to a specific numbered page from 1 to 5
   * @param {number} page       The page number to change to
   * @returns {Promise<void>}
   */
  async changePage(page) {
    if ( !Number.isInteger(page) || !page.between(1, 5) ) {
      throw new Error("The Hotbar page must be an integer between 1 and 5");
    }
    this.#page = page;
    await this.render();
  }

  /* -------------------------------------------- */

  /**
   * Change the page of the hotbar by cycling up (positive) or down (negative).
   * @param {number} direction    The direction to cycle
   * @returns {Promise<void>}
   */
  async cyclePage(direction) {
    const d = Number.isNumeric(direction) ? Math.sign(direction) : 1;
    let next = (this.#page + d) % 6;
    if ( next === 0 ) next = d > 0 ? 1 : 5;
    return this.changePage(next);
  }

  /* -------------------------------------------- */

  /**
   * A reusable helper that can be used for toggling display of a document sheet.
   * @param {string} uuid     The Document UUID to display
   * @returns {Promise<void>}
   */
  static async toggleDocumentSheet(uuid) {
    const doc = await fromUuid(uuid);
    if ( !doc ) {
      return ui.notifications.warn("WARNING.ObjectDoesNotExist", {format: {
        name: game.i18n.localize("Document"),
        identifier: uuid
      }});
    }
    const sheet = doc.sheet;
    if ( sheet.rendered ) await sheet.close();
    else await sheet.render(true); // TODO change to {force: true} once everything is AppV2
  }

  /* -------------------------------------------- */
  /*  Action Event Handlers                      */
  /* -------------------------------------------- */

  /**
   * A helper method used to retrieve a Macro document from a hotbar slot element.
   * @param {HTMLLIElement} element
   * @returns {Macro|null}
   */
  #getMacroForSlot(element) {
    const slot = element.dataset.slot;
    const macroId = game.user.hotbar[slot];
    if ( !macroId) return null;
    return game.macros.get(macroId) ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to execute a Macro or create a new Macro.
   * @this {Hotbar}
   * @param {PointerEvent} event
   * @returns {Promise<void>}
   */
  static async #onExecute(event) {
    const macro = this.#getMacroForSlot(event.target);

    // Execute a Macro
    if ( macro ) await macro?.execute();

    // Create a temporary Macro
    else {
      const cls = getDocumentClass$1("Macro");
      const macro = new cls({name: cls.defaultName({type: "chat"}), type: "chat", scope: "global"});
      const hotbarSlot = event.target.dataset.slot;
      await macro.sheet.render({force: true, hotbarSlot});
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to toggle the hotbar locked state.
   * @this {Hotbar}
   * @returns {Promise<void>}
   */
  static async #onToggleLock() {
    await game.settings.set("core", "hotbarLock", !this.locked, {render: false});
    this._updateToggles();
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to toggle the global mute state.
   * @this {Hotbar}
   * @returns {Promise<void>}
   */
  static async #onToggleMute() {
    game.audio.globalMute = !game.audio.globalMute;
    this._updateToggles();
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to toggle the game main menu.
   * @this {Hotbar}
   * @returns {Promise<void>}
   */
  static async #onToggleMenu() {
    await ui.menu.toggle();
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to toggle the game main menu.
   * @this {Hotbar}
   * @returns {Promise<void>}
   */
  static async #onClear() {
    const proceed = await foundry.applications.api.DialogV2.confirm({
      window: {
        title: "HOTBAR.CLEAR",
        icon: "fa-solid fa-trash"
      },
      content: game.i18n.localize("HOTBAR.CLEAR_CONFIRM"),
      modal: true
    });
    if ( proceed ) await game.user.update({hotbar: {}}, {recursive: false, diff: false, noHook: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to cycle the viewed hotbar page.
   * @this {Hotbar}
   * @param {PointerEvent} event
   * @returns {Promise<void>}
   */
  static async #onPage(event) {
    const button = event.target;
    const direction = Number(button.dataset.direction);
    await this.cyclePage(direction);
  }

  /* -------------------------------------------- */

  /**
   * Update hotbar display based on viewport size.
   * @internal
   */
  _onResize() {
    const { uiScale = 1 } = game.settings.get("core", "uiConfig") ?? {};
    const cameraDock = ui.webrtc.isVertical && !ui.webrtc.hidden;
    const { innerWidth } = window;
    const effectiveWidth = innerWidth / uiScale;
    this.element.classList.toggle("lg", effectiveWidth >= 2110);
    this.element.classList.toggle("md", effectiveWidth <= (cameraDock ? 1920 : 1680));
    this.element.classList.toggle("sm", effectiveWidth <= (cameraDock ? 1680 : 1488));
    this.element.classList.toggle("min", effectiveWidth <= (cameraDock ? 1488 : 1024));
  }

  /* -------------------------------------------- */
  /*  Drag and Drop                               */
  /* -------------------------------------------- */

  /**
   * Begin dragging a Macro to change its location on the bar.
   * @param {DragEvent} event
   */
  #onDragStart(event) {
    const li = event.target.closest(".slot");
    const macro = this.#getMacroForSlot(li);
    if ( !macro || this.locked ) {
      event.preventDefault();
      return;
    }
    this.#dragSlot = li.dataset.slot;
    const dragData = foundry.utils.mergeObject(macro.toDragData(), {slot: this.#dragSlot});
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /**
   * Clean up state at the termination of a drag event.
   */
  #onDragEnd() {
    this.#dragSlot = undefined;
  }

  /* -------------------------------------------- */

  /**
   * Highlight the Scene which becomes the drop target for drag-and-drop handling.
   * @param {DragEvent} event
   */
  #onDragOver(event) {
    const target = event.target.closest(".slot");
    if ( target === this.#dropTarget ) return;
    if ( this.#dropTarget ) this.#dropTarget.classList.remove("drop-target");
    this.#dropTarget = target;
    if ( !target || (target.dataset.slot === this.#dragSlot) ) return;
    target.classList.add("drop-target");
  }

  /* -------------------------------------------- */

  /**
   * Conclude dragging a Scene to change its navigation order.
   * @param {DragEvent} event
   */
  async #onDragDrop(event) {
    if ( this.#dropTarget ) {
      this.#dropTarget.classList.remove("drop-target");
      this.#dropTarget = undefined;
    }

    // Get the dropped slot
    const li = event.target.closest(".slot");
    const dropSlot = li.dataset.slot;
    if ( this.#dragSlot === dropSlot ) return;
    this.#dragSlot = undefined;

    // Get the Macro to add
    const data = TextEditor$1.implementation.getDragEventData(event);
    if ( Hooks$1.call("hotbarDrop", this, data, dropSlot) === false ) return;
    if ( this.locked ) return;  // Do nothing if the bar is locked

    // Get the dropped Document
    const cls = getDocumentClass$1(data.type);
    const doc = await cls?.fromDropData(data);
    if ( !doc ) return;

    // Get or create a 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, dropSlot, {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
   * @protected
   */
  async _createRollTableRollMacro(table) {
    const command = `const table = await fromUuid("${table.uuid}");\nawait table.draw();`;
    return Macro$1.implementation.create({
      name: `${game.i18n.localize("TABLE.ACTIONS.DrawResult")} ${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$1.implementation.create({
      name: `${game.i18n.localize("Display")} ${name}`,
      type: CONST.MACRO_TYPES.SCRIPT,
      img: "icons/svg/book.svg",
      command: `await foundry.applications.ui.Hotbar.toggleDocumentSheet("${doc.uuid}");`
    });
  }

  /* -------------------------------------------- */
  /*  Compatibility and Deprecations             */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get macros() {
    foundry.utils.logCompatibilityWarning("Hotbar#macros is deprecated in favor of Hotbar#slots.",
      {since: 13, until: 15});
    return this.#slots;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  collapse() {
    foundry.utils.logCompatibilityWarning("Hotbar#collapse is no longer a supported feature.",
      {since: 13, until: 15});
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  expand() {
    foundry.utils.logCompatibilityWarning("Hotbar#expand is no longer a supported feature.",
      {since: 13, until: 15});
  }
}

/**
 * The main menu application which is toggled via the ESC key.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class MainMenu extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "menu",
    classes: ["themed", "theme-dark"],
    tag: "dialog",
    window: {
      frame: false,
      positioned: false
    },
    actions: {
      menuItem: MainMenu.#onClickMenuItem
    }
  };

  /** @override */
  static PARTS = {
    items: {
      template: "templates/ui/main-menu.hbs"
    }
  };

  /**
   * @typedef MainMenuItem
   * @property {string} label
   * @property {string} icon
   * @property {boolean|function():boolean} enbaled
   * @property {function(event):void} onClick
   */

  /**
   * Configuration of Main Menu items.
   * @type {Record<string, MainMenuItem>}
   */
  static ITEMS = {
    reload: {
      label: "MENU.Reload",
      icon: '<i class="fa-solid fa-arrow-rotate-right"></i>',
      enabled: true,
      onClick: () => window.location.reload()
    },
    logout: {
      label: "MENU.Logout",
      icon: '<i class="fa-solid fa-user"></i>',
      enabled: true,
      onClick: () => game.logOut()
    },
    players: {
      label: "MENU.Players",
      icon: '<i class="fa-solid fa-users"></i>',
      enabled: () => game.user.isGM && !game.data.demoMode,
      onClick: () => window.location.href = "./players"
    },
    world: {
      label: "GAME.ReturnSetup",
      icon: '<i class="fa-solid fa-globe"></i>',
      enabled: () => game.user.hasRole("GAMEMASTER") && !game.data.demoMode,
      onClick: function() {
        this.close();
        game.shutDown();
      }
    }
  };

  /* ----------------------------------------- */

  /**
   * A record of menu items which are currently enabled.
   * @returns {Record<string, MainMenuItem>}
   */
  get items() {
    const items = {};
    for ( const [k, v] of Object.entries(MainMenu.ITEMS) ) {
      if ( (v.enabled === false) || ((v.enabled instanceof Function) && !v.enabled()) ) continue;
      items[k] = v;
    }
    return items;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _insertElement(element) {
    const existing = document.getElementById(element.id);
    if ( existing ) existing.replaceWith(element);
    else {
      const parent = document.getElementById("interface");
      parent.append(element);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _onFirstRender(context, options) {
    this.element.showModal();
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(_options) {
    return {
      items: this.items
    };
  }

  /* ----------------------------------------- */

  /**
   * Toggle display of the menu, or render it in the first place.
   * @returns {Promise<void>}
   */
  async toggle() {
    if ( this.rendered ) {
      this.element.classList.remove("active");
      await this._awaitTransition(this.element, 10000);
      await this.close();
    } else {
      await this.render({force: true});
      this.element.classList.add("active");
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle click actions on menu items.
   * @this {MainMenu}
   * @param {PointerEvent} event
   */
  static #onClickMenuItem(event) {
    const li = event.target.closest(".menu-item");
    const item = this.items[li.dataset.menuItem];
    if ( item.onClick instanceof Function ) item.onClick.call(this, event);
  }
}

/**
 * A common framework for displaying notifications to the client.
 * Submitted notifications are added to a queue, and up to {@link Notifications.MAX_ACTIVE}
 * notifications are displayed at once. Each notification is displayed for
 * {@link Notifications.LIFETIME_MS} milliseconds before being
 * removed, at which point further notifications are pulled from the queue.
 *
 *
 * @example Displaying Notification Messages
 * ```js
 * ui.notifications.error("This is a permanent error message", {permanent: true});
 * ui.notifications.warn("LOCALIZED.WARNING.MESSAGE", {localize: true});
 * ui.notifications.success("This is a success message, not logged to the console", {console: false});
 * ui.notifications.info("LOCALIZED.FORMAT.STRING", {format: {key1: "foo", key2: "bar"}});
 * ```
 *
 * @example Progress Bar Notification
 * ```js
 * const progress = ui.notifications.info("Thing Happening!", {progress: true});
 * progress.update({pct: 0.25, message: "Still happening!"});
 * progress.update({pct: 0.50, message: "Almost there!"});
 * progress.update({pct: 0.75, message: "Stay on target!"});
 * progress.update({pct: 1.0, message: "Done!"});
 * ```
 */
class Notifications {
  constructor() {
    if ( ui.notifications ) throw new Error("You may not create a second instance of the ui.notifications singleton.");
    this.#initialize();
  }

  /**
   * The list element which contains active notifications.
   * @type {HTMLOListElement}
   */
  #element;

  /**
   * Submitted notifications which are queued for display
   * @type {Notification[]}
   */
  #queue = [];

  /**
   * Notifications which are currently displayed on screen.
   * @type {Record<number, Notification>}
   */
  #active = {};

  /**
   * An incrementing counter for the notification IDs.
   * @type {number}
   */
  #id = 1;

  /**
   * The maximum number of active notifications.
   * @type {number}
   */
  static MAX_ACTIVE = 5;

  /**
   * Notification lifetime in milliseconds.
   * @type {number}
   */
  static LIFETIME_MS = 5000;

  /* -------------------------------------------- */

  /**
   * Initialize the Notifications system by displaying any system-generated messages which were passed from the server.
   */
  #initialize() {

    // Create the containing element
    const ol = document.createElement("ol");
    ol.id = "notifications";
    const parent = document.body;
    parent.prepend(ol);
    this.#element = ol;

    // Add pending messages to the queue
    for ( const m of globalThis.MESSAGES ) {
      m.options ||= {};
      m.options.localize = true;
      this.notify(m.message, m.type, m.options);
    }
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * @typedef Notification
   * @property {number} id
   * @property {string} type
   * @property {number} timestamp
   * @property {string} message
   * @property {Error} [error]
   * @property {boolean} permanent
   * @property {boolean} console
   * @property {boolean} active
   * @property {boolean} progress
   * @property {number} pct
   * @property {HTMLLIElement} [element]
   * @property {() => void} [remove]
   * @property {(pct: number) => void} [update]
   */

  /**
   * @typedef NotificationOptions
   * @property {boolean} [permanent=false]     Should the notification be permanently displayed until dismissed
   * @property {boolean} [progress=false]      Does this Notification include a progress bar?
   * @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
   * @property {boolean} [escape=true]         Whether to escape the values of `format`
   * @property {boolean} [clean=true]          Whether to clean the provided message string as untrusted user input.
   *                                           No cleaning is applied if `format` is passed and `escape` is true or
   *                                           `localize` is true and `format` is not passed.
   * @property {Record<string, string>} [format] A mapping of formatting strings passed to Localization#format
   */

  /**
   * Push a new notification into the queue
   * @param {string|object} message            The content of the notification message. A passed object should have a
   *                                           meaningful override of the `toString` method. If the object is an
   *                                           `Error` and console logging is requested, the stack trace will be
   *                                           included.
   * @param {string} type                      The type of notification, "info", "warning", and "error" are supported
   * @param {NotificationOptions} [options={}] Additional options which affect the notification
   * @returns {Notification}                   The registered notification
   */
  notify(message, type="info", {localize=false, permanent=false, progress=false, console=true, escape=true, clean=true, format}={}) {
    const error = message instanceof Error ? message : null;
    message = String(message);
    if ( format ) {
      if ( escape ) {
        format = {...format};
        for ( const key in format ) format[key] = foundry.utils.escapeHTML(format[key]);
        // Formatted message should be safe if the format arguments are escaped
        if ( game.i18n.has(message) ) clean = false;
      }
      message = game.i18n.format(message, format);
    } else if ( localize ) {
      if ( game.i18n.has(message) ) clean = false; // Localized message should be safe
      message = game.i18n.localize(message);
    }
    if ( clean ) message = foundry.utils.cleanHTML(message);
    const notification = {
      id: this.#id++,
      type,
      message,
      timestamp: new Date().getTime(),
      active: false,
      progress,
      permanent,
      console,
      pct: 0
    };
    if ( error ) notification.error = error;
    notification.remove = this.#remove.bind(this, notification);
    notification.update = this.#update.bind(this, notification);
    this.#queue.push(notification);
    this.#fetch();
    return notification;
  }

  /* -------------------------------------------- */

  /**
   * Display a notification with the "info" type.
   * @param {string|object} message             The content of the info message
   * @param {NotificationOptions} [options]     Notification options passed to the notify function
   * @returns {Readonly<Notification>}          The registered notification
   * @see {@link notify}
   */
  info(message, options) {
    return this.notify(message, "info", options);
  }

  /* -------------------------------------------- */

  /**
   * Display a notification with the "warning" type.
   * @param {string|object} message             The content of the warning message
   * @param {NotificationOptions} [options]     Notification options passed to the notify function
   * @returns {Readonly<Notification>}          The registered notification
   * @see {@link notify}
   */
  warn(message, options) {
    return this.notify(message, "warning", options);
  }

  /* -------------------------------------------- */

  /**
   * Display a notification with the "error" type.
   * @param {string|object} message             The content of the error message
   * @param {NotificationOptions} [options]     Notification options passed to the notify function
   * @returns {Readonly<Notification>}          The registered notification
   * @see {@link notify}
   */
  error(message, options) {
    return this.notify(message, "error", options);
  }

  /* -------------------------------------------- */

  /**
   * Display a notification with the "success" type.
   * @param {string|object} message             The content of the success message
   * @param {NotificationOptions} [options]     Notification options passed to the notify function
   * @returns {Readonly<Notification>}          The registered notification
   * @see {@link notify}
   */
  success(message, options) {
    return this.notify(message, "success", options);
  }

  /* -------------------------------------------- */

  /**
   * Update the progress of the notification.
   * @param {Notification|number} notification    A Notification or ID to update
   * @param {object} [update]                     An incremental progress update
   * @param {string} [update.message]             An update to the string message
   * @param {string} [update.localize=false]      Localize updates to presented progress text
   * @param {string} [update.escape=true]         See {@link NotificationOptions#escape}
   * @param {string} [update.clean=true]          See {@link NotificationOptions#clean}
   * @param {Record<string, string>} [update.format]    A mapping of formatting strings passed to Localization#format
   * @param {number} [update.pct]                 An update to the completion percentage
   */
  update(notification, update) {
    if ( typeof notification === "number" ) notification = this.#active[notification] ?? this.#queue.find(n => n.id === notification);
    if ( !notification ) return;
    this.#update(notification, update);
  }

  /* -------------------------------------------- */

  /**
   * Remove the notification linked to the ID.
   * @param {Notification|number} notification    The Notification or ID to remove
   */
  remove(notification) {
    const id = notification?.id ?? notification;
    if ( !(id > 0) ) throw new Error("You must pass a Notification or numeric ID to Notifications#remove");
    let n = this.#queue.findSplice(n => n.id === id);
    if ( n ) return;
    n = this.#active[id];
    if ( n ) n.remove();
  }

  /* -------------------------------------------- */

  /**
   * Does the notification linked to the ID exist?.
   * @param {Notification|number} notification    The Notification or ID to remove
   * @returns {boolean}
   */
  has(notification) {
    const id = notification?.id ?? notification;
    if ( !(id > 0) ) throw new Error("You must pass a Notification or numeric ID to Notifications#has");
    let n = this.#queue.find(n => n.id === id);
    if ( n ) return true;
    n = this.#active[id];
    if ( n ) return true;
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Clear all notifications.
   */
  clear() {
    this.#queue.length = 0;
    for ( const id in this.#active ) {
      const n = this.#active[id];
      n.active = false;
      n.element.remove();
      delete this.#active[id];
    }
  }

  /* -------------------------------------------- */
  /*  Private Helpers                             */
  /* -------------------------------------------- */

  /**
   * Retrieve a pending notification from the queue and display it.
   */
  #fetch() {
    if ( !this.#queue.length || (Object.keys(this.#active).length >= Notifications.MAX_ACTIVE) ) return;
    const next = this.#queue.shift();
    next.timestamp = Date.now();
    next.active = true;
    next.element ??= this.#render(next);
    next.element.addEventListener("click", next.remove);
    this.#element.prepend(next.element);
    this.#active[next.id] = next;
    if ( next.console ) {
      let fn = next.type === "warning" ? "warn" : next.type;
      if ( !console.hasOwnProperty(fn) ) fn = "debug";
      console[fn](next.error ?? next.element.textContent);
    }
    if ( !next.permanent && !next.progress ) window.setTimeout(next.remove, Notifications.LIFETIME_MS);
  }

  /* -------------------------------------------- */

  /**
   * Remove a notification from circulation.
   * @param {Notification} notification
   */
  #remove(notification) {
    notification.active = false;
    notification.element?.remove();
    if ( notification.id in this.#active ) delete this.#active[notification.id];
    else this.#queue.findSplice(n => n.id === notification.id);
    this.#fetch();
  }

  /* -------------------------------------------- */

  /**
   * Increment the progress of the notification.
   * @param {Notification} notification
   * @param {object} update
   * @param {string} [update.message]            Change the presented progress text for the update
   * @param {boolean} [update.localize=false]    Localize updates to presented progress text
   * @param {boolean} [update.escape=true]       See {@link NotificationOptions#escape}
   * @param {boolean} [update.clean=true]        See {@link NotificationOptions#clean}
   * @param {Record<string, string>} [update.format]    A mapping of formatting strings passed to Localization#format
   * @param {number} [update.pct]                Change the presented progress percentage, on [0, 1]
   */
  #update(notification, {message, localize=false, format, escape=true, clean=true, pct}={}) {
    const el = notification.element ??= this.#render(notification);

    // Update percentage progress
    if ( notification.progress && Number.isNumeric(pct) ) {
      notification.pct = Number(pct);
      const pctString = `${Math.round(pct * 100)}%`;
      el.style.setProperty("--pct", pctString);
      el.children[1].innerText = pctString;
      if ( (pct === 1) && !notification.permanent ) window.setTimeout(notification.remove, 500);
    }

    // Update status text
    if ( message ) {
      if ( format ) {
        if ( escape ) {
          format = {...format};
          for ( const key in format ) format[key] = foundry.utils.escapeHTML(format[key]);
          // Formatted message should be safe if the format arguments are escaped
          clean = false;
        }
        message = game.i18n.format(message, format);
      } else if ( localize ) {
        message = game.i18n.localize(message);
        clean = false; // Localized message should be safe
      }
      if ( clean ) message = foundry.utils.cleanHTML(message);
      notification.message = message;
      el.children[0].innerHTML = message;
    }

    // Log message to console
    if ( notification.console ) {
      let fn = notification.type === "warning" ? "warn" : notification.type;
      if ( !console.hasOwnProperty(fn) ) fn = "debug";
      console[fn](el.textContent);
    }
  }

  /* -------------------------------------------- */

  /**
   * Render a notification as an HTML element.
   * @param {Notification} notification
   * @returns {HTMLLIElement}
   */
  #render(notification) {
    const li = document.createElement("li");
    li.classList.add("notification", notification.type);
    if ( notification.permanent ) li.classList.add("permanent");
    if ( notification.progress ) {
      li.classList.add("progress");
      li.style.setProperty("--pct", "0%");
    }
    li.dataset.id = String(notification.id);
    li.innerHTML = `<p>${notification.message}</p> <span class="pct"></span>`;
    return li;
  }
}

/**
 * @import User from "@client/documents/user.mjs"
 * @import {ContextMenuEntry} from "../ux/context-menu.mjs"
 */

/**
 * A UI element which displays the Users defined for this world.
 * Currently active users are always displayed, while inactive users can be displayed on toggle.
 *
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class Players extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "players",
    classes: ["faded-ui", "flexcol"],
    tag: "aside",
    window: {
      frame: false,
      positioned: false
    },
    actions: {
      expand: Players.#onExpand
    }
  };

  /** @override */
  static PARTS = {
    players: {
      root: true,
      template: "templates/ui/players.hbs"
    }
  };

  /**
   * How often latency is refreshed.
   * @type {number}
   */
  static REFRESH_LATENCY_FREQUENCY_MS = 60 * 1000; // 1 Minute

  /**
   * A threshold of time in milliseconds after which a player is considered idle if they have no observed activity.
   * @type {number}
   */
  static IDLE_THRESHOLD_MS = 5 * 60 * 1000; // 5 Minutes

  /* -------------------------------------------- */

  /**
   * Is the application currently expanded?
   * @type {boolean}
   */
  get expanded() {
    return this.element.classList.contains("expanded");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(_options) {
    const active = [];
    const inactive = [];
    for ( const u of game.users ) {
      const user = {
        id: u.id,
        name: this._formatName(u),
        role: u.role,
        tooltip: u.roleLabel,
        isSelf: u.isSelf,
        cssClass: [u.active ? "active" : "", u.isGM ? "gm" : "", u.isSelf ? "self" : ""].filterJoin(" "),
        color: u.active ? u.color.css : "#333333",
        border: u.active ? u.border.css : "#000000"
      };
      if ( u.active ) active.push(user);
      else inactive.push(user);
    }
    active.sort(Players.#sortUsers);
    inactive.sort(Players.#sortUsers);

    // Return the data for rendering
    return {active, inactive};
  }

  /* -------------------------------------------- */

  /**
   * Format the display of a user's name using their name, pronouns (if defined), and character name (if defined).
   * @param {User} user
   * @returns {string}
   * @protected
   */
  _formatName(user) {
    const parts = [user.name];
    if ( user.pronouns ) parts.push(`(${user.pronouns})`);
    if ( user.isGM ) parts.push(`[${game.i18n.localize("USER.GM")}]`);
    else if ( user.character ) parts.push(`[${user.character.name}]`);
    return parts.join(" ");
  }

  /* -------------------------------------------- */

  /**
   * A helper method used to sort users.
   * @param {{role: number, isSelf: boolean, name: string}} a
   * @param {{role: number, isSelf: boolean, name: string}} b
   * @returns {number}
   */
  static #sortUsers(a, b) {
    if ( a.isSelf || b.isSelf ) return b.isSelf - a.isSelf;
    if ( (b.role >= CONST.USER_ROLES.ASSISTANT) && (b.role > a.role) ) return 1;
    return a.name.localeCompare(b.name, game.i18n.lang);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onFirstRender(_context, _options) {
    game.users.apps.push(this);
    window.setInterval(this.refreshLatency.bind(this), this.constructor.REFRESH_LATENCY_FREQUENCY_MS);
    /** @fires {hookEvents:getUserContextOptions} */
    this._createContextMenu(this._getContextMenuOptions, ".player", {
      hookName: "getUserContextOptions",
      parentClassHooks: false,
      fixed: true
    });
  }

  /* -------------------------------------------- */

  /** @override */
  async _onRender(_context, _options) {
    this.element.classList.toggle("expanded", this.expanded);
    this.refreshLatency();
    this.refreshFPS();
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Collapse the players list.
   */
  collapse() {
    this.toggleExpanded(false);
  }

  /* -------------------------------------------- */

  /**
   * Expand the players list.
   */
  expand() {
    this.toggleExpanded(true);
  }

  /* -------------------------------------------- */

  /**
   * Update the display which reports average latency.
   */
  refreshLatency() {
    if ( !this.rendered ) return;

    // Latency Indicator
    const el = this.element.querySelector("#latency");
    if ( el ) {
      const avg = game.time.averageLatency;
      el.querySelector(".average").innerText = `${Math.round(avg)}ms`;
      el.className = [[250, "good"], [1000, "fair"], [Infinity, "poor"]].find(s => avg <= s[0])[1];
    }

    // Idle Players
    const t = Date.now();
    for ( const li of this.element.querySelectorAll(".players-list > .player") ) {
      const user = game.users.get(li.dataset.userId);
      const isIdle = user.active && !user.isSelf && ((t - user.lastActivityTime) > this.constructor.IDLE_THRESHOLD_MS);
      li.classList.toggle("idle", isIdle);
    }
  }

  /* -------------------------------------------- */

  /**
   * Update the display which reports average framerate.
   * @param {object} [options={}]                   Options which customize FPS reporting
   * @param {boolean} [options.deactivate=false]      Deactivate tracking
   */
  refreshFPS({deactivate=false}={}) {
    if ( !this.rendered ) return;
    const el = this.element.querySelector("#fps");
    if ( !el ) return;
    if ( deactivate || !canvas.ready ) {
      el.querySelector(".average").innerText = "--";
      el.className = "";
      return;
    }
    const values = canvas.fps.values;
    const avg = values.reduce((fps, total) => total + fps, 0) / values.length;
    el.querySelector(".average").innerText = Math.round(avg);
    const max = canvas.app.ticker.maxFPS || 60;
    const r = avg / max;
    if ( !Number.isFinite(r) ) el.className = "fair";
    else el.className = [[0.5, "poor"], [0.8, "fair"], [Infinity, "good"]].find(f => r < f[0])[1];
  }

  /* -------------------------------------------- */

  /**
   * Toggle the expanded state of the players list.
   * @param {boolean} [expanded]  Force the expanded state to the provided value, otherwise toggle the state.
   */
  toggleExpanded(expanded) {
    expanded ??= !this.expanded;
    this.element.classList.toggle("expanded", expanded);
  }

  /* -------------------------------------------- */
  /*  Action Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * Handle click events to expand the inactive player tray.
   * @this {Players}
   */
  static #onExpand() {
    this.toggleExpanded();
  }

  /* -------------------------------------------- */
  /*  Context Menu                                */
  /* -------------------------------------------- */

  /**
   * Get the set of ContextMenu options which should be applied to each User in the Players UI.
   * @returns {ContextMenuEntry[]}   The Array of context options passed to the ContextMenu instance
   * @protected
   */
  _getContextMenuOptions() {
    return [
      {
        name: game.i18n.localize("PLAYERS.ConfigTitle"),
        icon: '<i class="fa-solid fa-person"></i>',
        condition: li => game.user.isGM || (li.dataset.userId === game.user.id),
        callback: li => {
          const user = game.users.get(li.dataset.userId);
          user?.sheet.render({force: true});
        }
      },
      {
        name: game.i18n.localize("PLAYERS.ViewAvatar"),
        icon: '<i class="fa-solid fa-image"></i>',
        condition: li => {
          const user = game.users.get(li.dataset.userId);
          return user.avatar !== CONST.DEFAULT_TOKEN;
        },
        callback: li => {
          const user = game.users.get(li.dataset.userId);
          new foundry.applications.apps.ImagePopout({
            src: user.avatar,
            uuid: user.uuid,
            window: {title: user.name}
          }).render({force: true});
        }
      },
      {
        name: game.i18n.localize("PLAYERS.PullToScene"),
        icon: '<i class="fa-solid fa-diamond-turn-right"></i>',
        condition: li => {
          const user = game.users.get(li.dataset.userId);
          return user.active && game.user.isGM && !user.isSelf;
        },
        callback: li => canvas.scene.pullUsers([li.dataset.userId])
      },
      {
        name: game.i18n.localize("PLAYERS.Kick"),
        icon: '<i class="fa-solid fa-door-open"></i>',
        condition: li => {
          const user = game.users.get(li.dataset.userId);
          return game.user.isGM && user.active && !user.isSelf;
        },
        callback: li => {
          const user = game.users.get(li.dataset.userId);
          return Players.#kickUser(user);
        }
      },
      {
        name: game.i18n.localize("PLAYERS.Ban"),
        icon: '<i class="fa-solid fa-ban"></i>',
        condition: li => {
          const user = game.users.get(li.dataset.userId);
          return game.user.isGM && !user.isSelf && (user.role !== CONST.USER_ROLES.NONE);
        },
        callback: li => {
          const user = game.users.get(li.dataset.userId);
          return Players.#banUser(user);
        }
      },
      {
        name: game.i18n.localize("PLAYERS.UnBan"),
        icon: '<i class="fas fa-ban"></i>',
        condition: li => {
          const user = game.users.get(li.dataset.userId);
          return game.user.isGM && !user.isSelf && (user.role === CONST.USER_ROLES.NONE);
        },
        callback: li => {
          const user = game.users.get(li.dataset.userId);
          return Players.#unbanUser(user);
        }
      },
      {
        name: game.i18n.localize("WEBRTC.TooltipShowUser"),
        icon: '<i class="fas fa-eye"></i>',
        condition: li => {
          const userId = li.dataset.userId;
          return game.webrtc.settings.client.users[userId]?.blocked;
        },
        callback: async li => {
          const userId = li.dataset.userId;
          await game.webrtc.settings.set("client", `users.${userId}.blocked`, false);
          ui.webrtc.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>}
   */
  static 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.MESSAGES.KICKED", {format: {user: user.name}});
  }

  /* -------------------------------------------- */

  /**
   * Ban a User by changing their role to "NONE".
   * @param {User} user     The User to ban
   * @returns {Promise<void>}
   */
  static async #banUser(user) {
    if ( user.role === CONST.USER_ROLES.NONE ) return;
    await user.update({role: CONST.USER_ROLES.NONE});
    ui.notifications.info("USER.MESSAGES.BANNED", {format: {user: user.name}});
  }

  /* -------------------------------------------- */

  /**
   * Unban a User by changing their role to "PLAYER".
   * @param {User} user     The User to unban
   * @returns {Promise<void>}
   */
  static async #unbanUser(user) {
    if ( user.role !== CONST.USER_ROLES.NONE ) return;
    await user.update({role: CONST.USER_ROLES.PLAYER});
    ui.notifications.info("USER.MESSAGES.UNBANNED", {format: {user: user.name}});
  }
}

/**
 * Scene Region Legend.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class RegionLegend extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "region-legend",
    tag: "aside",
    window: {
      title: "REGION.LEGEND.title",
      icon: "fa-regular fa-game-board",
      minimizable: false
    },
    position: {width: 320},
    actions: {
      config: RegionLegend.#onConfig,
      control: RegionLegend.#onControl,
      create: RegionLegend.#onCreate,
      delete: RegionLegend.#onDelete,
      lock: RegionLegend.#onLock
    }
  };

  /** @override */
  static PARTS = {
    list: {
      id: "list",
      template: "templates/scene/region-legend.hbs",
      scrollable: ["ol.region-list"]
    }
  };

  /* -------------------------------------------- */

  /**
   * The currently filtered Regions.
   * @type {{bottom: number, top: number}}
   */
  #visibleRegions = new Set();

  /* -------------------------------------------- */

  /**
   * The currently viewed elevation range.
   * @type {{bottom: number, top: number}}
   */
  elevation = {bottom: -Infinity, top: Infinity};

  /* -------------------------------------------- */

  /** @type {foundry.applications.ux.SearchFilter} */
  #searchFilter = new foundry.applications.ux.SearchFilter({
    inputSelector: 'input[name="search"]',
    contentSelector: ".region-list",
    callback: this.#onSearchFilter.bind(this)
  });

  /* -------------------------------------------- */

  /**
   * Record a reference to the currently highlighted Region.
   * @type {Region|null}
   */
  #hoveredRegion = null;

  /* -------------------------------------------- */

  /** @override */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( options.isFirstRender && ui.nav ) {
      const {right, top} = ui.nav.element.getBoundingClientRect();
      const uiScale = game.settings.get("core", "uiConfig").uiScale;
      options.position.left ??= right + (16 * uiScale);
      options.position.top ??= top;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _canRender(options) {
    const rc = options.renderContext;
    if ( rc && !["createregions", "updateregions", "deleteregions"].includes(rc) ) return false;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderFrame(options) {
    const frame = await super._renderFrame(options);
    this.window.close.remove(); // Prevent closing
    return frame;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async close(options={}) {
    if ( !options.closeKey ) return super.close(options);
    return this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    canvas.scene.apps[this.id] = this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    new DragDrop.implementation({
      dragSelector: ".region-name",
      permissions: {
        dragstart: () => true,
        drop: () => false
      },
      callbacks: {
        dragstart: this.#onDragStart.bind(this)
      }
    }).bind(this.element);
    this.#searchFilter.bind(this.element);
    for ( const li of this.element.querySelectorAll(".region") ) {
      li.addEventListener("mouseover", this.#onRegionHoverIn.bind(this));
      li.addEventListener("mouseout", this.#onRegionHoverOut.bind(this));
    }
    this.element.querySelector("input[name=elevationBottom]")
      .addEventListener("change", this.#onElevationBottomChange.bind(this));
    this.element.querySelector("input[name=elevationTop]")
      .addEventListener("change", this.#onElevationTopChange.bind(this));
    this.#updateVisibleRegions();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    this.#visibleRegions.clear();
    this.elevation.bottom = -Infinity;
    this.elevation.top = Infinity;
    if ( canvas.scene ) delete canvas.scene.apps[this.id];
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(_options) {
    const regions = canvas.scene.regions.map(r => this.#prepareRegion(r));
    regions.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));
    return {
      regions,
      elevation: {
        bottom: Number.isFinite(this.elevation.bottom) ? this.elevation.bottom : "",
        top: Number.isFinite(this.elevation.top) ? this.elevation.top : ""
      }
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare each Region for rendering in the legend.
   * @param {Region} region
   * @returns {object}
   */
  #prepareRegion(region) {
    const hasElv = Number.isFinite(region.elevation.bottom) || Number.isFinite(region.elevation.top);
    return {
      id: region.id,
      name: region.name,
      color: region.color.css,
      elevation: region.elevation,
      elevationLabel: hasElv ? `[${Number.isFinite(region.elevation.bottom)
        ? region.elevation.bottom.toNearest(0.01).toLocaleString(game.i18n.lang) : "&minus;&infin;"}, `
        + `${Number.isFinite(region.elevation.top) ? region.elevation.top.toNearest(0.01).toLocaleString(game.i18n.lang)
          : "&plus;&infin;"}]` : "",
      empty: !region.shapes.length,
      locked: region.locked,
      controlled: region.object?.controlled,
      hover: region.object?.hover,
      buttons: [
        {
          action: "config",
          icon: "fa-cogs",
          tooltip: game.i18n.localize("REGION.LEGEND.config"),
          disabled: ""
        },
        {
          action: "lock",
          icon: region.locked ? "fa-lock" : "fa-unlock",
          tooltip: game.i18n.localize(region.locked ? "REGION.LEGEND.unlock" : "REGION.LEGEND.lock"),
          disabled: ""
        },
        {
          action: "delete",
          icon: "fa-trash",
          tooltip: game.i18n.localize("REGION.LEGEND.delete"),
          disabled: region.locked ? "disabled" : ""
        }
      ]
    };
  }

  /* -------------------------------------------- */

  /**
   * Update the region list and hide regions that are not visible.
   */
  #updateVisibleRegions() {
    this.#visibleRegions.clear();
    for ( const li of this.element.querySelectorAll(".region-list > .region") ) {
      const id = li.dataset.regionId;
      const region = canvas.scene.regions.get(id);
      const hidden = !((this.#searchFilter.rgx?.test(this.#searchFilter.constructor.cleanQuery(region.name)) !== false)
        && (Math.max(region.elevation.bottom, this.elevation.bottom)
          <= Math.min(region.elevation.top, this.elevation.top)));
      if ( !hidden ) this.#visibleRegions.add(region);
      li.classList.toggle("hidden", hidden);
    }
    this.setPosition({height: "auto"});
    for ( const region of canvas.regions.placeables ) region.renderFlags.set({refreshState: true});
  }

  /* -------------------------------------------- */

  /**
   * An event that occurs when a drag workflow begins.
   * @param {DragEvent} event    The initiating drag start event
   */
  #onDragStart(event) {
    const regionId = event.currentTarget.closest("[data-region-id]").dataset.regionId;
    const region = canvas.scene.regions.get(regionId);
    const dragData = region.toDragData();
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /**
   * Filter regions.
   * @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
   */
  #onSearchFilter(event, query, rgx, html) {
    if ( !this.rendered ) return;
    this.#updateVisibleRegions();
  }

  /* -------------------------------------------- */

  /**
   * Handle change events of the elevation range (bottom) input.
   * @param {KeyboardEvent} event
   */
  #onElevationBottomChange(event) {
    this.elevation.bottom = Number(event.currentTarget.value || -Infinity);
    this.#updateVisibleRegions();
  }

  /* -------------------------------------------- */

  /**
   * Handle change events of the elevation range (top) input.
   * @param {KeyboardEvent} event
   */
  #onElevationTopChange(event) {
    this.elevation.top = Number(event.currentTarget.value || Infinity);
    this.#updateVisibleRegions();
  }

  /* -------------------------------------------- */

  /**
   * Is this Region visible in this RegionLegend?
   * @param {Region} region    The region
   * @returns {boolean}
   * @internal
   */
  _isRegionVisible(region) {
    if ( !this.rendered ) return true;
    return this.#visibleRegions.has(region.document);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-in events on a region in the legend.
   * @param {PointerEvent} event
   */
  #onRegionHoverIn(event) {
    event.preventDefault();
    if ( !canvas.ready ) return;
    const li = event.currentTarget.closest(".region");
    const region = canvas.regions.get(li.dataset.regionId);
    region._onHoverIn(event, {hoverOutOthers: true, updateLegend: false});
    this.#hoveredRegion = region;
    li.classList.add("hovered");
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-out events for a region in the legend.
   * @param {PointerEvent} event
   */
  #onRegionHoverOut(event) {
    event.preventDefault();
    const li = event.currentTarget.closest(".region");
    this.#hoveredRegion?._onHoverOut(event, {updateLegend: false});
    this.#hoveredRegion = null;
    li.classList.remove("hovered");
  }

  /* -------------------------------------------- */

  /**
   * Highlight a hovered region in the legend.
   * @param {Region} region    The Region
   * @param {boolean} hover    Whether they are being hovered in or out.
   * @internal
   */
  _hoverRegion(region, hover) {
    if ( !this.rendered ) return;
    const li = this.element.querySelector(`.region[data-region-id="${region.id}"]`);
    if ( !li ) return;
    if ( hover ) li.classList.add("hovered");
    else li.classList.remove("hovered");
  }

  /* -------------------------------------------- */

  /**
   * Handle clicks to configure a Region.
   * @param {PointerEvent} event
   */
  static #onConfig(event) {
    const regionId = event.target.closest(".region").dataset.regionId;
    const region = canvas.scene.regions.get(regionId);
    region.sheet.render({force: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle clicks to assume control over a Region.
   * @param {PointerEvent} event
   */
  static #onControl(event) {
    const regionId = event.target.closest(".region").dataset.regionId;
    const region = canvas.scene.regions.get(regionId);

    // Double-click = toggle sheet
    if ( event.detail === 2 ) {
      region.object.control({releaseOthers: true});
      region.sheet.render({force: true});
    }

    // Single-click = toggle control
    else if ( event.detail === 1 ) {
      if ( region.object.controlled ) region.object.release();
      else region.object.control({releaseOthers: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to create a new Region.
   * @param {PointerEvent} event
   */
  static async #onCreate(event) {
    await canvas.scene.createEmbeddedDocuments("Region", [{
      name: RegionDocument.implementation.defaultName({parent: canvas.scene})
    }]);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicks to delete a Region.
   * @param {PointerEvent} event
   */
  static async #onDelete(event) {
    const regionId = event.target.closest(".region").dataset.regionId;
    const region = canvas.scene.regions.get(regionId);
    await region.deleteDialog();
  }

  /* -------------------------------------------- */

  /**
   * Handle clicks to toggle the locked state of a Region.
   * @param {PointerEvent} event
   */
  static async #onLock(event) {
    const regionId = event.target.closest(".region").dataset.regionId;
    const region = canvas.scene.regions.get(regionId);
    await region.update({locked: !region.locked});
  }
}

/**
 * @import {ApplicationConfiguration, ApplicationRenderOptions} from "../_types.mjs"
 * @import {HandlebarsRenderOptions} from "../api/handlebars-application.mjs"
 */

/**
 * @typedef SceneControlTool
 * The data structure for a single tool in the {@link SceneControl#tools} record.
 * @property {string} name An identifier for the tool, unique among the tools of its SceneControl
 * @property {number} order An integer indicating the tool's order, with 0 being at the top
 * @property {string} title A title for the tool: can be a localization path
 * @property {string} icon  One or more icon classes for the tool, typically Font Awesome classes such as
 *                          "fa-solid fa-face-smile"
 * @property {boolean} [visible] Whether the tool should be visible to the current User
 * @property {boolean} [toggle] Is the tool an on-or-off toggle?
 * @property {boolean} [active] Is the tool the currently the active one? Not applicable to toggles or buttons.
 * @property {boolean} [button] Is the tool a "button" in the sense of immediately resolving on click without
 *                              becoming the active tool?
 * @property {(event: Event, active: boolean) => void} [onChange] A callback invoked when the tool is activated or
 *                                                                deactivated
 * @property {ToolclipConfiguration} [toolclip] Configuration for rendering the tool's toolclip
 */

/**
 * @typedef SceneControl
 * The data structure for a set of controls in the {@link SceneControls#controls} record.
 * @property {string} name A unique identifier for the control
 * @property {number} order An integer indicating the control's order, with 0 being at the top
 * @property {string} title A title for the control: can be a localization path
 * @property {string} icon  One or more icon classes for the control, typically Font Awesome classes such as
 *                          "fa-solid fa-face-smile"
 * @property {boolean} [visible] Whether the control should be visible to the current User
 * @property {Record<string, SceneControlTool>} tools
 * @property {string} activeTool
 * @property {(event: Event, active: boolean) => void} [onChange]
 * A callback invoked when control set is activated or deactivated
 * @property {(event: Event, tool: SceneControlTool) => void} [onToolChange]
 * A callback invoked when the active tool changes
 */

/**
 * @typedef 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 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.
 */

/**
 * @typedef _SceneControlsRenderOptions
 * @property {Event} [event]                      An event which prompted a re-render
 * @property {boolean} [reset]                    Re-prepare the possible list of controls
 * @property {string} [control]                   The control set to activate. If undefined, the current control set
 *                                                remains active
 * @property {string} [tool]                      A specific tool to activate. If undefined the current tool or default
 *                                                tool for the control set becomes active
 * @property {Record<string, boolean>} [toggles]  Changes to apply to toggles within the control set
 */

/**
 * @typedef {ApplicationRenderOptions &
 *           HandlebarsRenderOptions &
 *           _SceneControlsRenderOptions} SceneControlsRenderOptions
 * Options that can be passed to {@link SceneControls#render} to customize rendering behavior.
 */

/**
 * @typedef SceneControlsActivationChange
 * The data structure provided to the {@link SceneControl#onChange} callback.
 * @property {Event} event
 * @property {string} controlChange
 * @property {string} toolChange
 * @property {Record<string, boolean>} toggleChanges
 */

/**
 * The Scene Controls UI element.
 * @extends ApplicationV2<ApplicationConfiguration, SceneControlsRenderOptions>
 * @mixes HandlebarsApplication
 */
class SceneControls extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "scene-controls",
    classes: ["faded-ui"],
    tag: "aside",
    window: {
      frame: false,
      positioned: false
    },
    actions: {
      control: SceneControls.#onChangeControl,
      tool: SceneControls.#onChangeTool
    }
  };

  /** @override */
  static PARTS = {
    layers: {
      id: "layers",
      template: "templates/ui/scene-controls-layers.hbs"
    },
    tools: {
      id: "tools",
      template: "templates/ui/scene-controls-tools.hbs"
    }
  };

  /** @inheritDoc */
  static emittedEvents = Object.freeze([...super.emittedEvents, "activate"]);

  /* -------------------------------------------- */

  /**
   * The currently active control layer.
   * @type {string}
   */
  #control = "tokens";

  /**
   * The currently active tools per control layer.
   * @type {Record<string, string|null>}
   */
  #tools = {};

  /**
   * Cache the number of rendered tools.
   * @type {number}
   */
  #nTools = 0;

  /* -------------------------------------------- */

  /**
   * Prepared data of available controls.
   * @type {Record<string, SceneControl>}
   */
  get controls() {
    return this.#controls;
  }

  #controls;

  /* -------------------------------------------- */

  /**
   * The currently active control layer.
   * @type {SceneControl|null}
   */
  get control() {
    return this.#controls[this.#control] || null;
  }

  /* -------------------------------------------- */

  /**
   * The tools which are available within the current control layer.
   * @type {Record<string, SceneControlTool>}
   */
  get tools() {
    return this.control?.tools || {};
  }

  /* -------------------------------------------- */

  /**
   * The currently active tool in the control palette.
   * @type {SceneControlTool}
   */
  get tool() {
    const activeTool = this.#tools[this.#control];
    return this.tools[activeTool];
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Activate a new control layer or tool.
   * This method is advantageous to use because it minimizes the amount of re-rendering necessary.
   * @param {Pick<SceneControlsRenderOptions, "event"|"control"|"tool"|"toggles">} options
   * @returns {Promise<void>}
   */
  async activate(options={}) {
    const change = this.#preActivate(options);
    if ( !change.controlChange && !change.toolChange && foundry.utils.isEmpty(change.toggleChanges) ) return;


    // Change control set
    if ( change.controlChange ) {
      await this.render({parts: ["tools"]});
      for ( const k of Object.keys(this.controls) ) {
        const button = this.element.querySelector(`button.layer[data-control="${k}"]`);
        if ( !button ) continue;
        button.setAttribute("aria-pressed", this.#control === k ? "true" : "false");
      }
    }

    // Change tool
    if ( change.toolChange || !foundry.utils.isEmpty(change.toggleChanges) ) {
      for ( const [k, tool] of Object.entries(this.tools) ) {
        const button = this.element.querySelector(`button.tool[data-tool="${k}"]`);
        if ( !button ) continue;
        const active = (this.#tools[this.#control] === k) || (tool.toggle && tool.active);
        button.setAttribute("aria-pressed", active ? "true" : "false");
      }
    }

    // Post-change callbacks
    await this._doEvent(this.#postActivate, {
      handlerArgs: [change],
      debugText: "Post-activate",
      eventName: "activate",
      hookName: "activate",
      hookArgs: [change]
    });
  }

  /* -------------------------------------------- */
  /*  Rendering Methods                           */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( options.reset || options.isFirstRender ) this.#controls = this.#prepareControls();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preRender(context, options) {
    await super._preRender(context, options);
    context.activationChange = this.#preActivate(options);
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    const showToolclips = game.settings.get("core", "showToolclips");

    // Prepare Controls
    const controls = Object.values(this.#controls);
    for ( const c of controls ) c.active = c.name === this.#control;
    controls.sort((a, b) => a.order - b.order);

    // Prepare Tools
    const tools = foundry.utils.deepClone(Object.values(this.tools));
    for ( const t of tools ) {
      if ( !t.toggle ) t.active = t.name === this.#tools[this.#control];
      t.cssClass = [t.button ? "button" : "", t.toggle ? "toggle" : ""].filterJoin(" ");
      t.showToolclip = showToolclips && !!t.toolclip;
    }
    tools.sort((a, b) => a.order - b.order);
    if ( tools.length !== this.#nTools ) options.position = {height: "auto"}; // Trigger resize
    this.#nTools = tools.length;
    return {controls, tools};
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.#postActivate(context.activationChange);
    for ( const t of this.element.querySelectorAll("#scene-controls-tools .tool[data-toolclip]") ) {
      t.addEventListener("pointerenter", this.#onPointerEnterToolclip.bind(this),
        {once: true, capture: true, passive: true});
    }
    this._updateNotesIcon();
  }

  /* -------------------------------------------- */

  /**
   * Update the class of the notes layer icon to reflect whether there are visible notes or not.
   * @internal
   */
  _updateNotesIcon() {
    const icon = this.element.querySelector(".control[data-control=notes] > i");
    if ( !icon || !canvas.ready ) return;
    const hasVisibleNotes = canvas.notes.placeables.some(n => n.visible);
    icon.classList.toggle("fa-solid", !hasVisibleNotes);
    icon.classList.toggle("fa-duotone", hasVisibleNotes);
    icon.classList.toggle("has-notes", hasVisibleNotes);
  }

  /* -------------------------------------------- */

  /** @override */
  setPosition(position) {
    const tools = this.element.querySelector("#scene-controls-tools");
    const h = tools.clientHeight;
    const cols = Math.ceil((this.#nTools * 40) / h) + 1;
    document.getElementById("ui-left").style.setProperty("--control-columns", String(cols));
    return super.setPosition(position);
  }

  /* -------------------------------------------- */
  /*  Rendering Helpers                           */
  /* -------------------------------------------- */

  /**
   * Prepare layers rendered in the Scene Controls.
   * This is only done once when the application is first rendered. Subsequent renders reuse this data structure.
   * @returns {Record<string, SceneControl>}
   */
  #prepareControls() {

    // Populate controls for each interaction layer
    const controls = {};
    for ( const {layerClass} of Object.values(CONFIG.Canvas.layers) ) {
      if ( !foundry.utils.isSubclass(layerClass, InteractionLayer) ) continue;
      const control = layerClass.prepareSceneControls();
      if ( !control ) continue; // Control set is not available to the current user
      controls[control.name] = control;
    }

    // Allow additional controls to be added by packages
    Hooks$1.callAll("getSceneControlButtons", controls);

    // Cleaning for all controls and tools
    for ( const [controlId, control] of Object.entries(controls) ) {
      if ( control.visible === false ) { // Control set is not available to the current user
        delete controls[controlId];
        continue;
      }
      this.#tools[control.name] ||= control.activeTool;
      for ( const [toolId, tool] of Object.entries(control.tools) ) {
        if ( tool.visible === false ) delete control.tools[toolId];
        if ( tool.toggle && tool.button ) {
          console.warn(`The SceneControlTool "${controlId}.${toolId}" may not be both a toggle and a button.`);
          tool.button = false;
        }
      }
      if ( foundry.utils.isEmpty(control.tools) ) delete controls[controlId]; // No available tools
    }
    return controls;
  }

  /* -------------------------------------------- */

  /**
   * Actions to take before re-rendering the SceneControls with the new control layer.
   * Deactivate the prior tool or control and identify what changed.
   * @param {Pick<SceneControlsRenderOptions, "event"|"control"|"tool"|"toggles">} change
   * @returns {SceneControlsActivationChange}
   */
  #preActivate({event, control, tool, toggles}={}) {

    // Enforce valid defaults
    if ( !(control in this.#controls) ) control = this.#control;
    if ( !(tool in this.#controls[control].tools) ) tool = this.#tools[control];
    toggles ??= {};
    event ??= new Event("change", {bubbles: false, cancelable: false});

    // Identify changes
    const priorTool = this.tool;
    const priorControl = this.control;
    const controlChange = control !== this.#control ? control : null;
    const toolChange = (controlChange || (tool !== this.#tools[control])) ? tool : null;
    const toggleChanges = {};

    // Assign new values before invoking callbacks
    this.#control = control;
    this.#tools[control] = tool;
    for ( const [name, active] of Object.entries(toggles) ) {
      const toggle = this.tools[name];
      if ( toggle && (toggle.active !== active) ) {
        toggle.active = active;
        toggleChanges[name] = active;
      }
    }

    // Deactivate prior tool and control
    if ( toolChange ) this.#onToolChange(priorControl, priorTool, event, false);
    if ( controlChange ) this.#onChange(priorControl, event, false);

    // Return the change operation for later use in #postActivate
    return {event, controlChange, toolChange, toggleChanges};
  }

  /* -------------------------------------------- */

  /**
   * Actions to take after re-rendering SceneControls with a new control layer.
   * Activate the new control, tool, or toggles.
   * @param {SceneControlsActivationChange} change
   */
  #postActivate({event, controlChange, toolChange, toggleChanges}={}) {
    if ( controlChange ) this.#onChange(this.control, event, true);
    if ( toolChange ) this.#onToolChange(this.control, this.tool, event, true);
    for ( const [name, active] of Object.entries(toggleChanges) ) {
      const toggle = this.tools[name];
      this.#onChange(toggle, event, active);
    }
  }

  /* -------------------------------------------- */

  /**
   * Trigger the onChange callback for a control or tool if defined.
   * @param {SceneControl|SceneControlTool} toolOrControl
   * @param {Event} event
   * @param {boolean} active
   */
  #onChange(toolOrControl, event, active) {
    if ( toolOrControl.onChange instanceof Function ) toolOrControl.onChange(event, active);
    /** @deprecated since 13 */
    if ( toolOrControl.onClick instanceof Function ) {
      foundry.utils.logCompatibilityWarning("SceneControlTool#onClick is deprecated in favor of "
        + "SceneControlTool#onChange", {once: true, since: 13, until: 15});
      toolOrControl.onClick(active);
    }
  }

  /* -------------------------------------------- */

  /**
   * Trigger the onChange callback for tool if defined.
   * Trigger the onToolChange callback for control if defined.
   * @param {SceneControl} control
   * @param {SceneControlTool} tool
   * @param {Event} event
   * @param {boolean} active
   */
  #onToolChange(control, tool, event, active) {
    this.#onChange(tool, event, active);
    if ( control.onToolChange instanceof Function ) control.onToolChange(event, tool);

    // Cancel current drag workflow
    if ( canvas.currentMouseManager ) {
      canvas.currentMouseManager.interactionData.cancelled = true;
      canvas.currentMouseManager.cancel();
    }
  }

  /* -------------------------------------------- */
  /*  Toolclip Definitions                        */
  /* -------------------------------------------- */

  /**
   * Reusable toolclip items.
   * @type {Record<string, {heading: string, reference: string}>}
   */
  static COMMON_TOOLCLIP_ITEMS = {
    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.RightClick2"},
    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"}
  };

  /* -------------------------------------------- */

  /**
   * A helper function used to prepare an array of toolclip items.
   * @param {Array<ToolclipConfigurationItem|string|null>} items
   * @returns {ToolclipConfigurationItem[]}
   */
  static buildToolclipItems(items) {
    const prepared = [];
    for ( let i of items ) {
      if ( !i ) continue;
      if ( i in this.COMMON_TOOLCLIP_ITEMS ) i = this.COMMON_TOOLCLIP_ITEMS[i];
      prepared.push(i);
    }
    return prepared;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle changing the control layer.
   * @this {SceneControls}
   * @param {PointerEvent} event
   */
  static #onChangeControl(event) {
    if ( !canvas.ready ) return;
    if ( event.target.dataset.control === this.#control ) return;
    this.activate({event, control: event.target.dataset.control});
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the active tool within the currently active control set.
   * @this {SceneControls}
   * @param {PointerEvent} event
   */
  static #onChangeTool(event) {
    if ( !canvas.ready ) return;
    const tool = this.control.tools[event.target.dataset.tool];
    if ( tool === this.tool ) return;
    const options = {event};

    // Buttons
    if ( tool.button ) {
      this.#onChange(tool, event, true);
      return;
    }

    // Toggles
    if ( tool.toggle ) {
      options.toggles = {[tool.name]: !tool.active};
    }

    // Tools
    else options.tool = tool.name;
    this.activate(options);
  }

  /* -------------------------------------------- */

  /**
   * Lazily render toolclip HTML when a tool is hovered.
   * @param {PointerEvent} event
   * @returns {Promise<void>}
   */
  async #onPointerEnterToolclip(event) {
    event.target.dataset.tooltipHtml = await this.#renderToolclip(event.target.dataset.tool);
    delete event.target.dataset.toolclip;
    const pointerover = new event.constructor(event.type, event);
    event.target.dispatchEvent(pointerover);
  }

  /* -------------------------------------------- */

  /**
   * Lazily render and activate a toolclip tooltip.
   * @param {string} toolId
   * @returns {Promise<string>}
   */
  async #renderToolclip(toolId) {
    const tool = this.tools[toolId];
    const isMac = navigator.appVersion.includes("Mac");
    const mod = isMac ? "⌘" : game.i18n.localize("CONTROLS.CtrlAbbr");
    const alt = isMac ? "⌥" : game.i18n.localize("CONTROLS.Alt");
    return foundry.applications.handlebars.renderTemplate("templates/ui/toolclip.hbs", {...tool.toolclip, alt, mod});
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get activeControl() {
    foundry.utils.logCompatibilityWarning("SceneControls#activeControl is deprecated in favor of"
      + " SceneControls#control#name", {since: 13, until: 15});
    return this.#control;
  }

  /**
   * @deprecated since v13
   * @ignore
   */
  get activeTool() {
    foundry.utils.logCompatibilityWarning("SceneControls#activeTool is deprecated in favor of"
      + " SceneControls#tool#name", {since: 13, until: 15});
    return this.#tools[this.#control];
  }

  /**
   * @deprecated since v13
   * @ignore
   */
  async initialize({layer, tool}={}) {
    foundry.utils.logCompatibilityWarning("SceneControls#initialize is deprecated in favor of SceneControls#render"
      + " with {controls, tool} passed as render options.", {since: 13, until: 15});
    return this.render({control: layer, tool});
  }
}

/**
 * The Scene Navigation UI element.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class SceneNavigation extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "scene-navigation",
    classes: ["faded-ui", "flexcol"],
    tag: "nav",
    window: {
      frame: false,
      positioned: false
    },
    actions: {
      viewScene: SceneNavigation.#onViewScene,
      toggleExpand: SceneNavigation.#onToggleExpand
    }
  };

  /** @override */
  static PARTS = {
    scenes: {
      root: true,
      template: "templates/ui/scene-navigation.hbs"
    }
  };

  /**
   * A reference to the Scene currently being dragged.
   * @type {Scene}
   */
  #dragScene;

  /**
   * An HTMLElement which is temporarily the drop target for a drag-and-drop operation.
   * @type {HTMLElement}
   */
  #dropTarget;

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Whether the scene navigation is currently expanded.
   * @type {boolean}
   */
  get expanded() {
    return this.element.classList.contains("expanded");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(_options) {
    const scenes = this.#prepareScenes();
    return {
      scenes,
      canExpand: scenes.inactive.length
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare Scene data for rendering.
   * @returns {{inactive: object[], active: object[]}}
   */
  #prepareScenes() {
    const userScenes = game.users.reduce((obj, u) => {
      if ( !u.active ) return obj;
      obj[u.viewedScene] ||= [];
      obj[u.viewedScene].push({name: u.name, letter: u.name[0], color: u.color.multiply(0.5).css, border: u.color});
      return obj;
    }, {});
    const scenes = {active: [], inactive: []};
    for ( const scene of game.scenes ) {
      const {active, isView} = scene;
      const visible = active || isView || (scene.navigation && scene.visible);
      if ( !visible ) continue;
      const s = {
        id: scene.id,
        active,
        isView,
        navOrder: scene.navOrder,
        name: scene.navName || scene.name,
        tooltip: (scene.navName && game.user.isGM) ? scene.name : "",
        users: userScenes[scene.id],
        cssClass: [
          isView ? "view" : null,
          active ? "active" : null,
          scene.ownership.default === 0 ? "gm" : null
        ].filterJoin(" ")
      };
      if ( active || isView || s.users?.length ) scenes.active.push(s);
      else scenes.inactive.push(s);
    }
    scenes.active.sort((a, b) => (b.isView - a.isView) || (b.active - a.active) || (a.navOrder - b.navOrder));
    scenes.inactive.sort((a, b) => a.navOrder - b.navOrder);
    return scenes;
  }

  /* -------------------------------------------- */

  /** @override */
  async _onFirstRender(_context, _options) {
    game.scenes.apps.push(this);
    if ( !game.user.isGM ) return;
    /** @fires {hookEvents:getSceneContextOptions} */
    this._createContextMenu(this._getContextMenuOptions, ".scene", {
      fixed: true,
      hookName: "getSceneContextOptions",
      parentClassHooks: false
    });
  }

  /* -------------------------------------------- */

  /** @override */
  async _onRender(_context, _options) {
    this.#setCollapseTooltip(`SCENE_NAVIGATION.${this.expanded ? "COLLAPSE" : "EXPAND"}`);
    if ( !game.user.isGM ) return;

    // Drag and Drop
    new foundry.applications.ux.DragDrop.implementation({
      dragSelector: ".scene",
      dropSelector: "#scene-navigation-inactive",
      callbacks: {
        dragstart: this.#onDragStart.bind(this),
        dragover: this.#onDragOver.bind(this),
        drop: this.#onDragDrop.bind(this)
      }
    }).bind(this.element);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the tooltip and aria-label properties of the collapse/expand button.
   * @param {string} tooltip
   */
  #setCollapseTooltip(tooltip) {
    const button = this.element.querySelector("#scene-navigation-expand");
    if ( !button ) return;
    button.dataset.tooltip = tooltip;
    button.setAttribute("aria-label", game.i18n.localize(tooltip));
  }

  /* -------------------------------------------- */

  /**
   * Get the set of ContextMenu options which should be applied for Scenes in the menu.
   * @returns {ContextMenuEntry[]}   The Array of context options passed to the ContextMenu instance
   * @protected
   */
  _getContextMenuOptions() {
    return [
      {
        name: "SCENE.Activate",
        icon: '<i class="fa-solid fa-bullseye"></i>',
        condition: li => game.user.isGM && !game.scenes.get(li.dataset.sceneId).active,
        callback: li => {
          const scene = game.scenes.get(li.dataset.sceneId);
          scene.activate();
        }
      },
      {
        name: "SCENE.Configure",
        icon: '<i class="fa-solid fa-gears"></i>',
        condition: game.user.isGM,
        callback: li => {
          const scene = game.scenes.get(li.dataset.sceneId);
          scene.sheet.render(true);
        }
      },
      {
        name: "SCENE.Notes",
        icon: '<i class="fa-solid fa-scroll"></i>',
        condition: li => {
          if ( !game.user.isGM ) return false;
          const scene = game.scenes.get(li.dataset.sceneId);
          return !!scene.journal;
        },
        callback: li => {
          const scene = game.scenes.get(li.dataset.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: "SCENE.Preload",
        icon: '<i class="fa-solid fa-download"></i>',
        condition: game.user.isGM,
        callback: li => {
          const sceneId = li.dataset.sceneId;
          game.scenes.preload(sceneId, true);
        }
      },
      {
        name: "SCENE.ToggleNav",
        icon: '<i class="fas fa-compass"></i>',
        condition: li => {
          const scene = game.scenes.get(li.dataset.sceneId);
          return game.user.isGM && !scene.active;
        },
        callback: li => {
          const scene = game.scenes.get(li.dataset.sceneId);
          scene.update({navigation: !scene.navigation});
        }
      }
    ];
  }

  /* -------------------------------------------- */
  /*  Public API                                 */
  /* -------------------------------------------- */

  /**
   * Expand Scene Navigation, displaying inactive Scenes.
   * @fires {hookEvents:collapseSceneNavigation}
   */
  expand() {
    this.toggleExpanded(true);
  }

  /* -------------------------------------------- */

  /**
   * Collapse Scene Navigation, hiding inactive Scenes.
   * @fires {hookEvents:collapseSceneNavigation}
   */
  async collapse() {
    this.toggleExpanded(false);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the expanded state of scene navigation.
   * @param {boolean} [expanded]  Force the expanded state to the provided value, otherwise toggle the state.
   * @fires {hookEvents:collapseSceneNavigation}
   */
  toggleExpanded(expanded) {
    expanded ??= !this.expanded;
    this.element.classList.toggle("expanded");
    this.#setCollapseTooltip(`SCENE_NAVIGATION.${expanded ? "COLLAPSE" : "EXPAND"}`);
    Hooks$1.callAll("collapseSceneNavigation", this, !expanded);
  }

  /* -------------------------------------------- */
  /*  Action Event Handlers                      */
  /* -------------------------------------------- */

  /**
   * Handle a click event to view a certain Scene.
   * @this {SceneNavigation}
   * @param {PointerEvent} event
   * @returns {Promise<void>}
   */
  static async #onViewScene(event) {
    const sceneId = event.target.closest(".scene").dataset.sceneId;
    const scene = game.scenes.get(sceneId);
    if ( !scene ) return;
    await scene.view();
  }

  /* -------------------------------------------- */

  /**
   * Handle a click event to view a certain Scene.
   * @this {SceneNavigation}
   */
  static #onToggleExpand() {
    this.toggleExpanded();
  }

  /* -------------------------------------------- */

  /**
   * Begin dragging a Scene to change its navigation order.
   * @param {DragEvent} event
   */
  #onDragStart(event) {
    const target = event.target.closest(".scene");
    this.#dragScene = game.scenes.get(target.dataset.sceneId);
  }

  /* -------------------------------------------- */

  /**
   * Highlight the Scene which becomes the drop target for drag-and-drop handling.
   * @param {DragEvent} event
   */
  #onDragOver(event) {
    const target = event.target.closest(".scene");
    if ( target === this.#dropTarget ) return;

    // Remove drop target highlight
    if ( this.#dropTarget ) this.#dropTarget.classList.remove("drop-target-before", "drop-target-after");
    this.#dropTarget = target;
    if ( !target || ( target.dataset.sceneId === this.#dragScene.id) ) return;

    // Add drop target highlight
    const scene = game.scenes.get(target.dataset.sceneId);
    const dropClass = this.#dragScene.navOrder < scene.navOrder ? "drop-target-after" : "drop-target-before";
    target.classList.add(dropClass);
  }

  /* -------------------------------------------- */

  /**
   * Conclude dragging a Scene to change its navigation order.
   * @param {DragEvent} event
   */
  async #onDragDrop(event) {
    if ( this.#dropTarget ) {
      this.#dropTarget.classList.remove("drop-target-before", "drop-target-after");
      this.#dropTarget = undefined;
    }

    // Retrieve the drag target Scene
    const scene = this.#dragScene;
    this.#dragScene = undefined;
    if ( !scene ) return;

    // Retrieve the drop target Scene
    const li = event.target.closest(".scene");
    const target = game.scenes.get(li?.dataset.sceneId);
    if ( !target || (target === scene) ) return;

    // Sort Scenes on navOrder relative to siblings
    const siblings = game.scenes.filter(s => s !== scene);
    await scene.sortRelative({sortKey: "navOrder", target, siblings});
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  static displayProgressBar({label, pct} = {}) {
    foundry.utils.logCompatibilityWarning("SceneNavigation.displayProgressBar is deprecated in favor of "
      + "Notifications#notify using the {progress: true} option", {since: 13, until: 15});
    let bar = SceneNavigation.#loadingBar;
    if ( !bar || !ui.notifications.has(bar) ) {
      bar = SceneNavigation.#loadingBar = ui.notifications.info(label, {progress: true});
    }
    pct = Math.clamp(pct, 0, 100) / 100;
    bar.update({message: label, pct});
  }

  static #loadingBar;
}

var _module$A = /*#__PURE__*/Object.freeze({
  __proto__: null,
  GamePause: GamePause,
  Hotbar: Hotbar,
  MainMenu: MainMenu,
  Notifications: Notifications,
  Players: Players,
  RegionLegend: RegionLegend,
  SceneControls: SceneControls,
  SceneNavigation: SceneNavigation
});

/** @module applications */


/**
 * A registry of currently rendered ApplicationV2 instances.
 * @type {Map<string, ApplicationV2>}
 */
const instances = new Map();

/**
 * Parse an HTML string, returning a processed HTMLElement or HTMLCollection.
 * @param {string} htmlString
 * @deprecated since v13
 * @see {@link foundry.utils.parseHTML}
 */
function parseHTML(htmlString) {
  foundry.utils.logCompatibilityWarning(
    "You are accessing foundry.applications.parseHTML, which is now found at foundry.utils.parseHTML",
    {since: 13, until: 15, once: true}
  );
  return foundry.utils.parseHTML(htmlString);
}

// Initialize Handlebars Helpers
initialize();

var _module$z = /*#__PURE__*/Object.freeze({
  __proto__: null,
  api: _module$O,
  apps: _module$M,
  dice: _module$L,
  elements: _module$K,
  fields: fields$1,
  handlebars: handlebars,
  hud: _module$J,
  instances: instances,
  parseHTML: parseHTML,
  settings: _module$H,
  sheets: _module$F,
  sidebar: _module$B,
  types: _types$b,
  ui: _module$A,
  ux: _module$Z
});

/**
 * @import {ApplicationV1Options} from "./application-v1.mjs"
 */

/**
 * @typedef DialogV1Options
 * @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 DialogV1Button
 * @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 DialogData
 * @property {string} title                 The window title displayed in the dialog header
 * @property {string} content               HTML content for the dialog form
 * @property {Record<string, DialogV1Button>} 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.
 *
 * @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="fa-solid fa-check"></i>',
 *    label: "Option One",
 *    callback: () => console.log("Chose One")
 *   },
 *   two: {
 *    icon: '<i class="fa-solid fa-xmark"></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);
 * ```
 * @deprecated since v13
 */
class Dialog extends Application {
  /**
   * @param {DialogData} data          An object of dialog data which configures how the modal window is rendered
   * @param {ApplicationV1Options & DialogV1Options} [options]  Dialog rendering options, see
   *                                                            {@link foundry.appv1.api.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 {(event: KeyboardEvent) => void|Promise<void>}
   */
  #onKeyDown;

  /* -------------------------------------------- */

  /**
   * @override
   * @returns {DialogV1Options}
   */
  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) {
    const buttons = Object.keys(this.data.buttons).reduce((obj, key) => {
      const 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
   */
  #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
   * @protected
   */
  _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() {
    const 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
   */
  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                             */
  /* -------------------------------------------- */

  /**
   * @typedef DialogV1ConfirmOptions
   * @property {Function} [yes]               Callback function upon yes
   * @property {Function} [no]                Callback function upon no
   * @property {boolean} [defaultYes=true]    Make "yes" the default choice?
   * @property {boolean} [rejectClose=false]  Reject the Promise if the Dialog is closed without making a choice.
   * @param {ApplicationV1Options & DialogV1Options} [config.options]  Additional rendering options passed to the Dialog
   */

  /**
   * 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 & DialogV1ConfirmOptions} [config]    Dialog configuration options
   * @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="fa-solid fa-check"></i>',
          label: game.i18n.localize("Yes"),
          callback: html => yes ? yes(html) : true
        },
        no: {
          icon: '<i class="fa-solid fa-xmark"></i>',
          label: game.i18n.localize("No"),
          callback: html => no ? no(html) : false
        }
      }
    }, options);
  }

  /* -------------------------------------------- */

  /**
   * @typedef DialogV1PromptOptions
   * @param {string} [label]              The label of the button
   * @param {Function} [callback]         A callback function to fire when the button is clicked
   * @param {boolean} [rejectClose=true]  Reject the promise if the dialog is closed without confirming the
   *                                      choice, otherwise resolve as null
   * @param {ApplicationV1Options & DialogV1Options} [config.options]  Additional rendering options passed to the Dialog
   */

  /**
   * A helper factory method to display a basic "prompt" style Dialog with a single button
   * @param {DialogData & DialogV1PromptOptions} [config]    Dialog configuration 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="fa-solid 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 {ApplicationV1Options & DialogV1Options} [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;
        const callback = function(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);
    });
  }
}

var _module$y = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Application: Application,
  Dialog: Dialog,
  DocumentSheet: DocumentSheet,
  FormApplication: FormApplication
});

/**
 * @import Actor from "@client/documents/actor.mjs";
 * @import {ApplicationV1Options} from "../api/application-v1.mjs";
 * @import {DocumentSheetV1Options} from "../api/document-sheet-v1.mjs";
 */

/**
 * 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.
 * @deprecated since v13
 * @param {Actor} actor                     The Actor instance being displayed within the sheet.
 * @param {DocumentSheetV1Options & ApplicationV1Options} [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"}],
      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 {TokenDocument|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() {
    const 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: "fa-solid fa-circle-user",
        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 ( const 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
   * @internal
   */
  _onConfigureToken(event) {
    event.preventDefault();
    const renderOptions = {
      force: true,
      position: {
        left: Math.max(this.position.left - 560 - 10, 10),
        top: this.position.top
      }
    };
    if ( this.token ) return this.token.sheet.render(renderOptions);
    else new CONFIG.Token.prototypeSheetClass({prototype: this.actor.prototypeToken}).render(renderOptions);
  }

  /* -------------------------------------------- */
  /*  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$1.implementation.getDragEventData(event);
    const actor = this.actor;
    const allowed = Hooks$1.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$1.implementation.fromDropData(data);
    if ( !this.actor.isOwner || !effect ) return false;
    if ( effect.target === this.actor ) return false;
    return ActiveEffect$1.implementation.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$1.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$1.implementation.fromDropData(data);
    if ( folder.type !== "Item" ) return [];
    const droppedItemData = await Promise.all(folder.contents.map(async item => {
      if ( !(item instanceof Item$1) ) item = await foundry.utils.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[]>}
   * @internal
   */
  async _onDropItemCreate(itemData, event) {
    itemData = Array.isArray(itemData) ? 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
   * @internal
   */
  _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 ( const 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 = foundry.utils.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 importing an adventure from a compendium pack.
 * @deprecated since v13
 */
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-v1.hbs",
      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$1.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) {
    const prepareImportDefined = foundry.utils.getDefiningClass(this, "_prepareImportData");
    const importContentDefined = foundry.utils.getDefiningClass(this, "_importContent");
    if ( (prepareImportDefined !== AdventureImporter) || (importContentDefined !== AdventureImporter) ) {
      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
   * @param {object} formData
   */
  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$1.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 alert = game.i18n.localize("Warning");
      const warning = game.i18n.format("ADVENTURE.ImportOverwriteWarning", {name: this.adventure.name});
      const confirm = await foundry.applications.api.DialogV2.confirm({
        window: {title: "ADVENTURE.ImportOverwriteTitle"},
        position: {width: 480},
        content: `<p><strong>${alert}:</strong> ${warning}</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$1.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: 16});
    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: 16});
    return this.adventure.importContent({toCreate, toUpdate, documentCount});
  }
}

/**
 * @import Item from "@client/documents/item.mjs";
 * @import {ApplicationV1Options} from "../api/application-v1.mjs";
 * @import {DocumentSheetV1Options} from "../api/document-sheet-v1.mjs";
 */

/**
 * The Application responsible for displaying and editing a single Item document.
 * @deprecated since v13
 * @param {Item} item                       The Item instance being displayed within the sheet.
 * @param {DocumentSheetV1Options & ApplicationV1Options} [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;
  }
}

var _module$x = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActorSheet: ActorSheet,
  AdventureImporter: AdventureImporter,
  ItemSheet: ItemSheet,
  JournalPageSheet: JournalPageSheet,
  JournalSheet: JournalSheet,
  JournalTextPageSheet: JournalTextPageSheet,
  JournalTextTinyMCESheet: JournalTextTinyMCESheet
});

/** @module appv1 */

var _module$w = /*#__PURE__*/Object.freeze({
  __proto__: null,
  api: _module$y,
  sheets: _module$x
});

/**
 * @import {AVMaster} from "./master.mjs";
 * @import {AVSettings} from "./settings.mjs";
 */

/**
 * 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>}
   * @abstract
   */
  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?
   * @abstract
   */
  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?
   * @abstract
   */
  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}>}
   */
  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
   * @abstract
   */
  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
   * @abstract
   */
  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.
   * @abstract
   */
  getLevelsStreamForUser(userId) {
    throw new Error("An AVClient subclass must define the getLevelsStreamForUser method");
  }

  /* -------------------------------------------- */

  /**
   * Is outbound audio enabled for the current user?
   * @returns {boolean}
   * @abstract
   */
  isAudioEnabled() {
    throw Error("The isAudioEnabled() method must be defined by an AVClient subclass.");
  }

  /* -------------------------------------------- */

  /**
   * Is outbound video enabled for the current user?
   * @returns {boolean}
   * @abstract
   */
  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)
   * @abstract
   */
  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?
   * @abstract
   */
  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)
   * @abstract
   */
  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
   * @abstract
   */
  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.
   * @abstract
   */
  async updateLocalStream() {
    throw Error("The updateLocalStream() method must be defined by an AVClient subclass.");
  }
}

/**
 * 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}
   */
  #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.
   */
  #connect() {
    const promises = [];
    for ( const 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.
   */
  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?
   */
  #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.
   */
  #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);
  }
}

var _module$v = /*#__PURE__*/Object.freeze({
  __proto__: null,
  SimplePeerAVClient: SimplePeerAVClient
});

/**
 * @import {KeyboardEventContext} from "@client/_types.mjs";
 * @import {AVClient} from "./client.mjs";
 */

/**
 * The master Audio/Video controller instance.
 * This is available as the singleton game.webrtc
 */
class AVMaster {
  constructor() {
    this.settings = new AVSettings();
    this.config = new foundry.applications.settings.menus.AVConfig({webrtc: 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;

    // Other internal flags
    this._speakingData = {speaking: false, volumeHistories: []};
    this._pttMuteTimeout = 0;
    this._reconnectPeriodMS = 3000;
  }

  /* -------------------------------------------- */

  /**
   * 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}
   */
  #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}
   */
  #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}
   */
  #reconnecting = false;

  /* -------------------------------------------- */

  get mode() {
    return this.settings.world.mode;
  }

  /* -------------------------------------------- */
  /*  Initialization                              */
  /* -------------------------------------------- */

  /**
   * Connect to the Audio/Video client.
   * @returns {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(`${CONST.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.
   * @returns {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(`${CONST.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.
   * @returns {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.
   */
  #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();

    // 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
   * @returns {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
   * @returns {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
   * @returns {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
   * @returns {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
   * @internal
   */
  _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
   */
  #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] = 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
   * @internal
   */
  _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
   * @internal
   */
  _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();
  }

  /* -------------------------------------------- */
  /*  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));

    // Requires re-render.
    const rerender = ["client.dockPosition", "client.nameplates"].some(k => keys.includes(k));
    if ( rerender ) ui.webrtc.render({ force: true });

    // Call client specific setting handling
    this.client.onSettingsChanged(changed);
  }

  /* -------------------------------------------- */

  debug(message) {
    if ( this.settings.debug ) console.debug(message);
  }
}

/** @module av */

var _module$u = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AVClient: AVClient,
  AVMaster: AVMaster,
  AVSettings: AVSettings,
  clients: _module$v
});

/**
 * @import {AudioBufferCacheEntry} from "./_types.mjs";
 */

 /**
 * A specialized cache used for audio buffers.
 * This is an LRU cache which expires buffers from the cache once the maximum cache size is exceeded.
 * @extends {Map<string, AudioBufferCacheEntry>}
 */
class AudioBufferCache extends Map {
  /**
   * Construct an AudioBufferCache providing a maximum disk size beyond which entries are expired.
   * @param {number} [cacheSize]    The maximum cache size in bytes. 1GB by default.
   */
  constructor(cacheSize=Math.pow(1024, 3)) {
    super();
    this.#maxSize = cacheSize;
  }

  /**
   * The maximum cache size in bytes.
   * @type {number}
   */
  #maxSize;

  /**
   * The current memory utilization in bytes.
   * @type {number}
   */
  #memorySize = 0;

  /**
   * The head of the doubly-linked list.
   * @type {AudioBufferCacheEntry}
   */
  #head;

  /**
   * The tail of the doubly-linked list
   * @type {AudioBufferCacheEntry}
   */
  #tail;

  /**
   * A string representation of the current cache utilization.
   * @type {{current: number, max: number, pct: number, currentString: string, maxString: string, pctString: string}}
   */
  get usage() {
    return {
      current: this.#memorySize,
      max: this.#maxSize,
      pct: this.#memorySize / this.#maxSize,
      currentString: foundry.utils.formatFileSize(this.#memorySize),
      maxString: foundry.utils.formatFileSize(this.#maxSize),
      pctString: `${(this.#memorySize * 100 / this.#maxSize).toFixed(2)}%`
    };
  }

  /* -------------------------------------------- */
  /*  Cache Methods                               */
  /* -------------------------------------------- */

  /**
   * Retrieve an AudioBuffer from the cache.
   * @param {string} src      The audio buffer source path
   * @returns {AudioBuffer}   The cached audio buffer, or undefined
   */
  getBuffer(src) {
    const node = super.get(src);
    let buffer;
    if ( node ) {
      buffer = node.buffer;
      if ( this.#head !== node ) this.#shift(node);
    }
    return buffer;
  }

  /* -------------------------------------------- */

  /**
   * Insert an AudioBuffer into the buffers cache.
   * @param {string} src          The audio buffer source path
   * @param {AudioBuffer} buffer  The audio buffer to insert
   * @returns {AudioBufferCache}
   */
  setBuffer(src, buffer) {
    if ( !(buffer instanceof AudioBuffer) ) {
      throw new Error("The AudioBufferCache is only used to store AudioBuffer instances");
    }
    let node = super.get(src);
    if ( node ) this.#remove(node);
    node = {src, buffer, size: buffer.length * buffer.numberOfChannels * 4, next: this.#head};
    super.set(src, node);
    this.#insert(node);
    game.audio.debug(`Cached audio buffer "${src}" | ${this}`);
    this.#expire();
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Delete an entry from the cache.
   * @param {string} src          The audio buffer source path
   * @returns {boolean}           Was the buffer deleted from the cache?
   */
  delete(src) {
    const node = super.get(src);
    if ( node ) this.#remove(node);
    return super.delete(src);
  }

  /* -------------------------------------------- */

  /**
   * Lock a buffer, preventing it from being expired even if it is least-recently-used.
   * @param {string} src              The audio buffer source path
   * @param {boolean} [locked=true]   Lock the buffer, preventing its expiration?
   */
  lock(src, locked=true) {
    const node = super.get(src);
    if ( !node ) return;
    node.locked = locked;
  }

  /* -------------------------------------------- */

  /**
   * Insert a new node into the cache, updating the linked list and cache size.
   * @param {AudioBufferCacheEntry} node    The node to insert
   */
  #insert(node) {
    if ( this.#head ) {
      this.#head.previous = node;
      this.#head = node;
    }
    else this.#head = this.#tail = node;
    this.#memorySize += node.size;
  }

  /* -------------------------------------------- */

  /**
   * Remove a node from the cache, updating the linked list and cache size.
   * @param {AudioBufferCacheEntry} node    The node to remove
   */
  #remove(node) {
    if ( node.previous ) node.previous.next = node.next;
    else this.#head = node.next;
    if ( node.next ) node.next.previous = node.previous;
    else this.#tail = node.previous;
    this.#memorySize -= node.size;
  }

  /* -------------------------------------------- */

  /**
   * Shift an accessed node to the head of the linked list.
   * @param {AudioBufferCacheEntry} node    The node to shift
   */
  #shift(node) {
    node.previous = undefined;
    node.next = this.#head;
    this.#head.previous = node;
    this.#head = node;
  }

  /* -------------------------------------------- */

  /**
   * Recursively expire entries from the cache in least-recently used order.
   * Skip expiration of any entries which are locked.
   * @param {AudioBufferCacheEntry} [node]  A node from which to start expiring. Otherwise, starts from the tail.
   */
  #expire(node) {
    if ( this.#memorySize < this.#maxSize ) return;
    node ||= this.#tail;
    if ( !node.locked ) {
      this.#remove(node);
      game.audio.debug(`Expired audio buffer ${node.src} | ${this}`);
    }
    if ( node.previous ) this.#expire(node.previous);
  }

  /* -------------------------------------------- */

  /** @override */
  toString() {
    const {currentString, maxString, pctString} = this.usage;
    return `AudioBufferCache: ${currentString} / ${maxString} (${pctString})`;
  }
}

/**
 * A Tile is an implementation of PlaceableObject which represents a static piece of artwork or prop within the Scene.
 * @category Canvas
 * @see {@link foundry.documents.TileDocument}
 * @see {@link foundry.canvas.layers.TilesLayer}
 */
let Tile$1 = 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|PIXI.Spritesheet|null}
   */
  texture = null;

  /**
   * A Tile background which is displayed if no valid image texture is present
   * @type {PIXI.Graphics|null}
   */
  bg = null;

  /**
   * A reference to the SpriteMesh which displays this Tile in the PrimaryCanvasGroup.
   * @type {PrimarySpriteMesh|null}
   */
  mesh = null;

  /**
   * 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;
    const 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 {PIXI.ImageSource|null}
   */
  get sourceElement() {
    return this.texture?.baseTexture.resource.source ?? null;
  }

  /* -------------------------------------------- */

  /**
   * 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.position.x,
      rectangle.y - this.position.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.tools?.foreground.active ? canvas.scene.foregroundElevation : 0);
    data.sort = Math.max(canvas.tiles.getMaxSort() + 1, 0);

    // Create a pending TileDocument
    const cls = getDocumentClass$1("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 = null;
    }

    // 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() {
    if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Base texture destroyed for non preview video
    this.#unlinkedVideo = false;
    return super.clear();
  }

  /* -------------------------------------------- */

  /** @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 = null;
      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));
    if ( this.hasActiveHUD ) this.layer.hud.setPosition();
  }

  /* -------------------------------------------- */

  /**
   * 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.tools?.foreground.active;
    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 * canvas.dimensions.uiScale;

    // 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) {
    if ( event.nativeEvent && (event.nativeEvent.target.id !== canvas.app.view.id) ) return;
    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) {
    if ( event.nativeEvent && (event.nativeEvent.target.id !== canvas.app.view.id) ) return;
    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;
  }
};

/**
 * @typedef AudioTimeoutOptions
 * @property {AudioContext} [context]
 * @property {function(): any} [callback]
 */

/**
 * A special error class used for cancellation.
 */
class AudioTimeoutCancellation extends Error {}

/**
 * A framework for scheduled audio events with more precise and synchronized timing than using window.setTimeout.
 * This approach creates an empty audio buffer of the desired duration played using the shared game audio context.
 * The onended event of the AudioBufferSourceNode provides a very precise way to synchronize audio events.
 * For audio timing, this is preferable because it avoids numerous issues with window.setTimeout.
 *
 * @example Using a callback function
 * ```js
 * function playForDuration(sound, duration) {
 *   sound.play();
 *   const wait = new AudioTimeout(duration, {callback: () => sound.stop()})
 * }
 * ```
 *
 * @example Using an awaited Promise
 * ```js
 * async function playForDuration(sound, duration) {
 *   sound.play();
 *   const timeout = new AudioTimeout(delay);
 *   await timeout.complete;
 *   sound.stop();
 * }
 * ```
 *
 * @example Using the wait helper
 * ```js
 * async function playForDuration(sound, duration) {
 *   sound.play();
 *   await AudioTimeout.wait(duration);
 *   sound.stop();
 * }
 * ```
 */
class AudioTimeout {
  /**
   * Create an AudioTimeout by providing a delay and callback.
   * @param {number} delayMS                    A desired delay timing in milliseconds
   * @param {AudioTimeoutOptions} [options]     Additional options which modify timeout behavior
   */
  constructor(delayMS, {callback, context}={}) {
    if ( !(typeof delayMS === "number") ) throw new Error("Numeric timeout duration must be provided");
    this.#callback = callback;
    this.complete = new Promise((resolve, reject) => {
      this.#resolve = resolve;
      this.#reject = reject;

      // Immediately evaluated
      if ( delayMS <= 0 ) return this.end();

      // Create and play a blank AudioBuffer of the desired delay duration
      context ||= game.audio.music;
      const seconds = delayMS / 1000;
      const sampleRate = context.sampleRate;
      const buffer = new AudioBuffer({length: seconds * sampleRate, sampleRate});
      this.#sourceNode = new AudioBufferSourceNode(context, {buffer});
      this.#sourceNode.onended = this.end.bind(this);
      this.#sourceNode.start();
    })

    // The promise may get cancelled
    .catch(err => {
      if ( err instanceof AudioTimeoutCancellation ) return;
      throw err;
    });
  }

  /**
   * Is the timeout complete?
   * This can be used to await the completion of the AudioTimeout if necessary.
   * The Promise resolves to the returned value of the provided callback function.
   * @type {Promise<*>}
   */
  complete;

  /**
   * The resolution function for the wrapping Promise.
   * @type {Function}
   */
  #resolve;

  /**
   * The rejection function for the wrapping Promise.
   * @type {Function}
   */
  #reject;

  /**
   * A scheduled callback function
   * @type {Function}
   */
  #callback;

  /**
   * The source node used to maintain the timeout
   * @type {AudioBufferSourceNode}
   */
  #sourceNode;

  /* -------------------------------------------- */

  /**
   * Is this audio timeout cancelled?
   * @type {boolean}
   */
  get cancelled() {
    return this.#cancelled;
  }

  #cancelled = false;

  /* -------------------------------------------- */

  /**
   * Cancel an AudioTimeout by ending it early, rejecting its completion promise, and skipping any callback function.
   */
  cancel() {
    this.#cancelled = true;
    if ( !this.#reject ) return;
    const reject = this.#reject;
    this.#resolve = this.#reject = undefined;
    reject(new AudioTimeoutCancellation("AudioTimeout cancelled"));
    this.#sourceNode.onended = null;
    this.#sourceNode.stop();
  }

  /* -------------------------------------------- */

  /**
   * End the timeout, either on schedule or prematurely. Executing any callback function
   */
  end() {
    const resolve = this.#resolve;
    this.#resolve = this.#reject = undefined;
    resolve(this.#callback?.());
  }

  /* -------------------------------------------- */

  /**
   * Schedule a task according to some audio timeout.
   * @param {number} delayMS                  A desired delay timing in milliseconds
   * @param {AudioTimeoutOptions} [options]   Additional options which modify timeout behavior
   * @returns {Promise<void|any>}             A promise which resolves as a returned value of the callback or void
   */
  static async wait(delayMS, options) {
    const timeout = new this(delayMS, options);
    return timeout.complete;
  }
}

/**
 * @import {SoundPlaybackOptions, SoundScheduleCallback} from "./_types.mjs";
 */

/**
 * @typedef PositionalSoundPlaybackOptions
 * @property {number} [volume=1.0]                      The maximum volume at which the effect should be played
 * @property {boolean} [easing=true]                    Should volume be attenuated by distance?
 * @property {boolean} [walls=true]                     Should the sound be constrained by walls?
 * @property {boolean} [gmAlways=true]                  Should the sound always be played for GM users regardless
 *                                                      of actively controlled tokens?
 * @property {AmbientSoundEffect} [baseEffect]          A base sound effect to apply to playback
 * @property {AmbientSoundEffect} [muffledEffect]       A muffled sound effect to apply to playback, a sound may
 *                                                      only be muffled if it is not constrained by walls
 * @property {Partial<PointEffectSourceData>} [sourceData] Additional data passed to the SoundSource constructor
 * @property {SoundPlaybackOptions} [playbackOptions]   Additional options passed to Sound#play
 */

/**
 * A container around an AudioNode which manages sound playback in Foundry Virtual Tabletop.
 * Each Sound is either an AudioBufferSourceNode (for short sources) or a MediaElementAudioSourceNode (for long ones).
 * This class provides an interface around both types which allows standardized control over playback.
 * @see {EventEmitterMixin}
 */
class Sound extends EventEmitterMixin() {
  /**
   * Construct a Sound by providing the source URL and other options.
   * @param {string} src                    The audio source path, either a relative path or a remote URL
   * @param {object} [options]              Additional options which configure the Sound
   * @param {AudioContext} [options.context]  A non-default audio context within which the sound should play
   * @param {boolean} [options.forceBuffer]   Force use of an AudioBufferSourceNode even if the audio duration is long
   */
  constructor(src, {context, forceBuffer=false}={}) {
    super();
    Object.defineProperties(this, {
      id: {value: Sound.#nodeId++, writable: false, enumerable: true, configurable: false},
      src: {value: src, writable: false, enumerable: true, configurable: false}
    });
    this.#context = context || game.audio.music;
    this.#forceBuffer = forceBuffer;
  }

  /**
   * The sequence of container loading states.
   * @enum {Readonly<number>}
   */
  static STATES = Object.freeze({
    FAILED: -1,
    NONE: 0,
    LOADING: 1,
    LOADED: 2,
    STARTING: 3,
    PLAYING: 4,
    PAUSED: 5,
    STOPPING: 6,
    STOPPED: 7
  });

  /**
   * The maximum duration, in seconds, for which an AudioBufferSourceNode will be used.
   * Otherwise, a MediaElementAudioSourceNode will be used.
   * @type {number}
   */
  static MAX_BUFFER_DURATION = 10 * 60;  // 10 Minutes

  /**
   * An incrementing counter used to assign each Sound a unique id.
   * @type {number}
   */
  static #nodeId = 0;

  /** @override */
  static emittedEvents = ["load", "play", "pause", "end", "stop"];

  /**
   * A unique integer identifier for this sound.
   * @type {number}
   */
  id;

  /**
   * The audio source path.
   * Either a relative path served by the running Foundry VTT game server or a remote URL.
   * @type {string}
   */
  src;

  /**
   * The audio context within which this Sound is played.
   * @type {AudioContext}
   */
  get context() {
    return this.#context;
  }

  #context;

  /**
   * When this Sound uses an AudioBuffer, this is an AudioBufferSourceNode.
   * @type {AudioBufferSourceNode}
   */
  #bufferNode;

  /**
   * When this Sound uses an HTML Audio stream, this is a MediaElementAudioSourceNode.
   * @type {MediaElementAudioSourceNode}
   */
  #mediaNode;

  /**
   * The AudioSourceNode used to control sound playback.
   * @type {AudioBufferSourceNode|MediaElementAudioSourceNode}
   */
  get sourceNode() {
    return this.#bufferNode || this.#mediaNode;
  }

  /**
   * The GainNode used to control volume for this sound.
   * @type {GainNode}
   */
  gainNode;

  /**
   * An AudioBuffer instance, if this Sound uses an AudioBufferSourceNode for playback.
   * @type {AudioBuffer|null}
   */
  buffer = null;

  /**
   * An HTMLAudioElement, if this Sound uses a MediaElementAudioSourceNode for playback.
   * @type {HTMLAudioElement|null}
   */
  element = null;

  /**
   * Playback configuration options specified at the time that Sound#play is called.
   * @type {SoundPlaybackOptions}
   */
  #playback = {
    delay: 0,
    duration: undefined,
    fade: 0,
    loop: false,
    loopStart: 0,
    loopEnd: undefined,
    offset: 0,
    onended: null,
    volume: 1.0
  };

  /**
   * Force usage of an AudioBufferSourceNode regardless of audio duration?
   * @type {boolean}
   */
  #forceBuffer = false;

  /**
   * The life-cycle state of the sound.
   * @see {Sound.STATES}
   * @type {number}
   * @protected
   */
  _state = Sound.STATES.NONE;

  /**
   * Has the audio file been loaded either fully or for streaming.
   * @type {boolean}
   */
  get loaded() {
    if ( this._state < Sound.STATES.LOADED ) return false;
    return !!(this.buffer || this.element);
  }

  /**
   * Did the audio file fail to load.
   * @type {boolean}
   */
  get failed() {
    return this._state === Sound.STATES.FAILED;
  }

  /**
   * Is this sound currently playing?
   * @type {boolean}
   */
  get playing() {
    return (this._state === Sound.STATES.STARTING) || (this._state === Sound.STATES.PLAYING);
  }

  /**
   * Does this Sound use an AudioBufferSourceNode?
   * Otherwise, the Sound uses a streamed MediaElementAudioSourceNode.
   * @type {boolean}
   */
  get isBuffer() {
    return !!this.buffer && (this.sourceNode instanceof AudioBufferSourceNode);
  }

  /**
   * A convenience reference to the GainNode gain audio parameter.
   * @type {AudioParam}
   */
  get gain() {
    return this.gainNode?.gain;
  }

  /**
   * The AudioNode destination which is the output target for the Sound.
   * @type {AudioNode}
   */
  destination;

  /**
   * Record the pipeline of nodes currently used by this Sound.
   * @type {AudioNode[]}
   */
  #pipeline = [];

  /**
   * A pipeline of AudioNode instances to be applied to Sound playback.
   * @type {AudioNode[]}
   */
  effects = [];

  /**
   * The currently playing volume of the sound.
   * Undefined until playback has started for the first time.
   * @type {number}
   */
  get volume() {
    return this.gain?.value;
  }

  set volume(value) {
    if ( !this.gainNode || !Number.isFinite(value) ) return;
    const ct = this.#context.currentTime;
    this.gain.cancelScheduledValues(ct);
    this.gain.value = value;
    this.gain.setValueAtTime(value, ct); // Immediately schedule the new value
  }

  /**
   * The time in seconds at which playback was started.
   * @type {number}
   */
  startTime;

  /**
   * The time in seconds at which playback was paused.
   * @type {number}
   */
  pausedTime;

  /**
   * The total duration of the audio source in seconds.
   * @type {number}
   */
  get duration() {
    if ( this._state < Sound.STATES.LOADED ) return undefined;
    if ( this.buffer ) {
      const {loop, loopStart, loopEnd} = this.#playback;
      if ( loop && Number.isFinite(loopStart) && Number.isFinite(loopEnd) ) return loopEnd - loopStart;
      return this.buffer.duration;
    }
    return this.element?.duration;
  }

  /**
   * The current playback time of the sound.
   * @type {number}
   */
  get currentTime() {
    if ( !this.playing ) return undefined;
    if ( this.pausedTime ) return this.pausedTime;
    let time = this.#context.currentTime - this.startTime;
    if ( Number.isFinite(this.duration) ) time %= this.duration;
    return time;
  }

  /**
   * Is the sound looping?
   * @type {boolean}
   */
  get loop() {
    return this.#playback.loop;
  }

  set loop(value) {
    const loop = this.#playback.loop = Boolean(value);
    if ( this.#bufferNode ) this.#bufferNode.loop = loop;
    else if ( this.element ) this.element.loop = loop;
  }

  /**
   * A set of scheduled events orchestrated using the Sound#schedule function.
   * @type {Set<AudioTimeout>}
   */
  #scheduledEvents = new Set();

  /**
   * An operation in progress on the sound which must be queued.
   * @type {Promise}
   */
  #operation;

  /**
   * A delay timeout before the sound starts or stops.
   * @type {AudioTimeout}
   */
  #delay;

  /**
   * An internal reference to some object which is managing this Sound instance.
   * @type {AmbientSound|null}
   * @internal
   */
  _manager = null;

  /* -------------------------------------------- */
  /*  Life-Cycle Methods                          */
  /* -------------------------------------------- */

  /**
   * Load the audio source and prepare it for playback, either using an AudioBuffer or a streamed HTMLAudioElement.
   * @param {object} [options={}]   Additional options which affect resource loading
   * @param {boolean} [options.autoplay=false]  Automatically begin playback of the sound once loaded
   * @param {SoundPlaybackOptions} [options.autoplayOptions]  Playback options passed to Sound#play, if autoplay
   * @returns {Promise<Sound>}      A Promise which resolves to the Sound once it is loaded
   */
  async load({autoplay=false, autoplayOptions={}}={}) {
    const {STATES} = Sound;

    // Await audio unlock
    if ( game.audio.locked ) {
      game.audio.debug(`Delaying load of sound "${this.src}" until after first user gesture`);
      await game.audio.unlock;
    }

    // Wait for another ongoing operation
    if ( this.#operation ) {
      await this.#operation;
      return this.load({autoplay, autoplayOptions});
    }

    // Queue loading
    if ( !this.loaded ) {
      this._state = STATES.LOADING;
      this.#context ||= game.audio.music;
      try {
        this.#operation = this._load();
        await this.#operation;
        this._state = STATES.LOADED;
        this.dispatchEvent(new Event("load"));
      } catch(err) {
        console.error(err);
        this._state = STATES.FAILED;
      }
      finally {
        this.#operation = undefined;
      }
    }

    // Autoplay after load
    if ( autoplay && !this.failed && !this.playing ) {
      // noinspection ES6MissingAwait
      this.play(autoplayOptions);
    }
    return this;
  }

  /* -------------------------------------------- */

  /**
   * An inner method which handles loading so that it can be de-duplicated under a single shared Promise resolution.
   * This method is factored out to allow for subclasses to override loading behavior.
   * @returns {Promise<void>}                       A Promise which resolves once the sound is loaded
   * @throws {Error}                                An error if loading failed for any reason
   * @protected
   */
  async _load() {

    // Attempt to load a cached AudioBuffer
    this.buffer = game.audio.buffers.getBuffer(this.src) || null;
    this.element = null;

    // Otherwise, load the audio as an HTML5 audio element to learn its playback duration
    if ( !this.buffer ) {
      const element = await this.#createAudioElement();
      const isShort = (element?.duration || Infinity) <= Sound.MAX_BUFFER_DURATION;

      // For short sounds create and cache the audio buffer and use an AudioBufferSourceNode
      if ( isShort || this.#forceBuffer ) {
        this.buffer = await this.#createAudioBuffer();
        game.audio.buffers.setBuffer(this.src, this.buffer);
        Sound.#unloadAudioElement(element);
      }
      else this.element = element;
    }
  }

  /* -------------------------------------------- */

  /**
   * Begin playback for the Sound.
   * This method is asynchronous because playback may not start until after an initially provided delay.
   * The Promise resolves *before* the fade-in of any configured volume transition.
   * @param {SoundPlaybackOptions} [options]  Options which configure the beginning of sound playback
   * @returns {Promise<Sound>}                A Promise which resolves once playback has started (excluding fade)
   */
  async play(options={}) {

    // Signal our intention to start immediately
    const {STATES} = Sound;
    if ( ![STATES.LOADED, STATES.PAUSED, STATES.STOPPED].includes(this._state) ) return this;
    this._state = STATES.STARTING;

    // Wait for another ongoing operation
    if ( this.#operation ) {
      await this.#operation;
      return this.play(options);
    }

    // Configure options
    if ( typeof options === "number" ) {
      options = {offset: options};
      if ( arguments[1] instanceof Function ) options.onended = arguments[1];
      foundry.utils.logCompatibilityWarning("Sound#play now takes an object of playback options instead of "
        + "positional arguments.", {since: 12, until: 14});
    }

    // Queue playback
    try {
      this.#operation = this.#queuePlay(options);
      await this.#operation;
      this._state = STATES.PLAYING;
    } finally {
      this.#operation = undefined;
    }
    return this;
  }

  /* -------------------------------------------- */

  /**
   * 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 {@link foundry.canvas.layers.SoundsLayer#emitAtPosition}.
   * A helper which does not depend on a pre-existing Sound instance is available at
   * {@link foundry.canvas.layers.SoundsLayer#playAtPosition}.
   *
   * @param {Point|ElevatedPoint} origin                  The canvas coordinates from which the sound originates
   * @param {number} radius                               The radius of effect in distance units
   * @param {PositionalSoundPlaybackOptions} options      Additional options which configure playback
   * @returns {Promise<Sound|null>}                       A Promise which resolves to the played Sound, or null
   *
   * @example Play the sound of a trap springing
   * ```js
   * const sound = new Sound("modules/my-module/sounds/spring-trap.ogg", {context: game.audio.environment});
   * await sound.load();
   * const origin = {x: 5200, y: 3700};  // The origin point for the sound
   * const radius = 30;                  // Audible in a 30-foot radius
   * await sound.playAtPosition(origin, radius);
   * ```
   *
   * @example A Token casts a spell
   * ```js
   * const sound = new Sound("modules/my-module/sounds/spells-sprite.ogg", {context: game.audio.environment});
   * const origin = token.center;         // The origin point for the sound
   * const radius = 60;                   // Audible in a 60-foot radius
   * await sound.playAtPosition(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(origin, radius, {volume=1, easing=true, walls=true, gmAlways=true,
    baseEffect, muffledEffect, sourceData, playbackOptions}={}) {
    if ( !canvas.ready ) throw new Error("Sound#playAtPosition can only be used if the game canvas is active.");

    // Construct a point source which determines the effect of the sound
    if ( origin.elevation === undefined ) origin = {x: origin.x, y: origin.y, elevation: 0};
    const source = new CONFIG.Canvas.soundSourceClass({object: null});
    source.initialize({
      x: origin.x,
      y: origin.y,
      elevation: origin.elevation ?? 0,
      radius: canvas.dimensions.distancePixels * radius,
      walls,
      ...sourceData
    });

    // Configure playback
    /** @type {Partial<AmbientSoundPlaybackConfig>} */
    const config = {sound: this, source, listener: undefined, volume: 0, walls};

    // Configure playback volume using the closest listener position
    const listeners = (gmAlways && game.user.isGM) ? [origin] : canvas.sounds.getListenerPositions();
    for ( const l of listeners ) {
      const v = volume * source.getVolumeMultiplier(l, {easing});
      if ( v > config.volume ) Object.assign(config, {listener: l, volume: v});
    }
    canvas.sounds._configurePlayback(config);
    if ( !config.volume ) return null;

    // Apply special effects pipeline
    const sfx = CONFIG.soundEffects;
    let effect;
    if ( config.muffled && (muffledEffect?.type in sfx) ) {
      const muffledCfg = sfx[muffledEffect.type];
      effect = new muffledCfg.effectClass(this.context, muffledEffect);
    }
    if ( !effect && (baseEffect?.type in sfx) ) {
      const baseCfg = sfx[baseEffect.type];
      effect = new baseCfg.effectClass(this.context, baseEffect);
    }
    if ( effect ) this.effects.push(effect);

    // Initiate sound playback
    await this.play({loop: false, ...playbackOptions, volume: config.volume});
    return this;
  }

  /* -------------------------------------------- */

  /**
   * An inner method that is wrapped in an enqueued promise. See {@link Sound#play}.
   * @param {object} [options]
   */
  async #queuePlay(options={}) {

    // Configure playback
    this.#configurePlayback(options);
    const {delay, fade, offset, volume} = this.#playback;

    // Create the audio pipeline including gainNode and sourceNode used for playback
    this._createNodes();
    this._connectPipeline();

    // Delay playback start
    if ( delay ) {
      await this.wait(delay * 1000);
      if ( this._state !== Sound.STATES.STARTING ) return; // We may no longer be starting if the delay was cancelled
    }

    // Begin playback
    this._play();

    // Record state change
    this.startTime = this.#context.currentTime - offset;
    this.pausedTime = undefined;

    // Set initial volume
    this.volume = fade ? 0 : volume;
    if ( fade ) this.fade(volume, {duration: fade});
    this.#onStart();
  }

  /* -------------------------------------------- */

  /**
   * Begin playback for the configured pipeline and playback options.
   * This method is factored out so that subclass implementations of Sound can implement alternative behavior.
   * @protected
   */
  _play() {
    const {loop, loopStart, loopEnd, offset, duration} = this.#playback;
    if ( this.buffer ) {
      this.#bufferNode.loop = loop;
      if ( loop && Number.isFinite(loopStart) && Number.isFinite(loopEnd) ) {
        this.#bufferNode.loopStart = loopStart;
        this.#bufferNode.loopEnd = loopEnd;
      }
      this.#bufferNode.onended = this.#onEnd.bind(this);
      this.#bufferNode.start(0, offset, duration);
    }
    else if ( this.element ) {
      this.element.loop = loop;
      this.element.currentTime = offset;
      this.element.onended = this.#onEnd.bind(this);
      this.element.play();
    }
    game.audio.debug(`Beginning playback of Sound "${this.src}"`);
  }

  /* -------------------------------------------- */

  /**
   * Pause playback of the Sound.
   * For AudioBufferSourceNode this stops playback after recording the current time.
   * Calling Sound#play will resume playback from the pausedTime unless some other offset is passed.
   * For a MediaElementAudioSourceNode this simply calls the HTMLAudioElement#pause method directly.
   */
  pause() {
    const {STATES} = Sound;
    if ( this._state !== STATES.PLAYING ) {
      throw new Error("You may only call Sound#pause for a Sound which is PLAYING");
    }
    this._pause();
    this.pausedTime = this.currentTime;
    this._state = STATES.PAUSED;
    this.#onPause();
  }

  /* -------------------------------------------- */

  /**
   * Pause playback of the Sound.
   * This method is factored out so that subclass implementations of Sound can implement alternative behavior.
   * @protected
   */
  _pause() {
    if ( this.isBuffer ) {
      this.#bufferNode.onended = undefined;
      this.#bufferNode.stop(0);
    }
    else this.element.pause();
    game.audio.debug(`Pausing playback of Sound "${this.src}"`);
  }

  /* -------------------------------------------- */

  /**
   * Stop playback for the Sound.
   * This method is asynchronous because playback may not stop until after an initially provided delay.
   * The Promise resolves *after* the fade-out of any configured volume transition.
   * @param {SoundPlaybackOptions} [options]  Options which configure the stopping of sound playback
   * @returns {Promise<Sound>}                A Promise which resolves once playback is fully stopped (including fade)
   */
  async stop(options={}) {

    // Signal our intention to stop immediately
    if ( !this.playing ) return this;
    this._state = Sound.STATES.STOPPING;
    this.#delay?.cancel();

    // Wait for another operation to conclude
    if ( this.#operation ) {
      await this.#operation;
      return this.stop(options);
    }

    // Queue stop
    try {
      this.#operation = this.#queueStop(options);
      await this.#operation;
      this._state = Sound.STATES.STOPPED;
    } finally {
      this.#operation = undefined;
    }
    return this;
  }

  /* -------------------------------------------- */

  /**
   * An inner method that is wrapped in an enqueued promise. See {@link Sound#stop}.
   * @param {object} options
   */
  async #queueStop(options) {

    // Immediately disconnect the onended callback
    if ( this.#bufferNode ) this.#bufferNode.onended = undefined;
    if ( this.#mediaNode ) this.element.onended = undefined;

    // Configure playback settings
    options.volume ??= 0;
    this.#configurePlayback(options);
    const {delay, fade, volume} = this.#playback;

    // Fade out
    if ( fade ) await this.fade(volume, {duration: fade});
    else this.volume = volume;

    // Stop playback
    if ( delay ) {
      await this.wait(delay * 1000);
      if ( this._state !== Sound.STATES.STOPPING ) return; // We may no longer be stopping if the delay was cancelled
    }
    this._stop();

    // Disconnect the audio pipeline
    this._disconnectPipeline();

    // Record state changes
    this.#bufferNode = this.#mediaNode = undefined;
    this.startTime = this.pausedTime = undefined;
    this.#onStop();
  }

  /* -------------------------------------------- */

  /**
   * Stop playback of the Sound.
   * This method is factored out so that subclass implementations of Sound can implement alternative behavior.
   * @protected
   */
  _stop() {
    this.gain.cancelScheduledValues(this.context.currentTime);
    if ( this.buffer && this.sourceNode && (this._state === Sound.STATES.PLAYING) ) this.#bufferNode.stop(0);
    else if ( this.element ) {
      Sound.#unloadAudioElement(this.element);
      this.element = null;
    }
    game.audio.debug(`Stopping playback of Sound "${this.src}"`);
  }

  /* -------------------------------------------- */

  /**
   * Fade the volume for this sound between its current level and a desired target volume.
   * @param {number} volume                     The desired target volume level between 0 and 1
   * @param {object} [options={}]               Additional options that configure the fade operation
   * @param {number} [options.duration=1000]      The duration of the fade effect in milliseconds
   * @param {number} [options.from]               A volume level to start from, the current volume by default
   * @param {string} [options.type=linear]        The type of fade easing, "linear" or "exponential"
   * @returns {Promise<void>}                   A Promise that resolves after the requested fade duration
   */
  async fade(volume, {duration=1000, from, type="linear"}={}) {
    if ( !this.gain ) return;
    const ramp = this.gain[`${type}RampToValueAtTime`];
    if ( !ramp ) throw new Error(`Invalid fade type ${type} requested`);

    // Cancel any other ongoing transitions
    const startTime = this.#context.currentTime;
    this.gain.cancelScheduledValues(startTime);

    // Immediately schedule the starting volume
    from ??= this.gain.value;
    this.gain.setValueAtTime(from, startTime);

    // Ramp to target volume
    ramp.call(this.gain, volume, startTime + (duration / 1000));

    // Wait for the transition
    if ( volume !== from ) await this.wait(duration);
  }

  /* -------------------------------------------- */

  /**
   * Wait a certain scheduled duration within this sound's own AudioContext.
   * @param {number} duration                   The duration to wait in milliseconds
   * @returns {Promise<void>}                   A promise which resolves after the waited duration
   */
  async wait(duration) {
    this.#delay = new AudioTimeout(duration, {context: this.#context});
    await this.#delay.complete;
    this.#delay = undefined;
  }

  /* -------------------------------------------- */

  /**
   * Schedule a function to occur at the next occurrence of a specific playbackTime for this Sound.
   * @param {SoundScheduleCallback} fn  A function that will be called with this Sound as its single argument
   * @param {number} playbackTime       The desired playback time at which the function should be called
   * @returns {Promise<any>}            A Promise which resolves to the returned value of the provided function once
   *                                    it has been evaluated.
   * @example Schedule audio playback changes
   * ```js
   * sound.schedule(() => console.log("Do something exactly 30 seconds into the track"), 30);
   * sound.schedule(() => console.log("Do something next time the track loops back to the beginning"), 0);
   * sound.schedule(() => console.log("Do something 5 seconds before the end of the track"), sound.duration - 5);
   * ```
   */
  schedule(fn, playbackTime) {

    // Determine the amount of time until the next occurrence of playbackTime
    const {currentTime, duration} = this;
    playbackTime = Math.clamp(playbackTime, 0, duration);
    if ( this.#playback.loop && Number.isFinite(duration) ) {
      while ( playbackTime < currentTime ) playbackTime += duration;
    }
    const deltaMS = Math.max(0, (playbackTime - currentTime) * 1000);

    // Schedule the callback function with an AudioTimeout
    const timeout = new AudioTimeout(deltaMS, {context: this.#context});
    const resultPromise = this.#schedule(fn, timeout);

    // Return an annotated Promise
    resultPromise.timeout = timeout;
    return resultPromise;
  }

  /* -------------------------------------------- */

  /**
   * Internal async wrapper scheduling a callback.
   * @param {SoundScheduleCallback} fn  A function that will be called with this Sound as its single argument
   * @param {AudioTimeout} timeout      The audio time out to schedule
   * @returns {Promise<any>}
   */
  async #schedule(fn, timeout) {
    this.#scheduledEvents.add(timeout);
    try {
      await timeout.complete;
      if ( !timeout.cancelled ) return fn(this);
    }
    finally {
      this.#scheduledEvents.delete(timeout);
    }
  }

  /* -------------------------------------------- */

  /**
   * Cancel one scheduled event created with {@link Sound#schedule}.
   * You may pass either the {@link AudioTimeout} returned internally or the Promise returned by {@link Sound#schedule}.
   * @param {AudioTimeout|{timeout: AudioTimeout}} handle The handle to cancel.
   */
  unschedule(handle) {
    let timeout = null;
    if ( handle instanceof AudioTimeout ) timeout = handle;
    else if ( handle?.timeout instanceof AudioTimeout ) timeout = handle.timeout;
    else throw new Error("Invalid AudioTimeout handle provided");

    timeout.cancel();
    this.#scheduledEvents.delete(timeout);
  }

  /* -------------------------------------------- */

  /**
   * Cancel all events that are still scheduled for this sound.
   */
  unscheduleAll() {
    for ( const t of this.#scheduledEvents ) t.cancel();
    this.#scheduledEvents.clear();
  }

  /* -------------------------------------------- */

  /**
   * Update the array of effects applied to a Sound instance.
   * Optionally a new array of effects can be assigned. If no effects are passed, the current effects are re-applied.
   * @param {AudioNode[]} [effects]     An array of AudioNode effects to apply
   */
  applyEffects(effects) {
    if ( Array.isArray(effects) ) this.effects = effects;
    this._disconnectPipeline();
    this._connectPipeline();
    game.audio.debug(`Applied effects to Sound "${this.src}": ${this.effects.map(e => e.constructor.name)}`);
  }

  /* -------------------------------------------- */
  /*  Playback Events                             */
  /* -------------------------------------------- */

  /**
   * Additional workflows when playback of the Sound begins.
   */
  #onStart() {
    game.audio.playing.set(this.id, this); // Track playing sounds
    this.dispatchEvent(new Event("play"));
  }

  /* -------------------------------------------- */

  /**
   * Additional workflows when playback of the Sound is paused.
   */
  #onPause() {
    this.#cancelScheduledEvents();
    this.dispatchEvent(new Event("pause"));
  }

  /* -------------------------------------------- */

  /**
   * Additional workflows when playback of the Sound concludes.
   * This is called by the AudioNode#onended callback.
   */
  async #onEnd() {
    await this.stop();
    this.#playback.onended?.(this);
    this.dispatchEvent(new Event("end"));
  }

  /* -------------------------------------------- */

  /**
   * Additional workflows when playback of the Sound is stopped, either manually or by concluding its playback.
   */
  #onStop() {
    game.audio.playing.delete(this.id);
    this.#cancelScheduledEvents();
    this.dispatchEvent(new Event("stop"));
  }

  /* -------------------------------------------- */
  /*  Helper Methods                              */
  /* -------------------------------------------- */

  /**
   * Create an HTML5 Audio element which has loaded the metadata for the provided source.
   * @returns {Promise<HTMLAudioElement>}     A created HTML Audio element
   * @throws {Error}                          An error if audio element creation failed
   */
  async #createAudioElement() {
    game.audio.debug(`Loading audio element "${this.src}"`);
    return new Promise((resolve, reject) => {
      const element = new Audio();
      element.autoplay = false;
      element.crossOrigin = "anonymous";
      element.preload = "metadata";
      element.onloadedmetadata = () => resolve(element);
      element.onerror = () => reject(`Failed to load audio element "${this.src}"`);
      element.src = this.src;
    });
  }

  /* -------------------------------------------- */

  /**
   * Ensure to safely unload a media stream
   * @param {HTMLAudioElement} element      The audio element to unload
   */
  static #unloadAudioElement(element) {
    element.onended = undefined;
    element.pause();
    element.src = "";
    element.remove();
  }

  /* -------------------------------------------- */

  /**
   * Load an audio file and decode it to create an AudioBuffer.
   * @returns {Promise<AudioBuffer>}        A created AudioBuffer
   * @throws {Error}                        An error if buffer creation failed
   */
  async #createAudioBuffer() {
    game.audio.debug(`Loading audio buffer "${this.src}"`);
    try {
      const response = await foundry.utils.fetchWithTimeout(this.src);
      const arrayBuffer = await response.arrayBuffer();
      return this.#context.decodeAudioData(arrayBuffer);
    } catch(err) {
      err.message = `Failed to load audio buffer "${this.src}"`;
      throw err;
    }
  }

  /* -------------------------------------------- */

  /**
   * Create any AudioNode instances required for playback of this Sound.
   * @protected
   */
  _createNodes() {
    this.gainNode ||= this.#context.createGain();
    this.destination ||= (this.#context.gainNode ?? this.#context.destination); // Prefer a context gain if present
    const {buffer, element: mediaElement} = this;
    if ( buffer ) this.#bufferNode = new AudioBufferSourceNode(this.#context, {buffer});
    else if ( mediaElement ) this.#mediaNode = new MediaElementAudioSourceNode(this.#context, {mediaElement});
  }

  /* -------------------------------------------- */

  /**
   * Create the audio pipeline used to play this Sound.
   * The GainNode is reused each time to link volume changes across multiple playbacks.
   * The AudioSourceNode is re-created every time that Sound#play is called.
   * @protected
   */
  _connectPipeline() {
    if ( !this.sourceNode ) return;
    this.#pipeline.length = 0;

    // Start with the sourceNode
    let node = this.sourceNode;
    this.#pipeline.push(node);

    // Connect effect nodes
    for ( const effect of this.effects ) {
      node.connect(effect);
      effect.onConnectFrom?.(node);  // Special behavior to inform the effect node it has been connected
      node = effect;
      this.#pipeline.push(effect);
    }

    // End with the gainNode
    node.connect(this.gainNode);
    this.#pipeline.push(this.gainNode);
    this.gainNode.connect(this.destination);
  }

  /* -------------------------------------------- */

  /**
   * Disconnect the audio pipeline once playback is stopped.
   * Walk backwards along the Sound##pipeline from the Sound#destination, disconnecting each node.
   * @protected
   */
  _disconnectPipeline() {
    this.unscheduleAll();
    for ( let i=this.#pipeline.length-1; i>=0; i-- ) {
      const node = this.#pipeline[i];
      node.disconnect();
    }
  }

  /* -------------------------------------------- */

  /**
   * Configure playback parameters for the Sound.
   * @param {SoundPlaybackOptions}    Provided playback options
   */
  #configurePlayback({delay, duration, fade, loop, loopStart, loopEnd, offset, onended, volume}={}) {

    // Some playback options only update if they are explicitly passed
    this.#playback.loop = loop ?? this.#playback.loop;
    this.#playback.loopStart = loopStart ?? this.#playback.loopStart;
    this.#playback.loopEnd = loopEnd ?? this.#playback.loopEnd;
    this.#playback.volume = volume ?? this.#playback.volume;
    this.#playback.onended = onended !== undefined ? onended : this.#playback.onended;

    // Determine playback offset and duration timing
    const loopTime = (this.#playback.loopEnd ?? Infinity) - this.#playback.loopStart;

    // Starting offset
    offset ??= this.#playback.loopStart;
    if ( Number.isFinite(this.pausedTime) ) offset += this.pausedTime;

    // Loop forever
    if ( this.#playback.loop ) duration ??= undefined;

    // Play once
    else if ( Number.isFinite(loopTime) ) {
      offset = Math.clamp(offset, this.#playback.loopStart, this.#playback.loopEnd);
      duration ??= loopTime;
      duration = Math.min(duration, loopTime);
    }

    // Some playback options reset unless they are explicitly passed
    this.#playback.delay = delay ?? 0;
    this.#playback.offset = offset;
    this.#playback.duration = duration;
    this.#playback.fade = fade ?? 0;
  }

  /* -------------------------------------------- */

  /**
   * Cancel any scheduled events which have not yet occurred.
   */
  #cancelScheduledEvents() {
    for ( const timeout of this.#scheduledEvents ) timeout.cancel();
    this.#scheduledEvents.clear();
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  static get LOAD_STATES() {
    foundry.utils.logCompatibilityWarning("AudioContainer.LOAD_STATES is deprecated in favor of Sound.STATES",
      {since: 12, until: 14});
    return this.STATES;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  get loadState() {
    foundry.utils.logCompatibilityWarning("AudioContainer#loadState is deprecated in favor of Sound#_state",
      {since: 12, until: 14});
    return this._state;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  get container() {
    foundry.utils.logCompatibilityWarning("Sound#container is deprecated without replacement because the Sound and "
      + "AudioContainer classes are now merged", {since: 12, until: 14});
    return this;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  get node() {
    foundry.utils.logCompatibilityWarning("Sound#node is renamed Sound#sourceNode", {since: 12, until: 14});
    return this.sourceNode;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  on(eventName, fn, {once=false}={}) {
    foundry.utils.logCompatibilityWarning("Sound#on is deprecated in favor of Sound#addEventListener",
      {since: 12, until: 14});
    return this.addEventListener(eventName, fn, {once});
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  off(eventName, fn) {
    foundry.utils.logCompatibilityWarning("Sound#off is deprecated in favor of Sound#removeEventListener",
      {since: 12, until: 14});
    return this.removeEventListener(eventName, fn);
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  emit(eventName) {
    foundry.utils.logCompatibilityWarning("Sound#emit is deprecated in favor of Sound#dispatchEvent",
      {since: 12, until: 14});
    const event = new Event(eventName, {cancelable: true});
    return this.dispatchEvent(event);
  }
}

/**
 * @import * as io from "socket.io-client";
 * @import {SoundCreationOptions} from "./_types.mjs";
 * @import {ContextName} from "./_types.mjs";
 * @import {BandName} from "./_types.mjs";
 * @import {AnalysisNodes} from "./_types.mjs";
 * @import {AnalysisDataValue} from "./_types.mjs";
 * @import {AnalysisData} from "./_types.mjs";
 */

/**
 * A helper class to provide common functionality for working with the Web Audio API.
 * https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API
 * A singleton instance of this class is available as game#audio.
 * @see {@link foundry.Game#audio}
 */
class AudioHelper {
  constructor() {
    if ( game.audio instanceof this.constructor ) {
      throw new Error("You may not re-initialize the singleton AudioHelper. Use game.audio instead.");
    }
    this.unlock = this.awaitFirstGesture();

    /**
     * Analyzers for each context, plus an internal ticker. Each context key
     * holds data about its AnalyserNode, a Float32Array for FFT data, and so on.
     * @type {AnalysisData}
     */
    this.analyzer = Object.seal({
      music: {active: false, node: null, dataArray: null, lastUsed: 0, db: {}, bands: {}},
      environment: {active: false, node: null, dataArray: null, lastUsed: 0, db: {}, bands: {}},
      interface: {active: false, node: null, dataArray: null, lastUsed: 0, db: {}, bands: {}},
      analysisLoopActive: false
    });
  }

  /**
   * An array containing all possible audio context names.
   * @type {ReadonlyArray<ContextName>}
   */
  static AUDIO_CONTEXTS = Object.freeze(["music", "environment", "interface"]);

  /**
   * The Native interval for the AudioHelper to analyse audio levels from streams
   * Any interval passed to startLevelReports() would need to be a multiple of this value.
   * @type {number}
   */
  static levelAnalyserNativeInterval = 50;

  /**
   * The cache size threshold after which audio buffers will be expired from the cache to make more room.
   * 1 gigabyte, by default.
   */
  static THRESHOLD_CACHE_SIZE_BYTES = Math.pow(1024, 3);

  /**
   * Audio Context singleton used for analysing audio levels of each stream
   * Only created if necessary to listen to audio streams.
   * @type {AudioContext}
   */
  static #analyzerContext;

  /**
   * The set of singleton Sound instances which are shared across multiple uses of the same sound path.
   * @type {Map<string,WeakRef<Sound>>}
   */
  sounds = new Map();

  /**
   * Get a map of the Sound objects which are currently playing.
   * @type {Map<number,Sound>}
   */
  playing = new Map();

  /**
   * A user gesture must be registered before audio can be played.
   * This Array contains the Sound instances which are requested for playback prior to a gesture.
   * Once a gesture is observed, we begin playing all elements of this Array.
   * @type {Function[]}
   * @see {@link foundry.audio.Sound}
   */
  pending = [];

  /**
   * A Promise which resolves once the game audio API is unlocked and ready to use.
   * @type {Promise<void>}
   */
  unlock;

  /**
   * A flag for whether video playback is currently locked by awaiting a user gesture
   * @type {boolean}
   */
  locked = true;

  /**
   * A singleton audio context used for playback of music.
   * @type {AudioContext}
   */
  music;

  /**
   * A singleton audio context used for playback of environmental audio.
   * @type {AudioContext}
   */
  environment;

  /**
   * A singleton audio context used for playback of interface sounds and effects.
   * @type {AudioContext}
   */
  interface;

  /**
   * For backwards compatibility, AudioHelper#context refers to the context used for music playback.
   * @type {AudioContext}
   */
  get context() {
    return this.music;
  }

  /**
   * Interval ID as returned by setInterval for analysing the volume of streams
   * When set to 0, means no timer is set.
   * @type {number}
   */
  #analyserInterval;

  /**
   * A singleton cache used for audio buffers.
   * @type {AudioBufferCache}
   */
  buffers = new AudioBufferCache(AudioHelper.THRESHOLD_CACHE_SIZE_BYTES);

  /**
   * Map of all streams that we listen to for determining the decibel levels.
   * Used for analyzing audio levels of each stream.
   * @type {Record<string, {stream: MediaStream, analyser: AnalyserNode, interval: number, callback: Function}>}
   */
  #analyserStreams = {};

  /**
   * Fast Fourier Transform Array.
   * Used for analysing the decibel level of streams. The array is allocated only once
   * then filled by the analyser repeatedly. We only generate it when we need to listen to
   * a stream's level, so we initialize it to null.
   * @type {Float32Array}
   */
  #fftArray = null;

  /* -------------------------------------------- */

  /**
   * A global mute which suppresses all 3 audio channels.
   * @type {boolean}
   */
  get globalMute() {
    return this.#globalMute;
  }

  set globalMute(muted) {
    if ( typeof muted !== "boolean" ) throw new Error("AudioHelper#globalMute must be a boolean");
    if ( muted ) {
      AudioHelper.#onChangeMusicVolume(0);
      AudioHelper.#onChangeEnvironmentVolume(0);
      AudioHelper.#onChangeInterfaceVolume(0);
    } else {
      AudioHelper.#onChangeMusicVolume(game.settings.get("core", "globalPlaylistVolume"));
      AudioHelper.#onChangeEnvironmentVolume(game.settings.get("core", "globalAmbientVolume"));
      AudioHelper.#onChangeInterfaceVolume(game.settings.get("core", "globalInterfaceVolume"));
    }
    this.#globalMute = muted;
  }

  #globalMute = false;

  /* -------------------------------------------- */

  /**
   * Create a Sound instance for a given audio source URL
   * @param {SoundCreationOptions} options        Sound creation options
   * @returns {Sound}
   */
  create({src, context, singleton=true, preload=false, autoplay=false, autoplayOptions={}}) {
    let sound;

    // Share singleton sounds across multiple use cases
    if ( singleton ) {
      const ref = this.sounds.get(src);
      sound = ref?.deref();
      if ( !sound ) {
        sound = new Sound(src, {context});
        this.sounds.set(src, new WeakRef(sound));
      }
    }

    // Create an independent sound instance
    else sound = new Sound(src, {context});

    // Preload or autoplay
    if ( preload && !sound.loaded ) sound.load({autoplay, autoplayOptions});
    else if ( autoplay ) sound.play(autoplayOptions);
    return sound;
  }

  /* -------------------------------------------- */

  /**
   * Test whether a source file has a supported audio extension type
   * @param {string} src      A requested audio source path
   * @returns {boolean}       Does the filename end with a valid audio extension?
   */
  static hasAudioExtension(src) {
    let rgx = new RegExp(`(\\.${Object.keys(CONST.AUDIO_FILE_EXTENSIONS).join("|\\.")})(\\?.*)?`, "i");
    return rgx.test(src);
  }

  /* -------------------------------------------- */

  /**
   * Given an input file path, determine a default name for the sound based on the filename
   * @param {string} src      An input file path
   * @returns {string}        A default sound name for the path
   */
  static getDefaultSoundName(src) {
    const parts = src.split("/").pop().split(".");
    parts.pop();
    const name = decodeURIComponent(parts.join("."));
    return name.replace(/[-_.]/g, " ").titleCase();
  }

  /* -------------------------------------------- */

  /**
   * Play a single Sound by providing its source.
   * @param {string} src            The file path to the audio source being played
   * @param {object} [options]      Additional options which configure playback
   * @param {AudioContext} [options.context]  A specific AudioContext within which to play
   * @returns {Promise<Sound>}      The created Sound which is now playing
   */
  async play(src, {context, ...options}={}) {
    const sound = new Sound(src, {context});
    await sound.load();
    sound.play(options);
    return sound;
  }

  /* -------------------------------------------- */

  /**
   * Register an event listener to await the first mousemove gesture and begin playback once observed.
   * @returns {Promise<void>}       The unlocked audio context
   */
  async awaitFirstGesture() {
    if ( !this.locked ) return;
    await new Promise(resolve => {
      for ( const eventName of ["contextmenu", "auxclick", "pointerdown", "pointerup", "keydown"] ) {
        document.addEventListener(eventName, event => this.#onFirstGesture(event, resolve), {once: true});
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Request that other connected clients begin preloading a certain sound path.
   * @param {string} src          The source file path requested for preload
   * @returns {Promise<Sound>}    A Promise which resolves once the preload is complete
   */
  preload(src) {
    if ( !src || !AudioHelper.hasAudioExtension(src) ) {
      throw new Error(`Invalid audio source path ${src} provided for preload request`);
    }
    game.socket.emit("preloadAudio", src);
    return this.constructor.preloadSound(src);
  }

  /* -------------------------------------------- */
  /*  Settings and Volume Controls                */
  /* -------------------------------------------- */

  /**
   * Register client-level settings for global volume controls.
   */
  static registerSettings() {

    // Playlist Volume
    game.settings.register("core", "globalPlaylistVolume", {
      name: "Global Playlist Volume",
      hint: "Define a global playlist volume modifier",
      scope: "client",
      config: false,
      type: new foundry.data.fields.AlphaField({required: true, initial: 0.5}),
      onChange: AudioHelper.#onChangeMusicVolume
    });

    // Ambient Volume
    game.settings.register("core", "globalAmbientVolume", {
      name: "Global Ambient Volume",
      hint: "Define a global ambient volume modifier",
      scope: "client",
      config: false,
      type: new foundry.data.fields.AlphaField({required: true, initial: 0.5}),
      onChange: AudioHelper.#onChangeEnvironmentVolume
    });

    // Interface Volume
    game.settings.register("core", "globalInterfaceVolume", {
      name: "Global Interface Volume",
      hint: "Define a global interface volume modifier",
      scope: "client",
      config: false,
      type: new foundry.data.fields.AlphaField({required: true, initial: 0.5}),
      onChange: AudioHelper.#onChangeInterfaceVolume
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to the global music volume slider.
   * @param {number} volume
   */
  static #onChangeMusicVolume(volume) {
    volume = Math.clamp(volume, 0, 1);
    const ctx = game.audio.music;
    if ( !ctx ) return;
    ctx.gainNode.gain.setValueAtTime(volume, ctx.currentTime);
    ui.playlists?.render();
    Hooks$1.callAll("globalPlaylistVolumeChanged", volume);
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to the global environment volume slider.
   * @param {number} volume
   */
  static #onChangeEnvironmentVolume(volume) {
    volume = Math.clamp(volume, 0, 1);
    const ctx = game.audio.environment;
    if ( !ctx ) return;
    ctx.gainNode.gain.setValueAtTime(volume, ctx.currentTime);
    if ( canvas.ready ) {
      for ( const mesh of canvas.primary.videoMeshes ) {
        mesh.sourceElement.volume = mesh.object instanceof Tile$1 ? mesh.object.volume : volume;
      }
    }
    ui.playlists?.render();
    Hooks$1.callAll("globalAmbientVolumeChanged", volume);
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to the global interface volume slider.
   * @param {number} volume
   */
  static #onChangeInterfaceVolume(volume) {
    volume = Math.clamp(volume, 0, 1);
    const ctx = game.audio.interface;
    if ( !ctx ) return;
    ctx.gainNode.gain.setValueAtTime(volume, ctx.currentTime);
    ui.playlists?.render();
    Hooks$1.callAll("globalInterfaceVolumeChanged", volume);
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /**
   * Open socket listeners which transact ChatMessage data
   * @param {io.Socket} socket
   */
  static _activateSocketListeners(socket) {
    socket.on("playAudio", audioData => this.play(audioData, false));
    socket.on("playAudioPosition", args => canvas.sounds.playAtPosition(...args));
    socket.on("preloadAudio", src => this.preloadSound(src));
  }

  /* -------------------------------------------- */

  /**
   * Play a one-off sound effect which is not part of a Playlist
   *
   * @param {object} data        An object configuring the audio data to play.
   * @param {string} data.src    The audio source file path, either a public URL or a local path relative to the
   *                             public directory.
   * @param {string} [data.channel="interface"]  An audio channel in CONST.AUDIO_CHANNELS where the sound should play.
   *                                             Default: `"interface"`.
   * @param {number} [data.volume=1]        The volume level at which to play the audio, between 0 and 1. Default: `1`.
   * @param {boolean} [data.autoplay=false] Begin playback of the audio effect immediately once it is loaded.
   *                                        Default: `false`.
   * @param {boolean} [data.loop=false]     Loop the audio effect and continue playing it until it is manually stopped.
   *                                        Default: `false`.
   * @param {boolean|{recipients: string[]}} [socketOptions=false]  Options which only apply when emitting playback over
   *                       websocket. As a boolean, emits (true) or does not emit (false) playback to all other clients.
   *                       As an object, can configure which recipients (an array of User IDs) should receive the event
   *                       (all clients by default). Default: `false`.
   * @returns {Sound|void} A Sound instance which controls audio playback, or nothing if `data.autoplay` is false.
   *
   * @example Play the sound of a locked door for all players
   * ```js
   * AudioHelper.play({src: "sounds/lock.wav", volume: 0.8, loop: false}, true);
   * ```
   */
  static play(data, socketOptions) {
    const audioData = foundry.utils.mergeObject({
      src: null,
      volume: 1.0,
      loop: false,
      channel: "interface"
    }, data, {insertKeys: true});

    // Push the sound to other clients
    const push = socketOptions && (socketOptions !== false);
    if ( push ) {
      socketOptions = foundry.utils.getType(socketOptions) === "Object" ? socketOptions : {};
      if ( ("recipients" in socketOptions) && !Array.isArray(socketOptions.recipients)) {
        throw new Error("Socket recipients must be an array of User IDs");
      }
      game.socket.emit("playAudio", audioData, socketOptions);
    }

    // Backwards compatibility, if autoplay was passed as false take no further action
    if ( audioData.autoplay === false ) return;

    // Play the sound locally
    return game.audio.play(audioData.src, {
      volume: audioData.volume ?? 1.0,
      loop: audioData.loop,
      context: game.audio[audioData.channel]
    });
  }

  /* -------------------------------------------- */

  /**
   * Begin loading the sound for a provided source URL adding its
   * @param {string} src            The audio source path to preload
   * @returns {Promise<Sound>}      The created and loaded Sound ready for playback
   */
  static async preloadSound(src) {
    const sound = game.audio.create({src: src, preload: false, singleton: true});
    await sound.load();
    return sound;
  }

  /* -------------------------------------------- */

  /**
   * Returns the volume value based on a range input volume control's position.
   * This is using an exponential approximation of the logarithmic nature of audio level perception
   * @param {number|string} value   Value between [0, 1] of the range input
   * @param {number} [order=1.5]    The exponent of the curve
   * @returns {number}
   */
  static inputToVolume(value, order=1.5) {
    if ( typeof value === "string" ) value = parseFloat(value);
    return Math.pow(value, order);
  }

  /* -------------------------------------------- */

  /**
   * Counterpart to inputToVolume()
   * Returns the input range value based on a volume
   * @param {number} volume         Value between [0, 1] of the volume level
   * @param {number} [order=1.5]    The exponent of the curve
   * @returns {number}
   */
  static volumeToInput(volume, order=1.5) {
    return Math.pow(volume, 1 / order);
  }

  /* -------------------------------------------- */

  /**
   * Converts a volume level to a human-readable percentage value.
   * @param {number} volume                      Value in the interval [0, 1] of the volume level.
   * @param {object} [options]
   * @param {boolean} [options.label=false]      Prefix the returned tooltip with a localized 'Volume: ' label. This
   *                                             should be used if the returned string is intended for assistive
   *                                             technologies, such as the aria-valuetext attribute.
   * @param {number} [options.decimalPlaces=0]   The number of decimal places to round the percentage to.
   */
  static volumeToPercentage(volume, { label=false, decimalPlaces=0 }={}) {
    const pct = (volume * 100).toFixed(decimalPlaces);
    if ( label ) return game.i18n.format("PLAYLIST.VOLUME.TOOLTIP", { volume: pct });
    return `${pct}%`;
  }

  /* -------------------------------------------- */
  /*  Audio Stream Analysis                       */
  /* -------------------------------------------- */

  /**
   * Returns a singleton AudioContext if one can be created.
   * An audio context may not be available due to limited resources or browser compatibility
   * in which case null will be returned
   *
   * @returns {AudioContext}  A singleton AudioContext or null if one is not available
   */
  getAnalyzerContext() {
    if ( !AudioHelper.#analyzerContext ) AudioHelper.#analyzerContext = new AudioContext();
    return AudioHelper.#analyzerContext;
  }

  /* -------------------------------------------- */

  /**
   * Registers a stream for periodic reports of audio levels.
   * Once added, the callback will be called with the maximum decibel level of
   * the audio tracks in that stream since the last time the event was fired.
   * The interval needs to be a multiple of AudioHelper.levelAnalyserNativeInterval which defaults at 50ms
   *
   * @param {string} id             An id to assign to this report. Can be used to stop reports
   * @param {MediaStream} stream    The MediaStream instance to report activity on.
   * @param {Function} callback     The callback function to call with the decibel level. `callback(dbLevel)`
   * @param {number} [interval]     The interval at which to produce reports.
   * @param {number} [smoothing]    The smoothingTimeConstant to set on the audio analyser.
   * @returns {boolean}             Returns whether listening to the stream was successful
   */
  startLevelReports(id, stream, callback, interval=50, smoothing=0.1) {
    if ( !stream || !id ) return false;
    let audioContext = this.getAnalyzerContext();
    if (audioContext === null) return false;

    // Clean up any existing report with the same ID
    this.stopLevelReports(id);

    // Make sure this stream has audio tracks, otherwise we can't connect the analyser to it
    if (stream.getAudioTracks().length === 0) return false;

    // Create the analyser
    let analyser = audioContext.createAnalyser();
    analyser.fftSize = 512;
    analyser.smoothingTimeConstant = smoothing;

    // Connect the analyser to the MediaStreamSource
    audioContext.createMediaStreamSource(stream).connect(analyser);
    this.#analyserStreams[id] = {stream, analyser, interval, callback, _lastEmit: 0};

    // Ensure the analyser timer is started as we have at least one valid stream to listen to
    this.#ensureAnalyserTimer();
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Stop sending audio level reports
   * This stops listening to a stream and stops sending reports.
   * If we aren't listening to any more streams, cancel the global analyser timer.
   * @param {string} id      The id of the reports that passed to startLevelReports.
   */
  stopLevelReports(id) {
    delete this.#analyserStreams[id];
    if ( foundry.utils.isEmpty(this.#analyserStreams) ) this.#cancelAnalyserTimer();
  }

  /* -------------------------------------------- */

  /**
   * Ensures the global analyser timer is started
   *
   * We create only one timer that runs every 50ms and only create it if needed, this is meant to optimize things
   * and avoid having multiple timers running if we want to analyse multiple streams at the same time.
   * I don't know if it actually helps much with performance but it's expected that limiting the number of timers
   * running at the same time is good practice and with JS itself, there's a potential for a timer congestion
   * phenomenon if too many are created.
   */
  #ensureAnalyserTimer() {
    if ( !this.#analyserInterval ) {
      this.#analyserInterval = setInterval(this.#emitVolumes.bind(this), AudioHelper.levelAnalyserNativeInterval);
    }
  }

  /* -------------------------------------------- */

  /**
   * Cancel the global analyser timer
   * If the timer is running and has become unnecessary, stops it.
   */
  #cancelAnalyserTimer() {
    if ( this.#analyserInterval ) {
      clearInterval(this.#analyserInterval);
      this.#analyserInterval = undefined;
    }
  }

  /* -------------------------------------------- */

  /**
   * Capture audio level for all speakers and emit a webrtcVolumes custom event with all the volume levels
   * detected since the last emit.
   * The event's detail is in the form of {userId: decibelLevel}
   */
  #emitVolumes() {
    for ( const stream of Object.values(this.#analyserStreams) ) {
      if ( ++stream._lastEmit < (stream.interval / AudioHelper.levelAnalyserNativeInterval) ) continue;

      // Create the Fast Fourier Transform Array only once. Assume all analysers use the same fftSize
      if ( this.#fftArray === null ) this.#fftArray = new Float32Array(stream.analyser.frequencyBinCount);

      // Fill the array
      stream.analyser.getFloatFrequencyData(this.#fftArray);
      const maxDecibel = Math.max(...this.#fftArray);
      stream.callback(maxDecibel, this.#fftArray);
      stream._lastEmit = 0;
    }
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle the first observed user gesture
   * @param {Event} event         The mouse-move event which enables playback
   * @param {Function} resolve    The Promise resolution function
   */
  #onFirstGesture(event, resolve) {
    if ( !this.locked ) return resolve();

    // Create audio contexts
    this.music = AudioHelper.#createContext("globalPlaylistVolume");
    this.environment = AudioHelper.#createContext("globalAmbientVolume");
    this.interface = AudioHelper.#createContext("globalInterfaceVolume");

    // Unlock and evaluate pending playbacks
    this.locked = false;
    if ( this.pending.length ) {
      console.log(`${CONST.vtt} | Activating pending audio playback with user gesture.`);
      this.pending.forEach(fn => fn());
      this.pending = [];
    }
    return resolve();
  }

  /* -------------------------------------------- */

  /**
   * Create an AudioContext with an attached GainNode for master volume control.
   * @returns {AudioContext}
   */
  static #createContext(volumeSetting) {
    const ctx = new AudioContext();
    ctx.gainNode = ctx.createGain();
    ctx.gainNode.connect(ctx.destination);
    const volume = game.settings.get("core", volumeSetting);
    ctx.gainNode.gain.setValueAtTime(volume, ctx.currentTime);
    return ctx;
  }

  /* -------------------------------------------- */

  /**
   * Log a debugging message if the audio debugging flag is enabled.
   * @param {string} message      The message to log
   */
  debug(message) {
    if ( CONFIG.debug.audio ) console.debug(`${CONST.vtt} | ${message}`);
  }

  /* -------------------------------------------- */
  /*  Public Analyzer Methods and Properties      */
  /* -------------------------------------------- */

  /**
   * A static inactivity threshold for audio analysis, in milliseconds.
   * If no band value is requested for a channel within this duration,
   * the analyzer is disabled to conserve resources (unless the analyzer is enabled with the `keepAlive=true` option)
   * @type {number}
   */
  static ANALYSIS_TIMEOUT_MS = 1000;

  /* -------------------------------------------- */

  /**
   * Enable the analyzer for a given context (music, environment, interface),
   * attaching an AnalyserNode to its gain node if not already active.
   * @param {ContextName} contextName
   * @param {object} [options={}]
   * @param {boolean} [options.keepAlive=false]  If true, this analyzer will not auto-disable after inactivity.
   */
  enableAnalyzer(contextName, {keepAlive=false}={}) {
    const data = this.analyzer[contextName];
    if ( !this[contextName] || data.active ) return;

    const ctx = this[contextName];
    if ( !ctx.gainNode ) return;

    data.node = ctx.createAnalyser();
    data.node.fftSize = 512;
    data.node.smoothingTimeConstant = 0.8;
    ctx.gainNode.connect(data.node);

    data.dataArray = new Float32Array(data.node.frequencyBinCount);
    data.lastUsed = performance.now();
    data.db = {bass: -Infinity, mid: -Infinity, treble: -Infinity, all: -Infinity};
    data.bands = {bass: 0, mid: 0, treble: 0, all: 0};
    data.active = true;
    data.keepAlive = keepAlive;
    this.#startAnalysisLoop();
  }

  /* -------------------------------------------- */

  /**
   * Disable the analyzer for a given context, disconnecting the AnalyserNode.
   * @param {ContextName} contextName
   */
  disableAnalyzer(contextName) {
    const data = this.analyzer[contextName];
    if ( !data.active ) return;
    data.active = false;
    if ( data.node ) {
      this[contextName].gainNode.disconnect(data.node);
      data.node.disconnect();
      data.node = null;
      data.dataArray = null;
      data.db = {bass: -Infinity, mid: -Infinity, treble: -Infinity, all: -Infinity};
      data.bands = {bass: 0, mid: 0, treble: 0, all: 0};
      data.keepAlive = false;
    }
    this.#checkStopAnalysisLoop();
  }

  /* -------------------------------------------- */

  /**
   * Returns a normalized band value in [0,1].
   * Optionally, we can subtract the actual gainNode (global) volume from the measurement.
   * - Important:
   *   - Local gain applied to {@link foundry.audio.Sound} source can't be ignored.
   *   - If this method needs to activate the analyzer, the latter requires a brief warm-up.
   *     One or two frames may be needed before it produces meaningful values (instead of returning 0).
   * @param {ContextName} contextName
   * @param {BandName} bandName
   * @param {object} [options={}]
   * @param {boolean} [options.ignoreVolume=false]  If true, remove the real-time channel volume from the measurement.
   * @returns {number} The normalized band value in [0,1].
   */
  getBandLevel(contextName, bandName, {ignoreVolume=false}={}) {
    /** @type {AnalysisDataValue} */
    const data = this.analyzer[contextName];

    // If the analyzer is not active, enable it automatically
    if ( !data.active ) this.enableAnalyzer(contextName);

    // Update the last time we requested data
    data.lastUsed = performance.now();

    // Retrieve the raw dB for the chosen band
    const rawDb = data.db[bandName] ?? -Infinity;

    // If we do not want to remove the channel volume, return the stored normalized value
    if ( !ignoreVolume ) return data.bands[bandName] ?? 0;

    // Otherwise, compute a "dry" decibel level by subtracting the real-time gain
    const ctx = this[contextName];
    if ( !ctx?.gainNode ) return data.bands[bandName] ?? 0;
    const vol = ctx.gainNode.gain.value;
    if ( vol <= 0 ) return 0;

    // Convert an amplitude ratio to decibels (dB). Decibels use a logarithmic scale.
    // In dBFS (decibels full scale), the reference amplitude is 1, so we have:
    // - dB = 20 * log10(amplitude / 1) = 20 * log10(amplitude)
    // - See https://en.wikipedia.org/wiki/DBFS for more details on dBFS
    const volDb = 20 * Math.log10(vol);
    const correctedDb = rawDb - volDb;
    return this.#dbToNormalized(correctedDb);
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a single "peak" analyzer value across the three main audio contexts (music, environment, interface).
   * This takes the maximum of the three normalized [0,1] values for a given frequency band.
   * @param {BandName} [band="all"] The frequency band for which to retrieve an analyzer value.
   * @param {object} [options={}]
   * @param {boolean} [options.ignoreVolume=false] If true, remove the real-time channel volume from the measurement.
   * @returns {number} A number in the [0,1] range representing the loudest band value among the three contexts.
   */
  getMaxBandLevel(band="all", {ignoreVolume=false}={}) {
    let peak = 0;
    for ( const ctxName of AudioHelper.AUDIO_CONTEXTS ) {
      const val = this.getBandLevel(ctxName, band, {ignoreVolume});
      if ( val > peak ) peak = val;
    }
    return peak;
  }

  /* -------------------------------------------- */
  /*  Private Analyzer Methods                    */
  /* -------------------------------------------- */

  /**
   * Start the requestAnimationFrame loop for audio analysis if it's not already active.
   */
  #startAnalysisLoop() {
    if ( this.analyzer.analysisLoopActive ) return;
    this.analyzer.analysisLoopActive = true;
    const loop = () => {
      if ( !this.analyzer.analysisLoopActive ) return;
      this.#onAnalyzeAudioFrame();
      requestAnimationFrame(loop);
    };
    requestAnimationFrame(loop);
  }

  /* -------------------------------------------- */

  /**
   * Check if any analyzer is active. If none, stop the analysis loop.
   */
  #checkStopAnalysisLoop() {
    const activeAny = AudioHelper.AUDIO_CONTEXTS.some(c => this.analyzer[c].active);
    if ( !activeAny ) this.analyzer.analysisLoopActive = false;
  }

  /* -------------------------------------------- */

  /**
   * Invoked each frame while the internal loop is active. Updates FFT data for every active analyzer.
   * If the analyzer has not been queried for longer than {@link AudioHelper.ANALYSIS_TIMEOUT_MS},
   * it is disabled.
   */
  #onAnalyzeAudioFrame() {
    const now = performance.now();

    for ( const ctxName of AudioHelper.AUDIO_CONTEXTS ) {
      /** @type {AnalysisDataValue} */
      const data = this.analyzer[ctxName];
      if ( !data.active || !this[ctxName]?.gainNode || !data.node ) continue;

      // If we've exceeded the timeout without a request, disable the analyzer
      if ( !data.keepAlive && (now - data.lastUsed) > AudioHelper.ANALYSIS_TIMEOUT_MS ) {
        this.disableAnalyzer(ctxName);
        continue;
      }

      // Pull the FFT data into dataArray
      data.node.getFloatFrequencyData(data.dataArray);

      // Compute average decibel levels for several bands
      const bassDb = this.#computeAverageDb(data.dataArray, data.node.context.sampleRate, 20, 200);
      const midDb = this.#computeAverageDb(data.dataArray, data.node.context.sampleRate, 200, 2000);
      const trebleDb = this.#computeAverageDb(data.dataArray, data.node.context.sampleRate, 2000, 8000);
      const allDb = this.#computeAverageDb(data.dataArray, data.node.context.sampleRate, 20, 20000);

      // Store the raw dB values
      data.db.bass = bassDb;
      data.db.mid = midDb;
      data.db.treble = trebleDb;
      data.db.all = allDb;

      // Convert each band to [0..1] and store
      data.bands.bass = this.#dbToNormalized(bassDb);
      data.bands.mid = this.#dbToNormalized(midDb);
      data.bands.treble = this.#dbToNormalized(trebleDb);
      data.bands.all = this.#dbToNormalized(allDb);
    }

    // Possibly stop the analysis loop if no analyzer remains active
    this.#checkStopAnalysisLoop();
  }

  /* -------------------------------------------- */

  /**
   * Compute the average decibel value of a frequency band, without normalizing to [0,1].
   * @param {Float32Array} dataArray The frequency data array in decibels.
   * @param {number} sampleRate      The audio sample rate of the AnalyserNode context.
   * @param {number} freqMin         The lower bound of the frequency range.
   * @param {number} freqMax         The upper bound of the frequency range.
   * @returns {number}               The average decibel value (could be -Infinity if no bins).
   */
  #computeAverageDb(dataArray, sampleRate, freqMin, freqMax) {
    const fftSize = 512;
    const binHz = sampleRate / fftSize;

    const iMin = Math.floor(freqMin / binHz);
    const iMax = Math.floor(freqMax / binHz);

    let sum = 0;
    let count = 0;
    const start = Math.max(0, iMin);
    const end = Math.min(dataArray.length - 1, iMax);

    for ( let i = start; i <= end; i++ ) {
      sum += dataArray[i];
      count++;
    }
    if ( !count ) return -Infinity;
    return sum / count;
  }

  /* -------------------------------------------- */

  /**
   * Maps a dB value (in [-100, -30]) to a normalized [0,1].
   */
  #dbToNormalized(dbValue) {
    const minDb = -100;
    const maxDb = -30;
    if ( dbValue < minDb ) return 0;
    if ( dbValue > maxDb ) return 1;
    return (dbValue - minDb) / (maxDb - minDb);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  getCache(src) {
    foundry.utils.logCompatibilityWarning("AudioHelper#getCache is deprecated in favor of AudioHelper#buffers#get");
    return this.buffers.getBuffer(src, {since: 12, until: 14});
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  updateCache(src, playing=false) {
    foundry.utils.logCompatibilityWarning("AudioHelper#updateCache is deprecated without replacement");
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  setCache(src, buffer) {
    foundry.utils.logCompatibilityWarning("AudioHelper#setCache is deprecated in favor of AudioHelper#buffers#set");
    this.buffers.setBuffer(src, buffer);
  }
}

/**
 * A sound effect which applies a biquad filter.
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/BiquadFilterNode}
 */
class BiquadFilterEffect extends BiquadFilterNode {
  /**
   * A ConvolverEffect is constructed by passing the following parameters.
   * @param {AudioContext} context      The audio context required by the BiquadFilterNode
   * @param {object} [options]          Additional options which modify the BiquadFilterEffect behavior
   * @param {BiquadFilterType} [options.type=lowpass]  The filter type to apply
   * @param {number} [options.intensity=5]   The initial intensity of the effect
   */
  constructor(context, {type="lowpass", intensity=5, ...options}={}) {
    if ( !BiquadFilterEffect.#ALLOWED_TYPES.includes(type) ) {
      throw new Error(`Invalid BiquadFilterEffect type "${type}" provided`);
    }
    super(context, options);
    this.#type = this.type = type;
    this.#intensity = intensity;
    this.update();
  }

  /**
   * The allowed filter types supported by this effect class.
   */
  static #ALLOWED_TYPES = ["lowpass", "highpass", "bandpass", "lowshelf", "highshelf", "peaking", "notch"];

  /**
   * The original configured type of the effect.
   * @type {BiquadFilterType}
   */
  #type;

  /* -------------------------------------------- */

  /**
   * Adjust the intensity of the effect on a scale of 0 to 10.
   * @type {number}
   */
  get intensity() {
    return this.#intensity;
  }

  set intensity(intensity) {
    this.update({intensity});
  }

  #intensity;

  /* -------------------------------------------- */

  /**
   * Update the state of the effect node given the active flag and numeric intensity.
   * @param {object} options            Options which are updated
   * @param {number} [options.intensity]  A new effect intensity
   * @param {BiquadFilterType} [options.type] A new filter type
   */
  update({intensity, type} = {}) {
    if ( Number.isFinite(intensity) ) this.#intensity = Math.clamp(intensity, 1, 10);
    if ( BiquadFilterEffect.#ALLOWED_TYPES.includes(type) ) this.#type = type;
    this.type = this.#type;
    switch ( this.#type ) {
      case "lowpass":
        this.frequency.value = 1100 - (100 * this.#intensity); // More intensity cuts at a lower frequency
        break;
      case "highpass":
        this.frequency.value = 100 * this.#intensity; // More intensity cuts at higher frequency
        break;
      default:
        throw new Error(`BiquadFilterEffect type "${this.#type}" not yet configured`);
    }
  }
}

/**
 * A sound effect which applies a convolver filter.
 * The convolver effect splits the input sound into two separate paths:
 * 1. A "dry" node which is the original sound
 * 2. A "wet" node which contains the result of the convolution
 * This effect mixes between the dry and wet channels based on the intensity of the reverb effect.
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ConvolverNode}
 */
class ConvolverEffect extends ConvolverNode {
  /**
   * A ConvolverEffect is constructed by passing the following parameters.
   * @param {AudioContext} context      The audio context required by the ConvolverNode
   * @param {object} [options]          Additional options which modify the ConvolverEffect behavior
   * @param {string} [options.impulseResponsePath]  The file path to the impulse response buffer to use
   * @param {number} [options.intensity]            The initial intensity of the effect
   */
  constructor(context, {impulseResponsePath="sounds/impulse-responses/ir-full.wav", intensity=5, ...options}={}) {
    super(context, options);
    this.#impulseResponsePath = impulseResponsePath;
    this.#intensity = intensity;
    this.#dryGain = context.createGain();
    this.#wetGain = context.createGain();
    this.update();
  }

  /**
   * The identifier of the impulse response buffer currently used.
   * The default impulse response function was generated using https://aldel.com/reverbgen/.
   * @type {string}
   */
  #impulseResponsePath;

  /**
   * A GainNode which mixes base, non-convolved, audio playback into the final result.
   * @type {GainNode}
   */
  #dryGain;

  /**
   * A GainNode which mixes convolved audio playback into the final result.
   * @type {GainNode}
   */
  #wetGain;

  /**
   * Flag whether the impulse response buffer has been loaded to prevent duplicate load requests.
   * @type {boolean}
   */
  #loaded = false;

  /* -------------------------------------------- */

  /**
   * Adjust the intensity of the effect on a scale of 0 to 10.
   * @type {number}
   */
  get intensity() {
    return this.#intensity;
  }

  set intensity(value) {
    this.update({intensity: value});
  }

  #intensity;

  /* -------------------------------------------- */

  /**
   * Update the state of the effect node given the active flag and numeric intensity.
   * @param {object} options            Options which are updated
   * @param {number} [options.intensity]  A new effect intensity
   */
  update({intensity} = {}) {
    if ( Number.isFinite(intensity) ) this.#intensity = Math.clamp(intensity, 1, 10);

    // Load an impulse response buffer
    if ( !this.#loaded ) {
      const irSound = new Sound(this.#impulseResponsePath, {context: this.context});
      this.#loaded = true;
      irSound.load().then(s => this.buffer = s.buffer);
    }

    // Set mix of wet and dry gain based on reverb intensity
    this.#wetGain.gain.value = 0.2 + Math.sqrt(this.#intensity / 10); // [0.2, 1.2]
    this.#dryGain.gain.value = Math.sqrt((11 - this.#intensity) / 10);
  }

  /* -------------------------------------------- */

  /** @override */
  disconnect(...args) {
    this.#wetGain.disconnect();
    this.#dryGain.disconnect();
    return super.disconnect(...args);
  }

  /* -------------------------------------------- */

  /** @override */
  connect(destinationNode, ...args) {
    super.connect(this.#wetGain, ...args);
    this.#dryGain.connect(destinationNode);
    this.#wetGain.connect(destinationNode);
    return destinationNode;
  }

  /* -------------------------------------------- */

  /**
   * Additional side effects performed when some other AudioNode connects to this one.
   * This behavior is not supported by the base WebAudioAPI but is needed here for more complex effects.
   * @param {AudioNode} sourceNode      An upstream source node that is connecting to this one
   */
  onConnectFrom(sourceNode) {
    sourceNode.connect(this.#dryGain);
  }
}

/** @module audio */

var _module$t = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AudioBufferCache: AudioBufferCache,
  AudioHelper: AudioHelper,
  AudioTimeout: AudioTimeout,
  BiquadFilterEffect: BiquadFilterEffect,
  ConvolverEffect: ConvolverEffect,
  Sound: Sound
});

/**
 * A framework for imbuing special scripted behaviors into a single specific Scene.
 * Managed scenes are registered in CONFIG.Canvas.managedScenes.
 *
 * The SceneManager instance is called at various points in the Scene rendering life-cycle.
 *
 * This also provides a framework for registering additional hook events which are required only for the life-cycle of
 * the managed Scene.
 *
 * @example Registering a custom SceneManager
 * ```js
 * // Define a custom SceneManager subclass
 * class MyCustomSceneManager extends SceneManager {
 *   async _onInit() {
 *     console.log(`Initializing managed Scene "${this.scene.name}"`);
 *   }
 *
 *   async _onDraw() {
 *     console.log(`Drawing managed Scene "${this.scene.name}"`);
 *   }
 *
 *   async _onReady() {
 *     console.log(`Readying managed Scene "${this.scene.name}"`);
 *   }
 *
 *   async _onTearDown() {
 *     console.log(`Deconstructing managed Scene "${this.scene.name}"`);
 *   }
 *
 *   _registerHooks() {
 *     this.registerHook("updateToken", this.#onUpdateToken.bind(this));
 *   }
 *
 *   #onUpdateToken(document, updateData, options, userId) {
 *     console.log("Updating a token within the managed Scene");
 *   }
 * }
 *
 * // Register MyCustomSceneManager to be used for a specific Scene
 * CONFIG.Canvas.sceneManagers = {
 *   [sceneId]: MyCustomSceneManager
 * }
 * ```
 */
class SceneManager {
  /**
   * The SceneManager is constructed by passing a reference to the active Scene document.
   * @param {Scene} scene
   */
  constructor(scene) {
    this.#scene = scene;
  }

  /**
   * The managed Scene.
   * @type {Scene}
   */
  get scene() {
    return this.#scene;
  }

  #scene;

  /* -------------------------------------------- */
  /*  Scene Life-Cycle Methods                    */
  /* -------------------------------------------- */

  /**
   * Additional behaviors to perform when the Canvas is first initialized for the Scene.
   * @returns {Promise<void>}
   * @internal
   */
  async _onInit() {}

  /**
   * Additional behaviors to perform after core groups and layers are drawn to the canvas.
   * @returns {Promise<void>}
   * @internal
   */
  async _onDraw() {}

  /**
   * Additional behaviors to perform after the Canvas is fully initialized for the Scene.
   * @returns {Promise<void>}
   * @internal
   */
  async _onReady() {}

  /**
   * Additional behaviors to perform when the Scene is deactivated.
   * @returns {Promise<void>}
   * @internal
   */
  async _onTearDown() {}

  /* -------------------------------------------- */
  /*  Scene Hooks                                 */
  /* -------------------------------------------- */

  /**
   * Registered hook functions used within this specific Scene that are automatically deactivated.
   * @type {Record<string, number>}
   */
  #hooks = {};

  /**
   * Register additional hook functions are only used while this Scene is active and is automatically deactivated.
   * Hooks should be registered in this function by calling this._registerHook(hookName, handler)
   * @internal
   */
  _registerHooks() {}

  /**
   * Register additional hook functions are only used while this Scene is active and is automatically deactivated.
   * @param {string} hookName
   * @param {Function} handler
   */
  registerHook(hookName, handler) {
    this.#hooks[hookName] = Hooks$1.on(hookName, handler);
  }

  /**
   * Deactivate Hook functions that were added specifically for this Scene.
   * @internal
   */
  _deactivateHooks() {
    for ( const [hookName, hookId] of Object.entries(this.#hooks) ) Hooks$1.off(hookName, hookId);
  }
}

/**
 * @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,
      scripts: scripts?.map(s => foundry.utils.getRoute(s)),
      debug,
      loadPrimitives
    });
  }

  /**
   * A path reference to the JavaScript file which provides companion worker-side functionality.
   * @type {string}
   */
  static WORKER_HARNESS_JS = "scripts/worker.js";

  /**
   * The name of this worker.
   * @type {string}
   */
  name;

  /**
   * 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);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  terminate() {
    super.terminate();
    const error = new Error(`Worker ${this.name} has been terminated`);
    for ( const task of this.#tasks.values() ) task.reject(error);
    this.#tasks.clear();
  }
}

/* -------------------------------------------- */

/**
 * A client-side class responsible for managing a set of web workers.
 * This interface is accessed as a singleton instance via game.workers.
 * @see {@link foundry.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);
  }
}

/**
 * Wrapper for a web worker meant to convert a pixel buffer to the specified image format
 * and quality and return a base64 image.
 */
class TextureCompressor extends AsyncWorker {
  /**
   * @param {string} [name="TextureCompressor"]      The worker name to be initialized
   * @param {object} [config]                        Worker initialization options
   * @param {boolean} [config.controlHash=false]     Should use control hash?
   * @param {boolean} [config.debug=false]           Should the worker run in debug mode?
   */
  constructor(name="TextureCompressor", config={}) {
    config.debug ??= false;
    config.scripts ??= ["/scripts/workers/image-compressor.js", "/scripts/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 compressBufferBase64 hash.
   * @type {string}
   */
  #compressBufferBase64Hash = "";

  /**
   * Previous expandBufferRedToBufferRGBA hash.
   * @type {string}
   */
  #expandBufferRedToBufferRGBAHash = "";

  /**
   * Previous texture hash.
   * @type {string}
   */
  #reduceBufferRGBAToBufferREDHash = "";

  /**
   * Previous copyBufferHash hash.
   * @type {string}
   */
  #copyBufferHash = "";

  /* -------------------------------------------- */

  /**
   * 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.hash]                         The precomputed hash.
   * @param {boolean} [options.debug]                       The debug option.
   * @returns {Promise<*>}
   */
  async compressBufferBase64(buffer, width, height, options={}) {
    const params = {buffer, width, height, debug: options.debug};
    if ( this.#controlHash ) {
      if ( options.hash === this.#compressBufferBase64Hash ) {
        if ( options.debug ) console.debug("Compression | Skipped. Texture buffer has not changed.");
        return {base64img: undefined, buffer, hash: options.hash};
      }
      params.hash = options.hash ?? this.#compressBufferBase64Hash;
      params.skipHash = !!options.hash;
    }
    const result = await this.executeFunction("processBufferToBase64", [params], [buffer.buffer]);
    if ( result.hash ) this.#compressBufferBase64Hash = 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 {ArrayBuffer} [options.out]              The output buffer to write the expanded pixels to. May be detached.
   * @param {string} [options.hash]                  The precomputed hash.
   * @param {boolean} [options.debug]                The debug option.
   * @returns {Promise<unknown>}
   */
  async expandBufferRedToBufferRGBA(buffer, width, height, options={}) {
    if ( options.out?.byteLength < (buffer.length * 4) ) throw new Error("Output buffer is too small");
    const params = {buffer, width, height, out: options.out, debug: options.debug};
    if ( this.#controlHash ) {
      if ( options.hash === this.#expandBufferRedToBufferRGBAHash ) {
        if ( options.debug ) console.debug("Compression | Skipped. Texture buffer has not changed.");
        return {rgbaBuffer: undefined, buffer, out: options.out, hash: options.hash};
      }
      params.hash = options.hash ?? this.#expandBufferRedToBufferRGBAHash;
      params.skipHash = !!options.hash;
    }
    const transfer = [buffer.buffer];
    if ( options.out ) transfer.push(options.out);
    const result = await this.executeFunction("processBufferRedToBufferRGBA", [params], transfer);
    if ( result.hash ) this.#expandBufferRedToBufferRGBAHash = 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 {ArrayBuffer} [options.out]               The output buffer to write the reduced pixels to. May be detached.
   * @param {string} [options.hash]                   The precomputed hash.
   * @param {boolean} [options.debug]                 The debug option.
   * @returns {Promise<unknown>}
   */
  async reduceBufferRGBAToBufferRED(buffer, width, height, options={}) {
    if ( options.out?.byteLength < (buffer.length / 4) ) throw new Error("Output buffer is too small");
    const params = {buffer, width, height, out: options.out, debug: options.debug};
    if ( this.#controlHash ) {
      if ( options.hash === this.#reduceBufferRGBAToBufferREDHash ) {
        if ( options.debug ) console.debug("Compression | Skipped. Texture buffer has not changed.");
        return {redBuffer: undefined, buffer, out: options.out, hash: options.hash};
      }
      params.hash = options.hash ?? this.#reduceBufferRGBAToBufferREDHash;
      params.skipHash = !!options.hash;
    }
    const transfer = [buffer.buffer];
    if ( options.out ) transfer.push(options.out);
    const result = await this.executeFunction("processBufferRGBAToBufferRED", [params], transfer);
    if ( result.hash ) this.#reduceBufferRGBAToBufferREDHash = result.hash;
    return result;
  }

  /* -------------------------------------------- */

  /**
   * Copy a buffer.
   * @param {Uint8ClampedArray} buffer                      Buffer used to create the image data.
   * @param {object} [options]
   * @param {ArrayBuffer} [options.out]                     The output buffer to copy the pixels to. May be detached.
   * @param {string} [options.hash]                         The precomputed hash.
   * @param {boolean} [options.debug]                       The debug option.
   * @returns {Promise<unknown>}
   */
  async copyBuffer(buffer, options={}) {
    if ( options.out?.byteLength < buffer.length ) throw new Error("Output buffer is too small");
    if ( !this.#controlHash ) {
      let copy;
      if ( !options.out ) copy = new Uint8ClampedArray(buffer);
      else {
        copy = new Uint8ClampedArray(options.out, 0, buffer.length);
        copy.set(buffer);
      }
      return {copy, buffer, out: options.out, hash: undefined};
    }
    if ( options.hash === this.#copyBufferHash ) {
      if ( options.debug ) console.debug("Compression | Skipped. Texture buffer has not changed.");
      return {copy: undefined, buffer, out: options.out, hash: options.hash};
    }
    if ( options.hash ) {
      let copy;
      if ( !options.out ) copy = new Uint8ClampedArray(buffer);
      else {
        copy = new Uint8ClampedArray(options.out, 0, buffer.length);
        copy.set(buffer);
      }
      return {copy, buffer, out: options.out, hash: options.hash};
    }
    const params = {buffer, out: options.out, hash: this.#copyBufferHash, skipHash: false, debug: options.debug};
    const transfer = [buffer.buffer];
    if ( options.out ) transfer.push(options.out);
    const result = await this.executeFunction("copyBuffer", [params], transfer);
    if ( result.hash ) this.#copyBufferHash = result.hash;
    return result;
  }
}

/**
 * A class or interface that provide support for WebGL async read pixel/texture data extraction.
 */
class TextureExtractor {
  /**
   * @param {PIXI.Renderer} renderer                           The renderer
   * @param {object} [config={}]                               Worker initialization options
   * @param {PIXI.FORMATS} [config.format=PIXI.FORMATS.RED]    The texture format
   * @param {boolean} [config.controlHash=false]               Should use control hash?
   * @param {string} [config.callerName="TextureExtractor"]    The caller name
   * @param {boolean} [config.debug=false]                     Enable debug log?
   */
  constructor(renderer, {format=PIXI.FORMATS.RED, controlHash=false, callerName="TextureExtractor", debug=false}={}) {
    this.#renderer = renderer;
    this.#callerName = callerName;
    this.#compressor = new TextureCompressor(`TextureCompressor of ${callerName}`, {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}
   */
  get renderer() {
    return this.#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}
   */
  get format() {
    return this.#format;
  }

  #format;

  /**
   * The texture type on which the Texture Extractor must work.
   * @type {PIXI.TYPES}
   */
  get type() {
    return this.#type;
  }

  #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 = true;

  /**
   * Debug flag.
   * @type {boolean}
   */
  debug = false;

  /**
   * The reference to the pixel buffer.
   * @type {Uint8ClampedArray}
   */
  #pixelBuffer = new Uint8ClampedArray();

  /**
   * The width of the pixel buffer.
   * @type {number}
   */
  #pixelWidth = 0;

  /**
   * The height of the pixel buffer.
   * @type {number}
   */
  #pixelHeight = 0;

  /**
   * The pixel hash.
   * @type {string}
   */
  #pixelHash;

  /**
   * 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;

  /**
   * The base texture of the last extraction.
   * @type {WeakRef<PIXI.BaseTexture>}
   */
  #lastBaseTexture = null;

  /**
   * The dirtyId of the base texture of the last extraction.
   * @type {number}
   */
  #lastBaseTextureDirtyId = -1;

  /**
   * The frame of the last extraction.
   * @type {PIXI.Rectangle}
   */
  #lastFrame = new PIXI.Rectangle();

  /* -------------------------------------------- */
  /*  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 TexturePixelsExtractionOptions
   * @property {PIXI.Texture|PIXI.RenderTexture} [texture]        The texture the pixels are extracted from.
   * @property {PIXI.Rectangle} [frame]                           The rectangle which the pixels are extracted from.
   * @property {0} [compression]                                  The NONE compression mode.
   * @property {ArrayBuffer} [out]                                The optional output buffer to write the pixels to.
   *                                                              May be detached.
   *                                                              The (new) output buffer is returned.
   */

  /**
   * @typedef TextureBase64ExtractionOptions
   * @property {PIXI.Texture|PIXI.RenderTexture} [texture]        The texture the pixels are extracted from.
   * @property {PIXI.Rectangle} [frame]                           The rectangle which the pixels are extracted from.
   * @property {1} compression                                    The BASE64 compression mode.
   * @property {string} [type="image/png"]                        The optional image mime type. Default: `"image/png"`.
   * @property {number} [quality=1]                               The optional image quality. Default: `1`.
   */

  /**
   * Extract a rectangular block of pixels from the texture (without un-pre-multiplying).
   * @overload
   * @param {TexturePixelsExtractionOptions} options    Options which configure pixels extraction behavior
   * @returns {Promise<{pixels: Uint8ClampedArray|undefined, width: number, height: number, out?: ArrayBuffer}>}
   *   The pixels or undefined if there's no change compared to the last time pixels were extracted and
   *   the control hash option is enabled. If an output buffer was passed, the (new) output buffer is included
   *   in the result, which may be different from the output buffer that was passed because it was detached.
   */
  /**
   * @overload
   * @param {TextureBase64ExtractionOptions} options    Options which configure base64 extraction behavior
   * @returns {Promise<string|undefined>}    The base64 string or undefined if there's no change compared
   *   to the last time base64 was extracted and the control hash option is enabled.
   */
  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).
   * @overload
   * @param {TexturePixelsExtractionOptions} options    Options which configure pixels extraction behavior
   * @returns {Promise<{pixels: Uint8ClampedArray|undefined, width: number, height: number, out?: ArrayBuffer}>}
   *   The pixels or undefined if there's no change compared to the last time pixels were extracted and
   *   the control hash option is enabled. If an output buffer was passed, the (new) output buffer is included
   *   in the result, which may be different from the output buffer that was passed because it was detached.
   */
  /**
   * @overload
   * @param {TextureBase64ExtractionOptions} options    Options which configure base64 extraction behavior
   * @returns {Promise<string|undefined>}         The base64 string or undefined if there's no change compared
   *   to the last time base64 was extracted and the control hash option is enabled.
   */
  async #extract({texture, frame, compression, type, quality, out}={}) {
    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.");
    }

    // Check if pixels need to be read again
    let readPixels = true;
    if ( this.#lastBaseTexture ) {
      const lastBaseTexture = this.#lastBaseTexture.deref();
      const {x, y, width, height} = frame ?? texture.frame;
      if ( (lastBaseTexture === baseTexture) && (this.#lastBaseTextureDirtyId === baseTexture.dirtyId) ) {
        if ( (this.#lastFrame.x === x)
          && (this.#lastFrame.y === y)
          && (this.#lastFrame.width === width)
          && (this.#lastFrame.height === height) ) {
          readPixels = false;
        }
      }
    }

    let data;
    if ( readPixels ) {
      this.#lastBaseTexture = new WeakRef(baseTexture);
      this.#lastBaseTextureDirtyId = baseTexture.dirtyId;
      this.#lastFrame.copyFrom(frame ?? texture.frame);
      this.#pixelHash = undefined;

      const generateTexture = !((texture instanceof PIXI.RenderTexture) && ((baseTexture.format === this.#format)
        || (this.#readFormat === PIXI.FORMATS.RGBA)) && (baseTexture.type === this.#type));
      if ( generateTexture ) {
        texture = this.#generatedRenderTexture = this.#renderer.generateTexture(new PIXI.Sprite(texture), {
          format: this.#format,
          type: this.#type,
          resolution: baseTexture.resolution,
          multisample: PIXI.MSAA_QUALITY.NONE
        });
      }

      data = await this.#readPixels(texture, frame ?? texture.frame, texture.resolution);
    }
    else data = {buffer: this.#pixelBuffer, width: this.#pixelWidth, height: this.#pixelHeight};

    // Return the compressed image or the raw buffer
    let returnValue;
    if ( compression ) {
      returnValue = 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,
        {out, hash: this.#pixelHash, debug: this.debug});
      // Returning control of the buffer to the extractor
      this.#pixelBuffer = result.buffer;
      this.#pixelHash = result.hash;
      returnValue = {pixels: result.redBuffer, width: data.width, height: data.height};
      if ( out ) returnValue.out = result.out;
    } else {
      const result = await this.#compressor.copyBuffer(data.buffer, {out, hash: this.#pixelHash, debug: this.debug});
      // Returning control of the buffer to the extractor
      this.#pixelBuffer = result.buffer;
      this.#pixelHash = result.hash;
      returnValue = {pixels: result.copy, width: data.width, height: data.height};
      if ( out ) returnValue.out = result.out;
    }
    if ( this.debug ) {
      if ( returnValue ) this.#consoleDebug("Texture extraction done.");
      else this.#consoleDebug("Texture extraction done. No change.");
    }
    return returnValue;
  }

  /* -------------------------------------------- */

  /**
   * Free all the bound objects.
   */
  reset() {
    if ( this.debug ) this.#consoleDebug("Data reset.");
    this.#clear({buffer: true, syncObject: true, rt: true});
  }

  /* -------------------------------------------- */

  /**
   * Destroy this TextureExtractor.
   */
  destroy() {
    if ( this.debug ) this.#consoleDebug("Destroyed.");
    this.#clear({buffer: true, syncObject: true, rt: true});
    this.#renderer.runners.contextChange.remove(this);
    this.#compressor.terminate();
  }

  /* -------------------------------------------- */

  /**
   * 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 = new Uint8ClampedArray();
    this.#pixelWidth = 0;
    this.#pixelHeight = 0;
    this.#createBuffer = true;
  }

  /* -------------------------------------------- */
  /*  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="image/png"] Format of the image.
   * @param {number} [options.quality=1]        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, quality, hash: this.#pixelHash, debug: this.debug});
    }
    catch(e) {
      this.#consoleError("Buffer compression has failed!");
      throw e;
    }
    // Returning control of the buffer to the extractor
    this.#pixelBuffer = result.buffer;
    this.#pixelHash = result.hash;
    // 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 {string} [options.type="image/png"] Format of the image.
   * @param {number} [options.quality=1]        Quality of the compression.
   * @returns {Promise<string>}
   */
  async #compressBufferLocal(buffer, width, height, {type="image/png", quality=1}={}) {
    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,
          {hash: this.#pixelHash, 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;
      this.#pixelHash = result.hash;
      rgbaBuffer = result.rgbaBuffer;
    } else {
      rgbaBuffer = buffer;
    }
    if ( !rgbaBuffer ) return;

    // Proceed at the compression locally and return the base64 image
    const element = foundry.helpers.media.ImageHelper.pixelsToCanvas(rgbaBuffer, width, height);
    return await foundry.helpers.media.ImageHelper.canvasToBase64(element, type, quality);
  }

  /* -------------------------------------------- */

  /**
   * Prepare data for the asynchronous readPixel.
   * @param {PIXI.RenderTexture} texture
   * @param {PIXI.Rectangle} frame
   * @param {number} resolution
   * @returns {object}
   */
  async #readPixels(texture, frame, resolution) {

    // Bind the texture
    this.#renderer.renderTexture.bind(texture);

    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 = this.#pixelWidth = Math.round(frame.width * resolution);
    const height = this.#pixelHeight = 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, pixels.buffer.byteLength, 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);

    // 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.
   * @returns {object<Uint8ClampedArray, number, number>}
   */
  #getGPUBufferData(buffer, width, height) {
    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, buffer.length);
    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.buffer.byteLength < length ) {
      this.#pixelBuffer = new Uint8ClampedArray(length);
      // If the pixel cache need to be (re)created, the same for the GPU buffer
      this.#createBuffer = true;
    } else if ( this.#pixelBuffer.length !== length ) {
      this.#pixelBuffer = new Uint8ClampedArray(this.#pixelBuffer.buffer, 0, length);
    }
    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 = new Uint8ClampedArray();
      this.#pixelWidth = 0;
      this.#pixelHeight = 0;
      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}`);
  }
}

/**
 * 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);
  }
}

/**
 * @import {Point, ElevatedPoint} from "../../../../common/_types.mjs"
 * @import {PointSourcePolygonType} from "../_types.mjs"
 * @import {PointSourcePolygonConfig} from "../_types.mjs"
 */

/**
 * An extension of Polygon which is used to represent the line of sight for a point source.
 * @template {PointSourcePolygonConfig} PolygonConfig
 */
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 {ElevatedPoint}
   */
  origin;

  /**
   * The configuration of this polygon.
   * @type {PolygonConfig}
   */
  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|ElevatedPoint} origin        The origin point to benchmark
   * @param {PolygonConfig} 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|ElevatedPoint} origin            The origin source point. The elevation defaults to
   *                                                the elevation of config.source if passed and otherwise 0.
   * @param {PolygonConfig} [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|ElevatedPoint} origin          The provided polygon origin. The elevation defaults to
   *                                              the elevation of config.source if passed and otherwise 0.
   * @param {PolygonConfig} config                The provided configuration object
   */
  initialize(origin, config) {
    // Polygon origin
    this.origin = {
      x: Math.round(origin.x),
      y: Math.round(origin.y),
      elevation: origin.elevation ?? config.source?.elevation ?? 0
    };

    // 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;

    // Customize wall direction
    cfg.wallDirectionMode ??= PointSourcePolygon.WALL_DIRECTION_MODES.NORMAL;

    // Configure threshold
    cfg.useThreshold ??= 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};
    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|ElevatedPoint} origin            An origin point. The elevation defaults to
   *                                                the elevation of config.source if passed and otherwise 0.
   * @param {Point|ElevatedPoint} destination       A destination point. The elevation defaults to the elevation
   *                                                of the origin.
   * @param {PolygonConfig} 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 x0 = Math.round(origin.x);
    const y0 = Math.round(origin.y);
    const x1 = Math.round(destination.x);
    const y1 = Math.round(destination.y);
    const ray = new Ray({x: x0, y: y0}, {x: x1, y: y1});
    config.boundaryShapes ||= [];
    config.boundaryShapes.push(ray.bounds);
    const poly = new this();
    poly.initialize(origin, config);
    destination = {x: x1, y: y1, elevation: destination.elevation ?? poly.origin.elevation};
    return poly._testCollision(ray, mode, destination);
  }

  /* -------------------------------------------- */

  /**
   * Determine the set of collisions which occurs for a Ray.
   * @param {Ray} ray                           The Ray to test
   * @param {"any"|"all"|"closest"} mode        The collision mode being tested
   * @param {ElevatedPoint} destination         The destination
   * @returns {boolean|PolygonVertex|PolygonVertex[]|null} The collision test result
   * @protected
   * @abstract
   */
  _testCollision(ray, mode, destination) {
    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;
    const 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;
  }

  /* -------------------------------------------- */

  /**
   * Identifies edges in the Scene that include an active threshold.
   * Utilizes the Quadtree for efficient spatial querying to retrieve relevant edges.
   * @param {ElevatedPoint} origin The origin point from which to calculate thresholds.
   * @param {object} config Configuration object containing threshold parameters.
   * @param {string} config.type The type of the source.
   * @param {number} config.radius The radius of the source.
   * @param {number} config.externalRadius The external radius of the source.
   * @returns {{edges: Set<Edge>, nAttenuated: number}} An object containing the array of
   *   edges that meet the threshold criteria and the total attenuation value.
   */
  static #getThresholdEdges(origin, config) {
    let nAttenuated = 0;

    // Define a bounding box based on the origin and radius to query the Quadtree
    const bounds = new PIXI.Rectangle(origin.x - config.radius, origin.y - config.radius,
      config.radius * 2, config.radius * 2);

    // Define a collision test that ensures only threshold edges are returned
    const collisionTest = edge => edge.applyThreshold(config.type, origin, config.externalRadius);

    // Retrieve candidate edges within the defined bounding box using the Quadtree
    const edges = canvas.edges.getEdges(bounds, {includeOuterBounds: false, collisionTest});

    // Iterate over each candidate edge
    for ( const edge of edges ) 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 {Set<Edge>} edges                       The identified set 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 {ElevatedPoint} 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] : []);
  }
}

/**
 * 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 {number} options.clipType                 One of {@link foundry.canvas.geometry.WeilerAthertonClipper.CLIP_TYPES}
   * @param {boolean} [options.canMutate] If the WeilerAtherton constructor could mutate or not the subject polygon points
   *
   * - Any additional properties in `options` (besides clipType and canMutate)
   *   are captured by the rest operator (`...clipOpts`) and 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;
  }
}

/**
 * Extend PIXI.Circle with new methods.
 */
function extendPIXICircle() {
  /**
   * 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.scalingFactor=CONST.CLIPPER_SCALING_FACTOR]
   *   A scaling factor passed to Polygon#toClipperPoints to preserve precision
   * @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, {clipType, ...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
   * @param {number} [options.scalingFactor=1]  A scaling factor passed to Polygon#toClipperPoints to preserve precision
   * @returns {ClipperPoint[]}                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);
  };
}

/**
 * Extend PIXI Polygon with new methods.
 */
function extendPIXIPolygon() {

  /**
   * 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 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=CONST.CLIPPER_SCALING_FACTOR]
   *   A scaling factor passed to Polygon#toClipperPoints to preserve precision
   * @returns {PIXI.Polygon}       The intersected polygon
   */
  PIXI.Polygon.prototype.intersectPolygon = function(other, {clipType,
    scalingFactor=CLIPPER_SCALING_FACTOR} = {}) {
    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=1]  A scaling factor passed to Polygon#toClipperPoints to preserve precision
   * @returns {ClipperPoint[]}                The resulting ClipperPaths
   */
  PIXI.Polygon.prototype.intersectClipper = function(clipperPoints, {clipType, scalingFactor=1} = {}) {
    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
   * @param {number} [options.scalingFactor=CONST.CLIPPER_SCALING_FACTOR]
   *   A scaling factor passed to Polygon#toClipperPoints to preserve precision
   * @param {number} [options.clipType]       The clipper clip type
   * @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);
  };
}

/**
 * Extend PIXI.Rectangle with new methods.
 */
function extendPIXIRectangle() {

  /**
   * 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=CONST.CLIPPER_SCALING_FACTOR]
   *   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=CLIPPER_SCALING_FACTOR,
    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=1]  A scaling factor passed to Polygon#toClipperPoints to preserve precision
   * @returns {ClipperPoint[]}            The array of intersection points
   */
  PIXI.Rectangle.prototype.intersectClipper = function(clipperPoints, {clipType, scalingFactor=1} = {}) {
    if ( !this.width || !this.height ) return [];
    return this.toPolygon().intersectClipper(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 {PIXI.Rectangle}         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;
  };
}

/**
 * Extend PIXI.Graphics/SmoothGraphics with new methods AND replace PIXI.Graphics by SmoothGraphics.
 * The original PIXI.Graphics is put into PIXI.LegacyGraphics.
 */
function extendPIXIGraphics() {
  PIXI.LegacyGraphics = PIXI.Graphics;
  PIXI.Graphics = PIXI.smooth.SmoothGraphics;

  /**
   * Draws a path.
   * @param {number[]|PIXI.IPointData[]|PIXI.Polygon|...number|...PIXI.IPointData} path    The polygon or points.
   * @returns {PIXI.Graphics}    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 {PIXI.Graphics}         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.
   */
  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;
}

var _module$s = /*#__PURE__*/Object.freeze({
  __proto__: null,
  extendPIXICircle: extendPIXICircle,
  extendPIXIGraphics: extendPIXIGraphics,
  extendPIXIPolygon: extendPIXIPolygon,
  extendPIXIRectangle: extendPIXIRectangle
});

/**
 * @import Collection from "@common/utils/collection.mjs"
 * @import {ElevatedPoint} from "../../_types.mjs"
 * @import PlaceableObject from "../placeables/placeable-object.mjs"
 */


/**
 * @typedef BaseEffectSourceOptions
 * @property {PlaceableObject} [object] An optional PlaceableObject which is responsible for this source
 * @property {string} [sourceId]        A unique ID for this source. This will be set automatically if an
 *                                      object is provided, otherwise is required.
 */

/**
 * @typedef BaseEffectSourceData
 * @property {number} x                   The x-coordinate of the source location
 * @property {number} y                   The y-coordinate of the source location
 * @property {number} elevation           The elevation of the point source
 * @property {boolean} disabled           Whether or not the source is disabled
 */

/**
 * TODO - Re-document after ESM refactor.
 * An abstract base class which defines a framework for effect sources which originate radially from a specific point.
 * This abstraction is used by the LightSource, VisionSource, SoundSource, and MovementSource subclasses.
 *
 * @example A standard PointSource lifecycle:
 * ```js
 * const source = new PointSource({object}); // Create the point source
 * source.initialize(data);                  // Configure the point source with new data
 * source.refresh();                         // Refresh the point source
 * source.destroy();                         // Destroy the point source
 * ```
 *
 * @template {BaseEffectSourceData} [TSourceData=BaseEffectSourceData]
 * @template {PIXI.Polygon} [TSourceShape=PIXI.Polygon]
 * @abstract
 */
class BaseEffectSource {
  /**
   * An effect source is constructed by providing configuration options.
   * @param {BaseEffectSourceOptions} [options]  Options which modify the base effect source instance
   */
  constructor(options={}) {
    this.object = options.object ?? null;
    this.sourceId = options.sourceId;
  }

  /**
   * The type of source represented by this data structure.
   * Each subclass must implement this attribute.
   * @type {string}
   */
  static sourceType;

  /**
   * The target collection into the effects canvas group.
   * @type {string}
   * @abstract
   */
  static effectsCollection;

  /**
   * Effect source default data.
   * @type {BaseEffectSourceData}
   */
  static defaultData = {
    x: 0,
    y: 0,
    elevation: 0,
    disabled: false
  };

  /* -------------------------------------------- */
  /*  Source Data                                 */
  /* -------------------------------------------- */

  /**
   * Some other object which is responsible for this source.
   * @type {object|null}
   */
  object;

  /**
   * The source id linked to this effect source.
   * @type {Readonly<string>}
   */
  sourceId;

  /**
   * The data of this source.
   * @type {TSourceData}
   */
  data = foundry.utils.deepClone(this.constructor.defaultData);

  /**
   * The geometric shape of the effect source which is generated later.
   * @type {TSourceShape}
   */
  shape;

  /**
   * A collection of boolean flags which control rendering and refresh behavior for the source.
   * @type {Record<string, boolean|number>}
   * @protected
   */
  _flags = {};

  /**
   * The x-coordinate of the point source origin.
   * @type {number}
   */
  get x() {
    return this.data.x;
  }

  /**
   * The y-coordinate of the point source origin.
   * @type {number}
   */
  get y() {
    return this.data.y;
  }

  /**
   * The elevation bound to this source.
   * @type {number}
   */
  get elevation() {
    return this.data.elevation;
  }

  /* -------------------------------------------- */
  /*  Source State                                */
  /* -------------------------------------------- */

  /**
   * The EffectsCanvasGroup collection linked to this effect source.
   * @type {Collection<string, BaseEffectSource>}
   */
  get effectsCollection() {
    return canvas.effects[this.constructor.effectsCollection];
  }

  /**
   * Returns the update ID associated with this source.
   * The update ID is increased whenever the shape of the source changes.
   * @type {number}
   */
  get updateId() {
    return this.#updateId;
  }

  #updateId = 0;

  /**
   * Is this source currently active?
   * A source is active if it is attached to an effect collection and is not disabled or suppressed.
   * @type {boolean}
   */
  get active() {
    return this.#attached && !this.data.disabled && !this.suppressed;
  }

  /**
   * Is this source attached to an effect collection?
   * @type {boolean}
   */
  get attached() {
    return this.#attached;
  }

  #attached = false;

  /* -------------------------------------------- */
  /*  Source Suppression Management               */
  /* -------------------------------------------- */

  /**
   * Is this source temporarily suppressed?
   * @type {boolean}
   */
  get suppressed() {
    return Object.values(this.suppression).includes(true);
  }

  /**
   * Records of suppression strings with a boolean value.
   * If any of this record is true, the source is suppressed.
   * @type {Record<string, boolean>}
   */
  suppression = {};

  /* -------------------------------------------- */
  /*  Source Initialization                       */
  /* -------------------------------------------- */

  /**
   * Initialize and configure the source using provided data.
   * @param {Partial<TSourceData>} data        Provided data for configuration
   * @param {object} options                  Additional options which modify source initialization
   * @param {boolean} [options.reset=false]   Should source data be reset to default values before applying changes?
   * @returns {BaseEffectSource}              The initialized source
   */
  initialize(data={}, {reset=false}={}) {
    // Reset the source back to default data
    if ( reset ) data = Object.assign(foundry.utils.deepClone(this.constructor.defaultData), data);

    // Update data for the source
    let changes = {};
    if ( !foundry.utils.isEmpty(data) ) {
      const prior = foundry.utils.deepClone(this.data) || {};
      for ( const key in data ) {
        if ( !(key in this.data) ) continue;
        this.data[key] = data[key] ?? this.constructor.defaultData[key];
      }
      this._initialize(data);
      changes = foundry.utils.flattenObject(foundry.utils.diffObject(prior, this.data));
    }

    // Update shapes for the source
    try {
      this._createShapes();
      this.#updateId++;
    }
    catch (err) {
      console.error(err);
      this.remove();
    }

    // Configure attached and non disabled sources
    if ( this.#attached && !this.data.disabled ) this._configure(changes);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Subclass specific data initialization steps.
   * @param {Partial<TSourceData>} data    Provided data for configuration
   * @abstract
   */
  _initialize(data) {}

  /* -------------------------------------------- */

  /**
   * Create the polygon shape (or shapes) for this source using configured data.
   * @protected
   * @abstract
   */
  _createShapes() {}

  /* -------------------------------------------- */

  /**
   * Subclass specific configuration steps. Occurs after data initialization and shape computation.
   * Only called if the source is attached and not disabled.
   * @param {Partial<TSourceData>} changes   Changes to the source data which were applied
   * @protected
   */
  _configure(changes) {}

  /* -------------------------------------------- */
  /*  Source Refresh                              */
  /* -------------------------------------------- */

  /**
   * Refresh the state and uniforms of the source.
   * Only active sources are refreshed.
   */
  refresh() {
    if ( !this.active ) return;
    this._refresh();
  }

  /* -------------------------------------------- */

  /**
   * Subclass-specific refresh steps.
   * @protected
   * @abstract
   */
  _refresh() {}

  /* -------------------------------------------- */
  /*  Source Destruction                          */
  /* -------------------------------------------- */

  /**
   * Steps that must be performed when the source is destroyed.
   */
  destroy() {
    this.remove();
    this._destroy();
  }

  /* -------------------------------------------- */

  /**
   * Subclass specific destruction steps.
   * @protected
   */
  _destroy() {}

  /* -------------------------------------------- */
  /*  Source Management                           */
  /* -------------------------------------------- */

  /**
   * Add this BaseEffectSource instance to the active collection.
   */
  add() {
    if ( !this.sourceId ) throw new Error("A BaseEffectSource cannot be added to the active collection unless it has"
      + " a sourceId assigned.");
    this.effectsCollection.set(this.sourceId, this);
    const wasConfigured = this.#attached && !this.data.disabled;
    this.#attached = true;
    if ( !wasConfigured && !this.data.disabled ) this._configure({});
  }

  /* -------------------------------------------- */

  /**
   * Remove this BaseEffectSource instance from the active collection.
   */
  remove() {
    if ( !this.effectsCollection.has(this.sourceId) ) return;
    this.effectsCollection.delete(this.sourceId);
    this.#attached = false;
  }

  /* -------------------------------------------- */
  /*  Visibility Testing                          */
  /* -------------------------------------------- */

  /**
   * Test whether the point is contained within the shape of the source.
   * @param {ElevatedPoint} point   The point.
   * @returns {boolean}             Is inside the source?
   */
  testPoint(point) {
    if ( !this.shape ) return false;
    return this.shape.contains(point.x, point.y);
  }
}

/**
 * Extension of a PIXI.Mesh for PointEffectSources.
 */
class PointSourceMesh extends PIXI.Mesh {

  /**
   * 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._geometry !== undefined) ) 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 */
  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);
  }
}

/**
 * @import {LightingLevel} from "@common/constants.mjs"
 * @import {AdaptiveBackgroundShader, AdaptiveColorationShader, AdaptiveDarknessShader, AdaptiveIlluminationShader,
 *   AdaptiveLightingShader} from "../rendering/shaders/_module.mjs";
 * @import {BaseEffectSourceData} from "./base-effect-source.mjs";
 */

/**
 * @typedef RenderedEffectSourceData
 * @property {object} animation           An animation configuration for the source
 * @property {number|null} color          A color applied to the rendered effect
 * @property {number|null} seed           An integer seed to synchronize (or de-synchronize) animations
 * @property {boolean} preview            Is this source a temporary preview?
 */

/**
 * @typedef RenderedEffectSourceAnimationConfig
 * @property {string} [label]                                   The human-readable (localized) label for the animation
 * @property {Function} [animation]                             The animation function that runs every frame
 * @property {AdaptiveIlluminationShader} [illuminationShader]  A custom illumination shader used by this animation
 * @property {AdaptiveColorationShader} [colorationShader]      A custom coloration shader used by this animation
 * @property {AdaptiveBackgroundShader} [backgroundShader]      A custom background shader used by this animation
 * @property {AdaptiveDarknessShader} [darknessShader]          A custom darkness shader used by this animation
 * @property {number} [seed]                                    The animation seed
 * @property {number} [time]                                    The animation time
 */

/**
 * @typedef RenderedEffectLayerConfig
 * @property {typeof AdaptiveLightingShader} defaultShader      The default shader used by this layer
 * @property {PIXI.BLEND_MODES} blendMode                       The blend mode used by this layer
 */

/**
 * An abstract class which extends the base PointSource to provide common functionality for rendering.
 * This class is extended by both the LightSource and VisionSource subclasses.
 * @extends {BaseEffectSource<BaseEffectSourceData & RenderedEffectSourceData>}
 * @abstract
 */
class RenderedEffectSource extends BaseEffectSource {

  /**
   * Keys of the data object which require shaders to be re-initialized.
   * @type {string[]}
   * @protected
   */
  static _initializeShaderKeys = ["animation.type"];

  /**
   * Keys of the data object which require uniforms to be refreshed.
   * @type {string[]}
   * @protected
   */
  static _refreshUniformsKeys = [];

  /**
   * Layers handled by this rendered source.
   * @type {Record<string, RenderedEffectLayerConfig>}
   * @protected
   */
  static get _layers() {
    return {};
  }

  /**
   * The offset in pixels applied to create soft edges.
   * @type {number}
   */
  static EDGE_OFFSET = -8;

  /** @inheritDoc */
  static defaultData = {
    ...super.defaultData,
    animation: {},
    seed: null,
    preview: false,
    color: null
  };

  /* -------------------------------------------- */
  /*  Rendered Source Attributes                  */
  /* -------------------------------------------- */

  /**
   * The animation configuration applied to this source
   * @type {RenderedEffectSourceAnimationConfig}
   */
  animation = {};

  /**
   * @typedef RenderedEffectSourceLayer
   * @property {boolean} active             Is this layer actively rendered?
   * @property {boolean} reset              Do uniforms need to be reset?
   * @property {boolean} suppressed         Is this layer temporarily suppressed?
   * @property {PointSourceMesh} mesh       The rendered mesh for this layer
   * @property {AdaptiveLightingShader} shader  The shader instance used for the layer
   */

  /**
   * Track the status of rendering layers
   * @type {{
   *  background: RenderedEffectSourceLayer,
   *  coloration: RenderedEffectSourceLayer,
   *  illumination: RenderedEffectSourceLayer
   * }}
   */
  layers = Object.entries(this.constructor._layers).reduce((obj, [layer, config]) => {
    obj[layer] = {active: true, reset: true, suppressed: false,
      mesh: undefined, shader: undefined, defaultShader: config.defaultShader,
      vmUniforms: undefined, blendMode: config.blendMode};
    return obj;
  }, {});

  /**
   * Array of update uniforms functions.
   * @type {Function[]}
   */
  #updateUniformsFunctions = (() => {
    const initializedFunctions = [];
    for ( const layer in this.layers ) {
      const fn = this[`_update${layer.titleCase()}Uniforms`];
      if ( fn ) initializedFunctions.push(fn);
    }
    return initializedFunctions;
  })();

  /**
   * The color of the source as an RGB vector.
   * @type {[number, number, number]|null}
   */
  colorRGB = null;

  /**
   * PIXI Geometry generated to draw meshes.
   * @type {PIXI.Geometry|null}
   * @protected
   */
  _geometry = null;

  /* -------------------------------------------- */
  /*  Source State                                */
  /* -------------------------------------------- */

  /**
   * Is the rendered source animated?
   * @type {boolean}
   */
  get isAnimated() {
    return this.active && this.data.animation?.type;
  }

  /**
   * Has the rendered source at least one active layer?
   * @type {boolean}
   */
  get hasActiveLayer() {
    return this.#hasActiveLayer;
  }

  #hasActiveLayer = false;

  /**
   * Is this RenderedEffectSource a temporary preview?
   * @returns {boolean}
   */
  get isPreview() {
    return !!this.data.preview;
  }

  /* -------------------------------------------- */
  /*  Rendered Source Properties                  */
  /* -------------------------------------------- */

  /**
   * A convenience accessor to the background layer mesh.
   * @type {PointSourceMesh}
   */
  get background() {
    return this.layers.background.mesh;
  }

  /**
   * A convenience accessor to the coloration layer mesh.
   * @type {PointSourceMesh}
   */
  get coloration() {
    return this.layers.coloration.mesh;
  }

  /**
   * A convenience accessor to the illumination layer mesh.
   * @type {PointSourceMesh}
   */
  get illumination() {
    return this.layers.illumination.mesh;
  }

  /* -------------------------------------------- */
  /*  Rendered Source Initialization              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initialize(data) {
    super._initialize(data);
    const color = Color.from(this.data.color ?? null);
    this.data.color = color.valid ? color.valueOf() : null;
    const seed = this.data.seed ?? this.animation.seed ?? Math.floor(Math.random() * 100000);
    this.animation = this.data.animation = {seed, ...this.data.animation};

    // Initialize the color attributes
    const hasColor = this._flags.hasColor = (this.data.color !== null);
    if ( hasColor ) Color.applyRGB(color, this.colorRGB ??= [0, 0, 0]);
    else this.colorRGB = null;

    // We need to update the hasColor uniform attribute immediately
    for ( const layer of Object.values(this.layers) ) {
      if ( layer.shader ) layer.shader.uniforms.hasColor = hasColor;
    }
  }

  /* -------------------------------------------- */

  /**
   * Decide whether to render soft edges with a blur.
   * @protected
   */
  _initializeSoftEdges() {
    this._flags.renderSoftEdges = canvas.performance.lightSoftEdges && !this.isPreview;
  }

  /* -------------------------------------------- */

  /** @override */
  _configure(changes) {
    // To know if we need a first time initialization of the shaders
    const initializeShaders = !this._geometry;

    // Configure soft edges
    this._initializeSoftEdges();

    // Initialize meshes using the computed shape
    this.#initializeMeshes();

    // Initialize shaders
    if ( initializeShaders || this.constructor._initializeShaderKeys.some(k => k in changes) ) {
      this.#initializeShaders();
    }

    // Refresh uniforms
    else if ( this.constructor._refreshUniformsKeys.some(k => k in changes) ) {
      for ( const config of Object.values(this.layers) ) {
        config.reset = true;
      }
    }

    // Update the visible state the layers
    this.#updateVisibleLayers();
  }

  /* -------------------------------------------- */

  /**
   * Configure which shaders are used for each rendered layer.
   * @returns {Record<string, typeof AdaptiveLightingShader>}
   *   An object whose keys are layer identifiers and whose values are shader classes.
   * @protected
   */
  _configureShaders() {
    const a = this.animation;
    const shaders = {};
    for ( const layer in this.layers ) {
      shaders[layer] = a[`${layer.toLowerCase()}Shader`] || this.layers[layer].defaultShader;
    }
    return shaders;
  }

  /* -------------------------------------------- */

  /**
   * Specific configuration for a layer.
   * @param {object} layer
   * @param {string} layerId
   * @protected
   */
  _configureLayer(layer, layerId) {}

  /* -------------------------------------------- */

  /**
   * Initialize the shaders used for this source, swapping to a different shader if the animation has changed.
   */
  #initializeShaders() {
    const shaders = this._configureShaders();
    for ( const [layerId, layer] of Object.entries(this.layers) ) {
      layer.shader = RenderedEffectSource.#createShader(shaders[layerId], layer.mesh);
      this._configureLayer(layer, layerId);
    }
    this.#updateUniforms();
    Hooks$1.callAll(`initialize${this.constructor.name}Shaders`, this);
  }

  /* -------------------------------------------- */

  /**
   * Create a new shader using a provider shader class
   * @param {typeof AdaptiveLightingShader} cls   The shader class to create
   * @param {PointSourceMesh} container           The container which requires a new shader
   * @returns {AdaptiveLightingShader}            The shader instance used
   */
  static #createShader(cls, container) {
    const current = container.shader;
    if ( current?.constructor === cls ) return current;
    const shader = cls.create({
      primaryTexture: canvas.primary.renderTexture
    });
    shader.container = container;
    container.shader = shader;
    container.uniforms = shader.uniforms;
    if ( current ) current.destroy();
    return shader;
  }

  /* -------------------------------------------- */

  /**
   * Initialize the geometry and the meshes.
   */
  #initializeMeshes() {
    this._updateGeometry();
    if ( !this._flags.initializedMeshes ) this.#createMeshes();
  }

  /* -------------------------------------------- */

  /**
   * Create meshes for each layer of the RenderedEffectSource that is drawn to the canvas.
   */
  #createMeshes() {
    if ( !this._geometry ) return;
    const shaders = this._configureShaders();
    for ( const [l, layer] of Object.entries(this.layers) ) {
      layer.mesh = this.#createMesh(shaders[l]);
      layer.mesh.blendMode = PIXI.BLEND_MODES[layer.blendMode];
      layer.shader = layer.mesh.shader;
    }
    this._flags.initializedMeshes = true;
  }

  /* -------------------------------------------- */

  /**
   * Create a new Mesh for this source using a provided shader class
   * @param {typeof AdaptiveLightingShader} shaderCls   The shader class used for this mesh
   * @returns {PointSourceMesh}                         The created Mesh
   */
  #createMesh(shaderCls) {
    const state = new PIXI.State();
    const mesh = new PointSourceMesh(this._geometry, shaderCls.create(), state);
    mesh.drawMode = PIXI.DRAW_MODES.TRIANGLES;
    mesh.uniforms = mesh.shader.uniforms;
    mesh.cullable = true;
    return mesh;
  }

  /* -------------------------------------------- */

  /**
   * Create the geometry for the source shape that is used in shaders and compute its bounds for culling purpose.
   * Triangulate the form and create buffers.
   * @protected
   * @abstract
   */
  _updateGeometry() {}

  /* -------------------------------------------- */
  /*  Rendered Source Canvas Rendering            */
  /* -------------------------------------------- */

  /**
   * Render the containers used to represent this light source within the LightingLayer
   * @returns {Record<string, PIXI.Mesh|null>}
   */
  drawMeshes() {
    const meshes = {};
    for ( const layerId of Object.keys(this.layers) ) {
      meshes[layerId] = this._drawMesh(layerId);
    }
    return meshes;
  }

  /* -------------------------------------------- */

  /**
   * Create a Mesh for a certain rendered layer of this source.
   * @param {string} layerId            The layer key in layers to draw
   * @returns {PIXI.Mesh|null}          The drawn mesh for this layer, or null if no mesh is required
   * @protected
   */
  _drawMesh(layerId) {
    const layer = this.layers[layerId];
    const mesh = layer.mesh;

    if ( layer.reset ) {
      const fn = this[`_update${layerId.titleCase()}Uniforms`];
      fn.call(this);
    }
    if ( !layer.active ) {
      mesh.visible = false;
      return null;
    }

    // Update the mesh
    const {x, y} = this.data;
    mesh.position.set(x, y);
    mesh.visible = mesh.renderable = true;
    return layer.mesh;
  }

  /* -------------------------------------------- */
  /*  Rendered Source Refresh                     */
  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    this.#updateUniforms();
    this.#updateVisibleLayers();
  }

  /* -------------------------------------------- */

  /**
   * Update uniforms for all rendered layers.
   */
  #updateUniforms() {
    for ( const updateUniformsFunction of this.#updateUniformsFunctions ) updateUniformsFunction.call(this);
  }

  /* -------------------------------------------- */

  /**
   * Update the visible state of the component channels of this RenderedEffectSource.
   */
  #updateVisibleLayers() {
    const active = this.active;
    let hasActiveLayer = false;
    for ( const layer of Object.values(this.layers) ) {
      layer.active = active && (layer.shader?.isRequired !== false);
      if ( layer.active ) hasActiveLayer = true;
    }
    this.#hasActiveLayer = hasActiveLayer;
  }

  /* -------------------------------------------- */

  /**
   * Update shader uniforms used by every rendered layer.
   * @param {AbstractBaseShader} shader
   * @protected
   */
  _updateCommonUniforms(shader) {}

  /* -------------------------------------------- */

  /**
   * Update shader uniforms used for the background layer.
   * @protected
   */
  _updateBackgroundUniforms() {
    const shader = this.layers.background.shader;
    if ( !shader ) return;
    this._updateCommonUniforms(shader);
  }

  /* -------------------------------------------- */

  /**
   * Update shader uniforms used for the coloration layer.
   * @protected
   */
  _updateColorationUniforms() {
    const shader = this.layers.coloration.shader;
    if ( !shader ) return;
    this._updateCommonUniforms(shader);
  }

  /* -------------------------------------------- */

  /**
   * Update shader uniforms used for the illumination layer.
   * @protected
   */
  _updateIlluminationUniforms() {
    const shader = this.layers.illumination.shader;
    if ( !shader ) return;
    this._updateCommonUniforms(shader);
  }

  /* -------------------------------------------- */
  /*  Rendered Source Destruction                 */
  /* -------------------------------------------- */

  /** @override */
  _destroy() {
    for ( const layer of Object.values(this.layers) ) layer.mesh?.destroy();
    this._geometry?.destroy();
  }

  /* -------------------------------------------- */
  /*  Animation Functions                         */
  /* -------------------------------------------- */

  /**
   * Animate the PointSource, if an animation is enabled and if it currently has rendered containers.
   * @param {number} dt         Delta time.
   */
  animate(dt) {
    if ( !this.isAnimated ) return;
    const {animation, ...options} = this.animation;
    return animation?.call(this, dt, options);
  }

  /* -------------------------------------------- */

  /**
   * Generic time-based animation used for Rendered Point Sources.
   * @param {number} dt           Delta time.
   * @param {object} [options]    Options which affect the time animation
   * @param {number} [options.speed=5]            The animation speed, from 0 to 10
   * @param {number} [options.intensity=5]        The animation intensity, from 1 to 10
   * @param {boolean} [options.reverse=false]     Reverse the animation direction
   */
  animateTime(dt, {speed=5, intensity=5, reverse=false}={}) {

    // Determine the animation timing
    let t = canvas.app.ticker.lastTime;
    if ( reverse ) t *= -1;
    this.animation.time = ( (speed * t) / 5000 ) + this.animation.seed;

    // Update uniforms
    for ( const layer of Object.values(this.layers) ) {
      const u = layer.mesh.uniforms;
      u.time = this.animation.time;
      u.intensity = intensity;
    }
  }
  /* -------------------------------------------- */
  /*  Static Helper Methods                       */
  /* -------------------------------------------- */

  /**
   * Get corrected level according to level and active vision mode data.
   * @param {LightingLevel} level  The lighting level (one of {@link CONST.LIGHTING_LEVELS})
   * @returns {number} The corrected level.
   */
  static getCorrectedLevel(level) {
    // Retrieving the lighting mode and the corrected level, if any
    const lightingOptions = canvas.visibility.visionModeData?.activeLightingOptions;
    return (lightingOptions?.levels?.[level]) ?? level;
  }

  /* -------------------------------------------- */

  /**
   * Get corrected color according to level, dim color, bright color and background color.
   * @param {LightingLevel} level The lighting level (one of {@link CONST.LIGHTING_LEVELS})
   * @param {Color} colorDim
   * @param {Color} colorBright
   * @param {Color} [colorBackground]
   * @returns {Color}
   */
  static getCorrectedColor(level, colorDim, colorBright, colorBackground) {
    colorBackground ??= canvas.colors.background;

    // Returning the corrected color according to the lighting options
    const levels = LIGHTING_LEVELS;
    switch ( this.getCorrectedLevel(level) ) {
      case levels.HALFDARK:
      case levels.DIM: return colorDim;
      case levels.BRIGHT:
      case levels.DARKNESS: return colorBright;
      case levels.BRIGHTEST: return canvas.colors.ambientBrightest;
      case levels.UNLIT: return colorBackground;
      default: return colorDim;
    }
  }
}

/**
 * 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 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>}
   */
  #scaled = {};

  /**
   * Polygon mesh geometry
   * @type {PIXI.Geometry}
   */
  #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[]}}
   */
  #polygonNodeTree = null;

  /**
   * Contains the the number of offset passes required to compute the polygon
   * @type {number}
   */
  #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 {ClipperLib.Path|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
   */
  #computePolygonMesh(points) {
    if ( !points || points.length < 6 ) return;
    this.#updateVertices(points);
    this.#updatePolygonNodeTree();
  }

  /* -------------------------------------------- */

  /**
   * Update vertices and add depth
   * @param {number[]} vertices
   */
  #updateVertices(vertices) {
    const {offset, depthOuter, scale} = this.options;

    // Precompute the z value once
    const z = (offset === 0) ? 1.0 : depthOuter;

    // Reserve capacity if we know the size, for performance in some JS engines
    // Each point => 3 entries => (vertices.length/2 * 3)
    this.vertices = new Array((vertices.length / 2) * 3);

    // Fill
    let j = 0;
    for ( let i = 0; i < vertices.length; i += 2 ) {
      // Combine the scaling+rounding for x and y
      const x = Math.round(vertices[i] * scale);
      const y = Math.round(vertices[i + 1] * scale);

      this.vertices[j] = x;
      this.vertices[j + 1] = y;
      this.vertices[j + 2] = z;
      j += 3;
    }
  }

  /* -------------------------------------------- */

  /**
   * 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
   */
  #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
   */
  #flattenVertices(path, depth) {
    const n = path.length * 3;
    const flattened = new Array(n);
    let j = 0;
    for ( let i = 0; i < path.length; i++ ) {
      const {X, Y} = path[i];
      flattened[j] = X;
      flattened[j+1] = Y;
      flattened[j+2] = depth;
      j += 3;
    }
    return flattened;
  }

  /* -------------------------------------------- */

  /**
   * Normalize polygon coordinates and put result into nPoly property.
   * @param {number[]} poly       the poly to normalize
   * @returns {number[]}           the normalized poly array
   */
  #normalize(poly) {
    if ( !this.options.normalize ) return [];
    const {sx, sy, sradius} = this.#scaled;
    const nPoly = new Array(poly.length);
    for ( let i = 0; i < poly.length; i += 3 ) {
      const x = (poly[i] - sx) / sradius;
      const y = (poly[i+1] - sy) / sradius;
      nPoly[i]   = x;
      nPoly[i+1] = y;
      nPoly[i+2] = 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
   */
  #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
   */
  #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.
   * Uses pre-allocated arrays instead of repeated push calls.
   * @param {object} node              The polygon node tree to triangulate
   * @param {number[]} [indices=[]]    An optional array to receive indices (used for recursion)
   * @returns {number[]}               The array of indices, result of the triangulation
   */
  #triangulateNode(node, indices=[]) {
    const {normalize} = this.options;
    const hasChildren = !!node.children.length;
    const polyLength = node.poly.length / 3;

    // If node.position is not set, mark it as 0 and copy node polygon into this.vertices
    if ( !node.position ) {
      node.position = 0;
      const poly = normalize ? node.nPoly : node.poly;
      const oldSize = this.vertices.length;
      this.vertices.length += poly.length;
      for ( let i = 0; i < poly.length; i++ ) {
        this.vertices[oldSize + i] = poly[i];
      }
    }

    // If no children, earcut immediately and return
    if ( !hasChildren ) {
      // Build local "vert" array for earcut
      const vert = new Array(node.poly.length);
      for ( let i = 0; i < node.poly.length; i++ ) {
        vert[i] = node.poly[i];
      }
      // Triangulate with earcut, offset the indices by node.position
      const earIndices = PIXI.utils.earcut(vert, null, 3);
      for ( let i = 0; i < earIndices.length; i++ ) {
        indices.push(earIndices[i] + node.position);
      }
      return indices;
    }

    // Has children => combine outer ring + children polygons
    // Compute total size (outer + each child)
    const {totalLength, childPolyCounts} = this.#computeCombinedLength(node);

    // Build the local 'vert' array
    const vert = new Array(totalLength);
    let vertPos = 0;
    // copy node.poly first
    for ( let i = 0; i < node.poly.length; i++ ) {
      vert[vertPos++] = node.poly[i];
    }

    // Prepare holes array
    const holes = [];
    let holePosition = polyLength;
    let holeGroupPosition = 0;

    // Copy each child's poly data into 'vert' and into 'this.vertices' if needed while also populating the holes array
    let childIndex = 0;
    for ( const nodeChild of node.children ) {
      holes.push(holePosition);
      nodeChild.position = (this.vertices.length / 3);

      // Store holeGroupPosition if not set
      if ( !holeGroupPosition ) holeGroupPosition = nodeChild.position;

      // childPolyCounts[childIndex]
      const cCount = childPolyCounts[childIndex++];
      holePosition += (cCount / 3);

      // copy child poly into local vert
      for ( let i = 0; i < cCount; i++ ) {
        vert[vertPos++] = nodeChild.poly[i];
      }

      // Also copy child data into this.vertices
      const poly = normalize ? nodeChild.nPoly : nodeChild.poly;
      const oldSize = this.vertices.length;
      this.vertices.length += poly.length;
      for ( let i = 0; i < poly.length; i++ ) {
        this.vertices[oldSize + i] = poly[i];
      }
    }

    // 4) Earcut pass for the combined outer+holes
    const holeGroupShift = holeGroupPosition - polyLength;
    const earIdx = earcut.earcutEdges(vert, holes);
    for ( let i = 0; i < earIdx.length; i++ ) {
      const v = earIdx[i];
      if ( v < polyLength ) indices.push(v + node.position);
      else indices.push(v + holeGroupShift);
    }

    // Recurse on each child
    for ( const nodeChild of node.children ) {
      this.#triangulateNode(nodeChild, indices);
    }
    return indices;
  }

  /* -------------------------------------------- */

  /**
   * Compute combined length of node.poly and all children polygons
   * @param {object} node
   * @returns {{totalLength: number, childPolyCounts: number[]}}
   */
  #computeCombinedLength(node) {
    // node.poly.length is the outer ring (3 floats per vertex)
    let totalLength = node.poly.length;

    // Store each child's length for easy reference
    const childPolyCounts = [];
    for ( const child of node.children ) {
      childPolyCounts.push(child.poly.length);
      totalLength += child.poly.length;
    }
    return {totalLength, childPolyCounts};
  }

  /* -------------------------------------------- */

  /**
   * Updating or creating the PIXI.Geometry that will be used by the mesh
   */
  #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 ) {
      const count = this.vertices.length / 3;
      vertices = new Array(count * 2);
      depth = new Array(count);

      let idxV = 0;
      let idxD = 0;
      for ( let i = 0; i < this.vertices.length; i += 3 ) {
        vertices[idxV++] = this.vertices[i];
        vertices[idxV++] = this.vertices[i + 1];
        depth[idxD++] = 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.
   */
  #emptyGeometry() {
    const {interleaved} = this.options;

    // Empty the current geometry if it exists
    if ( this.#geometry ) {
      const vertBuffer = this.#geometry.getBuffer("aVertexPosition");
      // Already empty? If yes, skip update and return geometry
      if ( vertBuffer.data.length === 2 ) return this.#geometry;
      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]));
      }
      return this.#geometry;
    }

    // If geometry doesn't exist, create an empty geometry
    if ( interleaved ) {
      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);
  }
}

/**
 * @import BaseEffectSource from "./base-effect-source.mjs"
 * @import {PointSourcePolygonConfig, ClockwiseSweepPolygonConfig} from "../_types.mjs"
 */

/**
 * @typedef PointEffectSourceData
 * @property {number} radius              The radius of the source
 * @property {number} externalRadius      A secondary radius used for limited angles
 * @property {number} rotation            The angle of rotation for this point source
 * @property {number} angle               The angle of emission for this point source
 * @property {boolean} walls              Whether or not the source is constrained by walls
 * @property {number} priority            Strength of this source to beat or not negative/positive sources
 */

/**
 * Provides a common framework for effect sources that emanate from a central point and extend within a specific radius.
 * This mixin can be used to manage any effect with a point-based origin, such as light, darkness, or other effects.
 * @template {class} T
 * @param {T} BaseSource  The base source class to extend
 */
function PointEffectSourceMixin(BaseSource) {
  /**
   * @extends {BaseEffectSource<BaseEffectSourceData & PointEffectSourceData, PointSourcePolygon>}
   * @abstract
   */
  return class PointEffectSource extends BaseSource {

    /** @inheritDoc */
    static defaultData = {
      ...super.defaultData,
      radius: 0,
      externalRadius: 0,
      rotation: 0,
      angle: 360,
      walls: true,
      priority: 0
    };

    /**
     * The Edge instances added by this source.
     * @type {Edge[]}
     */
    edges = [];

    /* -------------------------------------------- */

    /**
     * Whether this Point Effect source can create edges or not.
     * Overriding classes can define dynamic behavior if needed.
     * Default to false so that typical point sources do not create edges.
     * @type {boolean}
     */
    get requiresEdges() {
      return false;
    }

    /**
     * A convenience reference to the radius of the source.
     * @type {number}
     */
    get radius() {
      return this.data.radius ?? 0;
    }

    /**
     * The priority of this point effect source.
     * @type {number}
     */
    get priority() {
      return this.data.priority;
    }

    /**
     * The (elevated) origin of this point effect source.
     * @type {ElevatedPoint}
     */
    get origin() {
      return this.#origin;
    }

    #origin = Object.seal({
      x: 0,
      y: 0,
      elevation: 0
    });

    /* -------------------------------------------- */

    /** @inheritDoc */
    _configure(changes) {
      super._configure(changes);
      this._createEdges();
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _initialize(data) {
      super._initialize(data);
      if ( this.data.radius > 0 ) this.data.radius = Math.max(this.data.radius, this.data.externalRadius);
      this.#origin.x = this.data.x;
      this.#origin.y = this.data.y;
      this.#origin.elevation = this.data.elevation;
    }

    /* -------------------------------------------- */
    /*  Point Source Geometry Methods               */
    /* -------------------------------------------- */

    /** @inheritDoc */
    _initializeSoftEdges() {
      super._initializeSoftEdges();
      const isCircle = (this.shape instanceof PointSourcePolygon) && this.shape.isCompleteCircle();
      this._flags.renderSoftEdges &&= !isCircle;
    }

    /* -------------------------------------------- */

    /**
     * Configure the parameters of the polygon that is generated for this source.
     * @returns {PointSourcePolygonConfig & ClockwiseSweepPolygonConfig}
     * @protected
     */
    _getPolygonConfiguration() {
      return {
        type: this.constructor.sourceType,
        edgeOptions: {
          wall: this.data.walls
        },
        radius: (this.data.disabled || this.suppressed) ? 0 : this.radius,
        externalRadius: this.data.externalRadius,
        angle: this.data.angle,
        rotation: this.data.rotation,
        priority: this.data.priority,
        source: this
      };
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _createShapes() {
      this._deleteEdges();
      const config = this._getPolygonConfiguration();
      const polygonClass = CONFIG.Canvas.polygonBackends[this.constructor.sourceType];
      this.shape = polygonClass.create(this.origin, config);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _destroy() {
      this._deleteEdges();
      super._destroy();
    }

    /* -------------------------------------------- */
    /*  Rendering methods                           */
    /* -------------------------------------------- */

    /** @override */
    _drawMesh(layerId) {
      const mesh = super._drawMesh(layerId);
      if ( mesh ) mesh.scale.set(this.shape.config.radius);
      return mesh;
    }

    /** @override */
    _updateGeometry() {
      const {x, y} = this.shape.origin;
      const radius = this.shape.config.radius;
      const offset = this._flags.renderSoftEdges ? this.constructor.EDGE_OFFSET * (canvas.grid.size / 100) : 0;
      const pm = new PolygonMesher(this.shape, {x, y, radius, normalize: true, offset});
      this._geometry = pm.triangulate(this._geometry);
      const bounds = new PIXI.Rectangle(0, 0, 0, 0);
      if ( radius > 0 ) {
        const b = this.shape instanceof PointSourcePolygon ? this.shape.bounds : this.shape.getBounds();
        bounds.x = (b.x - x) / radius;
        bounds.y = (b.y - y) / radius;
        bounds.width = b.width / radius;
        bounds.height = b.height / radius;
      }
      if ( this._geometry.bounds ) this._geometry.bounds.copyFrom(bounds);
      else this._geometry.bounds = bounds;
    }

    /* -------------------------------------------- */
    /*  Edge Management                             */
    /* -------------------------------------------- */

    /**
     * Create the Edge instances that correspond to this source.
     * @protected
     */
    _createEdges() {
      if ( !this.requiresEdges || !this.active || this.isPreview ) return;
      const cls = foundry.canvas.geometry.edges.Edge;
      const block = CONST.WALL_SENSE_TYPES.NORMAL;
      const direction = CONST.WALL_DIRECTIONS.LEFT;
      const points = [...this.shape.points];

      // Prepare iteration
      let p0 = {x: points[0], y: points[1]};
      points.push(p0.x, p0.y);
      let p1;

      // Build edges from polygon points
      for ( let i = 2; i < points.length; i += 2 ) {
        p1 = {x: points[i], y: points[i + 1]};
        const id = `${this.constructor.sourceType}.${this.sourceId}.${i / 2}`;
        const edge = new cls(p0, p1, {
          type: this.constructor.sourceType,
          id,
          object: this.object,
          direction,
          light: block,
          sight: block,
          priority: this.data.priority
        });
        this.edges.push(edge);
        canvas.edges.set(edge.id, edge);
        p0 = p1;
      }
    }

    /* -------------------------------------------- */

    /**
     * Remove edges from the active Edges collection.
     * @protected
     */
    _deleteEdges() {
      for ( const edge of this.edges ) canvas.edges.delete(edge.id);
      this.edges.length = 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 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}
  `;

  /**
   * 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 {

  /** @override */
  static FRAGMENT_END = `
  gl_FragColor = vec4(mix(computedBackgroundColor, 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: 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,
    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]
  };
}

/**
 * @import {BaseEffectSourceData} from "./base-effect-source.mjs";
 * @import {RenderedEffectSourceData} from "./rendered-effect-source.mjs";
 */

/**
 * @typedef VisionSourceData
 * @property {number} contrast            The amount of contrast
 * @property {number} attenuation         Strength of the attenuation between bright, dim, and dark
 * @property {number} saturation          The amount of color saturation
 * @property {number} brightness          The vision brightness.
 * @property {string} visionMode          The vision mode.
 * @property {number} lightRadius         The range of light perception.
 * @property {boolean} blinded            Is this vision source blinded?
 */

/**
 * A specialized subclass of RenderedEffectSource which represents a source of point-based vision.
 * @extends {RenderedEffectSource<BaseEffectSourceData & RenderedEffectSourceData & VisionSourceData, PointSourcePolygon>}
 */
class PointVisionSource extends PointEffectSourceMixin(RenderedEffectSource) {

  /** @inheritdoc */
  static sourceType = "sight";

  /** @override */
  static _initializeShaderKeys = ["visionMode", "blinded"];

  /** @override */
  static _refreshUniformsKeys = ["radius", "color", "attenuation", "brightness", "contrast", "saturation", "visionMode"];

  /**
   * The corresponding lighting levels for dim light.
   * @type {number}
   * @protected
   */
  static _dimLightingLevel = LIGHTING_LEVELS.DIM;

  /**
   * The corresponding lighting levels for bright light.
   * @type {number}
   * @protected
   */
  static _brightLightingLevel = LIGHTING_LEVELS.BRIGHT;

  /** @inheritdoc */
  static EDGE_OFFSET = -2;

  /** @override */
  static effectsCollection = "visionSources";

  /** @inheritDoc */
  static defaultData = {
    ...super.defaultData,
    contrast: 0,
    attenuation: 0.5,
    saturation: 0,
    brightness: 0,
    visionMode: "basic",
    lightRadius: null
  }

  /** @override */
  static get _layers() {
    return {
      background: {
        defaultShader: BackgroundVisionShader,
        blendMode: "MAX_COLOR"
      },
      coloration: {
        defaultShader: ColorationVisionShader,
        blendMode: "SCREEN"
      },
      illumination: {
        defaultShader: IlluminationVisionShader,
        blendMode: "MAX_COLOR"
      }
    };
  }

  /* -------------------------------------------- */
  /*  Vision Source Attributes                    */
  /* -------------------------------------------- */

  /**
   * The vision mode linked to this VisionSource
   * @type {VisionMode|null}
   */
  visionMode = null;

  /**
   * The vision mode activation flag for handlers
   * @type {boolean}
   * @internal
   */
  _visionModeActivated = false;

  /**
   * The unconstrained LOS polygon.
   * @type {PointSourcePolygon}
   */
  los;

  /**
   * The polygon of light perception.
   * @type {PointSourcePolygon}
   */
  light;

  /* -------------------------------------------- */

  /**
   * An alias for the shape of the vision source.
   * @type {PointSourcePolygon|PIXI.Polygon}
   */
  get fov() {
    return this.shape;
  }

  /* -------------------------------------------- */

  /**
   * If this vision source background is rendered into the lighting container.
   * @type {boolean}
   */
  get preferred() {
    return this.visionMode?.vision.preferred;
  }

  /* -------------------------------------------- */

  /**
   * Is the rendered source animated?
   * @type {boolean}
   */
  get isAnimated() {
    return this.active && this.data.animation && this.visionMode?.animated;
  }

  /* -------------------------------------------- */

  /**
   * Light perception radius of this vision source, taking into account if the source is blinded.
   * @type {number}
   */
  get lightRadius() {
    return this.#hasBlindedVisionMode ? 0 : (this.data.lightRadius ?? 0);
  }

  /* -------------------------------------------- */

  /** @override */
  get radius() {
    return (this.#hasBlindedVisionMode ? this.data.externalRadius : this.data.radius) ?? 0;
  }

  /* -------------------------------------------- */
  /*  Point Vision Source Blinded Management      */
  /* -------------------------------------------- */

  /**
   * Is this source temporarily blinded?
   * @type {boolean}
   */
  get isBlinded() {
    return (this.data.radius === 0) && ((this.data.lightRadius === 0) || !this.visionMode?.perceivesLight)
      || Object.values(this.blinded).includes(true);
  };

  /**
   * Records of blinding strings with a boolean value.
   * By default, if any of this record is true, the source is blinded.
   * @type {Record<string, boolean>}
   */
  blinded = {};

  /**
   * Data overrides that could happen with blindness vision mode.
   * @type {object}
   */
  visionModeOverrides = {};

  /* -------------------------------------------- */

  /**
   * Update darkness blinding according to darkness sources collection.
   */
  #updateBlindedState() {
    const condition = darknessSource => this.priority <= darknessSource.priority;
    this.blinded.darkness = canvas.effects.testInsideDarkness(this.origin, {condition});
  }

  /* -------------------------------------------- */

  /**
   * To know if blindness vision mode is configured for this source.
   * Note: Convenient method used to avoid calling this.blinded which is costly.
   * @returns {boolean}
   */
  get #hasBlindedVisionMode() {
    return this.visionMode === CONFIG.Canvas.visionModes.blindness;
  }

  /* -------------------------------------------- */
  /*  Vision Source Initialization                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initialize(data) {
    super._initialize(data);
    this.data.lightRadius ??= canvas.dimensions.maxR;
    if ( this.data.lightRadius > 0 ) this.data.lightRadius = Math.max(this.data.lightRadius, this.data.externalRadius);
    if ( this.data.radius > 0 ) this.data.radius = Math.max(this.data.radius, this.data.externalRadius);
    if ( !(this.data.visionMode in CONFIG.Canvas.visionModes) ) this.data.visionMode = "basic";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _createShapes() {
    this._updateVisionMode();
    super._createShapes();
    this.los = this.shape;
    this.light = this._createLightPolygon();
    this.shape = this._createRestrictedPolygon();
  }

  /* -------------------------------------------- */

  /**
   * Responsible for assigning the Vision Mode and calling the activation and deactivation handlers.
   * @protected
   */
  _updateVisionMode() {
    const previousVM = this.visionMode;
    this.visionMode = CONFIG.Canvas.visionModes[this.data.visionMode];

    // Check blinding conditions
    this.#updateBlindedState();

    // Apply vision mode according to conditions
    if ( this.isBlinded ) this.visionMode = CONFIG.Canvas.visionModes.blindness;

    // Process vision mode overrides for blindness
    const defaults = this.visionMode.vision.defaults;
    const data = this.data;
    const applyOverride = prop => this.#hasBlindedVisionMode && (defaults[prop] !== undefined) ? defaults[prop] : data[prop];
    const blindedColor = applyOverride("color");
    this.visionModeOverrides.colorRGB = blindedColor !== null ? Color.from(blindedColor).rgb : null;
    this.visionModeOverrides.brightness = applyOverride("brightness");
    this.visionModeOverrides.contrast = applyOverride("contrast");
    this.visionModeOverrides.saturation = applyOverride("saturation");
    this.visionModeOverrides.attenuation = applyOverride("attenuation");

    // Process deactivation and activation handlers
    if ( this.visionMode !== previousVM ) previousVM?.deactivate(this);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configure(changes) {
    this.visionMode.activate(this);
    super._configure(changes);
    this.animation.animation = this.visionMode.animate;
  }

  /* -------------------------------------------- */

  /** @override */
  _configureLayer(layer, layerId) {
    const vmUniforms = this.visionMode.vision[layerId].uniforms;
    layer.vmUniforms = Object.entries(vmUniforms);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getPolygonConfiguration() {
    return Object.assign(super._getPolygonConfiguration(), {
      radius: this.data.disabled || this.suppressed ? 0 : (this.blinded.darkness
        ? this.data.externalRadius : canvas.dimensions.maxR),
      useThreshold: true
    });
  }

  /* -------------------------------------------- */

  /**
   * Creates the polygon that represents light perception.
   * If the light perception radius is unconstrained, no new polygon instance is created;
   * instead the LOS polygon of this vision source is returned.
   * @returns {PointSourcePolygon}    The new polygon or `this.los`.
   * @protected
   */
  _createLightPolygon() {
    return this.#createConstrainedPolygon(this.lightRadius);
  }

  /* -------------------------------------------- */

  /**
   * Create a restricted FOV polygon by limiting the radius of the unrestricted LOS polygon.
   * If the vision radius is unconstrained, no new polygon instance is created;
   * instead the LOS polygon of this vision source is returned.
   * @returns {PointSourcePolygon}    The new polygon or `this.los`.
   * @protected
   */
  _createRestrictedPolygon() {
    return this.#createConstrainedPolygon(this.radius || this.data.externalRadius);
  }

  /* -------------------------------------------- */

  /**
   * Create a constrained polygon by limiting the radius of the unrestricted LOS polygon.
   * If the radius is unconstrained, no new polygon instance is created;
   * instead the LOS polygon of this vision source is returned.
   * @param {number} radius           The radius to constraint to.
   * @returns {PointSourcePolygon}    The new polygon or `this.los`.
   */
  #createConstrainedPolygon(radius) {
    if ( radius >= this.los.config.radius ) return this.los;
    const {x, y} = this.data;
    const circle = new PIXI.Circle(x, y, radius);
    return this.los.applyConstraint(circle);
  }

  /* -------------------------------------------- */
  /*  Shader Management                           */
  /* -------------------------------------------- */

  /** @override */
  _configureShaders() {
    const vm = this.visionMode.vision;
    const shaders = {};
    for ( const layer in this.layers ) {
      shaders[layer] = vm[`${layer.toLowerCase()}`]?.shader || this.layers[layer].defaultShader;
    }
    return shaders;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _updateColorationUniforms() {
    super._updateColorationUniforms();
    const shader = this.layers.coloration.shader;
    if ( !shader ) return;
    const u = shader?.uniforms;
    const d = shader.constructor.defaultUniforms;
    u.colorEffect = this.visionModeOverrides.colorRGB ?? d.colorEffect;
    u.useSampler = true;
    const vmUniforms = this.layers.coloration.vmUniforms;
    if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _updateIlluminationUniforms() {
    super._updateIlluminationUniforms();
    const shader = this.layers.illumination.shader;
    if ( !shader ) return;
    shader.uniforms.useSampler = false; // We don't need to use the background sampler into vision illumination
    const vmUniforms = this.layers.illumination.vmUniforms;
    if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _updateBackgroundUniforms() {
    super._updateBackgroundUniforms();
    const shader = this.layers.background.shader;
    if ( !shader ) return;
    const u = shader.uniforms;
    u.technique = 0;
    u.contrast = this.visionModeOverrides.contrast;
    u.useSampler = true;
    const vmUniforms = this.layers.background.vmUniforms;
    if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _updateCommonUniforms(shader) {
    const u = shader.uniforms;
    const d = shader.constructor.defaultUniforms;
    const c = canvas.colors;

    // Passing common environment values
    u.computeIllumination = true;
    u.darknessLevel = canvas.environment.darknessLevel;
    c.ambientBrightest.applyRGB(u.ambientBrightest);
    c.ambientDarkness.applyRGB(u.ambientDarkness);
    c.ambientDaylight.applyRGB(u.ambientDaylight);
    u.weights[0] = canvas.environment.weights.dark;
    u.weights[1] = canvas.environment.weights.halfdark;
    u.weights[2] = canvas.environment.weights.dim;
    u.weights[3] = canvas.environment.weights.bright;
    u.dimLevelCorrection = this.constructor._dimLightingLevel;
    u.brightLevelCorrection = this.constructor._brightLightingLevel;

    // Vision values
    const attenuation = this.visionModeOverrides.attenuation;
    u.attenuation = Math.max(attenuation, 0.0125);
    const brightness = this.visionModeOverrides.brightness;
    u.brightness = (brightness + 1) / 2;
    u.saturation = this.visionModeOverrides.saturation;
    u.linkedToDarknessLevel = this.visionMode.vision.darkness.adaptive;

    // Other values
    u.elevation = this.data.elevation;
    u.screenDimensions = canvas.screenDimensions;
    u.colorTint = this.visionModeOverrides.colorRGB ?? d.colorTint;

    // Textures
    if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
    if ( !u.primaryTexture ) u.primaryTexture = canvas.primary.renderTexture;
    if ( !u.darknessLevelTexture ) u.darknessLevelTexture = canvas.effects.illumination.renderTexture;
  }

  /* -------------------------------------------- */

  /**
   * Update layer uniforms according to vision mode uniforms, if any.
   * @param {AdaptiveVisionShader} shader        The shader being updated.
   * @param {Record<string, any>} vmUniforms     The targeted layer.
   * @protected
   */
  _updateVisionModeUniforms(shader, vmUniforms) {
    const shaderUniforms = shader.uniforms;
    for ( const [uniform, value] of vmUniforms ) {
      if ( Array.isArray(value) ) {
        const u = (shaderUniforms[uniform] ??= []);
        for ( const i in value ) u[i] = value[i];
      }
      else shaderUniforms[uniform] = 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 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 shaderSchema = () => new SchemaField({
      shader: new ShaderField(),
      uniforms: new ObjectField()
    });
    const lightingSchema = () => new SchemaField({
      visibility: new NumberField({
        initial: this.LIGHTING_VISIBILITY.ENABLED,
        choices: Object.values(this.LIGHTING_VISIBILITY)
      }),
      postProcessingModes: new ArrayField(new StringField()),
      uniforms: new ObjectField()
    });

    // Return model schema
    return {
      id: new StringField({blank: false}),
      label: new StringField({blank: false}),
      tokenConfig: new BooleanField({initial: true}),
      canvas: new SchemaField({
        shader: new ShaderField(),
        uniforms: new ObjectField()
      }),
      lighting: new SchemaField({
        background: lightingSchema(),
        coloration: lightingSchema(),
        illumination: lightingSchema(),
        darkness: lightingSchema(),
        levels: new ObjectField({
          validate: o => {
            const values = Object.values(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 ObjectField({
          validate: o => {
            const values = Object.values(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 SchemaField({
        background: shaderSchema(),
        coloration: shaderSchema(),
        illumination: shaderSchema(),
        darkness: new SchemaField({
          adaptive: new BooleanField({initial: true})
        }),
        defaults: new SchemaField({
          color: new ColorField({required: false, initial: undefined}),
          attenuation: new AlphaField({required: false, initial: undefined}),
          brightness: new NumberField({required: false, initial: undefined, nullable: false, min: -1, max: 1}),
          saturation: new NumberField({required: false, initial: undefined, nullable: false, min: -1, max: 1}),
          contrast: new NumberField({required: false, initial: undefined, nullable: false, min: -1, max: 1})
        }),
        preferred: new BooleanField({initial: false})
      })
    };
  }

  /**
   * The lighting illumination levels which are supported.
   * @enum {number}
   */
  static LIGHTING_LEVELS = 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 {PointVisionSource} source   Activate this VisionMode for a specific source
   * @abstract
   */
  _activate(source) {}

  /**
   * Special deactivation handling that could be implemented by VisionMode subclasses
   * @param {PointVisionSource} source   Deactivate this VisionMode for a specific source
   * @abstract
   */
  _deactivate(source) {}

  /**
   * Special handling which is needed when this Vision Mode is activated for a PointVisionSource.
   * @param {PointVisionSource} 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 PointVisionSource.
   * @param {PointVisionSource} 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 PointVisionSource.prototype.animateTime.call(this, dt);
  }
}

/**
 * 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 = 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 = 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 AdaptiveIlluminationShader extends AdaptiveLightingShader {

  /** @override */
  static FRAGMENT_END = `
  gl_FragColor = vec4(mix(computedBackgroundColor, 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`;
  }

  /** @override */
  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}
  `;

  /** @override */
  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,
    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 = 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;
  }
}

/**
 * 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
  };
}

/**
 * @import {BaseEffectSourceData} from "./base-effect-source.mjs";
 * @import {RenderedEffectSourceData} from "./rendered-effect-source.mjs";
 */

/**
 * @typedef LightSourceData
 * @property {number} alpha               An opacity for the emitted light, if any
 * @property {number} bright              The allowed radius of bright vision or illumination
 * @property {number} coloration          The coloration technique applied in the shader
 * @property {number} contrast            The amount of contrast this light applies to the background texture
 * @property {number} dim                 The allowed radius of dim vision or illumination
 * @property {number} attenuation         Strength of the attenuation between bright, dim, and dark
 * @property {number} luminosity          The luminosity applied in the shader
 * @property {number} saturation          The amount of color saturation this light applies to the background texture
 * @property {number} shadows             The depth of shadows this light applies to the background texture
 * @property {boolean} vision             Whether or not this source provides a source of vision
 */

/**
 * A specialized subclass of BaseEffectSource which deals with the rendering of light or darkness.
 * @extends {RenderedEffectSource<BaseEffectSourceData & RenderedEffectSourceData & LightSourceData>}
 * @abstract
 */
class BaseLightSource extends RenderedEffectSource {

  /** @override */
  static sourceType = "light";

  /** @override */
  static _initializeShaderKeys = ["animation.type", "walls"];

  /** @override */
  static _refreshUniformsKeys = ["dim", "bright", "attenuation", "alpha", "coloration", "color", "contrast",
    "saturation", "shadows", "luminosity"];

  /**
   * The corresponding lighting levels for dim light.
   * @type {number}
   * @protected
   */
  static _dimLightingLevel = LIGHTING_LEVELS.DIM;

  /**
   * The corresponding lighting levels for bright light.
   * @type {string}
   * @protected
   */
  static _brightLightingLevel = LIGHTING_LEVELS.BRIGHT;

  /**
   * The corresponding animation config.
   * @type {LightSourceAnimationConfig}
   * @protected
   */
  static get ANIMATIONS() {
    return CONFIG.Canvas.lightAnimations;
  }

  /** @override */
  static get _layers() {
    return {
      background: {
        defaultShader: AdaptiveBackgroundShader,
        blendMode: "MAX_COLOR"
      },
      coloration: {
        defaultShader: AdaptiveColorationShader,
        blendMode: "SCREEN"
      },
      illumination: {
        defaultShader: AdaptiveIlluminationShader,
        blendMode: "MAX_COLOR"
      }
    };
  }

  /** @inheritDoc */
  static defaultData = {
    ...super.defaultData,
    alpha: 0.5,
    bright: 0,
    coloration: 1,
    contrast: 0,
    dim: 0,
    attenuation: 0.5,
    luminosity: 0.5,
    saturation: 0,
    shadows: 0,
    vision: false
  }

  /* -------------------------------------------- */
  /*  Internal Animation Properties               */
  /* -------------------------------------------- */

  /**
   * Internal animation data, which could track various specific animation data.
   * @type {object}
   */
  #animationData = {};

  /* -------------------------------------------- */
  /*  Light Source Attributes                     */
  /* -------------------------------------------- */

  /**
   * A ratio of dim:bright as part of the source radius
   * @type {number}
   */
  ratio = 1;

  /* -------------------------------------------- */
  /*  Light Source Initialization                 */
  /* -------------------------------------------- */

  /** @override */
  _initialize(data) {
    super._initialize(data);
    const animationConfig = foundry.utils.deepClone(this.constructor.ANIMATIONS[this.data.animation.type] || {});
    this.animation = Object.assign(this.data.animation, animationConfig);
  }

  /* -------------------------------------------- */
  /*  Shader Management                           */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _updateColorationUniforms() {
    super._updateColorationUniforms();
    const u = this.layers.coloration.shader?.uniforms;
    if ( !u ) return;

    // Adapting color intensity to the coloration technique
    switch ( this.data.coloration ) {
      case 0: // Legacy
              // Default 0.25 -> Legacy technique needs quite low intensity default to avoid washing background
        u.colorationAlpha = Math.pow(this.data.alpha, 2);
        break;
      case 4: // Color burn
      case 5: // Internal burn
      case 6: // External burn
      case 9: // Invert absorption
              // Default 0.5 -> These techniques are better at low color intensity
        u.colorationAlpha = this.data.alpha;
        break;
      default:
        // Default 1 -> The remaining techniques use adaptive lighting,
        // which produces interesting results in the [0, 2] range.
        u.colorationAlpha = this.data.alpha * 2;
    }

    u.useSampler = this.data.coloration > 0;  // Not needed for legacy coloration (technique id 0)

    // Flag uniforms as updated
    this.layers.coloration.reset = false;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _updateIlluminationUniforms() {
    super._updateIlluminationUniforms();
    const u = this.layers.illumination.shader?.uniforms;
    if ( !u ) return;
    u.useSampler = false;

    // Flag uniforms as updated
    const i = this.layers.illumination;
    i.reset = i.suppressed = false;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _updateBackgroundUniforms() {
    super._updateBackgroundUniforms();
    const u = this.layers.background.shader?.uniforms;
    if ( !u ) return;

    canvas.colors.background.applyRGB(u.colorBackground);
    u.backgroundAlpha = this.data.alpha;
    u.useSampler = true;

    // Flag uniforms as updated
    this.layers.background.reset = false;
  }

  /* -------------------------------------------- */

  /** @override */
  _updateCommonUniforms(shader) {
    const u = shader.uniforms;
    const c = canvas.colors;

    // Passing common environment values
    u.computeIllumination = true;
    u.darknessLevel = canvas.environment.darknessLevel;
    c.ambientBrightest.applyRGB(u.ambientBrightest);
    c.ambientDarkness.applyRGB(u.ambientDarkness);
    c.ambientDaylight.applyRGB(u.ambientDaylight);
    u.weights[0] = canvas.environment.weights.dark;
    u.weights[1] = canvas.environment.weights.halfdark;
    u.weights[2] = canvas.environment.weights.dim;
    u.weights[3] = canvas.environment.weights.bright;
    u.dimLevelCorrection = this.constructor.getCorrectedLevel(this.constructor._dimLightingLevel);
    u.brightLevelCorrection = this.constructor.getCorrectedLevel(this.constructor._brightLightingLevel);

    // Passing advanced color correction values
    u.luminosity = this.data.luminosity;
    u.exposure = this.data.luminosity * 2.0 - 1.0;
    u.contrast = (this.data.contrast < 0 ? this.data.contrast * 0.5 : this.data.contrast);
    u.saturation = this.data.saturation;
    u.shadows = this.data.shadows;
    u.hasColor = this._flags.hasColor;
    u.ratio = this.ratio;
    u.technique = this.data.coloration;
    // Graph: https://www.desmos.com/calculator/e7z0i7hrck
    // mapping [0,1] attenuation user value to [0,1] attenuation shader value
    if ( this.cachedAttenuation !== this.data.attenuation ) {
      this.computedAttenuation = (Math.cos(Math.PI * Math.pow(this.data.attenuation, 1.5)) - 1) / -2;
      this.cachedAttenuation = this.data.attenuation;
    }
    u.attenuation = this.computedAttenuation;
    u.elevation = this.data.elevation;
    u.color = this.colorRGB ?? shader.constructor.defaultUniforms.color;

    // Passing screenDimensions to use screen size render textures
    u.screenDimensions = canvas.screenDimensions;
    if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
    if ( !u.primaryTexture ) u.primaryTexture = canvas.primary.renderTexture;
    if ( !u.darknessLevelTexture ) u.darknessLevelTexture = canvas.effects.illumination.renderTexture;
  }

  /* -------------------------------------------- */
  /*  Animation Functions                         */
  /* -------------------------------------------- */

  /**
   * An animation with flickering ratio and light intensity.
   * @param {number} dt                       Delta time
   * @param {object} [options={}]             Additional options which modify the flame animation
   * @param {number} [options.speed=5]        The animation speed, from 0 to 10
   * @param {number} [options.intensity=5]    The animation intensity, from 1 to 10
   * @param {boolean} [options.reverse=false] Reverse the animation direction
   */
  animateTorch(dt, {speed=5, intensity=5, reverse=false} = {}) {
    this.animateFlickering(dt, {speed, intensity, reverse, amplification: intensity / 5});
  }

  /* -------------------------------------------- */

  /**
   * An animation with flickering ratio and light intensity
   * @param {number} dt                                 Delta time
   * @param {object} [options={}]                       Additional options which modify the flame animation
   * @param {number} [options.speed=5]                  The animation speed, from 0 to 10
   * @param {number} [options.intensity=5]              The animation intensity, from 1 to 10
   * @param {number} [options.amplification=1]          Noise amplification (>1) or dampening (<1)
   * @param {boolean} [options.reverse=false]           Reverse the animation direction
   */
  animateFlickering(dt, {speed=5, intensity=5, reverse=false, amplification=1} = {}) {
    this.animateTime(dt, {speed, intensity, reverse});

    // Create the noise object for the first frame
    const amplitude = amplification * 0.45;
    /** @type {SmoothNoise} */
    const noise = this.#animationData.noise ??= new SmoothNoise({amplitude: amplitude, scale: 3, maxReferences: 2048});

    // Update amplitude
    if ( noise.amplitude !== amplitude ) noise.amplitude = amplitude;

    // Create noise from animation time. Range [0.0, 0.45]
    let n = noise.generate(this.animation.time);

    // Update brightnessPulse and ratio with some noise in it
    const co = this.layers.coloration.shader;
    const il = this.layers.illumination.shader;
    co.uniforms.brightnessPulse = il.uniforms.brightnessPulse = 0.55 + n;    // Range [0.55, 1.0 <* amplification>]
    co.uniforms.ratio = il.uniforms.ratio = (this.ratio * 0.9) + (n * 0.222);// Range [ratio * 0.9, ratio * ~1.0 <* amplification>]
  }

  /* -------------------------------------------- */

  /**
   * A basic "pulse" animation which expands and contracts.
   * @param {number} dt                           Delta time
   * @param {object} [options={}]                 Additional options which modify the pulse animation
   * @param {number} [options.speed=5]              The animation speed, from 0 to 10
   * @param {number} [options.intensity=5]          The animation intensity, from 1 to 10
   * @param {boolean} [options.reverse=false]       Reverse the animation direction
   */
  animatePulse(dt, {speed=5, intensity=5, reverse=false}={}) {

    // Determine the animation timing
    let t = canvas.app.ticker.lastTime;
    if ( reverse ) t *= -1;
    this.animation.time = ((speed * t)/5000) + this.animation.seed;

    // Define parameters
    const i = (10 - intensity) * 0.1;
    const w = 0.5 * (Math.cos(this.animation.time * 2.5) + 1);
    const wave = (a, b, w) => ((a - b) * w) + b;

    // Pulse coloration
    const co = this.layers.coloration.shader;
    co.uniforms.intensity = intensity;
    co.uniforms.time = this.animation.time;
    co.uniforms.pulse = wave(1.2, i, w);

    // Pulse illumination
    const il = this.layers.illumination.shader;
    il.uniforms.intensity = intensity;
    il.uniforms.time = this.animation.time;
    il.uniforms.ratio = wave(this.ratio, this.ratio * i, w);
  }

  /* -------------------------------------------- */

  /**
   * A sound-reactive animation that uses bass/mid/treble blending to control certain shader uniforms.
   * "speed" is interpreted as how quickly we adapt to changes in audio. No time-based pulsing is used by default,
   * but we incorporate dt into smoothing so that behavior is consistent across varying frame rates.
   *
   * @param {number} dt                       The delta time since the last frame, in milliseconds.
   * @param {object} [options={}]             Additional options for customizing the audio reaction.
   * @param {number} [options.speed=5]        A smoothing factor in [0..10], effectively updates/second.
   * @param {number} [options.intensity=5]    A blend factor in [0..10] that transitions from bass (near 0) to treble (near 10)
   *                                          Mid frequencies dominate around intensity=5.
   * @param {boolean} [options.reverse=false] Whether to invert the final amplitude as 1 - amplitude.
   */
  animateSoundPulse(dt, {speed=5, intensity=5, reverse=false}={}) {
    this.#animationData.reactiveSoundAmplitude ??= 0;

    // Capture bass, mid, treble
    let bassVal = Math.pow(game.audio.getMaxBandLevel("bass", {ignoreVolume: true}), 1.5);
    let midVal  = Math.pow(game.audio.getMaxBandLevel("mid",  {ignoreVolume: true}), 1.5);
    let trebVal = Math.pow(game.audio.getMaxBandLevel("treble",{ignoreVolume: true}), 1.5);

    // Blend frequencies: 0 => bass, 5 => mid, 10 => treble (approximatly)
    const i = Math.clamp(intensity, 0, 10) / 10;
    const finalVal = (i <= 0.5)
      ? Math.mix(bassVal, midVal, i * 2)
      : Math.mix(midVal, trebVal, (i - 0.5) * 2);

    // Apply exponential smoothing with dt to ensure consistent animation speed across different frame rates
    const smoothing = 1 - Math.exp(-speed * dt * 0.085);

    // Smooth amplitude toward finalVal
    this.#animationData.reactiveSoundAmplitude += (finalVal - this.#animationData.reactiveSoundAmplitude) * smoothing;

    // Optionally invert amplitude
    let amplitude = reverse ? 1 - this.#animationData.reactiveSoundAmplitude : this.#animationData.reactiveSoundAmplitude;
    amplitude = amplitude * this.ratio;

    // Update your shader uniforms
    const co = this.layers.coloration.shader;
    co.uniforms.intensity = intensity;
    co.uniforms.pulse = amplitude;

    const il = this.layers.illumination.shader;
    il.uniforms.intensity = intensity;
    il.uniforms.ratio = Math.clamp(amplitude * 1.11, 0, 1);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get isDarkness() {
    const msg = "BaseLightSource#isDarkness is now obsolete. Use DarknessSource instead.";
    foundry.utils.logCompatibilityWarning(msg, { since: 12, until: 14});
    return false;
  }
}

/**
 * A specialized subclass of the BaseLightSource which is used to render global light source linked to the scene.
 */
class GlobalLightSource extends BaseLightSource {

  /** @inheritDoc */
  static sourceType = "GlobalLight";

  /** @override */
  static effectsCollection = "lightSources";

  /** @inheritDoc */
  static defaultData = {
    ...super.defaultData,
    rotation: 0,
    angle: 360,
    attenuation: 0,
    priority: -Infinity,
    vision: false,
    walls: false,
    elevation: Infinity,
    darkness: {min: 0, max: 0}
  }

  /**
   * Name of this global light source.
   * @type {string}
   * @defaultValue GlobalLightSource.sourceType
   */
  name = this.constructor.sourceType;

  /**
   * A custom polygon placeholder.
   * @type {PIXI.Polygon|number[]|null}
   */
  customPolygon = null;

  /* -------------------------------------------- */
  /*  Global Light Source Initialization          */
  /* -------------------------------------------- */

  /** @override */
  _createShapes() {
    this.shape = this.customPolygon ?? canvas.dimensions.sceneRect.toPolygon();
  }

  /* -------------------------------------------- */

  /** @override */
  _initializeSoftEdges() {
    this._flags.renderSoftEdges = false;
  }

  /* -------------------------------------------- */

  /** @override */
  _updateGeometry() {
    const offset = this._flags.renderSoftEdges ? this.constructor.EDGE_OFFSET * (canvas.grid.size / 100) : 0;
    const pm = new PolygonMesher(this.shape, {offset});
    this._geometry = pm.triangulate(this._geometry);
    const bounds = this.shape instanceof PointSourcePolygon ? this.shape.bounds : this.shape.getBounds();
    if ( this._geometry.bounds ) this._geometry.bounds.copyFrom(bounds);
    else this._geometry.bounds = bounds;
  }


  /* -------------------------------------------- */

  /** @override */
  _updateCommonUniforms(shader) {
    super._updateCommonUniforms(shader);
    const {min, max} = this.data.darkness;
    const u = shader.uniforms;
    u.globalLight = true;
    u.globalLightThresholds[0] = min;
    u.globalLightThresholds[1] = max;
  }
}

/**
 * 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 = 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}
  }`;
}

/**
 * A specialized subclass of the BaseLightSource which renders a source of darkness as a point-based effect.
 * @extends {BaseLightSource}
 * @mixes PointEffectSource
 */
class PointDarknessSource extends PointEffectSourceMixin(BaseLightSource) {

  /** @override */
  static sourceType = "darkness";

  /** @override */
  static effectsCollection = "darknessSources";

  /** @override */
  static _dimLightingLevel = LIGHTING_LEVELS.HALFDARK;

  /** @override */
  static _brightLightingLevel = LIGHTING_LEVELS.DARKNESS;

  /** @override */
  static get ANIMATIONS() {
    return CONFIG.Canvas.darknessAnimations;
  }

  /** @override */
  static get _layers() {
    return {
      darkness: {
        defaultShader: AdaptiveDarknessShader,
        blendMode: "MAX_COLOR"
      }
    };
  }

  /**
   * The optional geometric shape is solely utilized for visual representation regarding darkness sources.
   * Used only when an additional radius is added for visuals.
   * @type {SourceShape}
   * @protected
   */
  _visualShape;

  /**
   * Padding applied on the darkness source shape for visual appearance only.
   * Note: for now, padding is increased radius. It might evolve in a future release.
   * @type {number}
   * @protected
   */
  _padding = (CONFIG.Canvas.darknessSourcePaddingMultiplier ?? 0) * canvas.grid.size;

  /**
   * The normalized border distance.
   * @type {number}
   */
  #borderDistance = 0;

  /* -------------------------------------------- */
  /*  Darkness Source Properties                  */
  /* -------------------------------------------- */

  /** @override */
  get requiresEdges() {
    return true;
  }

  /**
   * A convenience accessor to the darkness layer mesh.
   * @type {PointSourceMesh}
   */
  get darkness() {
    return this.layers.darkness.mesh;
  }

  /* -------------------------------------------- */
  /*  Source Suppression Management               */
  /* -------------------------------------------- */

  /**
   * Update light suppression according to light sources collection.
   */
  #updateLightSuppression() {
    const condition = lightSource => this.priority < lightSource.priority;
    this.suppression.light = canvas.effects.testInsideLight(this.origin, {condition});
  }

  /* -------------------------------------------- */
  /*  Visibility Testing                          */
  /* -------------------------------------------- */

  /** @override */
  testPoint(point) {
    const shape = this.shape;
    if ( !shape ) return false;
    const {x, y} = point;
    for ( let dx = -1; dx <= 1; dx += 1 ) {
      for ( let dy = -1; dy <= 1; dy += 1 ) {
        if ( shape.contains(x + dx, y + dy) ) return true;
      }
    }
    return false;
  }

  /* -------------------------------------------- */
  /*  Source Initialization and Management        */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initialize(data) {
    super._initialize(data);
    this.data.radius = this.data.bright = this.data.dim = Math.max(this.data.dim ?? 0, this.data.bright ?? 0);
    this.#borderDistance = Number.isFinite(this.radius) ? this.radius / (this.radius + this._padding) : 1;
  }

  /* -------------------------------------------- */


  /** @override */
  _createShapes() {
    this._deleteEdges();
    this.#updateLightSuppression();
    const config = this._getPolygonConfiguration();
    const polygonClass = CONFIG.Canvas.polygonBackends[this.constructor.sourceType];

    // Create shapes based on padding
    if ( this.radius < config.radius ) {
      this._visualShape = polygonClass.create(this.origin, config);
      this.shape = this.#createShapeFromVisualShape(this.radius);
    }
    else {
      this._visualShape = null;
      this.shape = polygonClass.create(this.origin, config);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getPolygonConfiguration() {
    return Object.assign(super._getPolygonConfiguration(), {
      useThreshold: true,
      radius: (this.data.disabled || this.suppressed) ? 0 : this.radius + this._padding
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _drawMesh(layerId) {
    const mesh = super._drawMesh(layerId);
    if ( mesh ) mesh.scale.set((this._visualShape ?? this.shape).config.radius);
    return mesh;
  }

  /* -------------------------------------------- */

  /** @override */
  _updateGeometry() {
    const shape = this._visualShape ?? this.shape;
    const {x, y} = shape.origin;
    const radius = shape.config.radius;
    const offset = this._flags.renderSoftEdges ? this.constructor.EDGE_OFFSET * (canvas.grid.size / 100) : 0;
    const pm = new PolygonMesher(shape, {x, y, radius, normalize: true, offset});
    this._geometry = pm.triangulate(this._geometry);
    const bounds = new PIXI.Rectangle(0, 0, 0, 0);
    if ( radius > 0 ) {
      const b = shape instanceof PointSourcePolygon ? shape.bounds : shape.getBounds();
      bounds.x = (b.x - x) / radius;
      bounds.y = (b.y - y) / radius;
      bounds.width = b.width / radius;
      bounds.height = b.height / radius;
    }
    if ( this._geometry.bounds ) this._geometry.bounds.copyFrom(bounds);
    else this._geometry.bounds = bounds;
  }

  /* -------------------------------------------- */

  /**
   * Create a radius constrained polygon from the visual shape polygon.
   * If the visual shape is not created, no polygon is created.
   * @param {number} radius           The radius to constraint to.
   * @returns {PointSourcePolygon} The new polygon or null if no visual shape is present.
   */
  #createShapeFromVisualShape(radius) {
    if ( !this._visualShape ) return null;
    const {x, y} = this.data;
    const circle = new PIXI.Circle(x, y, radius);
    return this._visualShape.applyConstraint(circle);
  }

  /* -------------------------------------------- */
  /*  Shader Management                           */
  /* -------------------------------------------- */

  /**
   * Update the uniforms of the shader on the darkness layer.
   * @protected
   */
  _updateDarknessUniforms() {
    const u = this.layers.darkness.shader?.uniforms;
    if ( !u ) return;
    u.color = this.colorRGB ?? this.layers.darkness.shader.constructor.defaultUniforms.color;
    u.enableVisionMasking = canvas.scene.tokenVision && (canvas.effects.visionSources.some(s => s.active) || !game.user.isGM);
    u.borderDistance = this.#borderDistance;
    u.colorationAlpha = this.data.alpha * 2;

    // Passing screenDimensions to use screen size render textures
    u.screenDimensions = canvas.screenDimensions;
    if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
    if ( !u.primaryTexture ) u.primaryTexture = canvas.primary.renderTexture;
    if ( !u.visionTexture ) u.visionTexture = canvas.masks.vision.renderTexture;

    // Flag uniforms as updated
    this.layers.darkness.reset = false;
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get isDarkness() {
    const msg = "BaseLightSource#isDarkness is now obsolete. Use DarknessSource instead.";
    foundry.utils.logCompatibilityWarning(msg, { since: 12, until: 14});
    return true;
  }
}

/**
 * @import {CanvasVisibilityTestConfiguration} from "../../_types.mjs";
 */

/**
 * A specialized subclass of the BaseLightSource which renders a source of light as a point-based effect.
 * @extends {BaseLightSource}
 * @mixes PointEffectSourceMixin
 */
class PointLightSource extends PointEffectSourceMixin(BaseLightSource) {

  /** @override */
  static effectsCollection = "lightSources";

  /** @override */
  get requiresEdges() {
    return this.priority > 0;
  }

  /* -------------------------------------------- */
  /*  Source Suppression Management               */
  /* -------------------------------------------- */

  /**
   * Update darkness suppression according to darkness sources collection.
   */
  #updateDarknessSuppression() {
    const condition = darknessSource => this.priority <= darknessSource.priority;
    this.suppression.darkness = canvas.effects.testInsideDarkness(this.origin, {condition});
  }

  /* -------------------------------------------- */
  /*  Light Source Initialization                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initialize(data) {
    super._initialize(data);
    Object.assign(this.data, {
      radius: Math.max(this.data.dim ?? 0, this.data.bright ?? 0)
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _createShapes() {
    this.#updateDarknessSuppression();
    super._createShapes();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configure(changes) {
    this.ratio = Math.clamp(Math.abs(this.data.bright) / this.data.radius, 0, 1);
    super._configure(changes);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getPolygonConfiguration() {
    return Object.assign(super._getPolygonConfiguration(), {
      useThreshold: true
    });
  }

  /* -------------------------------------------- */
  /*  Visibility Testing                          */
  /* -------------------------------------------- */

  /**
   * Test whether this LightSource provides visibility to see a certain target object.
   * @param {CanvasVisibilityTestConfiguration} config    The visibility test configuration
   * @returns {boolean}                                   Is the target object visible to this source?
   */
  testVisibility({tests, object}) {
    if ( !(this.data.vision && this._canDetectObject(object)) ) return false;
    return tests.some(test => this.shape.contains(test.point.x, test.point.y));
  }

  /* -------------------------------------------- */

  /**
   * Can this LightSource theoretically detect a certain object based on its properties?
   * This check should not consider the relative positions of either object, only their state.
   * @param {PlaceableObject} target      The target object being tested
   * @returns {boolean}                   Can the target object theoretically be detected by this vision source?
   * @protected
   */
  _canDetectObject(target) {
    const tgt = target?.document;
    const isInvisible = ((tgt instanceof TokenDocument) && tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE));
    return !isInvisible;
  }
}

/**
 * A specialized subclass of the BaseEffectSource which describes a movement-based source.
 * @extends {BaseEffectSource}
 * @mixes PointEffectSource
 */
class PointMovementSource extends PointEffectSourceMixin(BaseEffectSource) {

  /** @override */
  static sourceType = "move";
}

/**
 * @import {ElevatedPoint} from "../../_types.mjs";
 */

/**
 * A specialized subclass of the BaseEffectSource which describes a point-based source of sound.
 * @extends {BaseEffectSource}
 * @mixes PointEffectSource
 */
class PointSoundSource extends PointEffectSourceMixin(BaseEffectSource) {

  /** @override */
  static sourceType = "sound";

  /** @override */
  get effectsCollection() {
    return canvas.sounds.sources;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getPolygonConfiguration() {
    return Object.assign(super._getPolygonConfiguration(), {useThreshold: true});
  }

  /* -------------------------------------------- */

  /**
   * Get the effective volume at which an AmbientSound source should be played for a certain listener.
   * @param {ElevatedPoint} listener
   * @param {object} [options]
   * @param {boolean} [options.easing]
   * @returns {number}
   */
  getVolumeMultiplier(listener, {easing=true}={}) {
    /** @deprecated since v13 */
    if ( listener.elevation === undefined ) {
      foundry.utils.logCompatibilityWarning("PointSoundSource#getVolumeMultiplier(Point) has been deprecated "
        + "in favor of PointSoundSource#getVolumeMultiplier(ElevatedPoint).", {since: 13, until: 15, once: true});
      listener = {x: listener.x, y: listener.y, elevation: 0};
    }
    if ( !listener ) return 0;                                             // No listener = 0
    const {x, y, radius} = this.data;
    const distance = Math.hypot(listener.x - x, listener.y - y);
    if ( distance === 0 ) return 1;
    if ( distance > radius ) return 0;                                     // Distance outside of radius = 0
    if ( !this.testPoint(listener) ) return 0;                             // Point outside of source = 0
    if ( !easing ) return 1;                                               // No easing = 1
    return (Math.cos(Math.PI * (distance / radius)) + 1) * 0.5;            // Cosine easing [0, 1]
  }
}

var _module$r = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BaseEffectSource: BaseEffectSource,
  BaseLightSource: BaseLightSource,
  GlobalLightSource: GlobalLightSource,
  PointDarknessSource: PointDarknessSource,
  PointEffectSourceMixin: PointEffectSourceMixin,
  PointLightSource: PointLightSource,
  PointMovementSource: PointMovementSource,
  PointSoundSource: PointSoundSource,
  PointVisionSource: PointVisionSource,
  RenderedEffectSource: RenderedEffectSource
});

var _module$q = /*#__PURE__*/Object.freeze({
  __proto__: null,
  TextureCompressor: TextureCompressor
});

/**
 * @typedef GridMeshData
 * The grid mesh data.
 * @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
 */

var _types$a = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * 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++;
    }
  }
}

/**
 * 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();
  }
}

/**
 * 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
 */
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;
    }
  };
}

/**
 * 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);
    const s = canvas.dimensions.uiScale;

    // Define arguments
    this.iconSrc = texture;
    this.size = size;
    this.rect = [-2 * s, -2 * s, size + (4 * s), size + (4 * s)];
    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.beginFill(0x000000, 0.4);
    this.bg.lineStyle(2 * s, 0x000000, 1.0);
    this.bg.drawRoundedRect(...this.rect, 5 * s);
    this.bg.endFill();

    // Icon
    this.icon = this.addChild(new PIXI.Sprite());

    // Border
    this.border = this.addChild(new PIXI.Graphics());
    this.border.lineStyle(2 * s, 0xFFFFFF, 1.0);
    this.border.drawRoundedRect(...this.rect, 5 * s);
    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;
    value = value.toNearest(0.01);
    this.tooltip.text = `${value.signedString()} ${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;
    const s = canvas.dimensions.uiScale;
    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.scale.set(s, s);
    this.tooltip.position.set(this.size / 2, -12 * s);
    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.tint = this.borderColor;
    if ( borderVisible !== undefined ) this.border.visible = borderVisible;
    if ( visible !== undefined && (this.visible !== visible) ) {
      this.visible = visible;
      MouseInteractionManager.emulateMoveEvent();
    }
    return this;
  }
}

/**
 * @import {Point} from "@common/_types.mjs";
 */

/**
 * A single Mouse Cursor
 */
class Cursor extends PIXI.Container {
  constructor(user) {
    super();
    this.draw(user);
  }

  /* -------------------------------------------- */

  /**
   * The target cursor position.
   * @type {Point}
   */
  target = {x: 0, y: 0};

  /* -------------------------------------------- */

  /**
   * The current cursor position.
   * @type {Point}
   */
  #current = {x: 0, y: 0};

  /* -------------------------------------------- */

  /**
   * To know if this cursor is animated
   * @type {boolean}
   */
  #animating;

  /* -------------------------------------------- */

  /**
   * Update the position of this cursor based on the current position?
   * @type {boolean}
   * @internal
   */
  _updatePosition = true;

  /* -------------------------------------------- */

  /** @override */
  updateTransform() {
    if ( this._updatePosition ) {
      this._updatePosition = false;
      canvas.app.stage.worldTransform.apply(this.#current, this.position);
    }
    super.updateTransform();
  }

  /* -------------------------------------------- */

  /**
   * 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
   * @param {User} user
   */
  draw(user) {
    const s = game.settings.get("core", "uiConfig").uiScale;

    // Cursor dot
    const d = this.addChild(new PIXI.Graphics());
    d.beginFill(user.color, 0.35).lineStyle(s, 0x000000, 0.5).drawCircle(0, 0, 6 * s);

    // Player name
    const style = CONFIG.canvasTextStyle.clone();
    style.fontSize = 14;
    const n = this.addChild(new PreciseText(user.name, style));
    n.x -= n.width / 2 * s;
    n.y += 10 * s;
    n.scale.set(s, s);

    // Refresh
    this.refreshVisibility(user);
  }

  /* -------------------------------------------- */

  /**
   * Move an existing cursor to a new position smoothly along the animation loop
   */
  #animate() {
    const dx = this.target.x - this.#current.x;
    const dy = this.target.y - this.#current.y;
    if ( !(dx || dy) ) return;
    if ( Math.abs(dx) + Math.abs(dy) < 0.5 / CONFIG.Canvas.maxZoom ) {
      this.#current.x = this.target.x;
      this.#current.y = this.target.y;
    } else {
      this.#current.x += dx / 10;
      this.#current.y += dy / 10;
    }
    this._updatePosition = true;
  }

  /* -------------------------------------------- */

  /** @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() {
    const s = canvas.dimensions.uiScale;

    // Background
    this.bg = this.bg || this.addChild(new PIXI.Graphics());
    this.bg.clear().beginFill(0x000000, 1.0).drawRoundedRect(-2 * s, -2 * s, 44 * s, 44 * s, 5 * s).endFill();
    this.bg.alpha = 0;

    // Control Icon
    this.icon = this.icon || this.addChild(new PIXI.Sprite());
    this.icon.width = this.icon.height = 40 * s;
    this.icon.alpha = 0.6;
    this.icon.texture = this._getTexture();

    // Border
    this.border = this.border || this.addChild(new PIXI.Graphics());
    this.border.clear().lineStyle(s, 0xFF5500, 0.8).drawRoundedRect(-2 * s, -2 * s, 44 * s, 44 * s, 5 * s);
    this.border.visible = false;

    // Add control interactivity
    this.eventMode = "static";
    this.interactiveChildren = false;
    this.hitArea = new PIXI.Rectangle(-2 * s, -2 * s, 44 * s, 44 * s);
    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}
   * @protected
   */
  _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() {
    const s = canvas.dimensions.uiScale;
    const pos = this.wall.midpoint.map(p => p - (20 * s));
    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) {
    if ( event.nativeEvent && (event.nativeEvent.target.id !== canvas.app.view.id) ) return;
    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) {
    if ( event.nativeEvent && (event.nativeEvent.target.id !== canvas.app.view.id) ) return;
    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("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("ALT"));
    return this.wall.document.update({ds: state}, {sound});
  }
}

/**
 * @import {PrimarySpriteMeshConstructorOptions} from "../../primary/primary-sprite-mesh.mjs";
 * @import {CanvasAnimationData, CanvasAnimationAttribute} from "../../animation/_types.mjs";
 * @import {WallDoorAnimationConfig} from "@client/config.mjs";
 */

/**
 * @typedef DoorAnimationConfiguration
 * @property {number} [direction=1]
 * @property {boolean} [double=false]
 * @property {number} [duration=500]
 * @property {boolean} [flip=false]
 * @property {number} [strength=1.0]
 * @property {keyof typeof CONFIG.Wall.animationTypes} [type="swing"]
 * @property {DoorStyle} [style]
 * @property {WallDoorAnimationConfig} config
 */

/**
 * @typedef DoorStateSnapshot
 * @property {number} x
 * @property {number} y
 * @property {number} elevation
 * @property {number} sort
 * @property {number} rotation
 * @property {number} scaleX
 * @property {number} scaleY
 * @property {number} tint
 * @property {number} alpha
 */

/**
 * @typedef {typeof DoorMesh.DOOR_STYLES[keyof typeof DoorMesh.DOOR_STYLES]} DoorStyle
 */

/**
 * A special subclass of PrimarySpriteMesh used to render an interactive door.
 */
class DoorMesh extends PrimarySpriteMesh {
  /**
   * Construct a DoorMesh by providing PrimarySpriteMesh constructor options and specific door configuration.
   * @param {PrimarySpriteMeshConstructorOptions & DoorAnimationConfiguration & {style: DoorStyle}} options
   */
  constructor({direction, double, duration, flip, strength, type, style, ...spriteOptions}={}) {
    super(spriteOptions);
    this.initialize({direction, double, duration, flip, strength, style, type});
  }

  /**
   * The possible rendering styles for a door mesh.
   */
  static DOOR_STYLES = Object.freeze({
    SINGLE: "single",
    DOUBLE_LEFT: "doubleL",
    DOUBLE_RIGHT: "doubleR"
  });

  /**
   * The rendering style of the door
   * @type {DoorStyle}
   */
  #style;

  /**
   * The animation configuration of the door.
   * @type {Required<DoorAnimationConfiguration>}
   */
  #animation;

  /**
   * Is the door currently in the open state?
   * @type {boolean}
   */
  #open;

  /**
   * The original position of the door in its resting CLOSED state.
   * @type {DoorStateSnapshot}
   * @internal
   */
  _closedPosition;

  /**
   * The currently rendered position of the door.
   * @type {DoorStateSnapshot}
   * @internal
   */
  _animatedPosition;

  /**
   * An amount of pixel padding surrounding the door texture.
   * @type {number}
   */
  texturePadding = 0;

  /* -------------------------------------------- */

  /**
   * The identifier for this door animation.
   * @type {string}
   */
  get animationId() {
    return `Door.${this.object.id}.${this.#style}`;
  }

  /* -------------------------------------------- */
  /*  Initialization and Data Preparation         */
  /* -------------------------------------------- */

  /**
   * Configure and initialize the DoorMesh.
   * This is called automatically upon construction, but may be called manually later to update the DoorMesh.
   * @param {DoorAnimationConfiguration} animation
   */
  initialize(animation) {
    this.#configure(animation);
    if ( !this.#animation.config ) return;
    const {midpoint, initialize, animate} = this.#animation.config;
    this.anchor.set(midpoint ? 0.5 : 0, 0.5);

    // One-time initialization
    if ( initialize instanceof Function ) initialize.call(this, this.#open);

    // Initial animation
    if ( this.#open ) {
      const animation = animate.call(this, true);
      for ( const anim of animation ) foundry.utils.setProperty(anim.parent, anim.attribute, anim.to);
      this.#refresh();
    }
  }

  /* -------------------------------------------- */

  /**
   * Configure the door to be rendered in its current state.
   * @param {DoorAnimationConfiguration} animation
   */
  #configure(animation) {

    // Configure animation properties
    animation.type = (animation?.type in CONFIG.Wall.animationTypes) ? animation.type : "swing"; // Fallback to "swing"
    const defaults = foundry.documents.BaseWall.schema.fields.animation.clean({});
    this.#animation = Object.assign(defaults, animation);
    this.#animation.config = CONFIG.Wall.animationTypes[animation.type];

    // Configure rendering style
    this.#style ??= animation.style ?? DoorMesh.DOOR_STYLES[animation.double ? "DOUBLE_LEFT" : "SINGLE"];
    if ( this.#style === DoorMesh.DOOR_STYLES.DOUBLE_RIGHT ) this.#animation.direction *= -1;

    // Configure rendering data
    this._closedPosition = this.#getClosedPosition(animation);
    this._animatedPosition = foundry.utils.deepClone(this._closedPosition);

    // Assign initial properties of the sprite mesh
    this.name = this.animationId;
    this.hoverFade = false;
    this.#open = this.object.isOpen;
    this.#refresh();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the displayed state of the mesh with it's animated position data.
   */
  #refresh() {
    const a = this._animatedPosition;
    this.elevation = a.elevation;
    this.sort = a.sort;
    this.position.set(a.x, a.y);
    this.rotation = a.rotation;
    this.scale.set(a.scaleX, a.scaleY);
    this.tint = a.tint;
    this.alpha = a.alpha;
  }

  /* -------------------------------------------- */

  /**
   * Extract and prepare data used to render the DoorMesh in its closed position.
   * @param {Partial<DoorAnimationConfiguration>} config
   * @returns {DoorClosedState}
   */
  #getClosedPosition({flip, sort}={}) {
    const ray = this.object.toRay();
    sort ??= -Infinity;
    const styles = DoorMesh.DOOR_STYLES;

    // Pivot point for the animation
    let point = this.#style === styles.DOUBLE_RIGHT ? ray.B : ray.A;
    if ( this.#animation.config.midpoint ) {
      switch ( this.#style ) {
        case styles.SINGLE: {
          const [mx, my] = this.object.midpoint;
          point = {x: mx, y: my};
          break;
        }
        case styles.DOUBLE_LEFT:
          point = ray.project(0.25);
          break;
        case styles.DOUBLE_RIGHT:
          point = ray.project(0.75);
          break;
      }
    }

    // The texture is scaled to fill the full horizontal space
    const width = this.#style === styles.SINGLE ? ray.distance : (ray.distance / 2);
    const textureWidth = this.texture.width - (2 * this.texturePadding);
    const scaleX = width / textureWidth;

    // The texture is scaled vertically according to its native grid size
    const gridSize = canvas.dimensions.size;
    const textureGridSize = this.object.document?.flags.core?.textureGridSize ?? CONFIG.Wall.textureGridSize;
    const scaleY = (gridSize / textureGridSize) * (flip ? -1 : 1) * (this.#style === styles.DOUBLE_RIGHT ? -1 : 1);

    // Wall Elevation
    // TODO Unsupported except using a temporary core flag. Eventually elevation will become part of the Wall data model
    /** @deprecated since v13 until v14 */
    const elevation = this.object.document?.getFlag("core", "elevation") ?? (canvas.scene.foregroundElevation - 1);

    // Texture rotation
    const rotation = this.#style === styles.DOUBLE_RIGHT ? (ray.angle - Math.PI) : ray.angle;
    return {x: point.x, y: point.y, elevation, sort, rotation, scaleX, scaleY, tint: 0xFFFFFF, alpha: 1};
  }

  /* -------------------------------------------- */
  /*  Rendering and Animation                     */
  /* -------------------------------------------- */

  /**
   * Animate the door to its current rendered state.
   * @param {boolean} open      Is the door now open or closed?
   * @returns {Promise<void>}
   */
  async animate(open) {
    open ??= this.object.isOpen;
    if ( (open === this.#open) || !this.#animation.config ) return;

    // Immediately record the new "true state"
    this.#open = open;

    // Animate the change
    const {animate, easing, preAnimate, postAnimate} = this.#animation.config;
    const animation = animate.call(this, open);
    if ( preAnimate instanceof Function ) await preAnimate.call(this, open);
    await CanvasAnimation.animate(animation, {
      name: this.animationId,
      duration: this.#animation.duration,
      easing: easing || CanvasAnimation.easeInOutCosine,
      ontick: this.#refresh.bind(this)
    });
    if ( postAnimate instanceof Function ) await postAnimate.call(this, open);
  }

  /* -------------------------------------------- */

  /**
   * Configure the "swing" animation.
   * @this {DoorMesh}
   * @param {boolean} open
   * @returns {CanvasAnimationAttribute[]}
   */
  static animateSwing(open) {
    let {rotation} = this._closedPosition;
    const {strength, direction} = this.#animation;
    const delta = Math.PI * direction * strength / 2;
    if ( open ) rotation += delta;
    return [{parent: this._animatedPosition, attribute: "rotation", to: rotation}];
  }

  /* -------------------------------------------- */

  /**
   * Configure the "ascend" animation.
   * @this {DoorMesh}
   * @param {boolean} open
   * @returns {CanvasAnimationAttribute[]}
   */
  static animateAscend(open) {
    const {strength} = this.#animation;
    const {scaleX, scaleY} = this._closedPosition;
    const alpha = 0.25 * strength;
    const scale = open ? (0.1 * strength) : 0;
    const parent = this._animatedPosition;
    return [
      {parent, attribute: "alpha", to: open ? 1-alpha : 1.0},
      {parent, attribute: "scaleX", to: (Math.abs(scaleX) + scale) * Math.sign(scaleX)},
      {parent, attribute: "scaleY", to: (Math.abs(scaleY) + scale) * Math.sign(scaleY)},
      {parent, attribute: "tint", to: new Color(open ? 0x222222 : 0xFFFFFF)}
    ];
  }

  /* -------------------------------------------- */

  /**
   * Special initialization needed for descending door types.
   * @this {DoorMesh}
   * @param {boolean} open
   */
  static initializeDescend(open) {
    if ( open ) {
      this.elevation = 0;
      this.sort = Infinity;
    }
  }

  /* -------------------------------------------- */

  /**
   * When closing a descending door, shift its elevation to the foreground before animation.
   * @this {DoorMesh}
   * @param {boolean} open
   * @returns {Promise<void>}
   */
  static async preAnimateDescend(open) {
    if ( !open ) {
      this.elevation = this._closedPosition.elevation;
      this.sort = this._closedPosition.sort;
    }
  }

  /* -------------------------------------------- */

  /**
   * Configure the "descend" animation.
   * @this {DoorMesh}
   * @param {boolean} open
   * @returns {CanvasAnimationAttribute[]}
   */
  static animateDescend(open) {
    const {strength} = this.#animation;
    const {scaleX, scaleY} = this._closedPosition;
    const scale = open ? (0.05 * strength) : 0;
    const parent = this._animatedPosition;
    return [
      {parent, attribute: "scaleX", to: (Math.abs(scaleX) - scale) * Math.sign(scaleX)},
      {parent, attribute: "scaleY", to: (Math.abs(scaleY) - scale) * Math.sign(scaleY)},
      {parent, attribute: "tint", to: new Color(open ? 0x666666 : 0xFFFFFF)}
    ];
  }


  /* -------------------------------------------- */

  /**
   * When opening a descending door, shift its elevation to the background after animation.
   * @this {DoorMesh}
   * @param {boolean} open
   * @returns {Promise<void>}
   */
  static async postAnimateDescend(open) {
    if ( open ) {
      this.elevation = 0;
      this.sort = Infinity;
    }
  }

  /* -------------------------------------------- */

  /**
   * Configure the "slide" animation.
   * @this {DoorMesh}
   * @param {boolean} open
   * @returns {CanvasAnimationAttribute[]}
   */
  static animateSlide(open) {
    const {x, y} = this._closedPosition;
    const {direction, strength} = this.#animation;
    const {a, b} = this.object.edge;
    const m = this.#style === DoorMesh.DOOR_STYLES.SINGLE ? strength : (strength * 0.5);
    const dx = open ? (a.x - b.x) * direction * m : 0; // Retract towards A
    const dy = open ? (a.y - b.y) * direction * m : 0;
    const parent = this._animatedPosition;
    return [
      {parent, attribute: "x", to: x + dx},
      {parent, attribute: "y", to: y + dy}
    ];
  }
}

/**
 * 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.
 */
class QuadMesh extends PIXI.Container {
  /**
   * @param {typeof AbstractBaseShader} shaderClass     The shader class to use.
   */
  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 {typeof AbstractBaseShader} 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;
  }
}

/**
 * The grid shader used by {@link foundry.canvas.containers.GridMesh}.
 */
class GridShader extends AbstractBaseShader {

  /**
   * The grid type uniform.
   * @type {string}
   */
  static TYPE_UNIFORM = `
    const int TYPE_SQUARE = ${GRID_TYPES.SQUARE};
    const int TYPE_HEXODDR = ${GRID_TYPES.HEXODDR};
    const int TYPE_HEXEVENR = ${GRID_TYPES.HEXEVENR};
    const int TYPE_HEXODDQ = ${GRID_TYPES.HEXODDQ};
    const int TYPE_HEXEVENQ = ${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;
  }
}

/**
 * @import {GridMeshData} from "../_types.mjs"
 */

/**
 * The grid mesh, which uses the {@link foundry.canvas.rendering.shaders.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;
  }
}

/**
 * A special Graphics class which handles Grid layer highlighting
 * @extends {PIXI.Graphics}
 */
class GridHighlight extends PIXI.smooth.SmoothGraphics {
  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);
  }
}

/**
 * An interface for defining particle-based weather effects
 * @mixes FullCanvasObjectMixin
 */
class ParticleEffect extends FullCanvasObjectMixin(PIXI.Container) {
  /**
   * @param {object} [options]          Options passed to the getParticleEmitters method which can be used to customize
   *                                    values of the emitter configuration.
   */
  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)];
  }
}

var _module$p = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AutumnLeavesWeatherEffect: AutumnLeavesWeatherEffect,
  CachedContainer: CachedContainer,
  ControlIcon: ControlIcon,
  Cursor: Cursor,
  DoorControl: DoorControl,
  DoorMesh: DoorMesh,
  FullCanvasObjectMixin: FullCanvasObjectMixin,
  GridHighlight: GridHighlight,
  GridMesh: GridMesh,
  ParticleEffect: ParticleEffect,
  PointSourceMesh: PointSourceMesh,
  PreciseText: PreciseText,
  QuadMesh: QuadMesh,
  ResizeHandle: ResizeHandle,
  SpriteMesh: SpriteMesh,
  UnboundContainer: UnboundContainer,
  types: _types$a
});

/**
 * A minimalist filter (just used for blending)
 */
class VoidFilter extends AbstractBaseFilter {
  /** @override */
  static fragmentShader = `
  varying vec2 vTextureCoord;
  uniform sampler2D uSampler;
  void main() {
    gl_FragColor = texture2D(uSampler, vTextureCoord);
  }`;
}

/**
 * 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();
  }
}

/**
 * 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 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();

  /**
   * 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 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;
  }

  /* -------------------------------------------- */

  /**
   * 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);
  }

  /* -------------------------------------------- */

  /** @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 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});
  }
}

/**
 * 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];
  }
}

/**
 * @import {ElevatedPoint} from "../../_types.mjs";
 * @import {PointLightSource} from "../../_types.mjs";
 * @import {PointDarknessSource} from "../../_types.mjs";
 */

/**
 * @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();

  /**
   * An array of darkness and light sources whose edges contribute to the Scene.
   * The array is sorted by descending priority. In cases where multiple sources share the same priority, darkness
   * sources are placed before light sources to ensure they take precedence during polygon edges computations.
   * @type {(PointDarknessSource|PointLightSource)[]}
   */
  #highPrioritySources = [];

  /* -------------------------------------------- */

  /**
   * Iterator for all light and darkness sources.
   * @returns {Generator<PointDarknessSource|PointLightSource, void, void>}
   * @yields PointDarknessSource|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$1.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 ( const source of this.lightSources ) source.initialize();
    Hooks$1.callAll("initializeLightSources", this);
  }

  /* -------------------------------------------- */

  /**
   * Initialize all sources that generate edges (Darkness and certain Light sources).
   * Darkness sources always generate edges. Light sources only do so if their priority is strictly greater than 0.
   * The `edgesSources` array will be rebuilt and sorted by descending priority, in the case of a tie,
   * DarknessSources take precedence. Otherwise, the existing array is used as-is.
   * Regardless of whether the array is rebuilt, each source is re-initialized to ensure their geometry is refreshed.
   */
  initializePriorityLightSources() {
    // TODO: Add a dirty flag to avoid recreating/sorting the array every times
    this.#highPrioritySources.length = 0;

    // Darkness sources always generate edges
    for ( const darknessSource of this.darknessSources ) this.#highPrioritySources.push(darknessSource);

    // Light sources generate edges only if priority > 0 (and we have at least one darkness source)
    if ( this.#highPrioritySources.length > 0 ) {
      for ( const lightSource of this.lightSources ) {
        if ( lightSource.priority > 0 ) this.#highPrioritySources.push(lightSource);
      }
    }

    // Sort by descending priority. In case of a tie, PointDarknessSource comes first
    this.#highPrioritySources.sort(
      (a, b) => (b.priority - a.priority) || ((b instanceof PointDarknessSource) - (a instanceof PointDarknessSource))
    );

    // Always re-initialize each source to update geometry or other data.
    for ( const source of this.#highPrioritySources ) source.initialize();
    Hooks$1.callAll("initializePriorityLightSources", 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
    if ( this.illumination.filter ) this.illumination.filter.uniforms.replacementColor = canvas.colors.background.rgb;
    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$1.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 {ElevatedPoint} point        The point to test.
   * @param {object} [options={}]
   * @param {(source: PointLightSource) => boolean} [options.condition]  Optional condition a source must satisfy in
   * order to be tested.
   * @returns {boolean} Is inside light?
   */
  testInsideLight(point, options={}) {
    const isNumber = (typeof options === "number");

    /** @deprecated since v13 */
    if ( isNumber ) {
      foundry.utils.logCompatibilityWarning("EffectsCanvasGroup#testInsideLight(point: Point, elevation: number) has been deprecated "
        + "in favor of EffectsCanvasGroup#testInsideDarkness(point: ElevatedPoint, options: object).", {since: 13, until: 15, once: true});
      point = {x: point.x, y: point.y, elevation: options};
    }

    const globalLightSource = canvas.environment.globalLightSource;
    if ( globalLightSource.active ) {
      if ( options.condition?.(globalLightSource) !== false ) {
        const {min, max} = globalLightSource.data.darkness;
        const darknessLevel = this.getDarknessLevel(point);
        if ( (darknessLevel >= min) && (darknessLevel <= max) ) return true;
      }
    }

    for ( const lightSource of this.lightSources ) {
      if ( !lightSource.active || (lightSource instanceof GlobalLightSource) ) continue;
      if ( options.condition?.(lightSource) === false ) continue;
      if ( lightSource.testPoint(point) ) return true;
    }
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Test whether the point is inside darkness.
   * @param {ElevatedPoint} point        The point to test.
   * @param {object} [options={}]
   * @param {(source: PointDarknessSource) => boolean} [options.condition]  Optional condition a source must satisfy in
   * order to be tested.
   * @returns {boolean} Is inside darkness?
   */
  testInsideDarkness(point, options={}) {
    const isNumber = (typeof options === "number");

    /** @deprecated since v13 */
    if ( isNumber ) {
      foundry.utils.logCompatibilityWarning("EffectsCanvasGroup#testInsideDarkness(point: Point, elevation: number) has been deprecated "
        + "in favor of EffectsCanvasGroup#testInsideDarkness(point: ElevatedPoint, options: object).", {since: 13, until: 15, once: true});
      point = {x: point.x, y: point.y, elevation: options};
    }

    for ( const darknessSource of this.darknessSources ) {
      if ( !darknessSource.active || darknessSource.isPreview ) continue;
      if ( options.condition?.(darknessSource) === false ) continue;
      if ( darknessSource.testPoint(point) ) return true;
    }
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Get the darkness level at the given point.
   * @param {ElevatedPoint} point    The point.
   * @returns {number}               The darkness level.
   */
  getDarknessLevel(point, _elevation) {
    /** @deprecated since v13 */
    if ( _elevation !== undefined ) {
      foundry.utils.logCompatibilityWarning("EffectsCanvasGroup#getDarknessLevel(point: Point, elevation: number) has been deprecated "
        + "in favor of EffectsCanvasGroup#getDarknessLevel(point: ElevatedPoint).", {since: 13, until: 15, once: true});
      point = {x: point.x, y: point.y, elevation: _elevation};
    }
    const darknessLevelMeshes = canvas.effects.illumination.darknessLevelMeshes.children;
    for ( let i = darknessLevelMeshes.length - 1; i >= 0; i-- ) {
      const darknessLevelMesh = darknessLevelMeshes[i];
      if ( darknessLevelMesh.region.document.testPoint(point) ) {
        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) {
    enabled = enabled && !canvas.visibilityOptions.persistentVision;
    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
   */
  #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();
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  initializeDarknessSources() {
    const msg = "EffectsCanvasGroup#initializeDarknessSources and its associated hook are now obsolete and have no "
      + "replacement.";
    foundry.utils.logCompatibilityWarning(msg, {since: 13, until: 15});
    for ( const source of this.darknessSources ) source.initialize();
  }
}

/**
 * @typedef CanvasEnvironmentConfig
 * @property {ColorSource} [backgroundColor]              The background canvas color
 * @property {ColorSource} [brightestColor]               The brightest ambient color
 * @property {ColorSource} [darknessColor]                The color of darkness
 * @property {ColorSource} [daylightColor]                The ambient daylight color
 * @property {ColorSource} [fogExploredColor]             The color applied to explored areas
 * @property {ColorSource} [fogUnexploredColor]           The color applied to unexplored areas
 * @property {SceneEnvironmentData} [environment]         The scene environment data
 */

/**
 * 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                            */
  /* -------------------------------------------- */

  /**
   * @typedef {PIXI.FederatedEvent} CanvasEnvironmentDarknessChangeEvent
   * @param {"darknessChange"} type
   * @param {{darknessLevel: number, priorDarknessLevel: number}} environmentData
   */

  /**
   * @callback CanvasEnvironmentDarknessChange
   * @param {CanvasEnvironmentDarknessChangeEvent} event
   */

  /**
   * Initialize the scene environment options.
   * @param {CanvasEnvironmentConfig} config
   * @fires {hookEvents:initializeCanvasEnvironment}
   * @fires {CanvasEnvironmentDarknessChange}
   */
  initialize(config={}) {

    // Call environment initialization hooks
    Hooks$1.call("configureCanvasEnvironment", config);

    // Destructure configured parameters
    const {backgroundColor, brightestColor, darknessColor, daylightColor, fogExploredColor,
      fogUnexploredColor, darknessLevel, environment={}} = config;
    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
    });

    // Call environment initialization hooks
    Hooks$1.callAll("initializeCanvasEnvironment");
  }

  /* -------------------------------------------- */

  /**
   * 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 sfc = scene.fog.colors;
    const uc = Color.from(fogUnexploredColor ?? sfc.unexplored ?? cfg.unexploredColor ?? fbc.fogUnexplored);
    this.colors.fogUnexplored = this.colors.background.multiply(uc);
    const ec = Color.from(fogExploredColor ?? sfc.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;
  }
}

/**
 * @import {CanvasVisionContainerSight} from "../_types.mjs"
 * @import {CanvasVisionContainerLight} from "../_types.mjs"
 * @import {CanvasVisionContainerDarkness} from "../_types.mjs"
 * @import {CanvasVisionContainer} from "../_types.mjs"
 */

/**
 * 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;
  }
}

/**
 * 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;

  /**
   * The set of currently occluded canvas objects.
   * @type {Set<PrimaryCanvasObject>}
   */
  get occluded() {
    return this.#occluded;
  }
  #occluded = new Set();

  /* -------------------------------------------- */

  /**
   * 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.
   * @override
   */
  clear() {
    this.tokens.clear();
    return this;
  }

  /* -------------------------------------------- */
  /*  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) {
    this.#occluded.clear();
    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 || this.#occluded.has(pco) ) continue;
        if ( pco.testOcclusion(token, {corners: pco.restrictsLight && pco.restrictsWeather}) ) this.#occluded.add(pco);
      }
    }
    return this.#occluded;
  }
}

/**
 * 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.
   * @override
   */
  clear() {
    Canvas$1.clearContainer(this.roofs, false);
    return this;
  }
}

/**
 * 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);
  }
}

/**
 * @import {TextAnchorPoint} from "@common/constants.mjs"
 * @import Drawing from "../placeables/drawing.mjs"
 */

/**
 * 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;

  /**
   * 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() {
    const outline = this.addChild(new PIXI.Graphics());
    const {scene, dimensions} = canvas;
    const displayCanvasBorder = scene.padding !== 0;
    const displaySceneOutline = !scene.background.src;
    const s = canvas.dimensions.uiScale;
    if ( !(displayCanvasBorder || displaySceneOutline) ) return;
    if ( displayCanvasBorder ) outline.lineStyle({
      alignment: 1,
      alpha: 0.75,
      color: 0x000000,
      join: PIXI.LINE_JOIN.BEVEL,
      width: 4 * s
    }).drawShape(dimensions.rect);
    if ( displaySceneOutline ) outline.lineStyle({
      alignment: 1,
      alpha: 0.25,
      color: 0x000000,
      join: PIXI.LINE_JOIN.BEVEL,
      width: 4 * s
    }).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 {TextAnchorPoint} [options.anchor]    The original anchor point where the text appears
   * @param {TextAnchorPoint} [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<void>}                 A promise that resolves after the scrolling text animation ended.
   */
  async createScrollingText(origin, content, {duration=2000, distance, jitter=0, anchor, direction, ...textStyle}={}) {
    if ( !game.settings.get("core", "scrollingStatusText") ) return;
    const s = canvas.dimensions.uiScale;

    // 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 * s;
    const jy = (jitter ? (Math.random()-0.5) * jitter : 0) * text.height * s;
    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 * s); break;
      case CONST.TEXT_ANCHOR_POINTS.TOP:
        dy = -1 * (distance ?? (2 * text.height * s)); break;
      case CONST.TEXT_ANCHOR_POINTS.LEFT:
        dx = -1 * (distance ?? (2 * text.width * s)); break;
      case CONST.TEXT_ANCHOR_POINTS.RIGHT:
        dx = distance ?? (2 * text.width * s); break;
    }

    // Fade In
    await CanvasAnimation.animate([
      {parent: text, attribute: "alpha", from: 0, to: 1},
      {parent: text.scale, attribute: "x", from: 0.6 * s, to: s},
      {parent: text.scale, attribute: "y", from: 0.6 * s, to: s}
    ], {
      context: this,
      duration: duration * 0.25,
      easing: CanvasAnimation.easeInOutCosine,
      ontick: () => text.visible = true
    });

    // Scroll
    const scroll = [{parent: text, attribute: "alpha", to: 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;
}

/**
 * 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;
}

/**
 * @import Token from "../token.mjs";
 * @import {RingColorBand, RingData} from "../_types.mjs"
 */

/**
 * Dynamic Token Ring Manager.
 */
class TokenRing {
  /**
   * A TokenRing is constructed by providing a reference to a Token object.
   * @param {Token} token
   */
  constructor(token) {
    this.#token = new WeakRef(token);
  }

  /* -------------------------------------------- */
  /*  Rings System                                */
  /* -------------------------------------------- */

  /**
   * The effects which could be applied to a token ring (using bitwise operations).
   */
  static effects = Object.freeze({
    DISABLED: 0x00,
    ENABLED: 0x01,
    RING_PULSE: 0x02,
    RING_GRADIENT: 0x04,
    BKG_WAVE: 0x08,
    INVISIBILITY: 0x10, // Or spectral pulse effect
    COLOR_OVER_SUBJECT: 0x20
  });

  /* -------------------------------------------- */

  /**
   * Is the token rings framework enabled? Will be `null` if the system hasn't initialized yet.
   * @type {boolean|null}
   */
  static get initialized() {
    return this.#initialized;
  }

  static #initialized = null;

  /* -------------------------------------------- */

  /**
   * Token Rings sprite sheet base texture.
   * @type {PIXI.BaseTexture}
   */
  static baseTexture;

  /**
   * Rings and background textures UVs and center offset.
   * @type {Record<string, {UVs: Float32Array, center: {x: number, y: number}}>}
   */
  static texturesData;

  /**
   * The token ring shader class definition.
   * @type {typeof TokenRingSamplerShader}
   */
  static tokenRingSamplerShader;

  /**
   * The array of available RingData.
   * @type {RingData[]}
   */
  static #ringData;

  /**
   * Default ring thickness in normalized space.
   * @type {number}
   */
  static #defaultRingThickness = 0.1269848;

  /**
   * Default ring subject thickness in normalized space.
   * @type {number}
   */
  static #defaultSubjectThickness = 0.6666666;

  /* -------------------------------------------- */

  /**
   * Initialize the Token Rings system, registering the batch plugin and patching PrimaryCanvasGroup#addToken.
   */
  static initialize() {
    if ( TokenRing.#initialized ) return;
    TokenRing.#initialized = true;
    // Register batch plugin
    this.tokenRingSamplerShader = CONFIG.Token.ring.shaderClass;
    this.tokenRingSamplerShader.registerPlugin();
  }

  /* -------------------------------------------- */

  /**
   * Create texture UVs for each asset into the token rings sprite sheet.
   */
  static createAssetsUVs() {
    const spritesheet = TextureLoader.loader.getCache(CONFIG.Token.ring.spritesheet);
    if ( !spritesheet ) throw new Error("TokenRing UV generation failed because no spritesheet was loaded!");

    this.baseTexture = spritesheet.baseTexture;
    this.texturesData = {};
    this.#ringData = [];

    const {
      defaultColorBand={startRadius: 0.59, endRadius: 0.7225},
      defaultRingColor: drc,
      defaultBackgroundColor: dbc
    } = spritesheet.data.config ?? {};
    const defaultRingColor = Color.from(drc);
    const defaultBackgroundColor = Color.from(dbc);
    const validDefaultRingColor = defaultRingColor.valid ? defaultRingColor.littleEndian : null;
    const validDefaultBackgroundColor = defaultBackgroundColor.valid ? defaultBackgroundColor.littleEndian : null;

    const frames = Object.keys(spritesheet.data.frames || {});
    if ( !frames.length ) throw new Error("TokenRing UV generation failed because no frames were detected!");

    for ( const asset of frames ) {
      const assetTexture = PIXI.Assets.cache.get(asset);
      if ( !assetTexture ) continue;

      // Extracting texture UVs
      const frame = assetTexture.frame;
      const textureUvs = new PIXI.TextureUvs();
      textureUvs.set(frame, assetTexture.baseTexture, assetTexture.rotate);
      this.texturesData[asset] = {
        UVs: textureUvs.uvsFloat32,
        center: {
          x: frame.center.x / assetTexture.baseTexture.width,
          y: frame.center.y / assetTexture.baseTexture.height
        }
      };

      // Skip background assets
      if ( asset.includes("-bkg") || asset.includes("-msk") ) continue;

      // Extracting and determining final colors
      const {ringColor: rc, backgroundColor: bc, colorBand, gridTarget, ringThickness=this.#defaultRingThickness} =
        spritesheet.data.frames[asset] || {};

      const ringColor = Color.from(rc);
      const backgroundColor = Color.from(bc);

      const finalRingColor = ringColor.valid ? ringColor.littleEndian : validDefaultRingColor;
      const finalBackgroundColor = backgroundColor.valid ? backgroundColor.littleEndian : validDefaultBackgroundColor;
      const subjectScaleAdjustment = 1 / (ringThickness + this.#defaultSubjectThickness);

      const ringData = {
        ringName: asset,
        bkgName: `${asset}-bkg`,
        maskName: `${asset}-msk`,
        colorBand: foundry.utils.deepClone(colorBand ?? defaultColorBand),
        gridTarget: gridTarget ?? 1,
        defaultRingColorLittleEndian: finalRingColor,
        defaultBackgroundColorLittleEndian: finalBackgroundColor,
        subjectScaleAdjustment
      };
      this.#ringData.push(ringData);
    }

    // Sorting the rings data array
    this.#ringData.sort((a, b) => a.gridTarget - b.gridTarget);
  }

  /* -------------------------------------------- */

  /**
   * Get the UVs array for a given texture name and scale correction.
   * @param {string} name                  Name of the texture we want to get UVs.
   * @param {number} [scaleCorrection=1]   The scale correction applied to UVs.
   * @returns {Float32Array|void}
   */
  static getTextureUVs(name, scaleCorrection=1) {
    if ( !this.texturesData[name] ) return;
    if ( scaleCorrection === 1 ) return this.texturesData[name].UVs;
    const tUVs = this.texturesData[name].UVs;
    const c = this.texturesData[name].center;
    const UVs = new Float32Array(8);
    for ( let i=0; i<8; i+=2 ) {
      UVs[i] = ((tUVs[i] - c.x) * scaleCorrection) + c.x;
      UVs[i+1] = ((tUVs[i+1] - c.y) * scaleCorrection) + c.y;
    }
    return UVs;
  }

  /* -------------------------------------------- */

  /**
   * Get ring and background names for a given size.
   * @param {number} size   The size to match (grid size dimension)
   * @returns {RingData}
   */
  static getRingDataBySize(size) {
    if ( !Number.isFinite(size) || !this.#ringData.length ) {
      return {
        ringName: undefined,
        bkgName: undefined,
        maskName: undefined,
        colorBand: undefined,
        defaultRingColorLittleEndian: null,
        defaultBackgroundColorLittleEndian: null,
        subjectScaleAdjustment: null
      };
    }
    const rings = this.#ringData.map(r => [Math.abs(r.gridTarget - size), r]);

    // Sort rings on proximity to target size
    rings.sort((a, b) => a[0] - b[0]);

    // Choose the closest ring, access the second element of the first array which is the ring data object
    const closestRing = rings[0][1];

    return {
      ringName: closestRing.ringName,
      bkgName: closestRing.bkgName,
      maskName: closestRing.maskName,
      colorBand: closestRing.colorBand,
      defaultRingColorLittleEndian: closestRing.defaultRingColorLittleEndian,
      defaultBackgroundColorLittleEndian: closestRing.defaultBackgroundColorLittleEndian,
      subjectScaleAdjustment: closestRing.subjectScaleAdjustment
    };
  }

  /* -------------------------------------------- */
  /*  Attributes                                  */
  /* -------------------------------------------- */

  /** @type {string} */
  ringName;

  /** @type {string} */
  bkgName;

  /** @type {string} */
  maskName;

  /** @type {Float32Array} */
  ringUVs;

  /** @type {Float32Array} */
  bkgUVs;

  /** @type {Float32Array} */
  maskUVs;

  /** @type {number} */
  ringColorLittleEndian = 0xFFFFFF; // Little endian format => BBGGRR

  /** @type {number} */
  bkgColorLittleEndian = 0xFFFFFF; // Little endian format => BBGGRR

  /** @type {number|null} */
  defaultRingColorLittleEndian = null;

  /** @type {number|null} */
  defaultBackgroundColorLittleEndian = null;

  /** @type {number} */
  effects = 0;

  /** @type {number} */
  scaleCorrection = 1;

  /** @type {number} */
  scaleAdjustmentX = 1;

  /** @type {number} */
  scaleAdjustmentY = 1;

  /** @type {number} */
  subjectScaleAdjustment = 1;

  /** @type {number} */
  textureScaleAdjustment = 1;

  /** @type {RingColorBand} */
  colorBand;


  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Reference to the token that should be animated.
   * @type {Token|void}
   */
  get token() {
    return this.#token.deref();
  }

  /**
   * Weak reference to the token being animated.
   * @type {WeakRef<Token>}
   */
  #token;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Configure the sprite mesh.
   * @param {PrimarySpriteMesh} [mesh]  The mesh to which TokenRing functionality is configured (default to token.mesh)
   */
  configure(mesh) {
    mesh ??= this.token.mesh;
    if ( !mesh ) return;
    this.#configureTexture(mesh);
    this.configureSize({fit: this.token.document.texture.fit});
    this.configureVisuals();
  }

  /* -------------------------------------------- */

  /**
   * Clear configuration pertaining to token ring from the mesh.
   */
  clear() {
    this.ringName = undefined;
    this.bkgName = undefined;
    this.maskName = undefined;
    this.ringUVs = undefined;
    this.bkgUVs = undefined;
    this.maskUVs = undefined;
    this.colorBand = undefined;
    this.ringColorLittleEndian = 0xFFFFFF;
    this.bkgColorLittleEndian = 0xFFFFFF;
    this.defaultRingColorLittleEndian = null;
    this.defaultBackgroundColorLittleEndian = null;
    this.scaleCorrection = 1;
    this.scaleAdjustmentX = 1;
    this.scaleAdjustmentY = 1;
    this.subjectScaleAdjustment = 1;
    this.textureScaleAdjustment = 1;
    const mesh = this.token.mesh;
    if ( mesh ) mesh.padding = 0;
  }

  /* -------------------------------------------- */

  /**
   * Configure token ring size according to mesh texture, token dimensions, fit mode, and dynamic ring fit mode.
   * @param {object} [options]
   * @param {string} [options.fit="contain"]     The desired fit mode
   * @param {number} [options.scaleMultiplier=1] A custom scale multiplier applied on scale correction
   */
  configureSize({fit = "contain", scaleMultiplier=1}={}) {
    const mesh = this.token.mesh;
    const {width: gridWidth, height: gridHeight} = this.token.document;
    const size = Math.min(gridWidth, gridHeight);
    const explicitSubject = this.token.document.hasDistinctSubjectTexture;

    // Set ring size and subject scale
    Object.assign(this, this.constructor.getRingDataBySize(size));
    this.scaleCorrection = this.token.document.ring.subject.scale * scaleMultiplier;

    // Extract dimensions from the mesh or token texture
    const {width: textureWidth, height: textureHeight} = mesh.texture ?? this.token.texture;

    // Extract long and short sides
    const longSide = Math.max(textureWidth, textureHeight);
    const shortSide = Math.min(textureWidth, textureHeight);

    // Initialize scale padding (0 by default with scale correction >= 1)
    let scalePadding = 0;

    // Calculate padding for scale correction < 1
    // With low scale correction, the ring could be larger than the token gl viewport. We need padding for this case.
    if ( this.scaleCorrection < 1 ) {
      const {width: tokenWidth, height: tokenHeight} = this.token.document.getSize();
      const maxSideLength = Math.max(tokenWidth, tokenHeight);
      const scaleFactor = 1 - this.scaleCorrection;
      const aspectRatioLengthAdjustment = (longSide - shortSide); // Adjustment for aspect ratio <> 1
      scalePadding = scaleFactor * maxSideLength + aspectRatioLengthAdjustment;

      // Apply scale padding to scale correction
      this.scaleCorrection *= (1 + ((scalePadding * 2) / longSide));
    }

    // Calculate padding for X and Y sides
    const padding = (longSide - shortSide) / 2;
    const paddingX = (textureWidth < textureHeight) ? padding : 0;
    const paddingY = (textureWidth > textureHeight) ? padding : 0;

    // Apply mesh padding and scaling adjustments
    mesh.paddingX = paddingX + scalePadding;
    mesh.paddingY = paddingY + scalePadding;

    const scaleAdjustment = longSide / (longSide + scalePadding * 2);
    const aspectRatioAdjustment = (shortSide / longSide) * scaleAdjustment;
    this.scaleAdjustmentX = paddingX ? aspectRatioAdjustment : scaleAdjustment;
    this.scaleAdjustmentY = paddingY ? aspectRatioAdjustment : scaleAdjustment;

    // Apply texture scale adjustments based on fit mode and subject presence
    if ( !explicitSubject ) {
      if ( CONFIG.Token.ring.isGridFitMode ) this.textureScaleAdjustment = this.subjectScaleAdjustment;

      // Adjust scale based on fit mode
      this.#adjustScaleByFitMode(fit, textureWidth, textureHeight);
    }

    // Otherwise do nothing and initialize texture scale adjustment to normal
    else this.textureScaleAdjustment = 1;

    // Get the scaled dynamic ring assets
    this.#setRingAssets();
  }

  /* -------------------------------------------- */

  /**
   * Adjust the scale correction based on the specified fit mode and various texture and grid dimensions.
   * @param {string} fit               The desired fit mode.
   * @param {number} textureWidth      The width of the token or mesh texture.
   * @param {number} textureHeight     The height of the token or mesh texture.
   */
  #adjustScaleByFitMode(fit, textureWidth, textureHeight) {
    if ( fit === "fill" ) {
      this.scaleCorrection *= (Math.max(textureWidth, textureHeight) / Math.min(textureWidth, textureHeight));
      return;
    }
    const {width: baseWidth, height: baseHeight} = this.token.document.getSize();
    let meshScale;
    switch ( fit ) {
      case "cover":
        meshScale = Math.max(baseWidth / textureWidth, baseHeight / textureHeight);
        break;
      case "contain":
        meshScale = Math.min(baseWidth / textureWidth, baseHeight / textureHeight);
        break;
      case "width":
        meshScale = baseWidth / textureWidth;
        break;
      case "height":
        meshScale = baseHeight / textureHeight;
        break;
    }
    const meshDiameter = Math.max(textureWidth, textureHeight) * meshScale;
    const ringDiameter = Math.min(baseWidth, baseHeight);
    this.scaleCorrection *= (meshDiameter / ringDiameter);
  }

  /* -------------------------------------------- */

  /**
   * Set dynamic ring assets UVs according to scale correction.
   */
  #setRingAssets() {
    this.ringUVs = this.constructor.getTextureUVs(this.ringName, this.scaleCorrection);
    this.bkgUVs = this.constructor.getTextureUVs(this.bkgName, this.scaleCorrection);
    this.maskUVs = this.constructor.getTextureUVs(this.maskName, this.scaleCorrection);
  }

  /* -------------------------------------------- */

  /**
   * Configure the token ring visuals properties.
   */
  configureVisuals() {
    const ring = this.token.document.ring;

    // Configure colors
    const colors = foundry.utils.mergeObject(ring.colors, this.token.getRingColors(), {inplace: false});
    const resolveColor = (color, defaultColor) => {
      const resolvedColor = Color.from(color ?? 0xFFFFFF).littleEndian;
      return ((resolvedColor === 0xFFFFFF) && (defaultColor !== null)) ? defaultColor : resolvedColor;
    };
    this.ringColorLittleEndian = resolveColor(colors?.ring, this.defaultRingColorLittleEndian);
    this.bkgColorLittleEndian = resolveColor(colors?.background, this.defaultBackgroundColorLittleEndian);

    // Configure effects
    const effectsToApply = this.token.getRingEffects();
    this.effects = ((ring.effects >= this.constructor.effects.DISABLED)
      ? ring.effects : this.constructor.effects.ENABLED)
      | effectsToApply.reduce((acc, e) => acc |= e, 0x0);

    // Mask with enabled effects for the current token ring configuration
    let mask = this.effects & CONFIG.Token.ring.ringClass.effects.ENABLED;
    for ( const key in CONFIG.Token.ring.effects ) {
      const v = CONFIG.Token.ring.ringClass.effects[key];
      if ( v !== undefined ) {
        mask |= v;
      }
    }
    this.effects &= mask;
  }

  /* -------------------------------------------- */

  /**
   * Configure dynamic token ring subject texture.
   * @param {PrimarySpriteMesh} mesh                  The mesh being configured
   */
  #configureTexture(mesh) {
    const src = this.token.document.ring.subject.texture;
    if ( PIXI.Assets.cache.has(src) ) {
      const subjectTexture = getTexture(src);
      if ( subjectTexture?.valid ) mesh.texture = subjectTexture;
    }
  }

  /* -------------------------------------------- */
  /*  Animations                                  */
  /* -------------------------------------------- */

  /**
   * Flash the ring briefly with a certain color.
   * @param {Color} color                              Color to flash.
   * @param {CanvasAnimationOptions} animationOptions  Options to customize the animation.
   * @returns {Promise<boolean|void>}
   */
  async flashColor(color, animationOptions={}) {
    if ( Number.isNaN(color) ) return;

    const baseDuration = 1600;
    const isPS = canvas.photosensitiveMode === true;
    if ( isPS ) {
      // Apply a minimal duration for photosensitive mode
      animationOptions.duration = Math.max(1000, animationOptions.duration ?? baseDuration);
      // Override completely easing option with a soft ping pong easing
      animationOptions.easing = this.constructor.easePingPong;
    }

    const defaultColorFallback = this.token.ring.defaultRingColorLittleEndian ?? 0xFFFFFF;
    const configuredColor = Color.from(foundry.utils.mergeObject(
      this.token.document.ring.colors,
      this.token.getRingColors(),
      {inplace: false}
    ).ring);
    const originalColor = configuredColor.valid ? configuredColor.littleEndian : defaultColorFallback;
    const target = new Color(color.littleEndian);

    // Washing target color for photosensitive mode. Needed to reduce contrast
    const softTarget = isPS ? Color.from(originalColor).mix(target, 0.4) : target;

    return await CanvasAnimation.animate([{
      attribute: "ringColorLittleEndian",
      parent: this,
      from: originalColor,
      to: softTarget,
      color: true
    }], foundry.utils.mergeObject({
      duration: baseDuration,
      priority: PIXI.UPDATE_PRIORITY.HIGH,
      easing: this.constructor.createSpikeEasing(.15)
    }, animationOptions));
  }

  /* -------------------------------------------- */

  /**
   * Create an easing function that spikes in the center. Ideal duration is around 1600ms.
   * @param {number} [spikePct=0.5]  Position on [0,1] where the spike occurs.
   * @returns {Function(number): number}
   */
  static createSpikeEasing(spikePct=0.5) {
    const scaleStart = 1 / spikePct;
    const scaleEnd = 1 / (1 - spikePct);
    return pt => {
      if ( pt < spikePct ) return CanvasAnimation.easeInCircle(pt * scaleStart);
      else return 1 - CanvasAnimation.easeOutCircle(((pt - spikePct) * scaleEnd));
    };
  }

  /* -------------------------------------------- */

  /**
   * Easing function that produces two peaks before returning to the original value. Ideal duration is around 500ms.
   * @param {number} pt     The proportional animation timing on [0,1].
   * @returns {number}      The eased animation progress on [0,1].
   */
  static easeTwoPeaks(pt) {
    return (Math.sin((4 * Math.PI * pt) - (Math.PI / 2)) + 1) / 2;
  }

  /* -------------------------------------------- */

  /**
   * Soft ping pong curve for photosensitive people.
   * @param {number} pt   The proportional animation timing on [0,1].
   * @returns {number}    The eased animation progress on [0,1].
   */
  static easePingPong(pt) {
    return Math.sin(Math.PI * pt);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * To avoid breaking dnd5e.
   * @deprecated since v12
   * @ignore
   */
  configureMesh() {}

  /**
   * To avoid breaking dnd5e.
   * @deprecated since v12
   * @ignore
   */
  configureNames() {}

}

/**
 * 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 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 foundry.canvas.animation.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);
  }
}

/**
 * @typedef DepthBatchData
 * The batch data that is needed by {@link foundry.canvas.rendering.shaders.DepthSamplerShader} to
 * render an element with batching.
 * @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
 */

/* -------------------------------------------- */

/**
 * @typedef OccludableBatchData
 * The batch data that is needed by {@link foundry.canvas.rendering.shaders.OccludableSamplerShader}
 * to render an element with batching.
 * @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
 */

var _types$9 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * Bewitching Wave animation illumination shader
 */
class BewitchingWaveIlluminationShader extends AdaptiveIlluminationShader {
  /** @override */
  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 {
  /** @override */
  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 {
  /** @override */
  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 {
  /** @override */
  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 {
  /** @override */
  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 {
  /** @override */
  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 {
  /** @override */
  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 {
  /** @override */
  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
  };
}

/**
 * Roiling mass illumination shader: intended primarily for darkness
 */
class RoilingDarknessShader extends AdaptiveDarknessShader {
  /** @override */
  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 {
  /** @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, 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 {
  /** @override */
  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 {
  /** @override */
  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 {
  /** @override */
  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 {
  /** @override */
  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 {
  /** @override */
  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 {
  /** @override */
  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 {
  /** @override */
  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 {
  /** @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 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 {
  /** @override */
  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 {
  /** @override */
  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}
  }`;
}

/**
 * Creates a dense smoke area
 */
class DenseSmokeDarknessShader extends AdaptiveDarknessShader {

  /* -------------------------------------------- */
  /*  GLSL Statics                                */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.SIMPLEX_3D}
  ${this.FBMHQ(5, "fbm", "snoise", "vec3")}

  void main() {
    ${this.FRAGMENT_BEGIN}
    // Handling intensity and UVs
    float i = (intensity * 0.2);
    vec2 uv = vUvs * 2.5;
    
    // Smooth Noise for visuals
    float fn1 = i * 0.33 + 0.67 * fbm(vec3(uv, time * 0.25), 1.70);
    float fn2 = i * 0.33 + 0.67 * fbm(vec3(uv + 0.5, time * 0.25), 1.40);
    float fn3 = i * 0.33 + 0.67 * fbm(vec3(uv - 0.5, time * 0.25), 1.65);
    
    // Smooth Noise for mixing
    float m1 = fbm(vec3(uv - 1.301, time * 0.16), 1.66);
    float m2 = fbm(vec3(uv + 1.187, time * 0.21), 1.54);
    
    // Mixing noise to produce smoke
    float t = mix(fn1, fn2, m1);
    t = mix(t, fn3, m2);
    t = mix(t, fn1, 0.5);
    t = mix(t, fn2, 0.5);
    t = mix(t, fn3, 0.5);
    finalColor = vec3(t);
    
    // Border distance attenuation
    float bda = 1.0 - smoothstep(borderDistance, 1.0, dist);
    
    // Output
    gl_FragColor = vec4(finalColor * color, t) * depth * bda * colorationAlpha;
  }`;
}

/**
 * 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;
  }
}

/**
 * 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.PRNG}
    ${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.7312,  11.9312,
      21.8112,   14.7212,  61.3934
    );

    // Compute snow density according to uv and layer                       
    float computeSnowDensity(in vec2 uv, in float layer) {
      vec3 sb = vec3(floor(uv), 31.189 + layer);
      vec3 m = floor(sb) / 10000.0 + fract(sb);
      vec3 mp = (31415.9 + m) / fract(prng * m);
      vec3 r = fract(mp);
      vec2 s = abs(fract(uv) + 0.9 * r.xy - 0.95) + 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 e = 0.005 + 0.05 * min(0.5 * abs(layer - 5.0 - sin(time * 0.1)), 1.0);
      return smoothstep(e * 2.0, -e * 2.0, d) * r.x / (0.5 + layer * 0.015);
    }              
 
    void main() {
      ${this.COMPUTE_MASK}
      
      // Snow accumulation
      float accumulation = 0.0;
      
      // Compute layers  
      for( int i=5; i<25; i++ ) {
        // Compute uv layerization
        float f = float(i);
        float f1 = 1.0 + f * 1.5;
        float f2 = fract(f * 6.258817) - direction;
        float f3 = 1.0 + f * 0.045;
        vec2 snowuv = vUvs * f1;
        snowuv += vec2(snowuv.y * 1.2 * f2, -time / f3);
        
        // Perform accumulation layer after layer    
        accumulation += computeSnowDensity(snowuv, f);
      }
      
      // Output the accumulated snow pixel
      gl_FragColor = vec4(vec3(accumulation) * tint, 1.0) * mask * alpha;
    }
  `;
}

/**
 * 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));
    }
  }
}

/**
 * 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 = AdjustDarknessLevelRegionBehaviorType.MODES.OVERRIDE;

  /* ---------------------------------------- */

  /**
   * The darkness level modifier.
   * @type {number}
   */
  modifier = 0;

  /* ---------------------------------------- */

  /**
   * Current darkness level of this mesh.
   * @type {number}
   */
  get darknessLevel() {
    const M = 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 {bottom, top} = mesh.region.document.elevation;
    const u = this.uniforms;
    u.bottom = canvas.masks.depth.mapElevation(bottom);
    u.top = canvas.masks.depth.mapElevation(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;
    }
  `;
}

/**
 * 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 that makes the original texture's red channel the alpha channel while still keeping channel
 * information. Used in conjunction 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;
    }`;
}

/**
 * A colorization shader which keeps brightness contrary to "normal tinting"
 */
class ColorizeBrightnessShader 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 tintLinear;
    uniform bool grey;
    uniform float intensity;
    varying vec2 vUvs;
    varying vec2 vScreenCoord;

    ${this.CONSTANTS}
    ${this.PERCEIVED_BRIGHTNESS}
    ${this.COLOR_SPACES}

    void main() {
      vec4 baseColor = texture2D(sampler, vUvs);

      if ( baseColor.a > 0.0 ) {
       
        // Unmultiply rgb with alpha channel
        baseColor.rgb /= baseColor.a;
        
        // Convert to linear color
        vec3 linearBaseColor = srgb2linear(baseColor.rgb);
        
        // Convert to greyscale
        if ( grey ) linearBaseColor = linear2grey(linearBaseColor);
        
        // Modulate the tint based on luminance, preserving highlights
        vec3 colored = tintColorLinear(linearBaseColor, tintLinear, intensity);
        
        // Convert back to sRGB
        baseColor.rgb = linear2srgb(colored);
        
        // 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],
    tintLinear: [1, 1, 1],
    sampler: null,
    screenDimensions: [1, 1],
    grey: true,
    intensity: 1
  };
}

/**
 * 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: "aMaskTextureCoord", 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 + 14;

  /* -------------------------------------------- */

  /** @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);

    // Destructure the properties from the element for faster access
    const {vertexData} = element;
    const object = element.object.object || {};

    // Retrieve ring properties with default values
    const {
      ringColorLittleEndian = 0xFFFFFF,
      bkgColorLittleEndian = 0xFFFFFF,
      ringUVs = CONFIG.Token.ring.shaderClass.nullUvs,
      bkgUVs = CONFIG.Token.ring.shaderClass.nullUvs,
      maskUVs = CONFIG.Token.ring.shaderClass.nullUvs,
      effects = 0,
      scaleCorrection = 1,
      scaleAdjustmentX = 1,
      scaleAdjustmentY = 1,
      colorBand: {startRadius = 0, endRadius = 0} = {},
      textureScaleAdjustment = 1
    } = object.ring || {};

    // Calculate colors using the PIXI.Color class
    const ringColor = PIXI.Color.shared.setValue(ringColorLittleEndian).toNumber();
    const bkgColor = PIXI.Color.shared.setValue(bkgColorLittleEndian).toNumber();

    // Prepare ring attributes
    const states = effects + 0.5;
    const scaleCorrectionX = scaleCorrection * scaleAdjustmentX;
    const scaleCorrectionY = scaleCorrection * scaleAdjustmentY;
    const colorBandRadiusStart = maskUVs !== CONFIG.Token.ring.shaderClass.nullUvs ? 0 : startRadius;
    const colorBandRadiusEnd = maskUVs !== CONFIG.Token.ring.shaderClass.nullUvs ? 0 : endRadius;

    // Access Float32 and Uint32 views of the attribute buffer
    const {float32View, uint32View} = attributeBuffer;

    // Calculate vertex size and offset
    const vertexSize = this.vertexSize;
    const offset = aIndex + PrimaryBaseSamplerShader.batchVertexSize;

    // Loop through the vertex data to fill attribute buffers
    for ( let i = 0, j = 0; i < vertexData.length; i += 2, j += vertexSize ) {
      let k = offset + j;

      // Fill texture coordinates, colors, and state values into the buffer
      float32View[k++] = ringUVs[i];
      float32View[k++] = ringUVs[i + 1];
      float32View[k++] = bkgUVs[i];
      float32View[k++] = bkgUVs[i + 1];
      float32View[k++] = maskUVs[i];
      float32View[k++] = maskUVs[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;
    const uint STATE_COLOR_OVER_SUBJECT = 0x20U;
    
    vec4 colorOverlay;
    
    /* -------------------------------------------- */

    bool hasState(in uint state) {
      return (vStates & state) == state;
    }

    /* -------------------------------------------- */

    vec2 rotation(in vec2 uv, in float angle) {
      uv -= 0.5;
      float s = sin(angle);
      float c = cos(angle);
      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) {
      return 0.5 * sin(-time * 4.0 + dist * 100.0) + 0.9;
    }
    
    /* -------------------------------------------- */

    vec4 blend(vec4 src, vec4 dst) {
      return src + (dst * (1.0 - src.a));
    }
       
    /* -------------------------------------------- */
    
    vec4 colorizeTokenRing(in vec4 tokenRing, in float dist) {
      vec3 tokenColor = tokenRing.rgb / max(tokenRing.a, 1e-6);
      
      // Sample the mask texture
      vec4 maskTex = texture(tokenRingTexture, vMaskTextureCoord);
      vec3 maskColor = maskTex.rgb / max(maskTex.a, 1e-6);
      
      // Compute red channel based on mask if available, otherwise fall back to token color
      float redChannel = (maskTex.a > 0.0) ? maskColor.r : tokenColor.r;
  
      // Compute pulse factor, but only if the pulse state is active
      float pulseFactor = 1.0;
      if ( hasState(STATE_RING_PULSE) ) pulseFactor = cos(time * 2.0) * 0.325 + 0.675;
  
      // Compute the base pulse color
      vec3 pulseColor = vRingColor * redChannel * pulseFactor;
  
      // Compute gradient color if the gradient state is active
      vec3 gradientColor = pulseColor;
      if ( hasState(STATE_RING_GRADIENT) ) {
        // Calculate gradient mix based on rotation and time
        float gradientMix = smoothstep(0.0, 1.0, dot(rotation(vTextureCoord, time), vec2(0.5)));
        gradientColor = mix(pulseColor, vBackgroundColor * redChannel, gradientMix);
      }
  
      // Compute mixFactor based on ring color bands
      float mixFactor = step(vRingColorBand.x, dist) - step(vRingColorBand.y, dist);
      
      if ( hasState(STATE_COLOR_OVER_SUBJECT) ) {
        float mixAdjusted = mixFactor * tokenRing.a;
        if ( maskTex.a > 0.0 ) colorOverlay = vec4(gradientColor, 1.0) * maskTex.a;
        else colorOverlay = vec4(gradientColor, 1.0) * mixAdjusted;
        return tokenRing * (1.0 - mixAdjusted);
      } 
      
      // Compute the final color by mixing tokenColor and gradientColor based on mixFactor
      vec4 finalColor = vec4(mix(tokenColor, gradientColor, mixFactor), 1.0) * tokenRing.a;
  
      // If mask is present, blend the final color with the mask color
      if ( maskTex.a > 0.0 ) finalColor = blend(vec4(gradientColor, 1.0) * maskTex.a, finalColor);
      return finalColor;
    }

    /* -------------------------------------------- */

    vec4 colorizeTokenBackground(in vec4 tokenBackground, in float dist) {
      // Pre-correct the background color for alpha if necessary
      vec3 bgColor = (tokenBackground.a > 0.0) ? tokenBackground.rgb / tokenBackground.a : tokenBackground.rgb;
  
      // Calculate the wave factor based on the state
      float waveFactor = hasState(STATE_BKG_WAVE) ? (0.5 + wave(dist) * 1.5) : 1.0;
  
      // Check if tint color is pure white (no tint)
      vec3 tintColor = vBackgroundColor.rgb;
      vec3 resultColor = bgColor;
  
      // Apply overlay blend mode only if tint is not white
      if ( tintColor != vec3(1.0, 1.0, 1.0) ) {
        // Overlay blend: vectorized for each RGB channel
        resultColor = mix(2.0 * bgColor * tintColor, 
                          1.0 - 2.0 * (1.0 - bgColor) * (1.0 - tintColor),
                          step(0.5, bgColor));
      }
  
      // Return the final color with alpha and wave applied
      return vec4(resultColor, 1.0) * tokenBackground.a * waveFactor;
    }

    /* -------------------------------------------- */

    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);
    }
    
    /* -------------------------------------------- */
    
    float getTokenTextureClip() {
      // Check if both x and y coordinates are within the [0.0, 1.0] range
      return 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%

    // Compute distances for further processing
    vec2 scaledDistVec = (vOrigTextureCoord - 0.5) * 2.0 * vScaleCorrection;
    float dist = length(scaledDistVec);  // Euclidean distance
    
    // Rectangular mask to not bleed over other spritesheet assets
    float rectangularMask = step(max(abs(scaledDistVec.x), abs(scaledDistVec.y)), 1.0);
    
    // Clip token texture to handle padding on X/Y axis
    color *= getTokenTextureClip();
    
    // Precompute alpha-adjusted color
    vec4 alphaAdjustedColor = color * (vColor / vColor.a);
    
    // Process token color using a custom function
    vec4 processedColor = processTokenColor(alphaAdjustedColor);
    
    // Blend token texture, token ring, and token background
    vec4 ringColor = colorizeTokenRing(texture(tokenRingTexture, vRingTextureCoord), dist);
    vec4 backgroundColor = colorizeTokenBackground(texture(tokenRingTexture, vBackgroundTextureCoord), dist);
    vec4 blendedResult = blend(processedColor, blend(ringColor, backgroundColor) * rectangularMask);
    
    // Apply color overlay if the state is active
    if ( hasState(STATE_COLOR_OVER_SUBJECT) ) blendedResult = blend(colorOverlay * rectangularMask, blendedResult);
    
    // Apply final alpha adjustment
    result = blendedResult * 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 aMaskTextureCoord;
    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 vMaskTextureCoord;
    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;
      vMaskTextureCoord = aMaskTextureCoord;
      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 vMaskTextureCoord;
    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;
    }
  `;
}

/**
 * A modified version of the PIXI.smooth.DashLineShader that supports an offset.
 * @internal
 */
class DashLineShader extends PIXI.smooth.SmoothGraphicsShader {
  /**
   * @param {object} [options]             The options
   * @param {number} [options.dash=8]      The length of the dash
   * @param {number} [options.gap=5]       The length of the gap
   * @param {number} [options.offset=0]    The offset of the dashes
   */
  constructor({dash=8.0, gap=5.0, offset=0.0}={}) {
    const settings = {maxStyles: 16, maxTextures: 1, pixelLine: 1};
    const uniforms = {dash, gap, offset};
    super(settings, undefined, DashLineShader.#FRAGMENT_SHADER, uniforms);
  }

  /* -------------------------------------------- */

  /**
   * The fragment shader source.
   * @type {string}
   */
  static #FRAGMENT_SHADER = `\
    %PRECISION%

    varying vec4 vColor;
    varying vec4 vLine1;
    varying vec4 vLine2;
    varying vec4 vArc;
    varying float vType;
    varying float vTextureId;
    varying vec2 vTextureCoord;
    varying vec2 vTravel;
    uniform sampler2D uSamplers[%MAX_TEXTURES%];
    uniform float dash;
    uniform float gap;
    uniform float offset;

    %PIXEL_LINE%

    void main() {
      %PIXEL_COVERAGE%

      float d = dash * vTravel.y;
      if ( d > 0.0 ) {
        float g = gap * vTravel.y;
        if ( g > 0.0 ) {
          float o = offset * vTravel.y;
          float t = mod(vTravel.x - o, d + g);
          alpha *= mix(
            min((0.5 * d) + 0.5 - abs(t - (0.5 * d)), 1.0),
            max(abs(t - (0.5 * g) - d) - (0.5 * g) + 0.5, 0.0),
            step(d, t)
          );
        }
      } else {
        alpha = 0.0;
      }

      vec4 texColor;
      float textureId = floor(vTextureId + 0.5);

      %FOR_LOOP%

      gl_FragColor = vColor * texColor * alpha;
    }
  `;
}

var _module$o = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbstractBaseShader: AbstractBaseShader,
  AbstractDarknessLevelRegionShader: AbstractDarknessLevelRegionShader,
  AbstractWeatherShader: AbstractWeatherShader,
  AdaptiveBackgroundShader: AdaptiveBackgroundShader,
  AdaptiveColorationShader: AdaptiveColorationShader,
  AdaptiveDarknessShader: AdaptiveDarknessShader,
  AdaptiveIlluminationShader: AdaptiveIlluminationShader,
  AdaptiveLightingShader: AdaptiveLightingShader,
  AdaptiveVisionShader: AdaptiveVisionShader,
  AdjustDarknessLevelRegionShader: AdjustDarknessLevelRegionShader,
  AmplificationBackgroundVisionShader: AmplificationBackgroundVisionShader,
  AmplificationSamplerShader: AmplificationSamplerShader,
  BackgroundVisionShader: BackgroundVisionShader,
  BaseSamplerShader: BaseSamplerShader,
  BaselineIlluminationSamplerShader: BaselineIlluminationSamplerShader,
  BewitchingWaveColorationShader: BewitchingWaveColorationShader,
  BewitchingWaveIlluminationShader: BewitchingWaveIlluminationShader,
  BlackHoleDarknessShader: BlackHoleDarknessShader,
  ChromaColorationShader: ChromaColorationShader,
  ColorAdjustmentsSamplerShader: ColorAdjustmentsSamplerShader,
  ColorationVisionShader: ColorationVisionShader,
  ColorizeBrightnessShader: ColorizeBrightnessShader,
  DashLineShader: DashLineShader,
  DenseSmokeDarknessShader: DenseSmokeDarknessShader,
  DepthSamplerShader: DepthSamplerShader,
  EmanationColorationShader: EmanationColorationShader,
  EnergyFieldColorationShader: EnergyFieldColorationShader,
  FairyLightColorationShader: FairyLightColorationShader,
  FairyLightIlluminationShader: FairyLightIlluminationShader,
  FlameColorationShader: FlameColorationShader,
  FlameIlluminationShader: FlameIlluminationShader,
  FogColorationShader: FogColorationShader,
  FogSamplerShader: FogSamplerShader,
  FogShader: FogShader,
  ForceGridColorationShader: ForceGridColorationShader,
  GhostLightColorationShader: GhostLightColorationShader,
  GhostLightIlluminationShader: GhostLightIlluminationShader,
  GridShader: GridShader,
  HexaDomeColorationShader: HexaDomeColorationShader,
  HighlightRegionShader: HighlightRegionShader,
  IlluminationDarknessLevelRegionShader: IlluminationDarknessLevelRegionShader,
  IlluminationVisionShader: IlluminationVisionShader,
  LightDomeColorationShader: LightDomeColorationShader,
  MagicalGloomDarknessShader: MagicalGloomDarknessShader,
  OccludableSamplerShader: OccludableSamplerShader,
  PrimaryBaseSamplerShader: PrimaryBaseSamplerShader,
  PulseColorationShader: PulseColorationShader,
  PulseIlluminationShader: PulseIlluminationShader,
  RadialRainbowColorationShader: RadialRainbowColorationShader,
  RainShader: RainShader,
  RegionShader: RegionShader,
  RevolvingColorationShader: RevolvingColorationShader,
  RoilingDarknessShader: RoilingDarknessShader,
  SirenColorationShader: SirenColorationShader,
  SirenIlluminationShader: SirenIlluminationShader,
  SmokePatchColorationShader: SmokePatchColorationShader,
  SmokePatchIlluminationShader: SmokePatchIlluminationShader,
  SnowShader: SnowShader,
  StarLightColorationShader: StarLightColorationShader,
  SunburstColorationShader: SunburstColorationShader,
  SunburstIlluminationShader: SunburstIlluminationShader,
  SwirlingRainbowColorationShader: SwirlingRainbowColorationShader,
  TokenRingSamplerShader: TokenRingSamplerShader,
  TorchColorationShader: TorchColorationShader,
  TorchIlluminationShader: TorchIlluminationShader,
  VortexColorationShader: VortexColorationShader,
  VortexIlluminationShader: VortexIlluminationShader,
  WaveBackgroundVisionShader: WaveBackgroundVisionShader,
  WaveColorationShader: WaveColorationShader,
  WaveColorationVisionShader: WaveColorationVisionShader,
  WaveIlluminationShader: WaveIlluminationShader,
  WeatherShaderEffect: WeatherShaderEffect,
  types: _types$9
});

/**
 * @import Token from "../token.mjs";
 * @import {TurnMarkerAnimationConfigData} from "../_types.mjs"
 */

/**
 * The Turn Marker of a {@link foundry.canvas.placeables.Token}.
 */
class TokenTurnMarker extends PIXI.Container {
  /**
   * Construct a TokenTurnMarker by providing a Token object instance.
   * @param {Token} token    The Token that this Turn Marker belongs to
   */
  constructor(token) {
    if ( !(token instanceof foundry.canvas.placeables.Token) ) {
      throw new Error("The TokenTurnMarker may only be constructed with a Token instance.");
    }
    super();
    this.#token = token;
    this.zIndex = -Infinity;
  }

  /* -------------------------------------------- */

  /**
   * The Token who this Turn Marker belongs to.
   * @type {Token}
   */
  get token() {
    return this.#token;
  }

  #token;

  /* -------------------------------------------- */

  /**
   * The sprite of the Turn Marker.
   * @type {SpriteMesh}
   */
  mesh;

  /* -------------------------------------------- */

  /**
   * The animation configuration of the Turn Marker.
   * @type {TurnMarkerAnimationConfigData}
   */
  animation = {spin: 0, pulse: {speed: 0, min: 1, max: 1}};

  /**
   * Track whether the TokenTurnMarker has completed drawing.
   * 0 = Not yet drawn
   * 1 = Drawing
   * 2 = Drawn
   * @type {0|1|2}
   */
  #drawState = 0;

  /* -------------------------------------------- */

  /**
   * Draw the Turn Marker.
   * @returns {Promise<void>}
   */
  async draw() {
    if ( this.#drawState === 1 ) return; // Only draw once at a time
    this.#drawState = 1;
    if ( this.mesh ) {
      this.removeChild(this.mesh);
      this.mesh.destroy();
    }

    // Configure marker settings
    const defaultSettings = CONFIG.Combat.settings.turnMarker;
    const tokenSettings = this.#token.document.turnMarker;
    let config;
    switch ( this.#token.document.turnMarker.mode ) {
      case TOKEN_TURN_MARKER_MODES.DEFAULT:
        config = {...defaultSettings};
        break;
      case TOKEN_TURN_MARKER_MODES.CUSTOM:
        config = {...defaultSettings, ...tokenSettings};
        break;
    }
    if ( !config ) return;

    // Configure animation
    const animation = CONFIG.Combat.settings.getTurnMarkerAnimation(config.animation);
    const animationConfig = animation?.config ?? {};
    this.animation = foundry.utils.mergeObject({spin: 0, pulse: {speed: 0, min: 1, max: 1}}, animationConfig);

    // Create the SpriteMesh
    const fallback = CONFIG.Combat.fallbackTurnMarker;
    config.src ||= fallback;
    const texture = await loadTexture(config.src, {fallback});
    const mesh = new SpriteMesh(texture);
    mesh.anchor.set(0.5, 0.5);

    // Configure shader
    const shaderCls = animationConfig.shader ?? config.disposition ? ColorizeBrightnessShader : BaseSamplerShader;
    mesh.setShaderClass(shaderCls);
    this.#configureShader(mesh);
    const video = game.video.getVideoSource(texture);
    if ( video ) game.video.play(video, {volume: 0});

    // Add the Mesh and record as drawn
    this.mesh = this.addChild(mesh);
    this.#token.renderFlags.set({refreshPosition: true, refreshSize: true});
    this.#drawState = 2;
  }

  /* -------------------------------------------- */

  /**
   * Animate the Turn Marker.
   * @param {number} deltaTime    The delta time
   */
  animate(deltaTime) {
    if ( (this.#drawState !== 2) || !this.visible ) return;
    const a = this.animation;
    const t = canvas.app.ticker.lastTime;
    this.scale.set(a.pulse.min + ((a.pulse.max - a.pulse.min)
      * (0.5 + (0.5 * Math.sin(t * 2 * Math.PI * a.pulse.speed / 60000)))));
    this.rotation = (t * 2 * Math.PI * a.spin / 60000) % (2 * Math.PI);
  }

  /* -------------------------------------------- */

  /**
   * Configure shader uniforms according to shader class.
   * @param {SpriteMesh} mesh     The mesh being configured
   */
  #configureShader(mesh) {
    switch ( mesh.shader.constructor ) {
      case ColorizeBrightnessShader: {
        const u = mesh.shader.uniforms;
        Color.from(this.#token.getDispositionColor()).linear.applyRGB(u.tintLinear);
        u.grey = true;
        u.intensity = 1;
        break;
      }
    }
  }
}

/**
 * @import {DeepReadonly, ElevatedPoint, Point} from "@common/_types.mjs";
 * @import {TokenDisplayMode} from "@common/constants.mjs";
 * @import {TokenDetectionMode, TokenDimensions, TokenPosition} from "@common/documents/_types.mjs";
 * @import {DatabaseUpdateOperation} from "@common/abstract/_types.mjs";
 * @import {GridMeasurePathResult} from "@common/grid/_types.mjs";
 * @import {
 *   TokenFindMovementPathWaypoint, TokenFindMovementPathJob, TokenDragContext, TokenConstrainMovementPathOptions,
 *   TokenFindMovementPathOptions, TokenAnimationContext, TokenAnimationData, TokenAnimationOptions, ReticuleOptions,
 *   TokenAnimationTransition, TokenConstrainMovementPathWaypoint, TokenPlannedMovement, TokenTerrainMovementWaypoint,
 *   TokenConstrainedMovementWaypoint, TokenGetTerrainMovementPathWaypoint
 * } from "../../_types.mjs";
 * @import {
 *   TokenMovementCostFunction, TokenMeasureMovementPathOptions, TokenMeasureMovementPathWaypoint,
 *   TokenMeasuredMovementWaypoint, TokenMovementWaypoint
 * } from "../../documents/_types.mjs";
 * @import {PointSourcePolygonType} from "../geometry/_types.mjs";
 * @import PolygonVertex from "../geometry/edges/vertex.mjs";
 * @import {CanvasAnimationAttribute} from "../animation/_types.mjs";
 * @import Region from "./region.mjs";
 * @import BaseTokenRuler from "./tokens/base-ruler.mjs";
 * @import AbstractBaseFilter from "../rendering/filters/base-filter.mjs";
 * @import {LightSourceData} from "../sources/base-light-source.mjs";
 * @import {VisionSourceData} from "../sources/point-vision-source.mjs";
 * @import {PointLightSource, PointVisionSource} from "../sources/_module.mjs";
 * @import {Actor, Combatant, User} from "../../documents/_module.mjs";
 */

/**
 * A Token is an implementation of PlaceableObject which represents an {@link foundry.documents.Actor} within a viewed
 * Scene on the game canvas.
 * @category Canvas
 * @see {@link foundry.documents.TokenDocument}
 * @see {@link foundry.canvas.layers.TokenLayer}
 */
let Token$1 = class Token extends PlaceableObject {
  /**
   *
   * @param {TokenDocument} document   The TokenDocument that this Token represents
   */
  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", "refreshRuler", "refreshTurnMarker"], 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: {},
    refreshRuler: {},
    refreshTurnMarker: {},
    /** @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|PIXI.Ellipse}
   */
  shape;

  /**
   * Defines the filter to use for detection.
   * @type {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 arrows marker, which indicates that this Token is targeted by this User.
   * @type {PIXI.Graphics}
   */
  targetArrows;

  /**
   * The target pips marker, which indicates that this Token is targeted by other User(s).
   * @type {PIXI.Graphics}
   */
  targetPips;

  /**
   * The nameplate of this Token, which displays its name.
   * @type {PreciseText}
   */
  nameplate;

  /**
   * The ruler of this Token.
   * @type {BaseTokenRuler|null}
   */
  ruler;

  /**
   * The ruler data.
   * @type {{[userId: string]: TokenPlannedMovement}}
   * @protected
   */
  _plannedMovement = {};

  /**
   * 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.Container}
   */
  voidMesh;

  /**
   * Renders the mesh of with the detection filter.
   * @type {PIXI.Container}
   */
  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;

  /**
   * The Turn Marker of this Token.
   * Only a subset of Token objects have a turn marker at any given time.
   * @type {TokenTurnMarker|null}
   */
  turnMarker = null;

  /**
   * The center point adjustment. See {@link Token#getMovementAdjustedPoint}.
   * @type {Point}
   */
  #centerOffset;

  /**
   * The animated center point adjustment.
   * @type {Point}
   */
  #animationCenterOffset;

  /**
   * The Token central coordinate, adjusted for its most recent movement vector.
   * @type {Point}
   */
  #adjustedCenter;

  /**
   * A flag to capture whether this Token has an unlinked video texture.
   * @type {boolean}
   */
  #unlinkedVideo = false;

  /**
   * 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, AbstractBaseFilter>}
   */
  #filterEffects = new Map();

  /**
   * The current animations of this Token.
   * @type {Map<string, TokenAnimationContext>}
   */
  get animationContexts() {
    return this.#animationContexts;
  }

  #animationContexts = new Map();

  /**
   * The general animation name used for this Token.
   * @type {string}
   */
  get animationName() {
    return this.#animationName ??= `${this.objectId}.animate`;
  }

  #animationName;

  /**
   * The animation name used to animate this Token's movement.
   * @type {string}
   */
  get movementAnimationName() {
    return this.#movementAnimationName ??= `${this.objectId}.animateMovement`;
  }

  #movementAnimationName;

  /**
   * The promise of the current movement animation chain of this Token
   * or null if there isn't a movement animation in progress.
   * @type {Promise<void>|null}
   */
  get movementAnimationPromise() {
    const context = this.#animationContexts.get(this.movementAnimationName);
    if ( !context ) return null;
    return context.chain.at(-1)?.promise ?? context.promise;
  }

  /**
   * Should the ruler of this Token be visible?
   * @type {boolean}
   */
  get showRuler() {
    if ( this.#showRuler ) return true;
    for ( const [userId, {hidden}] of Object.entries(this._plannedMovement) ) {
      if ( !hidden || (userId === game.user.id) ) return true;
    }
    return false;
  }

  #showRuler;

  /**
   * Prevent keyboard movement of this Token?
   * @type {boolean}
   * @internal
   */
  _preventKeyboardMovement = false;

  /**
   * 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 {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 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.#centerOffset = {x: Math.sign(r.dx), y: Math.sign(r.dy)};
    this.#animationCenterOffset = {x: this.#centerOffset.x, y: this.#centerOffset.y};
    this.#adjustedCenter = this.getMovementAdjustedPoint(this.document.getCenterPoint());

    // Initialize animation data
    this.#animationData = foundry.utils.deepSeal(this._getAnimationData());
    this.#priorAnimationData = foundry.utils.deepSeal(foundry.utils.deepClone(this.#animationData));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  clone() {
    const clone = super.clone();
    clone.#centerOffset.x = clone.#animationCenterOffset.x = this.#centerOffset.x;
    clone.#centerOffset.y = clone.#animationCenterOffset.y = this.#centerOffset.y;
    clone.#adjustedCenter = clone.getMovementAdjustedPoint(clone.document.getCenterPoint());
    return clone;
  }

  /* -------------------------------------------- */

  /**
   * 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, 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.document.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.document.getSize().width;
  }

  /* -------------------------------------------- */

  /**
   * Translate the token's grid height into a pixel height based on the canvas size
   * @type {number}
   */
  get h() {
    return this.document.getSize().height;
  }

  /* -------------------------------------------- */

  /** @override */
  get center() {
    const {x, y} = this.document.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.
   * @overload
   * @param {ElevatedPoint} point The center point with elevation.
   * @param {object} [options]
   * @param {number} [options.offsetX] The x-offset.
   * @param {number} [options.offsetY] The y-offset.
   * @returns {ElevatedPoint} The adjusted center point.
   */
  /**
   * @overload
   * @param {Point} point The center point.
   * @param {object} [options]
   * @param {number} [options.offsetX] The x-offset.
   * @param {number} [options.offsetY] The y-offset.
   * @returns {Point} The adjusted center point.
   */
  getMovementAdjustedPoint(point, {offsetX, offsetY}={}) {
    const x = Math.round(point.x);
    const y = Math.round(point.y);
    const elevation = point.elevation;
    point = elevation !== undefined ? {x, y, elevation} : {x, y};
    offsetX ??= this.#centerOffset.x;
    offsetY ??= this.#centerOffset.y;
    if ( (offsetX === 0) && (offsetY === 0) ) return point;

    // Define a bounding box around the point to query relevant edges using the Quadtree
    const bounds = new PIXI.Rectangle(point.x, point.y, 0, 0);

    // Define a collisionTest that returns only edges that block movement and are collinear with the point.
    const collisionTest = edge => (edge.move && (foundry.utils.orient2dFast(edge.a, edge.b, point) === 0));

    // Retrieve candidate edges. Include inner bounds.
    const size = canvas.edges.getEdges(bounds, {
      includeInnerBounds: true,
      includeOuterBounds: false,
      collisionTest
    }).size;

    // If at least one collinear, blocking edge is found, offset the point.
    if ( size > 0 ) {
      point.x -= offsetX;
      point.y -= offsetY;
    }
    return point;
  }

  /* -------------------------------------------- */

  /**
   * The HTML source element for the primary Tile texture
   * @type {PIXI.ImageSource|null}
   */
  get sourceElement() {
    return this.texture?.baseTexture.resource.source ?? null;
  }

  /* -------------------------------------------- */

  /** @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);
  }

  /* -------------------------------------------- */

  /**
   * Is this Token currently being dragged?
   * @type {boolean}
   */
  get isDragged() {
    return !!this.#getDragContext();
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the detection modes array.
   * @type {TokenDetectionMode[]}
   */
  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.document.getSize();
    const tolerance = Math.min(width, height) / 4;
    return canvas.visibility.testVisibility(this.center, {tolerance, object: this});
  }

  /* -------------------------------------------- */
  /*  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 token 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 token 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) : 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);
  }

  /* -------------------------------------------- */

  /**
   * 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);
    }
    return this.getLightRadius(Math.max(this.document.sight.range, 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.document.getCenterPoint(),
      this.#animationCenterOffset);
    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}={}) {
    // Gather current state
    const sourceId = this.sourceId;
    const wasLight = canvas.effects.lightSources.has(sourceId);
    const wasDarkness = canvas.effects.darknessSources.has(sourceId);
    const previousPriority = this.lightSource?.priority ?? 0;
    const actualPriority = this.document.light.priority ?? 0;
    const isDarkness = this.document.light.negative;
    const perceptionFlags = {
      refreshEdges: wasDarkness || isDarkness,
      initializeVision: wasDarkness || isDarkness,
      initializeLighting: wasDarkness || isDarkness,
      refreshLighting: true,
      refreshVision: true
    };

    // Did the lightsource previously create edges?
    const edgesBefore = wasDarkness || (wasLight && (previousPriority > 0));

    // Should the lightsource create edges now?
    const edgesNow = isDarkness || (actualPriority > 0);

    // Check for key changes
    const darknessChanged = (wasDarkness !== isDarkness);
    const priorityChanged = (previousPriority !== actualPriority);
    const edgesChanged = (edgesBefore !== edgesNow);
    const fullUpdate = darknessChanged || edgesChanged || priorityChanged;

    // Handle deletion
    if ( deleted || !this._isLightSource() ) {
      if ( !this.light ) return;
      if ( this.light.active ) canvas.perception.update(perceptionFlags);
      this.#destroyLightSource();
      return;
    }

    // Otherwise handle potential recreation
    if ( fullUpdate ) 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();

    // If darkness or edges changed, we need a full edge-based refresh
    if ( fullUpdate ) {
      perceptionFlags.refreshEdges = perceptionFlags.initializeVision = perceptionFlags.initializeLighting = true;
    }

    // Update perception and rendering
    canvas.perception.update(perceptionFlags);
  }

  /* -------------------------------------------- */

  /**
   * Get the light source data.
   * @returns {LightSourceData}
   * @protected
   */
  _getLightSourceData() {
    const {x, y} = this.#adjustedCenter;
    const {elevation, rotation} = this.document;
    const lightDoc = this.document.light;
    return foundry.utils.mergeObject(lightDoc.toObject(false), {
      x, y, elevation, rotation,
      dim: this.getLightRadius(lightDoc.dim),
      bright: this.getLightRadius(lightDoc.bright),
      externalRadius: this.externalRadius,
      seed: this.document.getFlag("core", "animationSeed"),
      preview: this.isPreview,
      disabled: !this._isLightSource() || this.#isUnreachableDragPreview
    });
  }

  /* -------------------------------------------- */

  /**
   * 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 {x, y} = this.#adjustedCenter;
    const {elevation, rotation} = this.document;
    const sight = this.document.sight;
    return {
      x, y, elevation, rotation,
      radius: this.sightRange,
      lightRadius: this.lightPerceptionRange,
      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: this.#isUnreachableDragPreview
    };
  }

  /* -------------------------------------------- */

  /**
   * 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() {
    this.ruler?.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.close();
    return this;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _destroy(options) {
    this.#cancelDrag();
    this._removeAllFilterEffects();
    this.stopAnimation();                       // Cancel movement animations
    canvas.primary.removeToken(this);           // Remove the PrimarySpriteMesh from the PrimaryCanvasGroup
    this.#destroyLightSource();                 // Destroy the LightSource
    this.#destroyVisionSource();                // Destroy the VisionSource
    this.ruler?.destroy();                      // Destroy the ruler
    if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy();  // Destroy base texture if the token has an unlinked video
    if ( this.turnMarker ) canvas.tokens.turnMarkers.delete(this); // Unregister active turn marker
    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 token's PrimarySpriteMesh 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 token's PrimarySpriteMesh
    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 token's PrimarySpriteMesh
    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.targetArrows ||= this.addChild(new PIXI.Graphics());
    this.targetPips ||= this.addChild(new PIXI.Graphics());
    this.nameplate ||= this.addChild(this.#drawNameplate());
    this.sortableChildren = true;

    // Initialize and draw the ruler
    if ( this.ruler === undefined ) this.ruler = this._initializeRuler();
    if ( this.ruler ) await this.ruler.draw();

    // Add filter effects
    this._updateSpecialStatusFilterEffects();

    // Draw elements
    await this._drawEffects();

    // Initialize sources
    if ( !this.isPreview ) this.initializeSources();
  }

  /* -------------------------------------------- */

  /**
   * Create the BaseTokenRuler instance for this Token, if any.
   * This function is called when the Token is drawn for the first time.
   * @returns {BaseTokenRuler|null}
   * @protected
   */
  _initializeRuler() {
    const rulerClass = CONFIG.Token.rulerClass;
    if ( !rulerClass ) return null;
    return new rulerClass(this);
  }

  /* -------------------------------------------- */

  /**
   * 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.document.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();
    if ( flags.refreshTurnMarker ) this._refreshTurnMarker();
    if ( flags.refreshRuler ) this._refreshRuler();
  }

  /* -------------------------------------------- */

  /**
   * 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.border.zIndex = isHover ? Infinity : -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.targetPips.visible = this.targetArrows.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;
    if ( this.ruler ) this.ruler.visible = this.ruler.isVisible;
  }

  /* -------------------------------------------- */

  /**
   * Resize mesh and handle scale adjustment.
   * @protected
   */
  _refreshMeshSizeAndScale() {
    const {width, height} = this.document.getSize();
    const fit = this.document.texture.fit;
    let {scaleX, scaleY} = this.document.texture;

    // Configure ring size
    if ( this.hasDynamicRing ) {
      this.ring.configureSize({fit});

      // Handle dynamic ring scale adjustments
      if ( CONFIG.Token.ring.isGridFitMode ) {
        scaleX *= this.ring.subjectScaleAdjustment;
        scaleY *= this.ring.subjectScaleAdjustment;
      }
    }

    // Resize the mesh based on the computed fit and scale
    this.mesh.resize(width, height, {fit, scaleX, scaleY});
  }

  /* -------------------------------------------- */

  /**
   * Refresh the size.
   * @protected
   */
  _refreshSize() {
    this._refreshMeshSizeAndScale();

    // Adjust nameplate and tooltip positioning
    const {width, height} = this.document.getSize();

    this.nameplate.position.set(width / 2, height + 2);
    this.tooltip.position.set(width / 2, -2);

    // Adjust turn marker size (150% size by default);
    if ( this.turnMarker ) {
      const mesh = this.turnMarker.mesh;
      mesh.width = mesh.height = this.externalRadius * 3;
    }
  }

  /* -------------------------------------------- */

  /**
   * Refresh the token mesh.
   * @protected
   */
  _refreshMesh() {
    this._refreshMeshSizeAndScale();

    // Set additional properties specific to the token mesh
    const {alpha, texture: {anchorX, anchorY, tint, alphaThreshold}} = this.document;

    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 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);
    const center = this.mesh.position = this.center;
    if ( this.turnMarker ) this.turnMarker.position.set(center.x - x, center.y - y);
  }

  /* -------------------------------------------- */

  /**
   * 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 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 * canvas.dimensions.uiScale;
    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;
    return this.getDispositionColor();
  }

  /* -------------------------------------------- */

  /**
   * Get the Color used to represent the disposition of this Token.
   * @returns {number}
   */
  getDispositionColor() {
    const colors = CONFIG.Canvas.dispositionColors;
    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: return colors.INACTIVE;
    }
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * 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.
   * @protected
   */
  _refreshTarget() {
    this._drawTargetArrows({border: {width: 2 * canvas.dimensions.uiScale}});
    this._drawTargetPips();
  }

  /* -------------------------------------------- */

  /**
   * Draw the targeting arrows around this token.
   * @param {ReticuleOptions} [reticule]  Additional parameters to configure how the targeting reticule is drawn.
   * @protected
   */
  _drawTargetArrows({margin: m=0, alpha=1, size, color, border: {width=2, color: lineColor=0}={}}={}) {
    size ??= CONFIG.Canvas.targeting.size;
    this.targetArrows.clear();

    // We don't show the target arrows for a secret token disposition, non-GM users or for other users.
    if ( !this.targeted.size || !this.targeted.has(game.user) ) return;

    const l = size * 100 * canvas.dimensions.uiScale; // 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.targetArrows.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
  }

  /* -------------------------------------------- */

  /**
   * Draw the targeting pips around this token.
   * @protected
   */
  _drawTargetPips() {
    this.targetPips.clear();

    // We don't show the target arrows for a secret token disposition and non-GM users
    if ( !this.targeted.size ) return;

    // Get other users targeting this token
    const otherUsers = Array.from(this.targeted).filter(u => u !== game.user);
    if ( !otherUsers.length ) return;

    // For other users, draw offset pips
    const s = canvas.dimensions.uiScale;
    const hw = (this.w / 2) + (otherUsers.length % 2 === 0 ? 8 : 0);
    for ( const [i, u] of otherUsers.entries() ) {
      const offset = Math.floor((i+1) / 2) * 16;
      const sign = i % 2 === 0 ? 1 : -1;
      const x = hw + (sign * offset);
      this.targetPips.beginFill(u.color, 1.0).lineStyle(2 * s, 0x0000000).drawCircle(x, 0, 6 * s);
    }
  }

  /* -------------------------------------------- */

  /**
   * 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 === 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.document.getSize();
    const s = canvas.dimensions.uiScale;
    const bw = width;
    const bh = 8 * (this.document.height >= 2 ? 1.5 : 1) * s;

    // 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(s, 0x000000, 1.0);
    bar.beginFill(0x000000, 0.5).drawRoundedRect(0, 0, bw, bh, 3 * s);
    bar.beginFill(color, 1.0).drawRoundedRect(0, 0, pct * bw, bh, 2 * s);

    // 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 s = canvas.dimensions.uiScale;
    const nameplate = new PreciseText(this.document.name, this._getTextStyle());
    nameplate.anchor.set(0.5, 0);
    nameplate.scale.set(s, s);
    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 s = canvas.dimensions.uiScale;
    const tooltip = new PreciseText(this._getTooltipText(), this._getTextStyle());
    tooltip.anchor.set(0.5, 1);
    tooltip.scale.set(s, s);
    return tooltip;
  }

  /* -------------------------------------------- */

  /**
   * Return the text which should be displayed in a token's tooltip field
   * @returns {string}
   * @protected
   */
  _getTooltipText() {
    const elevation = this.document.elevation.toNearest(0.01);
    if ( !Number.isFinite(elevation) || (elevation === 0) ) return "";
    return `${elevation.signedString()} ${canvas.grid.units}`.trim();
  }

  /* -------------------------------------------- */

  /**
   * Get the text style that should be used for this Token's tooltip.
   * @returns {PIXI.TextStyle}
   * @protected
   */
  _getTextStyle() {
    const style = CONFIG.canvasTextStyle.clone();
    style.fontSize = 24;
    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() {
    const s = canvas.dimensions.uiScale;
    let i = 0;
    const size = 20 * s;
    const rows = Math.floor((this.document.getSize().height / size) + 1e-6);
    const bg = this.effects.bg.clear().beginFill(0x000000, 0.40).lineStyle(s, 0x000000);
    for ( const effect of this.effects.children ) {
      if ( effect === bg ) continue;

      // Overlay effect
      if ( effect === this.effects.overlay ) {
        const {width, height} = this.document.getSize();
        const size = Math.min(width * 0.6, height * 0.6);
        effect.width = effect.height = size;
        effect.position = this.document.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 + s, effect.y + s, size - (2 * s), size - (2 * s), 2 * s);
        i++;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Refresh presentation of the Token's combat turn marker, if any.
   * @protected
   */
  _refreshTurnMarker() {

    // Should a Turn Marker be active?
    const {turnMarker} = this.document;
    const markersEnabled = CONFIG.Combat.settings.turnMarker.enabled
      && (turnMarker.mode !== CONST.TOKEN_TURN_MARKER_MODES.DISABLED);
    const isTurn = game.combat?.combatant?.tokenId === this.id;
    const markerActive = markersEnabled && isTurn;

    // Activate a Turn Marker
    if ( markerActive ) {
      if ( !this.turnMarker ) this.turnMarker = this.addChildAt(new TokenTurnMarker(this), 0);
      canvas.tokens.turnMarkers.add(this);
      this.turnMarker.draw();
    }

    // Remove a Turn Marker
    else if ( this.turnMarker ) {
      canvas.tokens.turnMarkers.delete(this);
      this.turnMarker.destroy();
      this.turnMarker = null;
    }
  }

  /* -------------------------------------------- */

  /**
   * Refresh the display of the ruler.
   * @protected
   */
  _refreshRuler() {
    if ( !this.ruler ) return;
    const movement = this.document.movement;
    let passedWaypoints;
    let pendingWaypoints;
    if ( movement.showRuler && ((movement.state === "pending") || (movement.state === "paused")
      || this.animationContexts.has(this.movementAnimationName)) ) {
      passedWaypoints = [
        ...movement.history.recorded.waypoints,
        ...movement.history.unrecorded.waypoints,
        ...movement.passed.waypoints
      ];
      if ( (movement.state === "pending") || (movement.state === "paused") ) pendingWaypoints = movement.pending.waypoints;
      else pendingWaypoints = [];
    } else {
      passedWaypoints = this.document.movementHistory;
      pendingWaypoints = [];
    }
    const plannedMovement = {};
    for ( const [userId, movement] of Object.entries(this._plannedMovement) ) {
      if ( movement.hidden && (userId !== game.user.id) ) continue;
      plannedMovement[userId] = movement;
    }
    this.ruler.refresh({passedWaypoints, pendingWaypoints, plannedMovement});
  }

  /* -------------------------------------------- */

  /**
   * Helper method to determine whether a token attribute is viewable under a certain mode
   * @param {TokenDisplayMode} mode   The mode from {@link CONST.TOKEN_DISPLAY_MODES}
   * @returns {boolean}                  Is the attribute viewable?
   * @protected
   */
  _canViewMode(mode) {
    if ( mode === TOKEN_DISPLAY_MODES.NONE ) return false;
    else if ( mode === TOKEN_DISPLAY_MODES.ALWAYS ) return true;
    else if ( mode === TOKEN_DISPLAY_MODES.CONTROL ) return this.controlled;
    else if ( mode === TOKEN_DISPLAY_MODES.HOVER ) return this.hover || this.layer.highlightObjects;
    else if ( mode === TOKEN_DISPLAY_MODES.OWNER_HOVER ) return this.isOwner
      && (this.hover || this.layer.highlightObjects);
    else if ( mode === 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.placeables.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, elevation, 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, elevation, 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 {TokenAnimationOptions} [options]     The options that configure the animation behavior
   * @returns {Promise<void>}                     A promise which resolves once the animation has finished or stopped
   */
  animate(to, options={}) {
    return this.#animate(to, options, false);
  }

  /* -------------------------------------------- */

  /**
   * Animate from the old to the new state of this Token.
   * @param {Partial<TokenAnimationData>} to    The animation data to animate to
   * @param {TokenAnimationOptions} options     The options that configure the animation behavior
   * @param {boolean} chained                   Is this animation being chained to the current context?
   * @returns {Promise<void>}                   A promise which resolves once the animation has finished or stopped
   */
  #animate(to, options, chained) {

    /** @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
    let name = options.name;
    if ( name === undefined ) name = this.animationName;
    else name ||= Symbol(this.animationName);
    let from = this.#animationData;
    to = foundry.utils.filterObject(to, this.#animationData);
    let context = this.#animationContexts.get(name);

    // Use default options of movement action
    if ( TokenDocument._isMovementUpdate(to, from) ) {
      options = {...options};
      options.action ??= this.document.movementAction;
      const defaults = CONFIG.Token.movement.actions[options.action].getAnimationOptions(this);
      for ( const key in defaults ) options[key] ??= defaults[key];
    }

    let duration = options.duration;

    // Chain to exiting animation if requested
    if ( context && options.chain ) {

      // Get the animation duration ahead of time
      if ( duration === undefined ) {
        from = foundry.utils.mergeObject(from, context.to, {inplace: false});
        for ( const {to} of context.chain ) foundry.utils.mergeObject(from, to);
        const changes = foundry.utils.diffObject(from, to);
        duration = (foundry.utils.isEmpty(changes) ? 0 : this._getAnimationDuration(from, to, options));
      }

      let chainLink;
      const chainPromise = new Promise((resolve, reject) => {
        chainLink = {to, options: {...options, duration, chain: false}, promise: null, resolve, reject};
        context.chain.push(chainLink);
      });
      chainLink.promise = chainPromise;
      return chainPromise;
    }

    let chain;
    let time;

    // This animation was chained to the current context
    if ( chained ) {
      chain = context.chain;
      time = context.time - context.duration;
      this.#animationContexts.delete(name);
      for ( const fn of context.postAnimate ) fn(context);

      // Resolve current animation before the chained animation starts
      const animation = CanvasAnimation.getAnimation(name);
      animation?.resolve(true);
    }

    // Otherwise merge into existing animation if there is one
    else {
      if ( context ) to = foundry.utils.mergeObject(context.to, to, {inplace: false});

      // Conclude the current animation
      if ( context ) this.#animationContexts.delete(name);
      CanvasAnimation.terminateAnimation(name);
      if ( context ) {
        for ( const fn of context.postAnimate ) fn(context);
        for ( const {resolve} of context.chain ) resolve();
      }

      chain = [];
      time = 0;
    }

    // Compute animation data changes
    const changes = foundry.utils.diffObject(from, to);

    // Get the animation duration and create the animation context
    duration ??= (foundry.utils.isEmpty(changes) ? 0 : this._getAnimationDuration(from, to, options));
    let resolve;
    context = {name, chain, to, duration, time: 0, preAnimate: [], postAnimate: [], onAnimate: [],
      promise: new Promise(r => { resolve = r; })};

    // Set the animation context
    this.#animationContexts.set(name, context);

    // Prepare the animation data changes
    const attributes = this._prepareAnimation(from, changes, context, options);

    // Dispatch the animation
    const {easing, ontick} = options;
    CanvasAnimation.animate(attributes, {
      name,
      context: this,
      time,
      duration,
      easing,
      priority: PIXI.UPDATE_PRIORITY.OBJECTS + 1, // Before perception updates and Token render flags
      wait: context.preAnimate.length !== 0 ? Promise.all(context.preAnimate.map(fn => fn(context))) : undefined,
      ontick: (elapsedMS, animation) => {
        context.time = animation.time;
        if ( ontick ) ontick(elapsedMS, animation, this.#animationData);
        this.#animateFrame(context);
      }
    }).finally(() => {
      if ( this.#animationContexts.get(name) === context ) {
        this.#animationContexts.delete(name);
        for ( const fn of context.postAnimate ) fn(context);
        for ( const {resolve} of context.chain ) resolve();
      }
      resolve();
    });
    return context.promise;
  }

  /* -------------------------------------------- */

  /**
   * Get the duration of the animation.
   * @param {DeepReadonly<TokenAnimationData>} from           The animation data to animate from
   * @param {DeepReadonly<Partial<TokenAnimationData>>} to    The animation data to animate to
   * @param {TokenAnimationOptions} options                   The options that configure the animation behavior
   * @returns {number}                                        The duration of the animation in milliseconds
   * @protected
   */
  _getAnimationDuration(from, to, options) {
    let duration;
    if ( TokenDocument._isMovementUpdate(to, from) ) {
      const movementSpeed = this._modifyAnimationMovementSpeed(options.movementSpeed
        ?? this._getAnimationMovementSpeed(options), options);
      duration = Token.#getMovementAnimationDuration(from, to, movementSpeed);
    }
    const dr = from.rotation - (to.rotation ?? from.rotation);
    if ( dr ) {
      duration ??= 0;
      if ( this._requiresRotationAnimation() ) {
        const rotationSpeed = this._getAnimationRotationSpeed(options);
        duration = Math.max(duration, Math.abs(((Math.abs(dr) + 180) % 360) - 180) / (rotationSpeed * 60) * 1000);
      }
    }
    return duration ?? 1000; // The default animation duration is 1 second
  }

  /* -------------------------------------------- */

  /**
   * Get the base movement speed for the animation in grid size per second.
   * The default implementation returns `CONFIG.Token.movement.defaultSpeed`.
   * @param {TokenAnimationOptions} options                   The options that configure the animation behavior
   * @returns {number}                                        The base movement speed in grid size per second
   * @protected
   */
  _getAnimationMovementSpeed(options) {
    return CONFIG.Token.movement.defaultSpeed;
  }

  /* -------------------------------------------- */

  /**
   * Modify the base movement speed of the animation.
   * Divides by the terrain difficulty, if present, by default.
   * @param {number} speed                                    The base movement speed in grid size per second
   * @param {TokenAnimationOptions} options                   The options that configure the animation behavior
   * @returns {number}                                        The modified movement speed in grid size per second
   * @protected
   */
  _modifyAnimationMovementSpeed(speed, options) {
    if ( options.terrain instanceof foundry.data.TerrainData ) speed /= Math.min(options.terrain.difficulty, 10);
    return speed;
  }

  /* -------------------------------------------- */

  /**
   * Calculate the movement animation duration.
   * @param {Omit<TokenPosition, "elevation"|"shape">} from           The from-position
   * @param {Partial<Omit<TokenPosition, "elevation"|"shape">>} to    The (partial) to-position
   * @param {number} movementSpeed                                    The movement speed
   * @returns {number}                                                The movement animation duration
   */
  static #getMovementAnimationDuration(from, to, movementSpeed) {
    const dx = from.x - (to.x ?? from.x);
    const dy = from.y - (to.y ?? from.y);
    const dw = from.width - (to.width ?? from.width);
    const dh = from.height - (to.height ?? from.height);
    return Math.max(Math.hypot(dx, dy) / canvas.dimensions.size, Math.hypot(dw, dh) * 0.5) / movementSpeed * 1000;
  }

  /* -------------------------------------------- */

  /**
   * Configure the animation movement speed based on the given animation duration.
   * @param {DatabaseUpdateOperation} operation    The update operation
   * @param {TokenPosition} origin                 The origin
   * @param {TokenMovementWaypoint[]} waypoints    The candidante waypoints
   * @param {TokenDocument} document               The token document
   * @internal
   */
  static _configureAnimationMovementSpeed(operation, origin, waypoints, document) {
    if ( !document.rendered ) return;
    const animationDuration = operation.animation?.duration;
    if ( (animationDuration === undefined) || (operation.animation.movementSpeed !== undefined) ) return;
    if ( animationDuration === 0 ) operation.animation.movementSpeed = Number.MAX_VALUE;
    else {
      let normalizedDuration = 0;
      let previousWaypoint = origin;
      for ( const waypoint of waypoints ) {
        if ( CONFIG.Token.movement.actions[waypoint.action].getAnimationOptions(document.object).duration !== 0 ) {
          normalizedDuration += this.#getMovementAnimationDuration(previousWaypoint, waypoint, 1);
        }
        previousWaypoint = waypoint;
      }
      if ( normalizedDuration !== 0 ) {
        operation.animation.movementSpeed = Math.min(normalizedDuration / animationDuration, Number.MAX_VALUE);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the rotation speed for the animation in 60 degrees per second.
   * Returns the movement speed by default.
   * @param {TokenAnimationOptions} options                   The options that configure the animation behavior
   * @returns {number}                                        The rotation speed in 60 degrees per second
   * @protected
   */
  _getAnimationRotationSpeed(options) {
    return options.movementSpeed ?? this._getAnimationMovementSpeed(options);
  }

  /* -------------------------------------------- */

  /**
   * Does this Token require rotation changes to be animated?
   * If false is returned, the rotation speed is set to infinity.
   * @returns {boolean}
   * @protected
   */
  _requiresRotationAnimation() {
    return !this.document.lockRotation || this.hasLimitedSourceAngle;
  }

  /* -------------------------------------------- */

  /**
   * Handle a single frame of a token animation.
   * @param {TokenAnimationContext} context    The animation context
   */
  #animateFrame(context) {
    const completed = context.time >= context.duration;
    if ( completed ) foundry.utils.mergeObject(this.#animationData, context.to);
    const animationData = foundry.utils.filterObject(this.#animationData, context.to);
    const changed = foundry.utils.diffObject(this.#priorAnimationData, animationData);
    foundry.utils.mergeObject(this.#priorAnimationData, changed);
    foundry.utils.mergeObject(this.document, this.#animationData, {insertKeys: false});
    for ( const fn of context.onAnimate ) fn(context);
    this._onAnimationUpdate(changed, context);
    if ( completed ) this.#completeAnimation(context);
  }

  /* -------------------------------------------- */

  /**
   * Complete the token animation.
   * @param {TokenAnimationContext} context    The animation context
   */
  #completeAnimation(context) {
    if ( context.chain.length === 0 ) {
      this.#animationContexts.delete(context.name);
      for ( const fn of context.postAnimate ) fn(context);
    } else {
      const {to, options, resolve} = context.chain.shift();
      // noinspection ES6MissingAwait
      this.#animate(to, options, true).finally(resolve);
    }
  }

  /* -------------------------------------------- */

  /**
   * 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 elevationChanged = "elevation" 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,
      refreshElevation: elevationChanged,
      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 || elevationChanged || sizeChanged ) {
      canvas.perception.update({refreshSounds: true, refreshOcclusionMask: true, refreshOcclusionStates: true});
      if ( this.hasActiveHUD ) this.layer.hud.setPosition();
    }

    // Update light and sight sources unless Vision Animation is disabled
    if ( !game.settings.get("core", "visionAnimation") ) return;
    const perspectiveChanged = positionChanged || elevationChanged || 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, context] of this.#animationContexts.entries() ) {
      CanvasAnimation.terminateAnimation(name);
      for ( const fn of context.postAnimate ) fn(context);
      for ( const {resolve} of context.chain ) resolve();
    }
    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), chain: [], to, duration: 0, time: 0,
      preAnimate: [], postAnimate: [], onAnimate: [], promise: Promise.resolve()};
    this.#animationContexts.set(context.name, context);
    this._onAnimationUpdate(changes, context);
    this.#animationContexts.clear();
  }

  /* -------------------------------------------- */
  /*  Animation Preparation Methods               */
  /* -------------------------------------------- */

  /**
   * Handle the rotation changes for the animation, ensuring the shortest rotation path.
   * @param {DeepReadonly<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 {TokenAnimationOptions} options    The options that configure the animation behavior
   * @returns {TextureTransitionFilter}        The created filter
   */
  #createTransitionFilter(options) {
    const filter = TextureTransitionFilter.create();
    filter.enabled = false;
    filter.type = options.transition ?? this._getAnimationTransition(options);
    return filter;
  }

  /* -------------------------------------------- */

  /**
   * Get the texture transition type.
   * Returns `"fade"` by default.
   * @param {TokenAnimationOptions} options    The options that configure the animation behavior
   * @returns {TokenAnimationTransition}       The transition type
   * @protected
   */
  _getAnimationTransition(options) {
    return "fade";
  }

  /* -------------------------------------------- */

  /**
   * Prepare the animation data changes: performs special handling required for animating rotation.
   * @param {DeepReadonly<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 {TokenAnimationOptions} options                     The options that configure the animation behavior
   * @returns {CanvasAnimationAttribute[]}                      The animation attributes
   * @protected
   */
  _prepareAnimation(from, changes, context, options) {
    const attributes = [];

    // TODO: handle teleportation
    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>} changes               The animation data that changed
   * @param {Omit<TokenAnimationContext, "promise">} context    The animation context
   * @param {TokenAnimationOptions} options                     The options that configure the animation behavior
   * @param {CanvasAnimationAttribute[]} attributes             The array to push animation attributes to
   */
  #handleTransitionChanges(changes, context, options, attributes) {
    const textureChanged = ("texture" in changes) && ("src" in changes.texture);
    const ringEnabled = this.document.ring.enabled;
    const subjectTextureChanged = ringEnabled && ("ring" in changes) && ("subject" in changes.ring) && ("texture" in changes.ring.subject);

    // If no texture has changed, no need for a transition
    if ( !(textureChanged || subjectTextureChanged) ) return;

    const filter = this.#createTransitionFilter(options);
    let renderTexture;
    let targetMesh;
    let targetToken;

    if ( this.mesh ) {
      this.mesh.filters ??= [];
      this.mesh.filters.unshift(filter);
    }

    context.preAnimate.push(async context => {
      const targetAsset = !ringEnabled ? changes.texture.src
        : (subjectTextureChanged ? changes.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;
    });

    context.postAnimate.push(async context => {
      await Promise.resolve();
      // Clean up after the current tick because the redraw triggered by _onAnimationUpdate
      // won't take effect in the last frame of the animation
      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;
    });

    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
   */
  #prepareTargetToken(targetTexture) {
    const cloneDoc = this.document.clone();
    const clone = cloneDoc.object;
    clone.texture = targetTexture;
    return clone;
  }

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  /**
   * Get the drop position for the given token.
   * @param {TokenDocument} token
   * @param {{x: number; y: number; elevation?: number}} point
   * @param {object} [options]
   * @param {boolean} [options.snap=false]
   * @returns {TokenPosition}
   * @see {@link foundry.canvas.layers.TokenLayer#_onDropActorData}
   * @internal
   */
  static _getDropActorPosition(token, point, {snap=false}={}) {
    const {width, height, shape} = token._source;
    const pivot = token.getCenterPoint({x: 0, y: 0, elevation: 0, width, height, shape});
    let position = {
      x: point.x - pivot.x,
      y: point.y - pivot.y,
      elevation: (point.elevation ?? 0) - pivot.elevation,
      width, height, shape
    };
    if ( snap ) position = token.getSnappedPosition(position);
    position.x = Math.round(position.x);
    position.y = Math.round(position.y);
    position.width = width;
    position.height = height;
    position.shape = shape;
    return position;
  }

  /* -------------------------------------------- */

  /**
   * Check for collision when attempting a move to a new position.
   *
   * The result of this function must not be affected by the animation of this Token.
   * @param {Point|ElevatedPoint} destination         The central destination point of the attempted movement.
   *                                                  The elevation defaults to the elevation of the origin.
   * @param {object} [options={}]                     Additional options forwarded to PointSourcePolygon.testCollision
   * @param {Point|ElevatedPoint} [options.origin]    The origin to be used instead of the current origin. The elevation
   *                                                  defaults to the current elevation.
   * @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 {elevation, width, height, shape} = this.document._source;
    const {x: cx, y: cy} = this.document.getCenterPoint({x: 0, y: 0, elevation, width, height, shape});
    if ( !origin ) origin = this.document.getCenterPoint(this.document._source);
    else {
      origin = {
        x: Math.round(origin.x - cx) + cx,
        y: Math.round(origin.y - cy) + cy,
        elevation: origin.elevation ?? elevation
      };
    }
    destination = {
      x: Math.round(destination.x - cx) + cx,
      y: Math.round(destination.y - cy) + cy,
      elevation: destination.elevation ?? origin.elevation
    };

    // The test destination is the adjusted point based on the proposed movement vector
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;
    const offsetX = dx === 0 ? this.#centerOffset.x : Math.sign(dx);
    const offsetY = dy === 0 ? this.#centerOffset.y : Math.sign(dy);
    destination = this.getMovementAdjustedPoint(destination, {offsetX, offsetY});
    origin = this.getMovementAdjustedPoint(origin);

    // 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 {ElevatedPoint} origin        The origin of the source
   * @returns {PointMovementSource}
   */
  #getMovementSource(origin) {
    const movement = new PointMovementSource({object: this});
    movement.initialize(origin);
    return movement;
  }

  /* -------------------------------------------- */

  /**
   * Get the shape of this Token.
   * @returns {PIXI.Rectangle|PIXI.Polygon|PIXI.Circle|PIXI.Ellipse}
   */
  getShape() {
    if ( this.scene.grid.isGridless ) {
      const {width, height} = this.document.getSize();
      const shape = this.document.shape;
      if ( (shape === TOKEN_SHAPES.ELLIPSE_1) || (shape === TOKEN_SHAPES.ELLIPSE_2) ) {
        if ( width === height ) {
          const radius = width / 2;
          return new PIXI.Circle(radius, radius, radius);
        }
        const radiusX = width / 2;
        const radiusY = height / 2;
        return new PIXI.Ellipse(radiusX, radiusY, radiusX, radiusY);
      }
      return new PIXI.Rectangle(0, 0, width, height);
    }
    return new PIXI.Polygon(this.document.getGridSpacePolygon());
  }

  /* -------------------------------------------- */

  /**
   * Get the center point of the Token.
   * @param {Point} [position]  The position in pixels
   * @returns {Point}           The center point
   */
  getCenterPoint(position) {
    const {x, y} = this.document.getCenterPoint(position ? {x: position.x, y: position.y} : undefined);
    return {x, y};
  }

  /* -------------------------------------------- */

  /** @override */
  getSnappedPosition(position) {
    const {x, y} = this.document.getSnappedPosition(position ? {x: position.x, y: position.y} : undefined);
    return {x, y};
  }

  /* -------------------------------------------- */

  /** @override */
  _pasteObject(offset, {hidden=false, snap=true}={}) {
    const {x, y} = this.document;
    let position = {x: x + offset.x, y: y + offset.y};
    const data = this.document.toObject();
    delete data._id;
    data.hidden ||= hidden;
    const document = this.scene === canvas.scene ? this.document
      : foundry.utils.getDocumentClass("Token").fromSource(data, {parent: canvas.scene});
    const {elevation, width, height, shape} = data;
    if ( snap ) position = document.getSnappedPosition({x: position.x, y: position.y, elevation, width, height, shape});
    position.x = Math.round(position.x);
    position.y = Math.round(position.y);
    const d = canvas.dimensions;
    const {x: cx, y: cy} = document.getCenterPoint({x: 0, y: 0, elevation, width, height, shape});
    data.x = Math.clamp(position.x, -Math.floor(cx), d.width - Math.ceil(cx));
    data.y = Math.clamp(position.y, -Math.floor(cy), d.height - Math.ceil(cy));
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Measure the movement path for this Token.
   * @param {TokenMeasureMovementPathWaypoint[]} waypoints    The waypoints of movement
   * @param {TokenMeasureMovementPathOptions} [options]       Additional options that affect cost calculations
   *                                                          (passed to {@link Token#_getMovementCostFunction})
   * @returns {GridMeasurePathResult}
   */
  measureMovementPath(waypoints, options) {
    const cost = this._getMovementCostFunction(options);
    return this.document.measureMovementPath(waypoints, {cost});
  }

  /* -------------------------------------------- */

  /**
   * Create the movement cost function for this Token.
   * In square and hexagonal grids it calculates the cost for single grid space move between two grid space offsets.
   * For tokens that occupy more than one grid space the cost of movement is calculated as the median of all individual
   * grid space moves unless the cost of any of these is infinite, in which case total cost is always infinite.
   * In gridless grids the `from` and `to` parameters of the cost function are top-left offsets.
   * If the movement cost function is undefined, the cost equals the distance moved.
   * @param {TokenMeasureMovementPathOptions} [options]    Additional options that affect cost calculations
   * @returns {TokenMovementCostFunction|void}
   * @protected
   */
  _getMovementCostFunction(options) {
    const calculateTerrainCost = CONFIG.Token.movement.TerrainData.getMovementCostFunction(this.document, options);
    const actionCostFunctions = {};
    return (from, to, distance, segment) => {
      const terrainCost = calculateTerrainCost(from, to, distance, segment);
      const calculateActionCost = actionCostFunctions[segment.action]
        ??= segment.actionConfig.getCostFunction(this.document, options);
      return calculateActionCost(terrainCost, from, to, distance, segment);
    };
  }

  /* -------------------------------------------- */

  /**
   * Constrain the given movement path.
   *
   * The result of this function must not be affected by the animation of this Token.
   * @param {TokenConstrainMovementPathWaypoint[]} waypoints    The waypoints of movement
   * @param {TokenConstrainMovementPathOptions} [options]       Additional options
   * @returns {[constrainedPath: TokenConstrainedMovementWaypoint[], wasConstrained: boolean]}
   *   The (constrained) path of movement and a boolean that is true if and only if the path was constrained.
   *   If it wasn't constrained, then a copy of the path of all given waypoints with all default values filled in
   *   is returned.
   */
  constrainMovementPath(waypoints, {preview=false, ignoreWalls=false, ignoreCost=false, history=false}={}) {

    // Ignore preview if token vision is disabled or the current user is a GM
    if ( !canvas.visibility.tokenVision || game.user.isGM ) preview = false;

    // Compute the path up until the next waypoint that is blocked by a wall
    const result = {path: [], constrained: false};
    const source = this.document._source;
    let {x=source.x, y=source.y, elevation=source.elevation, width=source.width, height=source.height,
      shape=source.shape, action=this.document.movementAction, terrain=null, snapped=false, explicit=false,
      checkpoint=false, intermediate=false} = waypoints[0];
    x = Math.round(x);
    y = Math.round(y);
    if ( terrain ) terrain = terrain.clone();
    let waypoint = {x, y, elevation, width, height, shape, action, terrain, snapped, explicit, checkpoint,
      intermediate};
    result.path.push(waypoint);

    // Compute adjusted origin
    let origin;
    let offsetX;
    let offsetY;
    let center;
    if ( !ignoreWalls ) {
      offsetX = this.#centerOffset.x;
      offsetY = this.#centerOffset.y;
      center = this.document.getCenterPoint(waypoint);
      origin = this.getMovementAdjustedPoint(center, {offsetX, offsetY});
    }

    for ( let i = 1; i < waypoints.length; i++ ) {
      const priorWaypoint = waypoint;
      let {x=waypoint.x, y=waypoint.y, elevation=waypoint.elevation, width=waypoint.width, height=waypoint.height,
        shape=waypoint.shape, action=waypoint.action, terrain=null, snapped=false, explicit=false,
        checkpoint=false, intermediate=false} = waypoints[i];
      x = Math.round(x);
      y = Math.round(y);
      if ( terrain ) terrain = terrain.clone();
      waypoint = {x, y, elevation, width, height, shape, action, terrain, snapped, explicit, checkpoint, intermediate};

      // Test scene bounds
      const priorCenter = center;
      center = this.document.getCenterPoint(waypoint);
      if ( !canvas.dimensions.rect.contains(center.x, center.y) ) {
        result.constrained = true;
        break;
      }

      // Compute adjusted destination
      let destination;
      const priorOffsetX = offsetX;
      const priorOffsetY = offsetY;
      if ( !ignoreWalls ) {
        const ox = Math.sign(center.x - priorCenter.x);
        const oy = Math.sign(center.y - priorCenter.y);
        if ( ox !== 0 ) offsetX = ox;
        if ( oy !== 0 ) offsetY = oy;
        destination = this.getMovementAdjustedPoint(center, {offsetX, offsetY});
      }

      // Check for collisions with walls unless teleporting
      const wallType = CONFIG.Token.movement.actions[action].walls;
      if ( !ignoreWalls && wallType ) {
        let collision = this.#testWallCollision(origin, destination, wallType, preview);
        if ( collision ) {
          result.constrained = true;

          // Calculate the 3D collision point
          collision = {
            x: Math.round(collision.x - (center.x - waypoint.x)),
            y: Math.round(collision.y - (center.y - waypoint.y)),
            elevation: Math.mix(origin.elevation, destination.elevation, collision._distance)
          };

          // Restore prior center and x/y-offset
          center = priorCenter;
          offsetX = priorOffsetX;
          offsetY = priorOffsetY;

          // Get the collision waypoint
          const collisionWaypoint = this.#getCollisionWaypoint(waypoint, collision, origin, center, offsetX, offsetY,
            wallType, preview);
          if ( !collisionWaypoint ) {

            // The last waypoint must not be intermediate
            if ( priorWaypoint.intermediate ) {
              priorWaypoint.intermediate = false;
              priorWaypoint.explicit = false;
            }
            break;
          }

          // Skip if collision waypoint is almost the same as the origin waypoint
          if ( Math.max(Math.abs(collisionWaypoint.x - priorWaypoint.x),
            Math.abs(collisionWaypoint.y - priorWaypoint.y)) <= 1 ) break;

          // Add collision waypoint to the result
          result.path.push(collisionWaypoint);
          break;
        }
      }

      result.path.push(waypoint);
      origin = destination;
    }

    // Allow only movement with finite cost
    if ( !ignoreCost ) this.#constrainMovementPathCost(result, {preview, history});

    return [result.path, result.constrained];
  }

  /* -------------------------------------------- */

  /**
   * Get the collision waypoint for the given segment where a collision occurred.
   * @param {TokenMovementWaypoint} waypoint  The destination waypoint
   * @param {ElevatedPoint} collision         The point of collision with the wall
   * @param {ElevatedPoint} origin            The adjusted center point of the origin waypoint
   * @param {ElevatedPoint} center            The unadjusted center point of the origin waypoint
   * @param {number} offsetX                  The current x-offset
   * @param {number} offsetY                  The current y-offset
   * @param {string} type                     The wall type
   * @param {boolean} preview                 Is this a preview?
   * @returns {TokenConstrainedMovementWaypoint|void}    The collision waypoint, or undefined if the movement
   *                                                     should stop at the origin
   */
  #getCollisionWaypoint(waypoint, collision, origin, center, offsetX, offsetY, type, preview) {
    const priorOffsetX = offsetX;
    const priorOffsetY = offsetY;

    // If not snapped or gridless, we use the exact point of collision if possible
    if ( !waypoint.snapped || canvas.grid.isGridless ) {
      const k = Math.ceil(canvas.dimensions.size / 4);
      const n = Math.ceil(Math.hypot(collision.x - origin.x, collision.y - origin.y));
      for ( let j = 0; j < n; j += Math.clamp(j, 1, k) ) {
        const t = j / n;
        const p = {
          x: Math.round(Math.mix(collision.x, origin.x, t)),
          y: Math.round(Math.mix(collision.y, origin.y, t)),
          elevation: Math.mix(collision.elevation, origin.elevation, t),
          width: waypoint.width, height: waypoint.height, shape: waypoint.shape,
          action: waypoint.action, terrain: waypoint.terrain, snapped: false, explicit: false, checkpoint: false,
          intermediate: false
        };
        const c = this.document.getCenterPoint(p);
        const ox = Math.sign(c.x - center.x);
        const oy = Math.sign(c.y - center.y);
        if ( ox !== 0 ) offsetX = ox;
        if ( oy !== 0 ) offsetY = oy;
        const destination = this.getMovementAdjustedPoint(c, {offsetX, offsetY});
        if ( !this.#testWallCollision(origin, destination, type, preview) ) return p;
        offsetX = priorOffsetX;
        offsetY = priorOffsetY;
      }
    }

    // Otherwise we try to find the closest snapped position between the origin and the collision.
    // Note that this algorithm might not return the closest (best) snapped position.
    else {
      const d = canvas.dimensions;
      const n = Math.ceil(Math.hypot(collision.x - origin.x, collision.y - origin.y,
        (collision.elevation - origin.elevation) * d.distancePixels) / (d.size / 4));
      for ( let j = 0; j < n; j++ ) {
        const t = j / n;
        const p = this.document.getSnappedPosition({
          x: Math.mix(collision.x, origin.x, t),
          y: Math.mix(collision.y, origin.y, t),
          elevation: Math.mix(collision.elevation, origin.elevation, t),
          width: waypoint.width, height: waypoint.height, shape: waypoint.shape
        });
        p.x = Math.round(p.x);
        p.y = Math.round(p.y);
        p.width = waypoint.width;
        p.height = waypoint.height;
        p.shape = waypoint.shape;
        const c = this.document.getCenterPoint(p);
        const ox = Math.sign(c.x - center.x);
        const oy = Math.sign(c.y - center.y);
        if ( ox !== 0 ) offsetX = ox;
        if ( oy !== 0 ) offsetY = oy;
        const destination = this.getMovementAdjustedPoint(c, {offsetX, offsetY});
        if ( !this.#testWallCollision(origin, destination, type, preview) ) {
          p.action = waypoint.action;
          p.terrain = waypoint.terrain;
          p.snapped = true;
          p.explicit = false;
          p.checkpoint = false;
          p.intermediate = false;
          return p;
        }
        offsetX = priorOffsetX;
        offsetY = priorOffsetY;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Test for wall collision for a movement between two points.
   * @param {ElevatedPoint} origin         The adjusted origin
   * @param {ElevatedPoint} destination    The adjusted destination
   * @param {string} type                  The wall type
   * @param {boolean} preview              Is preview?
   * @returns {PolygonVertex|null}         The collision point with a wall, if any
   */
  #testWallCollision(origin, destination, type, preview) {
    let collision = null;
    const source = this.#getMovementSource(origin);
    const polygonBackend = CONFIG.Canvas.polygonBackends[type];
    if ( preview ) {
      // TODO: open doors that are not visible should be considered closed
      const collisions = polygonBackend.testCollision(origin, destination, {type, mode: "all", source});

      // Only visible or explored collisions block preview movement
      for ( const c of collisions ) {
        if ( canvas.fog.isPointExplored(c) || canvas.visibility.testVisibility(c, {tolerance: 1})) {
          collision = c;
          break;
        }
      }
    }
    else collision = polygonBackend.testCollision(origin, destination, {type, mode: "closest", source});
    return collision;
  }

  /* -------------------------------------------- */

  /**
   * Discard the first waypoint that requires an infinite cost to move to and all waypoints after this one.
   * @param {{path: TokenMovementWaypoint[], constrained: boolean}} result            The result
   * @param {Pick<TokenConstrainMovementPathOptions, "preview"|"history">} options    The options
   */
  #constrainMovementPathCost(result, {preview, history}) {
    if ( result.path.length === 0 ) return;
    if ( !Array.isArray(history) ) history = history ? this.document.movementHistory : [];

    // Bridge the gap between the last recorded position and the first of the given waypoints
    // so that the gap is measured with 0 cost
    const previous = history.at(-1);
    if ( previous ) {
      const origin = result.path[0];
      if ( !TokenDocument.arePositionsEqual(previous, origin) ) {
        const {x, y, elevation, width, height, shape} = origin;
        history = [...history, {x, y, elevation, width, height, shape, action: "displace", cost: 0}];
      }
    }

    // Discard the first waypoint that requires an infinite cost to move to and all waypoints after this one
    const measurement = this.measureMovementPath(history.concat(result.path), {preview});
    if ( measurement.cost !== Infinity ) return;
    let n = history.length;
    while ( (n < measurement.waypoints.length) && (measurement.waypoints[n].backward?.cost !== Infinity) ) n++;
    n -= history.length;
    if ( result.path.length === n ) return;
    if ( n > 0 ) {

      // The last waypoint must not be intermediate
      const last = result.path[n - 1];
      if ( last.intermediate ) {
        last.intermediate = false;
        last.explicit = false;
      }
    }
    result.path.length = n;
    result.constrained = true;
  }

  /* -------------------------------------------- */

  /**
   * Find a movement path through the waypoints.
   * The path may not necessarily be one with the least cost.
   * The path returned may be partial, i.e. it doesn't go through all waypoints, but must always start with the first
   * waypoints unless the waypoints are empty, in which case an empty path is returned.
   *
   * The result of this function must not be affected by the animation of this Token.
   * @param {TokenFindMovementPathWaypoint[]} waypoints    The waypoints of movement
   * @param {TokenFindMovementPathOptions} [options]       Additional options
   * @returns {TokenFindMovementPathJob}                   The job of the movement pathfinder
   */
  findMovementPath(waypoints, options) {

    // Add terrain waypoints
    let path = this.createTerrainMovementPath(waypoints, {preview: options.preview});

    // Constrain path with terrain data
    [path] = this.constrainMovementPath(path, options);

    // Remove waypoints and delete properties added because of terrain
    path = path.filter(waypoint => !waypoint.intermediate);
    for ( const waypoint of path ) {
      delete waypoint.terrain;
      delete waypoint.intermediate;
    }

    return {result: path, promise: Promise.resolve(path), cancel: () => {}};
  }

  /* -------------------------------------------- */

  /**
   * This function adds intermediate waypoints pre/post enter and exit for a {@link Region} if the Region
   * has at least one Behavior that could affect the movement, which is determined by
   * {@link foundry.data.regionBehaviors.RegionBehaviorType#_getTerrainEffects}.
   * For each segment of the movement path the terrain data is created from all behaviors that
   * could affect the movement of this Token with {@link CONFIG.Token.movement.TerrainData.resolveTerrainEffects}.
   * This terrain data is included in the returned regionalized movement path.
   * This terrain data may then be used in {@link Token#_getMovementCostFunction} and
   * {@link Token#constrainMovementPath}.
   * @param {TokenGetTerrainMovementPathWaypoint[]} waypoints    The waypoints of movement
   * @param {object} [options]                                   Additional options
   * @param {boolean} [options.preview=false]                    Is preview?
   * @returns {TokenTerrainMovementWaypoint[]}                   The movement path with terrain data
   */
  createTerrainMovementPath(waypoints, {preview=false}={}) {
    if ( !this.scene ) throw new Error("The Token must be in a Scene");
    const path = [];
    if ( waypoints.length === 0 ) return path;

    // Ignore preview if token vision is disabled or the current user is a GM
    if ( !canvas.visibility.tokenVision || game.user.isGM ) preview = false;

    // Add first waypoint
    const source = this.document._source;
    let {x=source.x, y=source.y, elevation=source.elevation, width=source.width, height=source.height,
      shape=source.shape, action=this.document.movementAction, snapped=false, explicit=false,
      checkpoint=false} = waypoints[0];
    x = Math.round(x);
    y = Math.round(y);
    path.push({x, y, elevation, width, height, shape, action, terrain: null, snapped, explicit, checkpoint,
      intermediate: false});
    let from = {x, y, elevation, width, height, shape, action};

    // Create region states
    const regionStates = this.scene.regions.map(region => ({region, active: false, effects: []}));

    const distancePixels = this.scene.dimensions.distancePixels;
    for ( let i = 1; i < waypoints.length; i++ ) {
      let {x=from.x, y=from.y, elevation=from.elevation, width=from.width, height=from.height, shape=from.shape,
        action=from.action, snapped=false, explicit=false, checkpoint=false} = waypoints[i];
      x = Math.round(x);
      y = Math.round(y);
      const to = {x, y, elevation, width, height, shape, action};

      // If preview, subdivide segments in explored/visible and unexplored/invisible sub-segments
      const subwaypoints = [];
      if ( !preview ) subwaypoints.push([to, undefined]);
      else {
        const c0 = this.document.getCenterPoint(from);
        const c1 = this.document.getCenterPoint(to);
        const pivot = this.document.getCenterPoint({x: 0, y: 0, elevation: 0, width, height, shape});
        const d = Math.hypot(c0.x - c1.x, c0.y - c1.y);
        const n = Math.ceil((d / canvas.dimensions.size) - 1e-6);
        let wasExplored;
        for ( let i = 0; i < n; i++ ) {
          const a0 = (i + (1 / 3)) / n;
          const p0 = {x: Math.mix(c0.x, c1.x, a0), y: Math.mix(c0.y, c1.y, a0)};
          const a1 = (i + (2 / 3)) / n;
          const p1 = {x: Math.mix(c0.x, c1.x, a1), y: Math.mix(c0.y, c1.y, a1)};
          const explored = (canvas.fog.isPointExplored(p0) || canvas.visibility.testVisibility(p0, {tolerance: 1}))
            && (canvas.fog.isPointExplored(p1) || canvas.visibility.testVisibility(p1, {tolerance: 1}));
          if ( explored === wasExplored ) continue;
          if ( wasExplored !== undefined ) {
            const a = i / n;
            const x = Math.round(Math.mix(c0.x, c1.x, a) - pivot.x);
            const y = Math.round(Math.mix(c0.y, c1.y, a) - pivot.y);
            const elevation = Math.mix(c0.elevation, c1.elevation, a) - pivot.elevation;
            subwaypoints.push([{x, y, elevation, width, height, shape}, wasExplored]);
          }
          wasExplored = explored;
        }
        subwaypoints.push([to, wasExplored ?? (canvas.fog.isPointExplored(c0)
          || canvas.visibility.testVisibility(c0, {tolerance: 1}))]);
      }

      // Iterate for each sub-waypoint of this segment
      const segment = {width, height, shape, action, preview};
      let previousTerrain;
      for ( let i = 0; i < subwaypoints.length; i++ ) {
        const [to, explored] = subwaypoints[i];

        // Initialize region states for this segment
        for ( const state of regionStates ) {
          const hadEffects = state.effects.length !== 0;
          state.effects.length = 0;
          if ( explored !== false ) {
            for ( const behavior of state.region.behaviors ) {
              if ( behavior.disabled ) continue;
              state.effects.push(...behavior.system._getTerrainEffects(this.document, segment));
            }
          }
          if ( state.effects.length === 0 ) state.active = false;
          else if ( !hadEffects ) state.active = this.document.testInsideRegion(state.region, from);
        }

        // Find region waypoints
        const regionWaypoints = [];
        const previousCenter = this.document.getCenterPoint(from);
        for ( const state of regionStates ) {
          if ( state.effects.length === 0 ) continue;
          const segments = this.document.segmentizeRegionMovementPath(state.region, [from, to]);
          for ( const {type, from} of segments ) {
            const center = this.document.getCenterPoint(from);
            const dx = center.x - previousCenter.x;
            const dy = center.y - previousCenter.y;
            const dz = (center.elevation - previousCenter.elevation) * distancePixels;
            const t = (dx * dx) + (dy * dy) + (dz * dz);
            regionWaypoints.push({t, x: from.x, y: from.y, elevation: from.elevation,
              width: from.width, height: from.height, shape: from.shape,
              crosses: type !== REGION_MOVEMENT_SEGMENTS.MOVE, state, terrain: null});
          }
        }

        // Sort region waypoints
        regionWaypoints.sort((w0, w1) => w0.t - w1.t);

        // Process region waypoints
        let n = regionWaypoints.length;
        if ( n !== 0 ) {
          let k = 0;
          let d = 0;
          for (let j = 0; j + 1 < n; j++) {
            const w0 = regionWaypoints[j];
            const w1 = regionWaypoints[j + 1];

            // Same position: combine them
            if ( TokenDocument.arePositionsEqual(w0, w1) ) {
              k++;
              d++;
              continue;
            }

            // Different position: set regions of the previous region waypoint
            w0.terrain = CONFIG.Token.movement.TerrainData.resolveTerrainEffects(
              regionStates.reduce((effects, state) => {
                if ( state.active ) effects.push(...state.effects);
                return effects;
              }, [])
            );

            // Update active states: moving to w1
            if ( w0.crosses ) w0.state.active = !w0.state.active;
            while ( d !== 0 ) {
              const w = regionWaypoints[j - d--];
              if ( w.crosses ) w.state.active = !w.state.active;
            }

            if ( k !== 0 ) regionWaypoints[j - k] = w0;
          }

          // Process the last region waypoint
          const w1 = regionWaypoints[n - 1];
          w1.terrain = CONFIG.Token.movement.TerrainData.resolveTerrainEffects(
            regionStates.reduce((effects, state) => {
              if ( state.active ) effects.push(...state.effects);
              return effects;
            }, [])
          );

          // Update active states: moving past the last waypoint
          if ( w1.crosses ) w1.state.active = !w1.state.active;
          while ( d !== 0 ) {
            const w = regionWaypoints[n - 1 - d--];
            if ( w.crosses ) w.state.active = !w.state.active;
          }

          if ( k !== 0 ) {
            regionWaypoints[n - 1 - k] = w1;
            n -= k;
          }
        }

        let terrain;

        let j = 0;
        if ( n !== 0 ) {

          // Skip the first region waypoint if it matches the previous movement waypoint
          const first = regionWaypoints[0];
          if ( TokenDocument.arePositionsEqual(first, from) ) j = 1;

          // Skip the last region waypoint if it matches the current movement waypoint
          const last = regionWaypoints[n - 1];
          if ( TokenDocument.arePositionsEqual(last, to) ) {
            n -= 1;
            terrain = last.terrain;
          }
        }

        // Add the region waypoints between the previous and the current movement waypoint
        while ( j < n ) {
          const {x, y, elevation, width, height, shape, terrain} = regionWaypoints[j++];

          // Remove redundant region waypoints
          if ( (previousTerrain !== undefined) && (!previousTerrain === !terrain)
            && (!previousTerrain || previousTerrain.equals(terrain)) ) {
            path.pop();
          }

          path.push({x, y, elevation, width, height, shape, action, terrain, snapped: false,
            explicit: true, checkpoint: false, intermediate: true});
          previousTerrain = terrain;
        }

        if ( terrain === undefined ) {
          terrain = CONFIG.Token.movement.TerrainData.resolveTerrainEffects(
            regionStates.reduce((effects, state) => {
              if ( state.active ) effects.push(...state.effects);
              return effects;
            }, [])
          );
        }

        // Remove redundant region waypoint
        if ( (previousTerrain !== undefined) && (!previousTerrain === !terrain)
          && (!previousTerrain || previousTerrain.equals(terrain)) ) {
          path.pop();
        }

        // Add the current movement waypoint
        path.push({...to, action, terrain, snapped, explicit, checkpoint,
          intermediate: i < subwaypoints.length - 1});
        previousTerrain = terrain;

        from = to;
      }
    }

    return path;
  }

  /* -------------------------------------------- */

  /**
   * Create the animation path.
   * @param {TokenPosition} origin                               The origin of movement
   * @param {TokenMeasuredMovementWaypoint[]} passedWaypoints    The passed waypoints
   * @returns {[waypoints: (Omit<TokenMovementWaypoint, "snapped"|"explicit"|"checkpoint">
   *    & {regions: Set<RegionDocument>; ray: Ray|null})[], initialRegions: Set<RegionDocument>])}
   *                                                             The regionalized animation path
   */
  #createAnimationMovementPath(origin, passedWaypoints) {
    const path = [];
    const initialRegions = new Set();
    if ( passedWaypoints.length === 0 ) return [path, initialRegions];

    let from = origin;
    let previousCenter = this.document.getCenterPoint(from);
    let rayIndex = 0;

    // Create region states
    const regionStates = [];
    for ( const region of this.scene.regions ) {
      if ( region.behaviors.some(b => !b.disabled && (b.hasEvent(CONST.REGION_EVENTS.TOKEN_ANIMATE_IN)
        || b.hasEvent(CONST.REGION_EVENTS.TOKEN_ANIMATE_OUT))) ) {
        const active = this.document.testInsideRegion(region, from);
        if ( active ) initialRegions.add(region);
        regionStates.push({region, active});
      }
    }

    const distancePixels = this.scene.dimensions.distancePixels;
    for ( let i = 0; i < passedWaypoints.length; i++ ) {
      const to = passedWaypoints[i];
      if ( to.intermediate && !to.explicit ) continue;

      // Find region waypoints
      const regionWaypoints = [];
      for ( const state of regionStates ) {
        const segments = this.document.segmentizeRegionMovementPath(state.region, [from, to]);
        for ( const {type, from} of segments ) {
          const center = this.document.getCenterPoint(from);
          const dx = center.x - previousCenter.x;
          const dy = center.y - previousCenter.y;
          const dz = (center.elevation - previousCenter.elevation) * distancePixels;
          const t = (dx * dx) + (dy * dy) + (dz * dz);
          regionWaypoints.push({t, x: from.x, y: from.y, elevation: from.elevation,
            width: from.width, height: from.height, shape: from.shape,
            crosses: type !== REGION_MOVEMENT_SEGMENTS.MOVE, state, regions: null});
        }
      }

      // Sort region waypoints
      regionWaypoints.sort((w0, w1) => w0.t - w1.t);

      // Process region waypoints
      let n = regionWaypoints.length;
      if ( n !== 0 ) {
        let k = 0;
        let d = 0;
        for (let j = 0; j + 1 < n; j++) {
          const w0 = regionWaypoints[j];
          const w1 = regionWaypoints[j + 1];

          // Same position: combine them
          if ( TokenDocument.arePositionsEqual(w0, w1) ) {
            k++;
            d++;
            continue;
          }

          // Different position: set regions of the previous region waypoint
          w0.regions = new Set();
          for ( const state of regionStates ) {
            if ( state.active ) w0.regions.add(state.region);
          }

          // Update active states: moving to w1
          if ( w0.crosses ) w0.state.active = !w0.state.active;
          while ( d !== 0 ) {
            const w = regionWaypoints[j - d--];
            if ( w.crosses ) w.state.active = !w.state.active;
          }

          if ( k !== 0 ) regionWaypoints[j - k] = w0;
        }

        // Process the last region waypoint
        const w1 = regionWaypoints[n - 1];
        w1.regions = new Set();
        for ( const state of regionStates ) {
          if ( state.active ) w1.regions.add(state.region);
        }

        // Update active states: moving past the last waypoint
        if ( w1.crosses ) w1.state.active = !w1.state.active;
        while ( d !== 0 ) {
          const w = regionWaypoints[n - 1 - d--];
          if ( w.crosses ) w.state.active = !w.state.active;
        }

        if ( k !== 0 ) {
          regionWaypoints[n - 1 - k] = w1;
          n -= k;
        }
      }

      let regions;

      let j = 0;
      if ( n !== 0 ) {

        // Skip the first region waypoint if it matches the previous movement waypoint
        const first = regionWaypoints[0];
        if ( TokenDocument.arePositionsEqual(first, from) ) j = 1;

        // Skip the last region waypoint if it matches the current movement waypoint
        const last = regionWaypoints[n - 1];
        if ( TokenDocument.arePositionsEqual(last, to) ) {
          n -= 1;
          regions = last.regions;
        }
      }

      let previousRegions;

      // Add the region waypoints between the previous and the current movement waypoint
      while ( j < n ) {
        const {x, y, elevation, width, height, shape, regions} = regionWaypoints[j++];

        // Remove redundant region waypoints
        if ( previousRegions?.equals(regions) ) path.pop();

        path.push({x, y, elevation, width, height, shape, action: to.action, terrain: to.terrain, regions, ray: null});
        previousRegions = regions;
      }

      if ( regions === undefined ) {
        regions = new Set();
        for ( const state of regionStates ) {
          if ( state.active ) regions.add(state.region);
        }
      }

      // Remove redundant region waypoint
      if ( previousRegions?.equals(regions) ) path.pop();

      // Add the current movement waypoint
      path.push({x: to.x, y: to.y, elevation: to.elevation, width: to.width, height: to.height, shape: to.shape,
        action: to.action, terrain: to.terrain, regions, ray: null});

      if ( !to.intermediate ) {
        const center = this.document.getCenterPoint(to);
        path[rayIndex].ray = new Ray(previousCenter, center);
        previousCenter = center;
        rayIndex = path.length;
      }

      from = to;
    }

    return [path, initialRegions];
  }

  /* -------------------------------------------- */

  /**
   * Set this Token as an active target for the current game User.
   * @param {boolean} targeted                        Is the Token now targeted?
   * @param {object} [options={}]                     Additional option which modify how targets are acquired
   * @param {boolean} [options.releaseOthers=true]    Release other active targets?
   */
  setTarget(targeted=true, {releaseOthers=true}={}) {
    if ( this.isPreview ) return;
    const mode = targeted ? (releaseOthers ? "replace" : "acquire") : "release";
    canvas.tokens.setTargets([this.id], {mode});
  }

  /* -------------------------------------------- */

  /**
   * Handle updating the targeting state of this Token for a particular User.
   * @param {boolean} targeted    Is the token now targeted?
   * @param {User} user           The user whose targeting state has changed
   * @internal
   */
  _updateTarget(targeted, user) {
    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();
    }
  }

  /* -------------------------------------------- */

  /**
   * The external radius of the token in pixels.
   * @type {number}
   */
  get externalRadius() {
    const {width, height} = this.document.getSize();
    return Math.min(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);
  }

  /* -------------------------------------------- */

  /**
   * Obtain a shifted waypoint for the Token. The returned waypoint must move the Token to a snapped position.
   * @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
   * @param {-1|0|1} dz                   The number of grid units to shift along the Z-axis
   * @returns {Partial<TokenPosition>}    The shifted target waypoint (snapped if square/hexagonal grid)
   * @override
   * @internal
   */
  _getShiftedPosition(dx, dy, dz) {
    const {x, y, elevation, width, height, shape} = this.document._source;
    const snapped = this.document.getSnappedPosition({x, y, elevation, width, height, shape});
    return PlaceableObject._getShiftedPosition(dx, dy, dz, {x, y, elevation}, snapped, this.scene.grid);
  }

  /* -------------------------------------------- */

  /**
   * Get the movement action in {@link CONFIG.Token.movement | CONFIG.Token.movement.actions} to be used for keyboard
   * movement.
   * The default implementation returns `this.document.movementAction`.
   * @returns {string}
   * @protected
   */
  _getKeyboardMovementAction() {
    return this.document.movementAction;
  }

  /* -------------------------------------------- */

  /**
   * Get the position for movement via the Token HUD.
   * @param {number} elevation
   * @returns {Partial<TokenPosition>}
   * @see {@link foundry.applications.hud.TokenHUD#_onSubmit}
   * @internal
   */
  _getHUDMovementPosition(elevation) {
    return {elevation};
  }

  /* -------------------------------------------- */

  /**
   * Get the movement action in {@link CONFIG.Token.movement | CONFIG.Token.movement.actions} to be used for movement
   * via the Token HUD.
   * The default implementation returns `this.document.movementAction`.
   * @returns {string}
   * @see {@link foundry.applications.hud.TokenHUD#_onSubmit}
   * @protected
   */
  _getHUDMovementAction() {
    return this.document.movementAction;
  }

  /* -------------------------------------------- */

  /**
   * Get the position for movement via the Token Config.
   * @param {Partial<TokenPosition>} changes
   * @returns {Partial<TokenPosition>}
   * @see {@link foundry.applications.sheets.TokenConfig#_processSubmitData}
   * @internal
   */
  _getConfigMovementPosition(changes) {
    return {...changes};
  }

  /* -------------------------------------------- */

  /** @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 && !canvas.tokens.controlled.length ) {
      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;

    // Update the center offset
    this.#onUpdateCenterOffset(options);

    // Update drag preview
    this.#onUpdateDrag(changed, options, userId);

    // Acquire or release Token control
    const hiddenChanged = "hidden" in changed;
    if ( hiddenChanged ) {
      if ( !game.user.isGM ) {
        // Release your controlled token if it becomes hidden
        if ( this.controlled && changed.hidden ) this.release();
        // Gain control over your token if it becomes visible and you don't already control something
        else if ( this.isOwner && (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
    const positionChanged = ("x" in changed) || ("y" in changed);
    if ( positionChanged && this.controlled && (options.pan !== false) ) this.#panCanvas();

    // Handle animation
    this.#onUpdateAnimation(changed, options, userId);

    // Process Combat Tracker changes
    if ( this.inCombat && ("name" in changed) ) game.combat.debounceSetup();

    // Source and perception updates
    if ( hiddenChanged || ("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 ) canvas.perception.update({refreshVision: true, refreshSounds: true, refreshOcclusion: true});
    if ( "occludable" in changed ) canvas.perception.update({refreshOcclusionMask: true});

    // Incremental refresh
    const textureChanged = "texture" in changed;
    const ringEnabled = doc.ring.enabled;
    const ringChanged = "ring" in changed;
    const ringEnabledChanged = ringChanged && ("enabled" in changed.ring);
    const ringVisualsChanged = ringEnabled && ringChanged && (("colors" in changed.ring) || ("effects" in changed.ring));
    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,
      refreshMesh: textureChanged && ("fit" in changed.texture),
      refreshShape: "shape" in changed,
      refreshBars: ["displayBars", "bar1", "bar2"].some(k => k in changed),
      refreshNameplate: ["displayName", "name"].some(k => k in changed),
      refreshRingVisuals: ringVisualsChanged,
      refreshTurnMarker: ("turnMarker" in changed) || ("disposition" in changed),
      refreshRuler: "_movementHistory" in changed
    });
  }

  /* -------------------------------------------- */

  /**
   * Update the center offset.
   * @param {TokenPosition} origin                                 The origin
   * @param {TokenPosition & {intermediate?: boolean}} waypoints   The waypoints
   */
  #updateCenterOffset(origin, waypoints) {
    if ( waypoints.length === 0 ) return;
    let c0 = this.document.getCenterPoint(origin);
    for ( const waypoint of waypoints ) {
      if ( waypoint.intermediate ) continue;
      const c1 = this.document.getCenterPoint(waypoint);
      if ( c1.x !== c0.x ) this.#centerOffset.x = Math.sign(c1.x - c0.x);
      if ( c1.y !== c0.y ) this.#centerOffset.y = Math.sign(c1.y - c0.y);
      c0 = c1;
    }
  }

  /* -------------------------------------------- */

  /**
   * Update the center offset unless animating.
   * Called in {@link Token#_onUpdate}.
   * @param {object} options    The update options
   */
  #onUpdateCenterOffset(options) {
    const movement = options._movement?.[this.document.id];
    if ( movement ) this.#updateCenterOffset(movement.origin, movement.passed.waypoints);

    // If animating, we update the animated center offset for each movement waypoint in Token##onUpdateAnimation
    if ( options.animate !== false ) return;
    this.#animationCenterOffset.x = this.#centerOffset.x;
    this.#animationCenterOffset.y = this.#centerOffset.y;
  }

  /* -------------------------------------------- */

  /**
   * Update drag preview and the ruler path.
   * Called in {@link Token#_onUpdate}.
   * @param {object} changed    The changes
   * @param {object} options    The update options
   * @param {string} userId     The ID of the User that initiated the update operation
   */
  #onUpdateDrag(changed, options, userId) {
    const context = this.#getDragContext();
    if ( !context ) return;
    if ( foundry.utils.isEmpty(changed) ) return;

    // Update the preview token, but keep current position
    context.clonedToken.document.updateSource(changed);
    context.clonedToken.document.x = context.destination.x;
    context.clonedToken.document.y = context.destination.y;
    context.clonedToken.document.elevation = context.destination.elevation;
    context.clonedToken.document.width = context.destination.width ?? this.document._source.width;
    context.clonedToken.document.height = context.destination.height ?? this.document._source.height;
    context.clonedToken.document.shape = context.destination.shape ?? this.document._source.shape;
    context.clonedToken.renderFlags.set({refresh: true});

    // The ruler only needs updating if the position, size, or shape changed, or the movement history changed
    const hasMoved = TokenDocument._isMovementUpdate(changed);
    if ( !hasMoved && !("_movementHistory" in changed) ) return;
    if ( hasMoved ) {

      // Prevent panning
      options.pan = false;

      // Update the origin
      const oldOrigin = context.origin;
      const newOrigin = context.origin = {
        x: this.document._source.x,
        y: this.document._source.y,
        elevation: this.document._source.elevation,
        width: this.document._source.width,
        height: this.document._source.height,
        shape: this.document._source.shape
      };

      // Update the waypoints and destination
      const updateWaypoint = (waypoint, ignoreElevation=true) => {

        // Update (x, y, elevation) such that center point before and after is the same
        const center = this.document.getCenterPoint({x: waypoint.x, y: waypoint.y,
          elevation: waypoint.elevation, width: waypoint.width ?? oldOrigin.width,
          height: waypoint.height ?? oldOrigin.height, shape: waypoint.shape ?? oldOrigin.shape});
        const pivot = this.document.getCenterPoint({x: 0, y: 0, elevation: 0, width: newOrigin.width,
          height: newOrigin.height, shape: newOrigin.shape});
        waypoint.x = Math.round(center.x - pivot.x);
        waypoint.y = Math.round(center.y - pivot.y);
        waypoint.elevation = center.elevation - pivot.elevation;

        // Check that after resizing the waypoint is still in a snapped position
        if ( waypoint.snapped ) {
          const {x, y, elevation} = waypoint;
          const {width=newOrigin.width, height=newOrigin.height, shape=newOrigin.shape} = waypoint;
          const snapped = this.document.getSnappedPosition({x, y, elevation, width, height, shape});
          if ( !((x === Math.round(snapped.x)) && (y === Math.round(snapped.y))
            && (ignoreElevation || elevation.almostEqual(snapped.elevation))) ) {
            waypoint.snapped = false;
          }
        }
      };
      context.waypoints.forEach(updateWaypoint);
      updateWaypoint(context.destination, false);

      // Update destination of the preview
      Token.#updateDragPreview(context.clonedToken, context.destination);
    }

    // Update the ruler path
    this.recalculatePlannedMovementPath();
  }

  /* -------------------------------------------- */

  /**
   * Animate the changes to this Token.
   * Called in {@link Token#_onUpdate}.
   * @param {object} changed    The changes
   * @param {object} options    The update options
   * @param {string} userId     The ID of the User that initiated the update operation
   */
  #onUpdateAnimation(changed, options, userId) {
    if ( options.animate === false ) {
      this.stopAnimation({reset: false});
      this.#showRuler = false;
      this._preventKeyboardMovement = false;
      return;
    }
    const movement = options._movement?.[this.document.id];
    const animationData = this._getAnimationData();
    const to = foundry.utils.filterObject(animationData, changed);

    // Delete positional and size from the animation data, which we are animating separately
    for ( const k of TokenDocument.MOVEMENT_FIELDS ) delete to[k];
    if ( movement && movement.autoRotate ) delete to.rotation;

    // TODO: Can we find a solution that doesn't require special handling for hidden?
    if ( "hidden" in changed ) to.alpha = animationData.alpha;

    // We need to infer subject texture if ring is enabled and texture is changed
    const ringEnabled = this.document.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);
    if ( (ringEnabled || ringEnabledChanged) && !ringSubjectTextureChanged && ("texture" in changed)
      && ("src" in changed.texture) && !this.document._source.ring.subject.texture ) {
      foundry.utils.mergeObject(to, {ring: {subject: {texture: animationData.ring.subject.texture}}});
    }

    // Animate movement separately from the non-movement-related fields
    let movementAnimationDuration;
    if ( movement ) {
      const previousMovementAnimationPromise = this.movementAnimationPromise;
      let movementAnimationPromise = previousMovementAnimationPromise;
      this.#showRuler = movement.showRuler;

      // Prevent keyboard movement until right before the movement animation ends
      this._preventKeyboardMovement = true;

      const rotationSpeed = 24;  // 250 ms per 360 degrees
      const runningAnimations = [];
      const [animationPath, initialRegions] = this.#createAnimationMovementPath(movement.origin,
        movement.passed.waypoints);
      for ( const waypoint of animationPath ) {

        // If there's a ray, it's the first waypoint on a segment
        const ray = waypoint.ray;
        if ( waypoint.ray ) {
          if ( movement.autoRotate ) {
            if ( ray.distance > 0 ) {
              const rotation = Math.toDegrees(ray.angle) + (movement.method === "undo" ? 90 : -90);
              movementAnimationPromise = this.animate({rotation}, {
                name: this.movementAnimationName,
                chain: true,
                action: waypoint.action,
                movementSpeed: rotationSpeed
              });
            }

            // Update center offset
            const updateCenterOffset = () => {
              if ( ray.dx !== 0 ) this.#animationCenterOffset.x = Math.sign(ray.dx);
              if ( ray.dy !== 0 ) this.#animationCenterOffset.y = Math.sign(ray.dy);
            };
            if ( movementAnimationPromise ) movementAnimationPromise.finally(updateCenterOffset);
            else updateCenterOffset();
          }
        }

        // Dispatch animation for the waypoint
        const start = movementAnimationPromise;
        const end = this.animate({
          x: waypoint.x,
          y: waypoint.y,
          elevation: waypoint.elevation,
          width: waypoint.width,
          height: waypoint.height,
          shape: waypoint.shape
        }, {
          name: this.movementAnimationName,
          chain: true,
          action: waypoint.action,
          terrain: waypoint.terrain,
          movementSpeed: options.animation?.movementSpeed
        });
        const context = this.#animationContexts.get(this.movementAnimationName);
        const duration = context ? context.chain.at(-1)?.options.duration ?? context.duration : 0;
        runningAnimations.push({start, end, duration});

        movementAnimationPromise = end;
      }
      if ( movement.autoRotate ) {
        movementAnimationPromise = this.animate({rotation: animationData.rotation}, {
          name: this.movementAnimationName,
          chain: true,
          action: animationPath.at(-1).action,
          movementSpeed: rotationSpeed
        });
      }

      // Handle region animation events
      this.#handleAnimateInOutRegionEvents(movement.origin, animationPath, initialRegions, runningAnimations);

      // Hide ruler at the end of the movement animation
      if ( movement.showRuler ) {
        if ( movementAnimationPromise ) {
          const movementId = movement.id;
          movementAnimationPromise.finally(() => {
            if ( this.document.movement.id !== movementId ) return;
            if ( ["completed", "stopped"].includes(this.document.movement.state) ) this.#showRuler = false;
          });
        }
        else if ( ["completed", "stopped"].includes(this.document.movement.state) ) this.#showRuler = false;
      }

      // Refresh ruler
      this.renderFlags.set({refreshRuler: true, refreshState: true});
      movementAnimationPromise?.finally(() => this.renderFlags.set({refreshRuler: true, refreshState: true}));

      // Update light and sight source after the animation finished if Vision Animation is disabled
      if ( !game.settings.get("core", "visionAnimation") ) {
        const initializeSources = () => {
          const positionChanged = ("x" in changed) || ("y" in changed);
          const elevationChanged = "elevation" in changed;
          const rotationChanged = "rotation" in changed;
          const sizeChanged = ("width" in changed) || ("height" in changed);
          const perspectiveChanged = positionChanged || elevationChanged || sizeChanged
            || (rotationChanged && this.hasLimitedSourceAngle);
          const visionChanged = perspectiveChanged && this.hasSight;
          const lightChanged = perspectiveChanged && this._isLightSource();
          if ( visionChanged || lightChanged ) this.initializeSources();
        };
        if ( movementAnimationPromise ) movementAnimationPromise.finally(initializeSources);
        else initializeSources();
      }

      // Calculate movement duration
      movementAnimationDuration = 0;
      const context = this.#animationContexts.get(this.movementAnimationName);
      if ( context ) {
        movementAnimationDuration = context.duration;
        for ( const {options: {duration}} of context.chain ) movementAnimationDuration += duration;
        movementAnimationDuration = Math.max(movementAnimationDuration - context.time, 0);
      }

      // Calculate the duration after which the movement is to be continued
      const movementContinuationDuration = this.document.movement.state !== "pending"
        ? movementAnimationDuration : Math.max(movementAnimationDuration - (2 * game.time.averageLatency) - 50, 0);

      // Unblock keyboard movement 500 ms before the movement animation ends or the movement would be continued,
      // but not before the previous movement animation completed
      if ( movementAnimationPromise ) {
        const movementId = movement.id;
        Promise.allSettled([
          previousMovementAnimationPromise ?? Promise.resolve(),
          Promise.race([movementAnimationPromise, new Promise(resolve => {
            setTimeout(resolve, Math.max(movementContinuationDuration - 500, 0));
          })])
        ]).finally(() => {
          if ( movementId !== this.document.movement.id ) return;
          this._preventKeyboardMovement = false;
        });
      } else {
        this._preventKeyboardMovement = false;
      }

      // Create promise for when to continue movement
      this.document._movementContinuation.waitPromise = new Promise(resolve => {

        // If no animation or browser tab inactive, resolve immediately
        if ( !movementAnimationPromise || window.document.hidden ) {
          this.document._movementContinuation.resolveWaitPromise = () => {};
          resolve();
          return;
        }

        // Otherwise resolve once the browser tab becomes inactive, the movement animation
        // resolves or the movement continuation timeout has completed
        let visibilitychange = event => {
          if ( window.document.hidden ) callback();
        };
        const callback = () => {
          if ( !visibilitychange ) return;
          window.document.removeEventListener("visibilitychange", visibilitychange);
          visibilitychange = undefined;
          resolve();
        };
        window.document.addEventListener("visibilitychange", visibilitychange);
        this.document._movementContinuation.resolveWaitPromise = callback;
        movementAnimationPromise.finally(callback);
        setTimeout(callback, movementContinuationDuration);
      });
    }

    // Set the duration of non-movement properties to the animation duration of movement if requested
    const {linkToMovement, ...animationOptions} = options.animation ?? {};
    if ( (linkToMovement === true) && (movementAnimationDuration !== undefined) ) {
      animationOptions.duration ??= movementAnimationDuration;
    }

    // Dispatch the animation
    // noinspection ES6MissingAwait
    this.animate(to, animationOptions);
  }

  /* -------------------------------------------- */

  /**
   * Handle TOKEN_ANIMATE_IN/_OUT region events.
   * @param {TokenPosition} origin                                                                The origin of movement
   * @param {(TokenPosition & {regions: Set<RegionDocument>})[]} animationPath                    The animation path
   * @param {Set<RegionDocument>} initialRegions                                                  The initial regions
   * @param {{start: Promise<void>; end: Promise<void>; duration: number}[]} runningAnimations    The running animations
   */
  #handleAnimateInOutRegionEvents(origin, animationPath, initialRegions, runningAnimations) {
    let from = origin;
    let activeRegions = initialRegions;
    for ( let i = 0; i < animationPath.length; i++ ) {
      const to = animationPath[i];
      const regionsIn = to.regions.difference(activeRegions);
      const regionsOut = activeRegions.difference(to.regions);
      const eventDataOut = {
        token: this.document,
        position: {
          x: from.x,
          y: from.y,
          elevation: from.elevation,
          width: from.width,
          height: from.height,
          shape: from.shape
        }
      };
      const handleRegionEventsOut = () => {
        for ( const region of regionsOut ) {
          region._handleEvent({
            name: CONST.REGION_EVENTS.TOKEN_ANIMATE_OUT,
            data: eventDataOut,
            region,
            user: game.user
          });
        }
      };
      const promiseOut = runningAnimations[i].start;
      if ( promiseOut ) promiseOut.finally(handleRegionEventsOut);
      else handleRegionEventsOut();
      const eventDataIn = {
        token: this.document,
        position: {
          x: to.x,
          y: to.y,
          elevation: to.elevation,
          width: to.width,
          height: to.height,
          shape: to.shape
        }
      };
      const handleRegionEventsIn = () => {
        for ( const region of regionsIn ) {
          region._handleEvent({
            name: CONST.REGION_EVENTS.TOKEN_ANIMATE_IN,
            data: eventDataIn,
            region,
            user: game.user
          });
        }
      };
      const promiseIn = i > 0 ? runningAnimations[i - 1].end : undefined;
      if ( promiseIn ) promiseIn.finally(handleRegionEventsIn);
      else handleRegionEventsIn();
      from = to;
      activeRegions = to.regions;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    game.user.targets.delete(this);
    this.initializeSources({deleted: true});
    canvas.perception.update({refreshOcclusion: true});
    return super._onDelete(options, userId);
  }

  /* -------------------------------------------- */

  /**
   * 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 = document.getElementById("sidebar").clientWidth + 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 {@link CONFIG.specialStatusEffects}
   * @param {boolean} active        Is the special status effect now active?
   * @protected
   */
  _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$1.callAll("applyTokenStatusEffect", this, statusId, active);
  }

  /* -------------------------------------------- */

  /**
   * Add/Modify a filter effect on this token.
   * @param {string} statusId       The status effect ID being applied, from {@link CONFIG.specialStatusEffects}
   * @param {boolean} active        Is the special status effect now active?
   * @internal
   */
  _configureFilterEffect(statusId, active) {
    let filterClass = null;
    const 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();
  }

  /* -------------------------------------------- */

  /**
   * Additional events that trigger once control of the Token is established
   * @param {object} options Optional parameters which apply for specific implementations
   * @param {boolean} [options.releaseOthers=true] Release control of all other Tokens
   * @param {bolean} [options.pan=false] Pan to the controlled Token
   * @protected
   * @override
   */
  _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
    });
    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 {x, y} = this.position;
    const shape = this.shape;
    const isRectangle = shape instanceof PIXI.Rectangle;
    if ( !isRectangle ) {
      const {width, height} = this.document.getSize();
      const bounds = new PIXI.Rectangle(x, y, width, height);
      if ( !rectangle.intersects(bounds) ) return false;
    }
    const localRectangle = new PIXI.Rectangle(
      rectangle.x - x,
      rectangle.y - 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: CLIPPER_SCALING_FACTOR});
    return intersection.points.length !== 0;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  _canControl(user, event) {
    if ( this.layer._draggedToken ) return false;
    if ( !this.layer.active || this.isPreview ) return false;
    if ( canvas.controls.ruler.active || (CONFIG.Canvas.rulerClass.canMeasure && (event?.type === "pointerdown")) ) return false;
    if ( game.activeTool === "target" ) return true;
    return super._canControl(user, event);
  }

  /* -------------------------------------------- */

  /** @override */
  _canHUD(user, event) {
    if ( this.layer._draggedToken ) return false;
    if ( !this.layer.active || this.isPreview ) return false;
    if ( canvas.controls.ruler.active || (CONFIG.Canvas.rulerClass.canMeasure && (event?.type === "pointerdown")) ) return false;
    return user.isGM || (this.actor?.testUserPermission(user, "OWNER") ?? false);
  }

  /* -------------------------------------------- */

  /** @override */
  _canConfigure(user, event) {
    if ( this.layer._draggedToken ) return false;
    if ( !this.layer.active || this.isPreview ) return false;
    if ( canvas.controls.ruler.active || (CONFIG.Canvas.rulerClass.canMeasure && (event?.type === "pointerdown")) ) return false;
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _canHover(user, event) {
    return !this.isPreview;
  }

  /* -------------------------------------------- */

  /** @override */
  _canView(user, event) {
    if ( this.layer._draggedToken ) return false;
    if ( !this.layer.active || this.isPreview ) return false;
    if ( canvas.controls.ruler.active || (CONFIG.Canvas.rulerClass.canMeasure && (event?.type === "pointerdown")) ) return false;
    if ( !this.actor ) ui.notifications.warn("TOKEN.WarningNoActor", {localize: true});
    return this.actor?.testUserPermission(user, "LIMITED");
  }

  /* -------------------------------------------- */

  /** @override */
  _canDrag(user, event) {
    if ( this.layer._draggedToken ) return false;
    if ( !this.layer.active || this.isPreview ) return false;
    if ( !this.controlled ) return false;
    return game.activeTool === "select";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onHoverIn(event, options) {
    const combatant = this.combatant;
    if ( combatant ) ui.combat.hoverCombatant(combatant, ui.combat._isTokenVisible(this));
    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.bringToFront();
    }
    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});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeDragLeft(event) {
    super._initializeDragLeft(event);

    // This token is now dragged
    this.layer._draggedToken = this;

    // Initialize the drag contexts
    const contexts = event.interactionData.contexts = {};
    for ( const clone of event.interactionData.clones ) {
      const token = clone._original;

      // Create the drag context
      contexts[token.document.id] = token.#initializeDragContext(event, clone);

      // Update planned movement
      token.#updatePlannedMovement();
    }

    // Initialize waypoint mode
    event.interactionData.dropped = false;
    event.interactionData.cancelled = false;
    event.interactionData.released = false;
  }

  /* -------------------------------------------- */

  /**
   * Initialize the drag context for this Token.
   * @param {PIXI.FederatedEvent} event    The pointermove event
   * @param {Token} clonedToken            The preview token
   * @returns {TokenDragContext}           The drag context
   */
  #initializeDragContext(event, clonedToken) {
    const {x, y, elevation, width, height, shape} = this.document._source;
    let snapped = false;
    if ( !canvas.grid.isGridless ) {
      const snappedPosition = this.document.getSnappedPosition({x, y, elevation, width, height, shape});
      snapped = (x === Math.round(snappedPosition.x)) && (y === Math.round(snappedPosition.y))
        && (elevation.almostEqual(snappedPosition.elevation));
    }
    const origin = {x, y, elevation, width, height, shape};
    const destination = {x, y, elevation, width, height, shape, action: this.document.movementAction,
      snapped, explicit: true, checkpoint: true};
    return {
      token: this,
      clonedToken,
      origin,
      destination,
      waypoints: [],
      foundPath: [destination],
      unreachableWaypoints: [],
      hidden: event.altKey,
      updating: false,
      search: null,
      searching: false,
      searchId: 0
    };
  }

  /* -------------------------------------------- */

  /**
   * Get the context of the current drag workflow for this Token.
   * @returns {TokenDragContext|void}    The drag context if the Token is being dragged
   */
  #getDragContext() {
    if ( this.isPreview ) return;
    const context = this.layer._draggedToken?.mouseInteractionManager.interactionData.contexts[this.document.id];
    if ( context ) return context;
  }

  /* -------------------------------------------- */

  /**
   * Is this a preview of drag operation and the destination is unreachable?
   * @type {boolean}
   */
  get #isUnreachableDragPreview() {
    if ( !this.isPreview ) return false;
    const context = this.layer._draggedToken?.mouseInteractionManager.interactionData.contexts[this.document.id];
    if ( !context ) return false;
    return context.unreachableWaypoints.length > 0;
  }

  /* -------------------------------------------- */

  /**
   * Get the constrain options used during the drag operation.
   * @returns {Omit<TokenConstrainMovementPathOptions, "preview"|"history">}    The constrain options
   * @protected
   */
  _getDragConstrainOptions() {
    const unconstrainedMovement = game.user.isGM && game.settings.get("core", "unconstrainedMovement");
    return {ignoreWalls: unconstrainedMovement, ignoreCost: unconstrainedMovement};
  }

  /* -------------------------------------------- */

  /**
   * Get the search options used during the drag operation to find the path of movement through the waypoints.
   * @returns {TokenFindMovementPathOptions}    The search options
   * @protected
   */
  _getDragPathfindingOptions() {
    return {...this._getDragConstrainOptions(), preview: true, history: true, delay: 250};
  }

  /* -------------------------------------------- */

  /**
   * Get the movement action for the waypoints placed during a drag operation.
   * @returns {string}    The movement action
   * @protected
   */
  _getDragMovementAction() {
    return this.layer._dragMovementAction ?? this.document.movementAction;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragLeftDrop(event) {
    if ( !event.interactionData.dropped && this._shouldPreventDragLeftDrop(event) ) {
      event.interactionData.released = true;
      event.preventDefault(); // Prevent drop workflow
      return;
    }
    event.interactionData.dropped = true;
    return super._onDragLeftDrop(event);
  }

  /* -------------------------------------------- */

  /**
   * Prevent the drop event?
   * Called by {@link Token#_onDragLeftDrop}.
   * @param {PIXI.FederatedEvent} event    The pointerup event
   * @returns {boolean}
   * @protected
   */
  _shouldPreventDragLeftDrop(event) {
    return (event.ctrlKey || event.metaKey) && !!this.ruler;
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareDragLeftDropUpdates(event) {
    const updates = [];
    const movement = {};
    for ( const [id, context] of Object.entries(event.interactionData.contexts) ) {
      if ( context.foundPath.length <= 1 ) continue;
      updates.push({_id: id});
      movement[id] = {waypoints: context.foundPath.slice(1), method: "dragging",
        constrainOptions: this._getDragConstrainOptions()};
    }
    return [updates, {movement}];
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftMove(event) {

    // Update the destinations of the drag previews and rulers
    this._updateDragDestination(event.interactionData.destination, {snap: !event.shiftKey});

    // Pan the canvas if the drag event approaches the edge
    canvas._onDragCanvasPan(event);
  }

  /* -------------------------------------------- */

  /**
   * Update the destinations of the drag previews and rulers
   * @param {Point} point                     The (unsnapped) center point of the waypoint
   * @param {object} [options]                Additional options
   * @param {boolean} [options.snap=false]    Snap the destination?
   * @protected
   */
  _updateDragDestination(point, {snap=false}={}) {
    const contexts = Object.values(this.mouseInteractionManager.interactionData.contexts);
    if ( canvas.grid.isGridless ) snap = false;

    // Determine dragged distance
    const origin = this._getDragOrigin();
    const dx = point.x - origin.x;
    const dy = point.y - origin.y;

    // Update the destinations
    for ( const context of contexts ) {
      const {x, y} = context.token.document._source;
      const destination = context.token._getDragWaypointPosition(context.destination, {x: x + dx, y: y + dy}, {snap});
      // The movement action is set in Token.#recalculatePlannedMovementPath
      destination.action = undefined;
      // Elevation is ignored here, but is considered in Token.#recalculatePlannedMovementPath
      destination.snapped = snap;
      destination.explicit = true;
      destination.checkpoint = true;
      if ( Object.keys(context.destination).every(k => context.destination[k] === destination[k]) ) continue;
      context.destination = destination;

      // Update the position of the preview token
      Token.#updateDragPreview(context.clonedToken, destination);

      // Update the ruler path
      Token.#recalculatePlannedMovementPath(context);
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the origin of the drag operation.
   * @returns {Point}
   * @internal
   */
  _getDragOrigin() {
    return this.document.getCenterPoint(this.document._source);
  }

  /* -------------------------------------------- */

  /**
   * Called by {@link foundry.canvas.layers.TokenLayer#_onClickLeft} while this Token is in a drag workflow.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   * @protected
   */
  _onDragClickLeft(event) {

    // Add waypoints if CTRL is down
    const isCtrl = event.ctrlKey || event.metaKey;
    if ( isCtrl && this.ruler ) this._addDragWaypoint(event.interactionData.origin, {snap: !event.shiftKey});

    // Otherwise drop the token
    else this._triggerDragLeftDrop();

    // Prevent left-click drag workflow on the canvas
    canvas.mouseInteractionManager.cancel();
  }

  /* -------------------------------------------- */

  /**
   * Add ruler waypoints and update ruler paths.
   * @param {Point} point                     The (unsnapped) center point of the waypoint
   * @param {object} [options]                Additional options
   * @param {boolean} [options.snap=false]    Snap the added waypoint?
   * @protected
   */
  _addDragWaypoint(point, {snap=false}={}) {
    const contexts = Object.values(this.mouseInteractionManager.interactionData.contexts);
    if ( canvas.grid.isGridless ) snap = false;

    // Determine dragged distance
    const origin = this._getDragOrigin();
    const dx = point.x - origin.x;
    const dy = point.y - origin.y;

    // Add waypoints and update ruler paths
    let redundantWaypoint = true;
    for ( const context of contexts ) {
      const {x, y} = context.origin;
      const waypoint = context.token._getDragWaypointPosition(context.destination, {x: x + dx, y: y + dy}, {snap});
      waypoint.action = context.token._getDragMovementAction();
      waypoint.snapped = snap && context.destination.elevation.almostEqual(waypoint.elevation);
      waypoint.explicit = true;
      waypoint.checkpoint = true;
      context.waypoints.push(waypoint);

      const lastWaypoint = context.waypoints.at(-2) ?? context.origin;
      if ( !TokenDocument.arePositionsEqual(lastWaypoint, waypoint) ) {
        Token.#recalculatePlannedMovementPath(context);
        redundantWaypoint = false;
      } else if ( lastWaypoint.snapped !== waypoint.snapped ) {
        lastWaypoint.snapped = waypoint.snapped;
        Token.#recalculatePlannedMovementPath(context);
      }
    }

    // If the waypoint is matching the last waypoint for all rulers,
    // remove that were waypoints that were just added
    if ( redundantWaypoint ) contexts.forEach(context => context.waypoints.pop());
  }

  /* -------------------------------------------- */

  /**
   * Trigger drop event. This drop cannot be prevented by {@link Token#_shouldPreventDragLeftDrop}.
   * @protected
   */
  _triggerDragLeftDrop() {
    const eventSystem = canvas.app.renderer.events;
    const rootBoundary = eventSystem.rootBoundary;
    const dropEvent = rootBoundary.createPointerEvent(eventSystem.pointer, "pointerup", this);
    dropEvent.defaultPrevented = false;
    dropEvent.path = null;
    try {
      this.mouseInteractionManager.interactionData.dropped = true;
      this.mouseInteractionManager.handleEvent(dropEvent);
    } finally {
      rootBoundary.freeEvent(dropEvent);
    }
  }

  /* -------------------------------------------- */

  /**
   * Called by {@link foundry.canvas.layers.TokenLayer#_onClickLeft2} while this Token is in a drag workflow.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   * @protected
   */
  _onDragClickLeft2(event) {

    // Prevent left-click drag workflow on the canvas
    canvas.mouseInteractionManager.cancel();
  }

  /* -------------------------------------------- */

  /**
   * Called by {@link foundry.canvas.layers.TokenLayer#_onClickRight} while this Token is in a drag workflow.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   * @protected
   */
  _onDragClickRight(event) {

    // Remove last waypoints
    if ( this.ruler ) this._removeDragWaypoint();

    // Otherwise cancel the drag workflow
    else this._triggerDragLeftCancel();

    // Prevent right-click drag workflow on the canvas
    canvas.mouseInteractionManager.cancel();
  }

  /* -------------------------------------------- */

  /**
   * Remove last ruler waypoints and update ruler paths.
   * @protected
   */
  _removeDragWaypoint() {

    // Update those ruler that have their path changed
    for ( const context of Object.values(this.mouseInteractionManager.interactionData.contexts) ) {

      // If one ruler has no waypoints, all of them have none: cancel the drag workflow
      if ( context.waypoints.length === 0 ) {
        this._triggerDragLeftCancel();
        break;
      }

      // Recalculate path if the waypoints change
      const previousWaypoint = context.waypoints.at(-2) ?? context.origin;
      const lastWaypoint = context.waypoints.pop();
      if ( !TokenDocument.arePositionsEqual(lastWaypoint, previousWaypoint) ) {
        Token.#recalculatePlannedMovementPath(context);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Cancel the drag workflow. This cancellation cannot be prevented by {@link Token#_onDragLeftCancel}.
   * @protected
   */
  _triggerDragLeftCancel() {
    this.mouseInteractionManager.interactionData.cancelled = true;
    this.mouseInteractionManager.cancel();
  }

  /* -------------------------------------------- */

  /**
   * Called by {@link foundry.canvas.layers.TokenLayer#_onClickRight2} while this Token is in a drag workflow.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   * @protected
   */
  _onDragClickRight2(event) {

    // Prevent right-click drag workflow on the canvas
    canvas.mouseInteractionManager.cancel();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragLeftCancel(event) {
    if ( event.interactionData.cancelled || event.interactionData.dropped ) return true;
    if ( event.interactionData.released ) return false;
    if ( event.interactionData.contexts[this.document.id].waypoints.length > 0 ) {
      this._removeDragWaypoint();
      return false;
    }
    return super._onDragLeftCancel(event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _finalizeDragLeft(event) {

    // This token is no longer dragged
    this.layer._draggedToken = null;

    // Reset the movement action override
    this.layer._dragMovementAction = null;

    // Cancel path searches and refesh ruler visualization
    for ( const context of Object.values(event.interactionData.contexts) ) {
      context.search?.cancel();
      context.token.#updatePlannedMovement();
    }

    super._finalizeDragLeft(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragEnd() {
    this.initializeSources({deleted: true});
    this._original?.initializeSources();
    super._onDragEnd();
  }

  /* -------------------------------------------- */

  /**
   * Change the elevation of Token during dragging.
   * @param {WheelEvent} event    The mousewheel event
   * @protected
   */
  _onDragMouseWheel(event) {
    const isCtrl = game.keyboard.isModifierActive("CONTROL"); // We cannot trust event.ctrlKey because of touchpads
    if ( !isCtrl ) return;
    this._changeDragElevation(-Math.sign(event.delta), {precise: event.shiftKey});
  }

  /* -------------------------------------------- */

  /**
   * Change the elevation of the dragged Tokens.
   * @param {number} delta                       The number vertical steps
   * @param {object} [options]                   Additional options
   * @param {boolean} [options.precise=false]    Round elevations to multiples of the grid distance divided by
   *                                             `CONFIG.Canvas.elevationSnappingPrecision`?
   *                                             If false, rounds to multiples of the grid distance.
   * @protected
   */
  _changeDragElevation(delta, {precise=false}={}) {

    // Calculate the elevation interval and delta
    const interval = canvas.dimensions.distance / (precise ? CONFIG.Canvas.elevationSnappingPrecision : 1);

    // Update the destination elevation of each ruler
    for ( const context of Object.values(this.mouseInteractionManager.interactionData.contexts) ) {
      const elevation = (context.destination.elevation + (delta * interval)).toNearest(interval, delta > 0 ? "floor" : "ceil");
      const destination = context.token._getDragWaypointPosition(context.destination, {elevation},
        {snap: context.destination.snap});
      if ( TokenDocument.arePositionsEqual(context.destination, destination) ) continue;
      for ( const k of TokenDocument.MOVEMENT_FIELDS ) context.destination[k] = destination[k];

      // Update the destination of the preview token
      Token.#updateDragPreview(context.clonedToken, destination);

      // Update the ruler path
      Token.#recalculatePlannedMovementPath(context);
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the drag waypoint position.
   * @param {DeepReadonly<Pick<TokenPosition, "x"|"y"|"elevation">>} current
   * @param {DeepReadonly<Partial<ElevatedPoint>>} changes
   * @param {object} [options]
   * @param {boolean} [options.snap=false]
   * @returns {Pick<TokenPosition, "x"|"y"|"elevation"> & Partial<TokenDimensions>}
   * @internal
   */
  _getDragWaypointPosition(current, changes, {snap=false}={}) {
    const x = Math.round(changes.x ?? current.x);
    const y = Math.round(changes.y ?? current.y);
    const elevation = changes.elevation ?? current.elevation;
    if ( !snap ) return {x, y, elevation};
    const {width, height, shape} = this.document._source;
    const position = this.document.getSnappedPosition({x, y, elevation, width, height, shape});
    position.x = Math.round(position.x);
    position.y = Math.round(position.y);
    position.elevation = elevation;
    return position;
  }

  /* -------------------------------------------- */

  /**
   * Update the position of the preview token.
   * @param {Token} preview                         The preview token
   * @param {Partial<TokenPosition>} destination    The destination
   */
  static #updateDragPreview(preview, destination) {
    const source = preview._original.document._source;
    const {x=source.x, y=source.y, elevation=source.elevation, width=source.width, height=source.height,
      shape=source.shape} = destination;
    const refreshPosition = (preview.document.x !== x) || (preview.document.y !== y);
    const refreshElevation = preview.document.elevation !== elevation;
    const refreshSize = (preview.document.width !== width) || (preview.document.height !== height);
    const refreshShape = preview.document.shape !== shape;
    preview.document.x = x;
    preview.document.y = y;
    preview.document.elevation = elevation;
    preview.document.width = width;
    preview.document.height = height;
    preview.document.shape = shape;
    preview.renderFlags.set({refreshPosition, refreshElevation, refreshSize, refreshShape});
  }

  /* -------------------------------------------- */

  /**
   * Cancel the drag workflow if this Token is the one the drag operation was initiated on, or
   * otherwise remove this Token from the drag operation if it is being part of it.
   */
  #cancelDrag() {
    if ( !this.layer._draggedToken ) return; // No drag workflow
    if ( this.isPreview ) return; // Previews cannot be dragged

    // If this token is the dragged token, cancel the drag workflow
    if ( this.layer._draggedToken === this ) {
      this._triggerDragLeftCancel();
      return;
    }

    // If this token not the dragged token but part of a drag operation,...
    const interactionData = this.layer._draggedToken.mouseInteractionManager.interactionData;
    const context = interactionData.contexts[this.document.id];
    if ( !context ) return;

    // ... cancel pathfinding, delete the drag context, and destroy the preview token
    context.search?.cancel();
    delete interactionData.contexts[this.document.id];
    interactionData.clones.findSplice(clone => clone === context.clonedToken);
    context.clonedToken._onDragEnd();
    context.clonedToken.destroy({children: true});
  }

  /* -------------------------------------------- */

  /**
   * Recalculate the planned movement path of this Token for the current User.
   */
  recalculatePlannedMovementPath() {
    const context = this.#getDragContext();
    if ( !context || context.updating ) return;
    context.updating = true;
    context.search?.cancel();
    canvas.app.ticker.addOnce(() => {
      context.updating = false;
      Token.#recalculatePlannedMovementPath(context);
    }, undefined, PIXI.UPDATE_PRIORITY.OBJECTS + 2);
  }

  /* -------------------------------------------- */

  /**
   * Update the planned movement path.
   * @param {TokenDragContext} context    The drag context
   */
  static #recalculatePlannedMovementPath(context) {
    if ( context.updating ) return;

    // Cancel current pathfinding job
    context.search?.cancel();

    // Remove repeating explicit waypoints
    const explicitWaypoints = [{...context.origin}];
    const destination = {...context.destination};
    destination.action = context.token._getDragMovementAction();
    destination.snapped &&= destination.elevation.almostEqual(
      context.token.document.getSnappedPosition(destination).elevation);
    for ( const {x, y, elevation, width=context.origin.width, height=context.origin.height, shape=context.origin.shape,
      action, snapped, explicit, checkpoint} of [...context.waypoints, destination] ) {
      const waypoint = {x, y, elevation, width, height, shape, action, snapped, explicit, checkpoint};
      const lastWaypoint = explicitWaypoints.at(-1);
      if ( TokenDocument.arePositionsEqual(lastWaypoint, waypoint) ) continue;
      explicitWaypoints.push(waypoint);
    }

    // Reuse parts of the previous found path that pass through the current explicit waypoints
    let lastReachedWaypointIndex = 0;
    let reachableWaypoints = 0;
    for ( let i = 0; (i < context.foundPath.length) && (reachableWaypoints < explicitWaypoints.length); i++ ) {
      const waypoint = context.foundPath[i];
      const explicitWaypoint = explicitWaypoints[reachableWaypoints];
      if ( TokenDocument.arePositionsEqual(explicitWaypoint, waypoint) ) {
        reachableWaypoints++;
        lastReachedWaypointIndex = i;
      }
    }
    context.foundPath = context.foundPath.slice(0, lastReachedWaypointIndex + 1);
    context.unreachableWaypoints = explicitWaypoints.slice(reachableWaypoints);
    context.searching = true;

    // Start new pathfinding job
    context.searchId += 1;
    const searchId = context.searchId;
    const searchStartTime = canvas.app.ticker.lastTime;
    if ( context.token.ruler ) {
      const searchOptions = context.token._getDragPathfindingOptions();
      context.search = context.token.findMovementPath(explicitWaypoints, searchOptions);
    } else {
      const foundPath = [];
      for ( const {x, y, elevation, width=context.origin.width, height=context.origin.height,
        shape=context.origin.shape, action=context.token.document.movementAction, snapped=false,
        explicit=false, checkpoint=false} of explicitWaypoints ) {
        foundPath.push({x, y, elevation, width, height, shape, action, snapped, explicit, checkpoint});
      }
      context.search = {
        result: foundPath,
        promise: Promise.resolve(foundPath),
        cancel: () => {}
      };
    }

    // Handle result of pathfinding job
    const handleResult = async foundPath => {
      if ( !foundPath || (context.searchId !== searchId) ) return; // Search was cancelled
      if ( context.token.destroyed || context.clonedToken.destroyed ) return;

      // If the path was found quickly but not before the the first frame was rendered with the search animation,
      // delay refreshing the ruler to prevent very brief flickering
      const elapsedTime = canvas.app.ticker.lastTime - searchStartTime;
      if ( elapsedTime > 0 ) {
        const minDuration = 500; // The minimum duration of the search animation
        if ( elapsedTime < minDuration ) {
          await new Promise(resolve => {
            setTimeout(resolve, minDuration - elapsedTime);
          });

          // Another pathfinding job was started while we delayed
          if ( context.searchId !== searchId ) return;
        }
      }

      // Identify reachable waypoints, which are those that passed through by the found path
      let reachableWaypoints = 0;
      for ( const waypoint of foundPath ) {
        const explicitWaypoint = explicitWaypoints[reachableWaypoints];
        if ( TokenDocument.arePositionsEqual(explicitWaypoint, waypoint) ) reachableWaypoints++;
      }
      context.foundPath = foundPath;
      context.unreachableWaypoints = explicitWaypoints.slice(reachableWaypoints);
      context.searching = false;

      // Update planned movement
      context.token.#updatePlannedMovement();
    };

    // Handle the search result immediately if the path was found instantly
    if ( context.search.result !== undefined ) handleResult(context.search.result);

    // Otherwise handle the result once the path is found
    else {
      context.search.promise.then(handleResult);

      // Update planned movement just before the next frame is rendered so that we don't broadcast
      // a state where searching is in progress when we find the path before the next frame
      canvas.app.ticker.addOnce(() => {
        if ( context.searching && (context.searchId === searchId) ) context.token.#updatePlannedMovement();
      }, undefined, PIXI.UPDATE_PRIORITY.OBJECTS + 1);
    }
  }

  /* -------------------------------------------- */

  /**
   * Update the planned movement for the current user.
   */
  #updatePlannedMovement() {
    const context = this.#getDragContext();
    if ( !context ) {
      if ( game.user.id in this._plannedMovement ) {
        delete this._plannedMovement[game.user.id];
        this.renderFlags.set({refreshRuler: true, refreshState: true});
        this.#throttleBroadcastPlannedMovement();
      }
      return;
    }

    // Add drag ruler state for the current user
    const foundPath = this.document.getCompleteMovementPath(
      this.createTerrainMovementPath(context.foundPath, {preview: true}));
    const unreachableWaypoints = this.document.getCompleteMovementPath(
      this.createTerrainMovementPath([foundPath.at(-1), ...context.unreachableWaypoints], {preview: true})).slice(1);
    const combinedPath = [...this.document.movementHistory, ...foundPath, ...unreachableWaypoints];
    const measurement = this.measureMovementPath(combinedPath, {preview: true});
    for ( let i = this.document.movementHistory.length; i < combinedPath.length; i++ ) {
      const waypoint = combinedPath[i];
      waypoint.cost = measurement.waypoints[i].backward?.cost ?? 0;
    }

    // Configure the origin of the found path based on the last recorded waypoint
    const current = this.document.movementHistory.at(-1);
    const origin = foundPath[0];
    origin.action = (current !== undefined) && !TokenDocument.arePositionsEqual(current, origin)
      ? "displace" : (current?.action ?? foundPath[0].action);
    origin.terrain = null;
    origin.snapped = false;
    origin.explicit = false;
    origin.checkpoint = true;
    origin.cost = 0;

    // Update planned movement and broadcast if it changed
    const previousPlannedMovement = this._plannedMovement[game.user.id];
    const plannedMovement = {foundPath, unreachableWaypoints, history: this.document.movementHistory,
      hidden: context.hidden, searching: context.searching};
    if ( foundry.utils.objectsEqual(previousPlannedMovement, plannedMovement) ) return;
    this._plannedMovement[game.user.id] = plannedMovement;
    this.#throttleBroadcastPlannedMovement();

    // Refresh ruler and state
    this.renderFlags.set({refreshRuler: true, refreshState: !previousPlannedMovement});

    // Update the center offset of the preview token
    context.clonedToken.#centerOffset.x = this.#centerOffset.x;
    context.clonedToken.#centerOffset.y = this.#centerOffset.y;
    context.clonedToken.#updateCenterOffset(origin, foundPath.slice(1).concat(unreachableWaypoints));
    context.clonedToken.#animationCenterOffset.x = context.clonedToken.#centerOffset.x;
    context.clonedToken.#animationCenterOffset.y = context.clonedToken.#centerOffset.y;

    // Update light and/or vision sources of the preview token if Token Drag Preview is enabled
    if ( game.settings.get("core", "tokenDragPreview") ) {
      context.clonedToken.initializeSources();
      canvas.perception.update({refreshLighting: true, refreshVision: true});
    } else {
      context.clonedToken.#adjustedCenter = context.clonedToken.getMovementAdjustedPoint(
        context.clonedToken.document.getCenterPoint());
    }
  }

  /* -------------------------------------------- */

  /**
   * A throttled function that broadcasts the planned movement.
   * @type {() => void}
   */
  #throttleBroadcastPlannedMovement = foundry.utils.throttle(this.#broadcastPlannedMovement.bind(this), 100);

  /* -------------------------------------------- */

  /**
   * Broadcast the planned movement.
   */
  #broadcastPlannedMovement() {
    game.user.broadcastActivity({plannedMovements: {[this.document.id]: game.user.hasPermission("SHOW_RULER")
      ? (this._plannedMovement[game.user.id] ?? null) : null}});
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @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} : undefined);
  }

  /**
   * @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});
    const 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();
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  testInsideRegion(region, position) {
    foundry.utils.logCompatibilityWarning("Token#testInsideRegion is deprecated "
      + "in favor of TokenDocument#testInsideRegion.", {since: 13, until: 15});
    return this.document.testInsideRegion(region.document, position);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  segmentizeRegionMovement(region, waypoints, options) {
    foundry.utils.logCompatibilityWarning("Token#segmentizeRegionMovement is deprecated "
      + "in favor of TokenDocument#segmentizeRegionMovementPath.", {since: 13, until: 15});
    if ( options?.teleport !== undefined ) {
      waypoints = waypoints.map(waypoint => {
        waypoint = {...waypoint};
        waypoint.action ??= (options.teleport ? "displace" : this.document.movementAction);
        return waypoint;
      });
    }
    return this.document.segmentizeRegionMovementPath(region.document, waypoints);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  getSize() {
    foundry.utils.logCompatibilityWarning("Token#getSize is deprecated in favor of TokenDocument#getSize.", {since: 13, until: 14, once: true});
    return this.document.getSize();
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get target() {
    foundry.utils.logCompatibilityWarning("Token#target is deprecated and has been split into two new graphics "
      + "object: targetArrows and targetPips. targetArrows is returned by the deprecated target property.", {since: 13, until: 14, once: true});
    return this.targetArrows;
  }
};

/**
 * @import {Point, ElevatedPoint, CanvasVisibilityTestConfiguration,
 *   CanvasVisibilityTextureConfiguration} from "../../_types.mjs";
 * @import {PointVisionSource} from "@client/canvas/sources/_module.mjs";
 */

/**
 * The visibility group which implements dynamic vision, lighting, and fog of war
 * This group uses an event-driven workflow to perform the minimal required calculation in response to changes.
 *
 * ### Hook Events
 * - {@link hookEvents.initializeVisionMode}
 * - {@link hookEvents.initializeVisionSources}
 * - {@link hookEvents.sightRefresh}
 * - {@link hookEvents.visibilityRefresh}
 *
 * @category Canvas
 */
class CanvasVisibility extends CanvasGroupMixin(PIXI.Container) {
  /** @override */
  static groupName = "visibility";

  /**
   * 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: PointVisionSource|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 group 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 {CanvasVisibilityTextureConfiguration}
   */
  get textureConfiguration() {
    return this.#textureConfiguration;
  }

  #textureConfiguration;

  /* -------------------------------------------- */

  /**
   * Optional overrides for exploration sprite dimensions.
   * @type {PIXI.Rectangle|undefined}
   */
  set explorationRect(rect) {
    this.#explorationRect = rect;
  }

  #explorationRect;

  /* -------------------------------------------- */
  /*  Group Initialization                        */
  /* -------------------------------------------- */

  /**
   * Initialize all Token vision sources which are present on this group.
   */
  initializeSources() {
    canvas.effects.toggleMaskingFilters(false); // Deactivate vision masking before destroying textures
    for ( const source of canvas.effects.visionSources ) source.initialize();
    Hooks$1.callAll("initializeVisionSources", canvas.effects.visionSources);
  }

  /* -------------------------------------------- */

  /**
   * Initialize the vision mode.
   */
  initializeVisionMode() {
    this.visionModeData.source = this.#getSingleVisionSource();
    this.#configureLightingVisibility();
    this.#updateLightingPostProcessing();
    this.#updateTintPostProcessing();
    Hooks$1.callAll("initializeVisionMode", this);
  }

  /* -------------------------------------------- */

  /**
   * Identify whether there is one singular vision source active (excluding previews).
   * @returns {PointVisionSource|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;
  }

  /* -------------------------------------------- */
  /*  Group 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$1.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 visibility group.
   * 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 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$1.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$1) && !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$1.callAll("sightRefresh", this);
  }

  /* -------------------------------------------- */

  /**
   * Test whether a target point on the Canvas is visible based on the current vision and LOS polygons.
   * @param {Point|ElevatedPoint} point       The point in space to test
   * @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$1) ) 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|ElevatedPoint} point       The point in space to test
   * @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 {CanvasVisibilityTestConfiguration}
   * @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]];
    let {x, y, elevation} = point;
    if ( (elevation === undefined) && (object instanceof Token$1) ) elevation = object.document.elevation;
    else elevation ??= 0;
    return {
      object,
      tests: offsets.map(([dx, dy]) => Object.defineProperty({
        point: {x: x + dx, y: y + dy, elevation},
        los: new Map()
      }, "elevation", {
        get() {
          foundry.utils.logCompatibilityWarning("CanvasVisibilityTest#elevation has been deprecated "
            + "in favor of CanvasVisibilityTest#point.elevation.", {since: 13, until: 15, once: true});
          return this.point.elevation;
        },
        set(value) {
          foundry.utils.logCompatibilityWarning("CanvasVisibilityTest#elevation has been deprecated "
            + "in favor of CanvasVisibilityTest#point.elevation.", {since: 13, until: 15, once: true});
          this.point.elevation = value;
        }
      }))
    };
  }

  /* -------------------------------------------- */
  /*  Visibility Overlay and Texture management   */
  /* -------------------------------------------- */

  /**
   * Load the scene fog overlay if provided and attach the fog overlay sprite to this group.
   */
  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;

    // Build sprite
    const fo = this.visibilityOverlay = new PIXI.Sprite(overlayTexture);
    const baseTex = overlayTexture.baseTexture;
    const bkg = canvas.primary.background;

    // Same intrinsic size as the background image?
    const sameSize = (bkg.texture.baseTexture.realWidth === baseTex.realWidth)
      && (bkg.texture.baseTexture.realHeight === baseTex.realHeight);

    if ( sameSize ) {
      // Re-use background transform to align perfectly
      fo.transform.setFromMatrix(bkg.localTransform);
      baseTex.wrapMode = PIXI.WRAP_MODES.CLAMP;
    }
    else {
      // Tile overlay across the entire scene
      const {width, height} = canvas.scene.dimensions;
      fo.position.set(0, 0);
      fo.width = width;
      fo.height = height;
      baseTex.wrapMode = PIXI.WRAP_MODES.REPEAT;
    }

    // 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 ) game.video.play(video, {volume: 0});

    // Store dimensions for shader calculations
    this.#visibilityOverlayDimensions = [fo.width, fo.height, bkg.width, bkg.height];
  }

  /* -------------------------------------------- */

  /**
   * 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 {CanvasVisibilityTextureConfiguration}
   */
  #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
    };
  }
}

var _module$n = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CanvasGroupMixin: CanvasGroupMixin,
  CanvasVisibility: CanvasVisibility,
  EffectsCanvasGroup: EffectsCanvasGroup,
  EnvironmentCanvasGroup: EnvironmentCanvasGroup,
  HiddenCanvasGroup: HiddenCanvasGroup,
  InterfaceCanvasGroup: InterfaceCanvasGroup,
  OverlayCanvasGroup: OverlayCanvasGroup,
  PrimaryCanvasGroup: PrimaryCanvasGroup,
  RenderedCanvasGroup: RenderedCanvasGroup
});

/**
 * @import Sound from "../../audio/sound.mjs";
 * @import {ElevatedPoint} from "../../_types.mjs";
 * @import {PlaceableObject, AmbientSound} from "../placeables/_module.mjs";
 * @import PointSoundSource from "../sources/point-sound-source.mjs";
 */

/**
 * @typedef AmbientSoundPlaybackConfig
 * @property {Sound} sound              The Sound node which should be controlled for playback
 * @property {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 {ElevatedPoint} 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
 */

/* -------------------------------------------- */

/**
 * @typedef CanvasHistoryEvent
 * @property {"create"|"update"|"delete"} type    The type of operation stored as history
 * @property {object[]} data                      The data corresponding to the action which may later be un-done
 * @property {object} options                     The options of the undo operation
 */

/**
 * @typedef 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?
 */

/* -------------------------------------------- */

/**
 * @typedef _CanvasVisionContainerSight
 * @property {PIXI.LegacyGraphics} preview    FOV that should not be committed to fog exploration.
 */

/**
 * @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerSight} CanvasVisionContainerSight
 * The sight part of {@link foundry.canvas.layers.types.CanvasVisionContainer}.
 * The blend mode is MAX_COLOR.
 */

/**
 * @typedef _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.
 */

/**
 * @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerLight} CanvasVisionContainerLight
 * The light part of {@link foundry.canvas.layers.types.CanvasVisionContainer}.
 * The blend mode is MAX_COLOR.
 */

/**
 * @typedef _CanvasVisionContainerDarkness
 * @property {PIXI.LegacyGraphics} darkness    Darkness source erasing fog of war.
 */

/**
 * @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerDarkness} CanvasVisionContainerDarkness
 * The sight part of {@link foundry.canvas.layers.types.CanvasVisionContainer}.
 * The blend mode is ERASE.
 */

/**
 * @typedef _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.
 */

/**
 * @typedef {PIXI.Container & _CanvasVisionContainer} CanvasVisionContainer
 * The currently visible areas.
 */

var _types$8 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * The filter used by the weather layer to mask weather above occluded roofs.
 * @see {@link foundry.canvas.layers.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);
  }
}

/**
 * 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;

  /* -------------------------------------------- */

  /** @override */
  get hookName() {
    return WeatherEffects.name;
  }

  /* -------------------------------------------- */

  /**
   * 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 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 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$1.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;
    }
  }
}

/**
 * @import Collection from "@common/utils/collection.mjs";
 * @import {Point} from "@common/_types.mjs";
 */

/**
 * 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({x: point.x, y: point.y}, canvas.forceSnapVertices ? {mode: M.VERTEX} : {
      mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT,
      resolution: size >= 128 ? 8 : (size >= 64 ? 4 : 2)
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getCopyableObjects(options) {
    if ( !game.user.can("DRAWING_CREATE") ) return [];
    return super._getCopyableObjects(options);
  }

  /* -------------------------------------------- */

  /**
   * Render a configuration sheet to configure the default Drawing settings
   */
  configureDefault() {
    const defaults = game.settings.get("core", DrawingsLayer.DEFAULT_CONFIG_SETTING);
    const document = DrawingDocument.fromSource({...defaults, shape: {type: "p", width: 1, height: 1, points: [0, 0, 1, 0]}});
    new foundry.applications.sheets.DrawingConfig({document, configureDefault: true}).render({force: 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
   * @protected
   */
  _getNewDrawingData(origin) {
    const tool = game.activeTool;

    // Get saved user defaults
    const defaults = game.settings.get("core", this.constructor.DEFAULT_CONFIG_SETTING);
    const data = foundry.utils.deepClone(defaults);

    // 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.drawings.tools.role;
    data.interface = interfaceToggle?.active;

    // Tool-based settings
    const strokeWidth = data.strokeWidth ?? 8;
    switch ( tool ) {
      case "rect":
        data.shape.type = Drawing$1.SHAPE_TYPES.RECTANGLE;
        data.shape.width = strokeWidth + 1;
        data.shape.height = strokeWidth + 1;
        break;
      case "ellipse":
        data.shape.type = Drawing$1.SHAPE_TYPES.ELLIPSE;
        data.shape.width = strokeWidth + 1;
        data.shape.height = strokeWidth + 1;
        break;
      case "polygon":
        data.shape.type = Drawing$1.SHAPE_TYPES.POLYGON;
        data.shape.points = [0, 0, 1, 0];
        data.bezierFactor = 0;
        break;
      case "freehand":
        data.shape.type = Drawing$1.SHAPE_TYPES.POLYGON;
        data.shape.points = [0, 0, 1, 0];
        break;
      case "text":
        data.shape.type = Drawing$1.SHAPE_TYPES.RECTANGLE;
        data.shape.width = strokeWidth + 1;
        data.shape.height = strokeWidth + 1;
        data.fillColor = "#ffffff";
        data.fillAlpha = 0.10;
        data.strokeColor = "#ffffff";
        data.text ||= "";
        break;
    }

    // Return the cleaned data
    return DrawingDocument.cleanData(data);
  }

  /* -------------------------------------------- */

  /** @override */
  static prepareSceneControls() {
    const sc = SceneControls;
    return {
      name: "drawings",
      order: 4,
      title: "CONTROLS.GroupDrawing",
      layer: "drawings",
      icon: "fa-solid fa-pencil",
      visible: game.user.can("DRAWING_CREATE"),
      onChange: (event, active) => {
        if ( active ) canvas.drawings.activate();
      },
      onToolChange: () => canvas.drawings.setAllRenderFlags({refreshState: true}),
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.DrawingSelect",
          icon: "fa-solid fa-expand",
          toolclip: {
            src: "toolclips/tools/drawing-select.webm",
            heading: "CONTROLS.DrawingSelect",
            items: sc.buildToolclipItems(["selectAlt", "selectMultiple", "move", "hud", "edit", "delete", "rotate"])
          }
        },
        rect: {
          name: "rect",
          order: 2,
          title: "CONTROLS.DrawingRect",
          icon: "fa-solid fa-square",
          toolclip: {
            src: "toolclips/tools/drawing-rect.webm",
            heading: "CONTROLS.DrawingRect",
            items: sc.buildToolclipItems(["draw", "move", "hud", "edit", "delete", "rotate"])
          }
        },
        ellipse: {
          name: "ellipse",
          order: 3,
          title: "CONTROLS.DrawingEllipse",
          icon: "fa-solid fa-circle",
          toolclip: {
            src: "toolclips/tools/drawing-ellipse.webm",
            heading: "CONTROLS.DrawingEllipse",
            items: sc.buildToolclipItems(["draw", "move", "hud", "edit", "delete", "rotate"])
          }
        },
        polygon: {
          name: "polygon",
          order: 4,
          title: "CONTROLS.DrawingPoly",
          icon: "fa-solid fa-draw-polygon",
          toolclip: {
            src: "toolclips/tools/drawing-polygon.webm",
            heading: "CONTROLS.DrawingPoly",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonDraw", content: "CONTROLS.DrawingPolyP"},
              "move", "hud", "edit", "delete", "rotate"])
          }
        },
        freehand: {
          name: "freehand",
          order: 5,
          title: "CONTROLS.DrawingFree",
          icon: "fa-solid fa-signature",
          toolclip: {
            src: "toolclips/tools/drawing-free.webm",
            heading: "CONTROLS.DrawingFree",
            items: sc.buildToolclipItems(["draw", "move", "hud", "edit", "delete", "rotate"])
          }
        },
        text: {
          name: "text",
          order: 6,
          title: "CONTROLS.DrawingText",
          icon: "fa-solid fa-font",
          onChange: () => {
            const controlled = canvas.drawings.controlled;
            if ( controlled.length === 1 ) controlled[0].enableTextEditing();
          },
          toolclip: {
            src: "toolclips/tools/drawing-text.webm",
            heading: "CONTROLS.DrawingText",
            items: sc.buildToolclipItems(["draw", "move", "hud", "edit", "delete", "rotate"])
          }
        },
        role: {
          name: "role",
          order: 7,
          title: "CONTROLS.DrawingRole",
          icon: "fa-solid fa-circle-info",
          toggle: true,
          active: false
        },
        snap: {
          name: "snap",
          order: 8,
          title: "CONTROLS.CommonForceSnap",
          icon: "fa-solid fa-plus",
          toggle: true,
          visible: !canvas.grid?.isGridless,
          active: canvas.forceSnapVertices,
          onChange: (event, toggled) => canvas.forceSnapVertices = toggled
        },
        configure: {
          name: "configure",
          order: 9,
          title: "CONTROLS.DrawingConfig",
          icon: "fa-solid fa-gear",
          onChange: () => canvas.drawings.configureDefault(),
          button: true
        },
        clear: {
          name: "clear",
          order: 10,
          title: "CONTROLS.DrawingClear",
          icon: "fa-solid fa-trash",
          visible: game.user.isGM,
          onChange: () => canvas.drawings.deleteAll(),
          button: true
        }
      },
      activeTool: "select"
    };
  }

  /* -------------------------------------------- */
  /*  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$1("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);
    drawing._fixedPoints = [0, 0];
    document._object = drawing;
    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;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _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$1("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();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftCancel(event) {
    const preview = this.preview.children?.[0] || null;
    if ( preview?._chain ) {
      preview._removePoint();
      preview.refresh();
      if ( preview.document.shape.points.length && !event.interactionData.cancelled ) 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 {this["_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});
  }

  /* -------------------------------------------- */

  /** @override */
  static prepareSceneControls() {
    const sc = SceneControls;
    return {
      name: "lighting",
      order: 6,
      title: "CONTROLS.GroupLighting",
      layer: "lighting",
      icon: "fa-regular fa-lightbulb",
      visible: game.user.isGM,
      onChange: (event, active) => {
        if ( active ) canvas.lighting.activate();
      },
      onToolChange: () => canvas.lighting.setAllRenderFlags({refreshState: true}),
      tools: {
        light: {
          name: "light",
          order: 1,
          title: "CONTROLS.LightDraw",
          icon: "fa-solid fa-lightbulb",
          toolclip: {
            src: "toolclips/tools/light-draw.webm",
            heading: "CONTROLS.LightDraw",
            items: sc.buildToolclipItems(["create", "edit", "rotate", "onOff"])
          }
        },
        day: {
          name: "day",
          order: 2,
          title: "CONTROLS.LightDay",
          icon: "fa-solid fa-sun",
          visible: !canvas.scene?.environment.darknessLock,
          onChange: () => canvas.scene.update(
            {environment: {darknessLevel: 0.0}},
            {animateDarkness: CONFIG.Canvas.darknessToDaylightAnimationMS}
          ),
          button: true,
          toolclip: {
            src: "toolclips/tools/light-day.webm",
            heading: "CONTROLS.LightDay",
            items: sc.buildToolclipItems([{heading: "CONTROLS.MakeDayH", content: "CONTROLS.MakeDayP"},
              {heading: "CONTROLS.AutoLightToggleH", content: "CONTROLS.AutoLightToggleP"}])
          }
        },
        night: {
          name: "night",
          order: 3,
          title: "CONTROLS.LightNight",
          icon: "fa-solid fa-moon",
          visible: !canvas.scene?.environment.darknessLock,
          onChange: () => canvas.scene.update(
            {environment: {darknessLevel: 1.0}},
            {animateDarkness: CONFIG.Canvas.daylightToDarknessAnimationMS}
          ),
          button: true,
          toolclip: {
            src: "toolclips/tools/light-night.webm",
            heading: "CONTROLS.LightNight",
            items: sc.buildToolclipItems([{heading: "CONTROLS.MakeNightH", content: "CONTROLS.MakeNightP"},
              {heading: "CONTROLS.AutoLightToggleH", content: "CONTROLS.AutoLightToggleP"}])
          }
        },
        reset: {
          name: "reset",
          order: 4,
          title: "CONTROLS.LightReset",
          icon: "fa-solid fa-cloud",
          onChange: () => {
            DialogV2.confirm({
              window: {title: "CONTROLS.FOWResetTitle", icon: "fa-solid fa-cloud"},
              content: `<p>${game.i18n.localize("CONTROLS.FOWResetDesc")}</p>`,
              yes: {callback: () => canvas.fog.reset()}
            });
          },
          button: true,
          toolclip: {
            src: "toolclips/tools/light-reset.webm",
            heading: "CONTROLS.LightReset",
            items: [{paragraph: "CONTROLS.LightResetP"}]
          }
        },
        clear: {
          name: "clear",
          order: 5,
          title: "CONTROLS.LightClear",
          icon: "fa-solid fa-trash",
          onChange: () => canvas.lighting.deleteAll(),
          button: true
        }
      },
      activeTool: "light"
    };
  }

  /* -------------------------------------------- */
  /*  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);
  }

  /* -------------------------------------------- */

  /** @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 AmbientLightDocument
    const cls = foundry.utils.getDocumentClass("AmbientLight");
    const doc = new cls(interaction.origin, {parent: canvas.scene});

    // Create the preview AmbientLight object
    const preview = new this.constructor.placeableClass(doc);
    doc._object = preview;

    // 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});
    }
  }
}

/**
 * @import Note from "../placeables/note.mjs";
 */

/**
 * 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
  /* -------------------------------------------- */

  /** @inheritDoc */
  _getCopyableObjects(options) {
    if ( !game.user.can("NOTE_CREATE") ) return [];
    return super._getCopyableObjects(options);
  }

  /* -------------------------------------------- */

  /** @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;
    ui.controls._updateNotesIcon();
  }

  /* -------------------------------------------- */

  /**
   * 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: true}),
      onChange: value => {
        if ( !canvas.ready ) return;
        const layer = canvas.notes;
        layer.objects.visible = layer.interactiveChildren = layer.active || value;
        ui.controls._updateNotesIcon();
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * 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});
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static prepareSceneControls() {
    return {
      name: "notes",
      order: 10,
      title: "CONTROLS.GroupNotes",
      layer: "notes",
      icon: "fa-solid fa-bookmark",
      onChange: (event, active) => {
        if ( active ) canvas.notes.activate();
      },
      onToolChange: () => canvas.notes.setAllRenderFlags({refreshState: true}),
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.NoteSelect",
          icon: "fa-solid fa-expand"
        },
        journal: {
          name: "journal",
          order: 2,
          title: "NOTE.Create",
          visible: game.user.hasPermission("NOTE_CREATE"),
          icon: CONFIG.JournalEntry.sidebarIcon
        },
        toggle: {
          name: "toggle",
          order: 3,
          title: "CONTROLS.NoteToggle",
          icon: "fa-solid fa-map-pin",
          toggle: true,
          active: game.settings.get("core", NotesLayer.TOGGLE_SETTING),
          onChange: (event, toggled) => game.settings.set("core", NotesLayer.TOGGLE_SETTING, toggled)
        },
        clear: {
          name: "clear",
          order: 4,
          title: "CONTROLS.NoteClear",
          icon: "fa-solid fa-trash",
          visible: game.user.isGM,
          onChange: () => canvas.notes.deleteAll(),
          button: true
        }
      },
      activeTool: "select"
    };
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onClickLeft(event) {
    if ( game.activeTool !== "journal" ) return super._onClickLeft(event);
    const origin = event.getLocalPosition(canvas.stage);
    const noteData = canvas.grid.getCenterPoint(origin);
    const cls = foundry.utils.getDocumentClass("Note");
    await cls.createDialog(noteData);
  }

  /* -------------------------------------------- */

  /**
   * 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$1.implementation.fromDropData(data);
    if ( data.type === "JournalEntryPage" ) {
      const page = await JournalEntryPage$1.implementation.fromDropData(data);
      entry = page.parent;
      noteData.pageId = page.id;
    }
    if ( entry?.inCompendium ) {
      const journalData = game.journal.fromCompendium(entry);
      entry = await JournalEntry$1.implementation.create(journalData);
    }
    noteData.entryId = entry?.id;
    return this._createPreview(noteData, {top: event.clientY - 20, left: event.clientX + 40});
  }
}

/**
 * @import {LineIntersection} from "@common/utils/_types.mjs";
 */

/**
 * @typedef PolygonVertexOptions
 * @property {number} [distance]       A known distance from a polygon origin to this vertex.
 * @property {number} [index]          An integer index of this vertex in an ordered sweep.
 * @property {boolean} [round=true]    Whether to round the input {x,y} coordinates provided.
 */

/**
 * A specialized point data structure used to represent vertices in the context of the ClockwiseSweepPolygon.
 * This class is not designed or intended for use outside of that context.
 */
class PolygonVertex {
  /**
   * Construct a PolygonVertex by providing {x, y} coordinates and vertex options.
   * @param {number} x                          The x-coordinate of the vertex
   * @param {number} y                          The y-coordinate of the vertex
   * @param {PolygonVertexOptions} [options]    Options which modify vertex context or behavior
   */
  constructor(x, y, {distance, index, round=true}={}) {
    const rx = Math.round(x);
    const ry = Math.round(y);
    this.x = round ? rx : x;
    this.y = round ? ry : y;
    this.key = PolygonVertex.getKey(rx, ry);
    this._distance = distance;
    this._index = index;
  }

  /**
   * The effective maximum texture size that Foundry VTT "ever" has to worry about.
   * @type {number}
   */
  static #MAX_TEXTURE_SIZE = Math.pow(2, 16);

  /**
   * Determine the sort key to use for this vertex, arranging points from north-west to south-east.
   * @param {number} x    The x-coordinate
   * @param {number} y    The y-coordinate
   * @returns {number}    The key used to identify the vertex
   */
  static getKey(x, y) {
    return (this.#MAX_TEXTURE_SIZE * x) + y;
  }

  /**
   * The set of edges which connect to this vertex.
   * This set is initially empty and populated later after vertices are de-duplicated.
   * @type {EdgeSet}
   */
  edges = new Set();

  /**
   * The subset of edges which continue clockwise from this vertex.
   * @type {EdgeSet}
   */
  cwEdges = new Set();

  /**
   * The subset of edges which continue counter-clockwise from this vertex.
   * @type {EdgeSet}
   */
  ccwEdges = new Set();

  /**
   * The set of vertices collinear to this vertex
   * @type {Set<PolygonVertex>}
   */
  collinearVertices = new Set();

  /**
   * Is this vertex an endpoint of one or more edges?
   * @type {boolean}
   */
  isEndpoint;

  /**
   * Does this vertex have a single counterclockwise limiting edge?
   * @type {boolean}
   */
  isLimitingCCW;

  /**
   * Does this vertex have a single clockwise limiting edge?
   * @type {boolean}
   */
  isLimitingCW;

  /**
   * Does this vertex have non-limited edges or 2+ limited edges counterclockwise?
   * @type {boolean}
   */
  isBlockingCCW;

  /**
   * Does this vertex have non-limited edges or 2+ limited edges clockwise?
   * @type {boolean}
   */
  isBlockingCW;

  /**
   * Does this vertex result from an internal collision?
   * @type {boolean}
   */
  isInternal = false;

  /**
   * The maximum restriction imposed by this vertex.
   * @type {number}
   */
  restriction = 0;

  /**
   * Record whether this PolygonVertex has been visited in the sweep
   * @type {boolean}
   * @internal
   */
  _visited = false;

  /**
   * The distance from a polygon origin to this vertex.
   * @type {number|undefined}
   * @internal
   */
  _distance;

  /**
   * The squared distance from a polygon origin to this vertex.
   * @type {number|undefined}
   * @internal
   */
  _d2;

  /**
   * The integer index of this vertex in an ordered sweep.
   * @type {number|undefined}
   * @internal
   */
  _index;

  /**
   * The angle of the ray from the origin to this vertex.
   * @type {number|undefined}
   * @internal
   */
  _angle;

  /**
   * The line intersection coordinates of the two edges that create this vertex.
   * @type {LineIntersection|undefined}
   * @internal
   */
  _intersectionCoordinates;

  /* -------------------------------------------- */

  /**
   * Is this vertex limited in type?
   * @returns {boolean}
   */
  get isLimited() {
    return this.restriction === CONST.WALL_SENSE_TYPES.LIMITED;
  }

  /* -------------------------------------------- */

  /**
   * Associate an edge with this vertex.
   * @param {Edge} edge             The edge being attached
   * @param {number} orientation    The orientation of the edge with respect to the origin
   * @param {string} type           The restriction type of polygon being created
   */
  attachEdge(edge, orientation, type) {
    this.edges.add(edge);
    this.restriction = Math.max(this.restriction ?? 0, edge[type]);
    if ( orientation <= 0 ) this.cwEdges.add(edge);
    if ( orientation >= 0 ) this.ccwEdges.add(edge);
    this.#updateFlags(type);
  }

  /* -------------------------------------------- */

  /**
   * Update flags for whether this vertex is limiting or blocking in certain direction.
   * @param {string} type
   */
  #updateFlags(type) {
    const classify = edges => {
      const s = edges.size;
      if ( s === 0 ) return {isLimiting: false, isBlocking: false};
      if ( s > 1 ) return {isLimiting: false, isBlocking: true};
      else {
        const isLimiting = edges.first().isLimited(type);
        return {isLimiting, isBlocking: !isLimiting};
      }
    };

    // Flag endpoint
    this.isEndpoint = this.edges.some(edge => {
      return (edge.vertexA || edge.a).equals(this) || (edge.vertexB || edge.b).equals(this);
    });

    // Flag CCW edges
    const ccwFlags = classify(this.ccwEdges);
    this.isLimitingCCW = ccwFlags.isLimiting;
    this.isBlockingCCW = ccwFlags.isBlocking;

    // Flag CW edges
    const cwFlags = classify(this.cwEdges);
    this.isLimitingCW = cwFlags.isLimiting;
    this.isBlockingCW = cwFlags.isBlocking;
  }

  /* -------------------------------------------- */

  /**
   * Is this vertex the same point as some other vertex?
   * @param {PolygonVertex} other   Some other vertex
   * @returns {boolean}             Are they the same point?
   */
  equals(other) {
    return this.key === other.key;
  }

  /* -------------------------------------------- */

  /**
   * Construct a PolygonVertex instance from some other Point structure.
   * @param {Point} point                     The point
   * @param {PolygonVertexOptions} [options]  Additional options that apply to this vertex
   * @returns {PolygonVertex}                 The constructed vertex
   */
  static fromPoint(point, options) {
    return new this(point.x, point.y, options);
  }
}

/**
 * @import Wall from "../placeables/wall.mjs";
 */

/**
 * 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}
   * @internal
   */
  _chain = false;

  /**
   * Track the most recently created or updated wall data for use with the clone tool
   * @type {object|null}
   * @internal
   */
  _cloneType = null;

  /**
   * Reference the last interacted wall endpoint for the purposes of chaining
   * @type {{point: PointArray}}
   * @internal
   */
  _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;
  }

  /* -------------------------------------------- */

  /**
   * 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 canvas.grid.getSnappedPoint({x: point.x, y: point.y}, 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) {
    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);
  }

  /* -------------------------------------------- */

  /**
   * 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
   */
  #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 PolygonVertex(edge.a.x, edge.a.y);
      const b = new 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 => 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);
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static prepareSceneControls() {
    const sc = SceneControls;
    return {
      name: "walls",
      order: 5,
      title: "CONTROLS.GroupWall",
      layer: "walls",
      icon: "fa-solid fa-block-brick",
      visible: game.user.isGM,
      onChange: (event, active) => {
        if ( active ) canvas.walls.activate();
      },
      onToolChange: () => canvas.walls.setAllRenderFlags({refreshState: true}),
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.WallSelect",
          icon: "fa-solid fa-expand",
          toolclip: {
            src: "toolclips/tools/wall-select.webm",
            heading: "CONTROLS.WallSelect",
            items: sc.buildToolclipItems(["selectAlt", "selectMultiple", "move",
              {heading: "CONTROLS.CommonMoveWithoutSnapping", reference: "CONTROLS.ShiftDrag"},
              {heading: "CONTROLS.CommonEdit", content: "CONTROLS.WallSelectEdit"}, "delete"])
          }
        },
        walls: {
          name: "walls",
          order: 2,
          title: "CONTROLS.WallDraw",
          icon: "fa-solid fa-bars",
          toolclip: {
            src: "toolclips/tools/wall-basic.webm",
            heading: "CONTROLS.WallBasic",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallBasicBlocks"},
              "place", "chain", "movePoint", "edit", "delete"])
          }
        },
        terrain: {
          name: "terrain",
          order: 3,
          title: "CONTROLS.WallTerrain",
          icon: "fa-solid fa-mountain",
          toolclip: {
            src: "toolclips/tools/wall-terrain.webm",
            heading: "CONTROLS.WallTerrain",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallTerrainBlocks"},
              "place", "chain", "movePoint", "edit", "delete"])
          }
        },
        invisible: {
          name: "invisible",
          order: 4,
          title: "CONTROLS.WallInvisible",
          icon: "fa-solid fa-eye-slash",
          toolclip: {
            src: "toolclips/tools/wall-invisible.webm",
            heading: "CONTROLS.WallInvisible",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallInvisibleBlocks"},
              "place", "chain", "movePoint", "edit", "delete"])
          }
        },
        ethereal: {
          name: "ethereal",
          order: 5,
          title: "CONTROLS.WallEthereal",
          icon: "fa-solid fa-mask",
          toolclip: {
            src: "toolclips/tools/wall-ethereal.webm",
            heading: "CONTROLS.WallEthereal",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallEtherealBlocks"},
              "place", "chain", "movePoint", "edit", "delete"])
          }
        },
        doors: {
          name: "doors",
          order: 6,
          title: "CONTROLS.WallDoors",
          icon: "fa-solid fa-door-open",
          toolclip: {
            src: "toolclips/tools/wall-door.webm",
            heading: "CONTROLS.WallDoors",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonBlocks", content: "CONTROLS.DoorBlocks"},
              "openClose", "openCloseSilently", "lock", "lockSilently", "place", "chain", "movePoint", "edit"])
          }
        },
        secret: {
          name: "secret",
          order: 7,
          title: "CONTROLS.WallSecret",
          icon: "fa-solid fa-user-secret",
          toolclip: {
            src: "toolclips/tools/wall-secret-door.webm",
            heading: "CONTROLS.WallSecret",
            items: sc.buildToolclipItems([
              {heading: "CONTROLS.WallSecretHidden", content: "CONTROLS.WallSecretHiddenP"},
              {heading: "CONTROLS.CommonBlocks", content: "CONTROLS.DoorBlocks"}, "openClose", "openCloseSilently",
              "lock", "lockSilently", "place", "chain", "movePoint", "edit"])
          }
        },
        window: {
          name: "window",
          order: 8,
          title: "CONTROLS.WallWindow",
          icon: "fa-solid fa-window-frame",
          toolclip: {
            src: "toolclips/tools/wall-window.webm",
            heading: "CONTROLS.WallWindow",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallWindowBlocks"},
              "place", "chain", "movePoint", "edit", "delete"])
          }
        },
        clone: {
          name: "clone",
          order: 9,
          title: "CONTROLS.WallClone",
          icon: "fa-regular fa-clone"
        },
        snap: {
          name: "snap",
          order: 10,
          title: "CONTROLS.CommonForceSnap",
          icon: "fa-solid fa-plus",
          toggle: true,
          visible: !canvas.grid?.isGridless,
          active: canvas.forceSnapVertices,
          onChange: (event, toggled) => canvas.forceSnapVertices = toggled,
          toolclip: {
            src: "toolclips/tools/wall-snap.webm",
            heading: "CONTROLS.CommonForceSnap",
            items: [{paragraph: "CONTROLS.WallSnapP"}]
          }
        },
        closeDoors: {
          name: "closeDoors",
          order: 11,
          title: "CONTROLS.WallCloseDoors",
          icon: "fa-regular fa-door-closed",
          button: true,
          onChange: () => {
            const 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("CONTROLS.WallDoorsClosed", {format: {number: updates.length}});
          }
        },
        clear: {
          name: "clear",
          order: 12,
          title: "CONTROLS.WallClear",
          icon: "fa-solid fa-trash",
          button: true,
          onChange: () => canvas.walls.deleteAll()
        }
      },
      activeTool: "walls"
    };
  }

  /* -------------------------------------------- */
  /*  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("CONTROL");
    const pt = (isChain && this._last.point) ? this._last.point : this._getWallEndpointCoordinates(origin, {snap});
    data.c = pt.concat(pt);
    const cls = getDocumentClass$1("Wall");
    const doc = new cls(data, {parent: canvas.scene});

    // Create the preview Wall object
    const wall = new this.constructor.placeableClass(doc);
    doc._object = wall;
    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("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;
      const 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$1(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);
  }

  /* -------------------------------------------- */

  /**
   * Custom undo for wall creation while chaining is active.
   * @param {object} event
   * @returns {Promise<Document[]>}
   * @protected
   */
  async _onUndoCreate(event) {
    const deleted = await super._onUndoCreate(event);

    // Nothing to do if not chaining, nothing deleted, or no active preview
    if ( !this._chain || !deleted.length || !this.hasPreview ) return deleted;

    // Get the points to anchor to
    const [x0, y0] = deleted[0].c;
    this._last = {point: [x0, y0]};

    // Re‑anchor the existing preview so it starts from the new last point
    const preview = this.preview.children[0];
    if ( !preview._destroyed ) {
      preview.document.updateSource({c: [x0, y0, x0, y0]});
      preview.refresh();
    }

    // If all walls are gone, exit chaining mode entirely
    if ( this.placeables.length <= 0 ) {
      this.hover = null;
      this._chain = false;
      this._last = {point: null};
      this.clearPreviewContainer();

      // Cancel current drag workflow
      if ( canvas.currentMouseManager ) {
        canvas.currentMouseManager.interactionData.cancelled = true;
        canvas.currentMouseManager.cancel();
      }
    }
    return deleted;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickRight(event) {
    if ( event.interactionData.wallsState > WallsLayer.CREATION_STATES.NONE ) return this._onDragLeftCancel(event);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @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.geometry.edges.Edge.identifyEdgeIntersections and has no effect.", {since: 12, until: 14});
  }
}

/**
 * @import {Rectangle, TokenPlannedMovement} from "../../_types.mjs";
 * @import Token from "../placeables/token.mjs";
 * @import User from "@client/documents/user.mjs";
 */

/**
 * The Tokens Container.
 * @category Canvas
 */
class TokenLayer extends PlaceablesLayer {
  constructor() {
    super();

    /**
     * The ruler paths.
     * @type {PIXI.Container}
     * @internal
     */
    this._rulerPaths = new PIXI.Container();
    this._rulerPaths.eventMode = "none";
  }

  /* -------------------------------------------- */

  /**
   * The current index position in the tab cycle
   * @type {number|null}
   * @internal
   */
  _tabIndex = null;

  /* -------------------------------------------- */

  /**
   * The Token that the drag workflow was initiated on, if there's a drag workflow in progress.
   * Set in {@link foundry.canvas.placeables.Token#_onDragLeftStart} and
   * {@link foundry.canvas.placeables.Token#_onDragLeftCancel}.
   * @type {Token|null}
   * @internal
   */
  _draggedToken = null;

  /* -------------------------------------------- */

  /**
   * The currently selected movement action override.
   * @type {string|null}
   * @internal
   */
  _dragMovementAction = null;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "tokens",
      controllableObjects: true,
      rotatableObjects: true,
      confirmDeleteKey: true,
      zIndex: 200
    });
  }

  /** @inheritdoc */
  static documentName = "Token";

  /* -------------------------------------------- */

  /**
   * The set of tokens that trigger occlusion (a union of {@link CONST.TOKEN_OCCLUSION_MODES}).
   * @type {number}
   */
  get occlusionMode() {
    return this.#occlusionMode;
  }

  set occlusionMode(value) {
    if ( this.#occlusionMode === value ) return;
    this.#occlusionMode = value;
    canvas.perception.update({refreshOcclusion: true});
  }

  #occlusionMode;

  /* -------------------------------------------- */

  /**
   * Called when the fog is explored.
   * @type {function()}
   */
  #onFogExplored = () => this.recalculatePlannedMovementPaths();

  /* -------------------------------------------- */

  /** @inheritdoc */
  get hookName() {
    return TokenLayer.name;
  }

  /* -------------------------------------------- */
  /*  Properties
  /* -------------------------------------------- */

  /** @override */
  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);
  }

  /**
   * A Set of Token objects which currently display a combat turn marker.
   * @type {Set<Token>}
   */
  turnMarkers = new Set();

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  /** @override */
  getSnappedPoint(point) {
    const M = CONST.GRID_SNAPPING_MODES;
    return canvas.grid.getSnappedPoint({x: point.x, y: point.y}, {mode: M.TOP_LEFT_CORNER, resolution: 1});
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareKeyboardMovementUpdates(objects, dx, dy, dz) {
    const updates = [];
    const movement = {};
    for ( const object of objects ) {
      updates.push({_id: object.id});
      const waypoint = object._getShiftedPosition(dx, dy, dz);
      waypoint.action = object._getKeyboardMovementAction();
      waypoint.snapped = !canvas.grid.isGridless;  // Keyboard movement should always be snapped in square/hex grids
      waypoint.explicit = false;                   // Keyboard movement does not explicitly place a waypoint
      waypoint.checkpoint = true;
      movement[object.id] = {waypoints: [waypoint], method: "keyboard"};
    }
    return [updates, {movement}];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    this.addChild(this._rulerPaths);
    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.#animate, this);
    canvas.fog.addEventListener("explored", this.#onFogExplored);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {
    this.removeChild(this._rulerPaths);
    this.concludeAnimation();
    canvas.fog.removeEventListener("explored", this.#onFogExplored);
    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;
  }

  /* -------------------------------------------- */

  /**
   * Target all Token instances which fall within a coordinate rectangle.
   * @param {Rectangle} rectangle                    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
   */
  targetObjects({x, y, width, height}, {releaseOthers=true}={}) {
    const targets = [];
    const rectangle = new PIXI.Rectangle(x, y, width, height);
    for ( const token of this.placeables ) {
      if ( !token.visible || !token.renderable || token.document.isSecret ) continue;
      if ( token._overlapsSelection(rectangle) ) targets.push(token.id);
    }
    return this.setTargets(targets, {mode: releaseOthers ? "replace" : "acquire"});
  }

  /* -------------------------------------------- */

  /**
   * Assign multiple token targets
   * @param {string[]|Set<string>} targetIds    The array or set of Token IDs.
   * @param {object} [options]                  Additional options to configure targeting behaviour.
   * @param {"replace"|"acquire"|"release"} [options.mode="replace"]   The mode that determines the targeting behavior.
   *   - `"replace"` (default): Replace the current set of targeted Tokens with provided set of Tokens.
   *   - `"acquire"`: Acquire the given Tokens as targets without releasing already targeted Tokens.
   *   - `"release"`: Release the given Tokens as targets.
   */
  setTargets(targetIds, {mode="replace"}={}) {
    let targets = new Set();
    if ( mode !== "replace" ) {
      for ( const token of game.user.targets ) targets.add(token.id);
    }
    if ( mode === "release" ) {
      for ( const id of targetIds ) targets.delete(id);
    } else {
      for ( const id of targetIds ) {
        const token = this.get(id);
        if ( token ) targets.add(id);
      }
    }
    targets = Array.from(targets);
    game.user.broadcastActivity({targets});
    game.user._onUpdateTokenTargets(targets);
  }

  /* -------------------------------------------- */

  /**
   * 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[]}
   */
  #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.#animate, this);
  }

  /* -------------------------------------------- */

  /**
   * Animate.
   * @param {number} deltaTime    The delta time
   */
  #animate(deltaTime) {
    this.#animateTargets();
    this.#animateTurnMarkers(deltaTime);
  }

  /* -------------------------------------------- */

  /**
   * Animate targeting arrows on targeted tokens.
   */
  #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);
    const config = {margin: m, alpha: a, border: {width: 2 * canvas.dimensions.uiScale}};
    for ( const t of game.user.targets ) t._drawTargetArrows(config);
  }

  /* -------------------------------------------- */

  /**
   * Animate the turn markers.
   * @param {number} deltaTime    The delta time
   */
  #animateTurnMarkers(deltaTime) {
    for ( const token of this.turnMarkers ) token.turnMarker?.animate(deltaTime);
  }

  /* -------------------------------------------- */

  /**
   * Recalculate the planned movement paths of all Tokens for the current User.
   */
  recalculatePlannedMovementPaths() {
    const contexts = this._draggedToken?.mouseInteractionManager.interactionData.contexts;
    if ( !contexts ) return;
    for ( const context of Object.values(contexts) ) context.token.recalculatePlannedMovementPath();
  }

  /* -------------------------------------------- */

  /**
   * Handle broadcast planned movement update.
   * @param {User} user    The User the planned movement data belongs to
   * @param {{[tokenId: string]: TokenPlannedMovement|null} | null} plannedMovements    The planned movement data
   * @internal
   */
  _updatePlannedMovements(user, plannedMovements) {

    // Clear all planned movement of this user
    if ( plannedMovements === null ) {
      for ( const token of this.placeables ) {
        if ( user.id in token._plannedMovement ) {
          delete token._plannedMovement[user.id];
          token.renderFlags.set({refreshRuler: true, refreshState: true});
        }
      }
    }

    // Update planned movement of this user
    else {
      for ( const tokenId in plannedMovements ) {
        const token = this.get(tokenId);
        if ( !token ) continue;
        const plannedMovement = plannedMovements[tokenId];

        // Remove planned movement from this token
        if ( plannedMovement === null ) {
          if ( user.id in token._plannedMovement ) {
            delete token._plannedMovement[user.id];
            token.renderFlags.set({refreshRuler: true, refreshState: true});
          }
        }

        // Update planned movement of this token
        else {
          token.renderFlags.set({refreshRuler: true, refreshState: !(user.id in token._plannedMovement)});
          token._plannedMovement[user.id] = plannedMovement;

          // Impute cost and create terrain data
          for ( const waypoints of [
            plannedMovement.foundPath,
            plannedMovement.unreachableWaypoints,
            plannedMovement.history
          ] ) {
            for ( const waypoint of waypoints ) {
              if ( waypoint.terrain ) waypoint.terrain = CONFIG.Token.movement.TerrainData.fromSource(waypoint.terrain);
              waypoint.cost ??= Infinity;
            }
          }
        }
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * 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
   */
  _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 */
  _getMovableObjects(ids, includeLocked) {
    const tokens = super._getMovableObjects(ids, includeLocked);
    return tokens.filter(token => !token._preventKeyboardMovement);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getCopyableObjects(options) {
    if ( !game.user.can("TOKEN_CREATE") || (options.cut && !game.user.can("TOKEN_DELETE")) ) return [];
    return super._getCopyableObjects(options);
  }

  /* -------------------------------------------- */

  /** @override */
  storeHistory(type, data, options) {

    // Don't store stop/pause/resume/clear movement updates in the history
    if ( options._stopMovement || options._pauseMovement || options._resumeMovement
      || options._clearMovementHistory ) return;

    const undoOptions = {};
    if ( type === "update" ) {

      // Clean actorData and delta updates from the history so changes to those fields are not undone.
      for ( const d of data ) {
        delete d.actorData;
        delete d.delta;
        delete d._movementHistory;
        delete d._regions;
      }

      // Undo movement
      if ( options._movement ) {
        for ( const d of data ) {
          const movement = options._movement[d._id];
          if ( !movement ) continue;
          for ( const key of TokenDocument.MOVEMENT_FIELDS ) delete d[key];

          // Force teleport the token back to the origin
          undoOptions.movement ??= {};
          undoOptions.movement[d._id] = {waypoints: [{...movement.origin, action: "displace"}], method: "undo"};
          undoOptions.diff = false;
          undoOptions.animate = false;

          // Undo movement history
          if ( movement.recorded ) {
            d._movementHistory = movement.history.recorded.waypoints.map(
              waypoint => ({...waypoint, cost: waypoint.cost === Infinity ? null : waypoint.cost}));
          }
        }
      }
    }

    this._storeHistory(type, data, undoOptions);
  }

  /* -------------------------------------------- */

  /** @override */
  _onCycleViewKey(event) {
    if ( (game.activeTool !== "select") || canvas.controls.ruler.active ) return false;
    if ( this._draggedToken ) this.#cycleDragMovementAction(event.shiftKey);
    else {
      const cycled = this.cycleTokens(!event.shiftKey, false);
      if ( !cycled ) canvas.recenter();
    }
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  async _confirmDeleteKey(documents) {
    let inCombat = false;
    const tokens = new Set(documents);
    for ( const combat of game.combats ) {
      for ( const combatant of combat.combatants ) {
        if ( tokens.has(combatant.token) ) {
          inCombat = true;
          break;
        }
      }
      if ( inCombat ) break;
    }
    if ( !inCombat ) return true;
    const question = game.i18n.localize("AreYouSure");
    const warning = game.i18n.localize("TOKEN.DeleteCombatantWarning");
    return DialogV2.confirm({
      window: {
        title: game.i18n.format("DOCUMENT.Delete", {type: game.i18n.localize(TokenDocument.metadata.label)}) // FIXME: double localization
      },
      content: `<p><strong>${question}</strong> ${warning}</p>`
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static prepareSceneControls() {
    const sc = SceneControls;
    return {
      name: "tokens",
      order: 1,
      title: "CONTROLS.GroupToken",
      icon: "fa-solid fa-user-large",
      onChange: (event, active) => {
        if ( active ) canvas.tokens.activate();
      },
      onToolChange: () => canvas.tokens.setAllRenderFlags({refreshState: true}),
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.BasicSelect",
          icon: "fa-solid fa-expand",
          toolclip: {
            src: "toolclips/tools/token-select.webm",
            heading: "CONTROLS.BasicSelect",
            items: sc.buildToolclipItems([
              {paragraph: "CONTROLS.BasicSelectP"},
              "selectAlt", "selectMultiple", "move", "rotate", "hud", "sheet",
              game.user.isGM ? "editAlt" : null,
              game.user.isGM ? "delete" : null,
              {heading: "CONTROLS.RulerPlaceWaypoint", reference: "CONTROLS.CtrlClick"},
              {heading: "CONTROLS.RulerRemoveWaypoint", reference: "CONTROLS.RightClick"}
            ])
          }
        },
        target: {
          name: "target",
          order: 2,
          title: "CONTROLS.TargetSelect",
          icon: "fa-solid fa-bullseye",
          toolclip: {
            src: "toolclips/tools/token-target.webm",
            heading: "CONTROLS.TargetSelect",
            items: sc.buildToolclipItems([
              {paragraph: "CONTROLS.TargetSelectP"}, "selectAlt", "selectMultiple"
            ])
          }
        },
        ruler: {
          name: "ruler",
          order: 3,
          title: "CONTROLS.BasicMeasure",
          icon: "fa-solid fa-ruler",
          toolclip: {
            heading: "CONTROLS.BasicMeasure",
            items: sc.buildToolclipItems([
              {heading: "CONTROLS.RulerPlaceWaypoint", reference: "CONTROLS.CtrlClick"},
              {heading: "CONTROLS.RulerRemoveWaypoint", reference: "CONTROLS.RightClick"}
            ])
          }
        },
        unconstrainedMovement: {
          name: "unconstrainedMovement",
          order: 4,
          title: "CONTROLS.UnconstrainedMovement",
          icon: "fa-solid fa-ghost",
          toggle: true,
          active: game.settings.get("core", "unconstrainedMovement"),
          visible: game.user.isGM,
          onChange: (event, toggled) => {
            game.settings.set("core", "unconstrainedMovement", toggled);
          },
          toolclip: {
            heading: "CONTROLS.UnconstrainedMovement",
            items: sc.buildToolclipItems([
              {paragraph: "CONTROLS.UnconstrainedMovementP"}
            ])
          }
        }
      },
      activeTool: "select"
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _highlightObjects(active) {
    super._highlightObjects(active);
    if ( this.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.HIGHLIGHTED ) {
      canvas.perception.update({refreshOcclusion: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Cycle the drag movement action override.
   * @param {boolean} reverse
   */
  #cycleDragMovementAction(reverse) {
    const token = this._draggedToken.document;
    const currentAction = token.movementAction;
    const actions = Object.entries(CONFIG.Token.movement.actions).reduce((actions, [action, {canSelect}]) => {
      if ( canSelect(token) || (action === currentAction) ) actions.push(action);
      return actions;
    }, []);
    const current = actions.indexOf(this._dragMovementAction ?? token.movementAction);
    if ( current < 0 ) this._dragMovementAction = actions[0];
    else {
      const next = (current + (reverse ? -1 : 1) + actions.length) % actions.length;
      this._dragMovementAction = actions[next];
    }
    this.recalculatePlannedMovementPaths();
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle dropping of Actor data onto the Scene canvas
   * @param {DragEvent} event
   * @param {{type: "Actor"; uuid: string; x: number; y: number; elevation?: number}} data
   * @internal
   */
  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!");
    }

    // Validate the drop position
    if ( !canvas.dimensions.rect.contains(data.x, data.y) ) return false;

    // Acquire dropped data and import the actor
    let actor = await Actor$1.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.inCompendium ) {
      const actorData = game.actors.fromCompendium(actor);
      actor = await Actor$1.implementation.create(actorData, {fromCompendium: true});
    }

    // Prepare the Token document
    const token = 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 position = CONFIG.Token.objectClass._getDropActorPosition(token, {x: data.x, y: data.y,
      elevation: data.elevation}, {snap: !event.shiftKey});
    token.updateSource(position);

    // Submit the Token creation request and activate the Tokens layer (if not already active)
    this.activate();
    return token.constructor.create(token, {parent: canvas.scene});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickLeft(event) {

    // If a token is being dragged, delegate to this token
    if ( this._draggedToken ) return this._draggedToken._onDragClickLeft(event);
    let tool = game.activeTool;

    // If Control is being held, we always want the Tool to be Ruler
    const ruler = canvas.controls.ruler;
    if ( ruler.active ) return ruler._onClickLeft(event);
    if ( CONFIG.Canvas.rulerClass.canMeasure ) tool = "ruler";

    // Tool-specific handling
    switch ( tool ) {
      case "target":
        // Clear targets if Left Click Release is set
        if ( game.settings.get("core", "leftClickRelease") ) {
          game.user._onUpdateTokenTargets([]);
          game.user.broadcastActivity({targets: []});
        }
        break;

      // Prevent default behavior with the Ruler tool
      case "ruler": return;
    }

    // If we don't explicitly return from handling the tool, use the default behavior
    super._onClickLeft(event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickLeft2(event) {

    // If a token is being dragged, delegate to this token
    if ( this._draggedToken ) return this._draggedToken._onDragClickLeft2(event);

    // Otherwise handle the event normally
    super._onClickLeft2(event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickRight(event) {

    // If a token is being dragged, delegate to this token
    if ( this._draggedToken ) return this._draggedToken._onDragClickRight(event);

    // Otherwise handle the event normally
    super._onClickRight(event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickRight2(event) {

    // If a token is being dragged, delegate to this token
    if ( this._draggedToken ) return this._draggedToken._onDragClickRight2(event);

    // Otherwise handle the event normally
    super._onClickRight2(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftCancel(event) {
    // Override so that previews are not cleared
  }

  /* -------------------------------------------- */

  /** @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});
  }
}

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) {}
}

/**
 * @import {BaseShapeData} from "@common/data/data.mjs";
 */

/**
 * 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.document.top - b.document.top)
      || (a.document.bottom - b.document.bottom)
      || (a._lastSortedIndex - b._lastSortedIndex));
    this.sortDirty = false;
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  get hookName() {
    return RegionLayer.name;
  }

  /* -------------------------------------------- */

  /**
   * The RegionLegend application of this RegionLayer.
   * @type {RegionLegend}
   */
  get legend() {
    return this.#legend ??= new 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;

  /* -------------------------------------------- */
  /*  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, options) {
    if ( type === "update" ) {
      for ( const d of data ) delete d.behaviors;
    }
    super.storeHistory(type, data, options);
  }

  /* -------------------------------------------- */

  /** @override */
  copyObjects() {
    return []; // Prevent copy & paste
  }

  /* -------------------------------------------- */

  /** @override */
  getSnappedPoint(point) {
    const M = CONST.GRID_SNAPPING_MODES;
    const size = canvas.dimensions.size;
    return canvas.grid.getSnappedPoint({x: point.x, y: point.y}, 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;

    /** @deprecated since v13 */
    for ( const region of canvas.scene.regions ) {
      for ( const behavior of region.behaviors ) {
        if ( behavior.hasEvent("behaviorStatus") ) {
          foundry.utils.logCompatibilityWarning(`RegionBehavior [${behavior.uuid}] subscribes to the BEHAVIOR_STATUS event, `
            + "which is deprecated in favor of BEHAVIOR_ACTIVATED, BEHAVIOR_DEACTIVATED, BEHAVIOR_VIEWED, and BEHAVIOR_UNVIEWED.", {since: 13, until: 15});
        }
        if ( behavior.hasEvent("tokenMove") ) {
          foundry.utils.logCompatibilityWarning(`RegionBehavior [${behavior.uuid}] subscribes to the TOKEN_MOVE event, `
            + "which is deprecated in favor of TOKEN_MOVE_WITHIN and is no longer triggered.", {since: 13, until: 15});
        }
        if ( behavior.hasEvent("tokenPreMove") ) {
          foundry.utils.logCompatibilityWarning(`RegionBehavior [${behavior.uuid}] subscribes to the TOKEN_PRE_MOVE event, `
            + "which is deprecated without replacement and is no longer triggered.", {since: 13, until: 15});
        }
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Highlight the shape or clear the highlight.
   * @param {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;
    const s = canvas.dimensions.uiScale;
    this.#highlight.visible = true;
    this.#highlight.lineStyle({
      width: CONFIG.Canvas.objectBorderThickness * s,
      color: 0x000000,
      join: PIXI.LINE_JOIN.ROUND,
      shader: new PIXI.smooth.DashLineShader({dash: 8 * s, gap: 5 * s})
    });
    switch ( data.type ) {
      case "circle":
        this.#highlight.drawCircle(data.x, data.y, data.radius);
        break;
      case "ellipse":
        if ( data.rotation !== 0 ) {
          this.#highlight.setMatrix(new PIXI.Matrix()
            .translate(-data.x, -data.y)
            .rotate(Math.toRadians(data.rotation))
            .translate(data.x, data.y));
        }
        this.#highlight.drawEllipse(data.x, data.y, data.radiusX, data.radiusY);
        break;
      case "polygon":
        this.#highlight.drawPolygon(data.points);
        break;
      case "rectangle":
        if ( data.rotation !== 0 ) {
          const centerX = data.x + (data.width / 2);
          const centerY = data.y + (data.height / 2);
          this.#highlight.setMatrix(new PIXI.Matrix()
            .translate(-centerX, -centerY)
            .rotate(Math.toRadians(data.rotation))
            .translate(centerX, centerY));
        }
        this.#highlight.drawRect(data.x, data.y, data.width, data.height);
        break;
      default: throw new Error("Invalid shape type");
    }
  }

  /* -------------------------------------------- */

  /**
   * Refresh the preview shape.
   * @param {PIXI.FederatedEvent} event
   */
  #refreshPreview(event) {
    const s = canvas.dimensions.uiScale;
    this.#preview.clear();
    this.#preview.lineStyle({
      width: CONFIG.Canvas.objectBorderThickness * s,
      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 * s,
      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 ) return;
    data.hole = ui.controls.controls.regions.tools.hole.active;
    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};
  }

  /* -------------------------------------------- */

  /** @override */
  static prepareSceneControls() {
    const sc = SceneControls;
    return {
      name: "regions",
      order: 9,
      title: "CONTROLS.GroupRegion",
      layer: "regions",
      icon: "fa-regular fa-game-board",
      visible: game.user.isGM,
      onChange: (event, active) => {
        if ( active ) canvas.regions.activate();
      },
      onToolChange: () => canvas.regions.setAllRenderFlags({refreshState: true}),
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.RegionSelect",
          icon: "fa-solid fa-expand",
          toolclip: {
            src: "toolclips/tools/region-select.webm",
            heading: "CONTROLS.RegionSelect",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionSelectP"}, "selectAlt", "selectMultiple",
              "edit", "delete"])
          }
        },
        rectangle: {
          name: "rectangle",
          order: 2,
          title: "CONTROLS.RegionRectangle",
          icon: "fa-solid fa-square",
          toolclip: {
            src: "toolclips/tools/region-rectangle.webm",
            heading: "CONTROLS.RegionRectangle",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionShape"}, "draw", "drawProportionally",
              {paragraph: "CONTROLS.RegionPerformance"}])
          }
        },
        ellipse: {
          name: "ellipse",
          order: 3,
          title: "CONTROLS.RegionEllipse",
          icon: "fa-solid fa-circle",
          toolclip: {
            src: "toolclips/tools/region-ellipse.webm",
            heading: "CONTROLS.RegionEllipse",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionShape"}, "draw", "drawProportionally",
              {paragraph: "CONTROLS.RegionPerformance"}])
          }
        },
        polygon: {
          name: "polygon",
          order: 4,
          title: "CONTROLS.RegionPolygon",
          icon: "fa-solid fa-draw-polygon",
          toolclip: {
            src: "toolclips/tools/region-polygon.webm",
            heading: "CONTROLS.RegionPolygon",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionShape"}, "draw", "drawProportionally",
              {paragraph: "CONTROLS.RegionPerformance"}])
          }
        },
        hole: {
          name: "hole",
          order: 5,
          title: "CONTROLS.RegionHole",
          icon: "fa-duotone fa-object-subtract",
          toggle: true,
          active: false,
          toolclip: {
            src: "toolclips/tools/region-hole.webm",
            heading: "CONTROLS.RegionHole",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionHoleP"}])
          }
        },
        snap: {
          name: "snap",
          order: 6,
          title: "CONTROLS.CommonForceSnap",
          icon: "fa-solid fa-plus",
          toggle: true,
          visible: !canvas.grid?.isGridless,
          active: canvas.forceSnapVertices,
          onChange: (event, toggled) => canvas.forceSnapVertices = toggled,
          toolclip: {
            src: "toolclips/tools/region-snap.webm",
            heading: "CONTROLS.CommonForceSnap",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionSnap"}, "draw", "drawProportionally"])
          }
        },
        clear: {
          name: "clear",
          order: 7,
          title: "CONTROLS.RegionClear",
          icon: "fa-solid fa-trash",
          onChange: () => canvas.regions.deleteAll(),
          button: true
        }
      },
      activeTool: "select"
    };
  }

  /* -------------------------------------------- */
  /*  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.almostEqual(points.at(0)) && point.y.almostEqual(points.at(1)) ) {
        interaction.complete = true;
      }

      // Don't add the point if it is equal to the last one
      else if ( !(point.x.almostEqual(points.at(-2)) && point.y.almostEqual(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("CONTROLS.ObjectIsLocked", {format: {
        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.cancelled ) {
      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);
  }
}

/**
 * @import Collection from "@common/utils/collection.mjs";
 * @import {ElevatedPoint, Point} from "../../_types.mjs";
 * @import {AmbientSoundPlaybackConfig} from "./_types.mjs";
 * @import {PointEffectSourceData} from "../sources/point-effect-source.mjs";
 * @import {PositionalSoundPlaybackOptions} from "@client/audio/sound.mjs";
 */

/**
 * This Canvas Layer provides a container for AmbientSound objects.
 * @category Canvas
 */
class SoundsLayer extends PlaceablesLayer {
  constructor(...args) {
    super(...args);
    canvas.registerMouseMoveHandler(this._onMouseMove, Canvas$1.MOUSE_MOVE_HANDLER_PRIORITIES.MEDIUM, this);
  }

  /**
   * 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, 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 ( const sound of this.placeables ) {
      sound.initializeSoundSource();
    }
    for ( const 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 position
   * @param {Point|ElevatedPoint} position    The position to preview
   */
  previewSound(position) {
    if ( !this.placeables.length || game.audio.locked ) return;
    if ( position.elevation === undefined ) position = {x: position.x, y: position.y, elevation: 0};
    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 {ElevatedPoint[]}
   */
  getListenerPositions() {
    const listeners = canvas.tokens.controlled.map(token => token.document.getCenterPoint());
    if ( !listeners.length && !game.user.isGM ) {
      for ( const token of canvas.tokens.placeables ) {
        if ( token.actor?.isOwner && token.isVisible ) listeners.push(token.document.getCenterPoint());
      }
    }
    return listeners;
  }

  /* -------------------------------------------- */

  /**
   * Sync the playing state and volume of all AmbientSound objects based on the position of listener points
   * @param {ElevatedPoint[]} 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 ) {
        /** @deprecated since v13 */
        if ( l.elevation === undefined ) {
          foundry.utils.logCompatibilityWarning("SoundsLayer#_syncPositions(listener: Point[], options: object) has been deprecated "
            + "in favor of SoundsLayer#_syncPositions(listener: ElevatedPoint[], options: object).", {since: 13, until: 15, once: true});
          l = {x: l.x, y: l.y, elevation: 0};
        }
        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
   * @internal
   */
  _configurePlayback(config) {
    const {source, walls} = config;

    // Inaudible sources
    if ( !config.listener ) {
      config.volume = 0;
      config.muffled = false;
      return;
    }

    // Exactly audible
    if ( (config.listener.x === source.x) && (config.listener.y === source.y)
      && (config.listener.elevation === source.elevation) ) {
      config.volume = 1;
      config.muffled = false;
      return;
    }

    // Blocked by walls
    if ( walls ) {
      config.muffled = false;
      return;
    }

    // Muffled by walls
    const polygonCls = CONFIG.Canvas.polygonBackends.sound;
    const x = polygonCls.testCollision(config.listener, source, {mode: "closest", type: "sound", source});
    config.muffled = !!x && (x._distance < 1); // Collided before reaching the source
  }

  /* -------------------------------------------- */

  /**
   * 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 {@link SoundsLayer#emitAtPosition}.
   *
   * @param {string} src                    The sound source path to play
   * @param {Point|ElevatedPoint} origin    The canvas coordinates from which the sound originates
   * @param {number} radius                 The radius of effect in distance units
   * @param {PositionalSoundPlaybackOptions} options  Options passed to {@link Sound#playAtPosition}
   * @returns {Promise<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, options={}) {
    const sound = new Sound(src, {context: game.audio.environment});
    await sound.load();
    return sound.playAtPosition(origin, radius, options);
  }

  /* -------------------------------------------- */

  /**
   * 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);
  }

  /* -------------------------------------------- */

  /** @override */
  static prepareSceneControls() {
    const sc = SceneControls;
    return {
      name: "sounds",
      order: 7,
      title: "CONTROLS.GroupSound",
      layer: "sounds",
      icon: "fa-solid fa-music",
      visible: game.user.isGM,
      onChange: (_event, active) => {
        if ( active ) canvas.sounds.activate();
      },
      onToolChange: () => canvas.sounds.setAllRenderFlags({refreshState: true}),
      tools: {
        sound: {
          name: "sound",
          order: 1,
          title: "CONTROLS.SoundDraw",
          icon: "fa-solid fa-volume-high",
          toolclip: {
            src: "toolclips/tools/sound-draw.webm",
            heading: "CONTROLS.SoundDraw",
            items: sc.buildToolclipItems(["create", "edit", "rotate", "onOff"])
          }
        },
        preview: {
          name: "preview",
          order: 2,
          title: "CONTROLS.SoundPreview",
          icon: "fa-solid fa-headphones",
          toggle: true,
          active: canvas.sounds?.livePreview ?? false,
          onChange: (_event, toggled) => {
            canvas.sounds.livePreview = toggled;
            canvas.sounds.refresh();
          },
          toolclip: {
            src: "toolclips/tools/sound-preview.webm",
            heading: "CONTROLS.SoundPreview",
            items: [{paragraph: "CONTROLS.SoundPreviewP"}]
          }
        },
        clear: {
          name: "clear",
          order: 3,
          title: "CONTROLS.SoundClear",
          icon: "fa-solid fa-trash",
          onChange: () => canvas.sounds.deleteAll(),
          button: true
        }
      },
      activeTool: "sound"
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle mouse cursor movements which may cause ambient audio previews to occur
   * @param {PIXI.Point} currentPos
   * @internal
   */
  _onMouseMove(currentPos) {
    if ( !this.livePreview ) return;
    if ( canvas.tokens.active && canvas.tokens.controlled.length ) return;
    this.previewSound(currentPos);
  }

  /* -------------------------------------------- */

  /** @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 = foundry.utils.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);
    doc._object = sound;
    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.
   * @protected
   */
  async _onDropData(event, data) {
    const playlistSound = await PlaylistSound$1.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 */
  _getCopyableObjects(options) {
    if ( !game.user.can("TEMPLATE_CREATE") ) return [];
    return super._getCopyableObjects(options);
  }

  /* -------------------------------------------- */

  /** @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();
      }
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static prepareSceneControls() {
    const sc = SceneControls;
    return {
      name: "templates",
      order: 2,
      title: "CONTROLS.GroupMeasure",
      icon: "fa-solid fa-ruler-combined",
      visible: game.user.can("TEMPLATE_CREATE"),
      onChange: (event, active) => {
        if ( active ) canvas.templates.activate();
      },
      onToolChange: () => canvas.templates.setAllRenderFlags({refreshState: true}),
      tools: {
        circle: {
          name: "circle",
          order: 1,
          title: "CONTROLS.MeasureCircle",
          icon: "fa-regular fa-circle",
          toolclip: {
            src: "toolclips/tools/measure-circle.webm",
            heading: "CONTROLS.MeasureCircle",
            items: sc.buildToolclipItems(["create", "move", "edit", "hide", "delete"])
          }
        },
        cone: {
          name: "cone",
          order: 2,
          title: "CONTROLS.MeasureCone",
          icon: "fa-solid fa-angle-left",
          toolclip: {
            src: "toolclips/tools/measure-cone.webm",
            heading: "CONTROLS.MeasureCone",
            items: sc.buildToolclipItems(["create", "move", "edit", "hide", "delete", "rotate"])
          }
        },
        rect: {
          name: "rect",
          order: 3,
          title: "CONTROLS.MeasureRect",
          icon: "fa-regular fa-square",
          toolclip: {
            src: "toolclips/tools/measure-rect.webm",
            heading: "CONTROLS.MeasureRect",
            items: sc.buildToolclipItems(["create", "move", "edit", "hide", "delete", "rotate"])
          }
        },
        ray: {
          name: "ray",
          order: 4,
          title: "CONTROLS.MeasureRay",
          icon: "fa-solid fa-up-down",
          toolclip: {
            src: "toolclips/tools/measure-ray.webm",
            heading: "CONTROLS.MeasureRay",
            items: sc.buildToolclipItems(["create", "move", "edit", "hide", "delete", "rotate"])
          }
        },
        clear: {
          name: "clear",
          order: 5,
          title: "CONTROLS.MeasureClear",
          icon: "fa-solid fa-trash",
          visible: game.user.isGM,
          onChange: () => canvas.templates.deleteAll(),
          button: true
        }
      },
      activeTool: "circle"
    };
  }

  /* -------------------------------------------- */
  /*  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 = foundry.utils.getDocumentClass("MeasuredTemplate");
    const doc = new cls(previewData, {parent: canvas.scene});

    // Create a preview MeasuredTemplate object
    const template = new this.constructor.placeableClass(doc);
    doc._object = template;
    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);
  }
}

/**
 * @import {PingData, PingOptions} from "../interaction/_types.mjs";
 * @import BaseRuler from "../interaction/ruler/base-ruler.mjs";
 * @import {ElevatedPoint, Point, Rectangle} from "@common/_types.mjs";
 * @import User from "../../documents/user.mjs";
 * @import {LineIntersection} from "@common/utils/_types.mjs";
 */

/**
 * 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 pings interaction elements.
     * Contains pings elements.
     * @type {PIXI.Container}
     */
    this.pings = this.addChild(new PIXI.Container());
    this.pings.eventMode = "none";
    this.pings.mask = canvas.masks.canvas;

    /**
     * A container of cursor interaction elements not bound to stage transforms.
     * Contains cursors elements.
     * @type {UnboundContainer}
     */
    this.cursors = this.addChild(new UnboundContainer());
    this.cursors.eventMode = "none";

    /**
     * The ruler paths.
     * @type {PIXI.Container}
     * @internal
     */
    this._rulerPaths = this.addChild(new PIXI.Container());
    this._rulerPaths.eventMode = "none";

    /**
     * A graphics instance used for drawing debugging visualization
     * @type {PIXI.Graphics}
     */
    this.debug = this.addChild(new PIXI.Graphics());
    this.debug.eventMode = "none";

    // Register mouse move handler
    canvas.registerMouseMoveHandler(this._onMouseMove, Canvas$1.MOUSE_MOVE_HANDLER_PRIORITIES.HIGH, this, true);
  }

  /**
   * 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, BaseRuler>}
   */
  #rulers = {};

  /**
   * The positions of any offscreen pings we are tracking.
   * @type {Record<string, Point>}
   */
  #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 {BaseRuler}
   */
  get ruler() {
    return this.getRulerForUser(game.user.id);
  }

  /* -------------------------------------------- */

  /**
   * Get the Ruler instance for a specific User ID.
   * @param {string} userId    The User ID
   * @returns {BaseRuler|null}
   */
  getRulerForUser(userId) {
    return this.#rulers[userId] ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Get the Cursor instance for a specific User ID.
   * @param {string} userId    The User ID
   * @returns {Cursor|null}
   */
  getCursorForUser(userId) {
    return this.#cursors[userId] ?? null;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);

    // Create additional elements
    this.drawCursors();
    await this.drawRulers();
    this.drawDoors();
    this.select = this.addChild(new PIXI.Graphics());

    // Adjust scale
    const d = canvas.dimensions;
    this.hitArea = d.rect;
  }

  /* -------------------------------------------- */

  /** @override */
  async _tearDown(options) {
    this.select.destroy();
    this.doors.removeChildren();
    this.pings.removeChildren().forEach(c => c.destroy({children: true}));
    this.cursors.removeChildren().forEach(c => c.destroy({children: true}));
    this.#cursors = {};
    Object.values(this.#rulers).forEach(ruler => ruler.destroy());
    this.#rulers = {};
    this.debug.clear();
    this.debug.debugText?.removeChildren().forEach(c => c.destroy({children: true}));
  }

  /* -------------------------------------------- */

  /**
   * Draw the cursors container
   */
  drawCursors() {
    for ( const user of game.users ) {
      if ( user.active && !user.isSelf ) this.drawCursor(user);
    }
  }

  /* -------------------------------------------- */

  /**
   * Create and add Ruler instances for every game User.
   */
  async drawRulers() {
    const promises = [];
    for ( const user of game.users ) {
      if ( user.active ) promises.push(this.drawRuler(user));
    }
    await Promise.all(promises);
  }

  /* -------------------------------------------- */

  /**
   * 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 {Rectangle} coords    The rectangle
   */
  drawSelect({x, y, width, height}) {
    const s = this.select.clear();
    s.lineStyle(3 * canvas.dimensions.uiScale, 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. With SHOW_CURSOR permission enabled,
   * the user's cursor position is transmitted.
   * @param {PIXI.Point} currentPos
   * @internal
   */
  _onMouseMove(currentPos) {
    game.user.broadcastActivity(game.user.hasPermission("SHOW_CURSOR") ? {cursor: currentPos} : {});
  }

  /* -------------------------------------------- */

  /**
   * 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("CONTROL");
    const isTokenLayer = canvas.activeLayer instanceof foundry.canvas.layers.TokenLayer;
    if ( !game.user.hasPermission("PING_CANVAS") || isCtrl || !isTokenLayer ) return;
    event.interactionData.cancelled = true;
    canvas.currentMouseManager.cancel(event);    // Cancel drag workflow
    return canvas.ping(origin);
  }

  /* -------------------------------------------- */

  /**
   * Handle the canvas panning to a new view.
   * @protected
   */
  _onCanvasPan() {
    for ( const cursor of Object.values(this.#cursors) ) cursor._updatePosition = true;
    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
   * @returns {Cursor}
   */
  drawCursor(user) {
    if ( user.id in this.#cursors ) this.#cursors[user.id].destroy({children: true});
    return this.#cursors[user.id] = this.cursors.addChild(new Cursor(user));
  }

  /* -------------------------------------------- */

  /**
   * Create and draw the Ruler object for a given User.
   * @param {User} user               The User document for whom to draw the Ruler
   * @returns {Promise<BaseRuler>}    The Ruler instance
   */
  async drawRuler(user) {
    if ( user.id in this.#rulers ) this.#rulers[user.id].destroy();
    const ruler = this.#rulers[user.id] = new CONFIG.Canvas.rulerClass(user);
    await ruler.draw();
    return ruler;
  }

  /* -------------------------------------------- */

  /**
   * 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 the Ruler for a User given the provided path.
   * @param {User} user                                             The User for whom to update the Ruler
   * @param {{path: ElevatedPoint[], hidden: boolean}|null} data    The path and hidden state of the Ruler
   */
  async updateRuler(user, data) {
    if ( user.isSelf ) return;
    const ruler = this.getRulerForUser(user.id) ?? await this.drawRuler(user);
    ruler.path = data?.path ?? [];
    ruler.hidden = data?.hidden ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Handle a broadcast ping.
   * @see {@link ControlsLayer#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: 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 });
  }

  /* -------------------------------------------- */

  /**
   * @typedef PingOffscreenDrawOptions
   * @param {string} [style="arrow"]  The style of ping to draw, from {@link CONFIG.Canvas.pings}. Default: `"arrow"`.
   * @param {User} [user]             The User who pinged.
   */

  /**
   * Draw a ping at the edge of the viewport, pointing to the location of an off-screen ping.
   * @see {@link ControlsLayer#drawPing}
   * @param {Point} position                                    The coordinates of the off-screen ping.
   * @param {PingOptions & PingOffscreenDrawOptions} [options]  Additional options to configure how the ping is drawn.
   * @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;
  }

  /* -------------------------------------------- */

  /**
   * @typedef PingDrawOptions
   * @param {string} [style="pulse"]  The style of ping to draw, from  {@link CONFIG.Canvas.pings}. Default: `"pulse"`.
   * @param {User} [user]             The User who pinged.
   */

  /**
   * Draw a ping on the canvas.
   * @see {@link foundry.canvas.interaction.Ping#animate}
   * @param {Point} position                           The position on the canvas that was pinged.
   * @param {PingOptions & PingDrawOptions} [options]  Additional options to configure how the ping is drawn.
   * @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: 100 * (cfg.size || 1) * canvas.dimensions.uiScale
    };
    const ping = new cfg.class(position, foundry.utils.mergeObject(options, pingOptions));
    this.pings.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.
   */
  #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 };
  }
}

/**
 * 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 */
  static get instance() {
    return canvas.interface.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
   *                               - If gridless you need to pass `shape` but not `x` and `y`.
   *                               - If not gridless you need to pass `x` and `y`, but not `shape`.
   * @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 ) {
      if ( !layer.highlight(x, y) ) return;
      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;
    layer.beginFill(color, alpha);
    if ( border !== null ) layer.lineStyle(canvas.grid.thickness, 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, which returns grid distance (gridSpaces: true) and Euclidean distance (gridSpaces: false).";
    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];
  }
}

/**
 * @import Tile from "../placeables/tile.mjs";
 */

/**
 * 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.tools?.foreground.active ?? 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({x: point.x, y: point.y}, {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);
  }

  /* -------------------------------------------- */

  /** @override */
  static prepareSceneControls() {
    const sc = SceneControls;
    return {
      name: "tiles",
      order: 3,
      title: "CONTROLS.GroupTile",
      layer: "tiles",
      icon: "fa-solid fa-cubes",
      visible: game.user.isGM,
      onChange: (event, active) => {
        if ( active ) canvas.tiles.activate();
      },
      onToolChange: () => canvas.tiles.setAllRenderFlags({refreshState: true}),
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.TileSelect",
          icon: "fa-solid fa-expand",
          toolclip: {
            src: "toolclips/tools/tile-select.webm",
            heading: "CONTROLS.TileSelect",
            items: sc.buildToolclipItems(["selectAlt", "selectMultiple", "move", "rotate", "hud", "edit", "delete"])
          }
        },
        tile: {
          name: "tile",
          order: 2,
          title: "CONTROLS.TilePlace",
          icon: "fa-solid fa-cube",
          toolclip: {
            src: "toolclips/tools/tile-place.webm",
            heading: "CONTROLS.TilePlace",
            items: sc.buildToolclipItems(["create", "move", "rotate", "hud", "edit", "delete"])
          }
        },
        browse: {
          name: "browse",
          order: 3,
          title: "CONTROLS.TileBrowser",
          icon: "fa-solid fa-folder",
          button: true,
          onChange: () => new FilePicker.implementation({
            type: "imagevideo",
            displayMode: "tiles",
            tileSize: true
          }).render({force: true}),
          toolclip: {
            src: "toolclips/tools/tile-browser.webm",
            heading: "CONTROLS.TileBrowser",
            items: sc.buildToolclipItems(["place", "move", "rotate", "hud", "edit", "delete"])
          }
        },
        foreground: {
          name: "foreground",
          order: 4,
          title: "CONTROLS.TileForeground",
          icon: "fa-solid fa-house",
          toggle: true,
          active: false,
          onChange: (event, active) => {
            for ( const tile of canvas.tiles.placeables ) {
              tile.renderFlags.set({refreshState: true});
              if ( tile.controlled ) tile.release();
            }
          }
        },
        snap: {
          name: "snap",
          order: 5,
          title: "CONTROLS.CommonForceSnap",
          icon: "fa-solid fa-plus",
          toggle: true,
          visible: !canvas.grid?.isGridless,
          active: canvas.forceSnapVertices,
          onChange: (event, toggled) => canvas.forceSnapVertices = toggled
        }
      },
      activeTool: "select"
    };
  }

  /* -------------------------------------------- */
  /*  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));

    // Signed width and height
    const w = event.altKey ? dist * Math.sign(dx) : dx;
    const h = event.altKey ? dist * Math.sign(dy) : dy;

    // Normalize rectangle
    const r = new PIXI.Rectangle(origin.x, origin.y, w, h).normalize();

    // Update the preview object
    Object.assign(preview.document, {
      x: r.x,
      y: r.y,
      width: r.width,
      height: r.height
    });
    preview.renderFlags.set({refreshTransform: 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 {x, y, width, height}= new PIXI.Rectangle(doc.x, doc.y, doc.width, doc.height).normalize();
    preview.document.updateSource({x, y, width, height});

    // Require a minimum created size
    if ( Math.hypot(width, height) < (canvas.dimensions.size / 2) ) return;

    // Render the preview sheet for confirmation
    preview.sheet.render({force: 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
   * @protected
   */
  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 = foundry.utils.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 fgToggle = ui.controls.controls.tiles.tools.foreground;
    data.elevation = fgToggle.active ? canvas.scene.foregroundElevation : 0;
    data.sort = Math.max(this.getMaxSort() + 1, 0);
    const mode = fgToggle.active ? CONST.OCCLUSION_MODES.FADE : CONST.OCCLUSION_MODES.NONE;
    foundry.utils.setProperty(data, "occlusion.mode", mode);

    // 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 v12
   * @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;
  }
}

var _module$m = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CanvasBackgroundAlterationEffects: CanvasBackgroundAlterationEffects,
  CanvasColorationEffects: CanvasColorationEffects,
  CanvasDarknessEffects: CanvasDarknessEffects,
  CanvasDepthMask: CanvasDepthMask,
  CanvasIlluminationEffects: CanvasIlluminationEffects,
  CanvasLayer: CanvasLayer,
  CanvasOcclusionMask: CanvasOcclusionMask,
  CanvasVisionMask: CanvasVisionMask,
  ControlsLayer: ControlsLayer,
  DarknessLevelContainer: DarknessLevelContainer,
  DrawingsLayer: DrawingsLayer,
  GridLayer: GridLayer,
  InteractionLayer: InteractionLayer,
  LightingLayer: LightingLayer,
  NotesLayer: NotesLayer,
  PlaceablesLayer: PlaceablesLayer,
  RegionLayer: RegionLayer,
  SoundsLayer: SoundsLayer,
  TemplateLayer: TemplateLayer,
  TilesLayer: TilesLayer,
  TokenLayer: TokenLayer,
  WallsLayer: WallsLayer,
  WeatherEffects: WeatherEffects,
  types: _types$8
});

/**
 * @typedef RingColorBand
 * The start and end radii of the token ring color band.
 * @property {number} startRadius The starting normalized radius of the token ring color band.
 * @property {number} endRadius   The ending normalized radius of the token ring color band.
 */

/**
 * @typedef {string} DynamicRingId
 * Dynamic ring id.
 */

/**
 * @typedef RingData
 * Represents the ring- and background-related properties for a given size
 * @property {string|undefined} ringName                 The filename of the ring asset, if available
 * @property {string|undefined} bkgName                  The filename of the background asset, if available
 * @property {string|undefined} maskName                 The filename of the mask asset, if available
 * @property {RingColorBand|undefined} colorBand         Defines color stops for the ring gradient, if applicable
 * @property {number|null} defaultRingColorLittleEndian  Default color for the ring in little-endian BBGGRR format, or null if not set
 * @property {number|null} defaultBackgroundColorLittleEndian  Default color for the background in little-endian BBGGRR format, or null if not set
 * @property {number|null} subjectScaleAdjustment        Scaling factor to adjust how the subject texture fits within the ring, or null if unavailable
 */

var _types$7 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * @import Region from "../region.mjs";
 */

/**
 * The geometry of a {@link foundry.canvas.placeables.Region}.
 * - Vertex Attribute: `aVertexPosition` (`vec2`)
 * - Draw Mode: `PIXI.DRAW_MODES.TRIANGLES`
 */
class RegionGeometry extends PIXI.Geometry {

  /**
   * Create a RegionGeometry.
   * @param {Region} region    The Region to create the RegionGeometry from.
   * @internal
   */
  constructor(region) {
    super();
    this.#region = region;
    this.addAttribute("aVertexPosition", new PIXI.Buffer(new Float32Array(), true, false), 2);
    this.addIndex(new PIXI.Buffer(new Uint16Array(), true, true));
  }

  /* -------------------------------------------- */

  /**
   * The Region this geometry belongs to.
   * @type {Region}
   */
  get region() {
    return this.#region;
  }

  #region;

  /* -------------------------------------------- */

  /**
   * Do the buffers need to be updated?
   * @type {boolean}
   */
  #invalidBuffers = true;

  /* -------------------------------------------- */

  /**
   * Update the buffers.
   * @internal
   */
  _clearBuffers() {
    this.buffers[0].update(new Float32Array());
    this.indexBuffer.update(new Uint16Array());
    this.#invalidBuffers = true;
  }

  /* -------------------------------------------- */

  /**
   * Update the buffers.
   * @internal
   */
  _updateBuffers() {
    if ( !this.#invalidBuffers ) return;
    const triangulation = this.region.document.triangulation;
    this.buffers[0].update(triangulation.vertices);
    this.indexBuffer.update(triangulation.indices);
    this.#invalidBuffers = false;
  }
}

var _module$l = /*#__PURE__*/Object.freeze({
  __proto__: null,
  RegionGeometry: RegionGeometry,
  RegionMesh: RegionMesh
});

/**
 * A special subclass of DataField used to reference a class definition.
 */
class ClassReferenceField extends DataField {
  constructor(options) {
    super(options);
    this.#baseClass = options.baseClass;
  }

  /**
   * The base class linked to this data field.
   * @type {typeof Function}
   */
  #baseClass;

  /** @inheritdoc */
  static get _defaults() {
    const defaults = super._defaults;
    defaults.required = true;
    return defaults;
  }

  /** @override */
  _validateType(value) {
    if ( !foundry.utils.isSubclass(value, this.#baseClass) ) {
      throw new Error(`The value provided to a ClassReferenceField must be a ${this.#baseClass.name} subclass.`);
    }
  }

  /** @override */
  getInitialValue(data) {
    const isConstructed = this.initial?.prototype?.constructor === this.initial;
    if ( isConstructed ) return this.initial;
    return super.getInitialValue(data);
  }
}

/* -------------------------------------------- */

/**
 * Dynamic Ring configuration data model.
 * @extends {foundry.abstract.DataModel}
 * @property {string} id                        The id of this Token Ring configuration.
 * @property {string} label                     The label of this Token Ring configuration.
 * @property {string} spritesheet               The spritesheet path which provides token ring frames for various
 *                                              sized creatures.
 * @property {Record<string, string>} [effects] Registered special effects which can be applied to a token ring.
 * @property {Object} framework
 * @property {typeof TokenRing} [framework.ringClass=TokenRing] The manager class responsible for rendering token rings.
 * @property {typeof PrimaryBaseSamplerShader} [framework.shaderClass=TokenRingSamplerShader]  The shader class used to
 *                                              render the TokenRing.
 */
class DynamicRingData extends DataModel {
  /** @inheritDoc */
  static defineSchema() {
    const fields = foundry.data.fields;

    // Return model schema
    return {
      id: new fields.StringField({blank: true}),
      label: new fields.StringField({blank: false}),
      spritesheet: new fields.FilePathField({categories: ["TEXT"], required: true}),
      effects: new fields.ObjectField({initial: {
        RING_PULSE: "TOKEN.RING.EFFECTS.RING_PULSE",
        RING_GRADIENT: "TOKEN.RING.EFFECTS.RING_GRADIENT",
        BKG_WAVE: "TOKEN.RING.EFFECTS.BKG_WAVE",
        INVISIBILITY: "TOKEN.RING.EFFECTS.INVISIBILITY",
        COLOR_OVER_SUBJECT: "TOKEN.RING.EFFECTS.COLOR_OVER_SUBJECT"
      }}),
      framework: new fields.SchemaField({
        ringClass: new ClassReferenceField({initial: TokenRing, baseClass: TokenRing}),
        shaderClass: new ClassReferenceField({initial: TokenRingSamplerShader, baseClass: PrimaryBaseSamplerShader})
      })
    };
  }
}

/**
 * @import {RingColorBand} from "../_types.mjs"
 * @import {DynamicRingId} from "../_types.mjs"
 */

/**
 * Token Ring configuration Singleton Class.
 *
 * @example Add a new custom ring configuration. Allow only ring pulse, ring gradient and background wave effects.
 * const customConfig = new foundry.canvas.placeables.tokens.DynamicRingData({
 *   id: "myCustomRingId",
 *   label: "Custom Ring",
 *   effects: {
 *     RING_PULSE: "TOKEN.RING.EFFECTS.RING_PULSE",
 *     RING_GRADIENT: "TOKEN.RING.EFFECTS.RING_GRADIENT",
 *     BACKGROUND_WAVE: "TOKEN.RING.EFFECTS.BACKGROUND_WAVE"
 *   },
 *   spritesheet: "canvas/tokens/myCustomRings.json",
 *   framework: {
 *     shaderClass: MyCustomTokenRingSamplerShader,
 *     ringClass: TokenRing
 *   }
 * });
 * CONFIG.Token.ring.addConfig(customConfig.id, customConfig);
 *
 * @example Get a specific ring configuration
 * const config = CONFIG.Token.ring.getConfig("myCustomRingId");
 * console.log(config.spritesheet); // Output: canvas/tokens/myCustomRings.json
 *
 * @example Use a specific ring configuration
 * const success = CONFIG.Token.ring.useConfig("myCustomRingId");
 * console.log(success); // Output: true
 *
 * @example Get the labels of all configurations
 * const configLabels = CONFIG.Token.ring.configLabels;
 * console.log(configLabels);
 * // Output:
 * // {
 * //   "coreSteel": "Foundry VTT Steel Ring",
 * //   "coreBronze": "Foundry VTT Bronze Ring",
 * //   "myCustomRingId" : "My Super Power Ring"
 * // }
 *
 * @example Get the IDs of all configurations
 * const configIDs = CONFIG.Token.ring.configIDs;
 * console.log(configIDs); // Output: ["coreSteel", "coreBronze", "myCustomRingId"]
 *
 * @example Create a hook to add a custom token ring configuration. This ring configuration will appear in the settings.
 * Hooks.on("initializeDynamicTokenRingConfig", ringConfig => {
 *   const mySuperPowerRings = new foundry.canvas.placeables.tokens.DynamicRingData({
 *     id: "myCustomRingId",
 *     label: "My Super Power Rings",
 *     effects: {
 *       RING_PULSE: "TOKEN.RING.EFFECTS.RING_PULSE",
 *       RING_GRADIENT: "TOKEN.RING.EFFECTS.RING_GRADIENT",
 *       BACKGROUND_WAVE: "TOKEN.RING.EFFECTS.BACKGROUND_WAVE"
 *     },
 *     spritesheet: "canvas/tokens/mySuperPowerRings.json"
 *   });
 *   ringConfig.addConfig("mySuperPowerRings", mySuperPowerRings);
 * });
 *
 * @example Activate color bands debugging visuals to ease configuration
 * CONFIG.Token.ring.debugColorBands = true;
 */
class TokenRingConfig {
  constructor() {
    if ( TokenRingConfig.#instance ) {
      throw new Error("An instance of TokenRingConfig has already been created. "
        + "Use `CONFIG.Token.ring` to access it.");
    }
    TokenRingConfig.#instance = this;
  }

  /**
   * The token ring config instance.
   * @type {TokenRingConfig}
   */
  static #instance;

  /**
   * To know if the ring config is initialized.
   * @type {boolean}
   */
  static #initialized = false;

  /**
   * To know if a Token Ring registration is possible.
   * @type {boolean}
   */
  static #closedRegistration = true;

  /**
   * Core token rings used in Foundry VTT.
   * Each key is a string identifier for a ring, and the value is an object containing the ring's data.
   * This object is frozen to prevent any modifications.
   * @type {Readonly<Record<DynamicRingId, RingData>>}
   */
  static CORE_TOKEN_RINGS = Object.freeze({
    coreSteel: {
      id: "coreSteel",
      label: "TOKEN.RING.SETTINGS.coreSteel",
      spritesheet: "canvas/tokens/rings-steel.json"
    },
    coreBronze: {
      id: "coreBronze",
      label: "TOKEN.RING.SETTINGS.coreBronze",
      spritesheet: "canvas/tokens/rings-bronze.json"
    }
  });

  /**
   * Core token rings fit modes used in Foundry VTT.
   * @type {Readonly<object>}
   */
  static CORE_TOKEN_RINGS_FIT_MODES = Object.freeze({
    subject: {
      id: "subject",
      label: "TOKEN.RING.SETTINGS.FIT_MODES.subject"
    },
    grid: {
      id: "grid",
      label: "TOKEN.RING.SETTINGS.FIT_MODES.grid"
    }
  });

  /* -------------------------------------------- */

  /**
   * Register the token ring config and initialize it
   */
  static initialize() {
    // If token config is initialized
    if ( this.#initialized ) {
      throw new Error("The token configuration class can be initialized only once!");
    }

    // Open the registration window for the token rings
    this.#closedRegistration = false;

    // Add default rings
    for ( const id in this.CORE_TOKEN_RINGS ) {
      const config = new DynamicRingData(this.CORE_TOKEN_RINGS[id]);
      CONFIG.Token.ring.addConfig(config.id, config);
    }

    // Call an explicit hook for token ring configuration
    Hooks$1.callAll("initializeDynamicTokenRingConfig", CONFIG.Token.ring);

    // Initialize token rings configuration
    if ( !CONFIG.Token.ring.useConfig(game.settings.get("core", "dynamicTokenRing")) ) {
      CONFIG.Token.ring.useConfig(this.CORE_TOKEN_RINGS.coreSteel.id);
    }

    // Close the registration window for the token rings
    this.#closedRegistration = true;
    this.#initialized = true;
  }

  /* -------------------------------------------- */

  /**
   * Register game settings used by the Token Ring
   */
  static registerSettings() {
    game.settings.register("core", "dynamicTokenRing", {
      name: "TOKEN.RING.SETTINGS.label",
      hint: "TOKEN.RING.SETTINGS.hint",
      scope: "world",
      config: true,
      type: new foundry.data.fields.StringField({required: true, blank: false,
        initial: this.CORE_TOKEN_RINGS.coreSteel.id,
        choices: () => CONFIG.Token.ring.configLabels
      }),
      requiresReload: true
    });

    game.settings.register("core", "dynamicTokenRingFitMode", {
      name: "TOKEN.RING.SETTINGS.FIT_MODES.label",
      hint: "TOKEN.RING.SETTINGS.FIT_MODES.hint",
      scope: "world",
      config: true,
      type: new foundry.data.fields.StringField({
        required: true,
        blank: false,
        initial: this.CORE_TOKEN_RINGS_FIT_MODES.subject.id,
        choices: Object.fromEntries(Object.entries(this.CORE_TOKEN_RINGS_FIT_MODES).map(([k, m]) => [k, m.label]))
      }),
      requiresReload: true
    });
  }

  /* -------------------------------------------- */

  /**
   * Ring configurations.
   * @type {Map<string, DynamicRingData>}
   */
  #configs = new Map();

  /**
   * The current ring configuration.
   * @type {DynamicRingData}
   */
  #currentConfig;

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A mapping of token subject paths where modules or systems have configured subject images.
   * @type {Record<string, string>}
   */
  subjectPaths = {};

  /**
   * All color bands visual debug flag.
   * @type {boolean}
   */
  debugColorBands = false;

  /**
   * Get the current ring class.
   * @type {typeof TokenRing} The current ring class.
   */
  get ringClass() {
    return this.#currentConfig.framework.ringClass;
  }

  set ringClass(value) {
    this.#currentConfig.framework.ringClass = value;
  }

  /**
   * Get the current effects.
   * @type {Record<string, string>} The current effects.
   */
  get effects() {
    return this.#currentConfig.effects;
  }

  /**
   * Get the current spritesheet.
   * @type {string} The current spritesheet path.
   */
  get spritesheet() {
    return this.#currentConfig.spritesheet;
  }

  /**
   * Get the current shader class.
   * @type {typeof PrimaryBaseSamplerShader} The current shader class.
   */
  get shaderClass() {
    return this.#currentConfig.framework.shaderClass;
  }

  set shaderClass(value) {
    this.#currentConfig.framework.shaderClass = value;
  }

  /**
   * Get the current localized label.
   * @returns {string}
   */
  get label() {
    return this.#currentConfig.label;
  }

  /**
   * Get the current id.
   * @returns {string}
   */
  get id() {
    return this.#currentConfig.id;
  }

  /* -------------------------------------------- */
  /*  Management                                  */
  /* -------------------------------------------- */

  /**
   * Is a custom fit mode active?
   * @returns {boolean}
   */
  get isGridFitMode() {
    return game.settings.get("core", "dynamicTokenRingFitMode")
      === this.constructor.CORE_TOKEN_RINGS_FIT_MODES.grid.id;
  }

  /* -------------------------------------------- */

  /**
   * Add a new ring configuration.
   * @param {string} id         The id of the ring configuration.
   * @param {RingConfig} config The configuration object for the ring.
   */
  addConfig(id, config) {
    if ( TokenRingConfig.#closedRegistration ) {
      throw new Error("Dynamic Rings registration window is closed. You must register a dynamic token ring configuration during"
        + " the `initializeDynamicTokenRingConfig` hook.");
    }
    this.#configs.set(id, config);
  }

  /* -------------------------------------------- */

  /**
   * Get a ring configuration.
   * @param {string} id     The id of the ring configuration.
   * @returns {RingConfig}  The ring configuration object.
   */
  getConfig(id) {
    return this.#configs.get(id);
  }

  /* -------------------------------------------- */

  /**
   * Use a ring configuration.
   * @param {string} id  The id of the ring configuration to use.
   * @returns {boolean} True if the configuration was successfully set, false otherwise.
   */
  useConfig(id) {
    if ( this.#configs.has(id) ) {
      this.#currentConfig = this.#configs.get(id);
      return true;
    }
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Get the IDs of all configurations.
   * @returns {string[]} The names of all configurations.
   */
  get configIDs() {
    return Array.from(this.#configs.keys());
  }

  /* -------------------------------------------- */

  /**
   * Get the labels of all configurations.
   * @returns {Record<string, string>} An object with configuration names as keys and localized labels as values.
   */
  get configLabels() {
    const labels = {};
    for ( const [name, config] of this.#configs.entries() ) {
      labels[name] = config.label;
    }
    return labels;
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get configNames() {
    const msg = "TokenRingConfig#configNames is deprecated and replaced by TokenRingConfig#configIDs";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.configIDs;
  }
}

/**
 * @import User from "@client/documents/user.mjs";
 * @import Token from "../token.mjs";
 */

/**
 * A subclass of Set which manages the Token ids which the User has targeted.
 * @extends {Set<Token>}
 * @see {@link foundry.documents.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
   * @param {Token} token
   * @returns {this}
   */
  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
   * @param {Token} token
   * @returns {boolean}
   */
  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$1.callAll("targetToken", this.user, token, targeted);
  }
}

/**
 * @import Token from "../token.mjs";
 * @import {DeepReadonly, TokenRulerData} from "../../../_types.mjs";
 */

/**
 * The ruler of a Token visualizes
 *   - the movement history of the Token,
 *   - the movment path the Token currently animating along, and
 *   - the planned movement path while the Token is being dragged.
 * @abstract
 */
class BaseTokenRuler {

  /**
   * @param {Token} token    The Token that this ruler belongs to
   */
  constructor(token) {
    if ( !(token instanceof Token$1) ) throw new Error("The BaseTokenRuler may only be constructed with a Token instance.");
    this.#token = token;
  }

  /* -------------------------------------------- */

  /**
   * The reference to the Token this ruler belongs to.
   * @type {Token}
   */
  get token() {
    return this.#token;
  }

  #token;

  /* -------------------------------------------- */

  /**
   * Is the ruler visible?
   * @type {boolean}
   * @defaultValue false
   */
  get visible() {
    return this.#visible;
  }

  /**
   * Set to {@link BaseTokenRuler#isVisible} in {@link foundry.canvas.placeables.Token#_refreshState}.
   */
  set visible(value) {
    if ( this.#visible === value ) return;
    this.#visible = value;
    this._onVisibleChange();
  }

  #visible = false;

  /* -------------------------------------------- */

  /**
   * Called when the ruler becomes visible or invisible.
   * @abstract
   * @protected
   */
  _onVisibleChange() {
    throw new Error("A subclass of the BaseTokenRuler must implement the _onVisibleChange method.");
  }

  /* -------------------------------------------- */

  /**
   * Is the ruler supposed to be visible?
   * {@link BaseTokenRuler#visible} is set to {@link BaseTokenRuler#isVisible} in
   * {@link foundry.canvas.placeables.Token#_refreshState}.
   * @type {boolean}
   */
  get isVisible() {
    const show = this.token.hover || this.token.layer.highlightObjects || this.token.showRuler || this.token.isDragged;
    if ( !show ) return false;
    return (this.token.document.disposition === TOKEN_DISPOSITIONS.FRIENDLY) || this.token.document.testUserPermission(game.user, "OBSERVER");
  }

  /* -------------------------------------------- */

  /**
   * Draw the ruler.
   * Called in {@link foundry.canvas.placeables.Token#_draw}.
   * @abstract
   */
  async draw() {
    throw new Error("A subclass of the BaseTokenRuler must implement the draw method.");
  }

  /* -------------------------------------------- */

  /**
   * Clear the ruler.
   * Called in {@link foundry.canvas.placeables.Token#clear}.
   * @abstract
   */
  clear() {
    throw new Error("A subclass of the BaseTokenRuler must implement the clear method.");
  }

  /* -------------------------------------------- */

  /**
   * Destroy the ruler.
   * Called in {@link foundry.canvas.placeables.Token#_destroy}.
   * @abstract
   */
  destroy() {
    throw new Error("A subclass of the BaseTokenRuler must implement the destroy method.");
  }

  /* -------------------------------------------- */

  /**
   * Refresh the ruler.
   * Called in {@link foundry.canvas.placeables.Token#_refreshRuler}.
   * @param {DeepReadonly<TokenRulerData>} rulerData
   * @abstract
   */
  refresh(rulerData) {
    throw new Error("A subclass of the BaseTokenRuler must implement the refresh method.");
  }
}

/**
 * @import {GridMeasurePathResultWaypoint, GridOffset3D} from "../../../../common/grid/_types.mjs";
 * @import {DeepReadonly, TokenRulerWaypoint} from "../../../_types.mjs";
 * @import {TokenMovementWaypoint, TokenMeasuredMovementWaypoint} from "../../../documents/_types.mjs";
 * @import GridHighlight from "../../containers/elements/grid-highlight.mjs"
 */

/**
 * The default implementation of the Token ruler.
 */
class TokenRuler extends BaseTokenRuler {
  constructor(token) {
    super(token);
    this.#path.visible = false;
  }

  /* -------------------------------------------- */

  /**
   * A handlebars template used to render each waypoint label.
   * @type {string}
   */
  static WAYPOINT_LABEL_TEMPLATE = "templates/hud/waypoint-label.hbs";

  /* -------------------------------------------- */

  /**
   * This element is a Graphics instance which draws the line and points of the measured path.
   * @type {PIXI.Graphics}
   */
  #path = new PIXI.Graphics();

  /* -------------------------------------------- */

  /**
   * An HTML element in the #measurement HUD container containing labels for this TokenRuler instance.
   * @type {HTMLDivElement}
   */
  #labels;

  /* -------------------------------------------- */

  /**
   * The ID of the grid highlight layer.
   * @type {string|null}
   */
  #highlightId = null;

  /* -------------------------------------------- */

  /**
   * The grid highlight layer.
   * @type {GridHighlight|null}
   */
  #highlightLayer = null;

  /* -------------------------------------------- */

  /**
   * The thickness of the waypoint and segment outline in pixels.
   * @type {number}
   */
  #outlineThickness;

  /* -------------------------------------------- */

  /**
   * The color of the waypoint and segment outline.
   * @type {PIXI.ColorSource}
   */
  #outlineColor;

  /* -------------------------------------------- */

  /**
   * The dash lines.
   * @type {{[userId: string]: {
   *   offset: number;
   *   innerStart: number;
   *   outerStart: number;
   *   path: TokenRulerWaypoint[];
   *   shaders: {shader: DashLineShader; offset: number}[];
   *   animate: boolean
   * }}}
   */
  #dashLines = {};

  /* -------------------------------------------- */

  /**
   * Animate the dash line shader?
   * @type {boolean}
   */
  #dashLineAnimating = false;

  /* -------------------------------------------- */

  /**
   * The dash line dash in pixels.
   * @type {number}
   */
  #dashLineDash;

  /* -------------------------------------------- */

  /**
   * The dash line gap in pixels.
   * @type {number}
   */
  #dashLineGap;

  /* -------------------------------------------- */

  /**
   * The dash line speed.
   * @type {number}
   */
  #dashLineSpeed;

  /* -------------------------------------------- */

  /** @override */
  _onVisibleChange() {
    this.#path.visible = this.visible;
    this.#getLabelsElement().classList.toggle("hidden", !this.visible);
    if ( this.#highlightLayer ) this.#highlightLayer.visible = this.visible;
  }

  /* -------------------------------------------- */

  /**
   * Configure the properties of the outline.
   * Called in {@link TokenRuler#draw}.
   * @returns {{thickness: number; color: PIXI.ColorSource}}    The thickness in pixels and the color
   * @protected
   */
  _configureOutline() {
    const scale = canvas.dimensions.uiScale;
    return {thickness: scale, color: 0x000000};
  }

  /* -------------------------------------------- */

  /**
   * Configure the properties of the dash line.
   * Called in {@link TokenRuler#draw}.
   * @returns {{dash: number; gap: number; speed: number}}
   *   The dash in pixels, the gap in pixels, and the speed in pixels per second
   * @protected
   */
  _configureDashLine() {
    const scale = canvas.dimensions.uiScale;
    return {dash: 12 * scale, gap: 8 * scale, speed: 50 * scale};
  }

  /* -------------------------------------------- */

  /** @override */
  async draw() {
    if ( this.constructor.WAYPOINT_LABEL_TEMPLATE ) await getTemplate(this.constructor.WAYPOINT_LABEL_TEMPLATE);

    // Add ruler path
    this.token.layer._rulerPaths.addChild(this.#path);

    // Create labels
    this.#getLabelsElement().classList.toggle("hidden", !this.visible);

    // Create the grid highlight layer unless gridless
    if ( !this.#highlightLayer && !canvas.grid.isGridless ) {
      this.#highlightId = `TokenRuler.${this.token.document.id}`;
      if ( this.token.isPreview ) this.#highlightId += ".preview";
      this.#highlightLayer = canvas.interface.grid.addHighlightLayer(this.#highlightId);
      this.#highlightLayer.visible = this.visible;
    }

    // Configure outline
    const {thickness, color} = this._configureOutline();
    this.#outlineThickness = thickness;
    this.#outlineColor = color;

    // Configure dash line
    const {dash, gap, speed} = this._configureDashLine();
    this.#dashLineDash = dash;
    this.#dashLineGap = gap;
    this.#dashLineSpeed = speed / (PIXI.Ticker.targetFPMS * 1000);
  }

  /* -------------------------------------------- */

  /** @override */
  clear() {
    this.#path.clear();
    this.token.layer.removeChild(this.#path);
    this.#labels?.replaceChildren();
    if ( this.#highlightLayer ) this.#highlightLayer.clear();
    if ( this.#dashLineAnimating ) {
      this.#dashLineAnimating = false;
      canvas.app.ticker.remove(this.#tickDashLine, this);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  destroy() {
    this.#path.destroy({children: true});
    this.#labels?.remove();
    this.#labels = undefined;
    canvas.interface.grid.destroyHighlightLayer(this.#highlightId);
    if ( this.#dashLineAnimating ) canvas.app.ticker.remove(this.#tickDashLine, this);
  }

  /* -------------------------------------------- */

  /**
   * Get the ruler path without intermediate waypoints.
   * @param {{waypoints: TokenMeasuredMovementWaypoint[]; stage: "passed"|"pending"|"planned";
   *   unreachable: boolean, userId?: string}[]} pathParts    The paths that are to be combined
   * @returns {Omit<TokenRulerWaypoint, "index"|"center"|"size"|"ray">[]}   The combined path
   */
  #getSnappedIntermediatePath(pathParts) {
    const path = [];
    const actionConfigs = CONFIG.Token.movement.actions;
    for ( const {waypoints, stage, hidden, unreachable, userId} of pathParts ) {
      for ( let waypoint of waypoints ) {
        const actionConfig = actionConfigs[waypoint.action] ?? actionConfigs.displace;
        waypoint = {...waypoint, actionConfig, stage, hidden, unreachable};
        if ( userId ) waypoint.userId = userId;
        waypoint.movementId ??= null;
        const {x, y, elevation} = this.token.document.getSnappedPosition(waypoint);
        waypoint.x = Math.round(x);
        waypoint.y = Math.round(y);
        waypoint.elevation = elevation;
        path.push(waypoint);
      }
    }
    this.#measurePathAndLinkWaypoints(path);
    return path;
  }

  /* -------------------------------------------- */

  /**
   * Get the snapped ruler path with intermediate waypoints.
   * @param {{waypoints: TokenMeasuredMovementWaypoint[]; stage: "passed"|"pending"|"planned";
   *   unreachable: boolean, userId?: string}[]} pathParts    The paths that are to be combined
   * @returns {TokenRulerWaypoint[]}         The combined path
   */
  #getNonintermediatePath(pathParts) {
    const path = [];
    const actionConfigs = CONFIG.Token.movement.actions;
    let index = 0;
    for ( const {waypoints, stage, hidden, unreachable, userId} of pathParts ) {
      let skippedCost = 0;
      for ( let waypoint of waypoints ) {
        if ( waypoint.intermediate ) {
          skippedCost += waypoint.cost;
          continue;
        }
        if ( waypoint.explicit ) index++;
        const actionConfig = actionConfigs[waypoint.action] ?? actionConfigs.displace;
        waypoint = {...waypoint, cost: waypoint.cost + skippedCost, actionConfig, index, stage, hidden, unreachable};
        if ( userId ) waypoint.userId = userId;
        waypoint.movementId ??= null;
        waypoint.center = this.token.document.getCenterPoint(waypoint);
        waypoint.size = this.token.document.getSize(waypoint);
        waypoint.ray = path.length > 0 ? new Ray(path.at(-1).center, waypoint.center) : null;
        path.push(waypoint);
        skippedCost = 0;
      }
    }
    this.#measurePathAndLinkWaypoints(path);
    return path;
  }

  /* -------------------------------------------- */

  /**
   * Measure the given path through the waypoints and link the waypoints.
   * @param {Partial<TokenRulerWaypoint>[]} waypoints    The waypoints
   */
  #measurePathAndLinkWaypoints(waypoints) {
    const measurement = this.token.measureMovementPath(waypoints);
    for (let i = 0; i < waypoints.length; i++) {
      const waypoint = waypoints[i];
      waypoint.measurement = measurement.waypoints[i];
      waypoint.previous = i > 0 ? waypoints[i - 1] : null;
      waypoint.next = i < waypoints.length - 1 ? waypoints[i + 1] : null;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  refresh({passedWaypoints, pendingWaypoints, plannedMovement}) {
    const pathParts = [
      {waypoints: passedWaypoints, stage: "passed", hidden: false, unreachable: false},
      {waypoints: pendingWaypoints, stage: "pending", hidden: false, unreachable: false}
    ];
    const rulerPaths = [{waypoints: this.#getNonintermediatePath(pathParts), searching: false, userId: null}];
    const gridHightlightPaths = [this.#getSnappedIntermediatePath(pathParts)];

    // Make sure the paths and grid highlights of the current user are drawn on top
    const planned = Object.entries(plannedMovement);
    const selfIndex = planned.findIndex(([userId]) => userId === game.user.id);
    if ( (selfIndex >= 0) && (selfIndex < planned.length - 1) ) planned.push(...planned.splice(selfIndex, 1));

    for ( const [userId, {foundPath, unreachableWaypoints, history, hidden, searching}] of planned ) {
      let numNonintermediateHistory = 0;
      for ( const waypoint of history ) {
        if ( !waypoint.intermediate ) numNonintermediateHistory++;
      }
      const pathParts = [
        {waypoints: history, stage: "passed", hidden, unreachable: false},
        {waypoints: foundPath, stage: "planned", hidden, unreachable: false, userId},
        {waypoints: unreachableWaypoints, stage: "planned", hidden, unreachable: true, userId}
      ];
      rulerPaths.push({waypoints: this.#getNonintermediatePath(pathParts).slice(numNonintermediateHistory),
        searching, userId});
      gridHightlightPaths.push(this.#getSnappedIntermediatePath(pathParts).slice(history.length));
    }

    this.#updateDashLines(rulerPaths);
    this.#drawPaths(rulerPaths);
    this.#drawGridHighlights(gridHightlightPaths);
    this.#renderWaypointLabels(rulerPaths);
  }

  /* -------------------------------------------- */

  /**
   * Render waypoint labels using the defined TokenRuler.WAYPOINT_LABEL_TEMPLATE handlebars template.
   * @param {{waypoints: TokenRulerWaypoint[]}[]} paths    The paths
   */
  async #renderWaypointLabels(paths) {
    const labels = [];
    for ( const {waypoints} of paths ) {
      const state = {};
      for ( const waypoint of waypoints ) {
        const context = this._getWaypointLabelContext(waypoint, state);
        if ( !context ) continue;
        context.waypoint = waypoint;
        context.waypointType = "token-ruler";
        context.uiScale ??= canvas.dimensions.uiScale;
        let html = await renderTemplate(this.constructor.WAYPOINT_LABEL_TEMPLATE, context);
        html = foundry.utils.parseHTML(html);
        html.style.setProperty("--position-x", `${context.position.x}px`);
        html.style.setProperty("--position-y", `${context.position.y}px`);
        html.style.setProperty("--ui-scale", context.uiScale);
        labels.push(html);
      }
    }
    this.#getLabelsElement().replaceChildren(...labels);
  }

  /* -------------------------------------------- */

  /**
   * Create the HTMLDivElement used for labels belonging to this TokenRuler class.
   * We do this lazily within the scope of a refresh() because the #hud element is created after the Ruler is drawn.
   * TODO could we wait to draw rulers until after the HUD is rendered or render the HUD earlier?
   * @returns {HTMLDivElement}
   */
  #getLabelsElement() {
    let labels = this.#labels;
    if ( !labels ) {
      let rulerId = `token-ruler-${this.token.document.id}`;
      if ( this.token.isPreview ) rulerId += "-preview";
      labels = document.querySelector(`#hud #measurement #${rulerId}`);
      if ( !labels ) {
        labels = document.createElement("div");
        labels.classList.add("ruler-labels", "token-ruler-labels");
        if ( !this.visible ) labels.classList.add("hidden");
        labels.id = rulerId;
      }
      this.#labels = labels;
    }
    if ( labels.parent ) return labels;
    document.querySelector("#hud #measurement")?.appendChild(labels);
    return labels;
  }

  /* -------------------------------------------- */

  /**
   * Get the context used to render a ruler waypoint label.
   * @param {DeepReadonly<TokenRulerWaypoint>} waypoint
   * @param {object} state
   * @returns {object|void}
   * @protected
   */
  _getWaypointLabelContext(waypoint, state) {
    const {index, elevation, explicit, next, previous, ray} = waypoint;
    if ( !state.initialized ) {

      // If any of the prior waypoints has nonzero elevation, initialize to true
      state.hasElevation = false;
      let w = waypoint;
      while ( w && !state.hasElevation ) {
        state.hasElevation = (w.elevation !== 0);
        w = w.previous;
      }

      state.previousElevation = previous?.elevation ?? elevation;
      state.initialized = true;
    }
    state.hasElevation ||= (elevation !== 0);
    if ( !previous ) return;
    if ( !explicit && next && waypoint.actionConfig.visualize && next.actionConfig.visualize
      && (waypoint.action === next.action) ) return;
    if ( (ray.distance === 0) && (elevation === previous.elevation) ) return;

    // Prepare data structure
    const context = {
      action: waypoint.actionConfig,
      cssClass: [
        waypoint.hidden ? "secret" : "",
        waypoint.next ? "" : "last"
      ].filterJoin(" "),
      secret: waypoint.hidden,
      units: canvas.grid.units,
      uiScale: canvas.dimensions.uiScale,
      position: {x: ray.B.x, y: ray.B.y + (next ? 0 : 0.5 * this.token.h) + (16 * canvas.dimensions.uiScale)}
    };

    // Segment Distance
    context.distance = {total: waypoint.measurement.distance.toNearest(0.01).toLocaleString(game.i18n.lang)};
    if ( index >= 2 ) context.distance.delta = waypoint.measurement.backward.distance.toNearest(0.01).signedString();

    // Segment Cost
    const cost = waypoint.measurement.cost;
    const deltaCost = waypoint.cost;
    context.cost = {total: Number.isFinite(cost) ? cost.toNearest(0.01).toLocaleString(game.i18n.lang) : "∞", units: canvas.grid.units};
    if ( index >= 2 ) context.cost.delta = Number.isFinite(deltaCost) ? deltaCost.toNearest(0.01).signedString() : "∞";

    // Elevation
    const deltaElevation = elevation - state.previousElevation;
    context.elevation = {total: elevation, icon: "fa-solid fa-arrows-up-down", hidden: !state.hasElevation};
    if ( deltaElevation !== 0 ) context.elevation.delta = deltaElevation.signedString();
    state.previousElevation = elevation;

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Draw the path segments and waypoints.
   * @param {{waypoints: TokenRulerWaypoint[]; searching: boolean; userId: string}[]} paths  The paths
   */
  #drawPaths(paths) {
    this.#path.clear();

    // Create draw instructions
    const layer1 = [];
    const layer2 = [];
    const layer3 = [];
    for ( const {waypoints, userId} of paths ) {
      if ( waypoints.length === 0 ) continue;
      let previousWidth = -1;
      let previousColor = -1;
      const dashLine = userId ? this.#getDashLine(userId) : null;
      let innerDash = false;
      let outerDash = false;
      let dashedDistance = 0;
      const first = waypoints[0];
      if ( first.previous ) {
        const {x: fromX, y: fromY} = first.previous.center;
        layer1.push(g => g.moveTo(fromX, fromY));
        layer2.push(g => g.moveTo(fromX, fromY));
      }
      for ( let i = 0; i < waypoints.length; i++ ) {
        const waypoint = waypoints[i];
        const to = waypoint.center;

        // Create waypoint draw instruction
        const {radius, color=0xFFFFFF, alpha=1} = this._getWaypointStyle(waypoint);
        if ( radius > 0 ) {
          if ( alpha === 1 ) {
            layer3.push(g => {
              g.lineStyle(this.#outlineThickness, this.#outlineColor);
              g.beginFill(color, 1, this.#outlineThickness === 0);
              g.drawCircle(to.x, to.y, radius + (this.#outlineThickness / 2));
              g.endFill();
            });
          } else {
            layer3.push(g => {
              g.lineStyle(0);
              g.beginFill(this.#outlineColor, alpha, true);
              g.drawCircle(to.x, to.y, radius + this.#outlineThickness);
              g.endFill();
              g.beginFill(color, alpha, true);
              g.drawCircle(to.x, to.y, radius);
              g.endFill();
            });
          }
        }

        // Create segment draw instruction
        if ( i > 0 ) {
          let {width, color=0xFFFFFF, alpha=1} = this._getSegmentStyle(waypoint);
          color = PIXI.Color.shared.setValue(color).toNumber(); // Convert color to a number
          if ( width > 0 ) {
            let forceInnerStyleChange = false;
            let forceOuterStyleChange = false;
            if ( dashLine ) {
              if ( !innerDash && (i >= dashLine.innerStart) ) {
                innerDash = true;
                forceInnerStyleChange = true;
              }
              if ( !outerDash && (i >= dashLine.outerStart) ) {
                outerDash = true;
                forceOuterStyleChange = true;
              }
            }

            // Create outline draw instruction
            if ( this.#outlineThickness > 0 ) {
              if ( (width !== previousWidth) || forceOuterStyleChange ) {
                const style = {
                  width: width + (this.#outlineThickness * 2),
                  color: this.#outlineColor,
                  alpha,
                  join: PIXI.LINE_JOIN.ROUND,
                  cap: PIXI.LINE_CAP.ROUND,
                  shader: outerDash ? this.#createOuterDashLineShader(dashLine, dashedDistance) : null
                };
                layer1.push(g => g.lineStyle(style));
              }
              layer1.push(g => g.lineTo(to.x, to.y));
            }

            // Create segment draw instruction
            if ( (width !== previousWidth) || (color !== previousColor) || forceInnerStyleChange ) {
              const style = {
                width,
                color,
                alpha,
                join: PIXI.LINE_JOIN.ROUND,
                cap: PIXI.LINE_CAP.ROUND,
                shader: innerDash ? this.#createInnerDashLineShader(dashLine, dashedDistance) : null
              };
              layer2.push(g => g.lineStyle(style));
            }
            layer2.push(g => g.lineTo(to.x, to.y));

            if ( innerDash ) dashedDistance += waypoint.ray.distance;
            previousWidth = width;
            previousColor = color;
          } else {
            layer1.push(g => g.moveTo(to.x, to.y));
            layer2.push(g => g.moveTo(to.x, to.y));
          }
        } else {
          layer1.push(g => g.moveTo(to.x, to.y));
          layer2.push(g => g.moveTo(to.x, to.y));
        }
      }

      // Execute draw instructions
      for ( const instruction of layer1 ) instruction(this.#path);
      for ( const instruction of layer2 ) instruction(this.#path);
      for ( const instruction of layer3 ) instruction(this.#path);
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the style of the waypoint at the given waypoint.
   * @param {DeepReadonly<TokenRulerWaypoint>} waypoint       The waypoint
   * @returns {{radius: number; color?: PIXI.ColorSource; alpha?: number}}
   *   The radius, color, and alpha of the waypoint. If the radius is 0, no waypoint marker is drawn.
   * @protected
   */
  _getWaypointStyle(waypoint) {
    if ( !waypoint.explicit && waypoint.next && waypoint.previous && waypoint.actionConfig.visualize
      && waypoint.next.actionConfig.visualize && (waypoint.action === waypoint.next.action) ) return {radius: 0};
    const user = game.users.get(waypoint.userId);
    const scale = canvas.dimensions.uiScale;
    return {radius: 6 * scale, color: user?.color ?? 0x000000, alpha: 1};
  }

  /* -------------------------------------------- */

  /**
   * Get the style of the segment from the previous to the given waypoint.
   * @param {DeepReadonly<TokenRulerWaypoint>} waypoint      The waypoint
   * @returns {{width: number; color?: PIXI.ColorSource; alpha?: number}}
   *   The line width, color, and alpha of the segment.  If the width is 0, no segment is drawn.
   * @protected
   */
  _getSegmentStyle(waypoint) {
    if ( !waypoint.actionConfig.visualize ) return {width: 0};
    const user = game.users.get(waypoint.userId);
    const scale = canvas.dimensions.uiScale;
    return {width: 4 * scale, color: user?.color ?? 0x000000, alpha: 1};
  }

  /* -------------------------------------------- */

  /**
   * Draw the grid highlights.
   * @param {Omit<TokenRulerWaypoint, "index"|"center"|"size"|"ray">[][]} paths    The paths
   */
  #drawGridHighlights(paths) {
    if ( !this.#highlightLayer ) return;
    this.#highlightLayer.clear();

    // Iterate the paths in reverse
    for ( let i = paths.length - 1; i >= 0; i-- ) {
      const path = paths[i];

      // Iterate the path in reverse
      for ( let j = path.length - 1; j >= 0; j-- ) {
        const waypoint = path[j];

        // Highlight each occupied offset at this waypoint
        for ( const offset of this.token.document.getOccupiedGridSpaceOffsets(waypoint) ) {
          const offsetKey = `${offset.i},${offset.j}`;

          // Skip already highlighted grid spaces
          if ( this.#highlightLayer.positions.has(offsetKey) ) continue;

          // Get style for drawing the grid space polygon
          const {color=0xFFFFFF, alpha=0.5, texture=PIXI.Texture.WHITE,
            matrix=null} = this._getGridHighlightStyle(waypoint, offset);
          if ( !(alpha > 0) ) continue;

          this.#highlightLayer.beginTextureFill({texture, color, alpha, matrix, smooth: !canvas.grid.isSquare});
          this.#highlightLayer.drawPolygon(canvas.grid.getVertices(offset));
          this.#highlightLayer.endFill();
          this.#highlightLayer.positions.add(offsetKey);
        }
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the style to be used to highlight the grid offset.
   * @param {DeepReadonly<Omit<TokenRulerWaypoint, "index"|"center"|"size"|"ray">>} waypoint    The waypoint
   * @param {DeepReadonly<GridOffset3D>} offset  An occupied grid offset at the given waypoint that is to be highlighted
   * @returns {{color?: PIXI.ColorSource; alpha?: number; texture?: PIXI.Texture; matrix?: PIXI.Matrix | null}}
   *   The color, alpha, texture, and texture matrix to be used to draw the grid space.
   *   If the alpha is 0, the grid space is not highlighted.
   * @protected
   */
  _getGridHighlightStyle(waypoint, offset) {
    if ( waypoint.unreachable || (waypoint.actionConfig.teleport && waypoint.intermediate)
      || ((waypoint.stage === "planned") && (waypoint.previous?.stage === "passed")
      && !waypoint.actionConfig.teleport) ) return {alpha: 0};
    const user = game.users.get(waypoint.userId);
    return {color: user?.color ?? 0x000000, alpha: 0.5};
  }

  /* -------------------------------------------- */

  /**
   * Create an inner dash line shader.
   * @param {{offset: number; shaders: {shader: DashLineShader; offset: number}[]}} dashLine    The dash line
   * @param {number} distance     The current distance of the dashed path
   * @returns {DashLineShader}    The inner dash line shader
   */
  #createInnerDashLineShader(dashLine, distance) {
    const offset = this.#dashLineGap - distance;
    const shader = new DashLineShader({
      dash: this.#dashLineDash,
      gap: this.#dashLineGap,
      offset: dashLine.offset + offset
    });
    dashLine.shaders.push({shader, offset});
    return shader;
  }

  /* -------------------------------------------- */

  /**
   * Create an outer dash line shader.
   * @param {{offset: number; shaders: {shader: DashLineShader; offset: number}[]}} dashLine    The dash line
   * @param {number} distance     The current distance of the dashed path
   * @returns {DashLineShader}   The outer dash line shader
   */
  #createOuterDashLineShader(dashLine, distance) {
    const offset = this.#dashLineGap - distance - this.#outlineThickness;
    const shader = new DashLineShader({
      dash: this.#dashLineDash + (2 * this.#outlineThickness),
      gap: Math.max(this.#dashLineGap - (2 * this.#outlineThickness), 0),
      offset: dashLine.offset + offset
    });
    dashLine.shaders.push({shader, offset});
    return shader;
  }

  /* -------------------------------------------- */

  /**
   * Get the dash line for the given User ID.
   * @param {string} userId    The User ID
   * @returns {{
   *   offset: number;
   *   innerStart: number;
   *   outerStart: number;
   *   path: TokenRulerWaypoint[];
   *   shaders: {shader: DashLineShader; offset: number}[];
   *   animate: boolean
   * }}  The dash line
   */
  #getDashLine(userId) {
    let dashLine = this.#dashLines[userId];
    if ( !dashLine ) {
      this.#dashLines[userId] = dashLine = {
        offset: 0,
        innerStart: 0,
        outerStart: 0,
        path: [],
        shaders: [],
        animate: false
      };
    }
    return dashLine;
  }

  /* -------------------------------------------- */

  /**
   * Update the dash lines.
   * @param {{waypoints: TokenRulerWaypoint[]; searching: boolean; userId: string|null}[]} paths    The paths
   */
  #updateDashLines(paths) {
    let animate = false;
    const active = new Set();
    for ( const {waypoints, searching, userId} of paths ) {
      if ( !userId ) continue;
      active.add(userId);
      const dashLine = this.#getDashLine(userId);
      if ( searching ) dashLine.animate = animate = true;
      else dashLine.animate = false;
      const numWaypoints = waypoints.length;
      const unreachableIndex = waypoints.findIndex(waypoint => waypoint.unreachable);
      let index = searching ? 0 : unreachableIndex;
      let dashedPath;
      if ( index >= 0 ) dashedPath = waypoints.slice(Math.max(index - 1, 0));
      else {
        index = numWaypoints;
        dashedPath = [];
      }
      dashLine.innerStart = index;
      dashLine.outerStart = searching ? (unreachableIndex >= 0 ? unreachableIndex : numWaypoints) : index;
      const shift = this.#calculateDashOffsetShift(dashLine.path, dashedPath);
      dashLine.offset += shift;
      dashLine.path = dashedPath;
    }
    for ( const [userId, dashLine] of Object.entries(this.#dashLines) ) {
      dashLine.shaders.length = 0;
      if ( active.has(userId) ) continue;
      dashLine.offset = 0;
      dashLine.innerStart = 0;
      dashLine.outerStart = 0;
      dashLine.waypoints = [];
      dashLine.animate = false;
    }

    // Add/remove the ticker as necessary
    if ( this.#dashLineAnimating !== animate ) {
      this.#dashLineAnimating = animate;
      if ( animate ) canvas.app.ticker.add(this.#tickDashLine, this);
      else canvas.app.ticker.remove(this.#tickDashLine, this);
    }
  }

  /* -------------------------------------------- */

  /**
   * Calculate the necessary dash line offset shift to prevent the dashes of unreachable segments
   * to jump when the path is found and therefore the path changes.
   * @param {TokenMovementWaypoint[]} path1    The first path
   * @param {TokenMovementWaypoint[]} path2    The second path
   * @returns {number}                         The dash line offset shift
   */
  #calculateDashOffsetShift(path1, path2) {
    if ( !path1.length || !path2.length ) return 0;
    if ( path1.length < path2.length ) return -this.#calculateDashOffsetShift(path2, path1);

    // Find the longest common subpath (https://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Longest_common_substring#TypeScript)
    let i0 = 0;
    let j0 = 0;
    let maxD = -Infinity;
    let n0 = new Array(path2.length).fill(0);
    let n1 = new Array(path2.length).fill(0);
    let d0 = new Array(path2.length).fill(-Infinity);
    let d1 = new Array(path2.length).fill(-Infinity);
    for ( let i = 0; i < path1.length; i++ ) {
      const w1 = path1[i];
      for ( let j = 0; j < path2.length; j++ ) {
        const w2 = path2[j];
        if ( !(TokenDocument.arePositionsEqual(w1, w2) && (w1.unreachable === w2.unreachable)) ) {
          n1[j] = 0;
          d1[j] = -Infinity;
        } else {
          let n;
          let d;
          if ( (i === 0) || (j === 0) ) {
            n = n1[j] = 1;
            d = d1[j] = 0;
          } else {
            n = n1[j] = n0[j - 1] + 1;
            d = n1[j] = d0[j - 1] + path2[j].ray.distance;
          }
          if ( d > maxD ) {
            maxD = d;
            i0 = i - n + 1;
            j0 = j - n + 1;
          }
        }
      }
      [n0, n1] = [n1, n0];
      [d0, d1] = [d1, d0];
    }
    if ( maxD < 0 ) return 0;

    // Calculate shift, which is the difference in path length up to the start of the longest common subpath
    let o1 = 0;
    let o2 = 0;
    for ( let i = 1; i <= i0; i++ ) o1 += path1[i].ray.distance;
    for ( let j = 1; j <= j0; j++ ) o2 += path2[j].ray.distance;
    return o2 - o1;
  }

  /* -------------------------------------------- */

  /**
   * Animate the dash lines.
   * @param {number} deltaTime    The delta time
   */
  #tickDashLine(deltaTime) {
    const delta = this.#dashLineSpeed * deltaTime;
    const period = this.#dashLineDash + this.#dashLineGap;
    for ( const userId in this.#dashLines ) {
      const dashLine = this.#dashLines[userId];
      if ( !dashLine.animate ) continue;
      const baseOffset = dashLine.offset = (dashLine.offset + delta) % period;
      for ( const {shader, offset} of dashLine.shaders ) shader.uniforms.offset = offset + baseOffset;
    }
  }
}

var _module$k = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BaseTokenRuler: BaseTokenRuler,
  DynamicRingData: DynamicRingData,
  TokenRing: TokenRing,
  TokenRingConfig: TokenRingConfig,
  TokenRuler: TokenRuler,
  TokenTurnMarker: TokenTurnMarker,
  TurnMarkerData: TurnMarkerData,
  UserTargets: UserTargets
});

/**
 * 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 foundry.documents.NoteDocument}
 * @see {@link foundry.canvas.layers.NotesLayer}
 */
let Note$1 = 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 ) {
      ui.controls._updateNotesIcon();
      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 = foundry.applications.sheets.journal.JournalEntrySheet.VIEW_MODES.SINGLE;
      options.pageId = page.id;
    }
    const allowed = Hooks$1.call("activateNote", this, options);
    if ( allowed === false ) return;
    if ( page?.type === "image" ) {
      return new ImagePopout({
        src: page.src,
        uuid: page.uuid,
        caption: page.image.caption,
        window: {title: page.name}
      }).render({force: 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 {@link foundry.documents.RegionDocument}
 * @see {@link foundry.canvas.layers.RegionLayer}
 */
let Region$1 = class Region extends PlaceableObject {

  /** @override */
  static embeddedName = "Region";

  /* -------------------------------------------- */

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshBorder"], alias: true},
    refreshState: {},
    refreshBorder: {}
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The geometry of this Region.
   *
   * The value of this property must not be mutated.
   *
   * This property is updated only by a document update.
   * @type {RegionGeometry}
   */
  get geometry() {
    return this.#geometry;
  }

  #geometry = new RegionGeometry(this);

  /* -------------------------------------------- */

  /** @override */
  get bounds() {
    return this.document.bounds.clone(); // PlaceableObject#bounds always returns a new instance
  }

  /* -------------------------------------------- */

  /** @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");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    this.#highlight = this.addChild(new RegionMesh(this, HighlightRegionShader));
    this.#highlight.eventMode = "auto";
    this.#highlight.shader.uniforms.hatchThickness = 4 * canvas.dimensions.uiScale;
    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 * canvas.dimensions.uiScale;
    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.document.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.activate({tool: "select"});
      this.layer.legend.render();
    }
  }

  /* -------------------------------------------- */

  /**
   * Actions that should be taken for this Region when a mouseover event occurs.
   * @param {PIXI.FederatedEvent} event The triggering canvas interaction event
   * @param {object} options Options that customize event handling
   * @param {boolean} [options.updateLegend=true] Highlight corresponding entry in the RegionLegend.
   * @returns {boolean|void}
   * @protected
   * @override
   */
  _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) {
    const localRectangle = new PIXI.Rectangle(
      rectangle.x - this.position.x,
      rectangle.y - this.position.y,
      rectangle.width,
      rectangle.height
    );
    if ( !localRectangle.intersects(this.bounds) ) return false;
    const x0 = Math.round(localRectangle.left * CLIPPER_SCALING_FACTOR);
    const y0 = Math.round(localRectangle.top * CLIPPER_SCALING_FACTOR);
    const x1 = Math.round(localRectangle.right * CLIPPER_SCALING_FACTOR);
    const y1 = Math.round(localRectangle.bottom * CLIPPER_SCALING_FACTOR);
    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.document.clipperPaths, ClipperLib.PolyType.ptClip, true);
    clipper.Execute(ClipperLib.ClipType.ctIntersection, solution);
    return solution.length !== 0;
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Update the shapes
    if ( "shapes" in changed ) this.#geometry?._clearBuffers();

    // Incremental Refresh
    this.renderFlags.set({
      refreshState: ("color" in changed) || ("visibility" in changed) || ("locked" in changed),
      refreshBorder: "shapes" in changed
    });
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  static get CLIPPER_SCALING_FACTOR() {
    foundry.utils.logCompatibilityWarning("Region.CLIPPER_SCALING_FACTOR has been deprecated in favor of CONST.CLIPPER_SCALING_FACTOR.",
      {since: 13, until: 15, once: true});
    return CLIPPER_SCALING_FACTOR;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  static get MOVEMENT_SEGMENT_TYPES() {
    foundry.utils.logCompatibilityWarning("Region.MOVEMENT_SEGMENT_TYPES has been deprecated in favor of CONST.REGION_MOVEMENT_SEGMENTS.",
      {since: 13, until: 15, once: true});
    return CONST.REGION_MOVEMENT_SEGMENTS;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get bottom() {
    foundry.utils.logCompatibilityWarning("Region#bottom has been deprecated in favor of RegionDocument#elevation.bottom.",
      {since: 13, until: 15, once: true});
    return this.document.elevation.bottom;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get top() {
    foundry.utils.logCompatibilityWarning("Region#top has been deprecated in favor of RegionDocument#elevation.top.",
      {since: 13, until: 15, once: true});
    return this.document.elevation.top;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get shapes() {
    foundry.utils.logCompatibilityWarning("Region#shapes has been deprecated in favor of RegionDocument#regionShapes.",
      {since: 13, until: 15, once: true});
    return this.document.regionShapes;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get polygons() {
    foundry.utils.logCompatibilityWarning("Region#polygons has been deprecated in favor of RegionDocument#polygons.",
      {since: 13, until: 15, once: true});
    return this.document.polygons;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get polygonTree() {
    foundry.utils.logCompatibilityWarning("Region#polygons has been deprecated in favor of RegionDocument#polygons.",
      {since: 13, until: 15, once: true});
    return this.document.polygonTree;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get clipperPaths() {
    foundry.utils.logCompatibilityWarning("Region#clipperPaths has been deprecated in favor of RegionDocument#clipperPaths.",
      {since: 13, until: 15, once: true});
    return this.document.clipperPaths;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get triangulation() {
    foundry.utils.logCompatibilityWarning("Region#triangulation has been deprecated in favor of RegionDocument#triangulation.",
      {since: 13, until: 15, once: true});
    return this.document.triangulation;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  segmentizeMovement(waypoints, samples, options) {
    const msg = "Region#segmentizeMovement has been deprecated in favor of RegionDocument#segmentizeMovementPath.";
    foundry.utils.logCompatibilityWarning(msg, {since: 13, until: 15, once: true});
    if ( options?.teleport !== undefined ) {
      waypoints = waypoints.map(waypoint => {
        waypoint = {...waypoint};
        waypoint.teleport ??= options.teleport;
        return waypoint;
      });
    }
    return this.document.segmentizeMovementPath(waypoints, samples);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  testPoint(point, elevation) {
    foundry.utils.logCompatibilityWarning("Region#testPoint(point: Point, elevation?: number) has been deprecated "
      + "in favor of RegionDocument#testPoint(point: ElevatedPoint).", {since: 13, until: 15, once: true});
    return this.document.testPoint(point, elevation ?? this.document.elevation.bottom);
  }
};

/**
 * @import {Point} from "@common/_types.mjs";
 */

/**
 * A type of Placeable Object which highlights an area of the grid as covered by some area of effect.
 * @category Canvas
 * @see {@link foundry.documents.MeasuredTemplateDocument}
 * @see {@link foundry.canvas.layers.TemplateLayer}
 */
let MeasuredTemplate$1 = 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|null}
   */
  texture = null;

  /**
   * 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 r = this.document.distance * canvas.dimensions.distancePixels;
    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 = 60 * canvas.dimensions.uiScale;
    const icon = new ControlIcon({texture: CONFIG.controlIcons.template, 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 s = canvas.dimensions.uiScale;
    const style = CONFIG.canvasTextStyle.clone();
    const text = new PreciseText(null, style);
    text.anchor.set(0, 1);
    text.scale.set(s, s);
    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() {
    const {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 s = canvas.dimensions.uiScale;
    const t = this.template.clear();

    // Draw the Template outline
    t.lineStyle(this._borderThickness * s, 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 * s, 0x000000)
      .beginFill(0x000000, 0.5)
      .drawCircle(0, 0, 6 * s)
      .drawCircle(this.ray.dx, this.ray.dy, 6 * s)
      .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;
      let w = dx.toNearest(0.01).toLocaleString(game.i18n.lang);
      let h = dy.toNearest(0.01).toLocaleString(game.i18n.lang);
      if ( grid.units ) {
        w = `${w} ${grid.units}`;
        h = `${h} ${grid.units}`;
      }
      this.ruler.text = `${w} × ${h}`;
    } else {
      let r = distance.toNearest(0.01).toLocaleString(game.i18n.lang);
      if ( grid.units ) r = `${r} ${grid.units}`;
      this.ruler.text = r;
    }
    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 c = grid.getCenterPoint(offset);

        // If the origin of the template is a grid space center, this grid space is highlighted
        const covered = (Math.max(Math.abs(c.x - ox), Math.abs(c.y - oy)) < 1) || this.testPoint(c);
        if ( !covered ) continue;
        positions.push(grid.getTopLeftPoint(offset));
      }
    }
    return positions;
  }

  /* -------------------------------------------- */

  /**
   * Is the given point contained in the template's shape?
   * @param {Point} point    The point
   * @returns {boolean}      Is contained?
   */
  testPoint(point) {
    const {x: ox, y: oy} = this.document;
    const x0 = point.x - ox;
    const y0 = point.y - oy;
    const shape = this.shape;
    for ( let dx = -1; dx <= 1; dx += 1 ) {
      for ( let dy = -1; dy <= 1; dy += 1 ) {
        if ( shape.contains(x0 + dx, y0 + dy) ) return true;
      }
    }
    return false;
  }

  /* -------------------------------------------- */
  /*  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) || ("author" 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 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 foundry.documents.WallDocument}
 * @see {@link foundry.canvas.layers.WallsLayer}
 */
let Wall$1 = 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;

  /**
   * A set of optional DoorMesh instances used to render a door animation for this Wall.
   * @type {Set<DoorMesh>}
   */
  get doorMeshes() {
    return this.#doorMeshes;
  }

  #doorMeshes = new Set();

  /**
   * 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() {
    const d = this.document.dir;
    if ( !d ) return null;
    const c = this.coords;
    const 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");
  }

  /* -------------------------------------------- */

  /** @override */
  _pasteObject(offset, options) {
    const c = this.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 = this.document.toObject();
    delete data._id;
    data.c = [a.x, a.y, b.x, b.y];
    return data;
  }

  /* -------------------------------------------- */

  /**
   * 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 ) {
      if ( this.#edge ) {
        canvas.edges.delete(this.#edge.id);
        this.#edge = null;
      }
      return;
    }

    // Re-create the Edge for the wall
    this.#edge = this.#createEdge();
    canvas.edges.set(this.#edge.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 Edge({x: c[0], y: c[1]}, {x: c[2], y: c[3]}, {
      id: `wall.${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";
    this.createDoorMeshes();
  }

  /* -------------------------------------------- */

  /** @override */
  clear() {
    this.clearDoorControl();
    return super.clear();
  }

  /* -------------------------------------------- */

  /**
   * 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 * canvas.dimensions.uiScale;
    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 ( const 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 => {
      const p0 = w.coords.slice(0, 2).join(".");
      if ( !done.has(p0) ) test.add(p0);
      const 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;
        const p0 = w.coords.slice(0, 2).join(".");
        const 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 ( const p of testIds ) {
        const 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;
    const cr2 = cr * 2;
    const 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 ) {
      const 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 ) {
      const 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() {
    return 2 * canvas.dimensions.uiScale;
  }

  /* -------------------------------------------- */
  /*  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);
    }

    // DoorMesh animation
    const replaceDoorMesh = (!!this.#doorMeshes.size !== this.hasDoorMesh)
      || (changed.animation?.double !== undefined)
      || (changed.animation?.texture !== undefined);
    if ( replaceDoorMesh ) this.createDoorMeshes();
    else if ( this.#doorMeshes.size ) {
      for ( const doorMesh of this.#doorMeshes ) {
        if ( edgeChange || ("animation" in changed) ) doorMesh.initialize(this.document.animation);
        if ( "ds" in changed ) doorMesh.animate(this.isOpen);
      }
    }

    // Trigger door interaction sounds
    if ( "ds" in changed ) {
      const states = CONST.WALL_DOOR_STATES;
      let interaction;
      switch ( changed.ds ) {
        case states.OPEN:
          interaction = "open";
          break;
        case states.CLOSED:
          if ( this.#priorDoorState === states.OPEN ) interaction = "close";
          else if ( this.#priorDoorState === states.LOCKED ) interaction = "unlock";
          break;
        case states.LOCKED:
          if ( this.#priorDoorState === states.OPEN ) interaction = "close";
          else if ( this.#priorDoorState === states.CLOSED ) interaction = "lock";
          break;
      }
      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.destroyDoorMeshes();
    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();
  }

  /* -------------------------------------------- */
  /*  Animation                                   */
  /* -------------------------------------------- */

  /**
   * Should this Wall have a corresponding DoorMesh?
   * @type {boolean}
   */
  get hasDoorMesh() {
    const animation = this.document.animation;
    if ( !this.isDoor || !animation ) return false;
    return !!(animation.type && animation.texture);
  }

  /* -------------------------------------------- */

  /**
   * Create and add a DoorMesh to the PrimaryCanvasContainer.
   * @returns {Promise<void>}
   */
  async createDoorMeshes() {
    this.destroyDoorMeshes();
    if ( this.isPreview || !this.hasDoorMesh ) return;
    const {texture: textureSrc, ...animation} = this.document.animation;
    try {
      const texture = await loadTexture(textureSrc);
      const styles = [animation.double ? DoorMesh.DOOR_STYLES.DOUBLE_LEFT : DoorMesh.DOOR_STYLES.SINGLE];
      if ( animation.double ) styles.push(DoorMesh.DOOR_STYLES.DOUBLE_RIGHT);
      for ( const style of styles ) {
        const mesh = new DoorMesh({object: this, texture, style, ...animation});
        this.#doorMeshes.add(mesh);
        canvas.primary.addChild(mesh);
      }
    } catch(err) {
      console.error(err);
      this.#doorMeshes.clear();
    }
  }

  /* -------------------------------------------- */

  /**
   * Remove and destroy a DoorMesh from the PrimaryCanvasContainer.
   */
  destroyDoorMeshes() {
    if ( !this.#doorMeshes.size ) return;
    for ( const mesh of this.#doorMeshes ) {
      canvas.primary.removeChild(mesh);
      if ( mesh.destroyed === false ) mesh.destroy();
    }
    this.#doorMeshes.clear();
  }

  /* -------------------------------------------- */

  /**
   * 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
   */
  _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                               */
  /* -------------------------------------------- */

  /**
   * 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;
    }
  }

  /* -------------------------------------------- */

  /** @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.isModifierActive("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 {x: px, y: py} = this.position;
    let {left, right, top, bottom} = rectangle;
    left -= px;
    right -= px;
    top -= py;
    bottom -= py;
    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("ALT");
    const shift = game.keyboard.isModifierActive("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();
    this.sheet.render({force: true, walls: this.layer.controlled.map(w => w.document)});
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickRight2(event) {
    event.stopPropagation();
    this.sheet.render({force: true, walls: this.layer.controlled.map(w => w.document)});
  }

  /* -------------------------------------------- */

  /** @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;
    const 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 ( const 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.geometry.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.geometry.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;
  }
};

/**
 * An AmbientLight is an implementation of PlaceableObject which represents a dynamic light source within the Scene.
 * @category Canvas
 * @see {@link foundry.documents.AmbientLightDocument}
 * @see {@link foundry.canvas.layers.LightingLayer}
 */
let AmbientLight$1 = 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 = 60 * canvas.dimensions.uiScale;
    const icon = new ControlIcon({texture: CONFIG.controlIcons.light, 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.
   * Darkness sources always generate edges. Light sources only do so if their priority is strictly greater than 0.
   * If any aspect changes (deletion, switching between darkness/light, or priority change), the source may be destroyed
   * and recreated as needed, and relevant perception flags are set.
   * @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}={}) {
    // Gather current state
    const sourceId = this.sourceId;
    const wasLight = canvas.effects.lightSources.has(sourceId);
    const wasDarkness = canvas.effects.darknessSources.has(sourceId);
    const previousPriority = this.lightSource?.priority ?? 0;
    const actualPriority = this.document.config.priority;
    const isDarkness = !!this.document.config.negative;

    // Did the lightsource previously create edges?
    const edgesBefore = wasDarkness || (wasLight && (previousPriority > 0));

    // Should the lightsource create edges now?
    const edgesNow = isDarkness || (actualPriority > 0);

    // Check for key changes
    const darknessChanged = (wasDarkness !== isDarkness);
    const priorityChanged = (previousPriority !== actualPriority);
    const edgesChanged = (edgesBefore !== edgesNow);
    const fullUpdate = darknessChanged || edgesChanged || priorityChanged;

    // Prepare a single perception flags object
    const perceptionFlags = {
      refreshEdges: edgesBefore || edgesNow,
      initializeVision: edgesBefore || edgesNow,
      initializeLighting: edgesBefore || edgesNow,
      refreshLighting: true,
      refreshVision: true
    };

    // Handle deletion
    if ( deleted ) {
      if ( !this.lightSource?.active ) return;
      this.#destroyLightSource();
      canvas.perception.update(perceptionFlags);
      return;
    }

    // Otherwise handle potential recreation
    if ( fullUpdate ) this.#destroyLightSource();

    // Create if missing
    this.lightSource ??= this.#createLightSource();

    // Re-initialize source data and add it to the active collection
    this.lightSource.initialize(this._getLightSourceData());
    this.lightSource.add();

    // If darkness or edges changed, we need a full edge-based refresh
    if ( fullUpdate ) {
      perceptionFlags.refreshEdges = perceptionFlags.initializeVision = perceptionFlags.initializeLighting = true;
    }

    // Update perception and rendering
    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;
    canvas.dimensions;
    return foundry.utils.mergeObject(this.config.toObject(false), {
      x, y, elevation, rotation, walls, vision,
      dim: this.dimRadius,
      bright: this.brightRadius,
      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 {PointLightSource|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;
  }
};

/**
 * An AmbientSound is an implementation of PlaceableObject which represents a dynamic audio source within the Scene.
 * @category Canvas
 * @see {@link foundry.documents.AmbientSoundDocument}
 * @see {@link foundry.canvas.layers.SoundsLayer}
 */
let AmbientSound$1 = class AmbientSound extends PlaceableObject {

  /**
   * The Sound which manages playback for this AmbientSound effect
   * @type {Sound|null}
   */
  sound;

  /**
   * A sound effect attached to the managed Sound instance.
   * @type {BaseSoundEffect}
   */
  #baseEffect;

  /**
   * A  sound effect attached to the managed Sound instance when the sound source is muffled.
   * @type {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 {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 {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() {
    return this.document.radius * canvas.dimensions.distancePixels;
  }

  /* -------------------------------------------- */
  /* 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 = 60 * canvas.dimensions.uiScale;
    const icon = new ControlIcon({texture: CONFIG.controlIcons.sound, 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;
    const s = canvas.dimensions.uiScale;
    this.field.lineStyle(s, 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 {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: this.radius,
      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});
  }
};

var _module$j = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AmbientLight: AmbientLight$1,
  AmbientSound: AmbientSound$1,
  Drawing: Drawing$1,
  MeasuredTemplate: MeasuredTemplate$1,
  Note: Note$1,
  PlaceableObject: PlaceableObject,
  Region: Region$1,
  Tile: Tile$1,
  Token: Token$1,
  Wall: Wall$1,
  regions: _module$l,
  tokens: _module$k,
  types: _types$7
});

/**
 * Primary canvas container are reserved for advanced usage.
 * They allow to group PrimarySpriteMesh in a single Container.
 * The container elevation is replacing individual sprite elevation.
 */
class PrimaryCanvasContainer extends CanvasTransformMixin(PIXI.Container) {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * 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("PrimaryCanvasContainer#sort must be a numeric value.");
    }
    if ( value === this.#sort ) return;
    this.#sort = value;
    if ( this.parent ) this.parent.sortDirty = true;
  }

  #sort = 0;

  /* -------------------------------------------- */

  /**
   * The elevation of this container.
   * @type {number}
   */
  get elevation() {
    return this.#elevation;
  }

  set elevation(value) {
    if ( (typeof value !== "number") || Number.isNaN(value) ) {
      throw new Error("PrimaryCanvasContainer#elevation must be a numeric value.");
    }
    if ( value === this.#elevation ) return;
    this.#elevation = value;
    if ( this.parent ) {
      this.parent.sortDirty = true;
      for ( const child of this.children ) {
        if ( child?.shouldRenderDepth ) {
          canvas.masks.depth._elevationDirty = true;
          break;
        }
      }
    }
  }

  #elevation = 0;

  /* -------------------------------------------- */

  /**
   * To know if this container has at least one children that should render its depth.
   * @returns {boolean}
   */
  get shouldRenderDepth() {
    return this.children.some(child => child.shouldRenderDepth === true);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @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;
  }

  /* -------------------------------------------- */

  /** @override */
  updateCanvasTransform() {
    if ( this.sortDirty ) this.sortChildren();
    super.updateCanvasTransform();
  }

  /* -------------------------------------------- */

  /** @override */
  renderDepthData(renderer) {
    for ( const c of this.children ) c.renderDepthData?.(renderer);
  }
}

/**
 * A configurable particle effect meant to be used in the PrimaryCanvasGroup.
 * You must provide a full configuration object.
 */
class PrimaryParticleEffect extends CanvasTransformMixin(PIXI.Container) {
  constructor(config = {}) {
    super();
    if ( foundry.utils.isEmpty(config) ) {
      throw new Error("Configuration must be provided for the particle effect.");
    }
    this.cullable = true;
    this.initialize(config);
  }

  /**
   * Particle emitter options.
   * @type {PIXI.particles.EmitterConfigV3}
   */
  #config;

  /**
   * The array of emitters which are active for this particle effect
   * @type {PIXI.particles.Emitter}
   */
  #emitter;

  /* -------------------------------------------- */

  /**
   * 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("PrimaryParticleEffect#sort must be a numeric value.");
    }
    if ( value === this.#sort ) return;
    this.#sort = value;
    if ( this.parent ) this.parent.sortDirty = true;
  }

  #sort = 0;

  /* -------------------------------------------- */

  /**
   * The elevation of this container.
   * @type {number}
   */
  get elevation() {
    return this.#elevation;
  }

  set elevation(value) {
    if ( (typeof value !== "number") || Number.isNaN(value) ) {
      throw new Error("PrimaryParticleEffect#elevation must be a numeric value.");
    }
    if ( value === this.#elevation ) return;
    this.#elevation = value;
    if ( this.parent ) this.parent.sortDirty = true;
  }

  #elevation = 0;

  /* -------------------------------------------- */

  /**
   * Always false for a Primary Particle Effect.
   * @returns {boolean}
   */
  get shouldRenderDepth() {
    return false;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Create an emitter instance which automatically updates using the shared PIXI.Ticker
   */
  #createEmitter() {
    this.#config.autoUpdate = true;
    this.#config.emit = false;
    this.#emitter = new PIXI.particles.Emitter(this, this.#config);
  }

  /* -------------------------------------------- */

  /** @override */
  destroy(...args) {
    this.#emitter?.destroy();
    this.#emitter = undefined;
    super.destroy(...args);
  }

  /* -------------------------------------------- */

  /**
   * Initialize the emitter with optional configuration.
   * @param {object} [config]      Optional config object.
   * @param {boolean} [play=false] Should we play immediately? False by default.
   */
  initialize(config, play=false) {
    if ( config ) this.#config = config;
    this.#emitter?.destroy();
    this.#emitter = undefined;
    this.#createEmitter();
  }

  /* -------------------------------------------- */

  /**
   * Begin animation for the configured emitter.
   */
  play() {
    if ( this.#emitter ) this.#emitter.emit = true;
  }

  /* -------------------------------------------- */

  /**
   * Stop animation for the configured emitter.
   */
  stop() {
    if ( this.#emitter ) this.#emitter.emit = false;
  }
}

var _module$i = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CanvasTransformMixin: CanvasTransformMixin,
  PrimaryCanvasContainer: PrimaryCanvasContainer,
  PrimaryCanvasObjectMixin: PrimaryCanvasObjectMixin,
  PrimaryGraphics: PrimaryGraphics,
  PrimaryOccludableObjectMixin: PrimaryOccludableObjectMixin,
  PrimaryParticleEffect: PrimaryParticleEffect,
  PrimarySpriteMesh: PrimarySpriteMesh
});

/**
 * @import {PointEffectSource} from "@client/canvas/sources/_module.mjs";
 */

/**
 * @typedef ClipperPoint
 * @property {number} X
 * @property {number} Y
 */

/* -------------------------------------------- */

/**
 * @typedef {"wall"|"darkness"|"light"|"innerBounds"|"outerBounds"} EdgeType
 * @typedef {Record<EdgeType, boolean>} EdgeOptions
 */

// TODO: "universal" type will be deprecated in v14
/**
 * @typedef {"light"|"darkness"|"sight"|"sound"|"move"|"universal"} PointSourcePolygonType
 */

/**
 * @typedef 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} [debug]      Display debugging visualization and logging for the polygon
 * @property {PointEffectSource} [source] The object (if any) that spawned this polygon.
 * @property {Array<PIXI.Rectangle|PIXI.Circle|PIXI.Polygon>} [boundaryShapes] Limiting polygon boundary shapes
 * @property {boolean} [hasLimitedRadius] Does this polygon have a limited radius?
 * @property {boolean} [hasLimitedAngle] Does this polygon have a limited angle?
 * @property {PIXI.Rectangle} [boundingBox] The computed bounding box for the polygon
 */

/* -------------------------------------------- */

/**
 * @typedef ClockwiseSweepPolygonConfig
 * @property {number} [priority=0]    Optional priority when it comes to ignore edges from darkness and light sources
 * @property {Record<EdgeType, {priority: number, mode: 0|1|2}>} [edgeTypes] Edge types configuration object. This is
 * @property {EdgeOptions} [edgeOptions] Deactivate/Activate specific edge types behaviors
 * not required by most polygons and will be inferred based on the polygon type and priority.
 * @example
 * How modes are working:
 * - 0=no     : The edges of this type are rejected and not processed (equivalent of not having an edgeType.)
 * - 1=maybe  : The edges are processed and tested for inclusion.
 * - 2=always : The edges are automatically included.
 */

/* -------------------------------------------- */

/**
 * @typedef 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
 */

/* -------------------------------------------- */

/**
 * @typedef QuadtreeObject
 * @property {PIXI.Rectangle} r
 * @property {*} t
 * @property {Set<Quadtree>} [n]
 */

/* -------------------------------------------- */

/**
 * @typedef {Map<number,PolygonVertex>} VertexMap
 */

/**
 * @typedef {Set<Edge>} EdgeSet
 */

/**
 * @typedef {Ray} PolygonRay
 * @property {CollisionResult} result
 */

var _types$6 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * A specialized object that contains the result of a collision in the context of the ClockwiseSweepPolygon.
 * This class is not designed or intended for use outside of that context.
 */
class CollisionResult {
  constructor({target, collisions=[], cwEdges, ccwEdges, isBehind, isLimited, wasLimited}={}) {
    this.target = target;
    this.collisions = collisions;
    this.cwEdges = cwEdges;
    this.ccwEdges = ccwEdges;
    this.isBehind = isBehind;
    this.isLimited = isLimited;
    this.wasLimited = wasLimited;
  }

  /**
   * The vertex that was the target of this result
   * @type {PolygonVertex}
   */
  target;

  /**
   * The array of collision points which apply to this result
   * @type {PolygonVertex[]}
   */
  collisions;

  /**
   * The set of edges connected to the target vertex that continue clockwise
   * @type {EdgeSet}
   */
  cwEdges;

  /**
   * The set of edges connected to the target vertex that continue counter-clockwise
   * @type {EdgeSet}
   */
  ccwEdges;

  /**
   * Is the target vertex for this result behind some closer active edge?
   * @type {boolean}
   */
  isBehind;

  /**
   * Does the target vertex for this result impose a limited collision?
   * @type {boolean}
   */
  isLimited;

  /**
   * Has the set of collisions for this result encountered a limited edge?
   * @type {boolean}
   */
  wasLimited;

  /**
   * Is this result limited in the clockwise direction?
   * @type {boolean}
   */
  limitedCW = false;

  /**
   * Is this result limited in the counter-clockwise direction?
   * @type {boolean}
   */
  limitedCCW = false;

  /**
   * Is this result blocking in the clockwise direction?
   * @type {boolean}
   */
  blockedCW = false;

  /**
   * Is this result blocking in the counter-clockwise direction?
   * @type {boolean}
   */
  blockedCCW = false;

  /**
   * Previously blocking in the clockwise direction?
   * @type {boolean}
   */
  blockedCWPrev = false;

  /**
   * Previously blocking in the counter-clockwise direction?
   * @type {boolean}
   */
  blockedCCWPrev = false;
}

var _module$h = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CanvasEdges: CanvasEdges,
  CollisionResult: CollisionResult,
  Edge: Edge,
  PolygonVertex: PolygonVertex
});

/**
 * A custom Transform class allowing to observe changes with a callback.
 */
class ObservableTransform extends PIXI.Transform {
  /**
   * @param {Function} callback          The callback called to observe changes.
   * @param {object} scope               The scope of the callback.
   */
  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);
  }
}

/**
 * @import {VertexMap} from "./_types.mjs"
 * @import {EdgeSet} from "./_types.mjs"
 * @import {PolygonRay} from "./_types.mjs"
 * @import {ClockwiseSweepPolygonConfig} from "./_types.mjs"
 */

/**
 * 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<PointSourcePolygonConfig & ClockwiseSweepPolygonConfig>}
 */
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;

  /* -------------------------------------------- */
  /*  Getters/Setters                             */
  /* -------------------------------------------- */

  /**
   * Is this polygon using inner bounds?
   * @type {boolean}
   */
  get useInnerBounds() {
    return this.#useInnerBounds;
  }

  #useInnerBounds = false;

  /* -------------------------------------------- */
  /*  Initialization                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  initialize(origin, config) {
    super.initialize(origin, config);

    // Compute ray distance 2
    this.#rayDistance2 = Math.pow(canvas.dimensions.maxR, 2);

    // Define priority and validate it is a numeric
    config.priority ??= 0;
    if ( !Number.isNumeric(config.priority) ) throw new Error("config.priority must be a number.");

    // Determine edge types if necessary
    config.edgeTypes ??= this._determineEdgeTypes(config.type, config.priority, config);

    // For convenience and speed up access
    this.#useInnerBounds = (config.edgeTypes?.innerBounds?.mode === 2);

    // Compute the bounding box
    config.boundingBox ??= this._defineBoundingBox();
  }

  /* -------------------------------------------- */

  /**
   * Determine the edge types and their manner of inclusion for this polygon instance.
   * @param {string} type
   * @param {number} priority
   * @param {object} [config={}]           Optional polygon config which may include deprecated properties
   * @returns {Record<EdgeType, {priority: number, mode: 0|1|2}>}
   * @protected
   */
  _determineEdgeTypes(type, priority, config={}) {
    const et = config.edgeOptions ?? {};
    const edgeTypes = {};
    const addEdgeType = (name, fallback, p=priority) => {
      const v = et[name];
      if ( v === false ) return;
      else edgeTypes[name] = {mode: fallback, priority: p};
    };

    addEdgeType("wall", 1, -Infinity);
    let boundsType = et.outerBounds === false ? null : "outerBounds";


    /** @deprecated since v13 */
    if ( "useInnerBounds" in config ) {
      foundry.utils.logCompatibilityWarning(
        "config.useInnerBounds is now deprecated, replaced by edgeTypes polygon configuration behaviors.",
        {since: 13, until: 15, once: true}
      );
      if ( config.useInnerBounds && et.innerBounds !== false ) boundsType = "innerBounds";
      delete config.useInnerBounds;
    }

    switch ( type ) {
      case "universal": // TODO: deprecate universal type in v14
        delete edgeTypes.wall;
        break;

      case "sight":
        const insideScene = canvas.dimensions.sceneRect.contains(this.origin.x, this.origin.y);
        if ( insideScene && (et.innerBounds !== false) ) boundsType = "innerBounds";
        addEdgeType("darkness", 1);
        break;

      case "light":
        addEdgeType("darkness", 1);
        break;

      case "darkness":
        addEdgeType("light", 1, priority + 1);
        break;
    }

    if ( boundsType ) edgeTypes[boundsType] = {mode: 2, priority: -Infinity};

    /** @deprecated since v13 */
    if ( "includeDarkness" in config ) {
      foundry.utils.logCompatibilityWarning(
        "config.includeDarkness is now deprecated, replaced by edgeTypes polygon configuration behaviors.",
        {since: 13, until: 15, once: true}
      );
      if ( config.includeDarkness ) addEdgeType("darkness", 1);
      delete config.includeDarkness;
    }

    return edgeTypes;
  }

  /* -------------------------------------------- */

  /** @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                          */
  /* -------------------------------------------- */

  /**
   * Retrieves the super-set of walls that could potentially apply to this polygon.
   * Utilizes a custom collision test and the Quadtree to obtain candidate edges efficiently.
   * @protected
   */
  _identifyEdges() {
    const bounds = this.config.boundingBox;
    const edgeTypes = this.config.edgeTypes;

    // Prepare collision function
    const collisionTest = edge => this._testEdgeInclusion(edge, edgeTypes);

    // Retrieve only the edges that intersect with the bounding box and pass the collision test
    const matchedEdges = canvas.edges.getEdges(bounds, {
      includeOuterBounds: !this.useInnerBounds,
      includeInnerBounds: this.useInnerBounds,
      collisionTest
    });

    // Add identified edges to the set
    for ( const edge of matchedEdges ) this.edges.add(edge.clone());
  }

  /* -------------------------------------------- */

  /**
   * 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<EdgeType, {priority: number, mode: 0|1|2}>} edgeTypes Which types of edges are being used?
   *                                                                        0=no, 1=maybe, 2=always
   * @returns {boolean}                     Should the edge be included?
   * @protected
   */
  _testEdgeInclusion(edge, edgeTypes) {
    const { type, boundaryShapes, useThreshold, wallDirectionMode, externalRadius } = this.config;

    // Only include edges of the appropriate type
    const edgeType = edgeTypes[edge.type];
    const m = edgeType?.mode;
    if ( !m ) return false;
    if ( m === 2 ) return true;

    // Exclude edges with a lower priority than required for this polygon
    if ( edge.priority < edgeType.priority ) 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.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 ( const edge of this.edges ) {
      edgeMap.set(edge.id, edge);

      // Create or reference vertex A
      const ak = PolygonVertex.getKey(edge.a.x, edge.a.y);
      if ( this.vertices.has(ak) ) edge.vertexA = this.vertices.get(ak);
      else {
        edge.vertexA = new PolygonVertex(edge.a.x, edge.a.y);
        this.vertices.set(ak, edge.vertexA);
      }

      // Create or reference vertex B
      const bk = PolygonVertex.getKey(edge.b.x, edge.b.y);
      if ( this.vertices.has(bk) ) edge.vertexB = this.vertices.get(bk);
      else {
        edge.vertexB = new 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 ( const 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 = PolygonVertex.getKey(Math.round(i.x), Math.round(i.y));
        let v = this.vertices.get(vk);
        if ( !v ) {
          v = new 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
   * @protected
   */
  _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);
    }

    // Remove collinearity between the starting and ending points
    this.#closePoints();
  }

  /* -------------------------------------------- */

  /**
   * 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
   * @protected
   */
  _initializeActiveEdges() {
    const initial = {x: Math.round(this.origin.x - this.#rayDistance2), y: this.origin.y};
    const edges = new Set();
    for ( const 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
   * @protected
   */
  _sortVertices() {
    const size = this.vertices.size;
    if ( !size ) return [];

    const origin = this.origin;

    // Preallocate an array of the exact size
    const vertices = new Array(size);
    const valuesVertices = this.vertices.values();
    let i = 0;

    // Precompute angle and squared distance
    for ( const vertex of valuesVertices ) {
      // Use the true intersection coordinates if available
      const p = vertex._intersectionCoordinates || vertex;

      // Calculate dx, dy relative to the origin such that West => angle = 0
      const dx = origin.x - p.x;
      const dy = origin.y - p.y;

      // Determine the raw angle in the range [-PI, PI], then shift into [0, 2PI)
      const rawAngle = Math.atan2(dy, dx);
      vertex._angle = (rawAngle + (2 * Math.PI)) % (2 * Math.PI);

      // Compute squared distance (if it's not already set)
      vertex._d2 ||= (dx * dx) + (dy * dy);

      // Add to the preallocated array
      vertices[i++] = vertex;
    }

    // Sort by angle first, then by squared distance
    vertices.sort((a, b) => {
      const angleDiff = a._angle - b._angle;
      if ( angleDiff !== 0 ) return angleDiff;
      return a._d2 - b._d2;
    });

    // Handle collinearity
    let groupStart; // Index of where a collinear run starts

    for ( let j = 1; j < vertices.length; j++ ) {
      const prev = vertices[j - 1];
      const curr = vertices[j];

      // Check if current vertex is collinear with the previous
      if ( curr._angle.almostEqual(prev._angle, 1e-12) ) {
        if ( groupStart === undefined ) groupStart = j - 1;
      }
      else if ( groupStart !== undefined ) {
        this.#unifyCollinearGroup(vertices, groupStart, j - 1);
        groupStart = undefined;
      }
    }

    // If there is a trailing group that extends to the end
    if ( groupStart !== undefined ) this.#unifyCollinearGroup(vertices, groupStart, vertices.length - 1);

    return vertices;
  }

  /* -------------------------------------------- */

  /**
   * Attach all vertices in the range [start..end] to one another as collinear.
   * It is using i < j, so each pair is only processed once. Each pair is still stored symmetrically.
   * @param {PolygonVertex[]} verts   A sorted array of vertices
   * @param {number} start            Index where the collinear group starts
   * @param {number} end              Index where the collinear group ends (inclusive)
   */
  #unifyCollinearGroup(verts, start, end) {
    // If the group is only one vertex, nothing to do
    if ( end <= start ) return;

    for ( let i = start; i < end; i++ ) {
      const vi = verts[i];
      for ( let j = i + 1; j <= end; j++ ) {
        const vj = verts[j];
        // Record each as collinear with the other
        vi.collinearVertices.add(vj);
        vj.collinearVertices.add(vi);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * 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?
   * @protected
   */
  _isVertexBehindActiveEdges(vertex, activeEdges) {
    let wasLimited = false;
    for ( const 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?
   * @protected
   */
  _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 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.
   *
   * @param {CollisionResult} result    The pending collision result
   * @param {EdgeSet} activeEdges       The set of currently active edges
   * @protected
   */
  _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 = PolygonVertex.fromPoint(x, {round: false});
      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 = 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() {
    const 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 ( const 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 ( const 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 ) {
        const t = text.addChild(new PIXI.Text(String(vertex._index), CONFIG.canvasTextStyle));
        t.position.set(vertex.x, vertex.y);
      }
    }

    // Draw emitted rays
    for ( const 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 ( const 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
   * @protected
   */
  _visualizeCollision(ray, collisions) {
    const 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 ( const 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 ( const x of collisions ) {
      dg.lineStyle(1, 0x000000).beginFill(0xFF0000).drawCircle(x.x, x.y, 6).endFill();
    }
  }

  /* -------------------------------------------- */

  /**
   * This function has been adapted from Clipper's CleanPolygon function.
   * When adding a new point to the polygon, check for collinearity with prior points to cull unnecessary points.
   * This also removes spikes where we traverse points (a, b, a).
   * We also enforce a minimum distance between two points, or a minimum perpendicular distance between three almost
   * collinear points.
   * @override
   */
  addPoint({x, y}) {
    const MIN_DISTANCE_SQUARED = 0.25 ** 2;
    const points = this.points;
    const n = points.length;

    // If we have no points yet, add the next point
    if ( n === 0 ) {
      points.push(x, y);
      return this;
    }

    // If we have exactly one point already, add the next point if greater than the minimum required distance
    if ( n === 2 ) {
      const dx = points[n - 2] - x;
      const dy = points[n - 1] - y;
      if ( (dx * dx) + (dy * dy) >= MIN_DISTANCE_SQUARED ) points.push(x, y);
      return this;
    }

    // Get the last two points
    let x1 = x;
    let y1 = y;
    let x2 = points[n - 2];
    let y2 = points[n - 1];
    let x3 = points[n - 4];
    let y3 = points[n - 3];

    // If necessary, swap the points such that the point (x1, y1) lies geometrically between (x2, y2) and (x3, y3)
    if ( Math.abs(x1 - x2) > Math.abs(y1 - y2) ) {
      if ( (x1 > x2) !== (x1 < x3) ) {
        if ( (x2 > x1) === (x2 < x3) ) [x1, y1, x2, y2] = [x2, y2, x1, y1];
        else [x1, y1, x2, y2, x3, y3] = [x3, y3, x1, y1, x2, y2];
      }
    }
    else if ( (y1 > y2) !== (y1 < y3) ) {
      if ( (y2 > y1) === (y2 < y3) ) [x1, y1, x2, y2] = [x2, y2, x1, y1];
      else [x1, y1, x2, y2, x3, y3] = [x3, y3, x1, y1, x2, y2];
    }

    // Calculate the squared perpendicular distance of (x1, y1) from the line [(x2, y2), (x3, y3)]
    // If the perpendicular distance is equal to or greater than the minimum distance, add the next point
    const a = y2 - y3;
    const b = x3 - x2;
    const c = (a * (x1 - x2)) + (b * (y1 - y2));
    const distanceSquared = (c * c) / ((a * a) + (b * b));
    if ( distanceSquared >= MIN_DISTANCE_SQUARED ) {
      points.push(x, y);
      return this;
    }

    // Otherwise drop the last point, which is almost collinear with the second to last and the next point.
    // The last point is either between the second to last and the next point and almost collinear or a thin spike.
    // Only add the next point if it is sufficiently distant from the new last point.
    const dx = points[n - 4] - x;
    const dy = points[n - 3] - y;
    points.length -= 2;
    if ( (dx * dx) + (dy * dy) >= MIN_DISTANCE_SQUARED ) points.push(x, y);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Remove duplicate or collinear points between the first and last points.
   */
  #closePoints() {
    const points = this.points;

    // If we have two or fewer points, the polygon is empty
    if ( points.length < 6 ) {
      points.length = 0;
      return;
    }

    // Add the first two points from the beginning to the end
    const [x1, y1, x2, y2] = points;
    this.addPoint({x: x1, y: y1});
    this.addPoint({x: x2, y: y2});

    // Now replace the first two points with the last two points, which are then removed
    const n = points.length;
    [points[0], points[1], points[2], points[3]] = [points[n - 4], points[n - 3], points[n - 2], points[n - 1]];
    points.length -= 4;
  }
}

var _module$g = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CanvasQuadtree: CanvasQuadtree,
  ClockwiseSweepPolygon: ClockwiseSweepPolygon,
  LimitedAnglePolygon: LimitedAnglePolygon,
  ObservableTransform: ObservableTransform,
  PointSourcePolygon: PointSourcePolygon,
  PolygonMesher: PolygonMesher,
  Quadtree: Quadtree,
  Ray: Ray,
  UnboundTransform: UnboundTransform,
  WeilerAthertonClipper: WeilerAthertonClipper,
  edges: _module$h,
  types: _types$6
});

/**
 * @typedef 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.
 */

/* -------------------------------------------- */

/**
 * @typedef 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 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 foundry.canvas.animation.CanvasAnimation.animate}.
 */

/**
 * @typedef _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.
 */

/**
 * @typedef {PingOptions & _PulsePingOptions} PulsePingOptions
 */

var _types$5 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * @import {PingOptions} from "../_types.mjs"
 * @import {Point} from "@common/_types.mjs";
 */

/**
 * A class to manage a user ping on the canvas.
 */
class Ping extends PIXI.Container {
  /**
   * @param {Point} origin            The canvas coordinates of the origin of the ping.
   * @param {PingOptions} [options]   Additional options to configure the ping animation.
   */
  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);
  }
  /* -------------------------------------------- */

  /**
   * The color of the ping.
   * @type {Color}
   * @protected
   */
  _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.");
  }
}

/**
 * @import {PulsePingOptions} from "../_types.mjs";
 * @import {Point} from "@common/_types.mjs";
 */

/**
 * A type of ping that produces a pulsing animation.
 */
class PulsePing extends Ping {
  /**
   * @param {Point} origin                The canvas coordinates of the origin of the ping.
   * @param {PulsePingOptions} [options]  Additional options to configure the ping animation.
   */
  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();
  }

  /* -------------------------------------------- */

  /** @type {number} */
  #r;

  /** @type {number} */
  #r0;

  /** @type {Color} */
  #color2;

  /** @type {number} */
  #timeSlice;

  /** @type {number} */
  #timeSlice2;

  /** @type {number} */
  #timeSlice15;

  /** @type {number} */
  #timeSlice25;

  /** @type {number} */
  #timeSlice45;

  /* -------------------------------------------- */

  /**
   * 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.
   */
  #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 {Color}          The incremental color between from and to.
   */
  #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 * canvas.dimensions.uiScale, cap: PIXI.LINE_CAP.ROUND,
      join: PIXI.LINE_JOIN.BEVEL});
    g.drawCircle(0, 0, size);
  }
}

/**
 * @import {PingOptions} from "../_types.mjs"
 * @import {Point} from "@common/_types.mjs";
 */

/**
 * A type of ping that points to a specific location.
 */
class ChevronPing extends Ping {
  /**
   * @param {Point} origin           The canvas coordinates of the origin of the ping.
   * @param {PingOptions} [options]  Additional options to configure the ping animation.
   */
  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}
   */
  static CHEVRON_PATH = "icons/pings/chevron.webp";

  /* -------------------------------------------- */

  /** @type {number} */
  #r;

  /** @type {number} */
  #rInner;

  /** @type {number} */
  #t14;

  /** @type {number} */
  #t12;

  /** @type {number} */
  #t34;

  /** @type {number} */
  #y;

  /** @type {number} */
  #h2;

  /* -------------------------------------------- */

  /**
   * The sprite.
   * @type {PIXI.Sprite}
   */
  #chevron;

  /* -------------------------------------------- */

  /**
   * The inner ring.
   * @type {PIXI.Graphics}
   */
  #inner;

  /* -------------------------------------------- */

  /**
   * The outer ring.
   * @type {PIXI.Graphics}
   */
  #outer;

  /* -------------------------------------------- */

  /** @inheritDoc */
  async animate() {
    this.removeChildren().forEach(c => c.destroy({children: true}));
    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.
   */
  #drawRings(a) {
    const s = canvas.dimensions.uiScale;
    this.#outer.clear();
    this.#inner.clear();
    this.#outer.lineStyle(6 * s, this._color, a).drawCircle(0, 0, this.#r);
    this.#inner.lineStyle(3 * s, this._color, a).arc(0, 0, this.#rInner, 0, Math.PI * 1.5);
  }

  /* -------------------------------------------- */

  /**
   * Load the chevron texture.
   * @returns {Promise<PIXI.Sprite>}
   */
  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[]}
   */
  #createRings() {
    this.#outer = new PIXI.Graphics();
    this.#inner = new PIXI.Graphics();
    return [this.#outer, this.#inner];
  }
}

/**
 * @import {PulsePingOptions} from "../_types.mjs"
 * @import {Point} from "@common/_types.mjs";
 */

/**
 * A type of ping that produces a pulse warning sign animation.
 */
class AlertPing extends PulsePing {
  /**
   * @param {Point} origin                The canvas coordinates of the origin of the ping.
   * @param {PulsePingOptions} [options]  Additional options to configure the ping animation.
   */
  constructor(origin, {color="#ff0000", ...options}={}) {
    super(origin, {color, ...options});
    this.scale.set(2, 2);
  }

  /* -------------------------------------------- */

  /** @override */
  _drawShape(g, color, alpha, size) {
    // Draw a chamfered triangle.
    g.lineStyle({color, alpha, width: 3 * canvas.dimensions.uiScale, 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);
  }
}

/**
 * @import {PulsePingOptions} from "../_types.mjs"
 * @import {Point} from "@common/_types.mjs";
 */

/**
 * A type of ping that produces an arrow pointing in a given direction.
 */
class ArrowPing extends PulsePing {
  /**
   * @param {Point} origin    The canvas coordinates of the origin of the ping. This becomes the arrow's tip.
   * @param {PulsePingOptions & {rotation?: number}} [options]  Additional options to configure the ping animation.
   */
  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 * canvas.dimensions.uiScale, 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);
  }
}

/**
 * @import {ElevatedPoint, Point} from "@common/_types.mjs";
 */

/**
 * The ruler that is used to measure distances on the Canvas.
 * @mixes RenderFlagsMixin
 */
class BaseRuler extends RenderFlagsMixin() {
  /**
   * @param {User} user    The User for whom to construct the Ruler instance
   */
  constructor(user) {
    super();
    if ( !(user instanceof User$1) ) throw new Error("The BaseRuler may only be constructed with a User instance.");
    this.#user = user;
  }

  /* -------------------------------------------- */

  /** @override */
  static RENDER_FLAGS = {refresh: {}};

  /* -------------------------------------------- */

  /**
   * Is the Ruler ready to measure?
   * @type {boolean}
   */
  static get canMeasure() {
    return canvas.tokens.active && (game.activeTool === "ruler");
  }

  /* -------------------------------------------- */

  /**
   * Snaps the given point to the grid.
   * @param {Point} point    The point that is to be snapped
   * @returns {Point}        The snapped point
   */
  static getSnappedPoint(point) {
    let mode = GRID_SNAPPING_MODES.CENTER | GRID_SNAPPING_MODES.VERTEX | GRID_SNAPPING_MODES.EDGE_MIDPOINT;
    if ( canvas.grid.isSquare ) mode |= GRID_SNAPPING_MODES.CORNER;
    return canvas.grid.getSnappedPoint({x: point.x, y: point.y}, {mode, resolution: 1});
  }

  /* -------------------------------------------- */

  /**
   * The User who this Ruler belongs to.
   * @type {User}
   */
  get user() {
    return this.#user;
  }

  #user;

  /* -------------------------------------------- */

  /**
   * Is this Ruler active? True, if the path of the Ruler is nonempty.
   * @type {boolean}
   */
  get active() {
    return this.#path.length !== 0;
  }

  /* -------------------------------------------- */

  /**
   * The Ruler is visible if it is active and either not hidden or its User is the current User.
   * @type {boolean}
   */
  get visible() {
    return this.active && (!this.hidden || this.user.isSelf);
  }

  /* -------------------------------------------- */

  /**
   * The sequence of points that the Ruler measures.
   * @type {ReadonlyArray<Readonly<ElevatedPoint>>}
   * @defaultValue []
   */
  get path() {
    return this.#path;
  }

  /**
   * Set the sequence of points that the Ruler measures.
   */
  set path(value) {
    if ( this.#path.equals(value) ) return;
    this.#path = Object.freeze(value.map(({x, y, elevation}) => Object.freeze({x, y, elevation})));
    this._onPathChange();
  }

  #path = [];

  /* -------------------------------------------- */

  /**
   * The first point of the path, or undefined if the path is empty.
   * @type {ElevatedPoint|undefined}
   */
  get origin() {
    return this.#path.at(0);
  }

  /* -------------------------------------------- */

  /**
   * The last point of the path, or undefined if the path is empty.
   * @type {ElevatedPoint|undefined}
   */
  get destination() {
    return this.#path.at(-1);
  }

  /* -------------------------------------------- */

  /**
   * Is this Ruler hidden? If true, only the User of the Ruler can see it.
   * @type {boolean}
   * @defaultValue false
   */
  get hidden() {
    return this.#hidden;
  }

  set hidden(value) {
    if ( this.#hidden === value ) return;
    this.#hidden = value;
    this._onHiddenChange();
  }

  #hidden = false;

  /* -------------------------------------------- */

  /**
   * Called when the Ruler's path has changed.
   * @protected
   */
  _onPathChange() {
    this.refresh();
    if ( this.user.isSelf ) this.#throttleBroadcast();
  }

  /* -------------------------------------------- */

  /**
   * Called when the Ruler becomes hidden or unhidden.
   * @protected
   */
  _onHiddenChange() {
    this.refresh();
    if ( this.user.isSelf ) this.#throttleBroadcast();
  }

  /* -------------------------------------------- */

  /**
   * Reset the path and the hidden state of the Ruler.
   */
  reset() {
    this.path = [];
    this.hidden = false;
  }

  /* -------------------------------------------- */
  /*  Broadcasting                                */
  /* -------------------------------------------- */

  /**
   * A throttled function that broadcasts the Ruler data.
   * @type {() => void}
   */
  #throttleBroadcast = foundry.utils.throttle(this.#broadcast.bind(this), 100);

  /* -------------------------------------------- */

  /**
   * Broadcast the Ruler data.
   */
  #broadcast() {
    this.user.broadcastActivity({ruler: this.user.hasPermission("SHOW_RULER") ? {path: this.#path, hidden: this.#hidden} : null});
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Draw the Ruler.
   * @abstract
   */
  async draw() {
    throw new Error("A subclass of the BaseRuler must implement the draw method.");
  }

  /* -------------------------------------------- */

  /**
   * Destroy the Ruler.
   * @abstract
   */
  destroy() {
    throw new Error("A subclass of the BaseRuler must implement the destroy method.");
  }

  /* -------------------------------------------- */

  /**
   * Refresh the Ruler.
   */
  refresh() {
    this.renderFlags.set({refresh: true});
  }

  /* -------------------------------------------- */

  /**
   * Refresh the Ruler.
   * @protected
   * @abstract
   */
  _refresh() {
    throw new Error("A subclass of the BaseRuler must implement the _refresh method.");
  }

  /* -------------------------------------------- */

  /** @override */
  applyRenderFlags() {
    if ( !this.renderFlags.size ) return;
    const flags = this.renderFlags.clear();
    if ( flags.refresh ) this._refresh();
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Add a waypoint.
   * @param {Point} point                     The (unsnapped) waypoint
   * @param {object} [options]                Additional options
   * @param {boolean} [options.snap=false]    Snap the added waypoint?
   * @protected
   */
  _addDragWaypoint(point, {snap=false}={}) {
    const {x, y} = point;
    point = {x, y};
    if ( snap ) point = this.constructor.getSnappedPoint(point);
    point.elevation = this.path.at(-1).elevation;
    const lastPoint = this.path.at(-2);
    if ( (lastPoint.x === point.x) && (lastPoint.y === point.y) && (lastPoint.elevation === point.elevation) ) return;
    this.path = [...this.path.slice(0, -1), point, point];
  }

  /* -------------------------------------------- */

  /**
   * Remove the second to last waypoint.
   * @protected
   */
  _removeDragWaypoint() {
    if ( this.path.length > 2 ) this.path = this.path.toSpliced(-2, 1);
    else this.reset();
  }

  /* -------------------------------------------- */

  /**
   * Change the elevation of the destination.
   * @param {number} delta                       The number vertical steps
   * @param {object} [options]                   Additional options
   * @param {boolean} [options.precise=false]    Round elevations to multiples of the grid distance divided by
   *                                             `CONFIG.Canvas.elevationSnappingPrecision`?
   *                                             If false, rounds to multiples of the grid distance.
   * @protected
   */
  _changeDragElevation(delta, {precise=false}={}) {
    const interval = canvas.dimensions.distance / (precise ? CONFIG.Canvas.elevationSnappingPrecision : 1);
    let {x, y, elevation} = this.path.at(-1);
    elevation = (elevation + (delta * interval)).toNearest(interval, delta > 0 ? "floor" : "ceil");
    this.path = [...this.path.slice(0, -1), {x, y, elevation}];
  }

  /* -------------------------------------------- */

  /**
   * Handle the beginning of a new Ruler measurement workflow.
   * @param {PIXI.FederatedEvent} event    The drag start event
   * @protected
   */
  _onDragStart(event) {
    const {origin: {x: x0, y: y0}, destination: {x: x1, y: y1}} = event.interactionData;
    let origin = {x: x0, y: y0};
    let destination = {x: x1, y: y1};
    if ( !event.shiftKey ) {
      origin = this.constructor.getSnappedPoint(origin);
      destination = this.constructor.getSnappedPoint(destination);
    }
    origin.elevation = destination.elevation = 0;
    this.path = [origin, destination];
    this.hidden = event.altKey;
    event.interactionData.cancelled = false;
    event.interactionData.released = false;
  }

  /* -------------------------------------------- */

  /**
   * Handle the end of the Ruler measurement workflow
   * @param {PIXI.FederatedEvent} event    The drag cancel event
   * @returns {boolean|void}               If false, the cancellation of the drag workflow is prevented
   * @protected
   */
  _onDragCancel(event) {
    if ( !event.interactionData.cancelled ) {
      if ( event.interactionData.released ) {
        if ( this.path.length >= 2 ) return false;
      } else if ( this.path.length > 2 ) {
        this._removeDragWaypoint();
        return false;
      }
    }
    this.reset();
  }

  /* -------------------------------------------- */

  /**
   * Handle left-click events on the Canvas during Ruler measurement.
   * @param {PIXI.FederatedEvent} event    The pointer-down event
   * @protected
   */
  _onClickLeft(event) {
    const isCtrl = event.ctrlKey || event.metaKey;
    if ( !isCtrl ) {
      event.interactionData.cancelled = true;
      canvas.mouseInteractionManager.cancel(event);
      return;
    }
    this._addDragWaypoint(event.interactionData.origin, {snap: !event.shiftKey});
  }

  /* -------------------------------------------- */

  /**
   * Handle right-click events on the Canvas during Ruler measurement.
   * @param {PIXI.FederatedEvent} event    The pointer-down event
   * @protected
   */
  _onClickRight(event) {
    this._removeDragWaypoint();
    if ( this.active ) canvas.mouseInteractionManager._dragRight = false;
    else {
      event.interactionData.cancelled = true;
      canvas.mouseInteractionManager.cancel(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Continue a Ruler measurement workflow for left-mouse movements on the Canvas.
   * @param {PIXI.FederatedEvent} event    The mouse move event
   * @protected
   */
  _onMouseMove(event) {
    if ( !this.active ) return;
    const {x, y} = event.interactionData.destination;
    let point = {x, y};
    if ( !event.shiftKey ) point = this.constructor.getSnappedPoint(point);
    point.elevation = this.path.at(-1).elevation;
    this.path = [...this.path.slice(0, -1), point];
  }

  /* -------------------------------------------- */

  /**
   * Conclude a Ruler measurement workflow by releasing the left-mouse button.
   * @param {PIXI.FederatedEvent} event   The pointer-up event
   * @protected
   */
  _onMouseUp(event) {
    const isCtrl = event.ctrlKey || event.metaKey;
    if ( isCtrl || event.interactionData.released ) {
      if ( !event.interactionData.released ) {
        event.interactionData.released = true;
        event.preventDefault();
      }
    } else {
      event.interactionData.cancelled = true;
      canvas.mouseInteractionManager.cancel(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Adjust the elevation of Ruler waypoints by scrolling up/down.
   * @param {WheelEvent} event    The mousewheel event
   * @protected
   */
  _onMouseWheel(event) {
    const isCtrl = game.keyboard.isModifierActive("CONTROL"); // We cannot trust event.ctrlKey because of touchpads
    if ( !isCtrl ) return;
    this._changeDragElevation(-Math.sign(event.delta), {precise: event.shiftKey});
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  clear() {
    foundry.utils.logCompatibilityWarning("BaseRuler#clear is deprecated in favor of BaseRuler#reset.",
      {since: 13, until: 15, once: true});
    this.reset();
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  update(data) {
    foundry.utils.logCompatibilityWarning("BaseRuler#update is deprecated. Set BaseRuler#path and BaseRuler#hidden instead.",
      {since: 13, until: 15, once: true});
    data ??= {path: [], hidden: false};
    if ( "path" in data ) this.path = data.path;
    if ( "hidden" in data ) this.hidden = data.hidden;
  }
}

/**
 * @import {RulerWaypoint} from "../../../_types.mjs";
 */

/**
 * The default implementation of the Ruler.
 */
class Ruler extends BaseRuler {
  constructor(user) {
    super(user);
    this.#path.visible = false;
  }

  /* -------------------------------------------- */

  /**
   * A handlebars template used to render each waypoint label.
   * @type {string}
   */
  static WAYPOINT_LABEL_TEMPLATE = "templates/hud/waypoint-label.hbs";

  /* -------------------------------------------- */

  /**
   * This element is a Graphics instance which draws the line and points of the measured path.
   * @type {PIXI.Graphics}
   */
  #path = new PIXI.Graphics();

  /* -------------------------------------------- */

  /**
   * An HTML element in the #measurement HUD container containing labels for this TokenRuler instance.
   * @type {HTMLDivElement}
   */
  #labels;

  /* -------------------------------------------- */

  /**
   * The thickness of the waypoint and segment outline in pixels.
   * @type {number}
   */
  #outlineThickness;

  /* -------------------------------------------- */

  /**
   * The color of the waypoint and segment outline.
   * @type {PIXI.ColorSource}
   */
  #outlineColor;

  /* -------------------------------------------- */

  /**
   * Configure the properties of the outline.
   * Called in {@link Ruler#draw}.
   * @returns {{thickness: number; color: PIXI.ColorSource}}    The thickness in pixels and the color
   * @protected
   */
  _configureOutline() {
    const scale = canvas.dimensions.uiScale;
    return {thickness: scale, color: 0x000000};
  }

  /* -------------------------------------------- */

  /** @override */
  async draw() {
    if ( this.constructor.WAYPOINT_LABEL_TEMPLATE ) await getTemplate(this.constructor.WAYPOINT_LABEL_TEMPLATE);
    canvas.controls._rulerPaths.addChild(this.#path);
    const {thickness, color} = this._configureOutline();
    this.#outlineThickness = thickness;
    this.#outlineColor = color;
  }

  /* -------------------------------------------- */

  /** @override */
  destroy() {
    this.#path.destroy({children: true});
    this.#labels?.remove();
    this.#labels = undefined;
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    if ( !this.visible ) {
      this.#path.clear();
      this.#path.visible = false;
      this.#labels?.replaceChildren();
      return;
    }
    const waypoints = this.#getWaypoints();
    this.#drawPath(waypoints);
    this.#renderWaypointLabels(waypoints);
  }

  /* -------------------------------------------- */

  /**
   * Get the waypoints from the ruler path.
   * @returns {RulerWaypoint[]}    The waypoints
   */
  #getWaypoints() {
    const waypoints = [];
    const measurement = canvas.grid.measurePath(this.path);
    for (let i = 0; i < this.path.length; i++) {
      const {x, y, elevation} = this.path[i];
      let ray = null;
      if ( waypoints.length > 0 ) {
        const {x: x0, y: y0} = waypoints.at(-1);
        ray = new Ray({x: x0, y: y0}, {x, y});
      }
      waypoints.push({x, y, elevation, index: i, ray, measurement: measurement.waypoints[i],
        previous: null, next: null});
    }
    for (let i = 0; i < waypoints.length; i++) {
      const waypoint = waypoints[i];
      waypoint.previous = i > 0 ? waypoints[i - 1] : null;
      waypoint.next = i < waypoints.length - 1 ? waypoints[i + 1] : null;
    }
    return waypoints;
  }

  /* -------------------------------------------- */

  /**
   * Render waypoint labels using the defined Ruler.WAYPOINT_LABEL_TEMPLATE handlebars template.
   * @param {RulerWaypoint[]} waypoints     The waypoints to label
   */
  async #renderWaypointLabels(waypoints) {
    this.#labels = this.#getLabelsElement();
    if ( !this.#labels ) return;
    const labels = [];
    const state = {};
    for ( const waypoint of waypoints ) {
      const context = this._getWaypointLabelContext(waypoint, state);
      if ( !context ) continue;
      context.waypoint = waypoint;
      context.waypointType = "distance-ruler";
      context.uiScale ??= canvas.dimensions.uiScale;
      let html = await renderTemplate(this.constructor.WAYPOINT_LABEL_TEMPLATE, context);
      html = foundry.utils.parseHTML(html);
      html.style.setProperty("--position-x", `${context.position.x}px`);
      html.style.setProperty("--position-y", `${context.position.y}px`);
      html.style.setProperty("--ui-scale", context.uiScale);
      labels.push(html);
    }
    this.#labels.replaceChildren(...labels);
  }

  /* -------------------------------------------- */

  /**
   * Create the HTMLDivElement used for labels belonging to this Ruler class.
   * We do this lazily within the scope of a refresh() because the #hud element is created after the Ruler is drawn.
   * TODO could we wait to draw rulers until after the HUD is rendered or render the HUD earlier?
   * @returns {HTMLDivElement|undefined}
   */
  #getLabelsElement() {
    if ( this.#labels?.parent ) return this.#labels; // Cached and in DOM
    const measurement = document.querySelector("#hud #measurement");
    if ( !measurement ) return undefined;
    const rulerId = `distance-ruler-${this.user.id}`;
    let labels = measurement.querySelector(`#${rulerId}`);
    if ( !labels ) {
      labels = document.createElement("div");
      labels.classList.add("ruler-labels", "distance-ruler-labels");
      labels.id = rulerId;
    }
    if ( labels.parent !== measurement ) measurement.appendChild(labels);
    return labels;
  }

  /* -------------------------------------------- */

  /**
   * Get the context used to render a ruler waypoint label.
   * @param {DeepReadonly<RulerWaypoint>} waypoint
   * @param {object} state
   * @returns {object|void}
   * @protected
   */
  _getWaypointLabelContext(waypoint, state) {
    const {index, elevation, previous, ray} = waypoint;
    state.hasElevation ||= (elevation !== 0);
    if ( !previous ) return;
    const deltaElevation = elevation - previous.elevation;
    if ( (ray.distance === 0) && (deltaElevation === 0) ) return;

    // Prepare data structure
    const context = {
      action: {icon: "fa-solid fa-ruler"},
      cssClass: [
        this.hidden ? "secret" : "",
        waypoint.next ? "" : "last"
      ].filterJoin(" "),
      secret: this.hidden,
      units: canvas.grid.units,
      uiScale: canvas.dimensions.uiScale,
      position: {x: ray.B.x, y: ray.B.y - (16 * canvas.dimensions.uiScale)}
    };

    // Segment Distance
    context.distance = {total: waypoint.measurement.distance.toNearest(0.01).toLocaleString(game.i18n.lang)};
    if ( index >= 2 ) context.distance.delta = waypoint.measurement.backward.distance.toNearest(0.01).signedString();

    // Elevation
    context.elevation = {total: elevation, icon: "fa-solid fa-arrows-up-down", hidden: !state.hasElevation};
    if ( deltaElevation !== 0 ) context.elevation.delta = deltaElevation.signedString();

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Draw the path segments and waypoints.
   * @param {RulerWaypoint[]} waypoints    The waypoints
   */
  #drawPath(waypoints) {
    this.#path.clear();
    this.#path.visible = waypoints.length !== 0;
    if ( waypoints.length === 0 ) return;

    // Create draw instructions
    const layer1 = [];
    const layer2 = [];
    const layer3 = [];
    let previousWidth = -1;
    let previousColor = -1;
    const first = waypoints[0];
    if ( first.previous ) {
      const {x: fromX, y: fromY} = first.previous.center;
      layer1.push(g => g.moveTo(fromX, fromY));
      layer2.push(g => g.moveTo(fromX, fromY));
    }
    for ( const waypoint of waypoints ) {

      // Create waypoint draw instruction
      const {radius, color=0xFFFFFF, alpha=1} = this._getWaypointStyle(waypoint);
      if ( alpha === 1 ) {
        layer3.push(g => {
          g.lineStyle(this.#outlineThickness, this.#outlineColor);
          g.beginFill(color, 1, this.#outlineThickness === 0);
          g.drawCircle(waypoint.x, waypoint.y, radius + (this.#outlineThickness / 2));
          g.endFill();
        });
      } else {
        layer3.push(g => {
          g.lineStyle(0);
          g.beginFill(this.#outlineColor, alpha, true);
          g.drawCircle(waypoint.x, waypoint.y, radius + this.#outlineThickness);
          g.endFill();
          g.beginFill(color, alpha, true);
          g.drawCircle(waypoint.x, waypoint.y, radius);
          g.endFill();
        });
      }

      // Create segment draw instruction
      if ( waypoint.previous ) {
        let {width, color=0xFFFFFF, alpha=1} = this._getSegmentStyle(waypoint);
        color = PIXI.Color.shared.setValue(color).toNumber(); // Convert color to a number

        // Create outline draw instruction
        if ( this.#outlineThickness > 0 ) {
          if ( width !== previousWidth ) {
            const style = {
              width: width + (this.#outlineThickness * 2),
              color: this.#outlineColor,
              alpha,
              join: PIXI.LINE_JOIN.ROUND,
              cap: PIXI.LINE_CAP.ROUND
            };
            layer1.push(g => g.lineStyle(style));
          }
          layer1.push(g => g.lineTo(waypoint.x, waypoint.y));
        }

        // Create segment draw instruction
        if ( (width !== previousWidth) || (color !== previousColor) ) {
          const style = {
            width,
            color,
            alpha,
            join: PIXI.LINE_JOIN.ROUND,
            cap: PIXI.LINE_CAP.ROUND
          };
          layer2.push(g => g.lineStyle(style));
        }
        layer2.push(g => g.lineTo(waypoint.x, waypoint.y));

        previousWidth = width;
        previousColor = color;
      } else {
        layer1.push(g => g.moveTo(waypoint.x, waypoint.y));
        layer2.push(g => g.moveTo(waypoint.x, waypoint.y));
      }
    }

    // Execute draw instructions
    for ( const instruction of layer1 ) instruction(this.#path);
    for ( const instruction of layer2 ) instruction(this.#path);
    for ( const instruction of layer3 ) instruction(this.#path);
  }

  /* -------------------------------------------- */

  /**
   * Get the style of the waypoint at the given waypoint.
   * @param {DeepReadonly<RulerWaypoint>} waypoint    The waypoint
   * @returns {{radius: number; color?: PIXI.ColorSource; alpha?: number}}
   *   The radius, color, and alpha of the waypoint
   * @protected
   */
  _getWaypointStyle(waypoint) {
    const scale = canvas.dimensions.uiScale;
    return {radius: 6 * scale, color: this.user.color, alpha: 1};
  }

  /* -------------------------------------------- */

  /**
   * Get the style of the segment from the previous to the given waypoint.
   * @param {DeepReadonly<RulerWaypoint>} waypoint    The waypoint
   * @returns {{width: number, color?: PIXI.ColorSource, alpha?: number}}
   *   The line width, color, and alpha of the segment
   * @protected
   */
  _getSegmentStyle(waypoint) {
    const scale = canvas.dimensions.uiScale;
    return {width: 4 * scale, color: this.user.color, alpha: 1};
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get color() {
    foundry.utils.logCompatibilityWarning("Ruler#color is deprecated. Use Ruler#user#color instead.",
      {since: 13, until: 15, once: true});
    return this.user.color;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get ruler() {
    foundry.utils.logCompatibilityWarning("Ruler#ruler is deprecated without replacement.",
      {since: 13, until: 15, once: true});
    return this.#path;
  }
}

var _module$f = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AlertPing: AlertPing,
  ArrowPing: ArrowPing,
  BaseRuler: BaseRuler,
  ChevronPing: ChevronPing,
  MouseInteractionManager: MouseInteractionManager,
  Ping: Ping,
  PulsePing: PulsePing,
  RenderFlags: RenderFlags,
  RenderFlagsMixin: RenderFlagsMixin,
  Ruler: Ruler,
  types: _types$5
});

/**
 * @import {Color} from "../../../common/utils/_module.mjs";
 */

/**
 * @typedef CanvasAnimationAttribute
 * @property {string} attribute             The attribute name being animated
 * @property {object} parent                The object within which the attribute is stored
 * @property {number|Color} to              The destination value of the attribute
 * @property {number|Color} [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 {"easeInOutCosine"|"easeOutCircle"|"easeInCircle"|
 *   (percentage: number) => number} CanvasAnimationEasingFunction
 */

/**
 * @typedef 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} [time=0]              The current time of the animation, in milliseconds
 * @property {number} [priority]            A priority in PIXI.UPDATE_PRIORITY which defines when the animation
 *                                          should be evaluated related to others
 * @property {CanvasAnimationEasingFunction} [easing] An easing function used to translate animation time or
 *                                                    the string name of a static member of CanvasAnimation
 * @property {(elapsedMS: number, animation: CanvasAnimationData) => void} [ontick]
 *   A callback function which fires after every frame
 * @property {Promise<any>} [wait]          The animation isn't started until this promise resolves
 */

/**
 * @typedef _CanvasAnimationData
 * @property {() => void} fn                            The animation function being executed each frame
 * @property {CanvasAnimationAttribute[]} attributes    The attributes being animated
 * @property {number} state                             The current state of the animation
 *                                                      (see {@link foundry.canvas.animation.CanvasAnimation.STATES})
 * @property {Promise<boolean>} promise                 A Promise which resolves once the animation is complete
 * @property {(completed: boolean) => void} resolve     The resolution function, allowing animation to be ended early
 * @property {(error: Error) => void} reject            The rejection function, allowing animation to be ended early
 */

/**
 * @typedef {_CanvasAnimationData & CanvasAnimationOptions} CanvasAnimationData
 */

var _types$4 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * @typedef 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 {

  /**
   * The Handlebars template used to render Chat Bubbles.
   * @type {string}
   */
  template = "templates/hud/chat-bubble.html";

  /* -------------------------------------------- */

  /**
   * Track active Chat Bubbles
   * @type {object}
   */
  bubbles = {};

  /* -------------------------------------------- */

  /**
   * Track which Token was most recently panned to highlight
   * Use this to avoid repeat panning
   * @type {Token}
   */
  #panned = null;

  /* -------------------------------------------- */

  /**
   * A reference to the chat bubbles HTML container in which rendered bubbles should live
   * @returns {HTMLElement}
   */
  get element() {
    return document.getElementById("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<HTMLElement|null>}   A promise which resolves with the created bubble HTML, or null
   */
  async broadcast(token, message, options={}) {
    if ( token instanceof foundry.canvas.placeables.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<HTMLElement|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;
    const 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$1.implementation.getSpeakerActor({scene: token.scene.id, token: token.id});
    message = await TextEditor$1.implementation.enrichHTML(message, {rollData: actor?.getRollData(),
      secrets: actor?.isOwner ?? game.user.isGM});
    const html = await this.#renderHTML({token, message, cssClasses: cssClasses.join(" ")});
    const options = {cssClasses, requireVisible, pan};

    const allowed = Hooks$1.call("chatBubbleHTML", token, html, message, options);
    if ( allowed === false ) return false;

    /** @deprecated since v13 */
    if ( "chatBubble" in Hooks$1.events ) {
      foundry.utils.logCompatibilityWarning("The chatBubble hook is deprecated. Please use "
        + "chatBubbleHTML instead, which now passes an HTMLElement argument instead of jQuery.",
      { since: 13, until: 15, once: true });
      const allowed = Hooks$1.call("chatBubble", token, $(html), message, options);
      if ( allowed === false ) return null;
    }

    // Set initial dimensions
    const dimensions = this.#getMessageDimensions(message);
    this.#setPosition(token, html, dimensions);

    // Append to DOM
    this.element.append(html);

    // Optionally pan to the speaker
    let panPromise;
    const panToSpeaker = game.settings.get("core", "chatBubblesPan") && (options.pan === true) && (this.#panned !== token);
    if ( panToSpeaker ) {
      const scale = Math.max(1, canvas.stage.scale.x);
      panPromise = 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
    html.style.opacity = 0;
    html.animate({opacity: [0, 1]}, {duration: 250, easing: "ease"}).finished.then(async () => {
      html.style.opacity = 1;
      await panPromise;
      if ( scroll > 0 ) {
        const inner = html.querySelector(".bubble-content");
        inner.animate({top: [0, `${-scroll}px`]}, {duration: duration - 1000, easing: "linear"}).finished.then(
          () => inner.style.top = `${-scroll}px`);
      }
      setTimeout(() => {
        html.animate({opacity: [1, 0]}, {duration: 250, easing: "ease"}).finished.then(() => html.remove());
      }, duration);
    });

    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
   * @returns {Promise<void>}
   */
  async #clearBubble(token) {
    const existing = document.querySelector(`.chat-bubble[data-token-id="${token.id}"]`);
    if ( !existing ) return;
    await existing.animate({opacity: 0}, {duration: 100, easing: "ease"}).finished;
    existing.remove();
  }

  /* -------------------------------------------- */

  /**
   * Render the HTML template for the chat bubble
   * @param {object} data              Template data
   * @returns {Promise<HTMLElement>}   The rendered HTML
   */
  async #renderHTML(data) {
    const html = await foundry.applications.handlebars.renderTemplate(this.template, data);
    return foundry.utils.parseHTML(html);
  }

  /* -------------------------------------------- */

  /**
   * Before displaying the chat message, determine it's constrained and unconstrained dimensions
   * @param {string} message                                            The message content
   * @returns {{width: number; height: number; unconstrained: number}}  The rendered message dimensions
   */
  #getMessageDimensions(message) {
    const div = document.createElement("div");
    div.classList.add("chat-bubble");
    div.style.visibility = "hidden";
    div.innerHTML = foundry.utils.cleanHTML(message);
    document.body.append(div);
    const dims = {
      width: div.clientWidth + (8 * canvas.dimensions.uiScale),
      height: div.clientHeight
    };
    div.style.maxHeight = "none";
    dims.unconstrained = div.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 {HTMLElement} html        Chat bubble content
   * @param {Rectangle} dimensions    Positioning data
   */
  #setPosition(token, html, dimensions) {
    const cls = Math.random() > 0.5 ? "left" : "right";
    html.classList.add(cls);
    const {width, height} = dimensions;
    const top = token.y - dimensions.height - (8 * canvas.dimensions.uiScale);
    const left = cls === "right" ? token.x - (dimensions.width - token.w) : token.x;
    html.style.width = `${width}px`;
    html.style.height = `${height}px`;
    html.style.top = `${top}px`;
    html.style.left = `${left}px`;
  }

  /* -------------------------------------------- */

  /**
   * 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 {HTMLElement} html    The HTML message
   * @returns {number}            The number of milliseconds for which to display the message
   */
  #getDuration(html) {
    const words = html.textContent?.split(/\s+/).reduce((n, w) => n + Number(!!w.trim().length), 0) ?? 0;
    const ms = (words * 60 * 1000) / 300;
    return Math.clamp(1000, ms, 20000);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get container() {
    foundry.utils.logCompatibilityWarning("ChatBubbles#container (jQuery) is deprecated in favor of ChatBubbles#element (HTMLElement)",
      {since: 13, until: 15, once: true});
    return $("#chat-bubbles");
  }
}

var _module$e = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CanvasAnimation: CanvasAnimation,
  ChatBubbles: ChatBubbles,
  SmoothNoise: SmoothNoise,
  types: _types$4
});

var _types$3 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * 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 ( const [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 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);
  }
}

/**
 * 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()}
    
    float getOutlineAlpha() {
      if ( (thickness.x == 0.0) || (thickness.y == 0.0) ) {
        return 0.0;
      }
      
      vec4 curColor;
      float maxAlpha = 0.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);
      }
      return maxAlpha;
    }
    
    void main(void) {
      float dist = distance(vFilterCoord, vec2(0.5)) * 2.0;
      vec4 ownColor = texture2D(uSampler, vTextureCoord);

      float w = wave ? 1.0 : 0.0;    
      vec4 wColor = outlineColor *
                        wcos(0.0, 1.0, dist * 75.0,
                             -time * 0.01 + 3.0 * dot(vec4(1.0), ownColor)) *
                        0.33 * (1.0 - dist) * w;
  
      float texAlpha = smoothstep(alphaThreshold, 1.0, ownColor.a);
      float k = knockout ? 1.0 : 0.0;
      vec4 baseColor = mix(ownColor, wColor, wColor.a);
      vec4 texColor = mix(baseColor, wColor, k) * texAlpha;
  
      float oAlpha = getOutlineAlpha() * (1.0 - texAlpha);
      vec4 oColor = vec4(vec3(outlineColor) * oAlpha, oAlpha);
      gl_FragColor = texColor + oColor;
    }
    `;
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }
}

/**
 * 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);
  }
}

var _module$d = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbstractBaseFilter: AbstractBaseFilter,
  AbstractBaseMaskFilter: AbstractBaseMaskFilter,
  AlphaBlurFilter: AlphaBlurFilter,
  AlphaBlurFilterPass: AlphaBlurFilterPass,
  GlowOverlayFilter: GlowOverlayFilter,
  InvisibilityFilter: InvisibilityFilter,
  OutlineOverlayFilter: OutlineOverlayFilter,
  PrimaryCanvasGroupAmbienceFilter: PrimaryCanvasGroupAmbienceFilter,
  SMAABlendingWeightCalculationFilter: SMAABlendingWeightCalculationFilter,
  SMAAEdgeDetectionFilter: SMAAEdgeDetectionFilter,
  SMAAFilter: SMAAFilter,
  SMAANeighborhoodBlendingFilter: SMAANeighborhoodBlendingFilter,
  TextureTransitionFilter: TextureTransitionFilter,
  VisibilityFilter: VisibilityFilter,
  VisionMaskFilter: VisionMaskFilter,
  VisualEffectsMaskingFilter: VisualEffectsMaskingFilter,
  VoidFilter: VoidFilter,
  WeatherOcclusionMaskFilter: WeatherOcclusionMaskFilter,
  types: _types$3
});

var _module$c = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BatchRenderer: BatchRenderer,
  BatchShaderGenerator: BatchShaderGenerator
});

/**
 * A mixin that decorates a shader or filter and construct a fragment shader according to a chosen channel.
 * @category Mixins
 * @param {typeof PIXI.Shader|PIXI.Filter} ShaderClass The parent ShaderClass class being mixed.
 */
function 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;
}

var _module$b = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AdaptiveFragmentChannelMixin: AdaptiveFragmentChannelMixin,
  BaseShaderMixin: BaseShaderMixin
});

var _module$a = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BLEND_MODES: BLEND_MODES,
  batching: _module$c,
  filters: _module$d,
  mixins: _module$b,
  shaders: _module$o
});

/**
 * @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 foundry.canvas.groups.CanvasVisibility#initializeSources}.
 * @property {boolean} initializeVisionModes    Initialize the active vision modes.
 *                                              See {@link foundry.canvas.groups.CanvasVisibility#initializeVisionMode}.
 * @property {boolean} initializeSounds         Re-initialize the entire ambient sound configuration.
 *                                              See {@link foundry.canvas.layers.SoundsLayer#initializeSources}.
 * @property {boolean} refreshEdges             Recompute intersections between all registered edges.
 *                                              See {@link foundry.canvas.geometry.edges.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
 */

var _types$2 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * @import Semaphore from "@common/utils/semaphore.mjs";
 * @import {CanvasVisibilityTextureConfiguration, FogExplorationData, Point} from "../../_types.mjs";
 * @import FogExploration from "../../documents/fog-exploration.mjs";
 */

/**
 * A fog of war management class which is the singleton canvas.fog instance.
 * @category Canvas
 * @see {EventEmitterMixin}
 */
class FogManager extends EventEmitterMixin() {
  /** @override */
  static emittedEvents = Object.freeze(["explored"]);

  /**
   * 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}
   */
  #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();

  /**
   * The explored data.
   * @type {{
   *   pixels: Uint8ClampedArray,
   *   width: number,
   *   height: number,
   *   offset: number,
   *   stride: number,
   *   buffer: ArrayBuffer,
   *   extracting: boolean
   * }}
   */
  #explored = {
    pixels: new Uint8ClampedArray(0),
    width: 0,
    height: 0,
    offset: 0,
    stride: 1,
    buffer: new ArrayBuffer(0),
    extracting: false
  };

  /* -------------------------------------------- */
  /*  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 {CanvasVisibilityTextureConfiguration}
   */
  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;
  }

  /* -------------------------------------------- */

  /**
   * Is this position explored?
   * @param {Point} position      The position to be tested
   * @returns {boolean}           Is this position explored?
   */
  isPointExplored({x, y}) {
    if ( !this.#initialized ) return false;
    if ( !this.tokenVision ) return true;
    x = (x - this.#explorationSprite.x) / this.#explorationSprite.width;
    y = (y - this.#explorationSprite.y) / this.#explorationSprite.height;
    if ( (x < 0) || (x >= 1) || (y < 0) || (y >= 1) ) return false;
    const {pixels, width, height, offset, stride} = this.#explored;
    const x1 = (x * width) | 0;
    const x0 = x1 > 0 ? x1 - 1 : 0;
    const x2 = x1 < width ? x1 + 2 : width;
    const y1 = (y * height) | 0;
    const y0 = y1 > 0 ? y1 - 1 : 0;
    const y2 = y1 < height ? y1 + 2 : height;
    for ( let y = y0; y < y2; y++ ) {
      const k = y * width;
      for ( let x = x0; x < x2; x++ ) {
        if ( pixels[((k + x) * stride) + offset] !== 0 ) return true;
      }
    }
    return false;
  }

  /* -------------------------------------------- */
  /*  Fog of War Management                       */
  /* -------------------------------------------- */

  /**
   * Extract the pixels from the fog texture.
   * @returns {Promise<void>}
   */
  async #extractPixels() {
    if ( !this.#extractor ) return;
    const texture = this.#explorationSprite?.texture;
    if ( !texture?.valid ) return;
    if ( this.#explored.extracting ) {
      this.#throttleExtractPixels();
      return;
    }
    this.#explored.extracting = true;
    const {realWidth, realHeight} = texture.baseTexture;
    const size = this.#extractor.format === PIXI.FORMATS.RED ? 1 : 4;
    const minBufferSize = realWidth * realHeight * size;
    if ( this.#explored.buffer.byteLength < minBufferSize ) {
      this.#explored.buffer = new ArrayBuffer(minBufferSize);
    }
    try {
      const {pixels, width, height, out} = await this.#extractor.extract(
        {texture, compression: TextureExtractor.COMPRESSION_MODES.NONE, out: this.#explored.buffer});
      this.#explored.extracting = false;

      // Only update explored data only if no new fog has been loaded
      if ( pixels && (this.#explorationSprite?.texture === texture) ) {
        this.#explored.buffer = this.#explored.pixels.buffer; // Swap buffers
        this.#explored.pixels = pixels;
        this.#explored.width = width;
        this.#explored.height = height;
        this.#explored.offset = size - 1;
        this.#explored.stride = size;
        this.#onExploredChanged();
      } else {
        this.#explored.buffer = out;
      }
    } catch(err) {
      this.#explored.extracting = false;
      // FIXME this is needed because for some reason .extract() may throw a boolean false instead of an Error
      throw new Error("Fog of War pixels extraction failed", {cause: err});
    }
  }

  /**
   * A throttled function that extract pixels from the fog texture.
   * @type {function()}
   */
  #throttleExtractPixels = foundry.utils.throttle(this.#extractPixels.bind(this), 500);

  /* -------------------------------------------- */

  /**
   * Called when the explored data changed.
   */
  #onExploredChanged() {
    if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Explored area changed.");
    this.dispatchEvent(new Event("explored"));
  }

  /* -------------------------------------------- */

  /**
   * Create the exploration display object with or without a provided texture.
   * @param {PIXI.Texture|PIXI.RenderTexture} [tex] Optional exploration texture.
   * @returns {SpriteMesh}
   * @internal
   */
  _createExplorationObject(tex) {
    return new SpriteMesh(tex ?? Canvas$1.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: this.textureConfiguration?.format ?? PIXI.FORMATS.RED
        });
        this.#extractor.debug = CONFIG.debug.fog.extractor;
      } 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();
  }

  /* -------------------------------------------- */

  /**
   * Destroy this FogManager.
   */
  destroy() {
    this.exploration = null;
    this.#initialized = false;
    this.#extractor?.destroy();
    this.#extractor = undefined;
    this._updated = false;
    if ( this.#explorationSprite && !this.#explorationSprite.destroyed ) this.#explorationSprite.destroy(true);
    this.#explorationSprite = undefined;
  }

  /* -------------------------------------------- */

  /**
   * 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$1.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;

    this.#throttleExtractPixels();

    if ( !this.exploration ) {
      const fogExplorationCls = getDocumentClass$1("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 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$1("FogExploration");
    this.exploration = await fogExplorationCls.load();

    // Extract and assign the fog data image
    const assign = async (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();
      await this.#extractPixels();
      resolve(tex);
    };

    // Initialize the exploration sprite if no exploration data exists
    if ( !this.exploration ) {
      return new Promise(resolve => {
        assign(Canvas$1.getRenderTexture({
          clearColor: [0, 0, 0, 1],
          textureConfiguration: this.textureConfiguration
        }), resolve);
      });
    }
    // Otherwise load the texture from the exploration data
    return new Promise(resolve => {
      const tex = this.exploration.getTexture();
      if ( tex === null ) assign(Canvas$1.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.
   * @returns {Promise<void>}
   */
  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.
   * @returns {Promise<void>}
   */
  async save() {
    return 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.
   * @returns {Promise<void>}
   */
  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
    this.#extractor.debug = CONFIG.debug.fog.extractor;
    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);
  }

  /* -------------------------------------------- */

  /**
   * Synchronize one user's version of the Fog of War for this scene to other users.
   * Note: This API is experimental and may be removed in later versions *without deprecation*. It is intended for
   * one-time corrections of users' fog explorations, and should not be used for real-time synchronization of fog
   * exploration.
   * @param {User} from        The user whose Fog of War to use as the source of truth.
   * @param {User[]} [to]      A list of users that should have their Fog of War synced. If none are specified then all
   *                           users will be synced.
   * @returns {Promise<void>}  A promise that resolves when synchronization has been completed.
   */
  async sync(from, to) {
    if ( !game.user.isGM ) throw new Error("Only GMs may synchronize Fog of War.");
    if ( !(from instanceof foundry.documents.User) ) throw new Error("Required User to sync from.");
    to = to?.map(u => {
      if ( !(u instanceof foundry.documents.User) ) throw new Error("Required User to sync to.");
      return u.id;
    });
    return new Promise((resolve, reject) => {
      game.socket.emit("syncFog", canvas.scene.id, from.id, { to }, result => {
        if ( result?.error ) reject(new Error(result.error));
        else resolve();
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * 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,
        // TODO Allow for subclasses to change the extracted filetype without needing to override this method
        type: "image/webp",
        quality: 0.8
      });
    } 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", {cause: err});
    }
  }

  /* -------------------------------------------- */

  /**
   * 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;

    // Reset explored data
    if ( this.#explored.pixels.length !== 0 ) {
      this.#explored.pixels = new Uint8ClampedArray(0);
      this.#explored.width = 0;
      this.#explored.height = 0;
      this.#explored.offset = 0;
      this.#explored.stride = 1;
      this.#explored.buffer = new ArrayBuffer(0);
      this.#explored.extracting = false;
      this.#onExploredChanged();
    }
  }

  /* -------------------------------------------- */

  /**
   * If fog of war data is reset from the server, deactivate the current fog and initialize the exploration.
   * @returns {Promise<void>}
   * @internal
   */
  async _handleReset() {
    return 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<void>}
   */
  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();
  }
}

/**
 * @import {CanvasVisibilityTest, CanvasVisibilityTestConfiguration} from "@client/_types.mjs";
 * @import {TokenDetectionMode} from "@common/documents/_types.mjs";
 * @import PointVisionSource from "../sources/point-vision-source.mjs";
 */

/**
 * A Detection Mode which can be associated with any kind of sense/vision/perception.
 * A token could have multiple detection modes.
 */
class DetectionMode extends DataModel {

  /** @override */
  static defineSchema() {
    return {
      id: new StringField({blank: false}),
      label: new StringField({blank: false}),
      tokenConfig: new BooleanField({initial: true}),       // If this DM is available in Token Config UI
      walls: new BooleanField({initial: true}),             // If this DM is constrained by walls
      angle: new BooleanField({initial: true}),             // If this DM is constrained by the vision angle
      type: new 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;

  /* -------------------------------------------- */

  /**
   * The types of the detection mode.
   * @type {Readonly<{SIGHT: number, SOUND: number, MOVE: number, OTHER: number}>}
   */
  static get DETECTION_TYPES() {
    return DetectionMode.#DETECTION_TYPES;
  }

  static #DETECTION_TYPES = 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 {"basicSight"}
   */
  static get BASIC_MODE_ID() {
    return "basicSight";
  }

  /* -------------------------------------------- */
  /*  Visibility Testing                          */
  /* -------------------------------------------- */

  /**
   * Test visibility of a target object or array of points for a specific vision source.
   * @param {PointVisionSource} visionSource             The vision source being tested
   * @param {TokenDetectionMode} mode                    The detection mode configuration
   * @param {CanvasVisibilityTestConfiguration} 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 PointVisionSource theoretically detect a certain object based on its properties?
   * This check should not consider the relative positions of either object, only their state.
   * @param {PointVisionSource} visionSource   The vision source being tested
   * @param {object|null} 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$1 ) {
      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 {PointVisionSource} visionSource      The vision source being tested
   * @param {TokenDetectionMode} mode             The detection mode configuration
   * @param {object|null} 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 {PointVisionSource} visionSource  The vision source being tested
   * @param {TokenDetectionMode} mode         The detection mode configuration
   * @param {object|null} 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(
        visionSource.origin,
        test.point,
        { type, mode: "any", source: visionSource, useThreshold: true, priority: visionSource.priority }
      );
    }
    // 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 {PointVisionSource} visionSource  The vision source being tested
   * @param {TokenDetectionMode} mode         The detection mode configuration
   * @param {object|null} 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 {PointVisionSource} visionSource      The vision source being tested
   * @param {TokenDetectionMode} mode             The detection mode configuration
   * @param {object|null} 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) {
    const range = mode.range;
    if ( range <= 0 ) return false;
    if ( range === Infinity ) return true;
    const point = test.point;
    const {x, y} = visionSource.data;
    const radius = visionSource.object.getLightRadius(range);
    const dx = point.x - x;
    const dy = point.y - y;
    return ((dx * dx) + (dy * dy)) <= (radius * radius);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v13
   * @ignore
   */
  get BASIC_MODE_ID() {
    foundry.utils.logCompatibilityWarning(`${this.constructor.name}#BASIC_MODE_ID is deprecated. `
      + `Please use ${this.constructor.name}.BASIC_MODE_ID instead.`,
      {since: 13, until: 15, once: true});
    return DetectionMode.BASIC_MODE_ID;
  }
}

/**
 * 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$1 ) {
      const tgt = target.document;
      if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    }
    return true;
  }
}

/**
 * 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$1 ) {
      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);
  }
}

/**
 * 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$1) ) 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$1) ) 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;
  }
}

/**
 * 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 DetectionModeDarkvision 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$1 ) {
      const tgt = target.document;
      if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE)
        || tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    }
    return true;
  }
}

var _module$9 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  DetectionMode: DetectionMode,
  DetectionModeAll: DetectionModeAll,
  DetectionModeDarkvision: DetectionModeDarkvision,
  DetectionModeInvisibility: DetectionModeInvisibility,
  DetectionModeLightPerception: DetectionModeLightPerception,
  DetectionModeTremor: DetectionModeTremor,
  FogManager: FogManager,
  PerceptionManager: PerceptionManager,
  VisionMode: VisionMode,
  types: _types$2
});

/** @module canvas */


// Shim access to deprecated `tokens` property
const _tokens = {};
/**
 * @namespace
 * @deprecated since v13
 * @ignore
 * @see {@link foundry.canvas.placeables.tokens}
 */
const tokens = new Proxy(_tokens, {
  get(_, prop) {
    foundry.utils.logCompatibilityWarning(
      'You are accessing "foundry.canvas.tokens" which is now namespaced under foundry.canvas.placeables.tokens',
      {since: 13, until: 15, once: true}
    );
    return foundry.canvas.placeables.tokens[prop];
  }
});

var _module$8 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Canvas: Canvas$1,
  FramebufferSnapshot: FramebufferSnapshot,
  SceneManager: SceneManager,
  TextureExtractor: TextureExtractor,
  TextureLoader: TextureLoader,
  animation: _module$e,
  containers: _module$p,
  extensions: _module$s,
  geometry: _module$g,
  getTexture: getTexture,
  groups: _module$n,
  interaction: _module$f,
  layers: _module$m,
  loadTexture: loadTexture,
  perception: _module$9,
  placeables: _module$j,
  primary: _module$i,
  rendering: _module$a,
  sources: _module$r,
  srcExists: srcExists,
  tokens: tokens,
  workers: _module$q
});

/**
 * @import {RollTermData} from "./_types.mjs";
 * @import {RollParseNode} from "../_types.mjs";
 * @import RollResolver from "../../applications/dice/roll-resolver.mjs";
 */

/**
 * An abstract class which represents a single token that can be used as part of a Roll formula.
 * Every portion of a Roll formula is parsed into a subclass of RollTerm in order for the Roll to be fully evaluated.
 */
class RollTerm {

  /**
   * @param {object} [termData]
   * @param {object} [termData.options] An object of additional options which describes and modifies the term.
   */
  constructor({options={}}={}) {
    this.options = options;
  }

  /**
   * An object of additional options which describes and modifies the term.
   * @type {object}
   */
  options;

  /**
   * An internal flag for whether the term has been evaluated
   * @type {boolean}
   * @internal
   */
  _evaluated = false;

  /**
   * A reference to the Roll at the root of the evaluation tree.
   * @type {Roll}
   * @internal
   */
  _root;

  /**
   * Is this term intermediate, and should be evaluated first as part of the simplification process?
   * @type {boolean}
   */
  isIntermediate = false;

  /**
   * A regular expression pattern which identifies optional term-level flavor text
   * @type {string}
   */
  static FLAVOR_REGEXP_STRING = "(?:\\[([^\\]]+)\\])";

  /**
   * A regular expression which identifies term-level flavor text
   * @type {RegExp}
   */
  static FLAVOR_REGEXP = new RegExp(RollTerm.FLAVOR_REGEXP_STRING, "g");

  /**
   * A regular expression used to match a term of this type
   * @type {RegExp}
   */
  static REGEXP = undefined;

  /**
   * An array of additional attributes which should be retained when the term is serialized
   * @type {string[]}
   */
  static SERIALIZE_ATTRIBUTES = [];

  /* -------------------------------------------- */
  /*  RollTerm Attributes                         */
  /* -------------------------------------------- */

  /**
   * A string representation of the formula expression for this RollTerm, prior to evaluation.
   * @type {string}
   */
  get expression() {
    throw new Error(`The ${this.constructor.name} class must implement the expression attribute`);
  }

  /**
   * A string representation of the formula, including optional flavor text.
   * @type {string}
   */
  get formula() {
    let f = this.expression;
    if ( this.flavor ) f += `[${this.flavor}]`;
    return f;
  }

  /**
   * A string or numeric representation of the final output for this term, after evaluation.
   * @type {number|string|void}
   */
  get total() {
    throw new Error(`The ${this.constructor.name} class must implement the total attribute`);
  }

  /**
   * Optional flavor text which modifies and describes this term.
   * @type {string}
   */
  get flavor() {
    return this.options.flavor || "";
  }

  /**
   * Whether this term is entirely deterministic or contains some randomness.
   * @type {boolean}
   */
  get isDeterministic() {
    return true;
  }

  /**
   * A reference to the RollResolver app being used to externally resolve this term.
   * @type {RollResolver}
   */
  get resolver() {
    return this._root?._resolver;
  }

  /* -------------------------------------------- */
  /*  RollTerm Methods                            */
  /* -------------------------------------------- */

  /**
   * Evaluate the term, processing its inputs and finalizing its total.
   * @param {object} [options={}]                   Options which modify how the RollTerm is evaluated
   * @param {boolean} [options.minimize=false]      Minimize the result, obtaining the smallest possible value.
   * @param {boolean} [options.maximize=false]      Maximize the result, obtaining the largest possible value.
   * @param {boolean} [options.allowStrings=false]  If true, string terms will not throw an error when evaluated.
   * @returns {Promise<RollTerm>|RollTerm}          Returns a Promise if the term is non-deterministic.
   */
  evaluate(options={}) {
    if ( this._evaluated ) {
      throw new Error(`The ${this.constructor.name} has already been evaluated and is now immutable`);
    }
    this._evaluated = true;
    return this._evaluate(options);
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the term.
   * @param {object} [options={}]           Options which modify how the RollTerm is evaluated, see RollTerm#evaluate
   * @returns {Promise<RollTerm>|RollTerm}  Returns a Promise if the term is non-deterministic.
   * @protected
   */
  _evaluate(options={}) {
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Determine if evaluating a given RollTerm with certain evaluation options can be done so deterministically.
   * @param {RollTerm} term               The term.
   * @param {object} [options]            Options for evaluating the term.
   * @param {boolean} [options.maximize]  Force the result to be maximized.
   * @param {boolean} [options.minimize]  Force the result to be minimized.
   */
  static isDeterministic(term, { maximize, minimize }={}) {
    return maximize || minimize || term.isDeterministic;
  }

  /* -------------------------------------------- */
  /*  Serialization and Loading                   */
  /* -------------------------------------------- */

  /**
   * Construct a RollTerm from a provided data object
   * @param {RollTermData} data Provided data from an un-serialized term
   * @returns {RollTerm}        The constructed RollTerm
   */
  static fromData(data) {
    let cls = CONFIG.Dice.termTypes[data.class];
    if ( !cls ) {
      cls = Object.values(CONFIG.Dice.terms).find(c => c.name === data.class) || foundry.dice.terms.Die;
    }
    return cls._fromData(data);
  }

  /* -------------------------------------------- */

  /**
   * Construct a RollTerm from parser information.
   * @param {RollParseNode} node  The node.
   * @returns {RollTerm}
   */
  static fromParseNode(node) {
    return this.fromData(deepClone(node));
  }

  /* -------------------------------------------- */

  /**
   * Define term-specific logic for how a de-serialized data object is restored as a functional RollTerm
   * @param {RollTermData} data The de-serialized term data
   * @returns {RollTerm}        The re-constructed RollTerm object
   * @protected
   */
  static _fromData(data) {
    if ( data.roll && !(data.roll instanceof Roll) ) data.roll = Roll.fromData(data.roll);
    const term = new this(data);
    term._evaluated = data.evaluated ?? true;
    return term;
  }

  /* -------------------------------------------- */

  /**
   * Reconstruct a RollTerm instance from a provided JSON string
   * @param {string} json   A serialized JSON representation of a DiceTerm
   * @returns {RollTerm}    A reconstructed RollTerm from the provided JSON
   */
  static fromJSON(json) {
    let data;
    try {
      data = JSON.parse(json);
    } catch(err) {
      throw new Error("You must pass a valid JSON string");
    }
    return this.fromData(data);
  }

  /* -------------------------------------------- */

  /**
   * Serialize the RollTerm to a JSON string which allows it to be saved in the database or embedded in text.
   * This method should return an object suitable for passing to the JSON.stringify function.
   * @returns {RollTermData}
   */
  toJSON() {
    const data = {
      class: this.constructor.name,
      options: this.options,
      evaluated: this._evaluated
    };
    for ( const attr of this.constructor.SERIALIZE_ATTRIBUTES ) {
      data[attr] = this[attr];
    }
    return data;
  }
}

/**
 * @import {DiceTermResult} from "../_types.mjs";
 */

/**
 * An abstract base class for any type of RollTerm which involves randomized input from dice, coins, or other devices.
 */
class DiceTerm extends RollTerm {
  /**
   * @param {object} termData                  Data used to create the Dice Term, including the following:
   * @param {number|Roll} [termData.number=1]  The number of dice of this term to roll, before modifiers are applied, or
   *                                           a Roll instance that will be evaluated to a number.
   * @param {number|Roll} [termData.faces=6]   The number of faces on each die of this type, or a Roll instance that
   *                                           will be evaluated to a number.
   * @param {string} termData.method           The resolution method used to resolve DiceTerm.
   * @param {string[]} [termData.modifiers]    An array of modifiers applied to the results
   * @param {DiceTermResult[]} [termData.results]      An optional array of pre-cast results for the term
   * @param {object} [termData.options]        Additional options that modify the term
   */
  constructor({number=1, faces=6, method, modifiers=[], results=[], options={}}) {
    super({options});

    this._number = number;
    this._faces = faces;
    this.method = method;
    this.modifiers = modifiers;
    this.results = results;

    // If results were explicitly passed, the term has already been evaluated
    if ( results.length ) this._evaluated = true;
  }

  /* -------------------------------------------- */

  /**
   * The resolution method used to resolve this DiceTerm.
   * @type {string}
   */
  get method() {
    return this.#method;
  }

  set method(method) {
    if ( this.#method || !(method in CONFIG.Dice.fulfillment.methods) ) return;
    this.#method = method;
  }

  #method;

  /**
   * An Array of dice term modifiers which are applied
   * @type {string[]}
   */
  modifiers;

  /**
   * The array of dice term results which have been rolled
   * @type {DiceTermResult[]}
   */
  results;

  /**
   * Define the denomination string used to register this DiceTerm type in CONFIG.Dice.terms
   * @type {string}
   */
  static DENOMINATION = "";

  /**
   * Define the named modifiers that can be applied for this particular DiceTerm type.
   * @type {Record<string, string|Function>}
   */
  static MODIFIERS = {};

  /**
   * A regular expression pattern which captures the full set of term modifiers
   * Anything until a space, group symbol, or arithmetic operator
   * @type {string}
   */
  static MODIFIERS_REGEXP_STRING = "([^ (){}[\\]+\\-*/]+)";

  /**
   * A regular expression used to separate individual modifiers
   * @type {RegExp}
   */
  static MODIFIER_REGEXP = /([A-z]+)([^A-z\s()+\-*/]+)?/g;

  /** @inheritDoc */
  static REGEXP = new RegExp(`^([0-9]+)?[dD]([A-z]|[0-9]+)${this.MODIFIERS_REGEXP_STRING}?${this.FLAVOR_REGEXP_STRING}?$`);

  /** @inheritDoc */
  static SERIALIZE_ATTRIBUTES = ["number", "faces", "modifiers", "results", "method"];

  /* -------------------------------------------- */
  /*  Dice Term Attributes                        */
  /* -------------------------------------------- */

  /**
   * The number of dice of this term to roll. Returns undefined if the number is a complex term that has not yet been
   * evaluated.
   * @type {number|void}
   */
  get number() {
    if ( typeof this._number === "number" ) return this._number;
    else if ( this._number?._evaluated ) return this._number.total;
  }

  /**
   * The number of dice of this term to roll, before modifiers are applied, or a Roll instance that will be evaluated to
   * a number.
   * @type {number|Roll}
   * @protected
   */
  _number;

  /**
   * @param {number|Roll} value
   */
  set number(value) {
    this._number = value;
  }

  /* -------------------------------------------- */

  /**
   * The number of faces on the die. Returns undefined if the faces are represented as a complex term that has not yet
   * been evaluated.
   * @type {number|void}
   */
  get faces() {
    if ( typeof this._faces === "number" ) return this._faces;
    else if ( this._faces?._evaluated ) return this._faces.total;
  }

  /**
   * The number of faces on the die, or a Roll instance that will be evaluated to a number.
   * @type {number|Roll}
   * @protected
   */
  _faces;

  /**
   * @param {number|Roll} value
   */
  set faces(value) {
    this._faces = value;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get expression() {
    const x = this.constructor.DENOMINATION === "d" ? this._faces : this.constructor.DENOMINATION;
    return `${this._number}d${x}${this.modifiers.join("")}`;
  }

  /* -------------------------------------------- */

  /**
   * The denomination of this DiceTerm instance.
   * @type {string}
   */
  get denomination() {
    return this.constructor.DENOMINATION;
  }

  /* -------------------------------------------- */

  /**
   * An array of additional DiceTerm instances involved in resolving this DiceTerm.
   * @type {DiceTerm[]}
   */
  get dice() {
    const dice = [];
    if ( this._number instanceof Roll ) dice.push(...this._number.dice);
    if ( this._faces instanceof Roll ) dice.push(...this._faces.dice);
    return dice;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get total() {
    if ( !this._evaluated ) return undefined;
    let total = this.results.reduce((t, r) => {
      if ( !r.active ) return t;
      if ( r.count !== undefined ) return t + r.count;
      else return t + r.result;
    }, 0);
    if ( this.number < 0 ) total *= -1;
    return total;
  }

  /* -------------------------------------------- */

  /**
   * Return an array of rolled values which are still active within this term
   * @type {number[]}
   */
  get values() {
    return this.results.reduce((arr, r) => {
      if ( !r.active ) return arr;
      arr.push(r.result);
      return arr;
    }, []);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get isDeterministic() {
    return false;
  }

  /* -------------------------------------------- */
  /*  Dice Term Methods                           */
  /* -------------------------------------------- */

  /**
   * Alter the DiceTerm by adding or multiplying the number of dice which are rolled
   * @param {number} multiply   A factor to multiply. Dice are multiplied before any additions.
   * @param {number} add        A number of dice to add. Dice are added after multiplication.
   * @returns {DiceTerm}        The altered term
   */
  alter(multiply, add) {
    if ( this._evaluated ) throw new Error("You may not alter a DiceTerm after it has already been evaluated");
    multiply = Number.isFinite(multiply) && (multiply >= 0) ? multiply : 1;
    add = Number.isInteger(add) ? add : 0;
    if ( multiply >= 0 ) {
      if ( this._number instanceof Roll ) this._number = Roll.create(`(${this._number} * ${multiply})`);
      else this._number = Math.round(this.number * multiply);
    }
    if ( add ) {
      if ( this._number instanceof Roll ) this._number = Roll.create(`(${this._number} + ${add})`);
      else this._number += add;
    }
    return this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _evaluate(options={}) {
    if ( RollTerm.isDeterministic(this, options) ) return this._evaluateSync(options);
    return this._evaluateAsync(options);
  }

  /* -------------------------------------------- */

  /**
   * Evaluate this dice term asynchronously.
   * @param {object} [options]  Options forwarded to inner Roll evaluation.
   * @returns {Promise<DiceTerm>}
   * @protected
   */
  async _evaluateAsync(options={}) {
    for ( const roll of [this._faces, this._number] ) {
      if ( !(roll instanceof Roll) ) continue;
      if ( this._root ) roll._root = this._root;
      await roll.evaluate(options);
    }
    if ( Math.abs(this.number) > 999 ) {
      throw new Error("You may not evaluate a DiceTerm with more than 999 requested results");
    }
    // If this term was an intermediate term, it has not yet been added to the resolver, so we add it here.
    if ( this.resolver && !this._id ) await this.resolver.addTerm(this);
    for ( let n = this.results.length; n < Math.abs(this.number); n++ ) await this.roll(options);
    await this._evaluateModifiers();
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Evaluate deterministic values of this term synchronously.
   * @param {object} [options]
   * @param {boolean} [options.maximize]  Force the result to be maximized.
   * @param {boolean} [options.minimize]  Force the result to be minimized.
   * @param {boolean} [options.strict]    Throw an error if attempting to evaluate a die term in a way that cannot be
   *                                      done synchronously.
   * @returns {DiceTerm}
   * @protected
   */
  _evaluateSync(options={}) {
    if ( this._faces instanceof Roll ) this._faces.evaluateSync(options);
    if ( this._number instanceof Roll ) this._number.evaluateSync(options);
    if ( Math.abs(this.number) > 999 ) {
      throw new Error("You may not evaluate a DiceTerm with more than 999 requested results");
    }
    for ( let n = this.results.length; n < Math.abs(this.number); n++ ) {
      const roll = { active: true };
      if ( options.minimize ) roll.result = Math.min(1, this.faces);
      else if ( options.maximize ) roll.result = this.faces;
      else if ( options.strict ) throw new Error("Cannot synchronously evaluate a non-deterministic term.");
      else continue;
      this.results.push(roll);
    }
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Roll the DiceTerm by mapping a random uniform draw against the faces of the dice term.
   * @param {object} [options={}]                 Options which modify how a random result is produced
   * @param {boolean} [options.minimize=false]    Minimize the result, obtaining the smallest possible value.
   * @param {boolean} [options.maximize=false]    Maximize the result, obtaining the largest possible value.
   * @returns {Promise<DiceTermResult>}           The produced result
   */
  async roll({minimize=false, maximize=false, ...options}={}) {
    const roll = {result: undefined, active: true};
    roll.result = await this._roll(options);
    if ( minimize ) roll.result = Math.min(1, this.faces);
    else if ( maximize ) roll.result = this.faces;
    else if ( roll.result === undefined ) roll.result = this.randomFace();
    this.results.push(roll);
    return roll;
  }

  /* -------------------------------------------- */

  /**
   * Generate a roll result value for this DiceTerm based on its fulfillment method.
   * @param {object} [options]        Options forwarded to the fulfillment method handler.
   * @returns {Promise<number|void>}  Returns a Promise that resolves to the fulfilled number, or undefined if it could
   *                                  not be fulfilled.
   * @protected
   */
  async _roll(options={}) {
    return this.#invokeFulfillmentHandler(options);
  }

  /* -------------------------------------------- */

  /**
   * Invoke the configured fulfillment handler for this term to produce a result value.
   * @param {object} [options]        Options forwarded to the fulfillment method handler.
   * @returns {Promise<number|void>}  Returns a Promise that resolves to the fulfilled number, or undefined if it could
   *                                  not be fulfilled.
   */
  async #invokeFulfillmentHandler(options={}) {
    const config = game.settings.get("core", Roll.DICE_CONFIGURATION_SETTING);
    const method = config[this.denomination] || CONFIG.Dice.fulfillment.defaultMethod;
    if ( (method === "manual") && !game.user.hasPermission("MANUAL_ROLLS") ) return;
    const { handler, interactive } = CONFIG.Dice.fulfillment.methods[method] ?? {};
    if ( interactive && this.resolver ) return this.resolver.resolveResult(this, method, options);
    return handler?.(this, options);
  }

  /* -------------------------------------------- */

  /**
   * Maps a randomly-generated value in the interval [0, 1) to a face value on the die.
   * @param {number} randomUniform  A value to map. Must be in the interval [0, 1).
   * @returns {number}              The face value.
   */
  mapRandomFace(randomUniform) {
    return Math.ceil((1 - randomUniform) * this.faces);
  }

  /* -------------------------------------------- */

  /**
   * Generate a random face value for this die using the configured PRNG.
   * @returns {number}
   */
  randomFace() {
    return this.mapRandomFace(CONFIG.Dice.randomUniform());
  }

  /* -------------------------------------------- */

  /**
   * Return a string used as the label for each rolled result
   * @param {DiceTermResult} result     The rolled result
   * @returns {string}                   The result label
   */
  getResultLabel(result) {
    return String(result.result);
  }

  /* -------------------------------------------- */

  /**
   * Get the CSS classes that should be used to display each rolled result
   * @param {DiceTermResult} result The rolled result
   * @returns {(string|null)[]}     The desired classes
   */
  getResultCSS(result) {
    const hasSuccess = result.success !== undefined;
    const hasFailure = result.failure !== undefined;
    const isMax = result.result === this.faces;
    const isMin = result.result === 1;
    return [
      this.constructor.name.toLowerCase(),
      `d${this.faces}`,
      result.success ? "success" : null,
      result.failure ? "failure" : null,
      result.rerolled ? "rerolled" : null,
      result.exploded ? "exploded" : null,
      result.discarded ? "discarded" : null,
      !(hasSuccess || hasFailure) && isMin ? "min" : null,
      !(hasSuccess || hasFailure) && isMax ? "max" : null
    ];
  }

  /* -------------------------------------------- */

  /**
   * Render the tooltip HTML for a Roll instance
   * @returns {object}      The data object used to render the default tooltip template for this DiceTerm
   */
  getTooltipData() {
    const { total, faces, flavor } = this;
    const method = CONFIG.Dice.fulfillment.methods[this.method];
    const icon = method?.interactive ? (method.icon ?? '<i class="fa-solid fa-bluetooth"></i>') : null;
    return {
      total, faces, flavor, icon,
      method: method?.label,
      formula: this.expression,
      rolls: this.results.map(r => {
        return {
          result: this.getResultLabel(r),
          classes: this.getResultCSS(r).filterJoin(" ")
        };
      })
    };
  }

  /* -------------------------------------------- */
  /*  Modifier Methods                            */
  /* -------------------------------------------- */

  /**
   * Sequentially evaluate each dice roll modifier by passing the term to its evaluation function
   * Augment or modify the results array.
   * @internal
   */
  async _evaluateModifiers() {
    const cls = this.constructor;
    const requested = foundry.utils.deepClone(this.modifiers);
    this.modifiers = [];

    // Sort modifiers from longest to shortest to ensure that the matching algorithm greedily matches the longest
    // prefixes first.
    const allModifiers = Object.keys(cls.MODIFIERS).sort((a, b) => b.length - a.length);

    // Iterate over requested modifiers
    for ( const m of requested ) {
      let command = m.match(/[A-z]+/)[0].toLowerCase();

      // Matched command
      if ( command in cls.MODIFIERS ) {
        await this._evaluateModifier(command, m);
        continue;
      }

      // Unmatched compound command
      while ( command ) {
        let matched = false;
        for ( const modifier of allModifiers ) {
          if ( command.startsWith(modifier) ) {
            matched = true;
            await this._evaluateModifier(modifier, modifier);
            command = command.replace(modifier, "");
            break;
          }
        }
        if ( !matched ) command = "";
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Asynchronously evaluate a single modifier command, recording it in the array of evaluated modifiers
   * @param {string} command        The parsed modifier command
   * @param {string} modifier       The full modifier request
   * @internal
   */
  async _evaluateModifier(command, modifier) {
    let fn = this.constructor.MODIFIERS[command];
    if ( typeof fn === "string" ) fn = this[fn];
    if ( fn instanceof Function ) {
      const result = await fn.call(this, modifier);
      const earlyReturn = (result === false) || (result === this); // Handling this is backwards compatibility
      if ( !earlyReturn ) this.modifiers.push(modifier.toLowerCase());
    }
  }

  /* -------------------------------------------- */

  /**
   * A helper comparison function.
   * Returns a boolean depending on whether the result compares favorably against the target.
   * @param {number} result         The result being compared
   * @param {string} comparison     The comparison operator in [=,&lt;,&lt;=,>,>=]
   * @param {number} target         The target value
   * @returns {boolean}             Is the comparison true?
   */
  static compareResult(result, comparison, target) {
    switch ( comparison ) {
      case "=":
        return result === target;
      case "<":
        return result < target;
      case "<=":
        return result <= target;
      case ">":
        return result > target;
      case ">=":
        return result >= target;
    }
  }

  /* -------------------------------------------- */

  /**
   * A helper method to modify the results array of a dice term by flagging certain results are kept or dropped.
   * @param {object[]} results      The results array
   * @param {number} number         The number to keep or drop
   * @param {object} [options]
   * @param {boolean} [options.keep]        Keep results?
   * @param {boolean} [options.highest]     Keep the highest?
   * @returns {object[]}            The modified results array
   */
  static _keepOrDrop(results, number, {keep=true, highest=true}={}) {

    // Sort remaining active results in ascending (keep) or descending (drop) order
    const ascending = keep === highest;
    const values = results.reduce((arr, r) => {
      if ( r.active ) arr.push(r.result);
      return arr;
    }, []).sort((a, b) => ascending ? a - b : b - a);

    // Determine the cut point, beyond which to discard
    number = Math.clamp(keep ? values.length - number : number, 0, values.length);
    const cut = values[number];

    // Track progress
    let discarded = 0;
    const ties = [];
    const comp = ascending ? "<" : ">";

    // First mark results on the wrong side of the cut as discarded
    results.forEach(r => {
      if ( !r.active ) return;  // Skip results which have already been discarded
      const discard = this.compareResult(r.result, comp, cut);
      if ( discard ) {
        r.discarded = true;
        r.active = false;
        discarded++;
      }
      else if ( r.result === cut ) ties.push(r);
    });

    // Next discard ties until we have reached the target
    ties.forEach(r => {
      if ( discarded < number ) {
        r.discarded = true;
        r.active = false;
        discarded++;
      }
    });
    return results;
  }

  /* -------------------------------------------- */

  /**
   * A reusable helper function to handle the identification and deduction of failures
   */
  static _applyCount(results, comparison, target, {flagSuccess=false, flagFailure=false}={}) {
    for ( const r of results ) {
      const success = this.compareResult(r.result, comparison, target);
      if (flagSuccess) {
        r.success = success;
        if (success) delete r.failure;
      }
      else if (flagFailure ) {
        r.failure = success;
        if (success) delete r.success;
      }
      r.count = success ? 1 : 0;
    }
  }

  /* -------------------------------------------- */

  /**
   * A reusable helper function to handle the identification and deduction of failures
   */
  static _applyDeduct(results, comparison, target, {deductFailure=false, invertFailure=false}={}) {
    for ( const r of results ) {

      // Flag failures if a comparison was provided
      if (comparison) {
        const fail = this.compareResult(r.result, comparison, target);
        if ( fail ) {
          r.failure = true;
          delete r.success;
        }
      }

      // Otherwise treat successes as failures
      else {
        if ( r.success === false ) {
          r.failure = true;
          delete r.success;
        }
      }

      // Deduct failures
      if ( deductFailure ) {
        if ( r.failure ) r.count = -1;
      }
      else if ( invertFailure ) {
        if ( r.failure ) r.count = -1 * r.result;
      }
    }
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Determine whether a string expression matches this type of term
   * @param {string} expression               The expression to parse
   * @param {object} [options={}]             Additional options which customize the match
   * @param {boolean} [options.imputeNumber=true]  Allow the number of dice to be optional, i.e. "d6"
   * @returns {RegExpMatchArray|null}
   */
  static matchTerm(expression, {imputeNumber=true}={}) {
    const match = expression.match(this.REGEXP);
    if ( !match ) return null;
    if ( (match[1] === undefined) && !imputeNumber ) return null;
    return match;
  }

  /* -------------------------------------------- */

  /**
   * Construct a term of this type given a matched regular expression array.
   * @param {RegExpMatchArray} match          The matched regular expression array
   * @returns {DiceTerm}                      The constructed term
   */
  static fromMatch(match) {
    let [number, denomination, modifiers, flavor] = match.slice(1);

    // Get the denomination of DiceTerm
    denomination = denomination.toLowerCase();
    const cls = denomination in CONFIG.Dice.terms ? CONFIG.Dice.terms[denomination] : CONFIG.Dice.terms.d;
    if ( !foundry.utils.isSubclass(cls, foundry.dice.terms.DiceTerm) ) {
      throw new Error(`DiceTerm denomination ${denomination} not registered to CONFIG.Dice.terms as a valid DiceTerm class`);
    }

    // Get the term arguments
    number = Number.isNumeric(number) ? parseInt(number) : 1;
    const faces = Number.isNumeric(denomination) ? parseInt(denomination) : null;

    // Match modifiers
    modifiers = Array.from((modifiers || "").matchAll(this.MODIFIER_REGEXP)).map(m => m[0]);

    // Construct a term of the appropriate denomination
    return new cls({number, faces, modifiers, options: {flavor}});
  }

  /* -------------------------------------------- */

  /** @override */
  static fromParseNode(node) {
    let { number, faces } = node;
    let denomination = "d";
    if ( number === null ) number = 1;
    if ( number.class ) {
      number = Roll.defaultImplementation.fromTerms(Roll.defaultImplementation.instantiateAST(number));
    }
    if ( typeof faces === "string" ) denomination = faces.toLowerCase();
    else if ( faces.class ) {
      faces = Roll.defaultImplementation.fromTerms(Roll.defaultImplementation.instantiateAST(faces));
    }
    const modifiers = Array.from((node.modifiers || "").matchAll(this.MODIFIER_REGEXP)).map(([m]) => m);
    const cls = CONFIG.Dice.terms[denomination];
    const data = { ...node, number, modifiers, class: cls.name };
    if ( denomination === "d" ) data.faces = faces;
    return this.fromData(data);
  }

  /* -------------------------------------------- */
  /*  Serialization & Loading                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  toJSON() {
    const data = super.toJSON();
    if ( this._number instanceof Roll ) data._number = this._number.toJSON();
    if ( this._faces instanceof Roll ) data._faces = this._faces.toJSON();
    return data;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static _fromData(data) {
    if ( data._number ) data.number = Roll.fromData(data._number);
    if ( data._faces ) data.faces = Roll.fromData(data._faces);
    return super._fromData(data);
  }
}

/**
 * A type of DiceTerm used to represent flipping a two-sided coin.
 */
class Coin extends DiceTerm {
  constructor(termData) {
    termData.faces = 2;
    super(termData);
  }

  /** @inheritdoc */
  static DENOMINATION = "c";

  /** @inheritdoc */
  static MODIFIERS = {
    c: "call"
  };

  /* -------------------------------------------- */

  /** @inheritdoc */
  async roll({minimize=false, maximize=false, ...options}={}) {
    /** @type {result: number; active: boolean} */
    const roll = {result: undefined, active: true};
    if ( minimize ) roll.result = 0;
    else if ( maximize ) roll.result = 1;
    else roll.result = await this._roll(options);
    if ( roll.result === undefined ) roll.result = this.randomFace();
    this.results.push(roll);
    return roll;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getResultLabel(result) {
    return {
      0: "T",
      1: "H"
    }[result.result];
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getResultCSS(result) {
    return [
      this.constructor.name.toLowerCase(),
      result.result === 1 ? "heads" : "tails",
      result.success ? "success" : null,
      result.failure ? "failure" : null
    ];
  }

  /* -------------------------------------------- */

  /** @override */
  mapRandomFace(randomUniform) {
    return Math.round(randomUniform);
  }

  /* -------------------------------------------- */
  /*  Term Modifiers                              */
  /* -------------------------------------------- */

  /**
   * Call the result of the coin flip, marking any coins that matched the called target as a success
   * 3dcc1      Flip 3 coins and treat "heads" as successes
   * 2dcc0      Flip 2 coins and treat "tails" as successes
   * @param {string} modifier     The matched modifier query
   */
  call(modifier) {

    // Match the modifier
    const rgx = /c([01])/i;
    const match = modifier.match(rgx);
    if ( !match ) return false;
    let [target] = match.slice(1);
    target = parseInt(target);

    // Treat each result which matched the call as a success
    for ( const r of this.results ) {
      const match = r.result === target;
      r.count = match ? 1 : 0;
      r.success = match;
    }
  }
}

/**
 * A type of DiceTerm used to represent rolling a fair n-sided die.
 *
 * @example Roll four six-sided dice
 * ```js
 * let die = new Die({faces: 6, number: 4}).evaluate();
 * ```
 */
class Die extends DiceTerm {
  /** @inheritDoc */
  static DENOMINATION = "d";

  /** @inheritDoc */
  static MODIFIERS = {
    r: "reroll",
    rr: "rerollRecursive",
    x: "explode",
    xo: "explodeOnce",
    k: "keep",
    kh: "keep",
    kl: "keep",
    d: "drop",
    dh: "drop",
    dl: "drop",
    min: "minimum",
    max: "maximum",
    even: "countEven",
    odd: "countOdd",
    cs: "countSuccess",
    cf: "countFailures",
    df: "deductFailures",
    sf: "subtractFailures",
    ms: "marginSuccess"
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  get total() {
    const total = super.total;
    if ( this.options.marginSuccess ) return total - parseInt(this.options.marginSuccess);
    else if ( this.options.marginFailure ) return parseInt(this.options.marginFailure) - total;
    else return total;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get denomination() {
    return `d${this.faces}`;
  }

  /* -------------------------------------------- */
  /*  Term Modifiers                              */
  /* -------------------------------------------- */

  /**
   * Re-roll the Die, rolling additional results for any values which fall within a target set.
   * If no target number is specified, re-roll the lowest possible result.
   *
   * 20d20r         reroll all 1s
   * 20d20r1        reroll all 1s
   * 20d20r=1       reroll all 1s
   * 20d20r1=1      reroll a single 1
   *
   * @param {string} modifier        The matched modifier query
   * @param {boolean} recursive      Reroll recursively, continuing to reroll until the condition is no longer met
   * @returns {Promise<false|void>}  False if the modifier was unmatched
   */
  async reroll(modifier, {recursive=false}={}) {

    // Match the re-roll modifier
    const rgx = /rr?([0-9]+)?([<>=]+)?([0-9]+)?/i;
    const match = modifier.match(rgx);
    if ( !match ) return false;
    let [max, comparison, target] = match.slice(1);

    // If no comparison or target are provided, treat the max as the target
    if ( max && !(target || comparison) ) {
      target = max;
      max = null;
    }

    // Determine target values
    max = Number.isNumeric(max) ? parseInt(max) : null;
    target = Number.isNumeric(target) ? parseInt(target) : 1;
    comparison = comparison || "=";

    // Recursively reroll until there are no remaining results to reroll
    let checked = 0;
    const initial = this.results.length;
    while ( checked < this.results.length ) {
      const r = this.results[checked];
      checked++;
      if ( !r.active ) continue;

      // Maybe we have run out of rerolls
      if ( (max !== null) && (max <= 0) ) break;

      // Determine whether to re-roll the result
      if ( DiceTerm.compareResult(r.result, comparison, target) ) {
        r.rerolled = true;
        r.active = false;
        await this.roll({ reroll: true });
        if ( max !== null ) max -= 1;
      }

      // Limit recursion
      if ( !recursive && (checked >= initial) ) checked = this.results.length;
      if ( checked > 1000 ) throw new Error("Maximum recursion depth for exploding dice roll exceeded");
    }
  }

  /**
   * Reroll recursively.
   * @param {string} modifier
   * @see {@link Die#reroll}
   */
  async rerollRecursive(modifier) {
    return this.reroll(modifier, {recursive: true});
  }

  /* -------------------------------------------- */

  /**
   * Explode the Die, rolling additional results for any values which match the target set.
   * If no target number is specified, explode the highest possible result.
   * Explosion can be a "small explode" using a lower-case x or a "big explode" using an upper-case "X"
   *
   * @param {string} modifier        The matched modifier query
   * @param {boolean} recursive      Explode recursively, such that new rolls can also explode?
   * @returns {Promise<false|void>}  False if the modifier was unmatched.
   */
  async explode(modifier, {recursive=true}={}) {

    // Match the "explode" or "explode once" modifier
    const rgx = /xo?([0-9]+)?([<>=]+)?([0-9]+)?/i;
    const match = modifier.match(rgx);
    if ( !match ) return false;
    let [max, comparison, target] = match.slice(1);

    // If no comparison or target are provided, treat the max as the target value
    if ( max && !(target || comparison) ) {
      target = max;
      max = null;
    }

    // Determine target values
    target = Number.isNumeric(target) ? parseInt(target) : this.faces;
    comparison = comparison || "=";

    // Determine the number of allowed explosions
    max = Number.isNumeric(max) ? parseInt(max) : null;

    // Recursively explode until there are no remaining results to explode
    let checked = 0;
    const initial = this.results.length;
    while ( checked < this.results.length ) {
      const r = this.results[checked];
      checked++;
      if ( !r.active ) continue;

      // Maybe we have run out of explosions
      if ( (max !== null) && (max <= 0) ) break;

      // Determine whether to explode the result and roll again!
      if ( DiceTerm.compareResult(r.result, comparison, target) ) {
        r.exploded = true;
        await this.roll({ explode: true });
        if ( max !== null ) max -= 1;
      }

      // Limit recursion
      if ( !recursive && (checked === initial) ) break;
      if ( checked > 1000 ) throw new Error("Maximum recursion depth for exploding dice roll exceeded");
    }
  }

  /**
   * Explode non-recursively.
   * @param {string} modifier
   * @see {@link Die#explode}
   */
  async explodeOnce(modifier) {
    return this.explode(modifier, {recursive: false});
  }

  /* -------------------------------------------- */

  /**
   * Keep a certain number of highest or lowest dice rolls from the result set.
   *
   * 20d20k       Keep the 1 highest die
   * 20d20kh      Keep the 1 highest die
   * 20d20kh10    Keep the 10 highest die
   * 20d20kl      Keep the 1 lowest die
   * 20d20kl10    Keep the 10 lowest die
   *
   * @param {string} modifier     The matched modifier query
   */
  keep(modifier) {
    const rgx = /k([hl])?([0-9]+)?/i;
    const match = modifier.match(rgx);
    if ( !match ) return false;
    let [direction, number] = match.slice(1);
    direction = direction ? direction.toLowerCase() : "h";
    number = parseInt(number) || 1;
    DiceTerm._keepOrDrop(this.results, number, {keep: true, highest: direction === "h"});
  }

  /* -------------------------------------------- */

  /**
   * Drop a certain number of highest or lowest dice rolls from the result set.
   *
   * 20d20d       Drop the 1 lowest die
   * 20d20dh      Drop the 1 highest die
   * 20d20dl      Drop the 1 lowest die
   * 20d20dh10    Drop the 10 highest die
   * 20d20dl10    Drop the 10 lowest die
   *
   * @param {string} modifier     The matched modifier query
   */
  drop(modifier) {
    const rgx = /d([hl])?([0-9]+)?/i;
    const match = modifier.match(rgx);
    if ( !match ) return false;
    let [direction, number] = match.slice(1);
    direction = direction ? direction.toLowerCase() : "l";
    number = parseInt(number) || 1;
    DiceTerm._keepOrDrop(this.results, number, {keep: false, highest: direction !== "l"});
  }

  /* -------------------------------------------- */

  /**
   * Count the number of successful results which occurred in a given result set.
   * Successes are counted relative to some target, or relative to the maximum possible value if no target is given.
   * Applying a count-success modifier to the results re-casts all results to 1 (success) or 0 (failure)
   *
   * 20d20cs      Count the number of dice which rolled a 20
   * 20d20cs>10   Count the number of dice which rolled higher than 10
   * 20d20cs<10   Count the number of dice which rolled less than 10
   *
   * @param {string} modifier     The matched modifier query
   */
  countSuccess(modifier) {
    const rgx = /(?:cs)([<>=]+)?([0-9]+)?/i;
    const match = modifier.match(rgx);
    if ( !match ) return false;
    let [comparison, target] = match.slice(1);
    comparison = comparison || "=";
    target = parseInt(target) ?? this.faces;
    DiceTerm._applyCount(this.results, comparison, target, {flagSuccess: true});
  }

  /* -------------------------------------------- */

  /**
   * Count the number of failed results which occurred in a given result set.
   * Failures are counted relative to some target, or relative to the lowest possible value if no target is given.
   * Applying a count-failures modifier to the results re-casts all results to 1 (failure) or 0 (non-failure)
   *
   * 6d6cf      Count the number of dice which rolled a 1 as failures
   * 6d6cf<=3   Count the number of dice which rolled less than 3 as failures
   * 6d6cf>4    Count the number of dice which rolled greater than 4 as failures
   *
   * @param {string} modifier     The matched modifier query
   */
  countFailures(modifier) {
    const rgx = /(?:cf)([<>=]+)?([0-9]+)?/i;
    const match = modifier.match(rgx);
    if ( !match ) return false;
    let [comparison, target] = match.slice(1);
    comparison = comparison || "=";
    target = parseInt(target) ?? 1;
    DiceTerm._applyCount(this.results, comparison, target, {flagFailure: true});
  }

  /* -------------------------------------------- */

  /**
   * Count the number of even results which occurred in a given result set.
   * Even numbers are marked as a success and counted as 1
   * Odd numbers are marked as a non-success and counted as 0.
   *
   * 6d6even    Count the number of even numbers rolled
   *
   * @param {string} modifier     The matched modifier query (unused here, but passed to overrides anyway)
   */
  countEven(modifier) {
    for ( const r of this.results ) {
      r.success = ( (r.result % 2) === 0 );
      r.count = r.success ? 1 : 0;
    }
  }

  /* -------------------------------------------- */

  /**
   * Count the number of odd results which occurred in a given result set.
   * Odd numbers are marked as a success and counted as 1
   * Even numbers are marked as a non-success and counted as 0.
   *
   * 6d6odd    Count the number of odd numbers rolled
   *
   * @param {string} modifier     The matched modifier query (unused here, but passed to overrides anyway)
   */
  countOdd(modifier) {
    for ( const r of this.results ) {
      r.success = ( (r.result % 2) !== 0 );
      r.count = r.success ? 1 : 0;
    }
  }

  /* -------------------------------------------- */

  /**
   * Deduct the number of failures from the dice result, counting each failure as -1
   * Failures are identified relative to some target, or relative to the lowest possible value if no target is given.
   * Applying a deduct-failures modifier to the results counts all failed results as -1.
   *
   * 6d6df      Subtract the number of dice which rolled a 1 from the non-failed total.
   * 6d6cs>3df  Subtract the number of dice which rolled a 3 or less from the non-failed count.
   * 6d6cf<3df  Subtract the number of dice which rolled less than 3 from the non-failed count.
   *
   * @param {string} modifier     The matched modifier query
   */
  deductFailures(modifier) {
    const rgx = /(?:df)([<>=]+)?([0-9]+)?/i;
    const match = modifier.match(rgx);
    if ( !match ) return false;
    let [comparison, target] = match.slice(1);
    if ( comparison || target ) {
      comparison = comparison || "=";
      target = parseInt(target) ?? 1;
    }
    DiceTerm._applyDeduct(this.results, comparison, target, {deductFailure: true});
  }

  /* -------------------------------------------- */

  /**
   * Subtract the value of failed dice from the non-failed total, where each failure counts as its negative value.
   * Failures are identified relative to some target, or relative to the lowest possible value if no target is given.
   * Applying a deduct-failures modifier to the results counts all failed results as -1.
   *
   * 6d6df<3    Subtract the value of results which rolled less than 3 from the non-failed total.
   *
   * @param {string} modifier     The matched modifier query
   */
  subtractFailures(modifier) {
    const rgx = /(?:sf)([<>=]+)?([0-9]+)?/i;
    const match = modifier.match(rgx);
    if ( !match ) return false;
    let [comparison, target] = match.slice(1);
    if ( comparison || target ) {
      comparison = comparison || "=";
      target = parseInt(target) ?? 1;
    }
    DiceTerm._applyDeduct(this.results, comparison, target, {invertFailure: true});
  }

  /* -------------------------------------------- */

  /**
   * Subtract the total value of the DiceTerm from a target value, treating the difference as the final total.
   * Example: 6d6ms>12    Roll 6d6 and subtract 12 from the resulting total.
   * @param {string} modifier     The matched modifier query
   */
  marginSuccess(modifier) {
    const rgx = /(?:ms)([<>=]+)?([0-9]+)?/i;
    const match = modifier.match(rgx);
    if ( !match ) return false;
    let [comparison, target] = match.slice(1);
    target = parseInt(target);
    if ( [">", ">=", "=", undefined].includes(comparison) ) this.options.marginSuccess = target;
    else if ( ["<", "<="].includes(comparison) ) this.options.marginFailure = target;
  }

  /* -------------------------------------------- */

  /**
   * Constrain each rolled result to be at least some minimum value.
   * Example: 6d6min2    Roll 6d6, each result must be at least 2
   * @param {string} modifier     The matched modifier query
   */
  minimum(modifier) {
    const rgx = /(?:min)([0-9]+)/i;
    const match = modifier.match(rgx);
    if ( !match ) return false;
    let [target] = match.slice(1);
    target = parseInt(target);
    for ( const r of this.results ) {
      if ( r.result < target ) {
        r.count = target;
        r.rerolled = true;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Constrain each rolled result to be at most some maximum value.
   * Example: 6d6max5    Roll 6d6, each result must be at most 5
   * @param {string} modifier     The matched modifier query
   */
  maximum(modifier) {
    const rgx = /(?:max)([0-9]+)/i;
    const match = modifier.match(rgx);
    if ( !match ) return false;
    let [target] = match.slice(1);
    target = parseInt(target);
    for ( const r of this.results ) {
      if ( r.result > target ) {
        r.count = target;
        r.rerolled = true;
      }
    }
  }
}

/**
 * A type of DiceTerm used to represent a three-sided Fate/Fudge die.
 * Mathematically behaves like 1d3-2
 */
class FateDie extends DiceTerm {
  constructor(termData) {
    termData.faces = 3;
    super(termData);
  }

  /** @inheritdoc */
  static DENOMINATION = "f";

  /** @inheritdoc */
  static MODIFIERS = {
    r: Die.prototype.reroll,
    rr: Die.prototype.rerollRecursive,
    k: Die.prototype.keep,
    kh: Die.prototype.keep,
    kl: Die.prototype.keep,
    d: Die.prototype.drop,
    dh: Die.prototype.drop,
    dl: Die.prototype.drop
  };

  /* -------------------------------------------- */

  /** @inheritdoc */
  async roll({minimize=false, maximize=false, ...options}={}) {
    /** @type {result: number; active: boolean} */
    const roll = {result: undefined, active: true};
    if ( minimize ) roll.result = -1;
    else if ( maximize ) roll.result = 1;
    else roll.result = await this._roll(options);
    if ( roll.result === undefined ) roll.result = this.randomFace();
    if ( roll.result === -1 ) roll.failure = true;
    if ( roll.result === 1 ) roll.success = true;
    this.results.push(roll);
    return roll;
  }

  /* -------------------------------------------- */

  /** @override */
  mapRandomFace(randomUniform) {
    return Math.ceil((randomUniform * this.faces) - 2);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getResultLabel(result) {
    return {
      "-1": "-",
      0: "&nbsp;",
      1: "+"
    }[result.result];
  }
}

/**
 * @import {RollFunction} from "@client/config.mjs";
 */

/**
 * A type of RollTerm used to apply a function.
 */
class FunctionTerm extends RollTerm {
  constructor({fn, terms=[], rolls=[], result, options={}}={}) {
    super({options});
    this.fn = fn;
    this.terms = terms;
    this.rolls = (rolls.length === terms.length) ? rolls : this.terms.map(t => Roll.create(t));
    this.result = result;
    if ( result !== undefined ) this._evaluated = true;
  }

  /**
   * The name of the configured function, or one in the Math environment, which should be applied to the term
   * @type {string}
   */
  fn;

  /**
   * An array of string argument terms for the function
   * @type {string[]}
   */
  terms;

  /**
   * The cached Roll instances for each function argument
   * @type {Roll[]}
   */
  rolls = [];

  /**
   * The cached result of evaluating the method arguments
   * @type {string|number}
   */
  result;

  /** @inheritdoc */
  isIntermediate = true;

  /** @inheritdoc */
  static SERIALIZE_ATTRIBUTES = ["fn", "terms", "rolls", "result"];

  /* -------------------------------------------- */
  /*  Function Term Attributes                    */
  /* -------------------------------------------- */

  /**
   * An array of evaluated DiceTerm instances that should be bubbled up to the parent Roll
   * @type {DiceTerm[]}
   */
  get dice() {
    return this.rolls.flatMap(r => r.dice);
  }

  /** @inheritdoc */
  get total() {
    return this.result;
  }

  /** @inheritdoc */
  get expression() {
    return `${this.fn}(${this.terms.join(",")})`;
  }

  /**
   * The function this term represents.
   * @returns {RollFunction}
   */
  get function() {
    return CONFIG.Dice.functions[this.fn] ?? Math[this.fn];
  }

  /** @inheritdoc */
  get isDeterministic() {
    if ( this.function?.constructor.name === "AsyncFunction" ) return false;
    return this.terms.every(t => Roll.create(t).isDeterministic);
  }

  /* -------------------------------------------- */
  /*  Math Term Methods                           */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _evaluate(options={}) {
    if ( RollTerm.isDeterministic(this, options) ) return this._evaluateSync(options);
    return this._evaluateAsync(options);
  }

  /* -------------------------------------------- */

  /**
   * Evaluate this function when it contains any non-deterministic sub-terms.
   * @param {object} [options]
   * @returns {Promise<RollTerm>}
   * @protected
   */
  async _evaluateAsync(options={}) {
    const args = await Promise.all(this.rolls.map(async roll => {
      if ( this._root ) roll._root = this._root;
      await roll.evaluate({ ...options, allowStrings: true });
      roll.propagateFlavor(this.flavor);
      return this.#parseArgument(roll);
    }));
    const fn = this.function;
    if ( !fn ) throw new Error(`The function "${this.fn}" is not registered in CONFIG.Dice.functions`);
    this.result = await fn.apply(this, args);
    if ( !options.allowStrings ) this.result = Number(this.result);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Evaluate this function when it contains only deterministic sub-terms.
   * @param {object} [options]
   * @returns {RollTerm}
   * @protected
   */
  _evaluateSync(options={}) {
    const args = [];
    for ( const roll of this.rolls ) {
      roll.evaluateSync({ ...options, allowStrings: true });
      roll.propagateFlavor(this.flavor);
      args.push(this.#parseArgument(roll));
    }
    const fn = this.function;
    if ( !fn ) throw new Error(`The function "${this.fn}" is not registered in CONFIG.Dice.functions`);
    this.result = fn.apply(this, args);
    if ( !options.allowStrings ) this.result = Number(this.result);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Parse a function argument from its evaluated Roll instance.
   * @param {Roll} roll  The evaluated Roll instance that wraps the argument.
   * @returns {string|number}
   */
  #parseArgument(roll) {
    const { product } = roll;
    if ( typeof product !== "string" ) return product;
    const [, value] = product.match(/^ᚖ([^ᚖ]+)ᚖ$/) || [];
    return value ? JSON.parse(value) : product;
  }

  /* -------------------------------------------- */
  /*  Saving and Loading                          */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _fromData(data) {
    data.rolls = (data.rolls || []).map(r => r instanceof Roll ? r : Roll.fromData(r));
    return super._fromData(data);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  toJSON() {
    const data = super.toJSON();
    data.rolls = data.rolls.map(r => r.toJSON());
    return data;
  }

  /* -------------------------------------------- */

  /** @override */
  static fromParseNode(node) {
    const rolls = node.terms.map(t => {
      return Roll.defaultImplementation.fromTerms(Roll.defaultImplementation.instantiateAST(t));
    });
    const modifiers = Array.from((node.modifiers || "").matchAll(DiceTerm.MODIFIER_REGEXP)).map(([m]) => m);
    return this.fromData({ ...node, rolls, modifiers, terms: rolls.map(r => r.formula) });
  }
}

/**
 * A type of RollTerm used to represent static numbers.
 */
class NumericTerm extends RollTerm {
  constructor({number, options}={}) {
    super({options});
    this.number = Number(number);
  }

  /**
   * The term's numeric value.
   * @type {number}
   */
  number;

  /** @inheritdoc */
  static REGEXP = new RegExp(`^([0-9]+(?:\\.[0-9]+)?)${RollTerm.FLAVOR_REGEXP_STRING}?$`);

  /** @inheritdoc */
  static SERIALIZE_ATTRIBUTES = ["number"];

  /** @inheritdoc */
  get expression() {
    return String(this.number);
  }

  /** @inheritdoc */
  get total() {
    return this.number;
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Determine whether a string expression matches a NumericTerm
   * @param {string} expression               The expression to parse
   * @returns {RegExpMatchArray|null}
   */
  static matchTerm(expression) {
    return expression.match(this.REGEXP) || null;
  }

  /* -------------------------------------------- */

  /**
   * Construct a term of this type given a matched regular expression array.
   * @param {RegExpMatchArray} match          The matched regular expression array
   * @returns {NumericTerm}                   The constructed term
   */
  static fromMatch(match) {
    const [number, flavor] = match.slice(1);
    return new this({number, options: {flavor}});
  }
}

/**
 * A type of RollTerm used to denote and perform an arithmetic operation.
 */
class OperatorTerm extends RollTerm {
  constructor({operator, options}={}) {
    super({options});
    this.operator = operator;
    this._evaluated = true; // Operator terms are always evaluated
  }

  /**
   * The term's operator value.
   * @type {string}
   */
  operator;

  /**
   * An object of operators with their precedence values.
   * @type {Readonly<Record<string, number>>}
   */
  static PRECEDENCE = Object.freeze({
    "+": 10,
    "-": 10,
    "*": 20,
    "/": 20,
    "%": 20
  });

  /**
   * An array of operators which represent arithmetic operations
   * @type {string[]}
   */
  static OPERATORS = Object.keys(this.PRECEDENCE);

  /** @override */
  static REGEXP = new RegExp(this.OPERATORS.map(o => `\\${o}`).join("|"), "g");

  /** @override */
  static SERIALIZE_ATTRIBUTES = ["operator"];

  /** @override */
  static _fromData(data) {
    return new this(data);
  }

  /** @override */
  get flavor() {
    return ""; // Operator terms cannot have flavor text
  }

  /** @override */
  get expression() {
    return ` ${this.operator} `;
  }

  /** @override */
  get total() {
    return ` ${this.operator} `;
  }
}

/**
 * @import {DiceTerm, ParentheticalTerm} from "./_module.mjs";
 */

/**
 * A type of RollTerm used to enclose a parenthetical expression to be recursively evaluated.
 */
class ParentheticalTerm extends RollTerm {
  constructor({term, roll, options}) {
    super({options});
    this.term = term;
    this.roll = roll;

    // If a roll was explicitly passed in, the parenthetical may have already been evaluated
    if ( this.roll ) {
      this.term = roll.formula;
      this._evaluated = this.roll._evaluated;
    }
  }

  /**
   * The original provided string term used to construct the parenthetical
   * @type {string}
   */
  term;

  /**
   * An already-evaluated Roll instance used instead of the string term.
   * @type {Roll}
   */
  roll;

  /** @inheritdoc */
  isIntermediate = true;

  /**
   * The regular expression pattern used to identify the opening of a parenthetical expression.
   * This could also identify the opening of a math function.
   * @type {RegExp}
   */
  static OPEN_REGEXP = /([A-z][A-z0-9]+)?\(/g;

  /**
   * A regular expression pattern used to identify the closing of a parenthetical expression.
   * @type {RegExp}
   */
  static CLOSE_REGEXP = /\)(?:\$\$F[0-9]+\$\$)?/g;

  /** @inheritdoc */
  static SERIALIZE_ATTRIBUTES = ["term", "roll"];

  /* -------------------------------------------- */
  /*  Parenthetical Term Attributes               */
  /* -------------------------------------------- */

  /**
   * An array of evaluated DiceTerm instances that should be bubbled up to the parent Roll
   * @type {DiceTerm[]}
   */
  get dice() {
    return this.roll?.dice;
  }

  /** @inheritdoc */
  get total() {
    return this.roll.total;
  }

  /** @inheritdoc */
  get expression() {
    return `(${this.term})`;
  }

  /** @inheritdoc */
  get isDeterministic() {
    return Roll.create(this.term).isDeterministic;
  }

  /* -------------------------------------------- */
  /*  Parenthetical Term Methods                  */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _evaluate(options={}) {
    const roll = this.roll || Roll.create(this.term);
    if ( this._root ) roll._root = this._root;
    if ( options.maximize || options.minimize || roll.isDeterministic ) return this._evaluateSync(roll, options);
    return this._evaluateAsync(roll, options);
  }

  /* -------------------------------------------- */

  /**
   * Evaluate this parenthetical when it contains any non-deterministic sub-terms.
   * @param {Roll} roll  The inner Roll instance to evaluate.
   * @param {object} [options]
   * @returns {Promise<RollTerm>}
   * @protected
   */
  async _evaluateAsync(roll, options={}) {
    this.roll = await roll.evaluate(options);
    this.roll.propagateFlavor(this.flavor);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Evaluate this parenthetical when it contains only deterministic sub-terms.
   * @param {Roll} roll  The inner Roll instance to evaluate.
   * @param {object} [options]
   * @returns {RollTerm}
   * @protected
   */
  _evaluateSync(roll, options={}) {
    this.roll = roll.evaluateSync(options);
    this.roll.propagateFlavor(this.flavor);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Construct a ParentheticalTerm from an Array of component terms which should be wrapped inside the parentheses.
   * @param {RollTerm[]} terms      The array of terms to use as internal parts of the parenthetical
   * @param {object} [options={}]   Additional options passed to the ParentheticalTerm constructor
   * @returns {ParentheticalTerm}   The constructed ParentheticalTerm instance
   *
   * @example Create a Parenthetical Term from an array of component RollTerm instances
   * ```js
   * const d6 = new Die({number: 4, faces: 6});
   * const plus = new OperatorTerm({operator: "+"});
   * const bonus = new NumericTerm({number: 4});
   * t = ParentheticalTerm.fromTerms([d6, plus, bonus]);
   * t.formula; // (4d6 + 4)
   * ```
   */
  static fromTerms(terms, options) {
    const roll = Roll.defaultImplementation.fromTerms(terms);
    return new this({roll, options});
  }

  /* -------------------------------------------- */

  /** @override */
  static fromParseNode(node) {
    const roll = Roll.defaultImplementation.fromTerms(Roll.defaultImplementation.instantiateAST(node.term));
    return this.fromData({ ...node, roll, term: roll.formula });
  }
}

/**
 * @import {DiceTermResult} from "../_types.mjs"
 */

/**
 * A type of RollTerm which encloses a pool of multiple inner Rolls which are evaluated jointly.
 *
 * A dice pool represents a set of Roll expressions which are collectively modified to compute an effective total
 * across all Rolls in the pool. The final total for the pool is defined as the sum over kept rolls, relative to any
 * success count or margin.
 *
 * @example Keep the highest of the 3 roll expressions
 * ```js
 * let pool = new PoolTerm({
 *   terms: ["4d6", "3d8 - 1", "2d10 + 3"],
 *   modifiers: ["kh"]
 * });
 * pool.evaluate();
 * ```
 */
class PoolTerm extends RollTerm {
  constructor({terms=[], modifiers=[], rolls=[], results=[], options={}}={}) {
    super({options});
    this.terms = terms;
    this.modifiers = modifiers;
    this.rolls = (rolls.length === terms.length) ? rolls : this.terms.map(t => Roll.create(t));
    this.results = results;

    // If rolls and results were explicitly passed, the term has already been evaluated
    if ( rolls.length && results.length ) this._evaluated = true;
  }

  /* -------------------------------------------- */

  /**
   * The original provided terms to the Dice Pool
   * @type {string[]}
   */
  terms;

  /**
   * The string modifiers applied to resolve the pool
   * @type {string[]}
   */
  modifiers;

  /**
   * Each component term of the dice pool as a Roll instance.
   * @type {Roll[]}
   */
  rolls;

  /**
   * The array of dice pool results which have been rolled
   * @type {DiceTermResult[]}
   */
  results;

  /**
   * Define the modifiers that can be used for this particular DiceTerm type.
   * @type {Record<string, Function|string>}
   */
  static MODIFIERS = {
    k: "keep",
    kh: "keep",
    kl: "keep",
    d: "drop",
    dh: "drop",
    dl: "drop",
    cs: "countSuccess",
    cf: "countFailures"
  };

  /**
   * The regular expression pattern used to identify the opening of a dice pool expression.
   * @type {RegExp}
   */
  static OPEN_REGEXP = /{/g;

  /**
   * A regular expression pattern used to identify the closing of a dice pool expression.
   * @type {RegExp}
   */
  static CLOSE_REGEXP = new RegExp(`}${DiceTerm.MODIFIERS_REGEXP_STRING}?(?:\\$\\$F[0-9]+\\$\\$)?`, "g");

  /**
   * A regular expression pattern used to match the entirety of a DicePool expression.
   * @type {RegExp}
   */
  static REGEXP = new RegExp(`{([^}]+)}${DiceTerm.MODIFIERS_REGEXP_STRING}?(?:\\$\\$F[0-9]+\\$\\$)?`);

  /** @inheritdoc */
  static SERIALIZE_ATTRIBUTES = ["terms", "modifiers", "rolls", "results"];

  /* -------------------------------------------- */
  /*  Dice Pool Attributes                        */
  /* -------------------------------------------- */

  /**
   * Return an Array of each individual DiceTerm instances contained within the PoolTerm.
   * @type {DiceTerm[]}
   */
  get dice() {
    return this.rolls.flatMap(r => r.dice);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get expression() {
    return `{${this.terms.join(",")}}${this.modifiers.join("")}`;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get total() {
    if ( !this._evaluated ) return undefined;
    return this.results.reduce((t, r) => {
      if ( !r.active ) return t;
      if ( r.count !== undefined ) return t + r.count;
      else return t + r.result;
    }, 0);
  }

  /* -------------------------------------------- */

  /**
   * Return an array of rolled values which are still active within the PoolTerm
   * @type {number[]}
   */
  get values() {
    return this.results.reduce((arr, r) => {
      if ( !r.active ) return arr;
      arr.push(r.result);
      return arr;
    }, []);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get isDeterministic() {
    return this.terms.every(t => Roll.create(t).isDeterministic);
  }

  /* -------------------------------------------- */

  /**
   * Alter the DiceTerm by adding or multiplying the number of dice which are rolled
   * @param {any[]} args        Arguments passed to each contained Roll#alter method.
   * @returns {PoolTerm}        The altered pool
   */
  alter(...args) {
    this.rolls.forEach(r => r.alter(...args));
    return this;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _evaluate(options={}) {
    if ( RollTerm.isDeterministic(this, options) ) return this._evaluateSync(options);
    return this._evaluateAsync(options);
  }

  /* -------------------------------------------- */

  /**
   * Evaluate this pool term when it contains any non-deterministic sub-terms.
   * @param {object} [options]
   * @returns {Promise<PoolTerm>}
   * @protected
   */
  async _evaluateAsync(options={}) {
    for ( const roll of this.rolls ) {
      if ( this._root ) roll._root = this._root;
      await roll.evaluate(options);
      roll.propagateFlavor(this.flavor);
      this.results.push({ result: roll.total, active: true });
    }
    await this._evaluateModifiers();
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Evaluate this pool term when it contains only deterministic sub-terms.
   * @param {object} [options]
   * @returns {PoolTerm}
   * @protected
   */
  _evaluateSync(options={}) {
    for ( const roll of this.rolls ) {
      if ( this._root ) roll._root = this._root;
      roll.evaluateSync(options);
      roll.propagateFlavor(this.flavor);
      this.results.push({ result: roll.total, active: true });
    }
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Use the same logic as for the DiceTerm to avoid duplication
   * @see {@link foundry.dice.terms.DiceTerm#_evaluateModifiers}
   * @internal
   */
  _evaluateModifiers() {
    return DiceTerm.prototype._evaluateModifiers.call(this);
  }

  /* -------------------------------------------- */

  /**
   * Use the same logic as for the DiceTerm to avoid duplication
   * @param {string} command
   * @param {string} modifier
   * @see {@link foundry.dice.terms.DiceTerm#_evaluateModifier}
   * @internal
   */
  _evaluateModifier(command, modifier) {
    return DiceTerm.prototype._evaluateModifier.call(this, command, modifier);
  }

  /* -------------------------------------------- */
  /*  Saving and Loading                          */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static _fromData(data) {
    data.rolls = (data.rolls || []).map(r => r instanceof Roll ? r : Roll.fromData(r));
    return super._fromData(data);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  toJSON() {
    const data = super.toJSON();
    data.rolls = data.rolls.map(r => r.toJSON());
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Given a string formula, create and return an evaluated PoolTerm object
   * @param {string} formula    The string formula to parse
   * @param {object} [options]  Additional options applied to the PoolTerm
   * @returns {PoolTerm|null}   The evaluated PoolTerm object or null if the formula is invalid
   */
  static fromExpression(formula, options={}) {
    const rgx = formula.trim().match(this.REGEXP);
    if ( !rgx ) return null;
    let [terms, modifiers] = rgx.slice(1);
    terms = terms.split(",");
    modifiers = Array.from((modifiers || "").matchAll(DiceTerm.MODIFIER_REGEXP)).map(m => m[0]);
    return new this({terms, modifiers, options});
  }

  /* -------------------------------------------- */

  /**
   * Create a PoolTerm by providing an array of existing Roll objects
   * @param {Roll[]} rolls      An array of Roll objects from which to create the pool
   * @returns {PoolTerm}        The constructed PoolTerm comprised of the provided rolls
   */
  static fromRolls(rolls=[]) {
    const allEvaluated = rolls.every(t => t._evaluated);
    const noneEvaluated = !rolls.some(t => t._evaluated);
    if ( !(allEvaluated || noneEvaluated) ) {
      throw new Error("You can only call PoolTerm.fromRolls with an array of Roll instances which are either all evaluated, or none evaluated");
    }
    const pool = new this({
      terms: rolls.map(r => r.formula),
      modifiers: [],
      rolls: rolls,
      results: allEvaluated ? rolls.map(r => ({result: r.total, active: true})) : []
    });
    pool._evaluated = allEvaluated;
    return pool;
  }

  /* -------------------------------------------- */

  /** @override */
  static fromParseNode(node) {
    const rolls = node.terms.map(t => {
      return Roll.defaultImplementation.fromTerms(Roll.defaultImplementation.instantiateAST(t)).toJSON();
    });
    const modifiers = Array.from((node.modifiers || "").matchAll(DiceTerm.MODIFIER_REGEXP)).map(([m]) => m);
    return this.fromData({ ...node, rolls, modifiers, terms: rolls.map(r => r.formula) });
  }

  /* -------------------------------------------- */
  /*  Modifiers                                   */
  /* -------------------------------------------- */

  /**
   * Keep a certain number of highest or lowest dice rolls from the result set.
   *
   * {1d6,1d8,1d10,1d12}kh2       Keep the 2 best rolls from the pool
   * {1d12,6}kl                   Keep the lowest result in the pool
   *
   * @param {string} modifier     The matched modifier query
   */
  keep(modifier) {
    return Die.prototype.keep.call(this, modifier);
  }

  /* -------------------------------------------- */

  /**
   * Keep a certain number of highest or lowest dice rolls from the result set.
   *
   * {1d6,1d8,1d10,1d12}dl3       Drop the 3 worst results in the pool
   * {1d12,6}dh                   Drop the highest result in the pool
   *
   * @param {string} modifier     The matched modifier query
   */
  drop(modifier) {
    return Die.prototype.drop.call(this, modifier);
  }

  /* -------------------------------------------- */

  /**
   * Count the number of successful results which occurred in the pool.
   * Successes are counted relative to some target, or relative to the maximum possible value if no target is given.
   * Applying a count-success modifier to the results re-casts all results to 1 (success) or 0 (failure)
   *
   * 20d20cs      Count the number of dice which rolled a 20
   * 20d20cs>10   Count the number of dice which rolled higher than 10
   * 20d20cs<10   Count the number of dice which rolled less than 10
   *
   * @param {string} modifier     The matched modifier query
   */
  countSuccess(modifier) {
    return Die.prototype.countSuccess.call(this, modifier);
  }

  /* -------------------------------------------- */

  /**
   * Count the number of failed results which occurred in a given result set.
   * Failures are counted relative to some target, or relative to the lowest possible value if no target is given.
   * Applying a count-failures modifier to the results re-casts all results to 1 (failure) or 0 (non-failure)
   *
   * 6d6cf      Count the number of dice which rolled a 1 as failures
   * 6d6cf<=3   Count the number of dice which rolled less than 3 as failures
   * 6d6cf>4    Count the number of dice which rolled greater than 4 as failures
   *
   * @param {string} modifier     The matched modifier query
   */
  countFailures(modifier) {
    return Die.prototype.countFailures.call(this, modifier);
  }
}

/**
 * A type of RollTerm used to represent strings which have not yet been matched.
 */
class StringTerm extends RollTerm {
  constructor({term, options}={}) {
    super({options});
    this.term = term;
  }

  /**
   * The term's string value.
   * @type {string}
   */
  term;

  /** @inheritdoc */
  static SERIALIZE_ATTRIBUTES = ["term"];

  /** @inheritdoc */
  get expression() {
    return this.term;
  }

  /** @inheritdoc */
  get total() {
    return this.term;
  }

  /** @inheritdoc */
  get isDeterministic() {
    const classified = Roll.defaultImplementation._classifyStringTerm(this.term, {intermediate: false});
    if ( classified instanceof StringTerm ) return true;
    return classified.isDeterministic;
  }

  /** @inheritdoc */
  evaluate({ allowStrings=false }={}) {
    if ( !allowStrings ) throw new Error(`Unresolved StringTerm ${this.term} requested for evaluation`);
    return this;
  }
}

var _module$7 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Coin: Coin,
  DiceTerm: DiceTerm,
  Die: Die,
  FateDie: FateDie,
  FunctionTerm: FunctionTerm,
  NumericTerm: NumericTerm,
  OperatorTerm: OperatorTerm,
  ParentheticalTerm: ParentheticalTerm,
  PoolTerm: PoolTerm,
  RollTerm: RollTerm,
  StringTerm: StringTerm
});

var Parser = // @generated by Peggy 4.2.0.
//
// https://peggyjs.org/
(function() {

function peg$subclass(child, parent) {
  function C() { this.constructor = child; }
  C.prototype = parent.prototype;
  child.prototype = new C();
}

function peg$SyntaxError(message, expected, found, location) {
  var self = Error.call(this, message);
  // istanbul ignore next Check is a necessary evil to support older environments
  if (Object.setPrototypeOf) {
    Object.setPrototypeOf(self, peg$SyntaxError.prototype);
  }
  self.expected = expected;
  self.found = found;
  self.location = location;
  self.name = "SyntaxError";
  return self;
}

peg$subclass(peg$SyntaxError, Error);

function peg$padEnd(str, targetLength, padString) {
  padString = padString || " ";
  if (str.length > targetLength) { return str; }
  targetLength -= str.length;
  padString += padString.repeat(targetLength);
  return str + padString.slice(0, targetLength);
}

peg$SyntaxError.prototype.format = function(sources) {
  var str = "Error: " + this.message;
  if (this.location) {
    var src = null;
    var k;
    for (k = 0; k < sources.length; k++) {
      if (sources[k].source === this.location.source) {
        src = sources[k].text.split(/\r\n|\n|\r/g);
        break;
      }
    }
    var s = this.location.start;
    var offset_s = (this.location.source && (typeof this.location.source.offset === "function"))
      ? this.location.source.offset(s)
      : s;
    var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column;
    if (src) {
      var e = this.location.end;
      var filler = peg$padEnd("", offset_s.line.toString().length, ' ');
      var line = src[s.line - 1];
      var last = s.line === e.line ? e.column : line.length + 1;
      var hatLen = (last - s.column) || 1;
      str += "\n --> " + loc + "\n"
          + filler + " |\n"
          + offset_s.line + " | " + line + "\n"
          + filler + " | " + peg$padEnd("", s.column - 1, ' ')
          + peg$padEnd("", hatLen, "^");
    } else {
      str += "\n at " + loc;
    }
  }
  return str;
};

peg$SyntaxError.buildMessage = function(expected, found) {
  var DESCRIBE_EXPECTATION_FNS = {
    literal: function(expectation) {
      return "\"" + literalEscape(expectation.text) + "\"";
    },

    class: function(expectation) {
      var escapedParts = expectation.parts.map(function(part) {
        return Array.isArray(part)
          ? classEscape(part[0]) + "-" + classEscape(part[1])
          : classEscape(part);
      });

      return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]";
    },

    any: function() {
      return "any character";
    },

    end: function() {
      return "end of input";
    },

    other: function(expectation) {
      return expectation.description;
    }
  };

  function hex(ch) {
    return ch.charCodeAt(0).toString(16).toUpperCase();
  }

  function literalEscape(s) {
    return s
      .replace(/\\/g, "\\\\")
      .replace(/"/g,  "\\\"")
      .replace(/\0/g, "\\0")
      .replace(/\t/g, "\\t")
      .replace(/\n/g, "\\n")
      .replace(/\r/g, "\\r")
      .replace(/[\x00-\x0F]/g,          function(ch) { return "\\x0" + hex(ch); })
      .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x"  + hex(ch); });
  }

  function classEscape(s) {
    return s
      .replace(/\\/g, "\\\\")
      .replace(/\]/g, "\\]")
      .replace(/\^/g, "\\^")
      .replace(/-/g,  "\\-")
      .replace(/\0/g, "\\0")
      .replace(/\t/g, "\\t")
      .replace(/\n/g, "\\n")
      .replace(/\r/g, "\\r")
      .replace(/[\x00-\x0F]/g,          function(ch) { return "\\x0" + hex(ch); })
      .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x"  + hex(ch); });
  }

  function describeExpectation(expectation) {
    return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation);
  }

  function describeExpected(expected) {
    var descriptions = expected.map(describeExpectation);
    var i, j;

    descriptions.sort();

    if (descriptions.length > 0) {
      for (i = 1, j = 1; i < descriptions.length; i++) {
        if (descriptions[i - 1] !== descriptions[i]) {
          descriptions[j] = descriptions[i];
          j++;
        }
      }
      descriptions.length = j;
    }

    switch (descriptions.length) {
      case 1:
        return descriptions[0];

      case 2:
        return descriptions[0] + " or " + descriptions[1];

      default:
        return descriptions.slice(0, -1).join(", ")
          + ", or "
          + descriptions[descriptions.length - 1];
    }
  }

  function describeFound(found) {
    return found ? "\"" + literalEscape(found) + "\"" : "end of input";
  }

  return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found.";
};

function peg$parse(input, options) {
  options = options !== undefined ? options : {};

  var peg$FAILED = {};
  var peg$source = options.grammarSource;

  var peg$startRuleFunctions = { Expression: peg$parseExpression };
  var peg$startRuleFunction = peg$parseExpression;

  var peg$c0 = "(";
  var peg$c1 = ",";
  var peg$c2 = ")";
  var peg$c3 = "{";
  var peg$c4 = "}";
  var peg$c5 = "\u1696";
  var peg$c6 = ".";
  var peg$c7 = "[";
  var peg$c8 = "]";

  var peg$r0 = /^[dD]/;
  var peg$r1 = /^[a-z]/i;
  var peg$r2 = /^[^\u1696]/;
  var peg$r3 = /^[^ (){}[\]$,+\-*%\/]/;
  var peg$r4 = /^[a-z$_]/i;
  var peg$r5 = /^[a-z$_0-9]/i;
  var peg$r6 = /^[^ (){}[\]$+\-*\/,]/;
  var peg$r7 = /^[0-9]/;
  var peg$r8 = /^[%*\/]/;
  var peg$r9 = /^[+\-]/;
  var peg$r10 = /^[^[\]]/;
  var peg$r11 = /^[ ]/;

  var peg$e0 = peg$literalExpectation("(", false);
  var peg$e1 = peg$literalExpectation(",", false);
  var peg$e2 = peg$literalExpectation(")", false);
  var peg$e3 = peg$classExpectation(["d", "D"], false, false);
  var peg$e4 = peg$classExpectation([["a", "z"]], false, true);
  var peg$e5 = peg$literalExpectation("{", false);
  var peg$e6 = peg$literalExpectation("}", false);
  var peg$e7 = peg$literalExpectation("\u1696", false);
  var peg$e8 = peg$classExpectation(["\u1696"], true, false);
  var peg$e9 = peg$classExpectation([" ", "(", ")", "{", "}", "[", "]", "$", ",", "+", "-", "*", "%", "/"], true, false);
  var peg$e10 = peg$classExpectation([["a", "z"], "$", "_"], false, true);
  var peg$e11 = peg$classExpectation([["a", "z"], "$", "_", ["0", "9"]], false, true);
  var peg$e12 = peg$classExpectation([" ", "(", ")", "{", "}", "[", "]", "$", "+", "-", "*", "/", ","], true, false);
  var peg$e13 = peg$classExpectation([["0", "9"]], false, false);
  var peg$e14 = peg$literalExpectation(".", false);
  var peg$e15 = peg$classExpectation(["%", "*", "/"], false, false);
  var peg$e16 = peg$classExpectation(["+", "-"], false, false);
  var peg$e17 = peg$literalExpectation("[", false);
  var peg$e18 = peg$classExpectation(["[", "]"], true, false);
  var peg$e19 = peg$literalExpectation("]", false);
  var peg$e20 = peg$otherExpectation("whitespace");
  var peg$e21 = peg$classExpectation([" "], false, false);

  var peg$f0 = function(leading, head, tail) {

  /*

  The grammar rules are matched in order of precedence starting at the top of the file, so the rules that match the
  largest portions of a string should generally go at the top, with matches for smaller substrings going at the bottom.

  Here we have a rule that matches the overall roll formula. If a given formula does not match this rule, it means that
  it is an invalid formula and will throw a parsing error.

  Prefixing a pattern with 'foo:' is a way to give a name to the sub-match in the associated javascript code. We use
  this fairly heavily since we want to forward these sub-matches onto the 'parser'. We can think of them like named
  capture groups.

  Prefixing a pattern with '@' is called 'plucking', and is used to identify sub-matches that should be assigned to the
  overall capture name (like 'foo:'), ignoring any that are not 'plucked'.

  For example 'tail:(_ @Operators _ @Term)*' matches operators followed by terms, with any amount of whitespace
  in-between, however only the operator and term matches are assigned to the 'tail' variable, the whitespace is ignored.

  The 'head:A tail:(Delimiter B)*' pattern is a way of representing a string of things separated by a delimiter,
  like 'A + B + C' for formulas, or 'A, B, C' for Pool and Math terms.

  In each of these cases we follow the same pattern: We match a term, then we call 'parser.on*', and that method is
  responsible for taking the raw matched sub-strings and returning a 'parse node', i.e. a plain javascript object that
  contains all the information we need to instantiate a real `RollTerm`.

  */

  return parser._onExpression(head, tail, leading, text(), error);
};
  var peg$f1 = function(fn, head, tail, flavor) {
  return parser._onFunctionTerm(fn, head, tail, flavor, text());
};
  var peg$f2 = function(number, faces, modifiers, flavor) {
  return parser._onDiceTerm(number, faces, modifiers, flavor, text());
};
  var peg$f3 = function(number, flavor) { return parser._onNumericTerm(number, flavor); };
  var peg$f4 = function(head, tail, modifiers, flavor) {
  return parser._onPoolTerm(head, tail, modifiers, flavor, text());
};
  var peg$f5 = function(term, flavor) { return parser._onParenthetical(term, flavor, text()); };
  var peg$f6 = function(term, flavor) { return parser._onStringTerm(term, flavor); };
  var peg$f7 = function() { return Number(text()); };
  var peg$f8 = function(head, tail) { return [head, ...tail]; };
  var peg$f9 = function(head, tail) { return [null, head, ...tail]; };
  var peg$currPos = options.peg$currPos | 0;
  var peg$savedPos = peg$currPos;
  var peg$posDetailsCache = [{ line: 1, column: 1 }];
  var peg$maxFailPos = peg$currPos;
  var peg$maxFailExpected = options.peg$maxFailExpected || [];
  var peg$silentFails = options.peg$silentFails | 0;

  var peg$result;

  if (options.startRule) {
    if (!(options.startRule in peg$startRuleFunctions)) {
      throw new Error("Can't start parsing from rule \"" + options.startRule + "\".");
    }

    peg$startRuleFunction = peg$startRuleFunctions[options.startRule];
  }

  function text() {
    return input.substring(peg$savedPos, peg$currPos);
  }

  function error(message, location) {
    location = location !== undefined
      ? location
      : peg$computeLocation(peg$savedPos, peg$currPos);

    throw peg$buildSimpleError(message, location);
  }

  function peg$literalExpectation(text, ignoreCase) {
    return { type: "literal", text: text, ignoreCase: ignoreCase };
  }

  function peg$classExpectation(parts, inverted, ignoreCase) {
    return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase };
  }

  function peg$endExpectation() {
    return { type: "end" };
  }

  function peg$otherExpectation(description) {
    return { type: "other", description: description };
  }

  function peg$computePosDetails(pos) {
    var details = peg$posDetailsCache[pos];
    var p;

    if (details) {
      return details;
    } else {
      if (pos >= peg$posDetailsCache.length) {
        p = peg$posDetailsCache.length - 1;
      } else {
        p = pos;
        while (!peg$posDetailsCache[--p]) {}
      }

      details = peg$posDetailsCache[p];
      details = {
        line: details.line,
        column: details.column
      };

      while (p < pos) {
        if (input.charCodeAt(p) === 10) {
          details.line++;
          details.column = 1;
        } else {
          details.column++;
        }

        p++;
      }

      peg$posDetailsCache[pos] = details;

      return details;
    }
  }

  function peg$computeLocation(startPos, endPos, offset) {
    var startPosDetails = peg$computePosDetails(startPos);
    var endPosDetails = peg$computePosDetails(endPos);

    var res = {
      source: peg$source,
      start: {
        offset: startPos,
        line: startPosDetails.line,
        column: startPosDetails.column
      },
      end: {
        offset: endPos,
        line: endPosDetails.line,
        column: endPosDetails.column
      }
    };
    return res;
  }

  function peg$fail(expected) {
    if (peg$currPos < peg$maxFailPos) { return; }

    if (peg$currPos > peg$maxFailPos) {
      peg$maxFailPos = peg$currPos;
      peg$maxFailExpected = [];
    }

    peg$maxFailExpected.push(expected);
  }

  function peg$buildSimpleError(message, location) {
    return new peg$SyntaxError(message, null, null, location);
  }

  function peg$buildStructuredError(expected, found, location) {
    return new peg$SyntaxError(
      peg$SyntaxError.buildMessage(expected, found),
      expected,
      found,
      location
    );
  }

  function peg$parseExpression() {
    var s0, s2, s3, s4, s5, s6, s8, s10;

    s0 = peg$currPos;
    peg$parse_();
    s2 = [];
    s3 = peg$currPos;
    s4 = peg$parse_();
    s5 = peg$parseAdditive();
    if (s5 !== peg$FAILED) {
      s3 = s5;
    } else {
      peg$currPos = s3;
      s3 = peg$FAILED;
    }
    while (s3 !== peg$FAILED) {
      s2.push(s3);
      s3 = peg$currPos;
      s4 = peg$parse_();
      s5 = peg$parseAdditive();
      if (s5 !== peg$FAILED) {
        s3 = s5;
      } else {
        peg$currPos = s3;
        s3 = peg$FAILED;
      }
    }
    s3 = peg$parse_();
    s4 = peg$parseTerm();
    if (s4 !== peg$FAILED) {
      s5 = [];
      s6 = peg$currPos;
      peg$parse_();
      s8 = peg$parseOperators();
      if (s8 !== peg$FAILED) {
        peg$parse_();
        s10 = peg$parseTerm();
        if (s10 !== peg$FAILED) {
          s6 = [ s8, s10 ];
        } else {
          peg$currPos = s6;
          s6 = peg$FAILED;
        }
      } else {
        peg$currPos = s6;
        s6 = peg$FAILED;
      }
      while (s6 !== peg$FAILED) {
        s5.push(s6);
        s6 = peg$currPos;
        peg$parse_();
        s8 = peg$parseOperators();
        if (s8 !== peg$FAILED) {
          peg$parse_();
          s10 = peg$parseTerm();
          if (s10 !== peg$FAILED) {
            s6 = [ s8, s10 ];
          } else {
            peg$currPos = s6;
            s6 = peg$FAILED;
          }
        } else {
          peg$currPos = s6;
          s6 = peg$FAILED;
        }
      }
      s6 = peg$parse_();
      peg$savedPos = s0;
      s0 = peg$f0(s2, s4, s5);
    } else {
      peg$currPos = s0;
      s0 = peg$FAILED;
    }

    return s0;
  }

  function peg$parseTerm() {
    var s0;

    s0 = peg$parseFunctionTerm();
    if (s0 === peg$FAILED) {
      s0 = peg$parseDiceTerm();
      if (s0 === peg$FAILED) {
        s0 = peg$parseNumericTerm();
        if (s0 === peg$FAILED) {
          s0 = peg$parsePoolTerm();
          if (s0 === peg$FAILED) {
            s0 = peg$parseParenthetical();
            if (s0 === peg$FAILED) {
              s0 = peg$parseStringTerm();
            }
          }
        }
      }
    }

    return s0;
  }

  function peg$parseFunctionTerm() {
    var s0, s1, s2, s4, s6, s7, s8, s9, s11;

    s0 = peg$currPos;
    s1 = peg$parseFunctionName();
    if (s1 !== peg$FAILED) {
      if (input.charCodeAt(peg$currPos) === 40) {
        s2 = peg$c0;
        peg$currPos++;
      } else {
        s2 = peg$FAILED;
        if (peg$silentFails === 0) { peg$fail(peg$e0); }
      }
      if (s2 !== peg$FAILED) {
        peg$parse_();
        s4 = peg$parseExpression();
        if (s4 === peg$FAILED) {
          s4 = null;
        }
        peg$parse_();
        s6 = [];
        s7 = peg$currPos;
        s8 = peg$parse_();
        if (input.charCodeAt(peg$currPos) === 44) {
          s9 = peg$c1;
          peg$currPos++;
        } else {
          s9 = peg$FAILED;
          if (peg$silentFails === 0) { peg$fail(peg$e1); }
        }
        if (s9 !== peg$FAILED) {
          peg$parse_();
          s11 = peg$parseExpression();
          if (s11 !== peg$FAILED) {
            s7 = s11;
          } else {
            peg$currPos = s7;
            s7 = peg$FAILED;
          }
        } else {
          peg$currPos = s7;
          s7 = peg$FAILED;
        }
        while (s7 !== peg$FAILED) {
          s6.push(s7);
          s7 = peg$currPos;
          s8 = peg$parse_();
          if (input.charCodeAt(peg$currPos) === 44) {
            s9 = peg$c1;
            peg$currPos++;
          } else {
            s9 = peg$FAILED;
            if (peg$silentFails === 0) { peg$fail(peg$e1); }
          }
          if (s9 !== peg$FAILED) {
            peg$parse_();
            s11 = peg$parseExpression();
            if (s11 !== peg$FAILED) {
              s7 = s11;
            } else {
              peg$currPos = s7;
              s7 = peg$FAILED;
            }
          } else {
            peg$currPos = s7;
            s7 = peg$FAILED;
          }
        }
        s7 = peg$parse_();
        if (input.charCodeAt(peg$currPos) === 41) {
          s8 = peg$c2;
          peg$currPos++;
        } else {
          s8 = peg$FAILED;
          if (peg$silentFails === 0) { peg$fail(peg$e2); }
        }
        if (s8 !== peg$FAILED) {
          s9 = peg$parseFlavor();
          if (s9 === peg$FAILED) {
            s9 = null;
          }
          peg$savedPos = s0;
          s0 = peg$f1(s1, s4, s6, s9);
        } else {
          peg$currPos = s0;
          s0 = peg$FAILED;
        }
      } else {
        peg$currPos = s0;
        s0 = peg$FAILED;
      }
    } else {
      peg$currPos = s0;
      s0 = peg$FAILED;
    }

    return s0;
  }

  function peg$parseDiceTerm() {
    var s0, s1, s2, s3, s4, s5;

    s0 = peg$currPos;
    s1 = peg$parseParenthetical();
    if (s1 === peg$FAILED) {
      s1 = peg$parseConstant();
    }
    if (s1 === peg$FAILED) {
      s1 = null;
    }
    s2 = input.charAt(peg$currPos);
    if (peg$r0.test(s2)) {
      peg$currPos++;
    } else {
      s2 = peg$FAILED;
      if (peg$silentFails === 0) { peg$fail(peg$e3); }
    }
    if (s2 !== peg$FAILED) {
      s3 = input.charAt(peg$currPos);
      if (peg$r1.test(s3)) {
        peg$currPos++;
      } else {
        s3 = peg$FAILED;
        if (peg$silentFails === 0) { peg$fail(peg$e4); }
      }
      if (s3 === peg$FAILED) {
        s3 = peg$parseParenthetical();
        if (s3 === peg$FAILED) {
          s3 = peg$parseConstant();
        }
      }
      if (s3 !== peg$FAILED) {
        s4 = peg$parseModifiers();
        if (s4 === peg$FAILED) {
          s4 = null;
        }
        s5 = peg$parseFlavor();
        if (s5 === peg$FAILED) {
          s5 = null;
        }
        peg$savedPos = s0;
        s0 = peg$f2(s1, s3, s4, s5);
      } else {
        peg$currPos = s0;
        s0 = peg$FAILED;
      }
    } else {
      peg$currPos = s0;
      s0 = peg$FAILED;
    }

    return s0;
  }

  function peg$parseNumericTerm() {
    var s0, s1, s2, s3, s4;

    s0 = peg$currPos;
    s1 = peg$parseConstant();
    if (s1 !== peg$FAILED) {
      s2 = peg$parseFlavor();
      if (s2 === peg$FAILED) {
        s2 = null;
      }
      s3 = peg$currPos;
      peg$silentFails++;
      s4 = peg$parseStringTerm();
      peg$silentFails--;
      if (s4 === peg$FAILED) {
        s3 = undefined;
      } else {
        peg$currPos = s3;
        s3 = peg$FAILED;
      }
      if (s3 !== peg$FAILED) {
        peg$savedPos = s0;
        s0 = peg$f3(s1, s2);
      } else {
        peg$currPos = s0;
        s0 = peg$FAILED;
      }
    } else {
      peg$currPos = s0;
      s0 = peg$FAILED;
    }

    return s0;
  }

  function peg$parsePoolTerm() {
    var s0, s1, s3, s4, s5, s6, s7, s8;

    s0 = peg$currPos;
    if (input.charCodeAt(peg$currPos) === 123) {
      s1 = peg$c3;
      peg$currPos++;
    } else {
      s1 = peg$FAILED;
      if (peg$silentFails === 0) { peg$fail(peg$e5); }
    }
    if (s1 !== peg$FAILED) {
      peg$parse_();
      s3 = peg$parseExpression();
      if (s3 !== peg$FAILED) {
        s4 = [];
        s5 = peg$currPos;
        if (input.charCodeAt(peg$currPos) === 44) {
          s6 = peg$c1;
          peg$currPos++;
        } else {
          s6 = peg$FAILED;
          if (peg$silentFails === 0) { peg$fail(peg$e1); }
        }
        if (s6 !== peg$FAILED) {
          s7 = peg$parse_();
          s8 = peg$parseExpression();
          if (s8 !== peg$FAILED) {
            s5 = s8;
          } else {
            peg$currPos = s5;
            s5 = peg$FAILED;
          }
        } else {
          peg$currPos = s5;
          s5 = peg$FAILED;
        }
        while (s5 !== peg$FAILED) {
          s4.push(s5);
          s5 = peg$currPos;
          if (input.charCodeAt(peg$currPos) === 44) {
            s6 = peg$c1;
            peg$currPos++;
          } else {
            s6 = peg$FAILED;
            if (peg$silentFails === 0) { peg$fail(peg$e1); }
          }
          if (s6 !== peg$FAILED) {
            s7 = peg$parse_();
            s8 = peg$parseExpression();
            if (s8 !== peg$FAILED) {
              s5 = s8;
            } else {
              peg$currPos = s5;
              s5 = peg$FAILED;
            }
          } else {
            peg$currPos = s5;
            s5 = peg$FAILED;
          }
        }
        if (input.charCodeAt(peg$currPos) === 125) {
          s5 = peg$c4;
          peg$currPos++;
        } else {
          s5 = peg$FAILED;
          if (peg$silentFails === 0) { peg$fail(peg$e6); }
        }
        if (s5 !== peg$FAILED) {
          s6 = peg$parseModifiers();
          if (s6 === peg$FAILED) {
            s6 = null;
          }
          s7 = peg$parseFlavor();
          if (s7 === peg$FAILED) {
            s7 = null;
          }
          peg$savedPos = s0;
          s0 = peg$f4(s3, s4, s6, s7);
        } else {
          peg$currPos = s0;
          s0 = peg$FAILED;
        }
      } else {
        peg$currPos = s0;
        s0 = peg$FAILED;
      }
    } else {
      peg$currPos = s0;
      s0 = peg$FAILED;
    }

    return s0;
  }

  function peg$parseParenthetical() {
    var s0, s1, s3, s5, s6;

    s0 = peg$currPos;
    if (input.charCodeAt(peg$currPos) === 40) {
      s1 = peg$c0;
      peg$currPos++;
    } else {
      s1 = peg$FAILED;
      if (peg$silentFails === 0) { peg$fail(peg$e0); }
    }
    if (s1 !== peg$FAILED) {
      peg$parse_();
      s3 = peg$parseExpression();
      if (s3 !== peg$FAILED) {
        peg$parse_();
        if (input.charCodeAt(peg$currPos) === 41) {
          s5 = peg$c2;
          peg$currPos++;
        } else {
          s5 = peg$FAILED;
          if (peg$silentFails === 0) { peg$fail(peg$e2); }
        }
        if (s5 !== peg$FAILED) {
          s6 = peg$parseFlavor();
          if (s6 === peg$FAILED) {
            s6 = null;
          }
          peg$savedPos = s0;
          s0 = peg$f5(s3, s6);
        } else {
          peg$currPos = s0;
          s0 = peg$FAILED;
        }
      } else {
        peg$currPos = s0;
        s0 = peg$FAILED;
      }
    } else {
      peg$currPos = s0;
      s0 = peg$FAILED;
    }

    return s0;
  }

  function peg$parseStringTerm() {
    var s0, s1, s2;

    s0 = peg$currPos;
    s1 = peg$parseReplacedData();
    if (s1 === peg$FAILED) {
      s1 = peg$parsePlainString();
    }
    if (s1 !== peg$FAILED) {
      s2 = peg$parseFlavor();
      if (s2 === peg$FAILED) {
        s2 = null;
      }
      peg$savedPos = s0;
      s0 = peg$f6(s1, s2);
    } else {
      peg$currPos = s0;
      s0 = peg$FAILED;
    }

    return s0;
  }

  function peg$parseReplacedData() {
    var s0, s1, s2, s3, s4, s5;

    s0 = peg$currPos;
    s1 = peg$currPos;
    if (input.charCodeAt(peg$currPos) === 5782) {
      s2 = peg$c5;
      peg$currPos++;
    } else {
      s2 = peg$FAILED;
      if (peg$silentFails === 0) { peg$fail(peg$e7); }
    }
    if (s2 !== peg$FAILED) {
      s3 = peg$currPos;
      s4 = [];
      s5 = input.charAt(peg$currPos);
      if (peg$r2.test(s5)) {
        peg$currPos++;
      } else {
        s5 = peg$FAILED;
        if (peg$silentFails === 0) { peg$fail(peg$e8); }
      }
      if (s5 !== peg$FAILED) {
        while (s5 !== peg$FAILED) {
          s4.push(s5);
          s5 = input.charAt(peg$currPos);
          if (peg$r2.test(s5)) {
            peg$currPos++;
          } else {
            s5 = peg$FAILED;
            if (peg$silentFails === 0) { peg$fail(peg$e8); }
          }
        }
      } else {
        s4 = peg$FAILED;
      }
      if (s4 !== peg$FAILED) {
        s3 = input.substring(s3, peg$currPos);
      } else {
        s3 = s4;
      }
      if (s3 !== peg$FAILED) {
        if (input.charCodeAt(peg$currPos) === 5782) {
          s4 = peg$c5;
          peg$currPos++;
        } else {
          s4 = peg$FAILED;
          if (peg$silentFails === 0) { peg$fail(peg$e7); }
        }
        if (s4 !== peg$FAILED) {
          s2 = [s2, s3, s4];
          s1 = s2;
        } else {
          peg$currPos = s1;
          s1 = peg$FAILED;
        }
      } else {
        peg$currPos = s1;
        s1 = peg$FAILED;
      }
    } else {
      peg$currPos = s1;
      s1 = peg$FAILED;
    }
    if (s1 !== peg$FAILED) {
      s0 = input.substring(s0, peg$currPos);
    } else {
      s0 = s1;
    }

    return s0;
  }

  function peg$parsePlainString() {
    var s0, s1, s2;

    s0 = peg$currPos;
    s1 = [];
    s2 = input.charAt(peg$currPos);
    if (peg$r3.test(s2)) {
      peg$currPos++;
    } else {
      s2 = peg$FAILED;
      if (peg$silentFails === 0) { peg$fail(peg$e9); }
    }
    if (s2 !== peg$FAILED) {
      while (s2 !== peg$FAILED) {
        s1.push(s2);
        s2 = input.charAt(peg$currPos);
        if (peg$r3.test(s2)) {
          peg$currPos++;
        } else {
          s2 = peg$FAILED;
          if (peg$silentFails === 0) { peg$fail(peg$e9); }
        }
      }
    } else {
      s1 = peg$FAILED;
    }
    if (s1 !== peg$FAILED) {
      s0 = input.substring(s0, peg$currPos);
    } else {
      s0 = s1;
    }

    return s0;
  }

  function peg$parseFunctionName() {
    var s0, s1, s2, s3, s4;

    s0 = peg$currPos;
    s1 = peg$currPos;
    s2 = input.charAt(peg$currPos);
    if (peg$r4.test(s2)) {
      peg$currPos++;
    } else {
      s2 = peg$FAILED;
      if (peg$silentFails === 0) { peg$fail(peg$e10); }
    }
    if (s2 !== peg$FAILED) {
      s3 = [];
      s4 = input.charAt(peg$currPos);
      if (peg$r5.test(s4)) {
        peg$currPos++;
      } else {
        s4 = peg$FAILED;
        if (peg$silentFails === 0) { peg$fail(peg$e11); }
      }
      while (s4 !== peg$FAILED) {
        s3.push(s4);
        s4 = input.charAt(peg$currPos);
        if (peg$r5.test(s4)) {
          peg$currPos++;
        } else {
          s4 = peg$FAILED;
          if (peg$silentFails === 0) { peg$fail(peg$e11); }
        }
      }
      s2 = [s2, s3];
      s1 = s2;
    } else {
      peg$currPos = s1;
      s1 = peg$FAILED;
    }
    if (s1 !== peg$FAILED) {
      s0 = input.substring(s0, peg$currPos);
    } else {
      s0 = s1;
    }

    return s0;
  }

  function peg$parseModifiers() {
    var s0, s1, s2;

    s0 = peg$currPos;
    s1 = [];
    s2 = input.charAt(peg$currPos);
    if (peg$r6.test(s2)) {
      peg$currPos++;
    } else {
      s2 = peg$FAILED;
      if (peg$silentFails === 0) { peg$fail(peg$e12); }
    }
    if (s2 !== peg$FAILED) {
      while (s2 !== peg$FAILED) {
        s1.push(s2);
        s2 = input.charAt(peg$currPos);
        if (peg$r6.test(s2)) {
          peg$currPos++;
        } else {
          s2 = peg$FAILED;
          if (peg$silentFails === 0) { peg$fail(peg$e12); }
        }
      }
    } else {
      s1 = peg$FAILED;
    }
    if (s1 !== peg$FAILED) {
      s0 = input.substring(s0, peg$currPos);
    } else {
      s0 = s1;
    }

    return s0;
  }

  function peg$parseConstant() {
    var s0, s2, s3, s4, s5, s6;

    s0 = peg$currPos;
    peg$parse_();
    s2 = [];
    s3 = input.charAt(peg$currPos);
    if (peg$r7.test(s3)) {
      peg$currPos++;
    } else {
      s3 = peg$FAILED;
      if (peg$silentFails === 0) { peg$fail(peg$e13); }
    }
    if (s3 !== peg$FAILED) {
      while (s3 !== peg$FAILED) {
        s2.push(s3);
        s3 = input.charAt(peg$currPos);
        if (peg$r7.test(s3)) {
          peg$currPos++;
        } else {
          s3 = peg$FAILED;
          if (peg$silentFails === 0) { peg$fail(peg$e13); }
        }
      }
    } else {
      s2 = peg$FAILED;
    }
    if (s2 !== peg$FAILED) {
      s3 = peg$currPos;
      if (input.charCodeAt(peg$currPos) === 46) {
        s4 = peg$c6;
        peg$currPos++;
      } else {
        s4 = peg$FAILED;
        if (peg$silentFails === 0) { peg$fail(peg$e14); }
      }
      if (s4 !== peg$FAILED) {
        s5 = [];
        s6 = input.charAt(peg$currPos);
        if (peg$r7.test(s6)) {
          peg$currPos++;
        } else {
          s6 = peg$FAILED;
          if (peg$silentFails === 0) { peg$fail(peg$e13); }
        }
        if (s6 !== peg$FAILED) {
          while (s6 !== peg$FAILED) {
            s5.push(s6);
            s6 = input.charAt(peg$currPos);
            if (peg$r7.test(s6)) {
              peg$currPos++;
            } else {
              s6 = peg$FAILED;
              if (peg$silentFails === 0) { peg$fail(peg$e13); }
            }
          }
        } else {
          s5 = peg$FAILED;
        }
        if (s5 !== peg$FAILED) {
          s4 = [s4, s5];
          s3 = s4;
        } else {
          peg$currPos = s3;
          s3 = peg$FAILED;
        }
      } else {
        peg$currPos = s3;
        s3 = peg$FAILED;
      }
      if (s3 === peg$FAILED) {
        s3 = null;
      }
      peg$savedPos = s0;
      s0 = peg$f7();
    } else {
      peg$currPos = s0;
      s0 = peg$FAILED;
    }

    return s0;
  }

  function peg$parseOperators() {
    var s0;

    s0 = peg$parseMultiplicativeFirst();
    if (s0 === peg$FAILED) {
      s0 = peg$parseAdditiveOnly();
    }

    return s0;
  }

  function peg$parseMultiplicativeFirst() {
    var s0, s1, s2, s3, s5;

    s0 = peg$currPos;
    s1 = peg$parseMultiplicative();
    if (s1 !== peg$FAILED) {
      s2 = [];
      s3 = peg$currPos;
      peg$parse_();
      s5 = peg$parseAdditive();
      if (s5 !== peg$FAILED) {
        s3 = s5;
      } else {
        peg$currPos = s3;
        s3 = peg$FAILED;
      }
      while (s3 !== peg$FAILED) {
        s2.push(s3);
        s3 = peg$currPos;
        peg$parse_();
        s5 = peg$parseAdditive();
        if (s5 !== peg$FAILED) {
          s3 = s5;
        } else {
          peg$currPos = s3;
          s3 = peg$FAILED;
        }
      }
      peg$savedPos = s0;
      s0 = peg$f8(s1, s2);
    } else {
      peg$currPos = s0;
      s0 = peg$FAILED;
    }

    return s0;
  }

  function peg$parseAdditiveOnly() {
    var s0, s1, s2, s3, s5;

    s0 = peg$currPos;
    s1 = peg$parseAdditive();
    if (s1 !== peg$FAILED) {
      s2 = [];
      s3 = peg$currPos;
      peg$parse_();
      s5 = peg$parseAdditive();
      if (s5 !== peg$FAILED) {
        s3 = s5;
      } else {
        peg$currPos = s3;
        s3 = peg$FAILED;
      }
      while (s3 !== peg$FAILED) {
        s2.push(s3);
        s3 = peg$currPos;
        peg$parse_();
        s5 = peg$parseAdditive();
        if (s5 !== peg$FAILED) {
          s3 = s5;
        } else {
          peg$currPos = s3;
          s3 = peg$FAILED;
        }
      }
      peg$savedPos = s0;
      s0 = peg$f9(s1, s2);
    } else {
      peg$currPos = s0;
      s0 = peg$FAILED;
    }

    return s0;
  }

  function peg$parseMultiplicative() {
    var s0;

    s0 = input.charAt(peg$currPos);
    if (peg$r8.test(s0)) {
      peg$currPos++;
    } else {
      s0 = peg$FAILED;
      if (peg$silentFails === 0) { peg$fail(peg$e15); }
    }

    return s0;
  }

  function peg$parseAdditive() {
    var s0;

    s0 = input.charAt(peg$currPos);
    if (peg$r9.test(s0)) {
      peg$currPos++;
    } else {
      s0 = peg$FAILED;
      if (peg$silentFails === 0) { peg$fail(peg$e16); }
    }

    return s0;
  }

  function peg$parseFlavor() {
    var s0, s1, s2, s3, s4;

    s0 = peg$currPos;
    if (input.charCodeAt(peg$currPos) === 91) {
      s1 = peg$c7;
      peg$currPos++;
    } else {
      s1 = peg$FAILED;
      if (peg$silentFails === 0) { peg$fail(peg$e17); }
    }
    if (s1 !== peg$FAILED) {
      s2 = peg$currPos;
      s3 = [];
      s4 = input.charAt(peg$currPos);
      if (peg$r10.test(s4)) {
        peg$currPos++;
      } else {
        s4 = peg$FAILED;
        if (peg$silentFails === 0) { peg$fail(peg$e18); }
      }
      if (s4 !== peg$FAILED) {
        while (s4 !== peg$FAILED) {
          s3.push(s4);
          s4 = input.charAt(peg$currPos);
          if (peg$r10.test(s4)) {
            peg$currPos++;
          } else {
            s4 = peg$FAILED;
            if (peg$silentFails === 0) { peg$fail(peg$e18); }
          }
        }
      } else {
        s3 = peg$FAILED;
      }
      if (s3 !== peg$FAILED) {
        s2 = input.substring(s2, peg$currPos);
      } else {
        s2 = s3;
      }
      if (s2 !== peg$FAILED) {
        if (input.charCodeAt(peg$currPos) === 93) {
          s3 = peg$c8;
          peg$currPos++;
        } else {
          s3 = peg$FAILED;
          if (peg$silentFails === 0) { peg$fail(peg$e19); }
        }
        if (s3 !== peg$FAILED) {
          s0 = s2;
        } else {
          peg$currPos = s0;
          s0 = peg$FAILED;
        }
      } else {
        peg$currPos = s0;
        s0 = peg$FAILED;
      }
    } else {
      peg$currPos = s0;
      s0 = peg$FAILED;
    }

    return s0;
  }

  function peg$parse_() {
    var s0, s1;

    peg$silentFails++;
    s0 = [];
    s1 = input.charAt(peg$currPos);
    if (peg$r11.test(s1)) {
      peg$currPos++;
    } else {
      s1 = peg$FAILED;
      if (peg$silentFails === 0) { peg$fail(peg$e21); }
    }
    while (s1 !== peg$FAILED) {
      s0.push(s1);
      s1 = input.charAt(peg$currPos);
      if (peg$r11.test(s1)) {
        peg$currPos++;
      } else {
        s1 = peg$FAILED;
        if (peg$silentFails === 0) { peg$fail(peg$e21); }
      }
    }
    peg$silentFails--;
    s1 = peg$FAILED;
    if (peg$silentFails === 0) { peg$fail(peg$e20); }

    return s0;
  }


  /*

  This is a per-parser initialization block. It runs whenever the parser is invoked. Any variables declared here are
  available in any javascript blocks in the rest of the grammar.

  In addition to the parser generated by peggy, we allow for certain parts of the process to be delegated to client
  code. A class implementing this 'parser' API may be passed-in here as an option when the peggy parser is invoked,
  otherwise we use the one configured at CONFIG.Dice.parser.

  */

  const Parser = options.parser ?? CONFIG.Dice.parser;
  const parser = new Parser(input);

  peg$result = peg$startRuleFunction();

  if (options.peg$library) {
    return /** @type {any} */ ({
      peg$result,
      peg$currPos,
      peg$FAILED,
      peg$maxFailExpected,
      peg$maxFailPos
    });
  }
  if (peg$result !== peg$FAILED && peg$currPos === input.length) {
    return peg$result;
  } else {
    if (peg$result !== peg$FAILED && peg$currPos < input.length) {
      peg$fail(peg$endExpectation());
    }

    throw peg$buildStructuredError(
      peg$maxFailExpected,
      peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null,
      peg$maxFailPos < input.length
        ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1)
        : peg$computeLocation(peg$maxFailPos, peg$maxFailPos)
    );
  }
}

  return {
    StartRules: ["Expression"],
    SyntaxError: peg$SyntaxError,
    parse: peg$parse
  };
})()
;

/**
 * @import {
 *   RollParseNode,
 *   RollParseTreeNode,
 *   NumericRollParseNode,
 *   FunctionRollParseNode,
 *   PoolRollParseNode,
 *   ParentheticalRollParseNode,
 *   DiceRollParseNode,
 *   RollParseArg
 * } from "./_types.mjs";
 */

/**
 * A class for transforming events from the Peggy grammar lexer into various formats.
 */
class RollParser {
  /**
   * @param {string} formula  The full formula.
   */
  constructor(formula) {
    this.formula = formula;
  }

  /**
   * The full formula.
   * @type {string}
   */
  formula;

  /* -------------------------------------------- */
  /*  Parse Events                                */
  /* -------------------------------------------- */

  /**
   * Handle a base roll expression.
   * @param {RollParseNode} head                The first operand.
   * @param {[string[], RollParseNode][]} tail  Zero or more subsequent (operators, operand) tuples.
   * @param {string} [leading]                  A leading operator.
   * @param {string} formula                    The original matched text.
   * @param {Function} error                    The peggy error callback to invoke on a parse error.
   * @returns {RollParseTreeNode}
   * @protected
   */
  _onExpression(head, tail, leading, formula, error) {
    if ( CONFIG.debug.rollParsing ) console.debug(this.constructor.formatDebug("onExpression", head, tail));
    if ( leading.length ) leading = this._collapseOperators(leading);
    if ( leading === "-" ) head = this._wrapNegativeTerm(head);

    // We take the list of (operator, operand) tuples and arrange them into a left-skewed binary tree.
    return tail.reduce((acc, [operators, operand]) => {
      let operator;
      let [multiplicative, ...additive] = operators;
      if ( additive.length ) additive = this._collapseOperators(additive);
      if ( multiplicative ) {
        operator = multiplicative;
        if ( additive === "-" ) operand = this._wrapNegativeTerm(operand);
      }
      else operator = additive;
      if ( typeof operator !== "string" ) error(`Failed to parse ${formula}. Unexpected operator.`);
      const operands = [acc, operand];
      return { class: "Node", formula: `${acc.formula} ${operator} ${operand.formula}`, operands, operator };
    }, head);
  }

  /* -------------------------------------------- */

  /**
   * Handle a dice term.
   * @param {NumericRollParseNode|ParentheticalRollParseNode|null} number  The number of dice.
   * @param {string|NumericRollParseNode|ParentheticalRollParseNode|null} faces  The number of die faces or a string
   *                                                                             denomination like "c" or "f".
   * @param {string|null} modifiers                                        The matched modifiers string.
   * @param {string|null} flavor                                           Associated flavor text.
   * @param {string} formula                                               The original matched text.
   * @returns {DiceRollParseNode}
   * @protected
   */
  _onDiceTerm(number, faces, modifiers, flavor, formula) {
    if ( CONFIG.debug.rollParsing ) {
      console.debug(this.constructor.formatDebug("onDiceTerm", number, faces, modifiers, flavor, formula));
    }
    return { class: "DiceTerm", formula, modifiers, number, faces, evaluated: false, options: { flavor } };
  }

  /* -------------------------------------------- */

  /**
   * Handle a numeric term.
   * @param {number} number  The number.
   * @param {string} flavor  Associated flavor text.
   * @returns {NumericRollParseNode}
   * @protected
   */
  _onNumericTerm(number, flavor) {
    if ( CONFIG.debug.rollParsing ) console.debug(this.constructor.formatDebug("onNumericTerm", number, flavor));
    return {
      class: "NumericTerm", number,
      formula: `${number}${flavor ? `[${flavor}]` : ""}`,
      evaluated: false,
      options: { flavor }
    };
  }

  /* -------------------------------------------- */

  /**
   * Handle a math term.
   * @param {string} fn             The Math function.
   * @param {RollParseNode} head    The first term.
   * @param {RollParseNode[]} tail  Zero or more additional terms.
   * @param {string} flavor         Associated flavor text.
   * @param {string} formula        The original matched text.
   * @returns {FunctionRollParseNode}
   * @protected
   */
  _onFunctionTerm(fn, head, tail, flavor, formula) {
    if ( CONFIG.debug.rollParsing ) {
      console.debug(this.constructor.formatDebug("onFunctionTerm", fn, head, tail, flavor, formula));
    }
    const terms = [];
    if ( head ) terms.push(head, ...tail);
    return { class: "FunctionTerm", fn, terms, formula, evaluated: false, options: { flavor } };
  }

  /* -------------------------------------------- */

  /**
   * Handle a pool term.
   * @param {RollParseNode} head     The first term.
   * @param {RollParseNode[]} tail   Zero or more additional terms.
   * @param {string|null} modifiers  The matched modifiers string.
   * @param {string|null} flavor     Associated flavor text.
   * @param {string} formula         The original matched text.
   * @returns {PoolRollParseNode}
   * @protected
   */
  _onPoolTerm(head, tail, modifiers, flavor, formula) {
    if ( CONFIG.debug.rollParsing ) {
      console.debug(this.constructor.formatDebug("onPoolTerm", head, tail, modifiers, flavor, formula));
    }
    const terms = [];
    if ( head ) terms.push(head, ...tail);
    return { class: "PoolTerm", terms, formula, modifiers, evaluated: false, options: { flavor } };
  }

  /* -------------------------------------------- */

  /**
   * Handle a parenthetical.
   * @param {RollParseNode} term  The inner term.
   * @param {string|null} flavor  Associated flavor text.
   * @param {string} formula      The original matched text.
   * @returns {ParentheticalRollParseNode}
   * @protected
   */
  _onParenthetical(term, flavor, formula) {
    if ( CONFIG.debug.rollParsing ) {
      console.debug(this.constructor.formatDebug("onParenthetical", term, flavor, formula));
    }
    return { class: "ParentheticalTerm", term, formula, evaluated: false, options: { flavor } };
  }

  /* -------------------------------------------- */

  /**
   * Handle some string that failed to be classified.
   * @param {string} term  The term.
   * @param {string|null} [flavor]  Associated flavor text.
   * @returns {StringParseNode}
   * @protected
   */
  _onStringTerm(term, flavor) {
    return { class: "StringTerm", term, evaluated: false, options: { flavor } };
  }

  /* -------------------------------------------- */

  /**
   * Collapse multiple additive operators into a single one.
   * @param {string[]} operators  A sequence of additive operators.
   * @returns {string}
   * @protected
   */
  _collapseOperators(operators) {
    let head = operators.pop();
    for ( const operator of operators ) {
      if ( operator === "-" ) head = head === "+" ? "-" : "+";
    }
    return head;
  }

  /* -------------------------------------------- */

  /**
   * Wrap a term with a leading minus.
   * @param {RollParseNode} term  The term to wrap.
   * @returns {RollParseNode}
   * @protected
   */
  _wrapNegativeTerm(term) {
    // Special case when we have a numeric term, otherwise we wrap it in a parenthetical.
    if ( term.class === "NumericTerm" ) {
      term.number *= -1;
      term.formula = `-${term.formula}`;
      return term;
    }

    return foundry.dice.RollGrammar.parse(`(${term.formula} * -1)`, { parser: this.constructor });
  }

  /* -------------------------------------------- */
  /*  Tree Manipulation                           */
  /* -------------------------------------------- */

  /**
   * Flatten a tree structure (either a parse tree or AST) into an array with operators in infix notation.
   * @param {RollParseNode} root  The root of the tree.
   * @returns {RollParseNode[]}
   */
  static flattenTree(root) {
    const list = [];

    /**
     * Flatten the given node.
     * @param {RollParseNode} node  The node.
     */
    function flattenNode(node) {
      if ( node.class !== "Node" ) {
        list.push(node);
        return;
      }

      const [left, right] = node.operands;
      flattenNode(left);
      list.push({ class: "OperatorTerm", operator: node.operator });
      flattenNode(right);
    }

    flattenNode(root);
    return list;
  }

  /* -------------------------------------------- */

  /**
   * Use the Shunting Yard algorithm to convert a parse tree or list of terms into an AST with correct operator
   * precedence.
   * See https://en.wikipedia.org/wiki/Shunting_yard_algorithm for a description of the algorithm in detail.
   * @param {RollParseNode|RollTerm[]} root  The root of the parse tree or a list of terms.
   * @returns {RollParseNode}                The root of the AST.
   */
  static toAST(root) {
    // Flatten the parse tree to an array representing the original formula in infix notation.
    const list = Array.isArray(root) ? root : this.flattenTree(root);
    const operators = [];
    const output = [];

    /**
     * Pop operators from the operator stack and push them onto the output stack until we reach an operator with lower
     * or equal precedence and left-associativity.
     * @param {RollParseNode} op  The target operator to push.
     */
    function pushOperator(op) {
      let peek = operators.at(-1);
      // We assume all our operators are left-associative, so we only check if the precedence is lower or equal here.
      while ( peek && ((OperatorTerm.PRECEDENCE[peek.operator] ?? 0) >= (OperatorTerm.PRECEDENCE[op.operator] ?? 0)) ) {
        output.push(operators.pop());
        peek = operators.at(-1);
      }
      operators.push(op);
    }

    for ( const node of list ) {
      // If this is an operator, push it onto the operators stack.
      if ( this.isOperatorTerm(node) ) {
        pushOperator(node);
        continue;
      }

      // Recursively reorganize inner terms to AST sub-trees.
      if ( node.class === "ParentheticalTerm" ) node.term = this.toAST(node.term);
      else if ( (node.class === "FunctionTerm") || (node.class === "PoolTerm") ) {
        node.terms = node.terms.map(term => this.toAST(term));
      }

      // Push the node onto the output stack.
      output.push(node);
    }

    // Pop remaining operators off the operator stack and onto the output stack.
    while ( operators.length ) output.push(operators.pop());

    // The output now contains the formula in postfix notation, with correct operator precedence applied. We recombine
    // it into a tree by matching each postfix operator with two operands.
    const ast = [];
    for ( const node of output ) {
      if ( !this.isOperatorTerm(node) ) {
        ast.push(node);
        continue;
      }
      const right = ast.pop();
      const left = ast.pop();
      ast.push({ class: "Node", operator: node.operator, operands: [left, right] });
    }

    // The postfix array has been recombined into an array of one element, which is the root of the new AST.
    return ast.pop();
  }

  /* -------------------------------------------- */

  /**
   * Determine if a given node is an operator term.
   * @param {RollParseNode|RollTerm} node
   */
  static isOperatorTerm(node) {
    return (node instanceof OperatorTerm) || (node.class === "OperatorTerm");
  }

  /* -------------------------------------------- */
  /*  Debug Formatting                            */
  /* -------------------------------------------- */

  /**
   * Format a list argument.
   * @param {RollParseArg[]} list  The list to format.
   * @returns {string}
   */
  static formatList(list) {
    if ( !list ) return "[]";
    return `[${list.map(RollParser.formatArg).join(", ")}]`;
  }

  /* -------------------------------------------- */

  /**
   * Format a parser argument.
   * @param {RollParseArg} arg  The argument.
   * @returns {string}
   */
  static formatArg(arg) {
    switch ( getType(arg) ) {
      case "null": return "null";
      case "number": return `${arg}`;
      case "string": return `"${arg}"`;
      case "Object": return arg.class;
      case "Array": return RollParser.formatList(arg);
    }
  }

  /* -------------------------------------------- */

  /**
   * Format arguments for debugging.
   * @param {string} method         The method name.
   * @param {...RollParseArg} args  The arguments.
   * @returns {string}
   */
  static formatDebug(method, ...args) {
    return `${method}(${args.map(RollParser.formatArg).join(", ")})`;
  }
}

/**
 * A standalone, pure JavaScript implementation of the Mersenne Twister pseudo random number generator.
 */
class MersenneTwister {
  /**
   * @author Raphael Pigulla <pigulla@four66.com>
   * @version 0.2.3
   * @license
   * Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura,
   * All rights reserved.
   *
   * Redistribution and use in source and binary forms, with or without
   * modification, are permitted provided that the following conditions
   * are met:
   *
   * 1. Redistributions of source code must retain the above copyright
   * notice, this list of conditions and the following disclaimer.
   *
   * 2. Redistributions in binary form must reproduce the above copyright
   * notice, this list of conditions and the following disclaimer in the
   * documentation and/or other materials provided with the distribution.
   *
   * 3. The names of its contributors may not be used to endorse or promote
   * products derived from this software without specific prior written
   * permission.
   *
   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
   * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
   * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
   * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR
   * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
   * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
   * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
   * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
   * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
   * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
   * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
   */

  /**
   * Instantiates a new Mersenne Twister.
   * @param {number} [seed]   The initial seed value, if not provided the current timestamp will be used.
   */
  constructor(seed) {

    // Initial values
    this.MAX_INT = 4294967296.0;
    this.N = 624;
    this.M = 397;
    this.UPPER_MASK = 0x80000000;
    this.LOWER_MASK = 0x7fffffff;
    this.MATRIX_A = 0x9908b0df;

    // Initialize sequences
    this.mt = new Array(this.N);
    this.mti = this.N + 1;
    this.SEED = this.seed(seed ?? new Date().getTime());
  };

  /**
   * Initializes the state vector by using one unsigned 32-bit integer "seed", which may be zero.
   *
   * @since 0.1.0
   * @param {number} seed The seed value.
   */
  seed(seed) {
    this.SEED = seed;
    let s;
    this.mt[0] = seed >>> 0;

    for (this.mti = 1; this.mti < this.N; this.mti++) {
      s = this.mt[this.mti - 1] ^ (this.mt[this.mti - 1] >>> 30);
      this.mt[this.mti] =
        (((((s & 0xffff0000) >>> 16) * 1812433253) << 16) + (s & 0x0000ffff) * 1812433253) + this.mti;
      this.mt[this.mti] >>>= 0;
    }
    return seed;
  };

  /**
   * Initializes the state vector by using an array key[] of unsigned 32-bit integers of the specified length. If
   * length is smaller than 624, then each array of 32-bit integers gives distinct initial state vector. This is
   * useful if you want a larger seed space than 32-bit word.
   *
   * @since 0.1.0
   * @param {array} vector The seed vector.
   */
  seedArray(vector) {
    let i = 1, j = 0, k = this.N > vector.length ? this.N : vector.length, s;
    this.seed(19650218);
    for (; k > 0; k--) {
      s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30);

      this.mt[i] = (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1664525) << 16) + ((s & 0x0000ffff) * 1664525))) +
        vector[j] + j;
      this.mt[i] >>>= 0;
      i++;
      j++;
      if (i >= this.N) {
        this.mt[0] = this.mt[this.N-1];
        i = 1;
      }
      if (j >= vector.length) {
        j = 0;
      }
    }

    for (k = this.N-1; k; k--) {
      s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30);
      this.mt[i] =
        (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1566083941) << 16) + (s & 0x0000ffff) * 1566083941)) - i;
      this.mt[i] >>>= 0;
      i++;
      if (i >= this.N) {
        this.mt[0] = this.mt[this.N - 1];
        i = 1;
      }
    }
    this.mt[0] = 0x80000000;
  };

  /**
   * Generates a random unsigned 32-bit integer.
   *
   * @since 0.1.0
   * @returns {number}
   */
  int() {
    let y, kk, mag01 = [0, this.MATRIX_A];

    if (this.mti >= this.N) {
      if (this.mti === this.N+1) {
        this.seed(5489);
      }

      for (kk = 0; kk < this.N - this.M; kk++) {
        y = (this.mt[kk] & this.UPPER_MASK) | (this.mt[kk + 1] & this.LOWER_MASK);
        this.mt[kk] = this.mt[kk + this.M] ^ (y >>> 1) ^ mag01[y & 1];
      }

      for (; kk < this.N - 1; kk++) {
        y = (this.mt[kk] & this.UPPER_MASK) | (this.mt[kk + 1] & this.LOWER_MASK);
        this.mt[kk] = this.mt[kk + (this.M - this.N)] ^ (y >>> 1) ^ mag01[y & 1];
      }

      y = (this.mt[this.N - 1] & this.UPPER_MASK) | (this.mt[0] & this.LOWER_MASK);
      this.mt[this.N - 1] = this.mt[this.M - 1] ^ (y >>> 1) ^ mag01[y & 1];
      this.mti = 0;
    }

    y = this.mt[this.mti++];

    y ^= (y >>> 11);
    y ^= (y << 7) & 0x9d2c5680;
    y ^= (y << 15) & 0xefc60000;
    y ^= (y >>> 18);

    return y >>> 0;
  };

  /**
   * Generates a random unsigned 31-bit integer.
   *
   * @since 0.1.0
   * @returns {number}
   */
  int31() {
    return this.int() >>> 1;
  };

  /**
   * Generates a random real in the interval [0;1] with 32-bit resolution.
   *
   * @since 0.1.0
   * @returns {number}
   */
  real() {
    return this.int() * (1.0 / (this.MAX_INT - 1));
  };

  /**
   * Generates a random real in the interval ]0;1[ with 32-bit resolution.
   *
   * @since 0.1.0
   * @returns {number}
   */
  realx() {
    return (this.int() + 0.5) * (1.0 / this.MAX_INT);
  };

  /**
   * Generates a random real in the interval [0;1[ with 32-bit resolution.
   *
   * @since 0.1.0
   * @returns {number}
   */
  rnd() {
    return this.int() * (1.0 / this.MAX_INT);
  };

  /**
   * Generates a random real in the interval [0;1[ with 32-bit resolution.
   *
   * Same as .rnd() method - for consistency with Math.random() interface.
   *
   * @since 0.2.0
   * @returns {number}
   */
  random() {
    return this.rnd();
  };

  /**
   * Generates a random real in the interval [0;1[ with 53-bit resolution.
   *
   * @since 0.1.0
   * @returns {number}
   */
  rndHiRes() {
    const a = this.int() >>> 5;
    const b = this.int() >>> 6;
    return (a * 67108864.0 + b) * (1.0 / 9007199254740992.0);
  };

  /**
   * A pseudo-normal distribution using the Box-Muller transform.
   * @param {number} mu     The normal distribution mean
   * @param {number} sigma  The normal distribution standard deviation
   * @returns {number}
   */
  normal(mu, sigma) {
    let u = 0;
    while (u === 0) u = this.random(); // Converting [0,1) to (0,1)
    let v = 0;
    while (v === 0) v = this.random(); // Converting [0,1) to (0,1)
    let n = Math.sqrt( -2 * Math.log(u) ) * Math.cos(2.0 * Math.PI * v);
    return (n * sigma) + mu;
  }

  /**
   * A factory method for generating random uniform rolls
   * @returns {number}
   */
  static random() {
    return twist.random();
  }

  /**
   * A factory method for generating random normal rolls
   * @return {number}
   */
  static normal(...args) {
    return twist.normal(...args);
  }
}

// Global singleton
const twist = new MersenneTwister(Date.now());

/** @module dice */

/** @type {{parse(formula: string): RollParseNode|RollTermData}} */
const RollGrammar = Parser;

var _module$6 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  MersenneTwister: MersenneTwister,
  Roll: Roll,
  RollGrammar: RollGrammar,
  RollParser: RollParser,
  terms: _module$7
});

/**
 * @import {CompendiumArtDescriptor, CompendiumArtInfo, CompendiumArtMapping} from "../_types.mjs"
 */

/**
 * A class responsible for managing package-provided art and applying it to Documents in compendium packs.
 * @extends {Map<string, CompendiumArtInfo>}
 */
class CompendiumArt extends Map {
  /**
   * @param {Iterable<[string, CompendiumArtInfo]>|null} [iterable]
   */
  constructor(iterable) {
    super(iterable);
    if ( game.compendiumArt instanceof this.constructor ) {
      throw new Error("You may not re-initialize the singleton CompendiumArt. Use game.compendiumArt instead.");
    }
  }

  /**
   * The key for the package manifest flag used to store the mapping information.
   * @type {string}
   */
  FLAG = "compendiumArtMappings";

  /**
   * The key for the setting used to store the World's art preferences.
   * @type {string}
   */
  SETTING = "compendiumArtConfiguration";

  /**
   * Whether art application is enabled. This should be switched off when performing client-side compendium migrations
   * in order to avoid persisting injected data.
   * @type {boolean}
   */
  enabled = true;

  /* -------------------------------------------- */

  /**
   * Retrieve all active packages that provide art mappings in priority order.
   * @returns {CompendiumArtDescriptor[]}
   */
  getPackages() {
    const settings = game.settings.get("core", this.SETTING);
    const unsorted = [];
    const configs = [];

    for ( const pkg of [game.system, ...game.modules] ) {
      const isActive = (pkg instanceof foundry.packages.System) || pkg.active;
      const flag = pkg.flags?.[this.FLAG]?.[game.system.id];
      if ( !isActive || !flag ) continue;
      const { id: packageId, title } = pkg;
      const { mapping, credit } = flag;
      const config = { packageId, title, mapping, credit };
      configs.push(config);
      const setting = settings[pkg.id] ?? { portraits: true, tokens: true };
      foundry.utils.mergeObject(config, setting);
      if ( config.priority === undefined ) unsorted.push(config);
    }

    const maxPriority = Math.max(...configs.map(({ priority }) => priority ?? -Infinity), CONST.SORT_INTEGER_DENSITY);
    unsorted.forEach((config, i) => config.priority = maxPriority + ((i + 1) * CONST.SORT_INTEGER_DENSITY));
    configs.sort((a, b) => a.priority - b.priority);
    return configs;
  }

  /* -------------------------------------------- */

  /**
   * Collate Document art mappings from active packages.
   * @internal
   */
  async _registerArt() {
    this.clear();
    // Load packages in reverse order so that higher-priority packages overwrite lower-priority ones.
    for ( const { packageId, mapping, credit } of this.getPackages().reverse() ) {
      try {
        const json = await foundry.utils.fetchJsonWithTimeout(mapping);
        await this.#parseArtMapping(packageId, json, credit);
      } catch(e) {
        const pkg = packageId === game.system.id ? game.system : game.modules.get(packageId);
        Hooks$1.onError("CompendiumArt#_registerArt", e, {
          msg: `Failed to parse compendium art mapping for package '${pkg?.title}'`,
          log: "error"
        });
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Parse a provided art mapping and store it for reference later, and update compendium indices to use the provided
   * art.
   * @param {string} packageId              The ID of the package providing the mapping.
   * @param {CompendiumArtMapping} mapping  The art mapping information provided by the package.
   * @param {string} [credit]               An optional credit string for use by the game system to apply in an
   *                                        appropriate place.
   */
  async #parseArtMapping(packageId, mapping, credit) {
    const settings = game.settings.get("core", this.SETTING)?.[packageId] ?? { portraits: true, tokens: true };
    for ( const [packName, actors] of Object.entries(mapping) ) {
      const pack = game.packs.get(packName);
      if ( !pack ) continue;
      for ( let [actorId, info] of Object.entries(actors) ) {
        const entry = pack.index.get(actorId);
        if ( !entry || !(settings.portraits || settings.tokens) ) continue;
        if ( settings.portraits ) entry.img = info.actor;
        else delete info.actor;
        if ( !settings.tokens ) delete info.token;
        if ( credit ) info.credit = credit;
        const uuid = pack.getUuid(actorId);
        info = Object.assign(this.get(uuid) ?? {}, info);
        this.set(uuid, info);
      }
    }
  }
}

/**
 * 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 foundry.canvas.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.implementation.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;
  }
}

/**
 * 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 {Record<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) {
    if ( !src ) return false;
    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(`${CONST.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 foundry.helpers.media.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();
      };
    });
  }
}

var _module$5 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CompendiumArt: CompendiumArt,
  ImageHelper: ImageHelper,
  VideoHelper: VideoHelper
});

/**
 * @typedef CompendiumArtInfo
 * @property {string} [actor]         The path to the Actor's portrait image.
 * @property {string|object} [token]  The path to the token image, or an object to merge into the Actor's prototype
 *                                    token.
 * @property {string} [credit]        An optional credit string for use by the game system to apply in an appropriate
 *                                    place.
 */

/**
 * @typedef {Record<string, Record<string, CompendiumArtInfo>>} CompendiumArtMapping
 * A mapping of compendium pack IDs to Document IDs to art information.
 */

/**
 * @typedef CompendiumArtDescriptor
 * @property {string} packageId  The ID of the package providing the art.
 * @property {string} title      The title of the package providing the art.
 * @property {string} mapping    The path to the art mapping file.
 * @property {string} [credit]   An optional credit string for use by the game system to apply in an appropriate place.
 * @property {number} priority   The package's user-configured priority.
 */

var _types$1 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * @typedef {Record<string, Record<string, number>>} ModuleSubTypeCounts
 * An object structure of document types at the top level, with a count of different sub-types for that document type.
 */

/**
 * 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 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 screen resolution.
   * @type {{width: number; height: number}}
   */
  static #MIN_SCREEN_RESOLUTION = {width: 1366, height: 768};

  /**
   * The minimum supported viewport dimensions.
   * @type {{width: number; height: number}}
   */
  static #MIN_VIEWPORT_DIMENSIONS = {width: 1024, height: 768};

  /**
   * @typedef BrowserTest
   * @property {number|string} 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: 34,
      match: /Electron\/(\d+)\./,
      message: "ERROR.ElectronVersion"
    },
    Chromium: {
      minimum: 132, // Electron 34
      match: /Chrom(?:e|ium)\/(\d+)\./,
      message: "ERROR.BrowserVersion"
    },
    Firefox: {
      minimum: 131, // Requires Iterator#filter
      match: /Firefox\/(\d+)\./,
      message: "ERROR.BrowserVersion"
    },
    Safari: {
      minimum: "18.4", // Requires Iterator#filter
      match: /Version\/(\d+\.\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 ( !version ) continue;
      if ( foundry.utils.isNewerVersion(minimum, version) ) {
        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 screen resolution and viewport dimensions, displaying a notification if either is too small.
   */
  #validateResolution() {
    if ( !ui.notifications ) return;
    if ( this.#resolutionTooLowNotification ) {
      ui.notifications.remove(this.#resolutionTooLowNotification);
      this.#resolutionTooLowNotification = undefined;
    }
    delete this.#usabilityIssues.resolutionTooLow;

    let errorMessage;
    let errorDimensions;
    const screen = {
      curWidth: window.screen.width,
      curHeight: window.screen.height,
      reqWidth: ClientIssues.#MIN_SCREEN_RESOLUTION.width,
      reqHeight: ClientIssues.#MIN_SCREEN_RESOLUTION.height
    };
    const viewport = {
      curWidth: window.innerWidth,
      curHeight: window.innerHeight,
      reqWidth: ClientIssues.#MIN_VIEWPORT_DIMENSIONS.width,
      reqHeight: ClientIssues.#MIN_VIEWPORT_DIMENSIONS.height
    };
    const satisfiesViewportSize = (viewport.curWidth >= viewport.reqWidth)
      && (viewport.curHeight >= viewport.reqHeight);
    if ( !satisfiesViewportSize ) {
      const hasZoomFactor = window.devicePixelRatio > 1;

      // Error Case 1: Insufficient Screen Size
      const canTestScreenSize = (viewport.curWidth >= screen.curWidth) && (viewport.curHeight >= screen.curHeight)
        && !hasZoomFactor;
      const satisfiesScreenSize = (screen.curWidth >= screen.reqWidth) && (screen.curHeight >= screen.reqHeight);
      if ( canTestScreenSize && !satisfiesScreenSize ) {
        errorMessage = "ERROR.RESOLUTION.Screen";
        errorDimensions = screen;
      }

      // Error Case 2: OS or Browser Zoom
      else if ( hasZoomFactor ) {
        errorMessage = "ERROR.RESOLUTION.Scale";
        errorDimensions = viewport;
      }

      // Error Case 3: Too Small Window
      else {
        errorMessage = "ERROR.RESOLUTION.Window";
        errorDimensions = viewport;
      }
    }
    if ( errorMessage ) {
      this.#usabilityIssues.resolutionTooLow = {message: errorMessage, severity: "error", params: errorDimensions};
      this.#resolutionTooLowNotification = ui.notifications.error(errorMessage, {permanent: true,
        format: errorDimensions});
    }
  }

  /* -------------------------------------------- */

  /**
   * 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 = foundry.applications.sidebar.apps.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.#validateResolution();
    this.#detectBrowserVersion();
    window.addEventListener("resize", foundry.utils.debounce(this.#validateResolution.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 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;
  }
}

/**
 * @import {SettingConfig, SettingSubmenuConfig} from "@client/_types.mjs";
 * @import ApplicationV2 from "../applications/api/application.mjs"
 * @import Application from "../appv1/api/application-v1.mjs"
 */

/**
 * 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 foundry.Game#settings}
 * @see {@link foundry.applications.sidebar.tabs.Settings}
 * @see {@link foundry.applications.settings.SettingsConfig}
 */
class ClientSettings {
  constructor(worldSettings) {
    if ( game.settings ) throw new Error("You may not re-construct the ClientSettings singleton.");

    /**
     * A object of registered game settings for this scope
     * @type {Map<string, SettingConfig>}
     */
    this.settings = new Map();

    /**
     * Registered settings menus which trigger secondary applications
     * @type {Map<string, ApplicationV2|Application}
     */
    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([
      [CONST.SETTING_SCOPES.CLIENT, window.localStorage],
      [CONST.SETTING_SCOPES.WORLD, new foundry.documents.collections.WorldSettings(worldSettings)]
    ]);
    this.storage.set(CONST.SETTING_SCOPES.USER, this.storage.get(CONST.SETTING_SCOPES.WORLD));
  }

  /* -------------------------------------------- */

  /**
   * Return a singleton instance of the Game Settings Configuration app
   * @returns {SettingsConfig}
   */
  get sheet() {
    return this.#sheet ??= new SettingsConfig();
  }

  #sheet;

  /* -------------------------------------------- */

  /**
   * Register a new namespaced game setting. The setting's scope determines where the setting is saved.
   * World - World settings are applied to everyone in the World. Use this for settings like system rule variants that
   * everyone must abide by.
   * User - User settings are applied to an individual user. Use this for settings that are a player's personal
   * preference, like 3D dice skins.
   * Client - Client settings are applied to the browser or client used to access the World. Use this for settings that
   * are affected by the client itself, such as screen dimensions, resolution, or performance.
   *
   * @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)
   *   }
   * });
   * ```
   *
   * @example Register a user setting
   * ```js
   * game.settings.register("myModule", "myUserSetting", {
   *   name: "Register a Module Setting with a checkbox",
   *   hint: "A description of the registered setting and its behavior.",
   *   scope: "user",       // This specifies a user-level setting
   *   config: true,        // This specifies that the setting appears in the configuration view
   *   type: new foundry.fields.BooleanField(),
   *   default: false
   * });
   * ```
   */
  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 = Object.values(CONST.SETTING_SCOPES).includes(data.scope) ? data.scope : CONST.SETTING_SCOPES.CLIENT;
    data.id = `${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 ${data.id} 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.getInitialValue();
        data.type.name = data.id;
        data.type.label ??= data.label;
        data.type.hint ??= data.hint;
      }

      // Special handling for DataModels
      if ( foundry.utils.isSubclass(data.type, foundry.abstract.DataModel) ) data.default ??= {};
    }

    // 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(data.id, data);

    // Reinitialize to cast the value of the Setting into its defined type
    if ( data.scope !== CONST.SETTING_SCOPES.CLIENT ) {
      const userId = data.scope === CONST.SETTING_SCOPES.USER ? game.userId : null;
      this.storage.get("world").getSetting(data.id, userId)?.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: "fa-solid 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 foundry.appv1.api.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
   * @param {object} options      Additional options for setting retrieval
   * @param {boolean} [options.document]  Retrieve the full Setting document instance instead of just its value
   * @returns {any|Setting}       The current value or the Setting document instance
   *
   * @example Retrieve the current setting value
   * ```js
   * game.settings.get("myModule", "myClientSetting");
   * ```
   */
  get(namespace, key, {document=false}={}) {
    const setting = this.#assertSetting(namespace, key);
    const storage = this.storage.get(setting.scope);
    const user = setting.scope === "user" ? game.userId : null;

    // Get the Setting document
    let doc;
    switch ( setting.scope ) {
      case CONST.SETTING_SCOPES.CLIENT:
        doc = new Setting$1({key: setting.id, value: storage.getItem(setting.id) ?? setting.default});
        break;
      case CONST.SETTING_SCOPES.USER:
      case CONST.SETTING_SCOPES.WORLD:
        doc = storage.getSetting(setting.id, user);
        if ( !doc ) doc = new Setting$1({key: setting.id, user, value: setting.default});
        break;
    }
    return document ? doc : doc.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 {any} value               The data to assign to the setting key
   * @param {object} [options]        Additional options passed to the server when updating world-scope settings
   * @param {boolean} [options.document]  Return the updated Setting document instead of just its value
   * @returns {Promise<any|Setting>}  The assigned setting value or the Setting document instance
   *
   * @example Update the current value of a setting
   * ```js
   * game.settings.set("myModule", "myClientSetting", "b");
   * ```
   */
  async set(namespace, key, value, {document=false, ...options}={}) {
    const setting = this.#assertSetting(namespace, key);
    let doc;
    if ( setting.scope === CONST.SETTING_SCOPES.CLIENT ) doc = this.#setClient(setting, value, options);
    else doc = await this.#setWorld(setting, value, options);
    return document ? doc : doc.value;
  }

  /* -------------------------------------------- */

  /**
   * Assert that the namespace and setting name were provided and form a valid key.
   * @param {string} namespace    The setting namespace
   * @param {string} key          The setting key
   * @returns {SettingConfig}     The setting configuration
   */
  #assertSetting(namespace, key) {
    const id = `${namespace}.${key}`;
    if ( !namespace || !key ) {
      throw new Error(`You must specify both namespace and key portions of the setting, you provided "${id}"`);
    }
    const setting = this.settings.get(id);
    if ( !setting ) throw new Error(`"${id}" is not a registered game setting`);
    return setting;
  }

  /* -------------------------------------------- */

  /**
   * Create or update a Setting document in the World database.
   * @param {SettingConfig} setting     The setting configuration to set
   * @param {*} value                   The desired setting value
   * @param {object} [options]          Additional options passed to the document creation or update workflows
   * @returns {Promise<Setting>}        The created or updated Setting document
   */
  async #setWorld(setting, value, options) {
    if ( !game.ready ) throw new Error("You may not set a World-level Setting before the Game is ready.");
    const user = setting.scope === CONST.SETTING_SCOPES.USER ? game.userId : null;
    const current = this.get(setting.namespace, setting.key, {document: true});
    const json = this.#cleanJSON(setting, value);
    if ( current?._id ) {
      await current.update({value: json}, options);
      return current;
    }
    return Setting$1.implementation.create({key: setting.id, user, value: json}, options);
  }

  /* -------------------------------------------- */

  /**
   * Create or update a Setting document in the browser client storage.
   * @param {SettingConfig} setting     The setting configuration to set
   * @param {*} value                   The desired setting value
   * @param {object} options            Additional options passed as part of the setting change request
   * @returns {Setting}                 A Setting document which represents the created setting
   */
  #setClient(setting, value, options) {
    const storage = this.storage.get("client");
    const json = this.#cleanJSON(setting, value);

    // Get current Setting document
    let doc;
    if ( setting.id in storage ) {
      doc = new Setting$1({key: setting.id, value: storage.getItem(setting.id)});
      const diff = doc.updateSource({value: json});
      if ( foundry.utils.isEmpty(diff) ) return doc;
    }
    else doc = new Setting$1({key: setting.id, value: json});

    // Write to client storage
    storage.setItem(doc.key, json);
    if ( setting.onChange instanceof Function ) setting.onChange(doc.value, options);
    Hooks$1.callAll("clientSettingChanged", doc.key, doc.value, options);
    return doc;
  }

  /* -------------------------------------------- */

  /**
   * Clean a candidate Setting value before writing it based on the registered setting configuration.
   * @param {SettingConfig} setting     The setting configuration to clean
   * @param {any} value                 The value to clean
   * @returns {any}                     The cleaned value
   */
  #cleanJSON(setting, value) {

    // Assign using DataField
    if ( setting.type instanceof foundry.data.fields.DataField ) {
      value = setting.type.clean(value);
      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});
    }

    // Plain default value
    else if ( value === undefined ) value = setting.default;
    return JSON.stringify(value);
  }
}

/**
 * @import {StringTreeEntryFilter, StringTreeNode, WordTreeEntry} from "@common/utils/_types.mjs";
 */


/**
 * This class is responsible for indexing all documents available in the world.
 * Stores documents using a word tree structure that allows for efficient 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}
   */
  #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 = foundry.utils.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(`${CONST.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.constructor.metadata?.indexed ) return;
    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 foundry.utils.fromUuidSync(uuid)?.testUserPermission(game.user, ownership);
  }
}

/**
 * @import CalendarData from "@client/data/calendar.mjs";
 * @import {CalendarConfig, TimeComponents, TimeFormatter} from "@client/data/_types.mjs";
 */

/**
 * A singleton class at which keeps the official Server and World time stamps.
 * Uses a basic implementation of https://www.geeksforgeeks.org/cristians-algorithm/ for synchronization.
 * @see {@link foundry.Game#time}
 */
class GameTime {
  constructor() {
    this.initializeCalendar();
  }

  /**
   * The amount of time to delay before re-syncing the official server time.
   * @type {number}
   */
  static SYNC_INTERVAL_MS = 1000 * 60 * 5;

  /**
   * How many samples of latency history to retain?
   * @type {number}
   */
  static #PING_HISTORY_LENGTH = 10;

  /**
   * The most recently synchronized timestamps retrieved from the server.
   * @type {{clientTime: number, serverTime: number, worldTime: number, components: TimeComponents}}
   */
  #time = {};

  /**
   * The average one-way latency across the most recent 5 trips
   * @type {number}
   */
  #dt = 0;

  /**
   * The most recent five synchronization durations
   * @type {number[]}
   */
  #dts = [];

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The calendar instance for in-world timekeeping.
   * @type {CalendarData}
   */
  get calendar() {
    return this.#calendar;
  }

  #calendar;

  /**
   * The "Earth" calendar instance for IRL timekeeping.
   * @type {CalendarData}
   */
  get earthCalendar() {
    return this.#earthCalendar;
  }

  #earthCalendar;

  /* -------------------------------------------- */

  /**
   * 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 expressed in seconds.
   * @type {number}
   */
  get worldTime() {
    return this.#time.worldTime;
  }

  /* -------------------------------------------- */

  /**
   * The current World time expressed as components.
   * @type {TimeComponents}
   */
  get components() {
    return this.#time.components;
  }

  /* -------------------------------------------- */

  /**
   * The average one-way latency between client and server in milliseconds.
   * @type {number}
   */
  get averageLatency() {
    return this.#dt;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Initialize a calendar configuration.
   * This is called once automatically upon construction, but can be called manually if CONFIG.time changes.
   */
  initializeCalendar() {

    // In-world Calendar
    const {worldCalendarConfig, worldCalendarClass} = CONFIG.time;
    this.#calendar = new worldCalendarClass(foundry.utils.deepClone(worldCalendarConfig), {strict: true});

    // Earth Calendar
    const {earthCalendarConfig, earthCalendarClass} = CONFIG.time;
    this.#earthCalendar = new earthCalendarClass(foundry.utils.deepClone(earthCalendarConfig), {strict: true});

    // Initialize time components
    this.#time.components = this.#calendar.timeToComponents(this.#time.worldTime);
  }

  /* -------------------------------------------- */

  /**
   * Advance or rewind the world time according to a delta amount expressed either in seconds or as components.
   * @param {TimeComponents|number} delta     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(delta, options) {
    const seconds = typeof delta === "number" ? delta : this.#calendar.componentsToTime(delta);
    const worldTime = this.worldTime;
    await game.settings.set("core", "time", worldTime + seconds, options);
    return worldTime + seconds;
  }

  /* -------------------------------------------- */

  /**
   * Directly set the world time to a certain value expressed either in seconds or as components.
   * @param {TimeComponents|number} time      The desired world time
   * @param {object} [options]                Additional options passed to game.settings.set
   * @returns {Promise<number>}               The new game time
   */
  async set(time, options) {
    const seconds = typeof time === "number" ? time : this.#calendar.componentsToTime(time);
    await game.settings.set("core", "time", seconds, options);
    return seconds;
  }

  /* -------------------------------------------- */
  /*  Synchronization and Update Methods          */
  /* -------------------------------------------- */

  /**
   * Synchronize the local client game time with the official time kept by the server
   * @returns {Promise<GameTime>}
   */
  async sync() {

    // Get the official time from the server
    const t0 = Date.now();
    const time = await new Promise(resolve => {
      game.socket.emit("time", time => resolve(time));
    });
    const t1 = Date.now();

    // Adjust for trip duration
    if ( this.#dts.length >= GameTime.#PING_HISTORY_LENGTH ) this.#dts.shift();
    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
    this.#time = Object.assign(time, {
      clientTime: t1 - this.#dt,
      components: this.#calendar.timeToComponents(time.worldTime)
    });
    ui.players?.refreshLatency();
    console.log(`${CONST.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;
    this.#time.components = this.#calendar.timeToComponents(worldTime);
    Hooks$1.callAll("updateWorldTime", worldTime, dt, options, userId);
    if ( CONFIG.debug.time ) console.log(`The world time advanced by ${dt} seconds, and is now ${worldTime}.`);
  }
}

/** @import DataModel from "@common/abstract/data.mjs" */

/**
 * 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}
     * @internal
     */
    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 ( const [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;
          config.typeLabels[t] = t === CONST.BASE_DOCUMENT_TYPE ? "TYPES.Base" :`TYPES.${documentName}.${t}`;
        }
      }
    }

    // Pre-localize data models
    Localization.#localizeDataModels();
    Hooks$1.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.
   *
   * @see {@link DataModel.LOCALIZATION_PREFIXES} for an example of the class definition and
   * localization file structure.
   */
  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() {
    const seenFields = new Set();
    for ( const documentName of CONST.ALL_DOCUMENT_TYPES ) {
      const cls = foundry.utils.getDocumentClass(documentName);
      Localization.localizeDataModel(cls, {seenFields});
      for ( const model of Object.values(CONFIG[cls.documentName].dataModels ?? {}) ) {
        Localization.localizeDataModel(model, {prefixPath: "system.", seenFields});
      }
    }
    for ( const cls of [foundry.packages.World, foundry.packages.System, foundry.packages.Module] ) {
      Localization.localizeDataModel(cls, {seenFields});
    }
    Localization.localizeDataModel(foundry.data.PrototypeToken, {seenFields});
  }

  /* -------------------------------------------- */

  /**
   * 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]
   * @param {Set<foundry.data.fields.DataField>} [options.seenFields]
   */
  static localizeSchema(schema, prefixes=[], {prefixPath="", seenFields=new Set()}={}) {
    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 foundry.utils.deepClone(rules);
    };
    const rules = getRules(prefixes);

    // Apply localization to fields of the model
    const localize = function(_v, options) {
      if ( seenFields.has(this) ) return;
      if ( options.collection ) return; // Embedded collection models are handled separately
      seenFields.add(this);

      // Remove prefix from field path
      let key = this.fieldPath;
      if ( prefixPath && key.startsWith(prefixPath) ) key = key.slice(prefixPath.length);

      // Localize model fields
      const field = foundry.utils.getProperty(rules, key);
      if ( typeof field?.label === "string" ) this.label ||= game.i18n.localize(field.label);
      if ( typeof field?.hint === "string" ) this.hint ||= game.i18n.localize(field.hint);

      // Inner models may have prefixes that take precedence
      if ( this instanceof foundry.data.fields.EmbeddedDataField ) {
        if ( this.model.LOCALIZATION_PREFIXES.length ) {
          foundry.utils.setProperty(rules, key, getRules(this.model.LOCALIZATION_PREFIXES));
        }
      }

      // Special handling of TypedObjectField
      else if ( this instanceof foundry.data.fields.TypedObjectField ) {
        this.element.apply(localize, undefined, {initializeArrays: true});
      }

      // Special handling of TypedSchemaField
      else if ( this instanceof foundry.data.fields.TypedSchemaField ) {
        for ( const schema of Object.values(this.types) ) schema.apply(localize, {}, {initializeArrays: true});
      }
    };
    schema.apply(localize, {}, {initializeArrays: true});
  }

  /* -------------------------------------------- */

  /**
   * 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
   */
  #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 ( const l of pkg.languages ) {
        if ( !sl.hasOwnProperty(l.lang) ) sl[l.lang] = l.name;
      }
    };

    // Register core translation languages first
    for ( const m of game.modules ) {
      if ( m.coreTranslation ) register(m);
    }

    // Discover and register languages
    for ( const 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
   */
  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 ( const 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 ( const p of promises ) {
      const 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
   */
  #filterLanguagePaths(pkg, lang) {
    return pkg.languages.reduce((arr, l) => {
      if ( l.lang !== lang ) return arr;
      const checkSystem = !l.system || (game.system && (l.system === game.system.id));
      const 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
   */
  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(`${CONST.vtt} | ${msg}`);
      if ( err ) Hooks$1.onError("Localization#_loadTranslationFile", err, {msg, src});
      return {};
    }

    // Parse and expand the provided translation object
    let json;
    try {
      json = await resp.json();
      console.log(`${CONST.vtt} | Loaded localization file ${src}`);
      json = foundry.utils.expandObject(json);
    } catch(err) {
      Hooks$1.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;
    if ( CONFIG.debug.i18n ) console.error(`Localization | Key "${stringId}" is not defined.`);
    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 {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat/ListFormat}
   * @param {object} [options]
   * @param {Intl.ListFormatStyle} [options.style="long"] The list formatter style, either "long", "short", or "narrow".
   * @param {Intl.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;
  }
}

/** @module helpers */

var _module$4 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AsyncWorker: AsyncWorker,
  ClientIssues: ClientIssues,
  ClientSettings: ClientSettings,
  DocumentIndex: DocumentIndex,
  GameTime: GameTime,
  Hooks: Hooks$1,
  Localization: Localization,
  SocketInterface: SocketInterface,
  WorkerManager: WorkerManager,
  interaction: _module$E,
  media: _module$5,
  types: _types$1
});

/**
 * 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 ) {
      ui.controls.activate({control: this.currentStep.layer, tool: this.currentStep.tool});
    }
  }
}

/**
 * @import {ApplicationV2} from "@client/applications/api/_module.mjs";
 */

/**
 * @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 {ApplicationV2}
   */
  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 foundry.applications.instances.values() ) {
        if ( app.hasFrame ) 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>}
   */
  async #installingASystem() {
    // Activate Systems tab and warm cache
    if ( this.currentStep.id === "systemsTab" ) {
      ui.setupPackages.changeTab("systems", "primary");

      // noinspection ES6MissingAwait
      game.warmPackages({type: "system"});
    }

    // Render the InstallPackage app with a filter
    else if ( this.currentStep.id === "searching" ) {
      await game.browsePackages("system", {search: "Simple Worldbuilding"});
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle Step setup for the Creating a World Tour
   * @returns {Promise<void>}
   */
  async #creatingAWorld() {

    // Activate the World tab
    if ( this.currentStep.id === "worldTab" ) {
      ui.setupPackages.changeTab("worlds", "primary");
    }
    else if ( this.currentStep.id === "worldTitle" ) {
      const world = new foundry.packages.World({
        id: "my-first-world",
        title: "My First World",
        system: Array.from(game.systems)[0].id,
        coreVersion: game.release.version,
        description: game.i18n.localize("SETUP.NueWorldDescription")
      });

      // Render the World configuration application
      this.focusedApp = new foundry.applications.sidebar.apps.WorldConfig({world, tour: true});
      await this.focusedApp.render({force: true});
    }
    else if ( this.currentStep.id === "launching" ) {
      await this.focusedApp.submit();
      await this.focusedApp.close();
    }
  }
}

/**
 * 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 ui[this.currentStep.sidebarTab]?.activate();
    }
  }
}

var _module$3 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CanvasTour: CanvasTour,
  SetupTour: SetupTour,
  SidebarTour: SidebarTour
});

/**
 * Responsible for managing the New User Experience workflows.
 * @see {@link foundry.Game#nue}
 */
class NewUserExperienceManager {
  constructor() {
    if ( game.nue ) throw new Error("You may not re-construct the singleton NewUserExperienceManager");
    Hooks$1.on("renderChatMessageHTML", this.#onRenderChatMessageHTML.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.
   */
  #createInitialChatMessages() {
    if ( game.settings.get("core", "nue.shownTips") ) return;

    // Get GM's
    const gms = ChatMessage$1.implementation.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$1.implementation.createDocuments(chatData);

    // Store flag indicating this was shown
    game.settings.set("core", "nue.shownTips", true);
  }

  /* -------------------------------------------- */

  /**
   * Create a default scene for the new world.
   * @param {Partial<SceneData>} sceneData      Additional data to merge with the default scene
   * @returns {Promise<Scene>}                  The created default scene
   */
  async createDefaultScene(sceneData={}) {
    const filePath = foundry.utils.getRoute("/nue/defaultscene/scene.json");
    const response = await foundry.utils.fetchWithTimeout(filePath, {method: "GET"});
    const json = await response.json();
    delete sceneData._id;
    sceneData = foundry.utils.mergeObject(json, sceneData);
    return Scene$1.implementation.create(sceneData, {keepId: true});
  }

  /* -------------------------------------------- */

  /**
   * Automatically show uncompleted Tours related to new worlds.
   */
  async #showNewWorldTour() {
    const tour = game.tours.get("core.welcome");
    if ( tour?.status === foundry.nue.Tour.STATUS.UNSTARTED ) {

      // Get or create the welcome Scene
      let scene = game.scenes.get("NUEDEFAULTSCENE0");
      if ( scene ) await scene.view();
      else if ( Scene$1.canUserCreate(game.user) ) scene = await this.createDefaultScene({active: true});
      if ( scene ) {
        await new Promise(resolve => window.setTimeout(resolve, 1000));
        await canvas.animatePan({...scene.dimensions.rect.center, scale: 0.7, duration: 1000});
      }

      // Start the tour
      tour.start();
    }
  }

  /* -------------------------------------------- */

  /**
   * Add event listeners to the chat card links.
   * @param {ChatMessage} msg     The ChatMessage being rendered.
   * @param {HTMLElement} html    The HTML content of the message.
   */
  #onRenderChatMessageHTML(msg, html) {
    if ( !msg.getFlag("core", "nue") ) return;
    html.querySelectorAll(".nue-tab").forEach(e => e.addEventListener("click", this.#onTabLink.bind(this)));
    html.querySelectorAll(".nue-action").forEach(e => e.addEventListener("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.
   */
  #onActionLink(event) {
    event.preventDefault();
    const action = event.currentTarget.dataset.action;
    switch ( action ) {
      case "invite": return new foundry.applications.sidebar.apps.InvitationLinks().render({force: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Switch to the appropriate tab when a user clicks on a link in the chat message.
   * @param {TriggeredEvent} event  The click event.
   */
  #onTabLink(event) {
    event.preventDefault();
    const tab = event.currentTarget.dataset.tab;
    ui[tab]?.activate();
  }
}

/**
 * A singleton Tour Collection class responsible for registering and activating Tours, accessible as game.tours.
 * @extends {Collection<string, Tour>}
 * @see {@link foundry.Game#tours}
 */
class ToursCollection extends Collection {
  constructor() {
    super();
    if ( game.tours ) throw new Error("You may not re-construct the singleton Tours collection.");
  }

  /* -------------------------------------------- */

  /**
   * 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
   */
  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 "${tour.key}" has already been registered`);
    this.set(`${namespace}.${id}`, tour);
  }

  /* -------------------------------------------- */

  /**
   * Set a Tour to the collection.
   * @param {string} key
   * @param {Tour} tour
   */
  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);
  }
}

/** @module nue */


/**
 * Register core Tours.
 * @returns {Promise<void>}
 */
async function 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);
  }
}

var _module$2 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  NewUserExperienceManager: NewUserExperienceManager,
  Tour: Tour,
  ToursCollection: ToursCollection,
  registerTours: registerTours,
  tours: _module$3
});

/**
 * @import {ReleaseData} from "@common/config.mjs"
 * @import {HotReloadData} from "./_types.mjs"
 * @import {Canvas} from "./canvas/_module.mjs"
 * @import {Actor, Combat, User} from "./documents/_module.mjs"
 * @import WorldCollection from "./documents/abstract/world-collection.mjs"
 * @import Document from "@common/abstract/document.mjs"
 * @import * as applications from "./applications/_module.mjs"
 * @import {GameUIConfiguration} from "./applications/settings/menus/ui-config.mjs"
 * @import * as appv1 from "./appv1/_module.mjs"
 * @import * as hookEvents from "./hooks.mjs"
 * @import * as nue from "./nue/_module.mjs"
 * @import {Module, System, World} from "./packages/_module.mjs"
 */

/**
 * 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 {io.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, writable: false},
      sessionId: {value: sessionId, writable: false},
      socket: {value: socket, writable: false},
      userId: {value: data.userId || null, writable: false},
      data: {value: data, writable: false},
      release: {value: new foundry.config.ReleaseData(data.release), writable: false}
    });

    // Set up package data
    this.setupPackages(data);

    // Helper Properties
    Object.defineProperties(this, {
      audio: {value: new AudioHelper(), writable: false},
      clipboard: {value: new ClipboardHelper(), writable: false},
      collections: {value: new Collection(), writable: false},
      compendiumArt: {value: new CompendiumArt(), writable: false},
      documentIndex: {value: new DocumentIndex(), writable: false},
      i18n: {value: new Localization(data?.options?.language), writable: false},
      issues: {value: new ClientIssues(), writable: false},
      gamepad: {value: new GamepadManager(), writable: false},
      keyboard: {value: new KeyboardManager(), writable: false},
      mouse: {value: new MouseManager(), writable: false},
      nue: {value: new foundry.nue.NewUserExperienceManager(), writable: false},
      packs: {value: new CompendiumPacks(), writable: false},
      settings: {value: new ClientSettings(data.settings || []), writable: false},
      tours: {value: new foundry.nue.ToursCollection(), writable: false},
      video: {value: new VideoHelper(), writable: false},
      workers: {value: new WorkerManager(), writable: false},
      keybindings: {value: new ClientKeybindings(), writable: false}
    });

    // The singleton game Canvas
    Object.defineProperty(this, "canvas", {value: new foundry.canvas.Canvas(), writable: false});
    Object.defineProperty(globalThis, "canvas", {value: this.canvas, writable: false});

    // Register default sheets
    foundry.applications.sheets._registerDefaultSheets();
  }


  /**
   * Whether the page is unloading (and a socket disconnection should be ignored).
   * @type {boolean}
   */
  static #unloading = false;

  /* -------------------------------------------- */
  /*  Session Attributes                          */
  /* -------------------------------------------- */

  /**
   * The named view which is currently active.
   * @type {"join"|"setup"|"players"|"license"|"game"|"stream"|"auth"|"update"}
   * @readonly
   */
  view;

  /**
   * The object of world data passed from the server.
   * @type {object}
   * @readonly
   */
  data;

  /**
   * The client session id which is currently active.
   * @type {string}
   * @readonly
   */
  sessionId;

  /**
   * A reference to the open Socket.io connection.
   * @type {io.Socket|null}
   * @readonly
   */
  socket;

  /**
   * The id of the active World user, if any.
   * @type {string|null}
   * @readonly
   */
  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 {Collection<string, Module>}
   */
  modules;

  /**
   * A mapping of CompendiumCollection instances, one per Compendium pack.
   * @type {collections.CompendiumPacks}
   * @readonly
   */
  packs;

  /**
   * A registry of document sub-types and their respective template.json defaults.
   * @type {Record<string, Record<string, object>>}
   */
  get model() {
    return this.#model;
  }

  #model;

  /**
   * A shortcut to compendiumConfiguration data settings
   * @type {WorldCompendiumConfiguration}
   */
  get compendiumConfiguration() {
    return game.settings.get("core", "compendiumConfiguration");
  }

  /* -------------------------------------------- */
  /*  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 {helpers.DocumentIndex}
   * @readonly
   */
  documentIndex;

  /**
   * The UUID redirects tree.
   * @type {StringTree}
   */
  compendiumUUIDRedirects;

  /**
   * A mapping of {@link WorldCollection} instances, one per primary {@link Document} type.
   * @type {Collection<string, WorldCollection>}
   * @readonly
   */
  collections;

  /**
   * The collection of Actor documents which exists in the World.
   * @type {collections.Actors}
   */
  actors;

  /**
   * The collection of Cards documents which exists in the World.
   * @type {collections.CardStacks}
   */
  cards;

  /**
   * The collection of Combat documents which exists in the World.
   * @type {collections.CombatEncounters}
   */
  combats;

  /**
   * The collection of Folder documents which exists in the World.
   * @type {collections.Folders}
   */
  folders;

  /**
   * The collection of Item documents which exists in the World.
   * @type {collections.Items}
   */
  items;

  /**
   * The collection of JournalEntry documents which exists in the World.
   * @type {collections.Journal}
   */
  journal;

  /**
   * The collection of Macro documents which exists in the World.
   * @type {collections.Macros}
   */
  macros;

  /**
   * The collection of ChatMessage documents which exists in the World.
   * @type {collections.ChatMessages}
   */
  messages;

  /**
   * The collection of Playlist documents which exists in the World.
   * @type {collections.Playlists}
   */
  playlists;

  /**
   * The collection of Scene documents which exists in the World.
   * @type {collections.Scenes}
   */
  scenes;

  /**
   * The collection of RollTable documents which exists in the World.
   * @type {collections.RollTables}
   */
  tables;

  /**
   * The collection of User documents which exists in the World.
   * @type {collections.Users}
   */
  users;

  /* -------------------------------------------- */
  /*  State Attributes                            */
  /* -------------------------------------------- */

  /**
   * The Release data for this version of Foundry
   * @type {ReleaseData}
   * @readonly
   */
  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 {Record<string, number[]>}
   */
  permissions;

  /**
   * A flag for whether the Game has successfully reached the {@link hookEvents.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 {helpers.media.CompendiumArt}
   * @readonly
   */
  compendiumArt;

  /**
   * The singleton Audio Helper.
   * @type {AudioHelper}
   * @readonly
   */
  audio;

  /**
   * The singleton game Canvas.
   * @type {Canvas}
   * @readonly
   */
  canvas;

  /**
   * The singleton Clipboard Helper.
   * @type {helpers.interaction.ClipboardHelper}
   * @readonly
   */
  clipboard;

  /**
   * Localization support.
   * @type {helpers.Localization}
   * @readonly
   */
  i18n;

  /**
   * The singleton ClientIssues manager.
   * @type {helpers.ClientIssues}
   * @readonly
   */
  issues;

  /**
   * The singleton Gamepad Manager.
   * @type {helpers.interaction.GamepadManager}
   * @readonly
   */
  gamepad;

  /**
   * The singleton Keyboard Manager.
   * @type {helpers.interaction.KeyboardManager}
   * @readonly
   */
  keyboard;

  /**
   * Client keybindings which are used to configure application behavior
   * @type {helpers.interaction.ClientKeybindings}
   * @readonly
   */
  keybindings;

  /**
   * The singleton Mouse Manager.
   * @type {helpers.interaction.MouseManager}
   * @readonly
   */
  mouse;

  /**
   * The singleton New User Experience manager.
   * @type {nue.NewUserExperienceManager}
   * @readonly
   */
  nue;

  /**
   * Client settings which are used to configure application behavior.
   * @type {helpers.ClientSettings}
   * @readonly
   */
  settings;

  /**
   * A singleton GameTime instance which manages the progression of time within the game world.
   * @type {helpers.GameTime}
   * @readonly
   */
  time;

  /**
   * The singleton TooltipManager.
   * @type {helpers.interaction.TooltipManager}
   * @readonly
   */
  tooltip;

  /**
   * The singleton Tours collection.
   * @type {nue.ToursCollection}
   * @readonly
   */
  tours;

  /**
   * The singleton Video Helper.
   * @type {helpers.media.VideoHelper}
   * @readonly
   */
  video;

  /**
   * A singleton web Worker manager.
   * @type {helpers.WorkerManager}
   * @readonly
   */
  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<io.Socket>} A promise which resolves to the connected socket, if successful
   */
  static async connect(sessionId) {

    /**
     * Connect to the websocket
     * @type {io.Socket}
     */
    const socket = await new Promise((resolve, reject) => {
      const socket = io.connect({
        path: 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
        closeOnBeforeunload: true,     // Automatically close the connection on page unload events
        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 debouncedReload();
        console.log(`${CONST.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();
      if ( Game.#unloading ) return;
      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(`${CONST.vtt} | Attempting to re-connect: ${((t - disconnectedTime) / 1000).toFixed(2)} seconds`);
    });

    // Reconnect failed
    socket.io.on("reconnect_failed", () => {
      ui.notifications.error(`${CONST.vtt} | Server connection lost.`);
      window.location.href = getRoute("no");
    });

    // Reconnect succeeded
    const reconnectTimeRequireRefresh = 5000;
    socket.io.on("reconnect", () => {
      ui.notifications.info(`${CONST.vtt} | Server connection re-established.`);
      if ( (Date.now() - disconnectedTime) >= reconnectTimeRequireRefresh ) {
        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 (const cookie of document.cookie.split("; ")) {
      const [name, value] = cookie.split("=");
      cookies[name] = decodeURIComponent(value);
    }
    return cookies;
  }

  /* -------------------------------------------- */

  /**
   * Request World data from server and return it
   * @param {io.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 = getRoute("join");
    }
    return new Promise(resolve => {
      socket.emit("world", resolve);
    });
  }

  /* -------------------------------------------- */

  /**
   * Get the current World status upon initial connection.
   * @param {io.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(`${CONST.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 foundry.packages.World(data.world);
    }
    if ( data.system ) {
      this.system = new foundry.packages.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 Collection(data.modules.map(m => [m.id, new foundry.packages.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, triggering the {@link hookEvents.init} event.
   */
  async initialize() {
    console.log(`${CONST.vtt} | Initializing Foundry Virtual Tabletop Game`);
    this.ready = false;

    Hooks$1.callAll("init");

    // Initialize properties
    Object.defineProperties(this, {
      tooltip: {value: new TooltipManager.implementation(), writable: false}
    });

    // Register game settings
    this.registerSettings();

    // Initialize language translations
    await this.i18n.initialize();

    // Register Tours
    await foundry.nue.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 foundry.applications.api.DialogV2.confirm({
        window: {title: "GAME.ReturnSetup"},
        content: `<p>${game.i18n.format(warning, {number: othersActive})}</p>`
      });
      if ( !confirm ) return;
    }

    // Dispatch the request
    await fetchWithTimeout(getRoute("setup"), {
      method: "POST",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify({shutdown: true}),
      redirect: "manual"
    });
  }

  /* -------------------------------------------- */
  /*  Primary Game Initialization                 */
  /* -------------------------------------------- */

  /**
   * Fully set up the game state, initializing Documents, UI applications, and the Canvas. Triggers the
   * {@link hookEvents.setup} and {@link hookEvents.ready} events.
   * @returns {Promise<void>}
   */
  async setupGame() {

    // Additional properties specific to the Game view
    this.permissions = this.settings.get("core", "permissions");
    Object.defineProperty(this, "time", {value: new GameTime(), writable: false});
    await this.time.sync();

    // Initialize configuration data
    this.initializeConfig();

    // Initialize world data
    this.initializePacks();             // Initialize compendium packs
    this.initializeDocuments();         // Initialize world documents
    this.initializeTrees();             // Initialize collection trees

    // Monkeypatch a search method on EmbeddedCollection
    foundry.abstract.EmbeddedCollection.prototype.search = DocumentCollection.prototype.search;

    // Call world setup hook
    Hooks$1.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.placeables.tokens.TokenRingConfig.initialize();

    // Initialize combat turn markers config
    foundry.data.CombatConfiguration.initialize();

    // Call this here to set up a promise that dependent UI elements can await.
    this.canvas.initializing = this.initializeCanvas();
    this.initializeUI();
    await foundry.applications.apps.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({force: 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$1.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 Token movement actions
    this.#initializeMovementActions();
  }

  /* -------------------------------------------- */

  /**
   * Initialize and validate Token movement actions.
   */
  #initializeMovementActions() {

    // Validate and initialize movement action
    for ( const [action, config] of Object.entries(CONFIG.Token.movement.actions) ) {
      if ( !config.label ) throw new Error(`CONFIG.Token.movement.actions["${action}"] must have a label`);
      if ( !config.icon ) throw new Error(`CONFIG.Token.movement.actions["${action}"] must have an icon`);
      if ( config.img === undefined ) config.img = null;
      if ( config.order === undefined ) config.order = 0;
      if ( config.teleport === undefined ) config.teleport = false;
      if ( config.measure === undefined ) config.measure = true;
      if ( config.walls === undefined ) config.walls = "move";
      if ( config.visualize === undefined ) config.visualize = true;
      if ( config.getAnimationOptions === undefined ) config.getAnimationOptions = () => ({});
      if ( config.canSelect === undefined ) config.canSelect = () => true;
      if ( config.deriveTerrainDifficulty === undefined ) config.deriveTerrainDifficulty = null;
      if ( config.getCostFunction === undefined ) config.getCostFunction = () => cost => cost;
    }

    // Special validation for the default movement action
    const defaultAction = CONFIG.Token.movement.actions[CONFIG.Token.movement.defaultAction];
    if ( !defaultAction ) throw new Error("CONFIG.Token.movement.defaultAction is not registered");
    if ( defaultAction.deriveTerrainDifficulty ) throw new Error("CONFIG.Token.movement.defaultAction must not have a derived difficulty");

    // Special validation for the "displace" movement action
    const displaceAction = {...(CONFIG.Token.movement.actions.displace ?? {})};
    if ( !Object.keys(displaceAction).length ) throw new Error("CONFIG.Token.movement.actions.displace is required");
    if ( displaceAction.teleport !== true ) throw new Error("CONFIG.Token.movement.displace must be teleportation");
    if ( displaceAction.measure !== false ) throw new Error("CONFIG.Token.movement.displace must be not be measured");
    if ( displaceAction.walls !== null ) throw new Error("CONFIG.Token.movement.displace must be not be blocked by walls");
    if ( displaceAction.visualize !== false ) throw new Error("CONFIG.Token.movement.displace must not be visualized");
    if ( !displaceAction.deriveTerrainDifficulty ) throw new Error("CONFIG.Token.movement.displace must have a derived difficulty");

    // Enforce specific return values of certain functions
    displaceAction.getAnimationOptions = () => ({duration: 0});
    displaceAction.getCostFunction = () => () => 0;
    CONFIG.Token.movement.actions.displace = displaceAction;

    // Sort the movement actions
    const actions = Object.fromEntries(Object.entries(CONFIG.Token.movement.actions)
      .sort((a, b) => a[1].order - b[1].order));

    // Freeze movement actions
    Object.defineProperty(CONFIG.Token.movement, "actions", {
      value: foundry.utils.deepFreeze(actions),
      writable: false,
      configurable: false
    });
  }

  /* -------------------------------------------- */

  /**
   * Initialize game state data by creating {@link WorldCollection} instances for every primary {@link 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$1(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 ( const 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 ( const 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(`${CONST.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 its collection ID.
   * @returns {collections.CompendiumPacks}
   */
  initializePacks() {
    for ( const metadata of this.data.packs ) {
      const pack = this.packs.get(metadata.id) ?? new CompendiumCollection(metadata);
      this.packs.set(pack.collection, pack);
    }
    return this.packs;
  }

  /* -------------------------------------------- */

  /**
   * Initialize collection trees.
   */
  initializeTrees() {
    this.packs.initializeTree();
    for ( const pack of this.packs ) pack.initializeTree();
    for ( const collection of this.collections ) collection.initializeTree();
  }

  /* -------------------------------------------- */

  /**
   * Initialize the WebRTC implementation
   */
  initializeRTC() {
    this.webrtc = new AVMaster();
    return this.webrtc.connect();
  }

  /* -------------------------------------------- */

  /**
   * Initialize core UI elements
   */
  initializeUI() {

    // Configure interface
    const uiConfig = game.settings.get("core", "uiConfig");
    this.configureUI(uiConfig);
    matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => this.configureUI());

    // Initialize all singleton applications
    for ( const [k, cls] of Object.entries(CONFIG.ui) ) {
      ui[k] = new cls();
    }

    // Initialize pack applications
    for ( const pack of this.packs.values() ) {
      const App = pack.applicationClass;
      if ( Application.isPrototypeOf(App) || foundry.applications.api.ApplicationV2.isPrototypeOf(App) ) {
        const app = new App({collection: pack});
        pack.apps.push(app);
      }
    }

    // Render some applications (asynchronously)
    ui.nav.render({force: true});
    ui.sidebar.render({force: true});
    ui.players.render({force: true});
    ui.hotbar.render({force: true});
    ui.webrtc.render({force: true});
    ui.pause.render({force: true});
    ui.controls.render({force: true});
  }

  /* -------------------------------------------- */

  /**
   * 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$1.onError("Game#initializeCanvas", err, {
        msg: "Failed to render WebGL canvas",
        log: "error"
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * Initialize Keyboard controls
   */
  initializeKeyboard() {
    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() {

    // Experimental: Universal Keybindings
    game.settings.register("core", "universalKeybindings", {
      name: "SETTINGS.UniversalKeybindingsN",
      hint: "SETTINGS.UniversalKeybindingsL",
      scope: "client",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: false}),
      requiresReload: true
    });

    // UI Configuration
    game.settings.registerMenu("core", "uiConfigMenu", {
      name: "SETTINGS.UI.MENU.name",
      label: "SETTINGS.UI.MENU.label",
      hint: "SETTINGS.UI.MENU.hint",
      icon: "fa-solid fa-table-layout",
      type: foundry.applications.settings.menus.UIConfig
    });

    // Permissions Control Menu
    game.settings.registerMenu("core", "permissions", {
      name: "PERMISSION.Configure",
      label: "PERMISSION.ConfigureLabel",
      hint: "PERMISSION.ConfigureHint",
      icon: "fa-solid fa-user-lock",
      type: foundry.applications.apps.PermissionConfig,
      restricted: true
    });

    // Combat turn markers settings
    foundry.data.CombatConfiguration.registerSettings();

    // 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.render({reset: true});
        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: "fa-solid fa-headset",
      type: foundry.applications.settings.menus.AVConfig,
      restricted: false
    });

    // WebRTC Settings
    foundry.av.AVSettings.register();

    // Prototype Token Overrides
    foundry.applications.settings.menus.PrototypeOverridesConfig.registerSettings();

    // 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", foundry.documents.Combat.CONFIG_SETTING, {
      name: "SETTINGS.CombatConfigN",
      label: "SETTINGS.CombatConfigL",
      hint: "SETTINGS.CombatConfigH",
      icon: "fa-solid fa-swords",
      type: foundry.applications.apps.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
    });

    // Token ring settings
    foundry.canvas.placeables.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: () => ui.chat._updateRollMode()
    });

    // Dice config settings
    foundry.applications.settings.menus.DiceConfig.registerSetting();

    // 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: (...args) => this.time.onUpdateWorldTime(...args)
    });

    // Register module configuration settings
    game.settings.register("core", foundry.applications.sidebar.apps.ModuleManagement.SETTING, {
      name: "Module Configuration Settings",
      scope: "world",
      config: false,
      type: new foundry.data.fields.TypedObjectField(new foundry.data.fields.BooleanField(),
        {validateKey: foundry.packages.BasePackage.validateId}),
      requiresReload: true
    });

    // Register compendium visibility setting
    game.settings.register("core", CompendiumCollection.CONFIG_SETTING, {
      name: "Compendium Configuration",
      scope: "world",
      config: false,
      type: CompendiumCollection.CONFIG_FIELD,
      onChange: CompendiumCollection._onConfigure
    });

    foundry.applications.settings.menus.DefaultSheetsConfig.registerSetting();

    game.settings.register("core", "sheetThemes", {
      scope: "client",
      config: false,
      default: {},
      type: Object
    });

    // 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;
      }
    });

    // UI Configuration
    game.settings.register("core", "uiConfig", {
      scope: "client",
      config: false,
      type: foundry.applications.settings.menus.UIConfig.schema,
      onChange: config => {
        game.configureUI(config);
        if ( canvas.ready ) canvas.draw();
        ui.chat?._toggleNotifications();
      }
    });

    // 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
    });

    // Token Automatic Rotation
    game.settings.register("core", "tokenAutoRotate", {
      name: "SETTINGS.TokenAutoRotateN",
      hint: "SETTINGS.TokenAutoRotateL",
      scope: "world",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: 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
    const DrawingDocument = foundry.documents.DrawingDocument;
    const drawingSchema = DrawingDocument.defineSchema();
    drawingSchema.bezierFactor.initial = 0.5;
    game.settings.register("core", foundry.canvas.layers.DrawingsLayer.DEFAULT_CONFIG_SETTING, {
      name: "Default Drawing Configuration",
      scope: "client",
      config: false,
      type: new foundry.data.fields.SchemaField(DrawingDocument.defaultDrawingFields.reduce((fields, key) => {
        fields[key] = drawingSchema[key];
        return fields;
      }, {}), {validate: data => {
        if ( !foundry.documents.BaseDrawing._validateVisibleContent(data) ) {
          throw new Error(game.i18n.localize("DRAWING.JointValidationError"));
        }
      }})
    });

    // 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"},
        "data-assets/": {source: "data", path: "assets/", label: "assets"}
      }
    });

    // 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}),
      onChange: (locked, options) => {
        if ( options.render !== false ) ui.hotbar.render();
      }
    });

    // Adventure imports
    game.settings.register("core", "adventureImports", {
      scope: "world",
      config: false,
      type: Object,
      default: {}
    });

    // Experimental flags
    game.settings.register("core", "experimental", {
      scope: "client",
      config: false,
      type: Object,
      default: {}
    });

    // Document-specific settings
    RollTables.registerSettings();
    foundry.applications.sidebar.tabs.PlaylistDirectory._registerSettings();

    // Audio playback settings
    foundry.audio.AudioHelper.registerSettings();

    // Register CanvasLayer settings
    foundry.canvas.layers.NotesLayer.registerSettings();

    // 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
    });

    foundry.canvas.layers.TemplateLayer.registerSettings();

    // Unconstrained Movement
    game.settings.register("core", "unconstrainedMovement", {
      scope: "client",
      config: false,
      type: new foundry.data.fields.BooleanField({initial: false}),
      onChange: active => {
        canvas.tokens?.recalculatePlannedMovementPaths();

        // Rerender scene controls button
        if ( !ui.controls ) return;
        const tools = ui.controls.controls.tokens.tools;
        if ( !tools.unconstrainedMovement ) return;
        if ( tools.unconstrainedMovement.active === active ) return;
        tools.unconstrainedMovement.active = active;
        ui.controls.render();
      }
    });

  }

  /* -------------------------------------------- */
  /*  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?.current ?? null;
  }

  /* -------------------------------------------- */

  /**
   * A convenience accessor for the currently viewed Combat encounter
   * @type {Combat|null}
   */
  get combat() {
    return this.combats?.viewed ?? null;
  }

  /* -------------------------------------------- */

  /**
   * 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?.tool?.name ?? "select";
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Toggle the pause state of the game, triggering the {@link hookEvents.pauseGame} hook when the paused
   * state changes.
   * @param {boolean} pause           The desired pause state; true for paused, false for un-paused
   * @param {object} [options]        Additional options which modify the pause operation
   * @param {boolean} [options.broadcast=false] Broadcast the pause state change to other connected clients?
   *                                            Broadcasting to other clients can only be done by a GM user.
   * @param {string} [options.userId]           The ID of the user who triggered the pause operation. This is
   *                                            populated automatically by the game server.
   * @returns {boolean}               The new paused state
   */
  togglePause(pause, options={}) {
    if ( typeof options === "boolean" ) {
      const msg = "You are passing the legacy \"push\" boolean to Game#togglePause. This is replaced by the"
        + " \"broadcast\" option, for example game.togglePause(true, {broadcast: true}).";
      logCompatibilityWarning(msg, {since: 13, until: 15});
      options = {broadcast: /** @type {boolean} */ options};
    }

    // Broadcast the pause request
    const wasPaused = this.data.paused;
    pause ??= !this.data.paused;
    if (options.broadcast && game.user.isGM) {
      options.userId = game.user.id;
      game.socket.emit("pause", pause, options);
    }

    // Handle pause locally
    this.data.paused = pause;
    if ( pause && !wasPaused ) game.user.movingTokens.forEach(token => token.stopMovement());
    ui.pause.render();
    Hooks$1.callAll("pauseGame", this.data.paused, options);
    return this.data.paused;
  }

  /* -------------------------------------------- */

  /**
   * Open Character sheet for current token or controlled actor
   * @returns {appv1.sheets.ActorSheet|applications.sheets.ActorSheetV2|null} The toggled {@link Actor} sheet, or null
   *                                                                          if the {@link User} has no assigned
   *                                                                          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 ) {
      const minimized = sheet instanceof foundry.applications.api.ApplicationV2 ? sheet.minimized : sheet._minimized;
      if ( 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() {
    window.location.href = getRoute("join");
  }

  /* -------------------------------------------- */

  /**
   * Configure the user interface.
   * @param {GameUIConfiguration} config
   */
  configureUI({fontScale=5, uiScale=1, colorScheme={}, fade={}}={}) {
    const {applications="", interface: iface=""} = colorScheme;
    const rootStyle = document.documentElement.style;
    const body = document.body;

    // UI Variables
    body.style.setProperty("--ui-scale", String(uiScale));
    body.style.setProperty("--ui-fade-opacity", String(fade.opacity));
    body.style.setProperty("--ui-fade-duration", `${fade.speed}ms`);
    body.style.setProperty("--ui-fade-delay", `${fade.speed}ms`);

    // Font Scale
    const fontSizes = [8, 10, 12, 14, 16, 18, 20, 24, 28, 32];
    const size = fontSizes[fontScale - 1] || 16;
    rootStyle.fontSize = `${size}px`;

    // Performance Mode
    const perfMode = game.settings.get("core", "performanceMode");
    const perf = foundry.utils.invertObject(CONST.CANVAS_PERFORMANCE_MODES)[perfMode];
    if ( perf ) body.classList.add(`performance-${perf.toLowerCase()}`);

    // No-blur
    const { noBlur } = game.settings.get("core", "experimental");
    if ( noBlur ) body.classList.add("noblur");

    let browserDefault;
    if ( matchMedia("(prefers-color-scheme: dark)").matches ) browserDefault = "theme-dark";
    else if ( matchMedia("(prefers-color-scheme: light)").matches ) browserDefault = "theme-light";

    // Preferred Color Scheme
    body.classList.remove("theme-light", "theme-dark");
    if ( applications || browserDefault ) body.classList.add(applications ? `theme-${applications}` : browserDefault);

    // Preferred Interface Theme
    const interfaceElements = [
      document.getElementById("interface"),
      ...foundry.applications.instances.values().reduce((arr, app) => {
        const isSidebarTab = app instanceof foundry.applications.sidebar.AbstractSidebarTab;
        const isCompendium = app instanceof foundry.applications.sidebar.apps.Compendium;
        const isCameraView = app instanceof foundry.applications.apps.av.CameraViews;
        const isCamera = app instanceof foundry.applications.apps.av.CameraPopout;
        const isHUD = app instanceof foundry.applications.hud.BasePlaceableHUD;
        if ( !isSidebarTab && !isCompendium && !isCameraView && !isCamera && !isHUD ) return arr;
        if ( !app.options.classes.includes("themed") ) arr.push(app.element);
        return arr;
      }, [])
    ];
    for ( const el of interfaceElements ) {
      el.classList.remove("theme-light", "theme-dark");
      if ( iface || browserDefault ) {
        el.classList.add("themed", iface ? `theme-${iface}` : browserDefault);
      }
    }

    // User Colors
    for ( const user of game.users ) rootStyle.setProperty(`--user-color-${user.id}`, user.color.css);
    rootStyle.setProperty("--user-color", game.user.color.css);

    // Cursors
    this.configureCursors();

    // Hotbar
    ui.hotbar?._onResize();
  }

  /* -------------------------------------------- */

  /**
   * Configure custom cursors.
   */
  configureCursors() {
    const rootStyle = document.documentElement.style;

    // Remove any prior cursor styles
    for ( const k of Object.keys(rootStyle) ) {
      if ( k.startsWith("--cursor") ) rootStyle.removeProperty(k);
    }

    // Merge config with required constants
    const cfg = {...CONST.CURSOR_STYLES, ...CONFIG.cursors};

    // Add new configured styles
    for ( const [cursor, config] of Object.entries(cfg) ) {
      const url = typeof config === "string" ? config : config.url;
      if ( !url ) continue;
      const isImage = ImageHelper.hasImageExtension(url);
      let css = isImage ? `url("${getRoute(url)}")` : url;
      if ( isImage && Number.isFinite(config.x) && Number.isFinite(config.y) ) css += ` ${config.x} ${config.y}`;
      if ( isImage ) css += `, ${cursor === "grab-down" ? "grabbing" : cursor.split("-")[0]}`;
      rootStyle.setProperty(`--cursor-${cursor}`, css);
    }
  }

  /* -------------------------------------------- */

  /**
   * Parse the configured UUID redirects and arrange them as a {@link foundry.utils.StringTree}.
   */
  #parseRedirects() {
    this.compendiumUUIDRedirects = new 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", this.#handlePause.bind(this));

    // 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 = getRoute("/"), 1000);
    });

    // Application reload.
    this.socket.on("reload", () => 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);
    foundry.canvas.animation.ChatBubbles._activateSocketListeners(this.socket);
    foundry.applications.ux.ProseMirrorEditor._activateSocketListeners(this.socket);
    CompendiumCollection._activateSocketListeners(this.socket);
    foundry.documents.RegionDocument._activateSocketListeners(this.socket);

    // Apply buffered events
    Game.#applyBufferedSocketEvents();

    // Request updated activity data
    game.socket.emit("getUserActivity");
  }

  /* -------------------------------------------- */

  /**
   * Handle a hot reload request from the server
   * @param {HotReloadData} data          The hot reload data
   */
  #handleHotReload(data) {
    const proceed = Hooks$1.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({ path }={}) {
    for ( const link of document.querySelectorAll("link") ) {
      const [href] = link.href?.split("?") ?? [];
      if ( href === path ) {
        link.href = `${path}?${Date.now()}`;
        return;
      }
    }
    const pathRegex = new RegExp(`@import "${path}(?:\\?[^"]+)?"`);
    for ( const style of document.querySelectorAll("style") ) {
      const [match] = style.textContent.match(pathRegex) ?? [];
      if ( match ) {
        style.textContent = style.textContent.replace(match, `@import "${path}?${Date.now()}"`);
        return;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * 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);
    }
    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();
  }

  /* -------------------------------------------- */

  /**
   * Handle requests to pause the game session.
   * @param {boolean} paused      The new paused state
   * @param {object} options      Options passed via the server socket
   */
  #handlePause(paused, options) {
    options.broadcast = false;
    game.togglePause(paused, options);
  }

  /* -------------------------------------------- */
  /*  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.bind(this));
    document.addEventListener("pointerup", this.#onPointerUp.bind(this));

    // Prevent dragging and dropping unless a more specific handler allows it
    document.addEventListener("dragstart", this.#onPreventDragstart.bind(this));
    document.addEventListener("dragover", this.#onPreventDragover.bind(this));
    document.addEventListener("drop", this.#onPreventDrop.bind(this));

    // Support mousewheel interaction for range input elements
    window.addEventListener("wheel", Game.#handleMouseWheelInputChange.bind(Game), {passive: false});

    // Tooltip rendering
    this.tooltip.activateEventListeners();

    // Document links
    TextEditor$1.implementation.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.bind(this));
    window.addEventListener("blur", this.#onWindowBlur.bind(this));
    window.addEventListener("resize", this.#onWindowResize.bind(this), { passive: true });
    if ( this.view === "game" ) {
      history.pushState(null, null, location.href);
      window.addEventListener("popstate", this.#onWindowPopState.bind(this));
    }

    // Force hyperlinks to a separate window/tab
    document.addEventListener("click", this._onClickHyperlink.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Support mousewheel control for range type input elements
   * @param {WheelEvent} event    A Mouse Wheel scroll event
   */
  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 {PointerEvent} event
   * @protected
   */
  _onClickHyperlink(event) {
    const a = event.target.closest("a[href]");
    // eslint-disable-next-line no-script-url
    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
   */
  #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
   */
  #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
   */
  #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
   */
  #onPointerDown(event) {
    if ([3, 4, 5].includes(event.button)) event.preventDefault();
    const inlineRoll = document.querySelector(".inline-roll.expanded");
    const target = event.target;
    if ( inlineRoll && !target.closest(".inline-roll") ) {
      return foundry.dice.Roll.defaultImplementation.collapseInlineResult(inlineRoll);
    }

    // Handle cursor depressed state.
    const targetIsValid = target instanceof HTMLElement && !["disabled", "readonly"].some(a => target.hasAttribute(a));
    if ( !targetIsValid || (target === canvas.app?.view) || ("cursor" in target.dataset) ) return;
    const style = getComputedStyle(target);
    const cursor = style.cursor?.split(", ").pop();
    const depressed = `${cursor}-down`;
    if ( depressed in CONFIG.cursors ) {
      target.dataset.cursor = target.style.cursor ?? "";
      target.style.cursor = `var(--cursor-${depressed})`;
    }
  }

  /* -------------------------------------------- */

  /**
   * Fallback handling for mouse-up events which aren't handled further upstream.
   * @param {PointerEvent} event    The mouseup pointer event
   */
  #onPointerUp(event) {
    for ( const el of document.querySelectorAll("[data-cursor]") ) {
      el.style.cursor = el.dataset.cursor;
      delete el.dataset.cursor;
    }

    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
   */
  #onWindowResize(event) {
    for ( const appV1 of Object.values(ui.windows) ) {
      if ( appV1.rendered ) appV1.setPosition({top: appV1.position.top, left: appV1.position.left});
    }
    for ( const appV2 of foundry.applications.instances.values() ) {
      if ( appV2.rendered ) appV2.setPosition();
    }
    if ( ui.webrtc?.rendered ) ui.webrtc.setPosition({height: "auto"});
    if ( ui.hotbar?.rendered ) ui.hotbar._onResize();
    if ( canvas.ready ) 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
   */
  #onWindowBeforeUnload(event) {
    Game.#unloading = true;
    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
   */
  #onWindowBlur(event) {
    game.keyboard?.releaseKeys();
  }

  /* -------------------------------------------- */

  /**
   * Handle cases when the active history entry changes while the user navigates the session history
   * @param {Event} event   The originating window.popstate event
   */
  #onWindowPopState(event) {
    if ( game._goingBack ) return;
    history.pushState(null, null, location.href);
    // eslint-disable-next-line no-alert
    if ( confirm(game.i18n.localize("APP.NavigateBackConfirm")) ) {
      game._goingBack = true;
      history.back();
      history.back();
    }
  }

  /* -------------------------------------------- */
  /*  View Handlers                               */
  /* -------------------------------------------- */

  /**
   * Initialize elements required for the current view
   * @internal
   */
  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
   */
  async #initializeGameView() {

    // Require a valid user cookie and EULA acceptance
    if ( !globalThis.SIGNED_EULA ) window.location.href = 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.data.idleLogout && !this.user.isGM ) {
      setTimeout(() => {
        console.log(`${CONST.vtt} | Ending demo session after 10 minutes. Thanks for testing!`);
        this.logOut();
      }, 1000 * 60 * 10);
    }

    // Context menu listeners
    foundry.applications.ux.ContextMenu.implementation.eventListeners();

    // ProseMirror menu listeners
    ProseMirror.ProseMirrorMenu.eventListeners();
  }

  /* -------------------------------------------- */

  /**
   * Initialization steps for the Stream helper view
   */
  async #initializeStreamView() {
    if ( !globalThis.SIGNED_EULA ) window.location.href = getRoute("license");
    Object.defineProperty(this, "time", {value: new GameTime(), writable: false});
    await this.time.sync();
    this.initializeDocuments();
    for ( const collection of game.collections ) collection.initializeTree();
    ui.chat = new foundry.applications.sidebar.tabs.ChatLog({stream: true});
    ui.chat.render(true);
    CONFIG.DatabaseBackend.activateSocketListeners(this.socket);
    Hooks$1.callAll("streamReady");
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get template() {
    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;

  /**
   * @deprecated since v13
   * @ignore
   */
  scaleFonts(index) {
    logCompatibilityWarning("Game#scaleFonts is deprecated in favor of Game#configureUI",
      {since: 13, until: 15});
    return this.configureUI({fontScale: index});
  }
}

/**
 * A collection of application instances
 * @module ui
 */

/**
 * @import * as applications from "./applications/_module.mjs";
 * @import * as appv1 from "./appv1/_module.mjs";
 */

/* eslint-disable prefer-const */

/**
 * @type {appv1.api.Application|applications.api.ApplicationV2|null}
 */
let activeWindow = null;

/**
 * @type {Record<string, appv1.api.Application>}
 */
const windows = {};

/** @type {applications.sidebar.tabs.ChatLog} */
let chat;

/** @type {applications.sidebar.tabs.CombatTracker} */
let combat;

/** @type {applications.ui.SceneControls} */
let controls;

/** @type {applications.ui.Hotbar} */
let hotbar;

/** @type {applications.ui.MainMenu} */
let menu;

/** @type {applications.ui.SceneNavigation} */
let nav;

/** @type {applications.ui.Notifications} */
let notifications;

/** @type {applications.ui.GamePause} */
let pause;

/** @type {applications.ui.Players} */
let players;

/** @type {applications.sidebar.Sidebar} */
let sidebar;

var globalUI = /*#__PURE__*/Object.freeze({
  __proto__: null,
  activeWindow: activeWindow,
  chat: chat,
  combat: combat,
  controls: controls,
  hotbar: hotbar,
  menu: menu,
  nav: nav,
  notifications: notifications,
  pause: pause,
  players: players,
  sidebar: sidebar,
  windows: windows
});

/**
 * @import ApplicationV2 from "./applications/api/application.mjs";
 * @import {DocumentHTMLEmbedConfig} from "./applications/ux/text-editor.mjs";
 * @import Application from "./appv1/api/application-v1.mjs";
 * @import {AVSettingsData} from "./av/settings.mjs";
 * @import {PingData} from "./canvas/interaction/_types.mjs";
 * @import Token from "./canvas/placeables/token.mjs";
 * @import PointVisionSource from "./canvas/sources/point-vision-source.mjs";
 * @import {TokenShapeType} from "@common/constants.mjs";
 * @import {BuiltinType, CanvasPerformanceMode, DeepReadonly, ElevatedPoint, Point, SocketRequest,
 *   SocketResponse} from "@common/_types.mjs";
 * @import {CustomFormInput} from "./applications/forms/fields.mjs";
 * @import {SceneDimensions, TokenMeasuredMovementWaypoint, TokenPosition,
 *   TokenGetCompleteMovementPathWaypoint, TokenMovementWaypoint, TokenMeasureMovementPathOptions,
 *   TokenMovementSegmentData} from "@client/documents/_types.mjs";
 * @import {Color} from "../common/utils/_module.mjs";
 * @import {CanvasAnimationData, CanvasAnimationEasingFunction} from "./canvas/animation/_types.mjs";
 * @import {GridMeasurePathResultWaypoint, GridOffset3D} from "@common/grid/_types.mjs";
 * @import {Ray} from "./canvas/geometry/_module.mjs";
 * @import {DataModel} from "@common/abstract/_module.mjs";
 * @import {DataField} from "@common/data/fields.mjs";
 * @import {PackageCompendiumData} from "./packages/_types.mjs";
 * @import {TokenDocument} from "./documents/_module.mjs";
 * @import {PrototypeToken} from "@common/data/data.mjs";
 */

/**
 * @typedef 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"
 */

/**
 * @typedef RulerWaypoint
 * @property {number} x                                     The x-coordinate in pixels.
 * @property {number} y                                     The y-coordinate in pixels.
 * @property {number} elevation                             The elevation in grid units.
 * @property {number} index                                 The index of the waypoint.
 * @property {Ray|null} ray                                 The ray from the center point of previous to the
 *                                                          center point of this waypoint, or null if there is
 *                                                          no previous waypoint.
 * @property {GridMeasurePathResultWaypoint} measurement    The measurements at this waypoint.
 * @property {RulerWaypoint|null} previous                  The previous waypoint, if any.
 * @property {RulerWaypoint|null} next                      The next waypoint, if any.
 */

/**
 * @typedef TokenFindMovementPathWaypoint
 * @property {number} [x]                    The top-left x-coordinate in pixels (integer).
 *                                           Default: the previous or source x-coordinate.
 * @property {number} [y]                    The top-left y-coordinate in pixels (integer).
 *                                           Default: the previous or source y-coordinate.
 * @property {number} [elevation]            The elevation in grid units.
 *                                           Default: the previous or source elevation.
 * @property {number} [width]                The width in grid spaces (positive).
 *                                           Default: the previous or source width.
 * @property {number} [height]               The height in grid spaces (positive).
 *                                           Default: the previous or source height.
 * @property {TokenShapeType} [shape]        The shape type (see {@link CONST.TOKEN_SHAPES}).
 *                                           Default: the previous or source shape.
 * @property {string} [action]               The movement action from the previous to this waypoint.
 * @property {boolean} [snapped=false]       Was this waypoint snapped to the grid? Default: `false`.
 * @property {boolean} [explicit=false]      Was this waypoint explicitly placed by the user? Default: `false`.
 * @property {boolean} [checkpoint=false]    Is this waypoint a checkpoint? Default: `false`.
 */

/**
 * @typedef TokenConstrainMovementPathWaypoint
 * @property {number} [x]                       The top-left x-coordinate in pixels (integer).
 *                                              Default: the previous or source x-coordinate.
 * @property {number} [y]                       The top-left y-coordinate in pixels (integer).
 *                                              Default: the previous or source y-coordinate.
 * @property {number} [elevation]               The elevation in grid units.
 *                                              Default: the previous or source elevation.
 * @property {number} [width]                   The width in grid spaces (positive).
 *                                              Default: the previous or source width.
 * @property {number} [height]                  The height in grid spaces (positive).
 *                                              Default: the previous or source height.
 * @property {TokenShapeType} [shape]           The shape type (see {@link CONST.TOKEN_SHAPES}).
 *                                              Default: the previous or source shape.
 * @property {string} [action]                  The movement action from the previous to this waypoint.
 *                                              Default: the previous or prepared movement action.
 * @property {DataModel|null} [terrain=null]    The terrain data of this segment. Default: `null`.
 * @property {boolean} [snapped=false]          Was this waypoint snapped to the grid? Default: `false`.
 * @property {boolean} [explicit=false]         Was this waypoint explicitly placed by the user? Default: `false`.
 * @property {boolean} [checkpoint=false]       Is this waypoint a checkpoint? Default: `false`.
 * @property {boolean} [intermediate=false]     Is this waypoint intermediate? Default: `false`.
 */

/**
 * @typedef TokenConstrainMovementPathOptions
 * @property {boolean} [preview=false]        Constrain a preview path? Default: `false`.
 * @property {boolean} [ignoreWalls=false]    Ignore walls? Default: `false`.
 * @property {boolean} [ignoreCost=false]     Ignore cost? Default: `false`.
 * @property {boolean|DeepReadonly<TokenMeasuredMovementWaypoint[]>} [history=false]
 *   Consider movement history? If true, uses the current movement history.
 *   If waypoints are passed, use those as the history. Default: `false`.
 */

/**
 * @typedef {Omit<TokenMeasuredMovementWaypoint, "userId"|"movementId"|"cost">} TokenConstrainedMovementWaypoint
 */

/**
 * @typedef TokenFindMovementPathOptions
 * @property {boolean} [preview=false]        Find a preview path? Default: `false`.
 * @property {boolean} [ignoreWalls=false]    Ignore walls? Default: `false`.
 * @property {boolean} [ignoreCost=false]     Ignore cost? Default: `false`.
 * @property {boolean|DeepReadonly<TokenMeasuredMovementWaypoint[]>} [history=false]
 *   Consider movement history? If true, uses the current movement history.
 *   If waypoints are passed, use those as the history. Default: `false`.
 * @property {number} [delay=0]               Unless the path can be found instantly, delay the start of the pathfinding
 *                                            computation by this number of milliseconds. Default: `0`.
 */

/**
 * @typedef TokenFindMovementPathJob
 * @property {TokenMovementWaypoint[]|null|undefined} result    The result of the pathfinding job. Undefined while the
 *                                                              search is in progress, null if the job was cancelled,
 *                                                              and the (partial) path if the job completed.
 * @property {Promise<TokenMovementWaypoint[]|null>} promise    The promise returning the (partial) path that as found
 *                                                              or null if cancelled.
 * @property {() => void} cancel                                If this function is called and the job hasn't completed
 *                                                              yet, the job is cancelled.
 */

/**
 * @typedef {Omit<TokenGetCompleteMovementPathWaypoint, "terrain">} TokenGetTerrainMovementPathWaypoint
 */

/**
 * @typedef {Omit<TokenMeasuredMovementWaypoint, "userId"|"movementId"|"cost">} TokenTerrainMovementWaypoint
 */

/**
 * @typedef TokenRulerData
 * @property {TokenMeasuredMovementWaypoint[]} passedWaypoints     The waypoints that were already passed by the Token
 * @property {TokenMeasuredMovementWaypoint[]} pendingWaypoints    The waypoints that the Token will try move to next
 * @property {{[userId: string]: TokenPlannedMovement}} plannedMovement    Movement planned by Users
 */

/**
 * @typedef TokenPlannedMovement
 * @property {Omit<TokenMeasuredMovementWaypoint, "userId"|"movementId">[]} foundPath    The found path, which goes
 *   through all but the unreachable waypoints
 * @property {Omit<TokenMeasuredMovementWaypoint, "userId"|"movementId">[]} unreachableWaypoints    The unreachable
 *   waypoints, which are those that are not reached by the found path
 * @property {TokenMeasuredMovementWaypoint[]} history    The movement history
 * @property {boolean} hidden                             Is the path hidden?
 * @property {boolean} searching                          Is the pathfinding still in progress?
 */

/**
 * @typedef TokenRulerWaypointData
 * @property {TokenMovementActionConfig} actionConfig       The config of the movement action
 * @property {string|null} movementId                       The ID of movement, or null if planned movement.
 * @property {number} index                                 The index of the waypoint, which is equal to the number of
 *                                                          explicit waypoints from the first to this waypoint.
 * @property {"passed"|"pending"|"planned"} stage           The stage this waypoint belongs to.
 * @property {boolean} hidden                               Is this waypoint hidden?
 * @property {boolean} unreachable                          Is this waypoint unreachable?
 * @property {Point} center                                 The center point of the Token at this waypoint.
 * @property {{width: number; height: number}} size         The size of the Token in pixels at this waypoint.
 * @property {Ray|null} ray                                 The ray from the center point of previous to the center
 *                                                          point of this waypoint, or null if there is no previous
 *                                                          waypoint.
 * @property {GridMeasurePathResultWaypoint} measurement    The measurements at this waypoint.
 * @property {TokenRulerWaypoint|null} previous             The previous waypoint, if any.
 * @property {TokenRulerWaypoint|null} next                 The next waypoint, if any.
 */

/**
 * @typedef {Omit<TokenMeasuredMovementWaypoint, "movementId"> & TokenRulerWaypointData} TokenRulerWaypoint
 */

/**
 * @typedef TokenDragContext
 * @property {Token} token
 * @property {Token} clonedToken
 * @property {TokenPosition} origin
 * @property {Omit<TokenMovementWaypoint, "width"|"height"|"shape"|"action">
 *   & Partial<Pick<TokenMovementWaypoint, "width"|"height"|"shape"|"action">>} destination
 * @property {(Omit<TokenMovementWaypoint, "width"|"height"|"shape"|"action">
 *   & Partial<Pick<TokenMovementWaypoint, "width"|"height"|"shape"|"action">>)[]} waypoints
 * @property {TokenMovementWaypoint[]} foundPath
 * @property {TokenMovementWaypoint[]} unreachableWaypoints
 * @property {boolean} hidden
 * @property {boolean} updating
 * @property {TokenFindMovementPathJob} search
 * @property {boolean} searching
 * @property {number} searchId
 * @property {TokenFindMovementPathOptions} searchOptions
 */

/**
 * @typedef TokenAnimationData
 * @property {number} x                        The x position in pixels
 * @property {number} y                        The y position in pixels
 * @property {number} elevation                The elevation in grid units
 * @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
 */

/**
 * @typedef TokenAnimationContext
 * @property {string|symbol} name                The name of the animation.
 * @property {{to: Partial<TokenAnimationData>, options: Omit<TokenAnimationOptions, "duration"> & {duration: number},
 *   promise: Promise<void> , resolve: () => void, reject: (error: Error) => void}[]} chain
 *                                               The animation chain.
 * @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 foundry.canvas.placeables.Token#_onAnimationUpdate}.
 * @property {Promise<void>} promise    The promise of the animation that resolves once it completes or is terminated.
 */

/**
 * @typedef TokenAnimationOptions
 * @property {string|symbol|null} [name]    The name of the animation, or null if nameless.
 *                                          Default: {@link foundry.canvas.placeables.Token#animationName}.
 * @property {boolean} [chain=false]        Chain the animation to the existing one of the same name? Default: `false`.
 * @property {number} [duration]            The duration of the animation in milliseconds (nonnegative).
 *                                          Default: automatic (determined by
 *                                          {@link foundry.canvas.placeables.Token#_getAnimationDuration},
 *                                          which returns 1000 by default unless it's a movement animation).
 * @property {number} [movementSpeed]       A desired base movement speed in grid size per second (positive),
 *                                          which determines the `duration` if the given `duration` is undefined and
 *                                          either `x`, `y`, `width`, `height`, or `rotation` is animated.
 *                                          Default: automatic (determined by
 *                                          {@link foundry.canvas.placeables.Token#_getAnimationMovementSpeed}).
 * @property {string} [action]              The movement action. Default: `this.document.movementAction`.
 * @property {DataModel|null} [terrain=null]           The terrain data. Default: `null`.
 * @property {TokenAnimationTransition} [transition]   The desired texture transition type.
 *                                                     Default: automatic (determined by
 *                                                     {@link foundry.canvas.placeables.Token#_getAnimationTransition},
 *                                                     which returns `"fade"` by default).
 * @property {CanvasAnimationEasingFunction} [easing]  The easing function of the animation.
 *                                                     Default: `undefined` (linear).
 * @property {(elapsedMS: number, animation: CanvasAnimationData, data: TokenAnimationData) => void} [ontick]
 *                                                     An on-tick callback.
 */

/**
 * @typedef {"crosshatch"|"dots"|"fade"|"glitch"|"hole"|"holeSwirl"|"hologram"|"morph"|"swirl"|"waterDrop"|"waves"
 *   |"wind"|"whiteNoise"} TokenAnimationTransition
 */


/**
 * @callback TokenMovementActionCostFunction
 * @param {number} baseCost                      The base cost (terrain cost)
 * @param {Readonly<GridOffset3D>} from          The offset that is moved from
 * @param {Readonly<GridOffset3D>} to            The offset that is moved to
 * @param {number} distance                      The distance between the grid spaces
 * @param {DeepReadonly<TokenMovementSegmentData>} segment    The properties of the segment
 * @returns {number}                             The cost of the move between the grid spaces (nonnegative)
 */

/**
 * @typedef TokenMovementActionConfig
 * @property {string} label          The label of the movement action.
 * @property {string} icon           The FontAwesome icon class.
 * @property {string|null} img       An image filename. Takes precedence over the icon if both are supplied.
 * @property {number} order          The number that is used to sort the movement actions / movement action configs.
 *                                   Determines the order in the Token Config/HUD and of cycling. Default: `0`.
 * @property {boolean} teleport      Is teleportation? If true, the movement does not go through all grid spaces
 *                                   between the origin and destination: it goes from the origin immediately to the
 *                                   destination grid space. Default: `false`.
 * @property {boolean} measure       Is the movement measured? The distance, cost, spaces, and diagonals
 *                                   of a segment that is not measured are always 0. Default: `true`.
 * @property {string|null} walls     The type of walls that block this movement, if any. Default: `"move"`.
 * @property {boolean} visualize     Is segment of the movement visualized by the ruler? Default: `true`.
 * @property {(token: Token) => Pick<TokenAnimationOptions, "duration"|"movementSpeed"|"easing"
 *   |"ontick">} getAnimationOptions Get the default animation options for this movement action. Default: `() => ({})`.
 * @property {(token: TokenDocument|PrototypeToken) => boolean} canSelect
 *   Can the current User select this movement action for the given Token? If selectable, the movement action of the
 *   Token can be set to this movement action by the User via the UI and when cycling. Default: `() => true`.
 * @property {((nonDerivedDifficulties: {[action: string]: number}) => number) | null} deriveTerrainDifficulty
 *   If set, this function is used to derive the terrain difficulty from from nonderived difficulties,
 *   which are those that do not have `deriveTerrainDifficulty` set.
 *   Used by {@link foundry.data.regionBehaviors.ModifyMovementCostRegionBehaviorType}.
 *   Derived terrain difficulties are not configurable via the behavior UI.
 * @property {(token: TokenDocument, options: TokenMeasureMovementPathOptions)
 *   => TokenMovementActionCostFunction} getCostFunction  The cost modification function. Default: `() => cost => cost`.
 */

/**
 * @typedef CanvasViewPosition
 * @property {number} x      The x-coordinate which becomes `stage.pivot.x`
 * @property {number} y      The y-coordinate which becomes `stage.pivot.y`
 * @property {number} scale  The zoom level which becomes `stage.scale.x` and `y`
 */

/**
 * @typedef CanvasVisibilityTest
 * @property {ElevatedPoint} point
 * @property {Map<PointVisionSource, boolean>} los
 */

/**
 * @typedef CanvasVisibilityTestConfiguration
 * @property {object|null} object              The target object
 * @property {CanvasVisibilityTest[]} tests    An array of visibility tests
 */

/**
 * @typedef CanvasVisibilityTextureConfiguration
 * @property {number} resolution
 * @property {number} width
 * @property {number} height
 * @property {number} mipmap
 * @property {number} scaleMode
 * @property {number} alphaMode
 * @property {number} multisample
 * @property {number} format
 */

/**
 * @typedef 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]            The size of the arrows as a proportion of grid size.
 *                                      Default: `CONFIG.Canvas.targeting.size`.
 * @property {number} [color]           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.
 */

/**
 * @typedef ActivityData
 * @property {string|null} [sceneId]           The ID of the scene that the user is viewing.
 * @property {Point} [cursor]                  The position of the user's cursor.
 * @property {ElevatedPoint[]} [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.
 */

/**
 * @typedef CanvasPerformanceSettings
 * @property {CanvasPerformanceMode} mode A performance mode in {@link 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
 */

/**
 * @typedef CanvasSupportedComponents
 * @property {boolean} webGL2           Is WebGL2 supported?
 * @property {boolean} readPixelsRED    Is reading pixels in RED format supported?
 * @property {boolean} offscreenCanvas  Is the OffscreenCanvas supported?
 */

/**
 * @typedef _CanvasDimensions
 * @property {{min: number, max: number, default: number}} scale  The minimum, maximum, and default canvas scale.
 * @property {number} uiScale                                     The scaling factor for canvas UI elements.
 *                                                                Based on the normalized grid size (100px).
 */

/**
 * @typedef {SceneDimensions & _CanvasDimensions} CanvasDimensions
 */

/**
 * @typedef 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.
 */

/**
 * @typedef {DataField|{[K in string]: SearchableField}} SearchableField
 */

/**
 * @typedef FromCompendiumOptions
 * @property {boolean} [clearFolder=false]    Clear the currently assigned folder.
 * @property {boolean} [clearState=true]      Clear fields which store Document state.
 * @property {boolean} [clearSort=true]       Clear the current sort order.
 * @property {boolean} [clearOwnership=true]  Clear Document ownership (recursive).
 * @property {boolean} [keepId=false]         Retain the Document ID from the source Compendium.
 */

/**
 * @typedef _RollTableHTMLEmbedConfig
 * @property {boolean} [rollable=false]  Adds a button allowing the table to be rolled directly from its embedded
 *                                       context.
 * @property {string} [rangeLabel]       The label to use for the range column. If rollable is true, this option is
 *                                       ignored.
 * @property {string} [resultLabel]      The label to use for the result column.
 */

/**
 * @typedef {DocumentHTMLEmbedConfig & _RollTableHTMLEmbedConfig} RollTableHTMLEmbedConfig
 */

/**
 * @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.
 */

/**
 * @typedef WorldCompendiumPackConfiguration
 * @property {string|null} folder
 * @property {number} [sort]
 * @property {boolean} [locked]
 * @property {Record<
 *   Exclude<keyof typeof CONST.USER_ROLES, "NONE">,
 *   keyof typeof CONST.DOCUMENT_OWNERSHIP_LEVELS
 * >} ownership
 */

/**
 * @typedef {Record<string, WorldCompendiumPackConfiguration>} WorldCompendiumConfiguration
 */

/* ----------------------------------------- */
/*  Settings Type Definitions                */
/* ----------------------------------------- */

/**
 * @typedef SettingConfig
 * A Client Setting
 * @property {string} key             A unique machine-readable id for the setting
 * @property {string} namespace       The namespace the setting belongs to
 * @property {string} name            The human-readable name
 * @property {string} hint            An additional human-readable hint
 * @property {"world"|"client"|"user"} scope  The scope the Setting is stored in, either World, Client, or User.
 * @property {boolean} config         Indicates if this Setting should render in the Config application
 * @property {BuiltinType|DataField|typeof DataModel} type The type of data stored by this Setting
 * @property {Object} [choices]       For string Types, defines the allowable values
 * @property {Object} [range]         For numeric Types, defines the allowable range
 * @property {any} [default]          The default value
 * @property {Function} [onChange]    Executes when the value of this Setting changes
 * @property {CustomFormInput} [input] A custom form field input used in conjunction with a DataField type
 * @property {string} [id]            The combination of `{namespace}.{key}`
 */

/**
 * @typedef SettingSubmenuConfig
 * A Client Setting Submenu
 * @property {string} name             The human readable name
 * @property {string} label            The human readable label
 * @property {string} hint             An additional human readable hint
 * @property {string} icon             The classname of an Icon to render
 * @property {typeof Application|typeof ApplicationV2} type The Application class to render
 * @property {boolean} restricted      If true, only a GM can edit this Setting
 */

/**
 * @typedef KeybindingActionConfig
 * A Client Keybinding Action Configuration
 * @property {string} [namespace]                       The namespace within which the action was registered
 * @property {string} name                              The human-readable name.
 * @property {string} [hint]                            An additional human-readable hint.
 * @property {KeybindingActionBinding[]} [uneditable]   The default bindings that can never be changed nor removed.
 * @property {KeybindingActionBinding[]} [editable]     The default bindings that can be changed by the user.
 * @property {(context: KeyboardEventContext) => boolean|void} [onDown]
 *                                                      A function to execute when a key down event occurs.
 *                                                      If True is returned, the event is consumed and no further
 *                                                      keybinds execute.
 * @property {(context: KeyboardEventContext) => boolean|void} [onUp]
 *                                                      A function to execute when a key up event occurs. If True is
 *                                                      returned, the event is consumed and no further keybinds execute.
 * @property {boolean} [repeat=false]                   If True, allows Repeat events to execute the Action's onDown.
 *                                                      Defaults to false.
 * @property {boolean} [restricted=false]               If true, only a GM can edit and execute this Action.
 * @property {string[]} [reservedModifiers]             Modifiers such as `["CONTROL"]` that can be also pressed when
 *                                                      executing this Action. Prevents using one of these modifiers as
 *                                                      a Binding.
 * @property {number} [precedence=0]                    The preferred precedence of running this Keybinding Action.
 * @property {number} [order]                           The recorded registration order of the action.
 */

/**
 * @typedef KeybindingActionBinding
 * A Client Keybinding Action Binding
 * @property {number} [index]           A numeric index which tracks this bindings position during form rendering
 * @property {string} key               The KeyboardEvent#code value from
 * @property {string} logicalKey        The Keyboard logical code if universal mode is enable (it is code otherwise)
 *   {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values}
 * @property {string[]} [modifiers]     An array of modifiers keys from
 *                                      {@link foundry.helpers.interaction.KeyboardManager.MODIFIER_KEYS}
 *                                      which are required for this binding to be activated
 */

/**
 * @typedef KeybindingAction
 * An action that can occur when a key is pressed
 * @property {string} action               The namespaced machine identifier of the Action
 * @property {string} key                  The Keyboard key
 * @property {string} name                 The human-readable name
 * @property {string[]} requiredModifiers  Required modifiers
 * @property {string[]} optionalModifiers  Optional (reserved) modifiers
 * @property {Function} onDown             The handler that executes onDown
 * @property {Function} onUp               The handler that executes onUp
 * @property {boolean} repeat              If True, allows Repeat events to execute this Action's onDown
 * @property {boolean} restricted          If true, only a GM can execute this Action
 * @property {number} precedence           The registration precedence
 * @property {number} order                The registration order
 */

/**
 * @typedef KeyboardEventContext
 * A keyboard event context
 * @property {string} key              The normalized string key, such as "KeyA"
 * @property {string} logicalKey       The logical string key, such as "a"
 * @property {KeyboardEvent} event     The originating keypress event
 * @property {boolean} isShift         Is the Shift modifier being pressed
 * @property {boolean} isControl       Is the Control or Meta modifier being processed
 * @property {boolean} isAlt           Is the Alt modifier being pressed
 * @property {boolean} hasModifier     Are any of the modifiers being pressed
 * @property {string[]} modifiers      A list of string modifiers applied to this context, such as `["CONTROL"]`
 * @property {boolean} up              True if the Key is Up, else False if down
 * @property {boolean} repeat          True if the given key is being held down such that it is automatically repeating.
 * @property {string} [action]         The executing Keybinding Action. May be undefined until the action is known.
 */

/**
 * @typedef ConnectedGamepad
 * Connected Gamepad info
 * @property {Map<string, number>} axes        A map of axes values
 * @property {Set<string>} activeButtons       The Set of pressed Buttons
 */

var _types = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * Display the End User License Agreement and prompt the user to agree before moving forwards.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class EULA extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "eula",
    classes: ["themed", "theme-dark"],
    tag: "aside",
    window: {
      title: "End User License Agreement",
      icon: "fa-solid fa-file-contract",
      minimizable: false
    },
    position: {
      width: 800,
      height: "auto"
    },
    actions: {
      accept: EULA.#onClickAccept,
      decline: EULA.#onClickDecline
    }
  };

  /** @override */
  static PARTS = {
    content: {
      id: "content",
      template: "templates/setup/parts/eula-content.hbs",
      scrollable: [""]
    },
    form: {
      id: "form",
      template: "templates/setup/parts/eula-form.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    const html = await foundry.utils.fetchWithTimeout("license.html").then(r => r.text());
    return {html};
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderFrame(options) {
    const frame = await super._renderFrame(options);
    this.window.controlsDropdown.remove();
    this.window.close.remove();
    return frame;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Require the user to have checked the agreement box in order to proceed.
   * @this {EULA}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} target
   */
  static #onClickAccept(event, target) {
    if ( !this.parts.form.agree.checked ) {
      event.preventDefault();
      event.stopPropagation();
      ui.notifications.error("EULA.ErrorAgreeRequired", {localize: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Require the user to have checked the agreement box in order to proceed.
   * @this {EULA}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} target
   */
  static #onClickDecline(event, target) {
    this.parts.form.agree.checked = false;
    ui.notifications.warn("EULA.Declined", {localize: true});
    setTimeout(() => window.location.href = CONST.WEBSITE_URL, 1000);
  }
}

/**
 * The Join Game setup application.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class JoinGameForm extends HandlebarsApplicationMixin(ApplicationV2) {
  constructor(options) {
    super(options);
    game.users.apps.push(this);
  }

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "join-game",
    window: {
      frame: false,
      positioned: false
    }
  };

  /** @override */
  static PARTS = {
    form: {
      id: "form",
      template: "templates/setup/parts/join-form.hbs",
      forms: {
        "#join-game-form": {
          handler: JoinGameForm.#onSubmitLoginForm
        }
      }
    },
    details: {
      id: "details",
      template: "templates/setup/parts/join-details.hbs"
    },
    setup: {
      id: "setup",
      template: "templates/setup/parts/join-setup.hbs",
      forms: {
        "#join-game-setup": {
          handler: JoinGameForm.#onSubmitSetupForm
        }
      }
    },
    world: {
      id: "world",
      template: "templates/setup/parts/join-world.hbs",
      scrollable: ["#world-description"]
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( game.world.joinTheme === "minimal" ) {
      const minimalParts = ["form", "setup"];
      options.parts = options.parts.filter(p => minimalParts.includes(p));
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    const context = {
      isAdmin: game.data.isAdmin,
      users: game.users,
      world: game.world,
      passwordString: game.data.passwordString,
      usersCurrent: game.users.filter(u => u.active).length,
      usersMax: game.users.contents.length
    };

    // Next session time
    const nextDate = new Date(game.world.nextSession || undefined);
    if ( nextDate.isValid() ) {
      context.nextTime = nextDate.toLocaleTimeString(game.i18n.lang, {
        weekday: "long",
        month: "short",
        day: "numeric",
        hour: "numeric",
        minute: "numeric",
        timeZoneName: "short"
      });
    }
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _syncPartState(partId, newElement, priorElement, state) {
    super._syncPartState(partId, newElement, priorElement, state);
    // Retain login form state
    if ( partId === "form" ) {
      newElement.userid.value = priorElement.userid.value;
      if ( newElement.userid.selectedOptions[0]?.disabled ) newElement.userid.value = "";
      newElement.password.value = priorElement.password.value;
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * @this {JoinGameForm}
   * @param {SubmitEvent} event
   * @param {HTMLFormElement} form
   * @param {foundry.applications.ux.FormDataExtended} formData
   */
  static async #onSubmitSetupForm(event, form, formData) {
    event.preventDefault();
    form.disabled = true;

    // Display a warning if other players are connected
    const othersActive = game.users.filter(u => u.active).length;
    if ( othersActive ) {
      const warning = othersActive > 1 ? "GAME.ReturnSetupActiveUsers" : "GAME.ReturnSetupActiveUser";
      const confirm = await foundry.applications.api.DialogV2.confirm({
        window: {title: "GAME.ReturnSetup"},
        content: `<p>${game.i18n.format(warning, {number: othersActive})}</p>`
      });
      if ( !confirm ) {
        form.disabled = false;
        return;
      }
    }

    // Submit the request
    const postData = Object.assign(formData.object, {action: "shutdown"});
    return this.#post(form, postData);
  }


  /* -------------------------------------------- */

  /**
   * @this {JoinGameForm}
   * @param {SubmitEvent} event
   * @param {HTMLFormElement} form
   * @param {foundry.applications.ux.FormDataExtended} formData
   */
  static async #onSubmitLoginForm(event, form, formData) {
    event.preventDefault();
    if ( !formData.get("userid") ) return ui.notifications.error("JOIN.ErrorMustSelectUser", {localize: true});
    const postData = Object.assign(formData.object, {action: "join"});
    return this.#post(form, postData);
  }

  /* -------------------------------------------- */

  /**
   * Submit join view POST requests to the server for handling.
   * @param {HTMLFormElement} form                    The form being submitted
   * @param {object} postData                         The processed form data
   * @returns {Promise<void>}
   */
  async #post(form, postData) {
    form.disabled = true;
    const joinURL = foundry.utils.getRoute("join");
    const user = game.users.get(postData.userid)?.name || postData.userid;
    let response;

    // Submit the request
    try {
      response = await foundry.utils.fetchJsonWithTimeout(joinURL, {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify(postData)
      });
    }
    catch(e) {
      if (e instanceof foundry.utils.HttpError) {
        ui.notifications.error(e.displayMessage, {format: {user}});
      }
      else {
        ui.notifications.error(foundry.utils.escapeHTML(e));
      }
      form.disabled = false;
      return;
    }

    // Redirect on success
    ui.notifications.info(response.message, {format: {user}});
    setTimeout(() => window.location.href = response.redirect, 500 );
  }
}

/**
 * @import {ServerSettings} from "@common/config.mjs";
 */

/**
 * A form application for managing core server configuration options.
 * @see {@link foundry.config.ServerSettings}
 */
class ServerSettingsConfig extends new HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    tag: "form",
    id: "server-settings-config",
    window: {
      title: "SERVER_SETTINGS.Title",
      icon: "fa-solid fa-gears",
      contentClasses: ["standard-form"]
    },
    position: {
      width: 720
    },
    actions: {
      changePassword: ServerSettingsConfig.#onChangePassword
    },
    form: {
      handler: ServerSettingsConfig.#onSubmit
    }
  };

  /** @override */
  static PARTS = {
    config: {
      template: "templates/setup/server-settings-config.hbs",
      scrollable: [""]
    },
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /* -------------------------------------------- */

  /**
   * Which CSS theme is currently being previewed
   * @type {string}
   */
  #previewTheme = game.serverSettings.cssTheme;

  /**
   * Has the ApplicationConfiguration model been localized?
   * @type {boolean}
   */
  #modelLocalized = false;


  /**
   * An ApplicationConfiguration instance which is used for validation and processing of form changes.
   * @type {ServerSettings}
   */
  get settings() {
    return game.serverSettings;
  }

  /* -------------------------------------------- */

  /**
   * Prompt the user with a request to share telemetry data if they have not yet chosen an option.
   */
  static async telemetryRequestDialog() {
    if ( typeof game.data.options.telemetry === "boolean" ) return;
    const content = [`<p>${game.i18n.localize("SERVER_SETTINGS.Telemetry.request1")}</p>`,
      `<blockquote>${game.i18n.localize("SERVER_SETTINGS.FIELDS.telemetry.hint")}</blockquote>`,
      `<p>${game.i18n.localize("SERVER_SETTINGS.Telemetry.request2")}</p>`].join("\n");
    const confirmed = await foundry.applications.api.DialogV2.confirm({
      window: {title: "SERVER_SETTINGS.FIELDS.telemetry.label"},
      position: {width: 480},
      content,
      yes: {label: "SERVER_SETTINGS.Telemetry.yes"},
      no: {label: "SERVER_SETTINGS.Telemetry.no"}
    });
    if ( typeof confirmed == "boolean" ) {
      const {changes} = await game.post({action: "adminConfigure", config: {telemetry: confirmed}});
      foundry.utils.mergeObject(game.data.options, changes);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareContext(options) {
    const cssThemes = Object.entries(CONST.CSS_THEMES).reduce((themes, [key, label]) => {
      themes[key] = game.i18n.localize(label);
      return themes;
    }, {});
    const languages = game.data.languages.flatMap(l => l.modules.map(m =>
      ({group: l.label, value: `${l.id}.${m.id}`, label: `${l.label} - ${m.label}`})));
    const worlds = game.worlds.contents
      .sort((a, b) => a.title.localeCompare(b.title, game.i18n.lang))
      .reduce((obj, world) => {
        obj[world.id] = world.title;
        return obj;
      }, {});
    const settings = game.serverSettings;
    return {
      rootId: this.id,
      source: settings._source,
      fields: settings.schema.fields,
      cssThemes, languages, worlds,
      noAdminPW: !game.data.options.adminPassword,
      buttons: [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "SERVER_SETTINGS.Save.label"}]
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preFirstRender(context, options) {
    await super._preFirstRender(context, options);
    if ( !this.#modelLocalized ) {
      Localization.localizeDataModel(foundry.config.ServerSettings);
      this.#modelLocalized = true;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    if ( event.target.name === "cssTheme") this.#applyThemeChange(this.form.cssTheme.value);
  }

  /* -------------------------------------------- */

  /**
   * Handle a request to change the administrator password.
   * @this {ServerSettingsConfig}
   * @returns {Promise<void>}
   */
  static async #onChangePassword() {
    return foundry.applications.api.DialogV2.prompt({
      content: `
        <p>${game.i18n.localize("SERVER_SETTINGS.ChangePassword.hint")}</p>
        <input type="password" name="adminPassword">
      `,
      window: {
        icon: "fa-solid fa-lock",
        title: "SERVER_SETTINGS.ChangePassword.title"
      },
      ok: {
        label: "SERVER_SETTINGS.ChangePassword.label",
        callback: (event, button) => button.form.elements.adminPassword.value
      },
      submit: async (attempt, dialog) => {
        dialog.element.querySelector("button").innerHTML = '<i class="fa-solid fa-spinner fa-spin-pulse" inert></i>';
        const response = await game.post({ action: "adminPassword", adminPassword: attempt });
        if ( response?.result ) {
          const password = document.createElement("input");
          Object.assign(password, { type: "password", name: "adminPassword", value: attempt });
          document.getElementById("changePassword").replaceWith(password);
        }
      }
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    this.#applyThemeChange(this.settings.cssTheme);
  }

  /* -------------------------------------------- */

  /**
   * Handle form submission.
   * @this {ServerSettingsConfig}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmit(event, form, formData) {
    const original = this.settings.toObject();

    // Validate the proposed changes
    let changes;
    try {
      changes = this.settings.updateSource(formData.object);
    } catch(err) {
      return ui.notifications.error(foundry.utils.escapeHTML(err.message));
    }
    if ( !Object.keys(changes).length ) return this.close();

    // Confirm that a server restart is okay
    const confirmed = await foundry.applications.api.DialogV2.confirm({
      window: {title: "SERVER_SETTINGS.Save.label"},
      position: {width: 480},
      content: `<p>${game.i18n.localize("SERVER_SETTINGS.Save.warning")}</p>`
    });

    // Submit the form
    if ( confirmed ) {
      const response = await game.post({action: "adminConfigure", config: changes});
      if ( response.restart ) ui.notifications.info("SERVER_SETTINGS.Save.restart", {localize: true, permanent: true});
      return this.close();
    }

    // Reset the form
    this.settings.updateSource(original);
  }

  /* -------------------------------------------- */

  /**
   * Update the body class with the previewed CSS theme.
   * @param {string} themeId     The theme ID to preview
   */
  #applyThemeChange(themeId) {
    document.body.classList.replace(`theme-${this.#previewTheme}`, `theme-${themeId}`);
    this.#previewTheme = themeId;
  }
}

/**
 * The Setup Authentication Form.
 * Prompt the user to provide a server administrator password if one has been configured.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class SetupAuthenticationForm extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "setup-authentication",
    classes: ["application", "framed"],
    window: {
      frame: false,
      positioned: false
    }
  };

  /** @override */
  static PARTS = {
    form: {
      template: "templates/setup/setup-authentication.hbs"
    }
  };
}

/**
 * An application that renders the floating setup menu buttons.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class SetupWarnings extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "setup-warnings",
    window: {
      title: "SETUP.WarningsTitle",
      icon: "fa-solid fa-triangle-exclamation"
    },
    position: {
      width: 680
    },
    actions: {
      reinstallPackage: SetupWarnings.#reinstallPackage,
      uninstallPackage: SetupWarnings.#uninstallPackage,
      managePackage: SetupWarnings.#managePackage
    }
  };

  /** @override */
  static PARTS = {
    packages: {
      id: "packages",
      template: "templates/setup/setup-warnings.hbs",
      scrollable: [""]
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    return `${super.title} (${game.issueCount.total})`;
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    const categories = {
      world: {label: "SETUP.Worlds", packages: {}},
      system: {label: "SETUP.Systems", packages: {}},
      module: {label: "SETUP.Modules", packages: {}}
    };

    // Organize warnings
    for ( const pkg of Object.values(game.data.packageWarnings) ) {
      const p = foundry.packages.PACKAGE_TYPES[pkg.type].get(pkg.id);
      categories[pkg.type].packages[pkg.id] = {
        id: pkg.id,
        type: pkg.type,
        name: p ? p.title : "",
        errors: pkg.error.map(e => e.trim()).join("\n"),
        warnings: pkg.warning.map(e => e.trim()).join("\n"),
        reinstallable: pkg.reinstallable,
        installed: p !== undefined
      };
    }

    // Filter categories to ones which have issues
    for ( const [k, v] of Object.entries(categories) ) {
      if ( foundry.utils.isEmpty(v.packages) ) delete categories[k];
    }
    return {categories};
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to manage the package in the main setup interface.
   * @param {PointerEvent} event          The initiating click event
   * @param {HTMLButtonElement} target    The clicked button
   */
  static #managePackage(event, target) {
    event.preventDefault();
    const li = target.closest(".package");
    const packageType = li.closest("section[data-package-type]").dataset.packageType;
    const tabName = `${packageType}s`;
    const packageId = li.dataset.packageId;
    ui.setupPackages.changeTab(tabName, "primary");
    ui.setupPackages.search(packageId);
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to reinstall a package.
   * @param {PointerEvent} event          The initiating click event
   * @param {HTMLButtonElement} target    The clicked button
   */
  static async #reinstallPackage(event, target) {
    event.preventDefault();
    const pkg = target.closest("[data-package-id]");
    const id = pkg.dataset.packageId;
    const type = pkg.dataset.packageType;
    target.querySelector("i").classList.add("fa-spin");

    // Uninstall current
    await game.uninstallPackage({id, type});
    delete game.data.packageWarnings[id];

    // Install package
    await game.warmPackages({ type });
    const warnInfo = game.data.packageWarnings[id];
    if ( !pkg && !warnInfo?.manifest ) {
      return ui.notifications.error("SETUP.ReinstallPackageNotFound", { localize: true, permanent: true });
    }
    await game.installPackage({ type, id, manifest: warnInfo?.manifest ?? pkg.manifest });
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to uninstall a package
   * @param {PointerEvent} event          The initiating click event
   * @param {HTMLButtonElement} target    The clicked button
   */
  static async #uninstallPackage(event, target) {
    event.preventDefault();
    const pkg = target.closest("[data-package-id]");
    const id = pkg.dataset.packageId;
    const type = pkg.dataset.packageType;
    await game.uninstallPackage({id, type});
    delete game.data.packageWarnings[id];
  }
}

/**
 * @import {ApplicationClickAction, ApplicationConfiguration} from "@client/applications/_types.mjs";
 */

/**
 * @typedef BackupListConfiguration
 * @property {{id: string, type: "world"|"system"|"module", title: string}} package
 */

/**
 * An application that manages backups for a single package.
 * @extends ApplicationV2<ApplicationConfiguration & BackupListConfiguration>
 */
class BackupList extends HandlebarsApplicationMixin(ApplicationV2) {

  /**
   * @param {ApplicationConfiguration & BackupListConfiguration} options
   */
  constructor(options) {
    super(options);
    this.package = options.package;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["backup-list"],
    window: {
      icon: "fa-solid fa-hard-drive",
      contentClasses: ["standard-form"]
    },
    position: {
      width: 640,
      height: 780
    },
    actions: {
      takeBackup: BackupList.#onTakeBackup,
      restoreBackup: BackupList.#onRestoreBackup,
      deleteBackups: BackupList.#onDeleteBackups
    }
  };

  /** @override */
  static PARTS = {
    list: {
      template: "templates/setup/backup-list.hbs",
      root: true,
      scrollable: ["[data-entries]"]
    }
  };

  /* -------------------------------------------- */

  /**
   * Package lookup data for the backups being listed
   * @type {{id: string, type: "world"|"system"|"module", title: string}}
   */
  package;

  /** @override */
  get id() {
    return `backup-list-${this.package.type}-${this.package.id}`;
  }

  /** @override */
  get title() {
    return game.i18n.format("SETUP.BACKUPS.ManagePackage", {package: this.package.title});
  }

  /**
   * The list of available backups for this package.
   * @type {BackupData[]}
   */
  #backups = [];

  /**
   * The backup date formatter.
   * @type {Intl.DateTimeFormat}
   */
  #dateFormatter = new Intl.DateTimeFormat(game.i18n.lang, {dateStyle: "full", timeStyle: "short"});

  /* -------------------------------------------- */

  /**
   * Determine the version tag for a given backup.
   * @param {BackupData} backupData  The backup.
   * @returns {BackupEntryTagDescriptor}
   */
  static getVersionTag(backupData) {
    const cls = foundry.packages.PACKAGE_TYPES[backupData.type];
    const availability = cls.testAvailability(backupData);
    return cls.getVersionBadge(availability, backupData);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async render(options) {
    await super.render(options);
    const refreshBackups = options?.refreshBackups ?? options?.force;
    if ( !game.backupsCache || refreshBackups ) {
      game.listBackups().then(() => this.render());
    }
    return this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    this.#backups = game.backupsCache?.[this.package.type]?.[this.package.id] ?? [];
    return {
      entries: this.#prepareEntries(),
      noDeletion: !this.#backups.length || this.#backups.every(e => e.snapshotId),
      loading: !game.backupsCache
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare template context data for backup entries.
   * @returns {BackupEntryUIDescriptor[]}
   */
  #prepareEntries() {
    return this.#backups.map(backupData => {
      const {id, size, note, createdAt, snapshotId} = backupData;
      const tags = [
        {label: foundry.utils.formatFileSize(size, {decimalPlaces: 0})},
        BackupList.getVersionTag(backupData)
      ];
      if ( snapshotId ) tags.unshift({label: game.i18n.localize("SETUP.BACKUPS.Snapshot")});
      return {
        id, tags, snapshotId,
        label: this.#dateFormatter.format(createdAt),
        hint: note,
        noRestore: !BackupList.canRestoreBackup(backupData)
      };
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);

    // Add a titlebar button to take a new backup for the package
    const button = document.createElement("button");
    button.type = "button";
    button.className = "header-control supplemental";
    button.dataset.action = "takeBackup";
    const icon = createFontAwesomeIcon("floppy-disk");
    button.append(icon, " ", game.i18n.localize("SETUP.BACKUPS.TakeBackup"));
    this.window.header.querySelector("button[data-action=close]").before(button);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    const html = this.element;

    // Toggle (un)checking all of the snapshot entry checkboxes
    const selectAllCheckbox = html.querySelector("input[data-action=selectAll]");
    selectAllCheckbox?.addEventListener("change", this.#onToggleSelectAll.bind(this));

    // Manage the disabled status of the delete-selected button
    const deleteButton = html.querySelector("button[data-action=deleteBackups]");
    const entryCheckboxes = Array.from(html.querySelectorAll("[data-entry] input[type=checkbox]:not(:disabled)"));
    if ( deleteButton ) deleteButton.disabled = !entryCheckboxes.some(c => c.checked);
    for ( const checkbox of entryCheckboxes ) {
      checkbox.addEventListener("change", () => {
        deleteButton.disabled = !entryCheckboxes.some(c => c.checked);
        selectAllCheckbox.checked = entryCheckboxes.every(c => c.checked);
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * Determine if a given backup is allowed to be restored.
   * @param {BackupData} backupData  The backup.
   * @returns {boolean}
   */
  static canRestoreBackup(backupData) {
    const { packageId, type } = backupData;
    const cls = foundry.packages.PACKAGE_TYPES[type];
    const pkg = cls.get(packageId);

    // If there is no currently-installed version of the package, it can always be restored.
    if ( !pkg ) return true;

    const codes = CONST.PACKAGE_AVAILABILITY_CODES;
    const usable = code => (code >= codes.VERIFIED) && (code <= codes.UNVERIFIED_GENERATION);

    // If the installed package is already unusable, there is no harm in restoring a backup, it can't make things worse.
    if ( !usable(pkg.availability) ) return true;

    // Otherwise check if restoring the backup would make the package unusable.
    return usable(cls.testAvailability(backupData));
  }

  /* -------------------------------------------- */

  /**
   * Toggle the locked state of the interface.
   * @param {boolean} locked  Is the interface locked?
   */
  toggleLock(locked) {
    this.window.header.querySelector("button[data-action=takeBackup]").disabled = locked;
    for ( const element of this.window.content.querySelectorAll("button, input") ) {
      const entryEl = element.closest("[data-entry]");
      element.disabled = locked || !!((element.tagName === "INPUT") && entryEl?.dataset.snapshotId);
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle taking a new backup of this package.
   * @this {BackupList}
   * @type {ApplicationClickAction}
   */
  static async #onTakeBackup() {
    await game.createBackup(this.package, {dialog: true});
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle selecting or deselecting all backups.
   * @param {Event} event The triggering event
   */
  #onToggleSelectAll(event) {
    const selectAll = event.currentTarget;
    const html = this.element;
    const checkboxes = Array.from(html.querySelectorAll("[data-entry] input[type=checkbox]:not(:disabled)"));
    for ( const input of checkboxes ) input.checked = selectAll.checked;
    const deleteButton = html.querySelector("button[data-action=deleteBackups]");
    deleteButton.disabled = !checkboxes.some(c => c.checked);
  }

  /* -------------------------------------------- */

  /**
   * Delete selected backups.
   * @this {BackupList}
   * @type {ApplicationClickAction}
   */
  static async #onDeleteBackups() {
    const entryEls = Array.from(this.element.querySelectorAll("[data-entry]:has(input[type=checkbox]:checked)"));
    const toDelete = entryEls.map(e => e.dataset.id);
    await game.deleteBackups(this.package, toDelete, {dialog: true});
    await this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle restoring a specific backup.
   * @this {BackupList}
   * @type {ApplicationClickAction}
   */
  static async #onRestoreBackup(event) {
    const backupId = event.target.closest("[data-entry]").dataset.id;
    const backupData = this.#backups.find(b => b.id === backupId);
    const pkg = game[`${this.package.type}s`].get(this.package.id);
    await game.restoreBackup(backupData, {dialog: !!pkg});
  }
}

/**
 * @import {ApplicationClickAction, ApplicationRenderOptions} from "@client/applications/_types.mjs";
 */

/**
 * @typedef CategoryBrowserRenderOptions
 * @property {boolean} [reloadData] Make a possibly long-running data-load request without blocking render
 */

/**
 * @typedef BackupEntryTagDescriptor
 * @property {"unsafe"|"warning"|"neutral"|"safe"} [type]  The tag type.
 * @property {string} [icon]                               An icon class.
 * @property {string} label                                The tag text.
 * @property {string} [tooltip]                            Tooltip text.
 */

/**
 * @typedef BackupEntryUIDescriptor
 * @property {string} [packageId]     The ID of the package this backup represents, if applicable.
 * @property {string} [backupId]      The ID of the package backup, if applicable.
 * @property {string} [snapshotId]    The ID of the snapshot, if applicable.
 * @property {number} [createdAt]     The snapshot's creation timestamp.
 * @property {string} label           The label for the entry. Either a formatted date for snapshots, or the title of
 *                                    the package for package backups.
 * @property {string} [restoreLabel]  The label for the restore button.
 * @property {string} description     The description for the entry. Either the user's note for snapshots, or the
 *                                    package description for package backups.
 * @property {boolean} [inSnapshot]   For package backups, this indicates that it is part of a snapshot.
 * @property {boolean} [noRestore]    Is the backup allowed to be restored.
 * @property {BackupEntryTagDescriptor[]} tags  Tag descriptors for the backup or snapshot.
 */

/**
 * An Application that manages user backups and snapshots.
 */
class BackupManager extends CategoryBrowser {

  /** @inheritdoc */
  static DEFAULT_OPTIONS = {
    id: "backup-manager",
    window: {
      title: "SETUP.BACKUPS.ManageBackups",
      icon: "fa-solid fa-floppy-disks"
    },
    position: {
      width: 920,
      height: 780
    },
    actions: {
      manageBackup: BackupManager.#onManageBackup,
      takeBackup: BackupManager.#onTakeBackup,
      restoreBackup: BackupManager.#onRestoreBackup,
      createSnapshot: BackupManager.#onCreateSnapshot,
      deleteSnapshots: BackupManager.#onDeleteSnapshots
    },
    packageList: true,
    initialCategory: "world",
    subtemplates: {
      category: "templates/setup/backup-manager/category.hbs",
      sidebarFooter: "templates/setup/backup-manager/total-size.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  get _dataLoaded() {
    return !!game.backupsCache;
  }

  /**
   * The snapshot date formatter.
   * @type {Intl.DateTimeFormat}
   */
  #dateFormatter = new Intl.DateTimeFormat(game.i18n.lang, {dateStyle: "full", timeStyle: "short"});

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    // Loading progress.
    const totalSize = Object.entries(game.backupsCache ?? {}).reduce((runningTotal, [key, data]) => {
      if ( key === "snapshots" ) return runningTotal;
      const categoryTotal = Object.values(data)
        .reduce((outer, arr) => outer + arr.reduce((inner, d) => inner + d.size, 0), 0);
      return runningTotal + categoryTotal;
    }, 0);
    context.totalSize = foundry.utils.formatFileSize(totalSize, {decimalPlaces: 0});
    context.loading = this._dataLoaded ? null : "SETUP.BACKUPS.Loading";
    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareCategoryData() {
    return ["snapshots", "world", "module", "system"].reduce((categories, id) => {
      const label = game.i18n.localize(`SETUP.BACKUPS.TYPE.${id}`);
      const entries = id === "snapshots"
        ? this.#getSnapshotsContext()
        : this.#getPackagesContext(id);
      categories[id] = {id, label, entries};
      return categories;
    }, {});
  }

  /* -------------------------------------------- */

  /** @override */
  async _loadCategoryData() {
    await game.listBackups();
  }

  /* -------------------------------------------- */

  /** @override */
  _sortCategories(a, b) {
    const order = ["snapshots", "world", "module", "system"];
    return order.indexOf(a.id) - order.indexOf(b.id);
  }

  /* -------------------------------------------- */

  /**
   * Get snapshot context data.
   * @returns {BackupEntryUIDescriptor[]}
   */
  #getSnapshotsContext() {
    return Object.values(game.backupsCache?.snapshots ?? {}).map(snapshotData => {
      const versionTag = this.#getSnapshotVersionTag(snapshotData);
      return {
        createdAt: snapshotData.createdAt,
        snapshotId: snapshotData.id,
        label: this.#dateFormatter.format(snapshotData.createdAt),
        restoreLabel: "SETUP.BACKUPS.Restore",
        hint: foundry.utils.escapeHTML(snapshotData.note),
        noRestore: versionTag.noRestore,
        tags: [
          versionTag,
          { label: foundry.utils.formatFileSize(snapshotData.size, { decimalPlaces: 0 }) }
        ]
      };
    }).sort((a, b) => b.createdAt - a.createdAt);
  }

  /* -------------------------------------------- */

  /**
   * Determine the version tag for a given snapshot.
   * @param {SnapshotData} snapshotData  The snapshot.
   * @returns {BackupEntryTagDescriptor}
   */
  #getSnapshotVersionTag({generation, build}) {
    const label = game.i18n.format("SETUP.BACKUPS.VersionFormat", {version: `${generation}.${build}`});

    // Safe to restore a snapshot taken in the current generation.
    if ( generation === game.release.generation ) return {
      label,
      type: "safe",
      icon: "fa-solid fa-code-branch"
    };

    // Potentially safe to restore a snapshot from an older generation into a newer generation software version.
    if ( generation < game.release.generation ) return {
      label,
      type: "warning",
      icon: "fa-solid fa-triangle-exclamation"
    };

    // Impossible to restore a snapshot from a newer generation than the current software version.
    if ( generation > game.release.generation ) return {
      label,
      type: "error",
      icon: "fa-solid fa-file-slash",
      noRestore: true
    };
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Get package backup context data.
   * @param {"module"|"system"|"world"} type  The package type.
   * @returns {BackupEntryUIDescriptor[]}
   */
  #getPackagesContext(type) {
    return Object.values(game.backupsCache?.[type] ?? {}).flatMap(backups => {
      if ( !backups.length ) return [];
      const newest = backups[0];
      const size = backups.reduce((acc, backupData) => acc + backupData.size, 0);
      const {packageId, title: label, description} = newest;
      const pkg = foundry.packages.PACKAGE_TYPES[type].get(packageId);
      const tags = [
        {label: game.i18n.format(`SETUP.BACKUPS.Num${backups.length === 1 ? "" : "Pl"}`, {number: backups.length})},
        {label: foundry.utils.formatFileSize(size, {decimalPlaces: 0})},
        BackupList.getVersionTag(newest)
      ];
      return {
        id: packageId,
        label, tags, type,
        backupId: newest.id,
        restoreLabel: "SETUP.BACKUPS.RestoreLatest",
        noRestore: !BackupList.canRestoreBackup(newest),
        packageExists: !!pkg,
        hint: foundry.applications.ux.TextEditor.previewHTML(description, 150)
      };
    }).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);

    // Add a titlebar button to create a snapshot
    const button = document.createElement("button");
    button.type = "button";
    button.className = "header-control supplemental";
    button.dataset.action = "createSnapshot";
    const icon = createFontAwesomeIcon("camera-retro");
    button.append(icon, " ", game.i18n.localize("SETUP.BACKUPS.CreateSnapshot"));
    this.window.header.querySelector("button[data-action=close]").before(button);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    const html = this.element;

    // Toggle (un)checking all of the snapshot entry checkboxes
    const selectAllCheckbox = html.querySelector("input[data-action=selectAll]");
    selectAllCheckbox?.addEventListener("change", this.#onToggleSelectAll.bind(this));

    // Manage the disabled status of the delete-snapshots button
    const deleteButton = html.querySelector("button[data-action=deleteSnapshots]");
    const entryCheckboxes = Array.from(html.querySelectorAll("[data-entry] input[type=checkbox]:not(:disabled)"));
    if ( deleteButton ) deleteButton.disabled = !entryCheckboxes.some(c => c.checked);
    for ( const checkbox of entryCheckboxes ) {
      checkbox.addEventListener("change", () => {
        deleteButton.disabled = !entryCheckboxes.some(c => c.checked);
        selectAllCheckbox.checked = entryCheckboxes.every(c => c.checked);
      });
    }

    // Hide the create-snapshot button in the title bar if an empty snapshot would be created
    const packageCount = game.worlds.size + game.systems.size + game.modules.size;
    this.window.header.querySelector("button[data-action=createSnapshot]").hidden = !packageCount;
  }

  /* -------------------------------------------- */

  /** @override */
  _getSearchFields(entry) {
    return [entry.dataset.packageId ?? "", entry.querySelector(".entry-title h3")?.textContent ?? ""];
  }

  /* -------------------------------------------- */

  /**
   * Toggle the locked state of the interface.
   * @param {boolean} locked  Is the interface locked?
   * @internal
   */
  toggleLock(locked) {
    this.window.header.querySelector("button[data-action=createSnapshot]").disabled = locked;
    for ( const element of this.window.content.querySelectorAll("button, input") ) {
      if ( element.dataset.action === "deleteSnapshots" && !locked ) {
        const snapshots = Object.keys(game.backupCache?.snapshots ?? {});
        element.disabled = snapshots.length === 0;
      }
      else element.disabled = locked;
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * View and manage a list of backups.
   * @this {BackupManager}
   * @type {ApplicationClickAction}
   */
  static #onManageBackup(event) {
    const {id, type, title} = event.target.closest("[data-entry]").dataset;
    return new BackupList({package: {id, type, title}}).render({force: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a new package backup.
   * @this {BackupManager}
   * @type {ApplicationClickAction}
   */
  static async #onTakeBackup(event) {
    const {id, type} = event.target.closest("[data-entry]").dataset;
    const pkg = foundry.packages.PACKAGE_TYPES[type].get(id);
    if ( !pkg ) return;
    await game.createBackup(pkg, {dialog: true});
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle restoring a snapshot or the latest backup of a package.
   * @this {BackupManager}
   * @type {ApplicationClickAction}
   */
  static async #onRestoreBackup(event) {
    const {id, type, snapshotId} = event.target.closest("[data-entry]").dataset;
    if ( snapshotId ) return game.restoreSnapshot(game.backupsCache.snapshots[snapshotId], {dialog: true});
    const pkg = foundry.packages.PACKAGE_TYPES[type].get(id);
    await game.restoreLatestBackup({id, type}, { dialog: !!pkg});
  }

  /* -------------------------------------------- */

  /**
   * Handle selecting or deselecting all snapshot entries.
   * @param {Event} event The triggering event
   */
  #onToggleSelectAll(event) {
    const selectAll = event.currentTarget;
    const html = this.element;
    const checkboxes = Array.from(html.querySelectorAll("[data-entry] input[type=checkbox]"));
    for ( const input of checkboxes ) {
      if ( !input.disabled ) input.checked = selectAll.checked && !input.closest("[data-entry]").hidden;
    }
    const deleteButton = html.querySelector("button[data-action=deleteSnapshots]");
    deleteButton.disabled = !checkboxes.some(c => c.checked);
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a snapshot.
   * @this {BackupManager}
   * @type {ApplicationClickAction}
   */
  static async #onCreateSnapshot() {
    await game.createSnapshot({dialog: true});
    await this.render({reloadData: true});
  }

  /* -------------------------------------------- */

  /**
   * Delete any selected snapshots.
   * @this {BackupManager}
   * @type {ApplicationClickAction}
   */
  static async #onDeleteSnapshots() {
    const entryEls = Array.from(this.element.querySelectorAll("[data-entry]:has(input[type=checkbox]:checked)"));
    const toDelete = entryEls.map(e => e.dataset.snapshotId).filter(id => !!id);
    await game.deleteSnapshots(toDelete, {dialog: true});
    await this.render({reloadData: true});
  }
}

/**
 * An application that renders the floating setup menu buttons.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class SetupMenu extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "setup-menu",
    tag: "nav",
    window: {
      frame: false,
      positioned: false
    },
    actions: {
      adminLogout: SetupMenu.#onClickAdminLogout,
      backups: SetupMenu.#onClickBackupManager,
      configure: SetupMenu.#onClickConfiguration,
      update: SetupMenu.#onClickUpdate,
      viewWarnings: SetupMenu.#onClickViewWarnings
    }
  };

  /** @override */
  static PARTS = {
    buttons: {
      id: "buttons",
      template: "templates/setup/parts/setup-menu.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    let { hasUpdate, channel } = game.data.coreUpdate;
    hasUpdate &&= ["testing", "stable"].includes(channel);
    const buttons = [
      {
        action: "viewWarnings",
        tooltip: "Warnings",
        icon: "fa-solid fa-triangle-exclamation",
        pip: game.issueCount.total ? {
          type: game.issueCount.error > 0 ? "error" : "warning",
          label: game.issueCount.total
        } : null
      },
      {
        action: "configure",
        tooltip: "Configure",
        icon: "fa-solid fa-gears",
        pip: !game.data.options.adminPassword ? {
          type: "warning",
          label: "!"
        } : null
      },
      {
        action: "update",
        tooltip: "Update",
        icon: "fa-solid fa-download",
        pip: hasUpdate ? {
          type: "warning",
          label: "!"
        } : null
      }
    ];

    // Backup
    const canBackup = !game.data.options.noBackups;
    if ( canBackup ) buttons.push({
      action: "backups",
      tooltip: "SETUP.BACKUPS.ManageBackups",
      icon: "fa-solid fa-floppy-disks"
    });

    // Log Out
    const canLogOut = !!game.data.options.adminPassword;
    if ( canLogOut ) buttons.push({
      action: "adminLogout",
      tooltip: "Logout",
      icon: "fa-solid fa-door-open"
    });
    return {buttons};
  }

  /* -------------------------------------------- */

  /**
   * Toggle the locked state of the interface.
   * @param {boolean} locked  Is the interface locked?
   */
  toggleLock(locked) {
    if ( !this.rendered ) return;
    this.element.querySelectorAll("button").forEach(el => el.disabled = locked);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  static #onClickAdminLogout() {
    game.post({action: "adminLogout"});
  }

  static #onClickBackupManager() {
    new BackupManager().render({force: true});
  }

  static #onClickConfiguration() {
    new ServerSettingsConfig().render({force: true});
  }

  static #onClickUpdate() {
    window.location.href = foundry.utils.getRoute("update");
  }

  static #onClickViewWarnings(event, target) {
    const warnings = new SetupWarnings();
    const {bottom, right} = target.parentElement.getBoundingClientRect();
    warnings.render(true, {left: right - warnings.options.width, top: bottom + 20});
  }
}

/**
 * @import {ApplicationClickAction, ApplicationFormSubmission} from "@client/applications/_types.mjs";
 */

/**
 * An Application facilitating the creation of a new Module or the reconfiguration of an existing one
 */
class ModuleConfig extends HandlebarsApplicationMixin(ApplicationV2) {

  constructor(options) {
    super(options);
    const title = options?.moduleData ? null : game.i18n.localize("PACKAGE.NewModuleTitle");
    const id = title?.slugify();
    this.#module = new Module(options?.moduleData ?? {
      id, title,
      version: "1.0.0",
      compatibility: {
        minimum: game.release.generation,
        verified: game.release.generation
      }
    });
    this.#existing = options?.moduleData ? game.modules.get(this.#module.id) : undefined;
  }

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    tag: "form",
    id: "module-config",
    window: {
      icon: "fa-solid fa-gear-code",
      contentClasses: ["standard-form"]
    },
    position: {width: 760},
    form: {
      handler: ModuleConfig.#onSubmit,
      closeOnSubmit: true
    },
    actions: {
      addAuthor: ModuleConfig.#onAddAuthor,
      deleteAuthor: ModuleConfig.#onDeleteAuthor,
      addPack: ModuleConfig.#onAddPack,
      deletePack: ModuleConfig.#onDeletePack,
      addRelationship: ModuleConfig.#onAddRelationship,
      deleteRelationship: ModuleConfig.#onDeleteRelationship
    }
  };

  /** @override */
  static PARTS = {
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    basics: {template: "templates/setup/module-config/basics.hbs", scrollable: [""]},
    authors: {template: "templates/setup/module-config/authors.hbs", scrollable: [""]},
    packs: {template: "templates/setup/module-config/packs.hbs", scrollable: [""]},
    relationships: {template: "templates/setup/module-config/relationships.hbs", scrollable: [""]},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /** @override */
  static TABS = {
    main: {
      tabs: [
        {id: "basics", icon: "fa-solid fa-memo-pad"},
        {id: "authors", icon: "fa-solid fa-users"},
        {id: "packs", icon: "fa-solid fa-book-atlas"},
        {id: "relationships", icon: "fa-solid fa-circle-nodes"}
      ],
      initial: "basics",
      labelPrefix: "PACKAGE.TABS"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.format("PACKAGE.ConfigTitle", {module: this.#module.title ?? "My New Module"});
  }

  /**
   * Will this module be created upon form submission?
   * @type {boolean}
   */
  get #isCreate() {
    return !this.#existing;
  }

  /**
   * A Module instance used as the source data for the form and to validate changes.
   * @type {Module}
   */
  #module;

  /**
   * If editing an existing package, track a reference to its persisted data
   * @type {Module}
   */
  #existing;

  /**
   * Display a pending relationship which has not yet been confirmed to appear at the bottom of the list?
   * @type {boolean}
   */
  #pendingRelationship = false;

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    return Object.assign(context, {
      rootId: this.id,
      fields: Module.schema.fields,
      module: this.#module._source
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    const partContext = await super._preparePartContext(partId, context, options);
    if ( partId in context.tabs ) partContext.tab = context.tabs[partId];
    switch ( partId ) {
      case "basics":
        Object.assign(partContext, {
          moduleId: this.#existing?.id ?? "",
          isCreate: this.#isCreate
        });
        break;
      case "authors":
        partContext.authorFields = context.fields.authors.element.fields;
        break;
      case "packs":
        Object.assign(partContext, {
          packFields: context.fields.packs.element.fields,
          packs: this.#getPacks(),
          packTypes: this.#getPackTypes(),
          systems: game.systems.contents.reduce((systems, system) => {
            systems[system.id] = system.title;
            return systems;
          }, {})
        });
        break;
      case "relationships": {
        const relationships = this.#getFlattenedRelationships();
        Object.assign(partContext, {
          relatedPackages: this.#getRelatedPacks(relationships),
          relationships,
          relationshipCategories: {
            requires: game.i18n.localize("PACKAGE.Relationships.Requires"),
            recommends: game.i18n.localize("PACKAGE.Relationships.Recommends"),
            conflicts: game.i18n.localize("PACKAGE.Relationships.Conflicts"),
            systems: game.i18n.localize("PACKAGE.Relationships.Systems")
          }
        });
        break;
      }
      case "footer": {
        const submitLabel = this.#isCreate ? "PACKAGE.ModuleCreate" : "PACKAGE.ModuleEdit";
        partContext.buttons = [
          {type: "submit", cssClass: "bright", icon: "fa-solid fa-floppy-disk", label: submitLabel}
        ];
      }
    }
    return partContext;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    for ( const select of this.element.querySelectorAll("select[data-relationship]") ) {
      this.#updateRelationshipOptions(select);
    }
  }

  /* -------------------------------------------- */

  /**
   * Get localized Document type choices.
   * @returns {Record<string, string>}
   */
  #getPackTypes() {
    const localize = v => game.i18n.localize(getDocumentClass(v).metadata.label);
    const list = CONST.COMPENDIUM_DOCUMENT_TYPES.map(value => ({value, label: localize(value)}));
    game.i18n.sortObjects(list);
    return list.reduce((types, type) => {
      types[type.value] = type.label;
      return types;
    }, {});
  }

  /* -------------------------------------------- */

  #getPacks() {
    return this.#module._source.packs.map(pack => {
      return {
        name: pack.name,
        label: pack.label,
        type: pack.type,
        system: pack.system,
        creating: pack.flags?._placeholder,
        existing: this.#existing?.packs.find(p => p.name === pack.name)
      };
    });
  }

  /* -------------------------------------------- */

  /**
   * Get related packages select choices.
   * @param {{id: string; type: string}[]} current The module's current list of related packages, which may include ones
   *                                               not currently installed.
   * @returns  {Record<string, {label: string; type: string; group: string}>}
   */
  #getRelatedPacks(current) {
    const groups = {
      system: game.i18n.localize("PACKAGE.Type.systemPl"),
      module: game.i18n.localize("PACKAGE.Type.modulePl")
    };
    const relatedPacks = [...game.systems.values(), ...game.modules.values()].reduce((packages, pkg) => {
      if ( !this.#isCreate && pkg.type === "module" && pkg.id === this.#existing.id ) return packages; // Exclude self
      packages[pkg.id] = {label: pkg.title, group: groups[pkg.type], dataset: {type: pkg.type}};
      return packages;
    }, {});
    for ( const pkg of current ) { // Add packages not currently installed
      relatedPacks[pkg.id] ??= {label: pkg.id, group: groups[pkg.type], dataset: {type: pkg.type}};
    }
    return relatedPacks;
  }

  /* -------------------------------------------- */

  /**
   * Flatten the relationships object into an array which is more convenient for rendering.
   * @returns {Array<{id: string, type: string, category: string}>}
   */
  #getFlattenedRelationships() {
    const allRelationships = [];
    for ( const [category, relationships] of Object.entries(this.#module._source.relationships) ) {
      if ( !["systems", "requires", "recommends", "conflicts"].includes(category) ) continue;
      for ( const [index, relationship] of Object.entries(relationships) ) {
        const clone = foundry.utils.deepClone(relationship);
        clone.category = category;
        clone.index = Number(index);
        allRelationships.push(clone);
      }
    }
    if ( this.#pendingRelationship ) allRelationships.push({id: "", category: "", index: -1});
    return allRelationships;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);

    // Automatically slugify a related input field as text is typed.
    if ( event.target.name === "title" ) {
      const newTitle = event.target.value.trim() || this.#module.title;
      if ( this.#isCreate && newTitle ) this.form.elements.id.placeholder = event.target.value.slugify({strict: true});
      this.window.title.textContent = game.i18n.format("PACKAGE.ConfigTitle", {module: newTitle});
    }

    // If the .relationship select changes, update the category select
    else if ( "relationship" in event.target.dataset ) {
      this.#updateRelationshipOptions(event.target);
    }
  }

  /* -------------------------------------------- */

  /**
   * Add a new entry to the authors array.
   * @this {ModuleConfig}
   * @type {ApplicationClickAction}
   */
  static #onAddAuthor() {
    const data = this._getSubmitData();
    data.authors.push({name: `Author ${data.authors.length + 1}`});
    this._tryUpdate(data, {render: "authors"});
  }

  /* -------------------------------------------- */

  /**
   * Delete an entry from the authors array.
   * @this {ModuleConfig}
   * @type {ApplicationClickAction}
   */
  static #onDeleteAuthor(_event, button) {
    const index = Number(button.dataset.index);
    const data = this._getSubmitData();
    data.authors.splice(index, 1);
    this._tryUpdate(data, {render: "authors"});
  }

  /* -------------------------------------------- */

  /**
   * Add a new entry to the packs array.
   * @this {ModuleConfig}
   * @type {ApplicationClickAction}
   */
  static #onAddPack() {
    const data = this._getSubmitData();
    let i = data.packs.length;
    let nextName;
    while ( true ) {
      i++;
      nextName = `pack-${i}`;
      // eslint-disable-next-line no-loop-func
      if ( !data.packs.find(p => p.name === nextName) && !this.#existing?.packs.some(p => p.name === nextName) ) break;
    }
    data.packs.push({
      name: nextName,
      label: `Pack ${i}`,
      path: `packs/${nextName}`,
      type: "JournalEntry",
      ownership: {PLAYER: "OBSERVER", ASSISTANT: "OWNER"},
      flags: {
        _placeholder: true
      }
    });
    this._tryUpdate(data, {render: "packs"});
  }

  /* -------------------------------------------- */

  /**
   * Delete an entry from the packs array.
   * @this {ModuleConfig}
   * @type {ApplicationClickAction}
   */
  static #onDeletePack(_event, button) {
    const index = Number(button.dataset.index);
    const data = this._getSubmitData();
    data.packs.splice(index, 1);
    this._tryUpdate(data, {render: "packs"});
  }

  /* -------------------------------------------- */

  /**
   * Add a pending relationship entry to the relationships object.
   * @this {ModuleConfig}
   * @type {ApplicationClickAction}
   */
  static async #onAddRelationship() {
    this.#pendingRelationship = true;
    const data = this._getSubmitData();
    this._tryUpdate(data, {render: "relationships"});
  }

  /* -------------------------------------------- */

  /**
   * Remove a relationship, or remove the pending relationship from the relationships object.
   * @this {ModuleConfig}
   * @type {ApplicationClickAction}
   */
  static async #onDeleteRelationship(_event, button) {
    const category = button.dataset.category;
    const index = Number(button.dataset.index);
    const data = this._getSubmitData();
    for ( const c of ["systems", "requires", "recommends", "conflicts"] ) {
      if ( !data.relationships[c] ) continue;
      for ( const [i, r] of Object.entries(data.relationships[c]) ) {
        if ( (r._category === category) && (r._index === index) ) {
          data.relationships[c].splice(i, 1);
          break;
        }
      }
    }
    this.#pendingRelationship = false;
    this._tryUpdate(data, {render: "relationships"});
  }

  /* -------------------------------------------- */

  /**
   * Swaps which options are available based on Package type: System relationships can only have the "system" category.
   * @param {HTMLSelectElement} relationshipSelect The select element
   */
  #updateRelationshipOptions(relationshipSelect) {
    const selectedOption = relationshipSelect.options[relationshipSelect.selectedIndex];
    const typeIsSystem = selectedOption.dataset.type === "system";
    const categorySelect = relationshipSelect.closest("fieldset").querySelector("select[data-category]");
    for ( const option of categorySelect.options ) {
      const categoryIsSystem = option.value === "systems";
      option.disabled = (categoryIsSystem !== typeIsSystem);
    }
    if ( typeIsSystem ) categorySelect.value = "systems";
    else if ( categorySelect.value === "systems" ) categorySelect.value = "";
  }

  /* -------------------------------------------- */

  /**
   * Handle form submission
   * @this {ModuleConfig}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmit(event, form, formData) {
    const submitData = this._getSubmitData();

    // Assert that the final data is valid
    this.form.disabled = true;
    if ( !this._tryUpdate(submitData) ) return;

    // Prepare request data
    const requestData = this.#existing?.updateSource(submitData, {dryRun: true}) ?? this.#module.toObject();
    requestData.id ??= this.#existing?.id;
    if ( !this.#existing ) {
      if ( game.modules.has(requestData.id) ) {
        const message = game.i18n.format("PACKAGE.ModuleCreateErrorAlreadyExists", {id: this.#module.id});
        ui.notifications.error(message);
        return;
      }
      requestData.flags = {canUpload: true}; // Set as a default flag to ease initial work on new module
    }
    requestData.action = "manageModule";

    // Submit the module management request
    await game.post(requestData);
    const message = this.#isCreate ? "PACKAGE.ModuleCreateSuccess" : "PACKAGE.ModuleEditSuccess";
    ui.notifications.info(message, {format: {id: this.#module.id}});
    game.reload();
  }

  /* -------------------------------------------- */

  /**
   * Attempt to update the working Module instance, displaying error messages for any validation failures.
   * @param {object} changes Proposed changes to the Module source
   * @param {object} options
   * @param {string|null} [options.render] Re-render which part?
   * @returns {boolean} What the update attempt a success?
   * @protected
   */
  _tryUpdate(changes, {render=null}={}) {
    try {
      this.#module.updateSource(changes);
    } catch(err) {
      ui.notifications.error(foundry.utils.escapeHTML(err.message));
      this.form.disabled = false;
      return false;
    }
    if ( render ) this.render({parts: [render]});
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve and prepare form submission data.
   * @returns {object} The prepared submit data
   */
  _getSubmitData() {
    const fd = new foundry.applications.ux.FormDataExtended(this.form, {disabled: true});
    const submitData = foundry.utils.expandObject(fd.object);
    const moduleData = this.#module.toObject();

    // Module ID
    if ( this.#existing ) submitData.id = this.#existing.id;
    else if ( !submitData.id ) submitData.id = submitData.title.slugify({strict: true});

    // Authors
    submitData.authors = Object.values(submitData.authors ?? {}).map((author, i) => {
      const moduleAuthor = moduleData.authors[i];
      author = foundry.utils.mergeObject(moduleAuthor, author, {inplace: false});
      if ( foundry.utils.isEmpty(author.flags) ) delete author.flags;
      return author;
    });

    // Packs
    submitData.packs = Object.values(submitData.packs || {}).map((pack, i) => {
      const modulePack = moduleData.packs[i];
      if ( !pack.name ) pack.name = pack.label.slugify({strict: true});
      const sourcePath = this.#existing?.packs.find(p => p.name === pack.name)?.path;
      pack.path = sourcePath?.replace(`modules/${this.#existing.id}/`, "") ?? `packs/${pack.name}`;
      pack = foundry.utils.mergeObject(modulePack, pack, {inplace: false});
      if ( pack.flags?._placeholder ) delete pack.flags._placeholder;
      if ( foundry.utils.isEmpty(pack.flags) ) delete pack.flags;
      return pack;
    });

    // Relationships
    submitData.relationships = Object.values(submitData.relationships ?? {}).reduce((accum, rel) => {
      if ( !(rel.category && rel.id) ) return accum;
      const category = rel.category;
      delete rel.category;
      if ( rel._category ) {
        const moduleRelationship = moduleData.relationships[rel._category][rel._index];
        rel = foundry.utils.mergeObject(moduleRelationship, rel, {inplace: false});
      }
      if ( foundry.utils.isEmpty(rel.compatibility) ) delete rel.compatibility;
      rel.type = game.systems.has(rel.id) ? "system" : "module";
      accum[category] ??= [];
      accum[category].push(rel);
      return accum;
    }, {});
    return submitData;
  }
}

/**
 * A class responsible for managing a server-side operation's progress lifecycle.
 */
class ProgressReceiver extends EventEmitterMixin() {

  /**
   * @typedef ProgressReceiverPacket
   * @property {string} action     The progress action.
   * @property {string} id         The operation identifier.
   * @property {number} pct        The progress percentage.
   * @property {string} step       The individual step in the action.
   * @property {string} [message]  A text status message.
   * @property {string} [title]    The title of the entry. If not provided, the ID is used instead.
   */

  /**
   * @typedef ProgressReceiverOptions
   * @property {boolean} [notify=true]                  Spawn UI notifications during the lifecycle events.
   * @property {string} [title]                         A human-readable title for the operation.
   * @property {string} [successMessage]                A message to display on operation success.
   * @property {string} [failureMessage]                A message to display on operation failure.
   */

  /**
   * @param {string} operationId  A unique identifier for the operation.
   * @param {string} action       The operation action.
   * @param {object} [context]    Additional context to send with the request.
   * @param {ProgressReceiverOptions} [options]
   */
  constructor(operationId, action, context={}, options={}) {
    super();
    this.#operationId = operationId;
    this.#action = action;
    this.#context = context;
    this.#options = { notify: true, ...options };
  }

  static emittedEvents = ["progress", "error", "complete"];

  /**
   * The operation action.
   * @type {string}
   */
  #action;

  /**
   * Additional context to send with the request.
   * @type {object}
   */
  #context;

  /**
   * Additional options to configure behavior.
   * @type {ProgressReceiverOptions}
   */
  #options;

  /**
   * A unique identifier for the operation.
   * @type {string}
   */
  get operationId() {
    return this.#operationId;
  }

  #operationId;

  /**
   * The progress listener.
   * @type {Function}
   */
  #progressListener = this._onProgress.bind(this);

  /**
   * A callback to invoke on operation success.
   * @type {Function}
   */
  #resolve;

  /* -------------------------------------------- */

  /**
   * Handle operation completion.
   * @param {ProgressReceiverPacket} data  Completion event data.
   * @protected
   */
  _onComplete(data) {
    const { notify, successMessage } = this.#options;
    if ( notify && successMessage ) ui.notifications.info(successMessage);
    game._removeProgressListener(this.#progressListener);
    const event = new Event("complete");
    event.data = data;
    this.dispatchEvent(event);
    this.#resolve(data);
  }

  /* -------------------------------------------- */

  /**
   * Handle an error during the operation.
   * @param {ProgressReceiverPacket} data       Error event data.
   * @param {string} data.error                   The error message.
   * @param {string} data.stack                   The error stack.
   * @protected
   */
  _onError(data) {
    const { error, stack, ...context } = data;
    const { notify, failureMessage } = this.#options;
    const err = new Error(error);
    err.stack = stack;
    err.context = context;
    if ( notify && failureMessage ) ui.notifications.error(failureMessage, { console: false, permanent: true });
    console.error(err);
    ui.setupPackages?.removeProgressBar(this.#operationId);
    game._removeProgressListener(this.#progressListener);
    const event = new Event("error");
    event.data = data;
    this.dispatchEvent(event);
    this.#resolve(err);
  }

  /* -------------------------------------------- */

  /**
   * Handle progress ticks.
   * @param {ProgressReceiverPacket} data  Progress event data.
   * @protected
   */
  _onProgress(data) {
    const { STEPS } = CONST.SETUP_PACKAGE_PROGRESS;
    const { action, step, id } = data;
    if ( (action !== this.#action) || (id !== this.operationId) ) return;
    if ( (this.#options.title !== undefined) && !("title" in data) ) data.title = this.#options.title;
    ui.setupPackages?.onProgress(data);
    const event = new Event("progress");
    event.data = data;
    this.dispatchEvent(event);
    if ( step === STEPS.ERROR ) return this._onError(data);
    if ( step === STEPS.COMPLETE ) return this._onComplete(data);
  }

  /* -------------------------------------------- */

  /**
   * Handle a warning during the operation.
   * @param {ProgressReceiverPacket} data         Warning event data.
   * @param {string} data.warning                  The warning message.
   * @protected
   */
  _onWarning({ warning }) {
    if ( this.#options.notify ) ui.notifications.warn(foundry.utils.escapeHTML(warning));
  }

  /* -------------------------------------------- */

  /**
   * Fire the request and begin listening for progress events.
   * @returns {Promise<void>}
   */
  listen() {
    return new Promise(async (resolve, reject) => {
      this.#resolve = resolve;
      game._addProgressListener(this.#progressListener);
      let response;
      try {
        response = await game.post({...this.#context, action: this.#action}, {notify: false});
      } catch(err) {
        game._removeProgressListener(this.#progressListener);
        reject(err);
        return;
      }
      if ( response.error ) this._onError(response);
      if ( response.warning ) this._onWarning(response);
    });
  }
}

/**
 * An enum that indicates a state the Cache is in
 */
const CACHE_STATES = Object.freeze({
  COLD: 0,
  WARMING: 1,
  WARMED: 2
});

/**
 * The name of the setting used to persist package favorites.
 */
const FAVORITE_PACKAGES_SETTING = "setupPackageFavorites";

/**
 * @import {HandlebarsRenderOptions} from "@client/applications/api/handlebars-application.mjs"
 */

/**
 * The primary application which renders packages on the Setup view.
 */
class SetupPackages extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "setup-packages",
    classes: ["application", "framed"],
    tag: "section",
    window: {
      frame: false,
      positioned: false
    }
  };

  /**
   * The allowed view modes which can be used for each package-type tab.
   * @enum {Readonly<{id: string, label: string, template: string}>}
   */
  static #VIEW_MODES = Object.freeze({
    GALLERY: {
      id: "GALLERY",
      icon: "fa-solid fa-image-landscape",
      label: "PACKAGE.VIEW_MODES.GALLERY",
      template: "templates/setup/parts/package-gallery.hbs"
    },
    TILES: {
      id: "TILES",
      icon: "fa-solid fa-grid-horizontal",
      label: "PACKAGE.VIEW_MODES.TILES",
      template: "templates/setup/parts/package-tiles.hbs"
    },
    DETAILS: {
      id: "DETAILS",
      icon: "fa-solid fa-list",
      label: "PACKAGE.VIEW_MODES.DETAILS",
      template: "templates/setup/parts/package-details.hbs"
    }
  });

  /**
   * Required template partials which must be loaded to render the application.
   * @type {string[]}
   */
  static #PARTIALS = [
    "templates/setup/parts/package-tags.hbs",
    ...Object.values(SetupPackages.#VIEW_MODES).map(m => m.template)
  ];

  /** @override */
  static PARTS = {
    header: {
      id: "header",
      template: "templates/setup/setup-packages-header.hbs"
    },
    worlds: {
      id: "worlds",
      template: "templates/setup/setup-packages-worlds.hbs",
      templates: SetupPackages.#PARTIALS,
      scrollable: ["#worlds-list"]
    },
    systems: {
      id: "systems",
      template: "templates/setup/setup-packages-systems.hbs",
      templates: SetupPackages.#PARTIALS,
      scrollable: ["#systems-list"]
    },
    modules: {
      id: "modules",
      template: "templates/setup/setup-packages-modules.hbs",
      templates: SetupPackages.#PARTIALS,
      scrollable: ["#modules-list"]
    }
  };

  /** @override */
  static TABS = {
    primary: {
      tabs: [
        {id: "worlds", label: "SETUP.Worlds"},
        {id: "systems", label: "SETUP.Systems"},
        {id: "modules", label: "SETUP.Modules"}
      ],
      initial: "worlds"
    }
  };

  /**
   * The maximum number of progress bars that will be displayed simultaneously.
   * @type {number}
   */
  static #MAX_PROGRESS_BARS = 5;

  /**
   * The set of progress actions eligible for display in the package progress bar.
   * @type {Set<string>}
   */
  static #PROGRESS_ACTIONS = new Set([
    SETUP_PACKAGE_PROGRESS.ACTIONS.INSTALL_PKG,
    SETUP_PACKAGE_PROGRESS.ACTIONS.LAUNCH_WORLD,
    SETUP_PACKAGE_PROGRESS.ACTIONS.CREATE_BACKUP,
    SETUP_PACKAGE_PROGRESS.ACTIONS.RESTORE_BACKUP,
    SETUP_PACKAGE_PROGRESS.ACTIONS.DELETE_BACKUP,
    SETUP_PACKAGE_PROGRESS.ACTIONS.CREATE_SNAPSHOT,
    SETUP_PACKAGE_PROGRESS.ACTIONS.RESTORE_SNAPSHOT
  ]);

  /**
   * A mapping of package IDs to progress bar elements
   * @type {Map<string, HTMLElement>}
   */
  progressBars = new Map();

  /**
   * The view modes applied to each package tab.
   * @type {{worlds: string, systems: string, modules: string}}
   */
  #viewModes = this.#initializeViewModes();

  /**
   * Track whether an "Update All" workflow is currently in progress.
   * @type {"world"|"system"|"module"|null}
   */
  #updatingAll = null;

  /**
   * Search filter instances used in the application
   * @type {Record<"worlds"|"systems"|"modules", SearchFilter>}
   */
  #searchFilters = {
    worlds: new SearchFilter({
      inputSelector: "#world-filter",
      contentSelector: "#worlds-list",
      callback: this.#onSearchFilter.bind(this)
    }),
    systems: new SearchFilter({
      inputSelector: "#system-filter",
      contentSelector: "#systems-list",
      callback: this.#onSearchFilter.bind(this)
    }),
    modules: new SearchFilter({
      inputSelector: "#module-filter",
      contentSelector: "#modules-list",
      callback: this.#onSearchFilter.bind(this)
    })
  };

  /**
   * Retrieve selected view modes from client storage.
   * @returns {{worlds: string, systems: string, modules: string}}
   */
  #initializeViewModes() {
    const vm = game.settings.get("core", "setupViewModes");
    if ( !(vm.worlds in SetupPackages.#VIEW_MODES) ) vm.worlds = "GALLERY";
    if ( !(vm.systems in SetupPackages.#VIEW_MODES) ) vm.systems = "GALLERY";
    if ( !(vm.modules in SetupPackages.#VIEW_MODES) ) vm.modules = "TILES";
    return vm;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    // Context Menu for package management
    new foundry.applications.ux.ContextMenu.implementation(this.element, ".package", [], {
      onOpen: this.#setContextMenuItems.bind(this),
      fixed: true,
      jQuery: false
    });

    // Search Filters
    for ( const [tabName, filter] of Object.entries(this.#searchFilters) ) {
      if ( options.parts.includes(tabName) ) filter.bind(this.element);
    }

    // Re-parent the progress bars element into the active tab
    const progressBars = document.getElementById("progress");
    progressBars.append(...this.progressBars.values());
    const activeTab = this.element.querySelector(".tab.active > header");
    activeTab?.insertAdjacentElement("afterend", progressBars);

    // If there are no systems, disable the worlds tab
    this.element.querySelector("[data-tab=worlds]").classList.toggle("disabled", !context.systems.count);
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    if ( options.isFirstRender || options.refreshFavorites ) this.#identifyFavorites();
    return {
      tabs: this._prepareTabs("primary"),
      worlds: {
        packages: this.#prepareWorlds(),
        count: game.worlds.size,
        viewMode: this.#viewModes.worlds,
        template: SetupPackages.#VIEW_MODES[this.#viewModes.worlds].template,
        icon: foundry.packages.World.icon,
        updatingAll: this.#updatingAll === "world"
      },
      systems: {
        packages: this.#prepareSystems(),
        count: game.systems.size,
        viewMode: this.#viewModes.systems,
        template: SetupPackages.#VIEW_MODES[this.#viewModes.systems].template,
        icon: foundry.packages.System.icon,
        updatingAll: this.#updatingAll === "system"
      },
      modules: {
        packages: this.#prepareModules(),
        count: game.modules.size,
        viewMode: this.#viewModes.modules,
        template: SetupPackages.#VIEW_MODES[this.#viewModes.modules].template,
        icon: foundry.packages.Module.icon,
        updatingAll: this.#updatingAll === "module"
      },
      viewModes: Object.values(SetupPackages.#VIEW_MODES)
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareTabs(tabGroup) {
    if ( !game.systems.size ) this.tabGroups.primary = "systems";
    const tabs = super._prepareTabs(tabGroup);
    if ( tabGroup !== "primary" ) return tabs;
    tabs.worlds.icon = `fa-solid ${foundry.packages.World.icon}`;
    tabs.systems.icon = `fa-solid ${foundry.packages.System.icon}`;
    tabs.modules.icon = `fa-solid ${foundry.packages.Module.icon}`;
    return tabs;
  }

  /* -------------------------------------------- */

  /**
   * Identify and mark favorited packages.
   */
  #identifyFavorites() {
    const packageFavorites = game.settings.get("core", FAVORITE_PACKAGES_SETTING);
    for ( const [collectionName, ids] of Object.entries(packageFavorites) ) {
      const c = game[collectionName];
      for ( const id of ids ) {
        const pkg = c.get(id);
        if ( pkg ) pkg.favorite = true;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare data for rendering the Worlds tab.
   * @returns {object[]}
   */
  #prepareWorlds() {
    const codes = CONST.PACKAGE_AVAILABILITY_CODES;
    const worlds = game.worlds.map(world => {
      const w = world.toObject();
      w.authors = this.#formatAuthors(w.authors);
      w.system = game.systems.get(w.system);
      w.thumb = this.#getCover(world) || this.#getCover(w.system) || "ui/anvil-bg.png";
      w.badge = world.getVersionBadge();
      w.systemBadge = world.getSystemBadge();
      w.available = (world.availability <= codes.REQUIRES_UPDATE) || (world.availability === codes.VERIFIED);
      w.lastPlayedDate = new Date(w.lastPlayed);
      w.lastPlayedLabel = this.#formatDate(w.lastPlayedDate);
      w.canPlay = !(world.locked || world.unavailable);
      w.favorite = world.favorite;
      w.locked = world.locked;
      w.shortDesc = foundry.applications.ux.TextEditor.previewHTML(w.description);
      return w;
    });
    worlds.sort(this.#sortWorlds);
    return worlds;
  }

  /* -------------------------------------------- */

  /**
   * Prepare data for rendering the Systems tab.
   * @returns {object[]}
   */
  #prepareSystems() {
    const systems = game.systems.map(system => {
      const s = system.toObject();
      s.authors = this.#formatAuthors(s.authors);
      s.shortDesc = foundry.applications.ux.TextEditor.previewHTML(s.description);
      s.badge = system.getVersionBadge();
      s.favorite = system.favorite;
      s.locked = system.locked;
      s.thumb = this.#getCover(system) || "ui/anvil-bg.png";
      return s;
    });
    systems.sort(this.#sortPackages);
    return systems;
  }

  /* -------------------------------------------- */

  /**
   * Prepare data for rendering the Modules tab.
   * @returns {object[]}
   */
  #prepareModules() {
    const modules = game.modules.map(module => {
      const m = module.toObject();
      m.authors = this.#formatAuthors(m.authors);
      m.shortDesc = foundry.applications.ux.TextEditor.previewHTML(m.description);
      m.badge = module.getVersionBadge();
      m.favorite = module.favorite;
      m.locked = module.locked;
      m.thumb = this.#getCover(module) || "ui/anvil-bg.png";
      return m;
    });
    modules.sort(this.#sortPackages);
    return modules;
  }

  /* -------------------------------------------- */

  /**
   * Obtain a cover image used to represent the package.
   * Prefer the "setup" media type, and prefer a thumbnail to the full image.
   * Otherwise, use a background image if the package has one.
   * @param {BasePackage} pkg     The package which requires a cover image
   * @returns {string|undefined}  A cover image URL or undefined
   */
  #getCover(pkg) {
    if ( !pkg ) return undefined;
    if ( pkg.media.size ) {
      const setup = pkg.media.find(m => m.type === "setup");
      if ( setup?.thumbnail ) return setup.thumbnail;
      else if ( setup?.url ) return setup.url;
    }
    if ( pkg.background ) return pkg.background;
  }

  /* -------------------------------------------- */

  /**
   * Format the authors of a package into the string of comma-separated links that are displayed.
   * @param {object[]} authors
   * @returns {string}
   */
  #formatAuthors(authors=[]) {
    return authors.map(a => {
      let html = foundry.utils.escapeHTML(a.name);
      if ( a.url ) html = `<a href="${foundry.utils.escapeHTML(a.url)}" target="_blank">${html}</a>`;
      return html;
    }).join(", ");
  }

  /* -------------------------------------------- */

  /**
   * Format dates displayed in the app.
   * @param {Date} date     The Date instance to format
   * @returns {string}      The formatted date string
   */
  #formatDate(date) {
    return date.isValid() ? date.toLocaleDateString(game.i18n.lang, {
      weekday: "long",
      month: "short",
      day: "numeric"
    }) : "";
  }

  /* -------------------------------------------- */

  /**
   * A sorting function used to order worlds.
   * @param {World} a
   * @param {World} b
   * @returns {number}
   */
  #sortWorlds(a, b) {

    // Favorites
    const fd = b.favorite - a.favorite;
    if ( fd !== 0 ) return fd;

    // Sort date
    const ad = a.lastPlayedDate.isValid() ? a.lastPlayedDate : 0;
    const bd = b.lastPlayedDate.isValid() ? b.lastPlayedDate : 0;
    if ( ad && !bd ) return -1;
    if ( bd && !ad ) return 1;
    if ( ad && bd ) return bd - ad;

    // Sort title
    return a.title.localeCompare(b.title, game.i18n.lang);
  }

  /* -------------------------------------------- */

  /**
   * A sorting function used to order systems and modules.
   * @param {ClientPackage} a   A system or module
   * @param {ClientPackage} b   Another system or module
   * @returns {number}          The relative sort order between the two
   */
  #sortPackages(a, b) {
    return (b.favorite - a.favorite) || a.title.localeCompare(b.title, game.i18n.lang);
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @inheritdoc */
  changeTab(tab, group, options) {
    if ( !tab || !group ) throw new Error("You must pass both the tab and tab group identifier");
    if ( (this.tabGroups[group] === tab) && !options?.force ) return;  // No change necessary

    super.changeTab(tab, group, options);

    // Clear search filters
    const activeFilter = this.#searchFilters[tab];
    for ( const filter of Object.values(this.#searchFilters) ) {
      filter.filter(null, "");
      if ( filter === activeFilter ) filter._input.focus();
    }

    // Re-parent progress container
    const progress = document.getElementById("progress");
    this.element.querySelector(".tab.active header").insertAdjacentElement("afterend", progress);
  }

  /* -------------------------------------------- */

  /**
   * Set the search filter.
   * @param {string} query  The query
   */
  search(query) {
    const tab = this.tabGroups.primary;
    const filter = this.#searchFilters[tab]._input;
    filter.value = query;
    filter.dispatchEvent(new Event("input", {bubbles: true}));
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickAction(event, button) {
    event.preventDefault();
    if ( event.button !== 0 ) return;
    const packageType = button.closest("[data-package-type]").dataset.packageType;
    const packageId = button.closest(".package")?.dataset.packageId;
    const pkg = packageId ? foundry.packages.PACKAGE_TYPES[packageType].get(packageId) : undefined;
    switch ( button.dataset.action ) {
      case "installPackage":
        game.browsePackages(packageType, {search: button.dataset.query});
        break;
      case "moduleCreate":
        new ModuleConfig().render({force: true});
        break;
      case "updateAll":
        this.#updateAll(packageType);
        break;
      case "updatePackage":
        this.#updatePackage(pkg);
        break;
      case "viewMode":
        this.#onChangeViewMode(button);
        break;
      case "worldCreate":
        this.#createWorld();
        break;
      case "worldInstall":
        game.browsePackages(packageType);
        break;
      case "worldLaunch":
        this.#launchWorld(pkg);
        break;
      case "tour": {
        const tour = game.tours.get(button.dataset.tour);
        tour?.start();
        break;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to search filter queries.
   * @type {SearchFilterCallback}
   */
  #onSearchFilter(_event, query, rgx, html) {
    if ( !html ) return;
    let anyMatch = !query;
    const noResults = html.closest("section").querySelector(".no-results");
    for ( const li of html.children ) {
      if ( !query ) {
        li.classList.remove("hidden");
        continue;
      }
      const id = li.dataset.packageId;
      const title = li.querySelector(".package-title")?.textContent;
      const match = rgx.test(id) || foundry.applications.ux.SearchFilter.testQuery(rgx, title);
      li.classList.toggle("hidden", !match);
      if ( match ) anyMatch = true;
    }
    const empty = !anyMatch || !html.children.length;
    html.classList.toggle("empty", empty);
    if ( !anyMatch ) {
      const name = foundry.utils.escapeHTML(query);
      const label = game.i18n.localize(`SETUP.${html.closest(".tab").dataset.tab.titleCase()}`);
      const search = game.i18n.format("SETUP.PackagesNoResultsSearch", {name});
      noResults.innerHTML = `<p>${game.i18n.format("SETUP.PackagesNoResults", {type: label, name})}
        <button type="button" class="search-packages" data-action="installPackage"
                data-query="${name}">${search}</button></p>`;
    }
    noResults.classList.toggle("hidden", anyMatch);
  }

  /* -------------------------------------------- */

  /**
   * Dynamically assign context menu options depending on the package that is interacted with.
   * @param {HTMLLIElement} li      The HTML <li> element to which the context menu is attached
   */
  #setContextMenuItems(li) {
    const packageType = li.closest("[data-package-type]").dataset.packageType;
    const typeLabel = game.i18n.localize(`PACKAGE.Type.${packageType}`);
    const pkg = foundry.packages.PACKAGE_TYPES[packageType].get(li.dataset.packageId);
    const menuItems = [];

    // Launch World
    if ( (packageType === "world") && !pkg.locked && !pkg.unavailable ) menuItems.push({
      name: "SETUP.WorldLaunch",
      icon: '<i class="fa-solid fa-circle-play"></i>',
      callback: () => this.#launchWorld(pkg),
      group: "primary"
    });

    // Edit World
    if ( (packageType === "world") && !pkg.locked ) menuItems.push({
      name: "SETUP.WorldEdit",
      icon: '<i class="fa-solid fa-pen-to-square"></i>',
      callback: () => new WorldConfig({world: pkg}).render({force: true}),
      group: "primary"
    });

    // Edit Module
    if ( (packageType === "module") && !pkg.locked ) menuItems.push({
      name: "PACKAGE.ModuleEdit",
      icon: '<i class="fa-solid fa-pen-to-square"></i>',
      callback: () => new ModuleConfig({moduleData: pkg.toObject()}).render({force: true}),
      group: "primary"
    });

    // Mark or Unmark Favorite
    menuItems.push({
      name: game.i18n.format(pkg.favorite ? "PACKAGE.Unfavorite" : "PACKAGE.Favorite"),
      icon: `<i class="${pkg.favorite ? "fa-regular fa-star" : "fa-solid fa-star"}"></i>`,
      callback: () => this.#toggleFavorite(pkg),
      group: "primary"
    });

    // Lock or Unlock Package
    menuItems.push({
      name: game.i18n.format(pkg.locked ? "PACKAGE.Unlock" : "PACKAGE.Lock", {type: typeLabel}),
      icon: `<i class="fa-solid fa-${pkg.locked ? "lock": "unlock"}"></i>`,
      callback: () => this.#toggleLock(pkg),
      group: "primary"
    });

    // Delete Package
    menuItems.push({
      name: packageType === "world" ? "SETUP.WorldDelete" : "SETUP.Uninstall",
      icon: '<i class="fa-solid fa-trash"></i>',
      callback: () => game.uninstallPackage(pkg),
      group: "primary"
    });

    if ( !game.data.options.noBackups ) {
      // Taking backups
      menuItems.push({
        name: "SETUP.BACKUPS.TakeBackup",
        icon: '<i class="fa-solid fa-floppy-disk"></i>',
        callback: () => game.createBackup(pkg, { dialog: true }),
        group: "backups"
      });

      if ( game.backupsCache?.[pkg.type]?.[pkg.id]?.length ) {
        menuItems.push({
          name: "SETUP.BACKUPS.RestoreLatestBackup",
          icon: '<i class="fa-solid fa-arrow-rotate-left"></i>',
          callback: () => game.restoreLatestBackup(pkg, { dialog: true }),
          group: "backups"
        });
      }

      // Managing backups
      menuItems.push({
        name: "SETUP.BACKUPS.ManageBackups",
        icon: '<i class="fa-solid fa-floppy-disks"></i>',
        callback: () => new BackupList({package: pkg}).render({force: true}),
        group: "backups"
      });
    }
    ui.context.menuItems = menuItems;
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the view mode for a certain package type.
   * @param {HTMLElement} button    The clicked button element
   */
  #onChangeViewMode(button) {
    const tab = button.closest(".tab").dataset.tab;
    this.#viewModes[tab] = button.dataset.viewMode;
    game.settings.set("core", "setupViewModes", this.#viewModes);
    this.render({parts: [tab]}); // Only re-render one tab
  }

  /* -------------------------------------------- */

  /**
   * Display a confirmation dialog which warns the user that launching the world will trigger irreversible migration.
   * @param {World} world                                       The World being launched
   * @returns {Promise<{confirm: boolean; backup?: boolean}>}  Did the user agree to proceed?
   */
  async #displayWorldMigrationInfo(world) {
    if ( !world ) return { confirm: false };
    const system = game.systems.get(world.system);
    const needsCoreMigration = foundry.utils.isNewerVersion(game.release.version, world.coreVersion);
    const needsSystemMigration = world.systemVersion
      && foundry.utils.isNewerVersion(system.version, world.systemVersion);

    if ( !needsCoreMigration && !needsSystemMigration ) return { confirm: true };
    if ( !needsCoreMigration && needsSystemMigration && game.data.options.noBackups ) return { confirm: true };

    // Prompt that world migration will be required
    const disableModules = game.release.isGenerationalChange(world.compatibility.verified);

    let content = [
      needsCoreMigration ? game.i18n.format("SETUP.WorldCoreMigrationRequired", {
        world: foundry.utils.escapeHTML(world.title),
        oldVersion: world.coreVersion,
        newVersion: game.release
      }) : game.i18n.format("SETUP.WorldSystemMigrationRequired", {
        oldVersion: world.systemVersion,
        newVersion: system.version
      }),
      system.availability !== CONST.PACKAGE_AVAILABILITY_CODES.VERIFIED
        ? game.i18n.format("SETUP.WorldMigrationSystemUnavailable", {
          system: foundry.utils.escapeHTML(system.title),
          systemVersion: system.version
        })
        : "",
      disableModules ? game.i18n.localize("SETUP.WorldMigrationDisableModules") : "",
      game.i18n.localize("SETUP.WorldMigrationBackupPrompt")
    ].filterJoin("");

    if ( !game.data.options.noBackups ) {
      content += `
        <label class="checkbox">
          ${game.i18n.localize("SETUP.WorldMigrationCreateBackup")}
          <input type="checkbox" name="createBackup" checked>
        </label>
      `;
    }

    // Present the confirmation dialog
    const result = await foundry.applications.api.DialogV2.confirm({
      window: {title: "SETUP.WorldMigrationRequiredTitle"},
      content,
      yes: {
        label: "SETUP.WorldMigrationBegin",
        icon: "fa-solid fa-laptop-arrow-down",
        callback: (event, button) => ({
          confirm: true,
          backup: button.form.elements.createBackup?.checked ?? false
        })
      },
      no: {
        label: "Cancel",
        icon: "fa-solid fa-xmark",
        callback: () => ({ confirm: false })
      }
    });
    return result ?? { confirm: false };
  }

  /* -------------------------------------------- */

  /**
   * Toggle the locked state of the interface.
   * @param {boolean} locked            Is the interface locked?
   * @param {object} [options]
   * @param {string} [options.message]  The message to display.
   */
  toggleLock(locked, { message }={}) {
    if ( !this.rendered ) return;
    this.element.querySelectorAll(".tabs .item").forEach(el => el.classList.toggle("disabled", locked));
    this.element.querySelectorAll(".package-list").forEach(el => el.classList.toggle("hidden", locked));
    this.element.querySelectorAll(".controls :is(input, button)").forEach(el => el.disabled = locked);
    const status = this.element.querySelector(".tab.active .locked");
    status.classList.toggle("hidden", !locked);
    if ( message ) status.querySelector("h3").innerText = game.i18n.localize(message);
  }

  /* -------------------------------------------- */
  /*  Package Management Operations               */
  /* -------------------------------------------- */

  /**
   * Create a new World.
   */
  #createWorld() {
    if ( !game.systems.size ) return ui.notifications.warn("SETUP.YouMustInstallASystem", {localize: true});
    let id = "new-world";
    let title = "New World";
    for ( let k = 2; game.worlds.has(id); k++ ) {
      id = `new-world-${k}`;
      title = `New World (${k})`;
    }
    let system = "invalid-system";
    while ( game.systems.has(id) ) system = `invalid-system-${foundry.utils.randomID()}`;
    const world = new foundry.packages.World({id, title, system, coreVersion: game.release.version});
    new foundry.applications.sidebar.apps.WorldConfig({world}).render({force: true});
  }

  /* -------------------------------------------- */

  /**
   * Request to launch a World.
   * @param {World} world      The requested World to launch
   * @returns {Promise<void>}  A Promise that resolves when the World is launched or an error occurs.
   */
  async #launchWorld(world) {
    if ( world.locked ) return ui.notifications.error("PACKAGE.LaunchLocked", {format: {id: world.id}});
    const { confirm, backup } = await this.#displayWorldMigrationInfo(world);
    if ( !confirm ) return;

    if ( backup ) await game.createBackup(world, { dialog: true });

    // Notify migration in progress.
    if ( foundry.utils.isNewerVersion(game.release.version, world.coreVersion) ) {
      const msg = game.i18n.format("SETUP.WorldMigrationInProcess", {version: game.release.version});
      ui.notifications.info(msg, {permanent: true});
    }

    // Show progress spinner and disable interaction with worlds.
    const worlds = document.getElementById("worlds-list");
    worlds.classList.add("disabled");
    const tile = worlds.querySelector(`.world[data-package-id="${world.id}"]`);
    tile.classList.add("loading");
    const icon = tile.querySelector(".control.play > i");
    icon.setAttribute("class", "fa-solid fa-spinner fa-spin-pulse");

    const { ACTIONS } = CONST.SETUP_PACKAGE_PROGRESS;
    const response = await new ProgressReceiver(world.id, ACTIONS.LAUNCH_WORLD, { world: world.id }).listen();
    if ( response instanceof Error ) {
      ui.notifications.error("SETUP.WorldLaunchFailure", {console: false, permanent: true,
        format: {message: response.message}});
    }
    else location.href = foundry.utils.getRoute("/game");
  }

  /* -------------------------------------------- */

  /**
   * Toggle marking a package as a favorite.
   * @param {BasePackage} pkg       The requested Package to mark or unmark as a favorite
   */
  async #toggleFavorite(pkg) {
    const favorites = game.settings.get("core", FAVORITE_PACKAGES_SETTING);
    const collectionName = foundry.packages.PACKAGE_TYPES[pkg.type].collection;
    if ( pkg.favorite ) favorites[collectionName].findSplice(f => f === pkg.id);
    else favorites[collectionName].push(pkg.id);
    game.settings.set("core", FAVORITE_PACKAGES_SETTING, favorites);
    pkg.favorite = !pkg.favorite;
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Toggle locking or unlocking a package.
   * @param {BasePackage} pkg       The requested Package to lock or unlock
   * @returns {Promise<object>}     Returned response from the server
   */
  async #toggleLock(pkg) {
    const shouldLock = !pkg.locked;
    await game.post({action: "lockPackage", type: pkg.type, id: pkg.id, shouldLock});
    pkg.locked = shouldLock;
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle update button press for a single Package.
   * @param {BasePackage} pkg       The requested Package to update
   * @param {object} [options]      Options which configure installation
   * @param {boolean} [options.notify=true]   Display a notification toast. Suppressed for "updateAll"
   * @returns {Promise<foundry.packages.BasePackage>}
   */
  async #installPackageUpdate(pkg, {notify=true}={}) {
    return game.installPackage({type: pkg.type, id: pkg.id, manifest: pkg.manifest, notify});
  }

  /* -------------------------------------------- */

  /**
   * Update all package for a certain package type.
   * @param {string} packageType         The package type to update
   * @param {object} [options]
   * @param {Set<string>} [options.ids]  A fixed set of package IDs to update, rather than all of the given type.
   * @param {boolean} [options.force]    Update the packages without prompting the user to confirm in cases of
   *                                     incompatible systems.
   * @returns {Promise<void>}
   */
  async #updateAll(packageType, { ids, force }={}) {
    if ( this.#updatingAll ) return ui.notifications.warn("PACKAGE.UpdateAllInProgress", {localize: true});
    this.#updatingAll = packageType;

    // Disable the "Update All" button
    let button = this.element.querySelector(`[data-package-type="${packageType}"] [data-action="updateAll"]`);
    button.disabled = true;
    button.firstElementChild.className = "fa-solid fa-spinner fa-spin";

    // Create two queues
    const max = SetupPackages.#MAX_PROGRESS_BARS;
    const pending = game[foundry.packages.PACKAGE_TYPES[packageType].collection].filter(p => {
      return p.manifest && !p.locked && (!ids || ids.has(p.id));
    });
    const active = new Set();
    const results = [];
    let requireReload = false;

    // Populate the package cache
    console.group(`${CONST.vtt} | Updating ${packageType.titleCase()}s`);
    await game.warmPackages({type: packageType});
    console.debug(`Warmed ${packageType} package cache`);

    // Store information on incompatible module updates.
    const incompatible = {};

    // A semaphore which updates a certain number of packages concurrently
    let complete;
    const next = () => {
      while ( (active.size < max) && pending.length ) {
        const pkg = pending.shift();
        active.add(pkg);
        update(pkg);
      }
      if ( !pending.length && !active.size ) complete();
    };

    // TODO #8732

    // Update function
    const update = async pkg => {
      console.debug(`Checking ${packageType} ${pkg.id} for updates`);
      const check = await this.#updateCheck(pkg);
      switch ( check.state ) {

        // Error
        case "error":
          results.push({
            package: pkg,
            action: game.i18n.localize("Error"),
            actionClass: "error",
            description: check.error
          });
          console.debug(`Checked ${packageType} ${pkg.id}: error`);
          break;

        // Warning
        case "warning":
          results.push({
            package: pkg,
            action: game.i18n.localize("Warning"),
            actionClass: "warning",
            description: check.warning
          });
          console.debug(`Checked ${packageType} ${pkg.id}: warning`);
          break;

        // Info
        case "info":
          results.push({
            package: pkg,
            action: game.i18n.localize("Information"),
            actionClass: "info",
            description: check.info
          });
          console.debug(`Checked ${packageType} ${pkg.id}: info`);
          break;

        // Sidegrade
        case "sidegrade":
          requireReload = true;
          console.debug(`Checked ${packageType} ${pkg.id}: sidegrade`);
          break;

        // Track Change
        case "trackChange": {
          const confirm = await this.#promptTrackChange(pkg, check.trackChange);
          if ( confirm ) {
            pkg.updateSource({manifest: check.trackChange.manifest});
            try {
              const trackChangeUpdate = await this.#installPackageUpdate(pkg, {notify: false});
              results.push({
                package: trackChangeUpdate,
                action: game.i18n.localize("Update"),
                actionClass: "success",
                description: `${pkg.version} ➞ ${trackChangeUpdate.version}`
              });
              console.debug(`${CONST.vtt} | Checked ${packageType} ${pkg.id}: track change success`);
            } catch(err) {
              results.push({
                package: pkg,
                action: game.i18n.localize("Error"),
                actionClass: "error",
                description: err.message
              });
              console.debug(`Checked ${packageType} ${pkg.id}: track change failed`);
            }
          }
          else console.debug(`Checked ${packageType} ${pkg.id}: track change declined`);
          break;
        }

        // Standard Update
        case "update":
          if ( !force && Object.values(check.systemCompatibility ?? {}).some(({ compatible }) => !compatible) ) {
            incompatible[pkg.id] = { pkg, systemCompatibility: check.systemCompatibility };
            break;
          }
          try {
            const updated = await this.#installPackageUpdate(pkg, {notify: false});
            results.push({
              package: updated,
              action: game.i18n.localize("Update"),
              actionClass: "success",
              description: `${pkg.version} ➞ ${updated.version}`
            });
            console.debug(`Checked ${packageType} ${pkg.id}: update success`);
          } catch(err) {
            results.push({
              package: pkg,
              action: game.i18n.localize("Error"),
              actionClass: "error",
              description: err.message
            });
            console.debug(`Checked ${packageType} ${pkg.id}: update failed`);
          }
          break;

        case "current":
          console.debug(`Checked ${packageType} ${pkg.id}: current`);
          break;

        // Unknown
        default:
          console.warn(`Checked ${packageType} ${pkg.id}: unknown state`);
          break;
      }
      active.delete(pkg);
      next();
    };

    // Wait for completion
    await new Promise(resolve => {
      complete = resolve;
      next();
    });
    console.debug("Update check complete");

    // Display Update Log
    let response;
    if ( results.length || !isEmpty(incompatible) ) {
      const dialog = isEmpty(incompatible)
        ? foundry.applications.api.DialogV2.prompt.bind(foundry.applications.api.DialogV2)
        : foundry.applications.api.DialogV2.confirm.bind(foundry.applications.api.DialogV2);
      const incompatibleList = Object.values(incompatible ?? {}).map(({ pkg, systemCompatibility }) => {
        return game.i18n.format("SETUP.PackageIncompatibleWithSystems", {
          module: `<strong>${foundry.utils.escapeHTML(pkg.title)}</strong>`,
          systems: Object.values(systemCompatibility).map(({ id, title, compatible }) => {
            return compatible ? null : `<strong>${foundry.utils.escapeHTML(title ?? id)}</strong>`;
          }).filterJoin(", ")
        });
      });
      const html = await foundry.applications.handlebars.renderTemplate("templates/setup/updated-packages.hbs", {
        changed: results,
        incompatible: incompatibleList
      });
      const content = document.createElement("div");
      content.innerHTML = html;
      response = await dialog({
        window: {
          title: "SETUP.UpdatedPackages",
          icon: "fa-solid fa-cloud-arrow-down",
          contentClasses: ["updated-packages"]
        },
        position: {width: 700},
        content
      });
    }

    // No results
    else ui.notifications.info("PACKAGE.AllUpdated", {format: {
      type: game.i18n.localize(`PACKAGE.Type.${packageType}Pl`)
    }});
    console.groupEnd();

    // Reload package data
    if ( requireReload ) await game.reload();

    // Re-enable the "Update All" button
    button = this.element.querySelector(`[data-package-type="${packageType}"] [data-action="updateAll"]`);
    button.disabled = false;
    button.firstElementChild.className = "fa-solid fa-cloud-download";

    this.#updatingAll = null;
    if ( response === true ) {
      return this.#updateAll(packageType, { ids: new Set(Object.keys(incompatible)), force: true });
    }
  }

  /* -------------------------------------------- */

  /**
   * Check for an available update for a specific package
   * @param {Package} pkg     The package to check
   */
  async #updatePackage(pkg) {
    // Disable the "Update" button
    let button = this.element.querySelector(`[data-package-id="${pkg.id}"] [data-action="updatePackage"]`);
    button.disabled = true;
    button.firstElementChild.className = "fas fa-spinner fa-spin";

    // TODO #8732

    const check = await this.#updateCheck(pkg);
    switch ( check.state ) {
      case "error":
        ui.notifications.error(foundry.utils.escapeHTML(check.error), {permanent: true});
        break;
      case "warning":
        ui.notifications.warn(foundry.utils.escapeHTML(check.warning));
        break;
      case "info":
        ui.notifications.info(foundry.utils.escapeHTML(check.info));
        break;
      case "sidegrade":
        await game.reload();
        break;
      case "trackChange": {
        const accepted = await this.#promptTrackChange(pkg, check.trackChange);
        if ( accepted ) {
          pkg.updateSource({manifest: check.trackChange.manifest});
          await this.#installPackageUpdate(pkg);
        }
        break;
      }
      case "current":
        await ui.notifications.info("PACKAGE.AlreadyUpdated", {format: {name: pkg.title}});
        break;
      case "update":
        if ( Object.values(check.systemCompatibility ?? {}).some(({ compatible }) => !compatible) ) {
          const proceed = await this.#promptIncompatibleSystems(check.systemCompatibility);
          if ( !proceed ) break;
        }
        await this.#installPackageUpdate(pkg);
        break;
    }

    // Re-enable the "Update" button
    button = this.element.querySelector(`[data-package-id="${pkg.id}"] [data-action="updatePackage"]`);
    button.disabled = false;
    button.firstElementChild.className = "fa-solid fa-rotate";
  }

  /* -------------------------------------------- */

  /**
   * @typedef PackageCheckResult
   * @property {BasePackage} package                                The checked package
   * @property {"error"|"sidegrade"|"trackChange"|"warning"|"info"|"update"|"current"|"unknown"} state  Check state
   * @property {string} [error]                                     An error to display, if any
   * @property {string} [warning]                                   A warning to display, if any
   * @property {string} [info]                                      An info to display, if any
   * @property {manifest: string, version: string} [trackChange]    The suggested track change, if any
   * @property {string} [manifest]                                  The manifest of the Update, if any
   * @property {Record<string, SystemCompatibility>} [systemCompatibility]  Compatibility information for this package's
   *                                                                        related systems.
   */

  /**
   * Execute upon an update check for a single Package
   * @param {BasePackage} pkg                  The Package to check
   * @returns {Promise<PackageCheckResult>}    The status of the update check
   */
  async #updateCheck(pkg) {
    const checkData = {package: pkg, state: "unknown"};
    let responseData;
    let manifestData;

    // Check whether an update is available
    try {
      responseData = await game.checkPackage({type: pkg.type, id: pkg.id});
      manifestData = responseData.remote;
    } catch(err) {
      checkData.state = "error";
      checkData.error = err.toString();
      return checkData;
    }

    // Metadata sidegrade performed
    if ( responseData.hasSidegraded ) {
      checkData.state = "sidegrade";
      return checkData;
    }

    // Track change suggested
    if ( responseData.trackChange ) {
      checkData.state = "trackChange";
      checkData.trackChange = responseData.trackChange;
      checkData.manifest = responseData.trackChange.manifest;
      return checkData;
    }

    // Verify remote manifest compatibility with current software
    const availability = responseData.availability;
    const codes = CONST.PACKAGE_AVAILABILITY_CODES;

    // Unsupported updates
    const wrongCore = [
      codes.REQUIRES_CORE_DOWNGRADE, codes.REQUIRES_CORE_UPGRADE_STABLE, codes.REQUIRES_CORE_UPGRADE_UNSTABLE
    ];
    if ( responseData.isUpgrade && wrongCore.includes(availability) ) {
      checkData.state = "info";
      const message = {
        [codes.REQUIRES_CORE_DOWNGRADE]: "Insufficient",
        [codes.REQUIRES_CORE_UPGRADE_STABLE]: "UpdateNeeded",
        [codes.REQUIRES_CORE_UPGRADE_UNSTABLE]: "Unstable"
      }[availability];
      checkData.info = game.i18n.format(`SETUP.PackageUpdateCore${message}`, {
        id: manifestData.id,
        title: manifestData.title,
        vmin: manifestData.compatibility.minimum,
        vmax: manifestData.compatibility.maximum,
        vcur: game.version
      });
      return checkData;
    }

    // TODO #8732

    // Available updates
    if ( responseData.isUpgrade && (availability <= codes.UNVERIFIED_GENERATION) ) {
      checkData.systemCompatibility = responseData.systemCompatibility;
      checkData.state = "update";
      checkData.manifest = manifestData.manifest;
      return checkData;
    }

    // Packages which are already current
    checkData.state = "current";
    return checkData;
  }

  /* -------------------------------------------- */

  /**
   * Prompt the user to use a new Package track it if they haven't previously declined.
   * @param {BasePackage} pkg                                     The Package being updated
   * @param {{manifest: string, version: string}} trackChange     A recommended track change provided by the server
   * @returns {Promise<boolean>}                                  Whether the recommended track change was accepted
   */
  async #promptTrackChange(pkg, trackChange) {

    // Verify that the user has not already declined a suggested track change
    const declinedManifestUpgrades = game.settings.get("core", "declinedManifestUpgrades");
    if ( declinedManifestUpgrades[pkg.id] === pkg.version ) return false;

    // Generate dialog HTML
    const html = await foundry.applications.handlebars.renderTemplate("templates/setup/manifest-update.hbs", {
      localManifest: pkg.manifest,
      localTitle: game.i18n.format("SETUP.PriorManifestUrl", {version: pkg.version}),
      remoteManifest: trackChange.manifest,
      remoteTitle: game.i18n.format("SETUP.UpdatedManifestUrl", {version: trackChange.version}),
      package: pkg.title
    });
    const content = document.createElement("div");
    content.innerHTML = html;

    // Prompt for confirmation
    const accepted = await foundry.applications.api.DialogV2.confirm({
      window: {title: "SETUP.ManifestUpdate"},
      content,
      yes: {
        callback: () => {
          delete declinedManifestUpgrades[pkg.id];
          return true;
        },
        default: true
      },
      no: {
        callback: () => {
          declinedManifestUpgrades[pkg.id] = pkg.version;
          return false;
        },
        default: false
      }
    });
    await game.settings.set("core", "declinedManifestUpgrades", declinedManifestUpgrades);
    return accepted;
  }

  /* -------------------------------------------- */

  /**
   * Confirm that the user wishes to proceed with the update in cases where it would make the module incompatible with
   * one of its listed system relationships.
   * @param {Record<string, SystemCompatibility>} compatibility  System compatibility information.
   * @returns {Promise<boolean|null>}
   */
  #promptIncompatibleSystems(compatibility) {
    const items = Object.values(compatibility).map(({ id, title, compatible }) => {
      return compatible ? null : `<li>${foundry.utils.escapeHTML(title ?? id)}</li>`;
    }).filterJoin("");
    return foundry.applications.api.DialogV2.confirm({
      modal: true,
      rejectClose: false,
      window: { title: "AreYouSure" },
      position: { width: 450 },
      content: `
        <p>${game.i18n.localize("SETUP.PackageIncompatibleSystemsPrompt")}</p>
        <ul>${items}</ul>`
    });
  }

  /* -------------------------------------------- */
  /*  Installation Progress Bar                   */
  /* -------------------------------------------- */

  /**
   * Update the UI progress bar in response to server progress ticks.
   * @param {ProgressReceiverPacket} [progress]  The incremental progress information.
   */
  onProgress({action, id, title, pct, step, message}={}) {
    const { STEPS } = CONST.SETUP_PACKAGE_PROGRESS;
    if ( !SetupPackages.#PROGRESS_ACTIONS.has(action) ) return;
    if ( [STEPS.VEND, STEPS.COMPLETE].includes(step) ) return this.removeProgressBar(id);
    const bar = this.#getProgressBar(id);
    if ( bar && Number.isNumeric(pct) ) {
      const status = [message ? game.i18n.localize(message) : null, title ?? id, `${pct}%`].filterJoin(" ");
      bar.firstElementChild.style.maxWidth = `${pct}%`;
      bar.firstElementChild.firstElementChild.innerText = status;
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the progress bar element used to track installation for a certain package ID.
   * @param {string} packageId        The package being installed
   * @returns {HTMLDivElement|null}   The progress bar element to use
   */
  #getProgressBar(packageId) {

    // Existing bar
    let bar = this.progressBars.get(packageId);
    if ( bar ) return bar;

    // Too many bars
    if ( this.progressBars.size >= SetupPackages.#MAX_PROGRESS_BARS ) return null;

    // New Bar
    const d = document.createElement("div");
    d.innerHTML = `
    <div class="progress-bar">
        <div class="bar">
            <span class="pct"></span>
        </div>
    </div>`;
    bar = d.firstElementChild;
    this.progressBars.set(packageId, bar);

    // Add to DOM
    document.getElementById("progress").appendChild(bar);
    return bar;
  }

  /* -------------------------------------------- */

  /**
   * Remove a Progress Bar from the DOM and from the progress mapping.
   * @param {string} id  The operation ID that is no longer being tracked.
   */
  removeProgressBar(id) {
    const bar = this.progressBars.get(id);
    if ( bar ) {
      bar.remove();
      this.progressBars.delete(id);
    }
  }
}

/**
 * @typedef NewsItem
 * @property {string} title           The title of the featured item
 * @property {string} image           The background image URL
 * @property {string} url             The website URL where clicking on the link should lead
 * @property {string} [caption]       A caption used for featured content
 */

/**
 * An application that renders the Setup sidebar containing News and Featured Content widgets
 * @extends ApplicationV2
 */
class SetupSidebar extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "setup-sidebar",
    tag: "aside",
    window: {
      frame: false,
      positioned: false
    }
  };

  /** @override */
  static PARTS = {
    news: {
      id: "news",
      template: "templates/setup/parts/setup-news.hbs",
      scrollable: ["#news-articles"]
    },
    featured: {
      id: "featured",
      template: "templates/setup/parts/setup-featured.hbs"
    }
  };


  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    return {
      featured: game.data.featuredContent,
      news: game.data.news
    };
  }
}

/**
 * @import {ApplicationClickAction, ApplicationConfiguration} from "@client/applications/_types.mjs"
 * @import {ReleaseData} from "@common/config.mjs"
 * @import {PackageManifestData} from "@common/packages/_types.mjs"
 */

/**
 * @typedef PreviewCompatibilitySummary
 * @property {string} icon                                  The icon.
 * @property {"success"|"neutral"|"warning"|"error"} status The compatibility status.
 * @property {string} label                                 The compatibility label.
 * @property {number} count                                 The number of packages.
 */

/**
 * @typedef CompatibilityCheckerConfiguration
 * @property {ReleaseData} release
 */

/**
 * An Application that allows for browsing the previewed compatibility state of packages in the next version of the core
 * software.
 */
class CompatibilityChecker extends CategoryBrowser {
  /**
   * @param {Partial<ApplicationConfiguration> & CompatibilityCheckerConfiguration} options
   */
  constructor(options) {
    super(options);
    this.#release = options.release;
  }

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "compatibility-checker",
    window: {
      icon: "fa-solid fa-code-branch",
      contentClasses: ["standard-form"]
    },
    position: {
      width: 920,
      height: 780
    },
    actions: {
      filterCompatibility: CompatibilityChecker.#onFilterCompatibility
    },
    packageList: true,
    initialCategory: "systems",
    subtemplates: {
      category: "templates/setup/compatibility-checker/category.hbs",
      filters: "templates/setup/compatibility-checker/filters.hbs",
      sidebarFooter: "templates/setup/compatibility-checker/summary.hbs"
    }
  };

  /**
   * Options for filtering on compatibility.
   */
  static #COMPATIBILITY_FILTERS = Object.freeze({
    NONE: 0,
    COMPATIBLE: 1,
    UNVERIFIED: 2,
    INCOMPATIBLE: 3
  });

  /* -------------------------------------------- */

  /**
   * The currently active filters.
   * @type {{types: Set<string>, compatibility: number}}
   */
  #filters = {
    compatibility: CompatibilityChecker.#COMPATIBILITY_FILTERS.NONE
  };

  /**
   * The release to preview.
   * @type {ReleaseData}
   */
  #release;

  /**
   * The previewed package compatibilities.
   * @type {Record<"world"|"system"|"module", Map<string, PackageManifestData>}
   */
  #preview;

  /** @override */
  get title() {
    const channel = game.i18n.localize(`SETUP.Update${this.#release.channel.titleCase()}`);
    const release = this.#release.display.replace(/(\d+) (\d+)$/, `$1 ${channel} $2`);
    return game.i18n.format("SETUP.COMPAT.Title", {release});
  }

  /** @override */
  get _dataLoaded() {
    return !!this.#preview;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const FILTERS = CompatibilityChecker.#COMPATIBILITY_FILTERS;
    Object.assign(context, {
      loading: this._dataLoaded ? null : "SETUP.COMPAT.Previewing",
      version: this.#release.version,
      summary: this.#prepareSummary(Object.values(context.categories).flatMap(c => c.entries)),
      filters: {
        compatibility: ["compatible", "unverified", "incompatible"].map(id => ({
          id,
          active: this.#filters.compatibility === FILTERS[id.toUpperCase()],
          label: game.i18n.localize(`SETUP.PackageVis${id.capitalize()}`)
        }))
      }
    });
    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  async _loadCategoryData() {
    if ( this._dataLoaded ) return;
    const preview = await game.previewCompatibility(this.#release);
    if ( !preview ) return;
    this.#preview = {
      world: new Map(preview.world.map(w => [w.id, new World(deepClone(w))])),
      system: new Map(preview.system.map(s => [s.id, new System(deepClone(s))])),
      module: new Map(preview.module.map(m => [m.id, new Module(deepClone(m))]))
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async _prepareCategoryData() {
    const FILTERS = CompatibilityChecker.#COMPATIBILITY_FILTERS;
    return PACKAGE_TYPES$1.reduce((categories, type) => {
      const entries = Array.from(this.#preview?.[type].values() ?? []).flatMap(pkg => {
        const {id, title: label, description, url, changelog, availability} = pkg;
        if ( this.#filters.compatibility > FILTERS.NONE ) {
          switch ( availability ) {
            case PACKAGE_AVAILABILITY_CODES.VERIFIED:
              if ( this.#filters.compatibility !== FILTERS.COMPATIBLE ) return [];
              break;
            case PACKAGE_AVAILABILITY_CODES.UNVERIFIED_BUILD:
            case PACKAGE_AVAILABILITY_CODES.UNVERIFIED_GENERATION:
              if ( this.#filters.compatibility !== FILTERS.UNVERIFIED ) return [];
              break;
            default:
              if ( this.#filters.compatibility !== FILTERS.INCOMPATIBLE ) return [];
          }
        }
        const tags = [
          this.#getVersionBadge(availability, pkg, {
            modules: this.#preview.module,
            systems: this.#preview.system
          })
        ];
        if ( type === "world" ) tags.unshift(this.#getSystemBadge(pkg, this.#preview.system.get(pkg.system)));
        return {
          id, label, url, tags, changelog, availability,
          hasLink: type !== "world",
          hint: TextEditor$1.previewHTML(description)
        };
      });
      const label = game.i18n.localize(`PACKAGE.Type.${type}Pl`);
      categories[`${type}s`] = {id: `${type}s`, label, entries};
      return categories;
    }, {});
  }

  /* -------------------------------------------- */

  /** @override */
  _sortCategories(a, b) {
    const categoryOrder = {systems: 0, modules: 1, worlds: 2};
    return categoryOrder[a.id] - categoryOrder[b.id];
  }

  /* -------------------------------------------- */

  /**
   * Determine a version badge for the provided package.
   * @param {number} availability  The availability level.
   * @param {ClientPackage} pkg    The package.
   * @param {object} context
   * @param {Collection<string, Module>} context.modules  The collection of modules to test availability against.
   * @param {Collection<string, System>} context.systems  The collection of systems to test availability against.
   * @returns {PackageCompatibilityBadge|null}
   */
  #getVersionBadge(availability, pkg, {modules, systems}) {
    const CODES = CONST.PACKAGE_AVAILABILITY_CODES;
    const badge = pkg.constructor.getVersionBadge(availability, pkg, {modules, systems});
    if ( !badge ) return badge;
    let level;
    switch ( availability ) {
      case CODES.REQUIRES_CORE_DOWNGRADE:
        level = "INCOMPATIBLE";
        break;
      case CODES.UNVERIFIED_GENERATION:
      case CODES.UNVERIFIED_BUILD:
        level = "RISK";
        break;
      case CODES.VERIFIED:
        level = "COMPATIBLE";
        break;
    }
    if ( level ) {
      const isWorld = pkg.type === "world";
      const system = this.#preview.system.get(pkg.system);
      const i18n = `SETUP.COMPAT.${level}.${isWorld ? "World" : "Latest"}`;
      const verified = isWorld ? system?.compatibility.verified : pkg.compatibility.verified;
      badge.tooltip = game.i18n.format(i18n, { version: this.#release.version, verified });
    }
    return badge;
  }

  /* -------------------------------------------- */

  /**
   * Determine a version badge for a World's System.
   * @param {World} world    The world.
   * @param {System} system  The system.
   * @returns {PackageCompatibilityBadge|null}
   */
  #getSystemBadge(world, system) {
    if ( !system ) return {
      type: "error",
      tooltip: game.i18n.format("SETUP.COMPAT.INCOMPATIBLE.World", { version: this.#release.version }),
      label: world.system,
      icon: "fa-solid fa-file-slash"
    };
    const badge = this.#getVersionBadge(system.availability, system, {
      modules: this.#preview.module,
      systems: this.#preview.system
    });
    if ( !badge ) return badge;
    badge.tooltip = `<p>${foundry.utils.escapeHTML(system.title)}</p><p>${badge.tooltip}</p>`;
    badge.label = system.id;
    return badge;
  }

  /* -------------------------------------------- */

  /**
   * Summarize the results of the compatibility check.
   * @param {{type: string; availability: "compatibile"|"unverified"|"incompatibile"}[]} entries
   * @returns {PreviewCompatibilitySummary[]}
   */
  #prepareSummary(entries) {
    const {compatible, incompatible, warning, unverified} = entries.reduce((obj, entry) => {
      switch ( entry.availability ) {
        case PACKAGE_AVAILABILITY_CODES.VERIFIED: obj.compatible++; break;
        case PACKAGE_AVAILABILITY_CODES.UNVERIFIED_BUILD: obj.unverified++; break;
        case PACKAGE_AVAILABILITY_CODES.UNVERIFIED_GENERATION: obj.warning++; break;
        default: obj.incompatible++;
      }
      return obj;
    }, {compatible: 0, incompatible: 0, warning: 0, unverified: 0});
    return [
      {
        icon: "fa-solid fa-circle-check",
        status: "success",
        count: compatible,
        label: "SETUP.COMPAT.Compatible",
        tooltip: "SETUP.COMPAT.CompatibleTooltip"
      },
      {
        icon: "fa-solid fa-circle-question",
        status: "neutral",
        count: unverified,
        label: "SETUP.COMPAT.Unverified",
        tooltip: "SETUP.COMPAT.UnverifiedTooltip"
      },
      {
        icon: "fa-solid fa-triangle-exclamation",
        status: "warning",
        count: warning,
        label: "SETUP.COMPAT.Warning",
        tooltip: "SETUP.COMPAT.WarningTooltip"
      },
      {
        icon: "fa-solid fa-circle-xmark",
        status: "error",
        count: incompatible,
        label: "SETUP.COMPAT.Incompatible",
        tooltip: "SETUP.COMPAT.IncompatibleTooltip"
      }
    ];
  }

  /* -------------------------------------------- */

  /** @override */
  _getSearchFields(entry) {
    return [
      entry.dataset.packageId ?? "",
      entry.querySelector(".entry-title h3")?.textContent ?? ""
    ];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getHeaderControls() {
    const buttons = super._getHeaderControls();
    buttons.unshift({
      icon: "fa-solid fa-circle-question",
      label: "SETUP.COMPAT.LearnMore",
      onClick: async () => {
        const tour = game.tours.get("core.compatOverview");
        await tour.reset();
        return tour.start();
      }
    });
    return buttons;
  }


  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    const tour = game.tours.get("core.compatOverview");
    if ( tour?.status === foundry.nue.Tour.STATUS.UNSTARTED ) tour.start();
    return super._onRender(context, options);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle toggling package compatibility filtering.
   * @this {CompatibilityChecker}
   * @type {ApplicationClickAction}
   */
  static async #onFilterCompatibility(event) {
    const FILTERS = CompatibilityChecker.#COMPATIBILITY_FILTERS;
    const value = FILTERS[event.target.dataset.filter.toUpperCase()];
    if ( this.#filters.compatibility === value ) this.#filters.compatibility = FILTERS.NONE;
    else this.#filters.compatibility = value;
    this.render();
  }
}

/**
 * @import {ApplicationClickAction, ApplicationConfiguration} from "@client/applications/_types.mjs";
 * @import {ReleaseData} from "@common/config.mjs";
 */

/**
 * @typedef UpdateNotesConfiguration
 * @property {ReleaseData} release
 */

/**
 * An application which displays Foundry Virtual Tabletop release notes to the user during the update progress.
 */
class UpdateNotes extends HandlebarsApplicationMixin(ApplicationV2) {

  /**
   * @param {ApplicationConfiguration & UpdateNotesConfiguration} options
   */
  constructor(options) {
    super(options);
    this.#release = options.release;
  }

  /* ----------------------------------------- */

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "update-notes",
    window: {
      icon: "fa-solid fa-newspaper",
      contentClasses: ["standard-form"]
    },
    position: {
      width: 720
    },
    actions: {
      checkCompatibility: UpdateNotes.#onCheckCompatability,
      createSnapshot: UpdateNotes.#onCreateSnapshot,
      update: UpdateNotes.#onUpdate
    }
  };

  /** @override */
  static PARTS = {
    notes: {
      template: "templates/setup/update-notes.hbs",
      root: true,
      scrollable: ["article"]
    }
  };

  /* ----------------------------------------- */

  /**
   * @type {ReleaseData}
   */
  #release;

  /** @override */
  get title() {
    return game.i18n.format("SETUP.UPDATE_NOTES.Title", {release: this.#release.display});
  }

  /* ----------------------------------------- */

  /**
   * Update the button at the footer of the Update Notes application to reflect the current status of the workflow.
   * @param {{pct: number, step: string}} progressData Data supplied by SetupConfig#_onCoreUpdate
   * @internal
   */
  static updateButton(progressData) {
    const notes = ui.updateNotes;
    if ( !notes?.rendered ) return;
    const button = notes.element.querySelector("button[data-action=update]");
    if ( !button ) return;
    const icon = button.querySelector("i");
    icon.className = progressData.pct < 100 ? "fa-solid fa-spinner fa-pulse" : "fa-solid fa-check";
    const label = button.querySelector("label");
    label.textContent = game.i18n.localize(progressData.step);
  }

  /* ----------------------------------------- */

  /** @override */
  async _prepareContext() {
    const {notes, version, requiresManualInstall} = this.#release;
    const {requires_full_reinstall, requires_generation_reinstall, requires_electron_reinstall} = this.#release.flags;
    const msg = requires_generation_reinstall
      ? "RequiresGenerationReinstall"
      : requires_electron_reinstall
        ? "RequiresElectronReinstall"
        : requires_full_reinstall ? "RequiresFullReinstall" : "";
    return {
      notes, version,
      manual: {
        required: requiresManualInstall,
        msg: `ERROR.${msg}`
      },
      canCheckCompatibility: foundry.utils.isNewerVersion(version, game.version),
      canSnapshot: !game.data.options.noBackups,
      canUpdate: game.data.coreUpdate.canUpdate && !requiresManualInstall
    };
  }

  /* -------------------------------------------- */

  /**
   * Toggle the locked state of the interface.
   * @param {boolean} locked Is the interface locked?
   * @internal
   */
  toggleLock(locked) {
    this.window?.content.querySelectorAll("[data-action]").forEach(el => el.disabled = locked);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Launch that Compatibility Checker.
   * @this {UpdateNotes}
   * @type {ApplicationClickAction}
   */
  static async #onCheckCompatability() {
    await new CompatibilityChecker({release: this.#release}).render({force: true});
  }

  /* -------------------------------------------- */

  /**
   * Create a new snapshot backup.
   * @this {UpdateNotes}
   * @type {ApplicationClickAction}
   */
  static async #onCreateSnapshot() {
    await ui.setupUpdate.onCreateSnapshot();
  }

  /* -------------------------------------------- */

  /**
   * Download and install the new release.
   * @this {UpdateNotes}
   * @type {ApplicationClickAction}
   */
  static async #onUpdate() {
    this.toggleLock(true);
    await foundry.applications.instances.get("setup-update").submit();
  }
}

/**
 * @import {ApplicationFormSubmission} from "@client/applications/_types.mjs";
 */

/**
 * The software update application.
 */
class SetupUpdate extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    tag: "form",
    id: "setup-update",
    classes: ["application", "framed", "standard-form"],
    window: {
      frame: false,
      positioned: false
    },
    form: {
      handler: SetupUpdate.#onSubmit
    }
  };

  /** @override */
  static PARTS = {
    form: {
      template: "templates/setup/setup-update.hbs",
      root: true
    }
  };

  /* -------------------------------------------- */

  /**
   * The current update step
   * @type {"updateCheck"|"updateDownload"}
   */
  #step = "updateCheck";

  /**
   * The currently bound update progress listener
   * @type {Function}
   */
  #onProgress;

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    const canReachInternet = game.data.addresses.remote;
    const couldReachWebsite = game.data.coreUpdate.couldReachWebsite;
    const updateChannels = {...CONST.SOFTWARE_UPDATE_CHANNELS};
    delete updateChannels.prototype;
    return {
      rootId: this.id,
      updateChannels,
      coreVersion: game.version,
      release: game.release,
      coreVersionHint: game.i18n.format("SETUP.CoreVersionHint", {versionDisplay: game.release.display}),
      updateChannel: game.data.options.updateChannel,
      updateChannelHints: Object.entries(updateChannels).reduce((obj, [chan, label]) => {
        obj[chan] = game.i18n.localize(`${label}Hint`);
        return obj;
      }, {}),
      coreUpdate: game.data.coreUpdate.hasUpdate
        ? game.i18n.format("SETUP.UpdateAvailable", game.data.coreUpdate)
        : false,
      canReachInternet: canReachInternet,
      couldReachWebsite: couldReachWebsite,
      slowResponse: game.data.coreUpdate.slowResponse,
      updateButtonEnabled: canReachInternet && couldReachWebsite
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    if ( event.target.name === "updateChannel" ) this.#onChangeChannel(event);
  }

  /* -------------------------------------------- */

  /**
   * When changing the software update channel, reset the state of the update button and "Force Update" checkbox.
   * Clear results from a prior check to ensure that users don't accidentally perform an update for some other channel.
   * @param {Event} event     The select change event
   */
  async #onChangeChannel(event) {
    this.#step = "updateCheck"; // Reset the step
    const button = this.element.querySelector("button[type=submit]");
    button.children[1].textContent = game.i18n.localize("SETUP.UpdateCheckFor");
    const check = this.element.querySelector("input[name=forceUpdate]");
    check.checked = false;
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to update the core VTT software
   * @this {SetupUpdate}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmit(event) {
    const form = this.form;
    const button = form.querySelector("button[type=submit]");

    // Disable the form
    button.disabled = true;
    form.disabled = true;

    // Prepare request data
    const requestData = {
      action: this.#step,
      updateChannel: form.updateChannel.value,
      forceUpdate: form.forceUpdate.checked
    };

    if ( this.#step === "updateDownload" ) {
      const {ACTIONS, STEPS} = SETUP_PACKAGE_PROGRESS;
      const progress = new ProgressReceiver(ACTIONS.UPDATE_CORE, ACTIONS.UPDATE_DOWNLOAD, requestData);
      progress.addEventListener("progress", ({data}) => {
        if ( (data.step !== STEPS.ERROR) && (data.step !== STEPS.COMPLETE) ) UpdateNotes.updateButton(data);
        this.#updateProgressBar(data);
        this.#updateProgressButton(data);
      });
      progress.addEventListener("error", ({data}) => {
        form.disabled = false;
        ui.updateNotes?.close();
        ui.notifications.error(data.error, {localize: true, permanent: true});
      });
      progress.addEventListener("complete", ({data}) => {
        form.disabled = false;
        ui.updateNotes?.close();
        ui.notifications.success(data.message, {localize: true, permanent: true});
      });
      return progress.listen();
    }

    // Submit request
    let response;
    try {
      response = await game.post(requestData, { notify: false });
    } catch(err) {
      button.disabled = false;
      form.disabled = false;
      throw err;
    }

    // Display response info
    if ( response.info || response.warn ) {
      button.disabled = false;
      form.disabled = false;
      return response.info
        ? ui.notifications.info(response.info, {localize: true})
        : ui.notifications.warn(response.warn, {localize: true});
    }

    // Proceed to download step
    if ( this.#step === "updateCheck" ) {

      // Construct the release data
      const releaseData = new foundry.config.ReleaseData(response);
      ui.notifications.info("SETUP.UpdateInfoAvailable", {format: {display: releaseData.display}});

      // Update the button
      const label = button.querySelector("span");
      if ( releaseData.requiresManualInstall ) {
        label.textContent = game.i18n.localize("SETUP.UpdateNewGeneration");
      } else {
        this.#step = "updateDownload";
        button.disabled = !game.data.coreUpdate.canUpdate;
        if ( button.disabled ) {
          label.textContent = game.i18n.localize("SETUP.NoUpdateMode.Label");
          button.ariaLabel = game.i18n.localize("SETUP.NoUpdateMode.Hint");
          button.dataset.tooltip = "";
        } else {
          label.textContent = game.i18n.format("SETUP.UpdateButtonDownload", {display: releaseData.display});
        }
      }

      // Render release notes
      if ( releaseData.notes ) {
        ui.updateNotes = new UpdateNotes({release: releaseData});
        ui.updateNotes.render({force: true});
      }

      // Warn about module disabling
      if ( response.willDisableModules ) {
        ui.notifications.warn("SETUP.UpdateWarningWillDisable", {permanent: true, format: {
          nIncompatible: game.modules.filter(m => m.incompatible).length,
          nModules: game.modules.size
        }});
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a snapshot.
   */
  async onCreateSnapshot() {
    const progress = this.#updateProgressBar.bind(this);
    game._addProgressListener(progress);
    await game.createSnapshot({dialog: true}, {packageList: false});
    game._removeProgressListener(progress);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the locked state of the update interface.
   * @param {boolean} locked Is the interface locked?
   */
  toggleLock(locked) {
    this.element?.querySelectorAll("button").forEach(el => el.disabled = locked);
  }

  /* -------------------------------------------- */

  /**
   * Update the display of an installation progress bar for a particular progress packet
   * @param {object} data The progress update data
   */
  #updateProgressBar(data) {
    const progress = document.getElementById("update-progress");

    // Update Bar
    const bar = progress.firstElementChild;
    bar.style.maxWidth = `${data.pct}%`;

    // Update Label
    const label = bar.firstElementChild;
    label.innerText = [game.i18n.localize(data.message), data.title, `${data.pct}%`].filterJoin(" ");
    const STEPS = SETUP_PACKAGE_PROGRESS.STEPS;
    progress.style.display = [STEPS.COMPLETE, STEPS.ERROR].includes(data.step) ? "" : "initial";
  }

  /* -------------------------------------------- */

  /**
   * Update installation progress for a particular button which triggered the action
   * @param {object} data The progress update data
   * @param {string} data.step
   * @param {string} data.message
   */
  #updateProgressButton({step, message}) {
    const STEPS = SETUP_PACKAGE_PROGRESS.STEPS;
    const button = this.element.querySelector("button[type=submit]");
    button.disabled = (step !== STEPS.ERROR) && (step !== STEPS.COMPLETE);

    // Update Icon
    const icon = button.firstElementChild;
    if ( step === STEPS.ERROR ) icon.className = "fa-solid fa-xmark";
    else if ( step === STEPS.COMPLETE ) icon.className = "fa-solid fa-check";
    else icon.className = "fa-solid fa-spinner fa-pulse";

    // Update label
    const label = icon.nextElementSibling;
    label.textContent = game.i18n.localize(message);
  }
}

/**
 * The User Management setup application.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class UserManagement extends HandlebarsApplicationMixin(ApplicationV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "manage-players",
    classes: ["application", "framed"],
    tag: "form",
    window: {
      frame: false,
      positioned: false
    },
    actions: {
      createUser: UserManagement.#onUserCreate,
      configurePermissions: UserManagement.#onConfigurePermission,
      deleteUser: UserManagement.#onUserDelete,
      showPassword: UserManagement.#onShowPassword
    },
    form: {
      handler: UserManagement.#onSubmitForm
    }
  };

  /**
   * The template path used to render a single user.
   * @type {string}
   */
  static USER_TEMPLATE = "templates/setup/parts/user-management-user.hbs";

  /** @override */
  static PARTS = {
    form: {
      id: "form",
      template: "templates/setup/parts/user-management-form.hbs",
      templates: [this.USER_TEMPLATE],
      scrollable: ["#player-list"]
    }
  };

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    return {
      users: game.users,
      roles: UserManagement.#getRoleLabels(),
      canConfigurePermissions: game.user.hasRole("GAMEMASTER"),
      userTemplate: this.constructor.USER_TEMPLATE,
      passwordString: game.data.passwordString
    };
  }

  /* -------------------------------------------- */

  /**
   * Get a mapping of role IDs to labels that should be displayed
   */
  static #getRoleLabels() {
    return Object.entries(CONST.USER_ROLES).reduce((obj, e) => {
      obj[e[1]] = game.i18n.localize(`USER.Role${e[0].titleCase()}`);
      return obj;
    }, {});
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachPartListeners(partId, htmlElement, options) {
    super._attachPartListeners(partId, htmlElement, options);
    for ( const passwordInput of htmlElement.querySelectorAll("input[type=password]") ) {
      passwordInput.addEventListener("focus", UserManagement.#onPasswordFocus);
      passwordInput.addEventListener("keydown", UserManagement.#onPasswordKeydown);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle focus in and out of the password field.
   * @param {PointerEvent} event     The initiating pointer event
   */
  static #onPasswordFocus(event) {
    event.currentTarget.select();
  }

  /* -------------------------------------------- */

  /**
   * Toggle visibility of the "Show Password" control.
   * @param {KeyboardEvent} event     The initiating keydown event
   */
  static #onPasswordKeydown(event) {
    if ( ["Shift", "Ctrl", "Alt", "Tab"].includes(event.key) ) return;
    const input = event.currentTarget;
    const show = input.parentElement.nextElementSibling;
    show.hidden = false;
  }

  /* -------------------------------------------- */

  /**
   * Reveal the password that is being configured so the user can verify they have typed it correctly.
   * @this {UserManagement}
   * @param {PointerEvent} event
   * @param {HTMLAnchorElement} button
   */
  static #onShowPassword(event, button) {
    const li = button.closest(".player");
    const label = li.querySelector(".password");
    const input = label.firstElementChild;
    input.type = input.type === "password" ? "text" : "password";
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to display the permission configuration app.
   * @this {UserManagement}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} button
   */
  static #onConfigurePermission(event, button) {
    const config = new foundry.applications.apps.PermissionConfig();
    config.render({force: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a new User record in the form.
   * @this {UserManagement}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} button
   */
  static async #onUserCreate(event, button) {

    // Create the new User
    let newPlayerIndex = game.users.size + 1;
    while ( game.users.getName(`Player${newPlayerIndex}` )) { newPlayerIndex++; }
    const user = await User$1.implementation.create({
      name: `Player${newPlayerIndex}`,
      role: CONST.USER_ROLES.PLAYER
    });

    // Render the User's HTML
    const roles = UserManagement.#getRoleLabels();
    const html = await foundry.applications.handlebars.renderTemplate(UserManagement.USER_TEMPLATE, {user, roles});
    const playerList = this.element.querySelector("#player-list");
    playerList.insertAdjacentHTML("beforeend", html);

    // Attach listeners to new password input
    const newInput = playerList.lastElementChild.querySelector("input[type=password]");
    newInput.addEventListener("focus", UserManagement.#onPasswordFocus);
    newInput.addEventListener("keydown", UserManagement.#onPasswordKeydown);
  }

  /* -------------------------------------------- */

  /**
   * Handle user deletion event.
   * @param {PointerEvent} event
   * @param {HTMLAnchorElement} button
   */
  static #onUserDelete(event, button) {
    const li = button.closest(".player");
    const user = game.users.get(li.dataset.userId, {strict: true});

    // Craft a message
    const lines = [
      `<strong>${game.i18n.localize("AreYouSure")}</strong>`,
      `<p>${game.i18n.localize("USERS.DeleteWarning")}</p>`
    ];
    if ( user.isGM ) lines.push(`<p class="notification warning">${game.i18n.localize("USERS.DeleteGMWarning")}</p>`);

    // Render a confirmation dialog
    foundry.applications.api.DialogV2.confirm({
      window: {title: game.i18n.format("USERS.DeleteName", {name: user.name})}, // FIXME: double localization
      content: lines.join("\n"),
      yes: {
        label: "Delete",
        icon: "fa-solid fa-trash",
        callback: async () => {
          await user.delete();
          li.remove();
        }
      },
      no: {
        label: "Cancel"
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle form submission.
   * @this {UserManagement}
   * @param {SubmitEvent} event
   * @param {HTMLFormElement} form
   * @param {foundry.applications.ux.FormDataExtended} formData
   */
  static async #onSubmitForm(event, form, formData) {
    const userData = foundry.utils.expandObject(formData.object).users;
    const updates = Object.entries(userData).reduce((arr, e) => {
      const [id, data] = e;

      // Identify changes
      const user = game.users.get(id);
      const diff = foundry.utils.diffObject(user.toObject(), data);
      if ( data.password === game.data.passwordString ) delete diff.password;
      else diff.password = data.password;

      // Register changes for update
      if ( !foundry.utils.isEmpty(diff) ) {
        diff._id = id;
        arr.push(diff);
      }
      return arr;
    }, []);

    // The World must have at least one Gamemaster
    if ( !Object.values(userData).some(u => u.role === CONST.USER_ROLES.GAMEMASTER) ) {
      return ui.notifications.error("USERS.NoGMError", {localize: true});
    }

    // Update all users and redirect
    try {
      if ( updates.length ) await User$1.updateDocuments(updates, {diff: false});
      ui.notifications.info("USERS.UpdateSuccess", {localize: true});
      return setTimeout(() => window.location.href = foundry.utils.getRoute("game"), 1000);
    }
    catch(err) {
      await this.render();
    }
  }
}

/**
 * An Application that manages the browsing and installation of Packages.
 */
class InstallPackage extends CategoryBrowser {
  constructor({packageType, search=null, ...options}={}) {
    super(options);
    this.#packageType = packageType;
    this.#initialSearch = search;
  }

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "install-package",
    window: {
      icon: "fa-solid fa-download"
    },
    position: {
      width: 920,
      height: 780
    },
    actions: {
      filterVisibility: InstallPackage.#onFilterVisibility,
      installPackage: InstallPackage.#onInstallPackage,
      installUrl: InstallPackage.#onInstallUrl,
      setManifestUrl: InstallPackage.#onSetManifestUrl
    },
    packageList: true,
    initialCategory: "all",
    subtemplates: {
      category: "templates/setup/install-package/category.hbs",
      filters: "templates/setup/install-package/filters.hbs"
    }
  };

  /** @override */
  static PARTS = {
    ...super.PARTS,
    footer: {template: "templates/setup/install-package/footer.hbs"}
  };

  /**
   * A list formatter used for showing keybinding conflicts in tooltips
   * @type {Intl.ListFormat}
   */
  static get listFormatter() {
    return this.#listFormatter ??= game.i18n.getListFormatter();
  }

  static #listFormatter;

  /* -------------------------------------------- */

  /**
   * The list of installable packages
   * @type {ClientPackage[]}
   */
  #packages = [];

  /**
   * The list of Tags available
   * @type {object}
   */
  #tags = {};

  /**
   * The type of package being installed, a value in PACKAGE_TYPES
   * @type {PackageType}
   */
  #packageType;

  /**
   * The current package visibility filter that is applied
   * @type {"all"|"inst"|"unin"}
   */
  #visibility = "all";

  /**
   * An initial provided search filter value.
   * @type {string|null}
   */
  #initialSearch = null;

  /**
   * Does the tabs configuration need to be reset due to newly-loaded categories?
   * @type {boolean}
   */
  #resetTabs = false;

  /** @override */
  get title() {
    return game.i18n.localize(`SETUP.Install${this.#packageType.titleCase()}`);
  }

  /** @override */
  get _dataLoaded() {
    return game.packageCache[this.#packageType].state > CACHE_STATES.COLD;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    options.resetTabs = this.#resetTabs;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    return Object.assign(context, {
      loading: this._dataLoaded ? null : "SETUP.PackagesLoading",
      packageType: this.#packageType,
      filters: [
        {id: "inst", active: this.#visibility === "inst", label: game.i18n.localize("SETUP.PackageVisInst")},
        {id: "unin", active: this.#visibility === "unin", label: game.i18n.localize("SETUP.PackageVisUnin")},
        {id: "all", active: this.#visibility === "all", label: game.i18n.localize("SETUP.PackageVisAll")}
      ],
      filtersLabel: game.i18n.localize(`SETUP.${this.#packageType.titleCase()}Filter`)
    });
  }

  /* -------------------------------------------- */

  /** @overload */
  async _loadCategoryData() {
    this.#packages = [];
    this.#tags = {};
    await game.warmPackages({type: this.#packageType});
    this.#resetTabs = true;
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareCategoryData() {
    if ( !this.#packages.length || !Object.keys(this.#tags).length ) {
      const {packages, tags} = InstallPackage.getTaggedPackages(this.#packageType);
      this.#packages = packages;
      this.#tags = tags;
    }
    return Object.entries(this.#tags).reduce((categories, [id, tag]) => {
      const packages = id === "all" ? this.#packages : this.#packages.filter(p => p.tags.includes(id));
      const showAll = this.#visibility === "all";
      const installedOnly = this.#visibility === "inst";
      const entries = showAll ? packages : packages.filter(p => installedOnly === p.installed);
      categories[id] = {id, label: tag.label, entries};
      return categories;
    }, {});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _sortCategories(a, b) {
    return ((b.id === "all") - (a.id === "all"))
      || ((b.id === "premium") - (a.id === "premium"))
      || ((b.id === "exclusive") - (a.id === "exclusive"))
      || super._sortCategories(a, b);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    // Assign an initial search value
    if ( this.#initialSearch && this._dataLoaded ) {
      const input = this.element.querySelector("input[type=search]");
      input.value = this.#initialSearch.trim();
      this.#initialSearch = null;
      this.search(input.value);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _tearDown(options) {
    super._tearDown(options);
    this.#packages = [];
    this.#tags = {};
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static async #onSetManifestUrl(event) {
    const formGroup = event.target.closest(".form-group");
    const button = formGroup.querySelector("button[data-action=installPackage]");
    if ( !button || button.disabled ) return;
    const manifestUrl = button.dataset.manifest?.trim() ?? "";
    document.getElementById(`${this.id}-manifestUrl`).value = manifestUrl;
  }

  /* -------------------------------------------- */

  /**
   * Handle left-click events to filter to a certain visibility state.
   * @this {InstallPackage}
   * @type {ApplicationClickAction}
   */
  static async #onFilterVisibility(event) {
    this.#visibility = event.target.dataset.filter;
    await this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle a left-click event on the package "Install" button.
   * @this {InstallPackage}
   * @type {ApplicationClickAction}
   */
  static async #onInstallPackage(_event, button) {
    button.disabled = true;
    const manifest = button.dataset.manifest;
    if ( !manifest ) return;
    this.#packages = [];
    this.#tags = {};
    await game.installPackage({type: this.#packageType, manifest});
  }

  /* -------------------------------------------- */

  /**
   * Handle a left-click event on the button to install by manifest URL.
   * @this {InstallPackage}
   * @type {ApplicationClickAction}
   */
  static async #onInstallUrl(_event, button) {
    const input = button.previousElementSibling;
    input.disabled = button.disabled = true;
    const url = input.value.trim();
    if ( !url) {
      input.disabled = button.disabled = false;
      return;
    }
    this.#packages = [];
    this.#tags = {};

    // Prompt for confirmation
    let allowed;
    try {
      allowed = await this.#confirmInstallUrl(url);
    }
    catch(err) {
      ui.notifications.error(err.message);
      allowed = false;
    }

    // Proceed with installation
    try {
      if ( allowed ) await game.installPackage({type: this.#packageType, manifest: url, force: true});
    } finally {
      input.value = "";
      input.disabled = button.disabled = false;
    }
  }

  /* -------------------------------------------- */

  /**
   * Confirm that the manifest URL is of the right package type and that the user wishes to install it even if the
   * package appears to be incompatible.
   * @param {string} url              The manifest URL which is desired for installation
   * @returns {Promise<boolean>}      A boolean confirmation that installation can proceed
   * @throws {Error}                  An error if installation cannot proceed
   */
  async #confirmInstallUrl(url) {
    const packageType = game.i18n.localize(`PACKAGE.Type.${this.#packageType}`);

    // Sanity check the manifest URL is named "{packageType}.json" or otherwise ambiguously named
    // FIXME https://github.com/foundryvtt/foundryvtt/issues/12707
    const parsedUrl = URL.parseSafe(url);
    if ( parsedUrl && CONST.PACKAGE_TYPES.some(t => parsedUrl.pathname.endsWith(`/${t}.json`))
      && !parsedUrl.pathname.endsWith(`/${this.#packageType}.json`) ) {
      throw new Error(game.i18n.format("SETUP.INSTALL_PACKAGE.ManifestWrongPackageType", {packageType}));
    }

    // Load package manifest and compatibility data
    const packageCls = foundry.packages.PACKAGE_TYPES[this.#packageType];
    const pkg = await packageCls.fromRemoteManifest(url, {strict: true});

    // Make the user confirm if the package is incompatible with the core software version
    if ( pkg.incompatibleWithCoreVersion ) {
      return DialogV2.confirm({
        window: {
          title: "SETUP.INSTALL_PACKAGE.ManifestIncompatible.Title",
          icon: "fa-solid fa-triangle-exclamation"
        },
        content: [
          game.i18n.format("SETUP.INSTALL_PACKAGE.ManifestIncompatible.Warning.0", {packageType}),
          game.i18n.localize("SETUP.INSTALL_PACKAGE.ManifestIncompatible.Warning.1")
        ].map(l => `<p>${l}</p>`).join("\n"),
        no: {default: true}
      });
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Organize package data and cache it to the application
   * @param {string} type  The type of packages being retrieved
   * @returns {object}     The retrieved or cached packages
   */
  static getTaggedPackages(type) {
    const packages = [];
    const unorderedTags = {
      all: {label: game.i18n.localize("SETUP.PackageVisAll"), [type]: true}
    };
    const CODES = CONST.PACKAGE_AVAILABILITY_CODES;
    for ( const pack of game.packageCache[type].packages.values() ) {

      // Skip packages that require downgrading or upgrading to an unstable version
      const availability = pack.availability;
      if ( CODES.REQUIRES_CORE_UPGRADE_UNSTABLE === availability ) continue;

      const requiresUpgrade = availability === CODES.REQUIRES_CORE_UPGRADE_STABLE;
      const requiresDowngrade = availability === CODES.REQUIRES_CORE_DOWNGRADE;
      const data = {
        id: pack.id,
        label: pack.title,
        version: pack.version,
        hint: pack.description,
        installed: game[`${type}s`].has(pack.id),
        installable: !requiresUpgrade && !requiresDowngrade,
        manifest: pack.manifest,
        protected: pack.protected,
        owned: pack.owned,
        author: InstallPackage.listFormatter.format(Array.from(pack.authors).map(a => a.name)),
        url: pack.url,
        changelog: pack.changelog,
        tags: pack.tags.map(([tag, label]) => {
          unorderedTags[tag] ??= {label, [type]: true};
          return tag;
        }),
        button: {label: "", tooltip: ""}
      };

      // Possibly modify package installability
      if ( availability === CODES.REQUIRES_CORE_UPGRADE_STABLE ) {
        data.button.label = game.i18n.localize("SETUP.PackageRequiresUpgradeLabel");
        data.button.tooltip = game.i18n.format("SETUP.PackageRequiresUpgradeHint", { vmin: pack.compatibility.minimum });
      }
      else if ( availability === CODES.REQUIRES_CORE_DOWNGRADE ) {
        data.button.label = game.i18n.localize("SETUP.PackageRequiresDowngradeLabel");
        data.button.tooltip = game.i18n.format("SETUP.PackageRequiresDowngradeHint", { vmax: pack.compatibility.maximum });
      }
      if ( pack.protected ) {
        if ( !pack.owned ) {
          data.installable = false;
          data.button.label = game.i18n.localize("SETUP.PackageNotOwned");
        }
        data.tags.push("premium");
        unorderedTags.premium ??= {label: game.i18n.localize("SETUP.PremiumContent"), [type]: true};
      }
      else if ( pack.exclusive ) {
        data.tags.push("exclusive");
        unorderedTags.exclusive ??= {label: game.i18n.localize("SETUP.ExclusiveContent"), [type]: true};
      }

      packages.push(data);
    }

    // Organize category tags
    const orderedTags = Object.entries(unorderedTags)
      .sort(([, a], [, b]) => a.label.localeCompare(b.label, game.i18n.lang))
      .reduce((obj, [key, data]) => {
        obj[key] = data;
        return obj;
      }, {});

    return {packages, tags: orderedTags};
  }
}

/**
 * A class responsible for managing snapshot progress events that include a side-track for individual backup progress
 * events.
 */
class SnapshotProgressReceiver extends ProgressReceiver {
  /**
   * @param {string} operationId   A unique identifier for the operation.
   * @param {string} action        The operation action.
   * @param {string} backupAction  The individual backup operation action.
   * @param {object} [context]     Additional context to send with the request.
   * @param {ProgressReceiverOptions} [options]
   */
  constructor(operationId, action, backupAction, context={}, options={}) {
    super(operationId, action, context, options);
    this.#backupAction = backupAction;
  }

  /**
   * The individual backup operation action to listen to.
   * @type {string}
   */
  #backupAction;

  /**
   * The passive backup progress listener.
   * @type {Function}
   */
  #backupProgressListener = this.#onBackupProgress.bind(this);

  /* -------------------------------------------- */

  /**
   * Handle progress ticks on individual backup operations.
   * @param {ProgressReceiverPacket} data  Progress event data.
   */
  #onBackupProgress(data) {
    if ( data.action === this.#backupAction ) ui.setupPackages?.onProgress(data);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onComplete(data) {
    game._removeProgressListener(this.#backupProgressListener);
    super._onComplete(data);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onError(data) {
    for ( const id of ui.setupPackages?.progressBars.keys() ?? [] ) ui.setupPackages.removeProgressBar(id);
    game._removeProgressListener(this.#backupProgressListener);
    super._onError(data);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  listen() {
    game._addProgressListener(this.#backupProgressListener);
    return super.listen();
  }
}

/**
 * @typedef SnapshotOperationDialogOptions
 * @property {boolean} [warning]            Whether the dialog contains a warning.
 * @property {boolean} [note]               Whether the dialog should prompt the user for a note.
 * @property {boolean} [confirmCode]        Whether the dialog should prompt the user for a confirmation code.
 * @property {boolean} [packageList]        Whether the dialog should include a list of currently-installed packages.
 * @property {SnapshotData} [snapshotData]  A snapshot associated with this operation.
 * @property {string} diskSpaceAction       The action value to send to /setup to request disk space information for
 *                                          this operation.
 * @property {string} message               The dialog message.
 * @property {string} [confirm]             An additional confirmation message.
 * @property {(result: { confirm: boolean; note?: string }) => void} resolve    The resolve function.
 */

/**
 * An application that prompts the user to confirm a snapshot operation.
 */
class SnapshotOperationDialog extends ApplicationV2 {
  /**
   * @param {Partial<ApplicationConfiguration & SnapshotOperationDialogOptions>} [options]
   */
  constructor(options) {
    super(options);
    if ( this.options.confirmCode ) this.#confirmCode = (Math.random() + 1).toString(36).substring(7, 11);
    this.#checkDiskSpace();
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["dialog", "snapshot-dialog"],
    tag: "dialog",
    form: {
      submitOnChange: false,
      closeOnSubmit: true,
      handler: SnapshotOperationDialog.#onSubmit
    },
    window: {
      contentTag: "form",
      contentClasses: ["dialog-form", "standard-form"],
      frame: true,
      positioned: true,
      minimizable: false
    },
    position: { width: 480 },
    actions: {
      cancel: SnapshotOperationDialog.#onCancel
    },
    warning: false,
    note: false,
    confirmCode: false,
    packageList: false,
    confirm: ""
  };

  /* -------------------------------------------- */

  /**
   * The code the user must enter to confirm the operation.
   * @type {string}
   */
  #confirmCode;

  /* -------------------------------------------- */

  /**
   * The disk space requirements for the operation.
   * @type {{required: string, available: string, enough: boolean}}
   */
  #diskSpace;

  /* -------------------------------------------- */

  /**
   * Request disk space information for the given operation from the /setup endpoint.
   */
  async #checkDiskSpace() {
    const data = { action: this.options.diskSpaceAction };
    if ( this.options.snapshotData ) data.snapshotData = this.options.snapshotData;
    let required = 0;
    let available = 0;
    try { ({required, available} = await game.post(data, {timeoutMs: null})); } catch {}    this.#diskSpace = {
      required: foundry.utils.formatFileSize(required, { decimalPlaces: 0 }),
      available: foundry.utils.formatFileSize(available, { decimalPlaces: 0 }),
      enough: available > required
    };
    this.render({ autofocus: true });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options = super._initializeApplicationOptions(options);
    if ( !options.diskSpaceAction ) throw new Error("You must provide options.diskSpaceAction");
    if ( !options.message ) throw new Error("You must provide options.message");
    if ( !options.resolve ) throw new Error("You must provide options.resolve");
    return options;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _renderHTML(_context, _options) {
    const confirmLabel = !this.#diskSpace ? "SETUP.BACKUPS.DiskSpaceChecking" : (this.#diskSpace.enough
      ? "Yes" : "SETUP.BACKUPS.DiskSpaceInsufficient");
    const confirmIcon = this.#diskSpace
      ? this.#diskSpace.enough ? "fa-check" : "fa-xmark"
      : "fa-spinner fa-spin fa-spin-pulse";
    return `
      <div class="dialog-content standard-form">${this.#buildContent()}</div>
      <footer class="form-footer">
        <button type="submit" class="yes default bright" ${this.#diskSpace?.enough ? "" : "disabled"} autofocus>
          <i class="fa-solid ${confirmIcon}"></i>
          <span>${game.i18n.localize(confirmLabel)}</span>
        </button>
        <button type="button" data-action="cancel" class="no">
          <i class="fa-solid fa-xmark"></i><span>${game.i18n.localize("No")}</span>
        </button>
      </footer>
    `;
  }

  /* -------------------------------------------- */

  /** @override */
  _replaceHTML(result, content, _options) {
    content.innerHTML = result;
  }

  /* -------------------------------------------- */

  /** @override */
  async _onFirstRender(_context, _options) {
    this.element.showModal();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    if ( options.autofocus ) this.element.querySelector("[autofocus]").focus();
  }

  /* -------------------------------------------- */

  /**
   * Build the content for this dialog based on the passed options.
   * @returns {string}
   */
  #buildContent() {
    const unchanged = this.#getUnchangedPackageCount();
    return `
      <p ${this.options.warning ? 'class="notification warning"' : ""}>${game.i18n.localize(this.options.message)}</p>
      ${this.options.packageList ? `
        <p>${game.i18n.localize("SETUP.BACKUPS.CreateSnapshotPackageList")}</p>
        <ul>
          <li>${game.i18n.format("SETUP.BACKUPS.WorldCount", { count: game.worlds.size })}</li>
          <li>${game.i18n.format("SETUP.BACKUPS.ModuleCount", { count: game.modules.size })}</li>
          <li>${game.i18n.format("SETUP.BACKUPS.SystemCount", { count: game.systems.size })}</li>
        </ul>
      ` : ""}
      ${unchanged ? `
        <p>${game.i18n.format(`SETUP.BACKUPS.RestoreSnapshotUnchangedPackages${unchanged === 1 ? "" : "Pl"}`, { count: unchanged })}</p>
      ` : ""}
      <div class="disk-space">
        <em>${game.i18n.localize("SETUP.BACKUPS.DiskSpace")}:</em>
        <span>
          ${this.#diskSpace ? `
            ${game.i18n.format("SETUP.BACKUPS.DiskSpaceRequired", { required: this.#diskSpace.required })}
            &sol;
            ${game.i18n.format("SETUP.BACKUPS.DiskSpaceAvailable", { available: this.#diskSpace.available })}
          ` : '<i class="fa-solid fa-spinner fa-spin fa-spin-pulse"></i>'}
        </span>
      </div>
      ${this.options.note ? `
        <p>${game.i18n.localize("SETUP.BACKUPS.NoteHint")}</p>
        <input class="dark" type="text" autocomplete="off" name="note">
      ` : ""}
      ${this.#confirmCode ? `
        <p>${game.i18n.localize("SETUP.WorldDeleteConfirmCode")}</p>
        <p id="confirm-code"><span class="reference">${this.#confirmCode}</span></p>
        <input id="delete-confirm" name="code" class="dark" type="text" autocomplete="off" autofocus
               placeholder="${this.#confirmCode}"
               aria-label="${game.i18n.format("SETUP.ConfirmCodeLabel", { code: this.#confirmCode })}">
      ` : ""}
      ${this.options.confirm ? `<p>${game.i18n.localize(this.options.confirm)}</p>` : ""}
    `;
  }

  /* -------------------------------------------- */

  /**
   * Determine the number of installed packages that are not included in the snapshot and will not be affected by the
   * snapshot restoration.
   * @returns {number}
   */
  #getUnchangedPackageCount() {
    if ( !this.options.snapshotData ) return 0;
    const packages = { world: new Set(), module: new Set(), system: new Set() };
    for ( const backupId of this.options.snapshotData.backups ) {
      const [type, id] = backupId.split(".");
      packages[type].add(id);
    }
    let count = 0;
    for ( const [type, cls] of Object.entries(foundry.packages.PACKAGE_TYPES) ) {
      for ( const pkg of game[cls.collection] ) {
        if ( !packages[type].has(pkg.id) ) count++;
      }
    }
    return count;
  }

  /* -------------------------------------------- */
  /*  Closing                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    if ( !options.submitted ) this.options.resolve({ confirm: false });
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("keydown", this.#onKeyDown.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle keypresses within the dialog.
   * @param {KeyboardEvent} event  The triggering event.
   */
  #onKeyDown(event) {
    // Capture Escape keypresses for dialogs to ensure that close is called properly.
    if ( event.key === "Escape" ) {
      event.preventDefault(); // Prevent default browser dialog dismiss behavior.
      event.stopPropagation();
      this.close();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle form submissing.
   * @this {SnapshotOperationDialog}
   * @param {SubmitEvent} _event          The form submission event.
   * @param {HTMLFormElement} _form       The form element that was submitted.
   * @param {FormDataExtended} _formData  Processed data for the submitted form.
   */
  static #onSubmit(_event, _form, _formData) {
    if ( this.options.confirmCode ) {
      const code = this.element.querySelector('input[name="code"]').value;
      if ( code !== this.#confirmCode ) {
        ui.notifications.error("SETUP.PackageDeleteWorldConfirm", { localize: true });
        return this.options.resolve({ confirm: false });
      }
    }
    if ( this.options.note ) return this.options.resolve({ confirm: true, note: this.element.querySelector('input[name="note"]').value });
    return this.options.resolve({ confirm: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle cancelling.
   * @this {SnapshotOperationDialog}
   * @param {PointerEvent} event
   */
  static #onCancel(event) {
    event.preventDefault();
    event.stopPropagation();
    this.close();
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Show the snapshot operation dialog.
   * @param {Partial<ApplicationConfiguration & Omit<SnapshotOperationDialogOptions, "resolve">>} options
   * @returns {Promise<{ confirm: boolean; note?: string }>}
   */
  static async show(options={}) {
    return new Promise(resolve => {
      new this({...options, resolve}).render({force: true});
    });
  }
}

/**
 * @import {Module, System, World} from "@client/packages/_module.mjs"
 * @import {PackageManifestData} from "@common/packages/_types.mjs"
 * @import {BackupsListing, PreviewCompatibilityDescriptor} from "./_types.mjs"
 */

/**
 * A library of package management commands which are used by various interfaces around the software.
 */
class Setup extends Game {
  constructor(...args) {
    super(...args);
    Object.defineProperty(this, "serverSettings", {value: new ServerSettings(this.data.options)});
  }

  /**
   * A cached object of retrieved packages from the web server
   * @type {{
   *   world: {packages: Map<string, World>, state: CACHE_STATES},
   *   system: {packages: Map<string, System>, state: CACHE_STATES},
   *   module: {packages: Map<string, Module>, state: CACHE_STATES}
   * }}
   */
  packageCache = {
    world: { packages: new Map(), state: CACHE_STATES.COLD },
    module: { packages: new Map(), state: CACHE_STATES.COLD },
    system: { packages: new Map(), state: CACHE_STATES.COLD }
  };

  /**
   * A cached list of the user's backups.
   * @type {BackupsListing|null}
   */
  backupsCache = null;

  /**
   * Store a reference to any in-flight request to list backups.
   * @type {Promise|null}
   */
  static #listingBackups = null;

  /**
   * Cached compatibility preview data.
   * @type {PreviewCompatibilityDescriptor|null}
   */
  static #compatibilityPreview = null;

  /**
   * Store a reference to any in-flight request to check package compatibility.
   * @type {Promise<unknown>|null}
   */
  static #checkingCompatibility = null;

  /**
   * A reference to the setup URL used under the current route prefix, if any
   * @type {string}
   */
  static get setupURL() {
    return foundry.utils.getRoute("setup");
  }

  /**
   * Track state when polling for World activity in the auth view.
   * @type {{polling: Promise<unknown>, last: number}}
   */
  #activePollState = {};

  /* -------------------------------------------- */

  /**
   * Register core game settings
   * @override
   */
  registerSettings() {
    super.registerSettings();
    this.settings.register("core", "declinedManifestUpgrades", {
      scope: "client",
      config: false,
      type: Object,
      default: {}
    });
    this.settings.register("core", FAVORITE_PACKAGES_SETTING, {
      scope: "client",
      config: false,
      type: Object,
      default: {worlds: [], systems: [], modules: []}
    });
    this.settings.register("core", "setupViewModes", {
      scope: "client",
      config: false,
      type: Object,
      default: {worlds: "GALLERY", systems: "GALLERY", modules: "TILES"}
    });
  }

  /* -------------------------------------------- */

  /** @override */
  setupPackages(data) {
    super.setupPackages(data);
    const Collection = foundry.utils.Collection;
    if ( data.worlds ) {
      this.worlds = new Collection(data.worlds.map(m => [m.id, new foundry.packages.World(m)]));
    }
    if ( data.systems ) {
      this.systems = new Collection(data.systems.map(m => [m.id, new foundry.packages.System(m)]));
    }
  }

  /* -------------------------------------------- */

  /** @override */
  static async getData(socket, view) {
    let req;
    switch (view) {
      case "auth": case "license": req = "getAuthData"; break;
      case "join": req = "getJoinData"; break;
      case "players": req = "getPlayersData"; break;
      case "setup": req = "getSetupData"; break;
      case "update": req = "getUpdateData"; break;
    }
    return new Promise(resolve => {
      socket.emit(req, resolve);
    });
  }

  /* -------------------------------------------- */
  /*  View Handlers                               */
  /* -------------------------------------------- */

  /** @override */
  async _initializeView() {
    ui.notifications = new Notifications();
    this.configureCursors();
    switch (this.view) {
      case "auth":
        return this.#authView();
      case "license":
        return this.#licenseView();
      case "setup":
        return this.#setupView();
      case "players":
        return this.#playersView();
      case "join":
        return this.#joinView();
      case "update":
        return this.#updateView();
      default:
        throw new Error(`Unknown view URL ${this.view} provided`);
    }
  }

  /* -------------------------------------------- */

  /**
   * The application view which displays the End User License Agreement (EULA).
   */
  #licenseView() {

    // Render EULA
    const form = document.getElementById("license-key");
    if ( !form ) {
      new EULA().render(true);
      return;
    }

    // Allow right-clicks specifically in the key field
    const input = document.getElementById("key");
    input?.addEventListener("contextmenu", ev => ev.stopPropagation());
  }

  /* -------------------------------------------- */

  /**
   * The application view which displays the admin authentication application.
   */
  #authView() {
    if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license");
    new SetupAuthenticationForm().render(true);
    this.#pollActive();
  }

  /* -------------------------------------------- */

  /**
   * Poll the server to see if a World has become active, and automatically refresh if so.
   * @returns {Promise<void>}
   */
  async #pollActive() {
    const poll = () => {
      const status = foundry.utils.getRoute("/api/status");
      this.#activePollState.last = Date.now();
      this.#activePollState.polling = foundry.utils.fetchJsonWithTimeout(status, {}, {
        timeoutMs: 10000
      }).catch(() => {});
      this.#pollActive();
    };

    const { polling, last=0 } = this.#activePollState;
    if ( polling ) {
      const response = await polling;
      if ( response?.active ) {
        window.location.reload();
        return;
      }
    }

    const delta = Date.now() - last - 4000;
    if ( delta > 0 ) poll(); // Schedule now.
    else setTimeout(poll, delta * -1);
  }

  /* -------------------------------------------- */

  /**
   * The application view which displays the application Setup and Configuration.
   */
  async #setupView() {
    if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license");
    this.issueCount = this.#logPackageWarnings(this.data.packageWarnings);

    // Data Architecture
    this.#activateSocketListeners();

    // Interface Elements
    // noinspection ES6MissingAwait
    FontConfig._loadFonts();

    /**
     * The setup menu application.
     * @type {SetupMenu}
     */
    ui.setupMenu = new SetupMenu();

    /**
     * Setup package management.
     * @type {SetupPackages}
     */
    ui.setupPackages = new SetupPackages();

    /**
     * The Setup screen sidebar.
     * @type {SetupSidebar}
     */
    ui.setupSidebar = new SetupSidebar();

    // Render applications
    await Promise.allSettled([
      ui.setupMenu.render({force: true}),
      ui.setupPackages.render({force: true}),
      ui.setupSidebar.render({force: true})
    ]);

    // User Interaction
    this.initializeKeyboard();
    foundry.applications.ux.ContextMenu.implementation.eventListeners();
    ProseMirror.ProseMirrorMenu.eventListeners();

    // Initial prompts
    await ServerSettingsConfig.telemetryRequestDialog();
    if ( !this.data.options.noBackups ) {
      const tour = this.tours.get("core.backupsOverview");
      if ( tour?.status === foundry.nue.Tour.STATUS.UNSTARTED ) tour.start();
      // noinspection ES6MissingAwait
      this.listBackups();
    }
  }

  /* -------------------------------------------- */

  /**
   * Log server-provided package warnings so that they are discoverable on the client-side.
   * @param {object} packageWarnings         An object of package warnings and errors by package ID.
   * @returns {{error: number, warning: number, total: number}}  A count of the number of warnings and errors
   */
  #logPackageWarnings(packageWarnings) {
    const counts = {
      error: 0,
      warning: 0
    };
    for ( const pkg of Object.values(packageWarnings) ) {
      for ( const error of pkg.error ) {
        counts.error++;
        console.error(`[${pkg.id}] ${error}`);
      }
      for ( const warning of pkg.warning ) {
        counts.warning++;
        console.warn(`[${pkg.id}] ${warning}`);
      }
    }

    // Return total count
    counts.total = counts.error + counts.warning;
    return counts;
  }

  /* -------------------------------------------- */

  /**
   * The application view which displays the User Configuration.
   */
  #playersView() {
    if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license");
    this.users = new foundry.documents.collections.Users(this.data.users);
    this.collections.set("User", this.users);
    this.collections.set("Setting", this.settings.storage.get("world"));

    // Render applications
    (new UserManagement()).render({force: true});

    // Game is ready for use
    this.ready = true;
  }

  /* -------------------------------------------- */

  /**
   * The application view which displays the Game join and authentication screen.
   */
  #joinView() {
    if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license");

    // Configure Join view data
    this.users = new foundry.documents.collections.Users(this.data.users);
    this.collections.set("User", this.users);

    // Activate Join view socket listeners
    foundry.documents.collections.Users._activateSocketListeners(this.socket);

    // Render Join view applications
    ui.join = new JoinGameForm();
    ui.join.render({force: true});
  }

  /* -------------------------------------------- */

  /**
   * The application update view which allows for updating the Foundry Virtual Tabletop software.
   */
  #updateView() {
    ui.setupUpdate = new SetupUpdate();
    ui.setupUpdate.render({force: true});
    this.#activateSocketListeners();
  }

  /* -------------------------------------------- */
  /*  Package Management                          */
  /* -------------------------------------------- */

  /**
   * Check with the server whether a package of a certain type may be installed or updated.
   * @param {object} options    Options which affect how the check is performed
   * @param {string} options.type       The package type to check
   * @param {string} options.id         The package id to check
   * @param {string} [options.manifest] The manifest URL to check
   * @param {number} [options.timeout]  A timeout in milliseconds after which the check will fail
   * @returns {Promise<PackageManifestData>} The resulting manifest if an update is available
   */
  async checkPackage({type="module", id, manifest, timeout=20000}={}) {
    return this.post({action: "checkPackage", strict: false, type, id, manifest}, {notify: false, timeoutMs: timeout});
  }

  /* -------------------------------------------- */

  /**
   * Prepares the cache of available and owned packages
   * @param {object} options          Options which configure how the cache is warmed
   * @param {string} options.type     The type of package being cached
   * @returns {Promise<void>}
   */
  async warmPackages({type="system"}={}) {
    if ( this.packageCache[type].state > CACHE_STATES.COLD ) return;
    this.packageCache[type].state = CACHE_STATES.WARMING;
    await this.getPackages({type});
    this.packageCache[type].state = CACHE_STATES.WARMED;
  }

  /* -------------------------------------------- */

  /**
   * Get a Map of available packages of a given type which may be installed
   * @param {string} type
   * @returns {Promise<Map<string, ClientPackage>>}
   */
  async getPackages({type="system"}={}) {

    // Return from cache
    if ( this.packageCache[type].packages?.size > 0 ) return this.packageCache[type].packages;

    // Request from server
    const packages = new Map();
    let response;
    try {
      response = await this.post({action: "getPackages", type: type});
    }
    catch(err) {
      ui.notifications.error(err.message, {localize: true});
      return packages;
    }

    // Populate the cache
    response.packages.forEach(p => {
      const pkg = new foundry.packages.PACKAGE_TYPES[type](p);
      packages.set(p.id, pkg);
    });
    this.packageCache[type].packages = packages;
    this.packageCache[type].owned = response.owned;
    return packages;
  }

  /* -------------------------------------------- */

  /**
   * List the user's current backups.
   * @returns {Promise<BackupsListing|null>}
   */
  async listBackups() {
    let backups = null;
    try {
      if ( !Setup.#listingBackups ) Setup.#listingBackups = this.post({ action: "listBackups" });
      backups = await Setup.#listingBackups;
    } catch( err ) {
      ui.notifications.error(err.message, { localize: true });
    }
    this.backupsCache = backups;
    Setup.#listingBackups = null;
    return backups;
  }

  /* -------------------------------------------- */

  /**
   * Open the Package Browser application
   * @param {string} packageType        The type of package being installed, in ["module", "system", "world"]
   * @param {object} [options]          Options forwarded to InstallPackage
   * @returns {Promise<void>}
   */
  async browsePackages(packageType, options={}) {
    ui.installPackage = new InstallPackage({packageType, ...options});
    await ui.installPackage.render({force: true});
  }

  /* -------------------------------------------- */

  /**
   * Install a Package
   * @param {object} options              Options which affect how the package is installed
   * @param {string} options.type           The type of package being installed, in ["module", "system", "world"]
   * @param {string} options.id             The package id
   * @param {string} options.manifest       The package manifest URL
   * @param {boolean} [options.notify=true] Display a notification toast?
   * @param {boolean} [options.force=false] Attempt to install even if a compatibility check would otherwise prevent it?
   * @returns {Promise<foundry.packages.BasePackage>} A Promise which resolves to the installed package
   */
  async installPackage({type="module", id, manifest, notify=true, force=false}={}) {
    const {ACTIONS} = CONST.SETUP_PACKAGE_PROGRESS;

    // Install package using the Progress Receiver
    const progress = new ProgressReceiver(manifest, ACTIONS.INSTALL_PKG, {type, id, manifest, force}, {notify: false});
    let response;
    try {
      response = await progress.listen();
    } catch(err) {
      response = err;
    }

    // Handle failed installation
    if ( response instanceof Error ) {
      if ( response.context?.packageWarnings ) {
        ui.notifications.error(response.message, {localize: true});
        this.#logPackageWarnings(response.context.packageWarnings);
      } else if ( notify ) {
        const [message] = response.message.split("\n");
        ui.notifications.error("SETUP.InstallFailure", {console: false, format: {message}});
      }
      ui.setupPackages?.render();
      throw response;
    }

    // Handle successful installation
    const pkg = new foundry.packages.PACKAGE_TYPES[type](response.pkg);
    if ( notify ) {
      ui.notifications.info("SETUP.InstallSuccess", {format: {
        id: pkg.id,
        type: this.i18n.localize(`PACKAGE.Type.${type}`)
      }});
    }

    // Trigger dependency installation (asynchronously)
    if ( pkg.relationships ) {
      // noinspection ES6MissingAwait
      this.installDependencies(pkg, { notify });
    }

    // Add the created package to game data
    pkg.install();
    await this.reload();
    return pkg;
  }

  /* -------------------------------------------- */

  /**
   * Install a set of dependency modules which are required by an installed package
   * @param {ClientPackage} pkg   The package which was installed that requested dependencies
   * @param {object} options      Options which modify dependency installation, forwarded to installPackage
   * @returns {Promise<void>}
   */
  async installDependencies(pkg, options={}) {
    const dependencyChecks = new Map();

    // Check required Relationships
    for ( const d of pkg.relationships?.requires ?? [] ) {
      await this.#checkDependency(d, dependencyChecks);
    }
    // Check recommended Relationships
    for ( const d of pkg.relationships?.recommends ?? [] ) {
      await this.#checkDependency(d, dependencyChecks, false);
    }

    const uninstalled = Array.from(dependencyChecks.values()).filter(d => d.installNeeded);
    if ( !uninstalled.length ) return;

    // Prepare data for rendering
    const categories = uninstalled.reduce((obj, dep) => {
      if ( dep.canInstall && dep.required ) obj.canInstallRequired.push(dep);
      if ( dep.canInstall && !dep.required ) obj.canInstallOptional.push(dep);
      if ( !dep.canInstall && dep.required ) obj.cantInstallRequired.push(dep);
      if ( !dep.canInstall && !dep.required ) obj.cantInstallOptional.push(dep);
      return obj;
    }, { canInstallRequired: [], canInstallOptional: [], cantInstallRequired: [], cantInstallOptional: [] });
    const { canInstallRequired, canInstallOptional, cantInstallRequired, cantInstallOptional } = categories;
    const data = {
      title: pkg.title,
      totalDependencies: uninstalled.length,
      canInstallRequired,
      canInstallOptional,
      cantInstallRequired,
      cantInstallOptional
    };

    // Handle pluralization
    const singleDependency = data.totalDependencies === 1;
    const canInstall = data.canInstallRequired.length + data.canInstallOptional.length;
    const cantInstall = data.cantInstallRequired.length + data.cantInstallOptional.length;
    data.hasDependenciesLabel = singleDependency
      ? this.i18n.format("SETUP.PackageHasDependenciesSingular", {title: pkg.title})
      : this.i18n.format("SETUP.PackageHasDependenciesPlural", {title: pkg.title, number: data.totalDependencies});
    data.autoInstallLabel = canInstall === 1
      ? this.i18n.localize("SETUP.PackageDependenciesCouldInstallSingular")
      : this.i18n.format("SETUP.PackageDependenciesCouldInstallPlural", {number: canInstall});
    data.manualInstallLabel = cantInstall === 1
      ? this.i18n.localize("SETUP.PackageDependenciesCouldNotInstallSingular")
      : this.i18n.format("SETUP.PackageDependenciesCouldNotInstallPlural", {number: cantInstall});
    // Prompt the user to confirm installation of dependency packages
    const html = await foundry.applications.handlebars.renderTemplate("templates/setup/install-dependencies.html", data);
    const content = document.createElement("div");
    content.innerHTML = html;
    await foundry.applications.api.DialogV2.wait({
      id: "setup-install-dependencies",
      window: {title: "SETUP.PackageDependenciesTitle"},
      position: {width: 600},
      content,
      buttons: [{
        action: "automatic",
        label: `SETUP.PackageDependenciesAutomatic${canInstall === 1 ? "Singular" : "Plural"}`,
        icon: "fa-solid fa-bolt-auto",
        disabled: canInstall === 0,
        callback: async (event, button) => {
          // Install selected dependency packages
          const inputs = Array.from(button.form.querySelectorAll("input"));
          let installed = 0;
          for ( const d of dependencyChecks.values() ) {
            if ( !d.installNeeded ) continue;

            // Only install the package if the input is checked
            if ( !inputs.find(i => i.name === d.id)?.checked ) continue;
            await this.installPackage({type: d.type, id: d.id, manifest: d.manifest, ...options});
            installed++;
          }
          return ui.notifications.info("SETUP.PackageDependenciesSuccess", {format: {
            title: pkg.title,
            number: String(installed)}
          });
        },
        default: true
      }, {
        action: "manual",
        label: `SETUP.PackageDependenciesManual${singleDependency ? "Singular" : "Plural"}`,
        icon: "fa-solid fa-wrench",
        callback: () => {
          if ( canInstallRequired.length ) {
            ui.notifications.warn("SETUP.PackageDependenciesDecline", {format: {title: pkg.title}});
          }
        }
      }]
    });
  }


  /* -------------------------------------------- */

  /**
   * @typedef PackageDependencyCheck
   * @property {string} id                The package id
   * @property {string} type              The package type
   * @property {string} manifest          The package manifest URL
   * @property {boolean} installNeeded    Whether the package is already installed
   * @property {boolean} canInstall       Whether the package can be installed
   * @property {string} message           An error message to display to the user
   * @property {string} url               The URL to the package
   * @property {string} version           The package version
   */

  /**
   * Checks a dependency to see if it needs to be installed
   * @param {RelatedPackage} relatedPackage                               The dependency
   * @param {Map<string, PackageDependencyCheck>} dependencyChecks        The current map of dependencies to install
   * @param {boolean} required                                            Is the dependency required?
   * @returns {Promise<void>}
   */
  async #checkDependency(relatedPackage, dependencyChecks, required=true) {
    if ( !relatedPackage.id || dependencyChecks.has(relatedPackage.id) ) return;
    relatedPackage.type = relatedPackage.type || "module";
    const dependencyCheck = {
      id: relatedPackage.id,
      type: relatedPackage.type,
      manifest: "",
      installNeeded: true,
      canInstall: false,
      message: "",
      url: "",
      version: "",
      required: required,
      note: required ? this.i18n.localize("SETUP.RequiredPackageNote") : this.i18n.localize("SETUP.RecommendedPackageNote"),
      reason: relatedPackage.reason
    };

    const installed = this.data[`${relatedPackage.type}s`].find(p => p.id === relatedPackage.id);
    if ( installed ) {
      const msg = `Dependency ${relatedPackage.type} ${relatedPackage.id} is already installed.`;
      console.debug(msg);
      dependencyCheck.installNeeded = false;
      dependencyCheck.message = msg;
      dependencyChecks.set(dependencyCheck.id, dependencyCheck);
      return;
    }

    // Manifest URL provided
    let dependency;
    if ( relatedPackage.manifest ) {
      dependencyCheck.manifest = relatedPackage.manifest;
      dependencyCheck.url = relatedPackage.manifest;
      const packageCls = foundry.packages.PACKAGE_TYPES[relatedPackage.type];
      dependency = await packageCls.fromRemoteManifest(relatedPackage.manifest);
      if ( !dependency ) {
        const msg = `Requested dependency "${relatedPackage.id}" not found at ${relatedPackage.manifest}.`;
        console.warn(msg);
        dependencyCheck.message = msg;
        dependencyChecks.set(dependencyCheck.id, dependencyCheck);
        return;
      }
    }
    else {
      // Discover from package listing
      const packages = await this.getPackages({type: relatedPackage.type});
      dependency = packages.get(relatedPackage.id);
      if ( !dependency ) {
        const msg = `Requested dependency "${relatedPackage.id}" not found in ${relatedPackage.type} directory.`;
        console.warn(msg);
        dependencyCheck.message = msg;
        dependencyChecks.set(dependencyCheck.id, dependencyCheck);
        return;
      }

      // Prefer linking to Readme over Project URL over Manifest
      if ( dependency.readme ) dependencyCheck.url = dependency.readme;
      else if ( dependency.url ) dependencyCheck.url = dependency.url;
      else dependencyCheck.url = dependency.manifest;
      dependencyCheck.manifest = dependency.manifest;
    }
    dependencyCheck.version = dependency.version;

    /**
     * Test whether a package dependency version matches the defined compatibility criteria of its dependant package.
     * @param {string} dependencyVersion                 The version string of the dependency package
     * @param {PackageCompatibility} compatibility       Compatibility criteria defined by the dependant package
     * @param {string} [compatibility.minimum]           A minimum version of the dependency which is required
     * @param {string} [compatibility.maximum]           A maximum version of the dependency which is allowed
     * @returns {boolean}
     */
    function isDependencyCompatible(dependencyVersion, {minimum, maximum}={}) {
      if ( minimum && foundry.utils.isNewerVersion(minimum, dependencyVersion) ) return false;
      return !( maximum && foundry.utils.isNewerVersion(dependencyVersion, maximum) );
    }

    // Validate that the dependency is compatible
    if ( !isDependencyCompatible(dependency.version, relatedPackage.compatibility) ) {
      const range = [
        relatedPackage.compatibility?.minimum ? `>= ${relatedPackage.compatibility.minimum}` : "",
        relatedPackage.compatibility?.maximum && relatedPackage.compatibility?.maximum ? " and " : "",
        relatedPackage.compatibility?.maximum ? `<= ${relatedPackage.compatibility.maximum}` : ""
      ].join("");
      const msg = `No version of dependency "${relatedPackage.id}" found matching required range of ${range}.`;
      console.warn(msg);
      dependencyCheck.message = msg;
      dependencyChecks.set(dependencyCheck.id, dependencyCheck);
      return;
    }
    dependencyCheck.canInstall = true;
    dependencyChecks.set(dependencyCheck.id, dependencyCheck);

    // If the dependency has dependencies itself, take a fun trip down recursion lane
    for ( const d of dependency.relationships?.requires ?? [] ) {
      await this.#checkDependency(d, dependencyChecks);
    }
    for ( const d of dependency.relationships?.recommends ?? [] ) {
      await this.#checkDependency(d, dependencyChecks, false);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle requests to uninstall a package.
   * TODO #8555 #10102
   * @param {BasePackage} pkg       The package to uninstall
   * @returns {Promise<void>}
   */
  async uninstallPackage(pkg) {
    const typeLabel = this.i18n.localize(`PACKAGE.Type.${pkg.type}`);
    if ( pkg.locked ) {
      return ui.notifications.error("PACKAGE.UninstallLocked", {format: {type: typeLabel, id: pkg.id}});
    }

    // Provide a deletion confirmation warning
    // For worlds, require the user to provide a deletion code
    const title = this.i18n.format("SETUP.PackageDeleteTitle", {type: typeLabel, title: pkg.title ?? pkg.id});
    const question = this.i18n.format("SETUP.PackageDeleteConfirm", {
      type: typeLabel,
      title: foundry.utils.escapeHTML(pkg.title ?? pkg.id)
    });
    let content = `<p>${question}</p>`;
    let confirm;
    if ( pkg.type === "world" ) {
      content += `<p class="notification warning">${this.i18n.localize("SETUP.WorldDeleteConfirmWarning")}</p>`;
      confirm = await Setup.#confirmCodeDialog({ title, content });
    } else {
      if ( pkg.hasStorage ) content += `<p>${this.i18n.localize("SETUP.PackageDeletePersistent")}</p>`;
      content += `<p class="notification warning">${this.i18n.localize("SETUP.PackageDeleteNoUndo")}</p>`;
      confirm = await foundry.applications.api.DialogV2.confirm({
        window: {title}, // FIXME: double localization
        position: {width: 400},
        content
      });
    }
    if ( !confirm ) return;
    // Submit the server request
    try {
      await this.post({action: "uninstallPackage", type: pkg.type, id: pkg.id});
    } catch(err) {
      ui.notifications.error(`${this.i18n.localize("SETUP.UninstallFailure")}: ${foundry.utils.escapeHTML(err.message)}`);
      throw err;
    }

    // Finalize the uninstallation
    foundry.packages.PACKAGE_TYPES[pkg.type].uninstall(pkg.id);
    ui.notifications.info(`${typeLabel} ${pkg.id} ${this.i18n.localize("SETUP.UninstallSuccess")}.`);
    return this.reload();
  }

  /* -------------------------------------------- */

  /**
   * Retrieve compatibility data for installed packages in the next version of the core software.
   * @param {ReleaseData} release  The release to check against.
   * @returns {Promise<PreviewCompatibilityDescriptor>}
   */
  async previewCompatibility(release) {
    if ( Setup.#compatibilityPreview?.version === release.version ) return Setup.#compatibilityPreview;
    let preview = null;
    try {
      if ( !Setup.#checkingCompatibility ) {
        Setup.#checkingCompatibility = this.post({ action: "previewCompatibility", release }, { timeoutMs: null });
      }
      preview = await Setup.#checkingCompatibility;
    } catch(err) {
      // Ignored as notification is already raised inside the post method.
    }
    if ( preview ) Setup.#compatibilityPreview = preview;
    Setup.#checkingCompatibility = null;
    return preview;
  }

  /* -------------------------------------------- */
  /*  Backup Management                           */
  /* -------------------------------------------- */

  /**
   * Create a backup of a given package.
   * @param {BasePackage} pkg           The package.
   * @param {object} [options]
   * @param {string} [options.note]     An optional note for the backup. Ignored if dialog is true.
   * @param {boolean} [options.dialog]  Spawn a dialog to prompt the user for a note.
   * @returns {Promise<void>}
   */
  async createBackup({ type, id, title }, { note, dialog=false }={}) {
    if ( dialog ) {
      const result = await Setup.#createBackupDialog(title);
      if ( !result.confirm ) return;
      note = result.note;
    }

    const { ACTIONS, STEPS } = CONST.SETUP_PACKAGE_PROGRESS;
    const now = new Date();
    const backupId = `${type}.${id}.${now.toDateInputString()}.${now.valueOf()}`;
    const backups = [{ type, packageId: id, note, id: backupId }];
    this.toggleLock(true, { message: "SETUP.BACKUPS.BackingUp" });
    let packet;
    setTimeout(() => {
      if ( (packet?.step === STEPS.ARCHIVE) && (packet?.pct === 0) ) {
        ui.notifications.info("SETUP.BACKUPS.LargePackageWarning", { localize: true, permanent: true });
      }
    }, 15000);
    const progress = new ProgressReceiver(backupId, ACTIONS.CREATE_BACKUP, { backups }, {
      successMessage: this.i18n.format("SETUP.BACKUPS.CreateBackupComplete", { title: foundry.utils.escapeHTML(title) }),
      failureMessage: this.i18n.format("SETUP.BACKUPS.CreateBackupFailure", { title: foundry.utils.escapeHTML(title) })
    });
    progress.addEventListener("progress", ({ data }) => packet = data);
    const response = await progress.listen();
    if ( this.backupsCache && !(response instanceof Error) ) {
      this.backupsCache[type] ??= {};
      this.backupsCache[type][id] ??= [];
      this.backupsCache[type][id].unshift(response.backupData);
    }
    this.toggleLock(false);
  }

  /* -------------------------------------------- */

  /**
   * Create a snapshot of the current installation state.
   * @param {object} [options]
   * @param {string} [options.note]     An optional note for the snapshot. Ignored if dialog is true.
   * @param {boolean} [options.dialog]  Spawn a dialog to prompt the user to confirm, and to supply a note.
   * @param {Partial<ApplicationConfiguration & Omit<SnapshotOperationDialogOptions, "resolve">>} [dialogOptions]
   *   Options to forward to the dialog.
   * @returns {Promise<void>}
   */
  async createSnapshot({ note, dialog=false }={}, dialogOptions={}) {
    const { CREATE_SNAPSHOT, CREATE_BACKUP } = CONST.SETUP_PACKAGE_PROGRESS.ACTIONS;
    this.toggleLock(true);
    if ( dialog ) {
      const result = await SnapshotOperationDialog.show({
        window: { title: "SETUP.BACKUPS.CreateSnapshot" },
        message: "SETUP.BACKUPS.CreateSnapshotHint",
        confirm: "SETUP.BACKUPS.CreateSnapshotConfirm",
        packageList: true,
        note: true,
        diskSpaceAction: "checkCreateSnapshotDiskSpace",
        ...dialogOptions
      });
      if ( !result.confirm ) {
        this.toggleLock(false);
        return;
      }
      note = result.note;
    }

    this.toggleLock(true, {message: "SETUP.BACKUPS.BackingUp"});
    const now = new Date();
    const id = `snapshot.${now.toDateInputString()}.${now.valueOf()}`;
    await new SnapshotProgressReceiver(id, CREATE_SNAPSHOT, CREATE_BACKUP, { note, id }, {
      title: "",
      successMessage: this.i18n.localize("SETUP.BACKUPS.CreateSnapshotComplete"),
      failureMessage: this.i18n.localize("SETUP.BACKUPS.CreateSnapshotFailure")
    }).listen();
    this.backupsCache = null;
    this.toggleLock(false);
  }

  /* -------------------------------------------- */

  /**
   * Delete backups.
   * @param {BasePackage} pkg           The package whose backups are being deleted.
   * @param {string[]} backupIds        The IDs of the backups to delete.
   * @param {object} [options]
   * @param {boolean} [options.dialog]  Spawn a warning dialog and ask the user to confirm the action.
   * @returns {Promise<void>}
   */
  async deleteBackups({ type, id, title }, backupIds, { dialog=false }={}) {
    const count = backupIds.length;
    if ( !count ) return;
    if ( dialog ) {
      const confirm = await Setup.#confirmCodeDialog({
        title: this.i18n.format("SETUP.BACKUPS.DeleteBackupTitle", { title }),
        content: `<p>${this.i18n.format(`SETUP.BACKUPS.DeleteBackupWarning${count === 1 ? "" : "Pl"}`, { count })}</p>`
      });
      if ( !confirm ) return;
    }

    const { DELETE_BACKUP } = CONST.SETUP_PACKAGE_PROGRESS.ACTIONS;
    const ids = new Set(backupIds);
    const backups = this.backupsCache[type][id].filter(backupData => ids.has(backupData.id));
    this.toggleLock(true, { message: "SETUP.BACKUPS.DeletingBackup" });
    await new ProgressReceiver(DELETE_BACKUP, DELETE_BACKUP, { backups }, {
      failureMessage: this.i18n.format("SETUP.BACKUPS.DeleteBackupFailure", { title: foundry.utils.escapeHTML(title) }),
      successMessage: this.i18n.format(`SETUP.BACKUPS.DeleteBackupComplete${count === 1 ? "" : "Pl"}`,
        { title: foundry.utils.escapeHTML(title), count })
    }).listen();
    if ( this.backupsCache ) {
      this.backupsCache[type][id] = this.backupsCache[type][id].filter(backupData => !ids.has(backupData.id));
    }
    this.toggleLock(false);
  }

  /* -------------------------------------------- */

  /**
   * Delete snapshots.
   * @param {string[]} snapshotIds      The IDs of the snapshots to delete.
   * @param {object} [options]
   * @param {boolean} [options.dialog]  Spawn a warning dialog and ask the user to confirm the action.
   * @returns {Promise<void>}
   */
  async deleteSnapshots(snapshotIds, { dialog=false }={}) {
    const count = snapshotIds.length;
    if ( !count ) return;
    if ( dialog ) {
      const confirm = await Setup.#confirmCodeDialog({
        title: this.i18n.localize("SETUP.BACKUPS.DeleteSnapshotTitle"),
        content: `
          <p>${this.i18n.format(`SETUP.BACKUPS.DeleteSnapshotWarning${count === 1 ? "" : "Pl"}`, { count })}</p>
        `
      });
      if ( !confirm ) return;
    }

    const { DELETE_SNAPSHOT, DELETE_BACKUP } = CONST.SETUP_PACKAGE_PROGRESS.ACTIONS;
    const snapshots = snapshotIds.map(id => this.backupsCache.snapshots[id]);
    this.toggleLock(true, { message: "SETUP.BACKUPS.DeletingSnapshot" });
    await new SnapshotProgressReceiver(DELETE_SNAPSHOT, DELETE_SNAPSHOT, DELETE_BACKUP, { snapshots }, {
      failureMessage: this.i18n.localize("SETUP.BACKUPS.DeleteSnapshotFailure"),
      successMessage: this.i18n.format(`SETUP.BACKUPS.DeleteSnapshotComplete${count === 1 ? "" : "Pl"}`, { count })
    }).listen();
    this.backupsCache = null;
    this.toggleLock(false);
  }

  /* -------------------------------------------- */

  /**
   * Restore a backup.
   * @param {BackupData} backupData     The backup to restore.
   * @param {object} [options]
   * @param {boolean} [options.dialog]  Spawn a warning dialog and ask the user to confirm the action.
   * @returns {Promise<void>}
   */
  async restoreBackup(backupData, { dialog=false }={}) {
    const { title, id } = backupData;
    if ( dialog ) {
      const confirm = await Setup.#confirmCodeDialog({
        title: this.i18n.format("SETUP.BACKUPS.RestoreBackupTitle", { title }),
        content: `<p class="notification warning">${this.i18n.localize("SETUP.BACKUPS.RestoreBackupWarning")}</p>`
      });
      if ( !confirm ) return;
    }

    const backups = [backupData];
    const dateFormatter = new Intl.DateTimeFormat(this.i18n.lang, {dateStyle: "full", timeStyle: "short"});
    this.toggleLock(true, { message: "SETUP.BACKUPS.Restoring" });
    await new ProgressReceiver(id, CONST.SETUP_PACKAGE_PROGRESS.ACTIONS.RESTORE_BACKUP, { backups }, {
      failureMessage: this.i18n.format("SETUP.BACKUPS.RestoreBackupFailure", { title: foundry.utils.escapeHTML(title) }),
      successMessage: this.i18n.format("SETUP.BACKUPS.RestoreBackupComplete", {
        title: foundry.utils.escapeHTML(title),
        date: dateFormatter.format(backupData.createdAt)
      })
    }).listen();
    await this.reload();
    this.toggleLock(false);
  }

  /* -------------------------------------------- */

  /**
   * Restore a snapshot.
   * @param {SnapshotData} snapshotData  The snapshot to restore.
   * @param {object} [options]
   * @param {boolean} [options.dialog]   Spawn a warning dialog and ask the user to confirm the action.
   * @returns {Promise<void>}
   */
  async restoreSnapshot(snapshotData, { dialog=false }={}) {
    if ( dialog ) {
      const { confirm } = await SnapshotOperationDialog.show({
        window: { title: "SETUP.BACKUPS.RestoreSnapshotTitle" },
        snapshotData,
        message: "SETUP.BACKUPS.RestoreSnapshotWarning",
        warning: true,
        confirmCode: true,
        diskSpaceAction: "checkRestoreSnapshotDiskSpace"
      });
      if ( !confirm ) return;
    }

    const { id, createdAt } = snapshotData;
    const { ACTIONS } = CONST.SETUP_PACKAGE_PROGRESS;
    const dateFormatter = new Intl.DateTimeFormat(this.i18n.lang, { dateStyle: "full", timeStyle: "short" });

    this.toggleLock(true, { message: "SETUP.BACKUPS.Restoring" });
    await new SnapshotProgressReceiver(id, ACTIONS.RESTORE_SNAPSHOT, ACTIONS.RESTORE_BACKUP, { snapshotData }, {
      title: "",
      failureMessage: this.i18n.localize("SETUP.BACKUPS.RestoreSnapshotFailure"),
      successMessage: this.i18n.format("SETUP.BACKUPS.RestoreSnapshotComplete", {
        date: dateFormatter.format(createdAt)
      })
    }).listen();
    await this.reload();
    this.toggleLock(false);
  }

  /* -------------------------------------------- */

  /**
   * Restore the latest backup for a given package.
   * @param {BasePackage} pkg           The package.
   * @param {object} [options]
   * @param {boolean} [options.dialog]  Spawn a warning dialog and ask the user to confirm the action.
   * @returns {Promise<void>}
   */
  async restoreLatestBackup({ id, type }, options={}) {
    if ( !this.backupsCache ) await this.listBackups();
    const [backupData] = this.backupsCache?.[type]?.[id] ?? [];
    if ( backupData ) return this.restoreBackup(backupData, options);
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /**
   * Activate socket listeners related to the Setup view.
   */
  #activateSocketListeners() {
    this.socket.on("progress", this.#onProgress.bind(this));
  }

  /* --------------------------------------------- */

  /**
   * A list of functions to call on progress events.
   * @type {Function[]}
   */
  #progressListeners = [];

  /* --------------------------------------------- */

  /**
   * Handle a progress event from the server.
   * @param {object} data  The progress update data.
   */
  #onProgress(data) {
    this.#progressListeners.forEach(l => l(data));
  }

  /* --------------------------------------------- */

  /**
   * Add a function to be called on a progress event.
   * @param {Function} listener
   * @internal
   */
  _addProgressListener(listener) {
    this.#progressListeners.push(listener);
  }

  /* --------------------------------------------- */

  /**
   * Stop sending progress events to a given function.
   * @param {Function} listener
   * @internal
   */
  _removeProgressListener(listener) {
    this.#progressListeners.findSplice(l => l === listener);
  }

  /* --------------------------------------------- */

  /**
   * Reload package data from the server and update its display
   * @returns {Promise<Object>}
   */
  async reload() {
    return Setup.getData(this.socket, this.view).then(setupData => {
      foundry.utils.mergeObject(this.data, setupData);
      this.setupPackages(setupData);
      ui.setupPackages.render({ refreshFavorites: true });
      ui.installPackage?.render();
    });
  }

  /** @inheritDoc */
  _onClickHyperlink(event) {
    const a = event.target.closest("a[href]");
    if ( a?.getAttribute("href") === "setup" ) return;
    return super._onClickHyperlink(event);
  }

  /* -------------------------------------------- */
  /*  Helper Functions                            */
  /* -------------------------------------------- */

  /**
   * Post to the Setup endpoint.
   * @param {object} requestData    An object of data which should be included with the POST request
   * @param {object} [options]      An object of options passed to the fetchWithTimeout method
   * @param {boolean} [options.notify]  Whether to spawn notification dialogs when errors are encountered.
   * @returns {Promise<object>}     A Promise resolving to the returned response data
   * @throws                        An error if the request was not successful
   */
  async post(requestData, { notify=true, ...requestOptions }={}) {
    if ( this.ready ) {
      throw new Error("You may not submit POST requests to the setup page while a game world is currently active.");
    }

    // Post the request and handle redirects
    const url = foundry.utils.getRoute(this.view);
    let responseData;
    const errorId = requestData.id ? `[${requestData.id}]` : "";
    try {
      const response = await foundry.utils.fetchWithTimeout(url, {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify(requestData)
      }, requestOptions);

      // Handle redirect
      if ( response.redirected ) return window.location.href = response.url;

      // Process response
      responseData = await response.json();
    } catch(error) {
      const message = `${errorId} ${this.i18n.localize(String(error))}`;
      if ( notify ) ui.notifications.error(foundry.utils.escapeHTML(message), {permanent: true});
      throw new Error(message, {cause: error});
    }

    // Handle server-side errors
    if ( responseData.error ) {
      const { error, ...data } = responseData;
      const err = new Error(`${errorId} ${this.i18n.localize(error)}`);
      Object.assign(err, data);
      if ( notify ) ui.notifications.error(foundry.utils.escapeHTML(err), {permanent: true});
      throw err;
    }
    return responseData;
  }

  /* -------------------------------------------- */

  /**
   * Toggle the locked state of the interface.
   * @param {boolean} locked  Is the interface locked?
   * @param {object} [options]
   */
  toggleLock(locked, options={}) {
    ui.setupMenu?.toggleLock(locked, options);
    ui.setupPackages?.toggleLock(locked, options);
    foundry.applications.instances.values().forEach(app => app.toggleLock?.(locked, options));
  }

  /* -------------------------------------------- */

  /**
   * Create a confirmation dialog that prompts the user to enter a code to proceed.
   * Base on https://stackoverflow.com/a/8084248
   * @param {object} [options]
   * @param {string} [options.title]    The dialog title.
   * @param {string} [options.content]  Additional dialog content.
   * @returns {Promise<boolean|null>}   Returns true if the user chose to proceed and the code was correct. Returns
   *                                    false if the code was incorrect or the user chose to not proceed. Returns null
   *                                    if the user dismissed the dialog.
   */
  static #confirmCodeDialog({ title, content }={}) {
    const code = (Math.random() + 1).toString(36).substring(7, 11);
    content = `
      ${content ?? ""}
      <p>${game.i18n.localize("SETUP.WorldDeleteConfirmCode")}</p>
      <p id="confirm-code"><span class="reference">${code}</span></p>
      <input id="delete-confirm" class="dark" type="text" autocomplete="off" placeholder="${code}"
             aria-label="${game.i18n.format("SETUP.ConfirmCodeLabel", { code })}" required autofocus>
    `;
    return foundry.applications.api.DialogV2.confirm({
      window: {title}, // FIXME: double localization
      position: {width: 480},
      content,
      yes: {
        callback: (event, button) => {
          const confirm = button.form.querySelector("#delete-confirm")?.value;
          if ( confirm === code ) return true;
          ui.notifications.error("SETUP.PackageDeleteWorldConfirm", { localize: true });
          return false;
        }
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * @typedef BackupNoteConfirmation
   * @property {string} [note]    The user-supplied backup note.
   * @property {boolean} confirm  Whether the user wishes to proceed.
   */

  /**
   * Spawn the backup creation confirmation dialog.
   * @param {string} title  The package title.
   * @returns {Promise<BackupNoteConfirmation>}
   */
  static async #createBackupDialog(title) {
    const result = await foundry.applications.api.DialogV2.prompt({
      window: { title: game.i18n.format("SETUP.BACKUPS.CreateBackup", { title }) }, // FIXME: double localization
      position: { width: 480 },
      content: `
        <p>${game.i18n.localize("SETUP.BACKUPS.NoteHint")}</p>
        <input class="dark" type="text" autocomplete="off" autofocus>
      `,
      ok: {
        label: "SETUP.BACKUPS.Backup",
        callback: (event, button) => button.form.querySelector("input").value
      }
    });
    if ( result === null ) return { confirm: false };
    return { note: result, confirm: true };
  }
}

var _module$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  EULA: EULA,
  JoinGameForm: JoinGameForm,
  SetupAuthenticationForm: SetupAuthenticationForm,
  SetupMenu: SetupMenu,
  SetupPackages: SetupPackages,
  UserManagement: UserManagement
});

var _module = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Setup: Setup,
  applications: _module$1
});

/* -------------------------------------------- */
/*  Commons                                     */
/* -------------------------------------------- */


// Set for subsequent @commons imports
globalThis.logger = console;
/**
 * A collection of application instances
 */
const ui$2 = {...globalUI};

var foundry$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CONST: constants,
  Game: Game,
  abstract: _module$S,
  applications: _module$z,
  appv1: _module$w,
  audio: _module$t,
  av: _module$u,
  canvas: _module$8,
  config: config,
  data: _module$U,
  dice: _module$6,
  documents: _module$W,
  grid: _module$P,
  helpers: _module$4,
  nue: _module$2,
  packages: _module$T,
  prosemirror: _module$Q,
  setup: _module,
  types: _types,
  ui: ui$2,
  utils: _module$R
});

/**
 * 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.
 * @module CONFIG
 */


/**
 * @import {DataModel, TypeDataModel} from "@common/abstract/_module.mjs";
 * @import {CanvasAnimationAttribute} from "./canvas/animation/_types.mjs";
 * @import ParticleEffect from "./canvas/containers/elements/particles/particle-effect.mjs";
 * @import RollResolver from "./applications/dice/roll-resolver.mjs";
 * @import {TokenMovementActionConfig} from "./_types.mjs";
 * @import {TokenMovementCostAggregator} from "./documents/_types.mjs";
 */

/**
 * Configure debugging flags to display additional information
 */
const debug = {
  applications: false,
  audio: false,
  combat: false,
  dice: false,
  documents: false,
  fog: {
    extractor: false,
    manager: false
  },
  hooks: false,
  av: false,
  avclient: false,
  i18n: false,
  mouseInteraction: false,
  time: false,
  keybindings: false,
  polygons: false,
  gamepad: false,
  canvas: {
    primary: {
      bounds: false
    }
  },
  queries: false,
  rollParsing: false,
  loader: {
    load: false,
    cache: false,
    eviction: false,
    memory: 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);
 * ```
 */
const compatibility = {
  mode: COMPATIBILITY_MODES.WARNING,
  includePatterns: [],
  excludePatterns: []
};

const 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
   * const newUuid = "Compendium.system.villains.Actor.DKYLeIliXXzlAZ2G";
   * CONFIG.compendium.uuidRedirects["Compendium.system.heroes.Actor.Tf0JDPzHOrIxz6BH"] = newUuid;
   * ```
   *
   * @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
 */
// eslint-disable-next-line prefer-const
let DatabaseBackend = new ClientDatabaseBackend();


/**
 * Configuration for the Actor document
 */
const Actor = {
  documentClass: Actor$1,
  collection: Actors,
  /** @type {string[]} */
  compendiumIndexFields: [],
  compendiumBanner: "ui/banners/actor-banner.webp",
  sidebarIcon: "fa-solid fa-user",
  /** @type {Record<string, typeof TypeDataModel>} */
  dataModels: {},
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeIcons: {},
  /** @type {Record<string, string>} */
  trackableAttributes: {}
};

/**
 * Configuration for the Adventure document.
 */
const Adventure = {
  documentClass: Adventure$1,
  exporterClass: AdventureExporter,
  /** @type {string[]} */
  compendiumIndexFields: [],
  compendiumBanner: "ui/banners/adventure-banner.webp",
  sidebarIcon: "fa-solid fa-treasure-chest"
};

/**
 * Configuration for the Cards primary Document type
 */
const Cards = {
  collection: CardStacks,
  /** @type {string[]} */
  compendiumIndexFields: [],
  compendiumBanner: "ui/banners/cards-banner.webp",
  documentClass: Cards$1,
  sidebarIcon: "fa-solid fa-cards",
  /** @type {Record<string, typeof TypeDataModel>} */
  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"
    }
  },
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeIcons: {
    deck: "fa-solid fa-cards",
    hand: "fa-duotone fa-cards",
    pile: "fa-duotone fa-layer-group"
  }
};

/**
 * Configuration for the ChatMessage document
 */
const ChatMessage = {
  documentClass: ChatMessage$1,
  popoutClass: ChatPopout,
  collection: ChatMessages,
  template: "templates/sidebar/chat-message.hbs",
  sidebarIcon: "fa-solid fa-comments",
  /** @type {Record<string, typeof TypeDataModel>} */
  dataModels: {},
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeIcons: {},
  batchSize: 100
};

/**
 * Configuration for the Combat document
 */
const Combat = {
  documentClass: Combat$1,
  collection: CombatEncounters,
  settings: new CombatConfiguration(),
  sidebarIcon: "fa-solid fa-swords",
  initiativeIcon: {
    icon: "../icons/svg/d20.svg",
    hover: "../icons/svg/d20-highlight.svg"
  },
  /** @type {Record<string, typeof TypeDataModel>} */
  dataModels: {},
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeIcons: {},
  initiative: {
    formula: null,
    decimals: 2
  },
  fallbackTurnMarker: "icons/vtt-512.png",
  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 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 DiceFulfillmentDenomination
 * @property {string} label  The human-readable label for the die.
 * @property {string} icon   An icon to display on the configuration sheet.
 */

/**
 * @typedef 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 {dice.terms.DiceTerm} term           The term being fulfilled.
 * @param {object} [options]        Additional options to configure fulfillment.
 * @returns {number|void|Promise<number|void>}  The fulfilled value, or undefined if it could not be fulfilled.
 */

/**
 * @callback RollFunction
 * @param {...any} args
 * @returns {Promise<number|string>|number|string}
 */

/**
 * @type {Record<string, typeof dice.terms.RollTerm>}
 */
const termTypes = {
  DiceTerm: DiceTerm,
  FunctionTerm: FunctionTerm,
  NumericTerm: NumericTerm,
  OperatorTerm: OperatorTerm,
  ParentheticalTerm: ParentheticalTerm,
  PoolTerm: PoolTerm,
  StringTerm: StringTerm
};

/**
 * Configuration for dice rolling behaviors in the Foundry Virtual Tabletop client.
 */
const Dice = {
  /**
   * The Dice types which are supported.
   * @type {Array<typeof dice.terms.DiceTerm>}
   */
  types: [Die, FateDie],
  rollModes: {
    publicroll: {
      label: "CHAT.RollPublic",
      icon: "fa-solid fa-globe"
    },
    gmroll: {
      label: "CHAT.RollPrivate",
      icon: "fa-solid fa-user-secret"
    },
    blindroll: {
      label: "CHAT.RollBlind",
      icon: "fa-solid fa-eye-slash"
    },
    selfroll: {
      label: "CHAT.RollSelf",
      icon: "fa-solid fa-user"
    }
  },
  /**
   * Configured Roll class definitions
   * @type {Array<typeof dice.Roll>}
   */
  rolls: [Roll],
  /**
   * Configured DiceTerm class definitions
   */
  termTypes,
  /**
   * Configured roll terms and the classes they map to.
   * @type {Record<string, typeof dice.terms.DiceTerm>}
   */
  terms: {
    c: Coin,
    d: Die,
    f: FateDie
  },
  /**
   * A function used to provide random uniform values.
   * @type {function():number}
   */
  randomUniform: MersenneTwister.random,

  /**
   * A parser implementation for parsing Roll expressions.
   * @type {typeof dice.RollParser}
   */
  parser: RollParser,

  /**
   * A collection of custom functions that can be included in roll expressions.
   * @type {Record<string, RollFunction>}
   */
  functions: {},

  /**
   * Dice roll fulfillment configuration.
   * @type {{
   *   dice: Record<string, DiceFulfillmentDenomination>;
   *   methods: Record<string, DiceFulfillmentMethod>;
   *   defaultMethod: string;
   * }}
   */
  fulfillment: {
    dice: {
      d4: { label: "d4", icon: '<i class="fa-solid fa-dice-d4"></i>' },
      d6: { label: "d6", icon: '<i class="fa-solid fa-dice-d6"></i>' },
      d8: { label: "d8", icon: '<i class="fa-solid fa-dice-d8"></i>' },
      d10: { label: "d10", icon: '<i class="fa-solid fa-dice-d10"></i>' },
      d12: { label: "d12", icon: '<i class="fa-solid fa-dice-d12"></i>' },
      d20: { label: "d20", icon: '<i class="fa-solid fa-dice-d20"></i>' },
      d100: { label: "d100", icon: '<i class="fa-solid fa-percent"></i>' }
    },
    methods: {
      mersenne: {
        label: "DICE.FULFILLMENT.Mersenne",
        interactive: false,
        handler: term => term.mapRandomFace(MersenneTwister.random())
      },
      manual: {
        label: "DICE.FULFILLMENT.Manual",
        icon: '<i class="fa-solid fa-keyboard"></i>',
        interactive: true
      }
    },
    defaultMethod: ""
  }
};

/**
 * Configuration for the FogExploration document
 */
const FogExploration = {
  documentClass: FogExploration$1,
  collection: FogExplorations
};

/**
 * Configuration for the Folder document
 */
const Folder = {
  documentClass: Folder$1,
  collection: Folders,
  sidebarIcon: "fa-solid fa-folder"
};

/**
 * Configuration for Item document
 */
const Item = {
  documentClass: Item$1,
  collection: Items,
  /** @type {string[]} */
  compendiumIndexFields: [],
  compendiumBanner: "ui/banners/item-banner.webp",
  sidebarIcon: "fa-solid fa-suitcase",
  /** @type {Record<string, typeof TypeDataModel>} */
  dataModels: {},
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeIcons: {}
};

/**
 * Configuration for the JournalEntry document
 */
const JournalEntry = {
  documentClass: JournalEntry$1,
  collection: Journal,
  /** @type {string[]} */
  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: "fa-solid fa-book-open"
};

/**
 * Configuration for the Macro document
 */
const Macro = {
  documentClass: Macro$1,
  collection: Macros,
  /** @type {string[]} */
  compendiumIndexFields: [],
  compendiumBanner: "ui/banners/macro-banner.webp",
  sidebarIcon: "fa-solid fa-code"
};

/**
 * Configuration for the Playlist document
 */
const Playlist = {
  documentClass: Playlist$1,
  collection: Playlists,
  /** @type {string[]} */
  compendiumIndexFields: [],
  compendiumBanner: "ui/banners/playlist-banner.webp",
  sidebarIcon: "fa-solid fa-music",
  autoPreloadSeconds: 20
};

/**
 * Configuration for RollTable random draws
 */
const RollTable = {
  documentClass: RollTable$1,
  collection: RollTables,
  compendiumIndexFields: ["formula"],
  compendiumBanner: "ui/banners/rolltable-banner.webp",
  sidebarIcon: "fa-solid fa-table-list",
  resultIcon: "icons/svg/d20-black.svg",
  resultTemplate: "templates/dice/table-result.hbs"
};

/**
 * Configuration for the Scene document
 */
const Scene = {
  documentClass: Scene$1,
  collection: Scenes,
  /** @type {string[]} */
  compendiumIndexFields: [],
  compendiumBanner: "ui/banners/scene-banner.webp",
  sidebarIcon: "fa-solid fa-map"
};

const Setting = {
  documentClass: Setting$1,
  collection: WorldSettings
};

/**
 * Configuration for the User document
 */
const User = {
  documentClass: User$1,
  collection: Users
};

/* -------------------------------------------- */
/*  Canvas                                      */
/* -------------------------------------------- */

/**
 * @typedef {Record<string, {label: string, animation: Function,
 * backgroundShader?: typeof canvas.rendering.shaders.AdaptiveBackgroundShader,
 * illuminationShader?: typeof canvas.rendering.shaders.AdaptiveIlluminationShader,
 * colorationShader: typeof canvas.rendering.shaders.AdaptiveColorationShader}>} LightSourceAnimationConfig
 * A light source animation configuration object.
 */

/**
 * @typedef {Record<string, {label: string, animation: Function,
 * darknessShader: typeof canvas.rendering.shaders.AdaptiveDarknessShader}>} DarknessSourceAnimationConfig
 * A darkness source animation configuration object.
 */

/**
 * Configuration settings for the Canvas and its contained layers and objects
 */
const Canvas = {
  elevationSnappingPrecision: 10,
  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 canvas.containers.DoorControl}
   */
  doorControlClass: DoorControl,
  exploredColor: 0x000000,
  unexploredColor: 0x000000,
  darknessToDaylightAnimationMS: 10000,
  daylightToDarknessAnimationMS: 10000,
  darknessSourceClass: PointDarknessSource,
  lightSourceClass: PointLightSource,
  globalLightSourceClass: GlobalLightSource,
  visionSourceClass: PointVisionSource,
  soundSourceClass: 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 canvas.geometry.PointSourcePolygon}
   */
  polygonBackends: {
    sight: ClockwiseSweepPolygon,
    light: ClockwiseSweepPolygon,
    darkness: ClockwiseSweepPolygon,
    sound: ClockwiseSweepPolygon,
    move: ClockwiseSweepPolygon
  },
  darknessSourcePaddingMultiplier: 0.5,
  visibilityFilter: VisibilityFilter,
  visualEffectsMaskingFilter: VisualEffectsMaskingFilter,
  rulerClass: Ruler,
  dragSpeedModifier: 0.8,
  maxZoom: undefined,
  minZoom: undefined,
  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
      }
    }
  },

  /** @type {LightSourceAnimationConfig} */
  lightAnimations: {
    flame: {
      label: "LIGHT.AnimationFlame",
      animation: PointLightSource.prototype.animateFlickering,
      illuminationShader: FlameIlluminationShader,
      colorationShader: FlameColorationShader
    },
    torch: {
      label: "LIGHT.AnimationTorch",
      animation: PointLightSource.prototype.animateTorch,
      illuminationShader: TorchIlluminationShader,
      colorationShader: TorchColorationShader
    },
    revolving: {
      label: "LIGHT.AnimationRevolving",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: RevolvingColorationShader
    },
    siren: {
      label: "LIGHT.AnimationSiren",
      animation: PointLightSource.prototype.animateTorch,
      illuminationShader: SirenIlluminationShader,
      colorationShader: SirenColorationShader
    },
    pulse: {
      label: "LIGHT.AnimationPulse",
      animation: PointLightSource.prototype.animatePulse,
      illuminationShader: PulseIlluminationShader,
      colorationShader: PulseColorationShader
    },
    reactivepulse: {
      label: "LIGHT.AnimationReactivePulse",
      animation: PointLightSource.prototype.animateSoundPulse,
      illuminationShader: PulseIlluminationShader,
      colorationShader: PulseColorationShader
    },
    chroma: {
      label: "LIGHT.AnimationChroma",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: ChromaColorationShader
    },
    wave: {
      label: "LIGHT.AnimationWave",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: WaveIlluminationShader,
      colorationShader: WaveColorationShader
    },
    fog: {
      label: "LIGHT.AnimationFog",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: FogColorationShader
    },
    sunburst: {
      label: "LIGHT.AnimationSunburst",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: SunburstIlluminationShader,
      colorationShader: SunburstColorationShader
    },
    dome: {
      label: "LIGHT.AnimationLightDome",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: LightDomeColorationShader
    },
    emanation: {
      label: "LIGHT.AnimationEmanation",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: EmanationColorationShader
    },
    hexa: {
      label: "LIGHT.AnimationHexaDome",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: HexaDomeColorationShader
    },
    ghost: {
      label: "LIGHT.AnimationGhostLight",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: GhostLightIlluminationShader,
      colorationShader: GhostLightColorationShader
    },
    energy: {
      label: "LIGHT.AnimationEnergyField",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: EnergyFieldColorationShader
    },
    vortex: {
      label: "LIGHT.AnimationVortex",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: VortexIlluminationShader,
      colorationShader: VortexColorationShader
    },
    witchwave: {
      label: "LIGHT.AnimationBewitchingWave",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: BewitchingWaveIlluminationShader,
      colorationShader: BewitchingWaveColorationShader
    },
    rainbowswirl: {
      label: "LIGHT.AnimationSwirlingRainbow",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: SwirlingRainbowColorationShader
    },
    radialrainbow: {
      label: "LIGHT.AnimationRadialRainbow",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: RadialRainbowColorationShader
    },
    fairy: {
      label: "LIGHT.AnimationFairyLight",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: FairyLightIlluminationShader,
      colorationShader: FairyLightColorationShader
    },
    grid: {
      label: "LIGHT.AnimationForceGrid",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: ForceGridColorationShader
    },
    starlight: {
      label: "LIGHT.AnimationStarLight",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: StarLightColorationShader
    },
    smokepatch: {
      label: "LIGHT.AnimationSmokePatch",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: SmokePatchIlluminationShader,
      colorationShader: SmokePatchColorationShader
    }
  },

  /** @type {DarknessSourceAnimationConfig} */
  darknessAnimations: {
    magicalGloom: {
      label: "LIGHT.AnimationMagicalGloom",
      animation: PointDarknessSource.prototype.animateTime,
      darknessShader: MagicalGloomDarknessShader
    },
    roiling: {
      label: "LIGHT.AnimationRoilingMass",
      animation: PointDarknessSource.prototype.animateTime,
      darknessShader: RoilingDarknessShader
    },
    hole: {
      label: "LIGHT.AnimationBlackHole",
      animation: PointDarknessSource.prototype.animateTime,
      darknessShader: BlackHoleDarknessShader
    },
    denseSmoke: {
      label: "LIGHT.AnimationDenseSmoke",
      animation: PointDarknessSource.prototype.animateTime,
      darknessShader: DenseSmokeDarknessShader
    }
  },

  /**
   * A registry of Scenes which are managed by a specific SceneManager class.
   * @type {Record<string, typeof 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
  },

  /* -------------------------------------------- */

  /**
   * The set of VisionMode definitions which are available to be used for Token vision.
   * @type {Record<string, canvas.perception.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, 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, brightness: 0 }
      }
    }),

    // Darkvision
    monochromatic: new VisionMode({
      id: "monochromatic",
      label: "VISION.ModeMonochromatic",
      canvas: {
        shader: ColorAdjustmentsSamplerShader,
        uniforms: { contrast: 0, saturation: -1, brightness: 0 }
      },
      lighting: {
        background: {
          postProcessingModes: ["SATURATION"],
          uniforms: { saturation: -1, tint: [1, 1, 1] }
        },
        illumination: {
          postProcessingModes: ["SATURATION"],
          uniforms: { saturation: -1, tint: [1, 1, 1] }
        },
        coloration: {
          postProcessingModes: ["SATURATION"],
          uniforms: { saturation: -1, 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, canvas.perception.DetectionMode>}
   */
  detectionModes: {
    lightPerception: new DetectionModeLightPerception({
      id: "lightPerception",
      label: "DETECTION.LightPerception",
      type: DetectionMode.DETECTION_TYPES.SIGHT
    }),
    basicSight: new DetectionModeDarkvision({
      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
    })
  },
  /**
   * @deprecated since v13
   * @ignore
   */
  get transcoders() {
    logCompatibilityWarning("CONFIG.Canvas.transcoders has been deprecated without replacement. "
      + "KTX2/Basis support is always enabled and this property has no effect anymore.",
    {since: 13, until: 15, once: true}
    );
    return {basis: true};
  }
};

/* -------------------------------------------- */

/**
 * Configure the default Token text style so that it may be reused and overridden by modules
 */
// eslint-disable-next-line prefer-const
let 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
});

/**
 * @typedef WeatherAmbienceConfiguration
 * Available Weather Effects implementations
 * @property {string} id
 * @property {string} label
 * @property {{enabled: boolean; blendMode?: PIXI.BLEND_MODES}} [filter]
 * @property {WeatherEffectConfiguration[]} effects
 */

/**
 * @typedef WeatherEffectConfiguration
 * @property {string} id
 * @property {typeof ParticleEffect|typeof canvas.rendering.shaders.WeatherShaderEffect} effectClass
 * @property {typeof canvas.rendering.shaders.AbstractWeatherShader} [shaderClass]
 * @property {PIXI.BLEND_MODES} [blendMode]
 * @property {object} [config]
 * @property {number} [performanceLevel]
 */

/** @type {Record<string, WeatherAmbienceConfiguration>} */
const 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,
        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
      }
    }]
  }
};

/**
 * The control icons used for rendering common HUD operations
 */
const 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 _FontDefinition
 * @property {string[]} urls  An array of remote URLs the font files exist at.
 */

/**
 * @typedef {FontFaceDescriptors & _FontDefinition} FontDefinition
 */

/**
 * @typedef 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>}
 */
const 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-light.woff2"], weight: "300"},
      {urls: ["fonts/signika/signika-regular.woff2"]},
      {urls: ["fonts/signika/signika-medium.woff2"], weight: "500"},
      {urls: ["fonts/signika/signika-semibold.woff2"], weight: "600"},
      {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
 */
// eslint-disable-next-line prefer-const
let defaultFontFamily = "Signika";

/**
 * @typedef _StatusEffectConfig
 * @property {string} id                       A string identifier for the effect.
 * @property {string} [label]                  DEPRECATED alias for "name".
 * @property {string} [icon]                   DEPRECATED alias for "img".
 * @property {boolean|{actorTypes?: string[]}} [hud=true]  Should this effect appear 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.
 */

/**
 * @typedef {_StatusEffectConfig & Partial<documents.types.ActiveEffectData>} StatusEffectConfig
 * Configured status effects recognized by the game system.
 * Properties "name" and "img" should be preferred over "label" and "icon".
 */

/**
 * The array of status effects which can be applied to an Actor.
 * @type {StatusEffectConfig[]}
 */
const 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() {
        logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
        return this[newKey];
      },
      set(value) {
        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.
 * @type {Record<string, string>}
 */
const 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 {Record<string, string>}
 */
const 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 {Record<string, string>}
 */
const supportedLanguages = {
  en: "English"
};

/**
 * Localization constants.
 */
const 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
};

/* -------------------------------------------- */
/*  Timekeeping                                 */
/* -------------------------------------------- */

/**
 * Configuration for time tracking.
 */
const time = {
  /**
   * The Calendar configuration used for in-world timekeeping.
   */
  worldCalendarConfig: SIMPLIFIED_GREGORIAN_CALENDAR_CONFIG,

  /**
   * The CalendarData subclass is used for in-world timekeeping.
   */
  worldCalendarClass: CalendarData,

  /**
   * The Calendar configuration used for IRL timekeeping.
   */
  earthCalendarConfig: SIMPLIFIED_GREGORIAN_CALENDAR_CONFIG,

  /**
   * The CalendarData subclass is used for IRL timekeeping.
   */
  earthCalendarClass: CalendarData,

  /**
   * The number of seconds which automatically elapse at the end of a Combat turn.
   * @type {number}
   */
  turnTime: 0,

  /**
   * The number of seconds which automatically elapse at the end of a Combat round.
   * @type {number}
   */
  roundTime: 0,

  /**
   * Formatting functions used to display time data as strings.
   * @type {Record<string, data.TimeFormatter>}
   */
  formatters: {
    timestamp: CalendarData.formatTimestamp,
    ago: CalendarData.formatAgo
  }
};

/* -------------------------------------------- */
/*  Embedded Documents                          */
/* -------------------------------------------- */

/**
 * Configuration for the ActiveEffect embedded document type
 */
const ActiveEffect = {
  documentClass: ActiveEffect$1,
  /** @type {Record<string, typeof TypeDataModel>} */
  dataModels: {},
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  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. It can be set to true until V14, at which point it will be removed.
   */
  legacyTransferral: false
};

/**
 * Configuration for the ActorDelta embedded document type.
 */
const ActorDelta = {
  documentClass: ActorDelta$1
};

/**
 * Configuration for the Card embedded Document type
 */
const Card = {
  documentClass: Card$1,
  /** @type {Record<string, typeof TypeDataModel>} */
  dataModels: {},
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeIcons: {}
};

/**
 * Configuration for the TableResult embedded document type
 */
const TableResult = {
  documentClass: TableResult$1
};

/**
 * Configuration for the JournalEntryCategory embedded document type.
 */
const JournalEntryCategory = {
  documentClass: JournalEntryCategory$1
};

/**
 * Configuration for the JournalEntryPage embedded document type.
 */
const JournalEntryPage = {
  documentClass: JournalEntryPage$1,
  /** @type {Record<string, typeof TypeDataModel>} */
  dataModels: {},
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeIcons: {
    image: "fa-solid fa-file-image",
    pdf: "fa-solid fa-file-pdf",
    text: "fa-solid fa-file-lines",
    video: "fa-solid fa-file-video"
  },
  defaultType: "text",
  sidebarIcon: "fa-solid fa-book-open"
};

/**
 * Configuration for the PlaylistSound embedded document type
 */
const PlaylistSound = {
  documentClass: PlaylistSound$1,
  sidebarIcon: "fa-solid fa-music"
};

/**
 * Configuration for the AmbientLight embedded document type and its representation on the game Canvas
 */
const AmbientLight = {
  documentClass: AmbientLightDocument,
  objectClass: AmbientLight$1,
  layerClass: LightingLayer
};

/**
 * Configuration for the AmbientSound embedded document type and its representation on the game Canvas
 */
const AmbientSound = {
  documentClass: AmbientSoundDocument,
  objectClass: AmbientSound$1,
  layerClass: SoundsLayer
};

/**
 * Configuration for the Combatant embedded document type within a Combat document
 */
const Combatant = {
  documentClass: Combatant$1,
  /** @type {Record<string, typeof DataModel>} */
  dataModels: {},
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeIcons: {}
};

/**
 * Configuration for the CombatantGroup embedded document type within a Combat document.
 */
const CombatantGroup = {
  documentClass: CombatantGroup$1,
  /** @type {Record<string, typeof TypeDataModel>} */
  dataModels: {},
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeIcons: {}
};

/**
 * Configuration for the Drawing embedded document type and its representation on the game Canvas
 */
const Drawing = {
  documentClass: DrawingDocument,
  objectClass: Drawing$1,
  layerClass: DrawingsLayer,
  hudClass: DrawingHUD
};

/**
 * Configuration for the MeasuredTemplate embedded document type and its representation on the game Canvas
 */
const MeasuredTemplate = {
  defaults: {
    angle: 53.13,
    width: 1
  },
  documentClass: MeasuredTemplateDocument,
  objectClass: MeasuredTemplate$1,
  layerClass: TemplateLayer,
  /**
   * @deprecated since v13
   * @ignore
   */
  get types() {
    logCompatibilityWarning("CONFIG.MeasuredTemplate.types has been deprecated without replacement. "
    // eslint-disable-next-line no-template-curly-in-string
    + "Use CONST.MEASURED_TEMPLATE_TYPES and `TEMPLATE.TYPES.${type}` instead.", {since: 13, until: 15, once: true});
    return Object.values(MEASURED_TEMPLATE_TYPES).reduce((types, type) => {
      types[type] = `TEMPLATE.TYPES.${type}`;
      return types;
    }, {});
  }
};

/**
 * Configuration for the Note embedded document type and its representation on the game Canvas
 */
const Note = {
  documentClass: NoteDocument,
  objectClass: Note$1,
  layerClass: NotesLayer
};

/**
 * Configuration for the Region embedded document type and its representation on the game Canvas
 */
const Region = {
  documentClass: RegionDocument,
  objectClass: Region$1,
  layerClass: RegionLayer
};

/**
 * Configuration for the RegionBehavior embedded document type
 */
const RegionBehavior = {
  documentClass: RegionBehavior$1,
  /** @type {Record<string, typeof data.regionBehaviors.RegionBehaviorType>} */
  dataModels: {
    adjustDarknessLevel: AdjustDarknessLevelRegionBehaviorType,
    displayScrollingText: DisplayScrollingTextRegionBehaviorType,
    executeMacro: ExecuteMacroRegionBehaviorType,
    executeScript: ExecuteScriptRegionBehaviorType,
    modifyMovementCost: ModifyMovementCostRegionBehaviorType,
    pauseGame: PauseGameRegionBehaviorType,
    suppressWeather: SuppressWeatherRegionBehaviorType,
    teleportToken: TeleportTokenRegionBehaviorType,
    toggleBehavior: ToggleBehaviorRegionBehaviorType
  },
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  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",
    modifyMovementCost: "fa-solid fa-shoe-prints",
    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
 */
const Tile = {
  documentClass: TileDocument,
  objectClass: Tile$1,
  layerClass: TilesLayer,
  hudClass: TileHUD
};

/**
 * Configuration for the Token embedded document type and its representation on the game Canvas
 */
const Token = {
  documentClass: TokenDocument,
  objectClass: Token$1,
  layerClass: TokenLayer,
  prototypeSheetClass: PrototypeTokenConfig,
  hudClass: TokenHUD,
  rulerClass: TokenRuler,
  movement: {
    /** @type {typeof data.BaseTerrainData} */
    TerrainData: TerrainData,
    /**
     * The movement cost aggregator.
     * @type {TokenMovementCostAggregator}
     */
    costAggregator: (results, distance, segment) => {
      results.sort((a, b) => a.cost - b.cost);
      if ( results.at(-1) === Infinity ) return Infinity;
      return results[(results.length - 1) >> 1].cost; // Median cost
    },
    /**
     * The default movement animation speed in grid spaces per second.
     * @type {number}
     */
    defaultSpeed: 6,
    /**
     * @type {string}
     */
    defaultAction: "walk",
    /**
     * @type {{[action: string]: Partial<TokenMovementActionConfig>}}
     */
    actions: {
      walk: {
        label: "TOKEN.MOVEMENT.ACTIONS.walk.label",
        icon: "fa-solid fa-person-walking",
        img: "icons/svg/walk.svg",
        order: 0
      },
      fly: {
        label: "TOKEN.MOVEMENT.ACTIONS.fly.label",
        icon: "fa-solid fa-person-fairy",
        img: "icons/svg/wing.svg",
        order: 1
      },
      swim: {
        label: "TOKEN.MOVEMENT.ACTIONS.swim.label",
        icon: "fa-solid fa-person-swimming",
        img: "icons/svg/whale.svg",
        order: 2,
        // eslint-disable-next-line jsdoc/require-description
        /** @type {typeof TokenMovementActionConfig#getAnimationOptions} */
        getAnimationOptions: () => ({movementSpeed: CONFIG.Token.movement.defaultSpeed / 2})
      },
      burrow: {
        label: "TOKEN.MOVEMENT.ACTIONS.burrow.label",
        icon: "fa-solid fa-person-digging",
        img: "icons/svg/burrow.svg",
        order: 3
      },
      crawl: {
        label: "TOKEN.MOVEMENT.ACTIONS.crawl.label",
        icon: "fa-solid fa-person-praying",
        img: "icons/svg/leg.svg",
        order: 4,
        // eslint-disable-next-line jsdoc/require-description
        /** @type {typeof TokenMovementActionConfig#getAnimationOptions} */
        getAnimationOptions: () => ({movementSpeed: CONFIG.Token.movement.defaultSpeed / 2}),
        deriveTerrainDifficulty: ({walk}) => walk,
        getCostFunction: () => cost => cost * 2
      },
      climb: {
        label: "TOKEN.MOVEMENT.ACTIONS.climb.label",
        icon: "fa-solid fa-person-through-window",
        img: "icons/svg/ladder.svg",
        order: 5,
        // eslint-disable-next-line jsdoc/require-description
        /** @type {typeof TokenMovementActionConfig#getAnimationOptions} */
        getAnimationOptions: () => ({movementSpeed: CONFIG.Token.movement.defaultSpeed / 2}),
        deriveTerrainDifficulty: ({walk}) => walk,
        getCostFunction: () => cost => cost * 2
      },
      jump: {
        label: "TOKEN.MOVEMENT.ACTIONS.jump.label",
        icon: "fa-solid fa-person-running-fast",
        img: "icons/svg/jump.svg",
        order: 6,
        deriveTerrainDifficulty: ({walk, fly}) => Math.max(walk, fly),
        getCostFunction: () => cost => cost * 2
      },
      blink: {
        label: "TOKEN.MOVEMENT.ACTIONS.blink.label",
        icon: "fa-solid fa-person-from-portal",
        img: "icons/svg/teleport.svg",
        order: 7,
        teleport: true,
        getAnimationOptions: () => ({duration: 0}),
        deriveTerrainDifficulty: () => 1
      },
      displace: {
        label: "TOKEN.MOVEMENT.ACTIONS.displace.label",
        icon: "fa-solid fa-transporter-1",
        img: "icons/svg/portal.svg",
        order: 8,
        teleport: true,
        measure: false,
        walls: null,
        visualize: false,
        getAnimationOptions: () => ({duration: 0}),
        canSelect: () => false,
        deriveTerrainDifficulty: () => 1,
        getCostFunction: () => () => 0
      }
    }
  },
  adjectivesPrefix: "TOKEN.Adjectives",
  ring: new TokenRingConfig()
};

/**
 * @typedef WallDoorSound
 * @property {string} label              A localization string label
 * @property {string|string[]} [close]   One or more sound paths for when the door is closed
 * @property {string|string[]} [lock]    One or more sound paths for when the door becomes locked
 * @property {string|string[]} [open]    One or more sound paths for when opening the door
 * @property {string|string[]} [test]    One or more sound paths for when attempting to open a locked door
 * @property {string|string[]} [unlock]  One or more sound paths for when the door becomes unlocked
 */

/**
 * @callback WallDoorAnimationFunction
 * @param {boolean} open
 * @returns {CanvasAnimationAttribute[]}
 */

/**
 * @callback WallDoorAnimationHook
 * @param {boolean} open
 * @returns {Promise<void>|void}
 */

/**
 * @typedef WallDoorAnimationConfig
 * @property {string} label
 * @property {boolean} [midpoint=false]
 * @property {string|Function} [easing="easeInOutCosine"]
 * @property {WallDoorAnimationHook} [initialize]
 * @property {WallDoorAnimationHook} [preAnimate]
 * @property {WallDoorAnimationFunction} animate
 * @property {WallDoorAnimationHook} [postAnimate]
 * @property {number} duration
 */

/**
 * Configuration for the Wall embedded document type and its representation on the game Canvas
 * @property {typeof ClientDocument} documentClass
 * @property {typeof canvas.placeables.PlaceableObject} objectClass
 * @property {typeof canvas.layers.CanvasLayer} layerClass
 * @property {number} thresholdAttenuationMultiplier
 * @property {Record<string, WallDoorSound>} doorSounds
 */
const Wall = {
  documentClass: WallDocument,
  objectClass: Wall$1,
  layerClass: WallsLayer,
  /**
   * The set of animation types that are supported for Wall door animations.
   * @type {Record<string, WallDoorAnimationConfig>}
   */
  animationTypes: {
    ascend: {
      label: "WALL.ANIMATION_TYPES.ASCEND",
      midpoint: true,
      animate: DoorMesh.animateAscend,
      duration: 1000
    },
    descend: {
      label: "WALL.ANIMATION_TYPES.DESCEND",
      midpoint: true,
      initialize: DoorMesh.initializeDescend,
      animate: DoorMesh.animateDescend,
      preAnimate: DoorMesh.preAnimateDescend,
      postAnimate: DoorMesh.postAnimateDescend,
      duration: 1000
    },
    slide: {
      label: "WALL.ANIMATION_TYPES.SLIDE",
      animate: DoorMesh.animateSlide,
      duration: 500
    },
    swing: {
      label: "WALL.ANIMATION_TYPES.SWING",
      animate: DoorMesh.animateSwing,
      duration: 500
    },
    swivel: {
      label: "WALL.ANIMATION_TYPES.SWIVEL",
      midpoint: true,
      animate: DoorMesh.animateSwing,
      duration: 500
    }
  },
  /** @type {Record<string, WallDoorSound>} */
  doorSounds: {
    futuristicFast: {
      label: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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"
    },
    slidingMetalHeavy: {
      label: "WALL.DoorSounds.SlidingMetalHeavy",
      close: "sounds/doors/metal/heavy-sliding-close.ogg",
      lock: "sounds/doors/metal/heavy-sliding-lock.ogg",
      open: "sounds/doors/metal/heavy-sliding-open.ogg",
      test: "sounds/doors/metal/heavy-sliding-test.ogg",
      unlock: "sounds/doors/metal/heavy-sliding-unlock.ogg"
    },
    slidingModern: {
      label: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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: "WALL.DoorSounds.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"
    }
  },
  /**
   * A default grid size in pixels which is used for rendering DoorMesh sizing.
   * @type {number}
   */
  textureGridSize: 200,
  thresholdAttenuationMultiplier: 1
};

/**
 * An enumeration of sound effects which can be applied to Sound instances.
 * @type {Record<string, {label: string, effectClass: typeof BiquadFilterNode|typeof ConvolverNode}>}
 */
const soundEffects = {
  lowpass: {
    label: "SOUND.EFFECTS.LOWPASS",
    effectClass: BiquadFilterEffect
  },
  highpass: {
    label: "SOUND.EFFECTS.HIGHPASS",
    effectClass: BiquadFilterEffect
  },
  reverb: {
    label: "SOUND.EFFECTS.REVERB",
    effectClass: ConvolverEffect
  }
};

/* -------------------------------------------- */
/*  Integrations                                */
/* -------------------------------------------- */

/**
 * Default configuration options for TinyMCE editors
 */
const 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 TextEditorEnricherConfig
 * @property {string} [id]                  A unique ID to assign to the enricher type. Required if you want to use
 *                                          the onRender callback.
 * @property {RegExp} pattern               The string pattern to match. Must be flagged as global.
 * @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.
 * @property {boolean} [replaceParent]      Hoist the replacement element out of its containing element if it replaces
 *                                          the entire contents of the element.
 * @property {function(HTMLEnrichedContentElement)} [onRender]  An optional callback that is invoked when the
 *                                          enriched content is added to the DOM.
 */

/**
 * Rich text editing configuration.
 */
const 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
 */
const 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.
 */
const ui$1 = {
  menu: MainMenu,
  sidebar: Sidebar,
  pause: GamePause,
  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: Players,
  playlists: PlaylistDirectory,
  scenes: SceneDirectory,
  settings: Settings,
  tables: RollTableDirectory,
  webrtc: CameraViews
};

/**
 * Overrides for various core UI/UX helpers.
 */
const ux = {
  ContextMenu: ContextMenu,
  Draggable: Draggable,
  DragDrop: DragDrop,
  FilePicker: FilePicker,
  TextEditor: TextEditor$1,
  TooltipManager: TooltipManager
};

/**
 * System and modules must prefix the names of the queries they register (e.g. "my-module.aCustomQuery").
 * Non-prefixed query names are reserved by core.
 */
const queries = {
  dialog: DialogV2._handleQuery,
  confirmTeleportToken: TeleportTokenRegionBehaviorType._confirmQuery
};

/**
 * @typedef CursorDescriptor
 * @property {string} url  The URL of the cursor image. Must be no larger than 128x128. 32x32 is recommended.
 * @property {number} [x]  The X co-ordinate of the cursor hotspot.
 * @property {number} [y]  The Y co-ordinate of the cursor hotspot.
 */

/**
 * Configure custom cursor images to use when interacting with the application.
 * @type {{
 *  default: string|CursorDescriptor,
 *  "default-down": string|CursorDescriptor,
 *  pointer: string|CursorDescriptor,
 *  "pointer-down": string|CursorDescriptor,
 *  grab: string|CursorDescriptor,
 *  "grab-down": string|CursorDescriptor,
 *  text: string|CursorDescriptor,
 *  "text-down": string|CursorDescriptor
 * }}
 *
 * @example Configuring a cursor with a hotspot in the default top-left.
 * ```js
 * Object.assign(CONFIG.cursors, {
 *   default: "icons/cursors/default.avif",
 *   "default-down": "icons/cursors/default-down.avif"
 * });
 * ```
 *
 * @example Configuring a cursor with a hotspot in the center.
 * ```js
 * Object.assign(CONFIG.cursors, {
 *   default: { url: "icons/cursors/target.avif", x: 16, y: 16 },
 *   "default-down": { url: "icons/cursors/target-down.avif", x: 16, y: 16 }
 * });
 * ```
 */
const cursors = {...CURSOR_STYLES};

// Lock the CONFIG.Token.ring property so it cannot be overwritten or redefined
Object.defineProperties(Token, {ring: {writable: false, configurable: false}});

// Lock the CONFIG.Combat.settings property so it cannot be overwritten or redefined
Object.defineProperties(Combat, {settings: {writable: false, configurable: false}});

/* -------------------------------------------- */
/*  Deprecations and Compatibility              */
/* -------------------------------------------- */

/**
 * @deprecated since v13
 * @ignore
 */
Object.values(Dice.rollModes).forEach(mode => {
  Object.defineProperties(mode, {toString: { value: function() {
    logCompatibilityWarning("The values of entries in CONFIG.Dice.rollModes have changed to objects. "
      + "The label value can now be accessed via the label property.", {since: 13, until: 15, once: true});
    return mode.label;
  }}});
});

var globalConfig = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActiveEffect: ActiveEffect,
  Actor: Actor,
  ActorDelta: ActorDelta,
  Adventure: Adventure,
  AmbientLight: AmbientLight,
  AmbientSound: AmbientSound,
  Canvas: Canvas,
  Card: Card,
  Cards: Cards,
  ChatMessage: ChatMessage,
  Combat: Combat,
  Combatant: Combatant,
  CombatantGroup: CombatantGroup,
  DatabaseBackend: DatabaseBackend,
  Dice: Dice,
  Drawing: Drawing,
  FogExploration: FogExploration,
  Folder: Folder,
  Item: Item,
  JournalEntry: JournalEntry,
  JournalEntryCategory: JournalEntryCategory,
  JournalEntryPage: JournalEntryPage,
  Macro: Macro,
  MeasuredTemplate: MeasuredTemplate,
  Note: Note,
  Playlist: Playlist,
  PlaylistSound: PlaylistSound,
  Region: Region,
  RegionBehavior: RegionBehavior,
  RollTable: RollTable,
  Scene: Scene,
  Setting: Setting,
  TableResult: TableResult,
  TextEditor: TextEditor,
  Tile: Tile,
  TinyMCE: TinyMCE,
  Token: Token,
  User: User,
  Wall: Wall,
  WebRTC: WebRTC,
  canvasTextStyle: canvasTextStyle,
  compatibility: compatibility,
  compendium: compendium,
  controlIcons: controlIcons,
  cursors: cursors,
  debug: debug,
  defaultFontFamily: defaultFontFamily,
  fontDefinitions: fontDefinitions,
  i18n: i18n,
  queries: queries,
  soundEffects: soundEffects,
  sounds: sounds,
  specialStatusEffects: specialStatusEffects,
  statusEffects: statusEffects,
  supportedLanguages: supportedLanguages,
  time: time,
  ui: ui$1,
  ux: ux,
  weatherEffects: weatherEffects
});

/**
 * The Foundry Virtual Tabletop client-side ESModule API.
 * @module foundry
 */


/* ----------------------------------------- */
/*  Client-Side Globals                      */
/* ----------------------------------------- */

// Get the current View from the URL.
const gameView = new URL(window.location.href).pathname.split("/").at(-1);
const {CONST: CONST$1, applications, appv1, data, dice, documents, helpers, prosemirror, utils} = foundry$1;
const CONFIG$1 = {...globalConfig};

Object.assign(globalThis, {
  foundry: {...foundry$1, CONFIG: CONFIG$1},
  CONST: CONST$1,
  CONFIG: CONFIG$1,
  /**
   * The singleton Game instance.
   * A simple object before the Game instance has been created.
   * @type {foundry.Game}
   */
  game: {view: gameView},
  ui: ui$2,

  /**
   * The global boolean for whether the EULA is signed
   */
  // eslint-disable-next-line no-undef
  SIGNED_EULA: SIGNED_EULA,

  /**
   * The global route prefix which is applied to this game
   * @type {string}
   */
  // eslint-disable-next-line no-undef
  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}>}
   */
  // eslint-disable-next-line no-undef
  MESSAGES: MESSAGES ?? [],

  /**
   * The string prefix used to prepend console logging
   * @type {string}
   */
  vtt: CONST$1.vtt,

  /**
   * The client side console logger
   * @type {Console}
   */
  logger: console,

  // Document Types
  ActiveEffect: documents.ActiveEffect,
  Actor: documents.Actor,
  ActorDelta: documents.ActorDelta,
  Adventure: documents.Adventure,
  AmbientLightDocument: documents.AmbientLightDocument,
  AmbientSoundDocument: documents.AmbientSoundDocument,
  Card: documents.Card,
  Cards: documents.Cards,
  ChatMessage: documents.ChatMessage,
  Combat: documents.Combat,
  Combatant: documents.Combatant,
  CombatantGroup: documents.CombatantGroup,
  DrawingDocument: documents.DrawingDocument,
  FogExploration: documents.FogExploration,
  Folder: documents.Folder,
  Item: documents.Item,
  JournalEntry: documents.JournalEntry,
  JournalEntryCategory: documents.JournalEntryCategory,
  JournalEntryPage: documents.JournalEntryPage,
  Macro: documents.Macro,
  MeasuredTemplateDocument: documents.MeasuredTemplateDocument,
  NoteDocument: documents.NoteDocument,
  Playlist: documents.Playlist,
  PlaylistSound: documents.PlaylistSound,
  RegionBehavior: documents.RegionBehavior,
  RegionDocument: documents.RegionDocument,
  RollTable: documents.RollTable,
  Scene: documents.Scene,
  Setting: documents.Setting,
  TableResult: documents.TableResult,
  TileDocument: documents.TileDocument,
  TokenDocument: documents.TokenDocument,
  User: documents.User,
  WallDocument: documents.WallDocument,

  // Global Helpers
  Color: utils.Color,
  Collection: utils.Collection,
  fromUuid: utils.fromUuid,
  fromUuidSync: utils.fromUuidSync,
  getDocumentClass: utils.getDocumentClass,
  Hooks: helpers.Hooks,
  ProseMirror: prosemirror,
  Roll: dice.Roll,
  TextEditor: applications.ux.TextEditor,

  /** @deprecated since v13 until v16 */
  Application: appv1.api.Application,
  /** @deprecated since v13 until v16 */
  Dialog: appv1.api.Dialog,
  /** @deprecated since v13 until v16 */
  FormApplication: appv1.api.FormApplication,
  /** @deprecated since v13 until v16 */
  DocumentSheet: appv1.api.DocumentSheet,

  /**
   * 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
   */
  _token: null
});

// Proxy Prototype Token Methods
data.PrototypeToken.prototype.getBarAttribute = documents.TokenDocument.prototype.getBarAttribute;

// PIXI Classes extensions
extendPIXICircle();
extendPIXIPolygon();
extendPIXIRectangle();
extendPIXIGraphics();

/* ----------------------------------------- */
/*  Backwards Compatibility                  */
/* ----------------------------------------- */

/** @deprecated since v12 */
addBackwardsCompatibilityReferences({
  AmbientLightConfig: "applications.sheets.AmbientLightConfig",
  AmbientSoundConfig: "applications.sheets.AmbientSoundConfig",
  AudioHelper: "audio.AudioHelper",
  BaseGrid: "grid.GridlessGrid",
  Coin: "dice.terms.Coin",
  DarknessSource: "canvas.sources.PointDarknessSource",
  DiceTerm: "dice.terms.DiceTerm",
  Die: "dice.terms.Die",
  FateDie: "dice.terms.FateDie",
  GlobalLightSource: "canvas.sources.GlobalLightSource",
  GridHex: "grid.GridHex",
  HexagonalGrid: "grid.HexagonalGrid",
  LightSource: "canvas.sources.PointLightSource",
  MathTerm: "dice.terms.FunctionTerm",
  MersenneTwister: "dice.MersenneTwister",
  MovementSource: "canvas.sources.PointMovementSource",
  NumericTerm: "dice.terms.NumericTerm",
  OperatorTerm: "dice.terms.OperatorTerm",
  ParentheticalTerm: "dice.terms.ParentheticalTerm",
  PermissionConfig: "applications.apps.PermissionConfig",
  PoolTerm: "dice.terms.PoolTerm",
  RollTerm: "dice.terms.RollTerm",
  Sound: "audio.Sound",
  SoundSource: "canvas.sources.PointSoundSource",
  SquareGrid: "grid.SquareGrid",
  StringTerm: "dice.terms.StringTerm",
  UserConfig: "applications.sheets.UserConfig",
  VisionSource: "canvas.sources.PointVisionSource",
  WordTree: "utils.WordTree",
  twist: "dice.MersenneTwister"
}, {since: 12, until: 14});

/** @deprecated since v12 */
for ( const [k, v] of Object.entries(utils) ) {
  if ( !(k in globalThis) ) {
    Object.defineProperty(globalThis, k, {
      get() {
        logCompatibilityWarning(`You are accessing globalThis.${k} which must now be accessed via `
          + `foundry.utils.${k}`, {since: 12, until: 14, once: true});
        return v;
      },
      configurable: true // Allow this to be reconfigured by later compatibility references
    });
  }
}

/** @deprecated since v13 */
addBackwardsCompatibilityReferences({
  Game: "Game",

  // Application API
  _appId: "applications.api.ApplicationV2._appId",
  _maxZ: "applications.api.ApplicationV2._maxZ",

  // Handlebars Utilities
  HandlebarsHelpers: "applications.handlebars",
  getTemplate: "applications.handlebars.getTemplate",
  loadTemplates: "applications.handlebars.loadTemplates",
  renderTemplate: "applications.handlebars.renderTemplate",

  // Application Implementations
  CombatTrackerConfig: "applications.apps.CombatTrackerConfig",
  DocumentSheetConfig: "applications.apps.DocumentSheetConfig",
  FilePicker: "applications.apps.FilePicker.implementation",
  GridConfig: "applications.apps.GridConfig",
  ImagePopout: "applications.apps.ImagePopout",
  DocumentOwnershipConfig: "applications.apps.DocumentOwnershipConfig",

  // UI Elements
  Hotbar: "applications.ui.Hotbar",
  Pause: "applications.ui.GamePause",
  SceneControls: "applications.ui.SceneControls",
  SceneNavigation: "applications.ui.SceneNavigation",
  Players: "applications.ui.Players",
  MainMenu: "applications.ui.MainMenu",
  Notifications: "applications.ui.Notifications",

  // Document Sheets
  ActiveEffectConfig: "applications.sheets.ActiveEffectConfig",
  AdventureExporter: "applications.sheets.AdventureExporter",
  BaseSheet: "applications.sheets.BaseSheet",
  CardConfig: "applications.sheets.CardConfig",
  CardHand: "applications.sheets.CardHandConfig",
  CardPile: "applications.sheets.CardPileConfig",
  CardsConfig: "applications.sheets.CardDeckConfig",
  CombatantConfig: "applications.sheets.CombatantConfig",
  DrawingConfig: "applications.sheets.DrawingConfig",
  FolderConfig: "applications.sheets.FolderConfig",
  MeasuredTemplateConfig: "applications.sheets.MeasuredTemplateConfig",
  MacroConfig: "applications.sheets.MacroConfig",
  NoteConfig: "applications.sheets.NoteConfig",
  PlaylistConfig: "applications.sheets.PlaylistConfig",
  PlaylistSoundConfig: "applications.sheets.PlaylistSoundConfig",
  RollTableConfig: "applications.sheets.RollTableSheet",
  SceneConfig: "applications.sheets.SceneConfig",
  TileConfig: "applications.sheets.TileConfig",
  TokenConfig: "applications.sheets.TokenConfig",
  WallConfig: "applications.sheets.WallConfig",
  JournalImagePageSheet: "applications.sheets.journal.JournalEntryPageImageSheet",
  JournalPDFPageSheet: "applications.sheets.journal.JournalEntryPagePDFSheet",
  JournalVideoPageSheet: "applications.sheets.journal.JournalEntryPageVideoSheet",
  MarkdownJournalPageSheet: "applications.sheets.journal.JournalEntryPageMarkdownSheet",

  // Sidebar Elements
  Sidebar: "applications.sidebar.Sidebar",
  ActorDirectory: "applications.sidebar.tabs.ActorDirectory",
  CardsDirectory: "applications.sidebar.tabs.CardsDirectory",
  ChatLog: "applications.sidebar.tabs.ChatLog",
  CombatTracker: "applications.sidebar.tabs.CombatTracker",
  CompendiumDirectory: "applications.sidebar.tabs.CompendiumDirectory",
  ItemDirectory: "applications.sidebar.tabs.ItemDirectory",
  JournalDirectory: "applications.sidebar.tabs.JournalDirectory",
  MacroDirectory: "applications.sidebar.tabs.MacroDirectory",
  PlaylistDirectory: "applications.sidebar.tabs.PlaylistDirectory",
  RollTableDirectory: "applications.sidebar.tabs.RollTableDirectory",
  SceneDirectory: "applications.sidebar.tabs.SceneDirectory",
  Compendium: "applications.sidebar.apps.Compendium",
  InvitationLinks: "applications.sidebar.apps.InvitationLinks",
  KeybindingsConfig: "applications.sidebar.apps.ControlsConfig",
  Settings: "applications.sidebar.tabs.Settings",
  SupportDetails: "applications.sidebar.apps.SupportDetails",
  ModuleManagement: "applications.sidebar.apps.ModuleManagement",
  ToursManagement: "applications.sidebar.apps.ToursManagement",

  // HUD Applications
  BasePlaceableHUD: "applications.hud.BasePlaceableHUD",
  DrawingHUD: "applications.hud.DrawingHUD",
  TileHUD: "applications.hud.TileHUD",
  TokenHUD: "applications.hud.TokenHUD",

  // AV
  CameraViews: "applications.apps.av.CameraViews",
  CameraPopoutAppWrapper: "applications.apps.av.CameraPopout",

  AVClient: "av.AVClient",
  AVMaster: "av.AVMaster",
  AVSettings: "av.AVSettings",
  SimplePeerAVClient: "av.clients.SimplePeerAVClient",

  // Settings Apps
  AVConfig: "applications.settings.menus.AVConfig",
  DefaultSheetsConfig: "applications.settings.menus.DefaultSheetsConfig",
  DiceConfig: "applications.settings.menus.DiceConfig",
  FontConfig: "applications.settings.menus.FontConfig",
  SettingsConfig: "applications.settings.SettingsConfig",
  DependencyResolution: "applications.settings.DependencyResolution",

  // Application UX
  ContextMenu: "applications.ux.ContextMenu.implementation",
  DragDrop: "applications.ux.DragDrop.implementation",
  Draggable: "applications.ux.Draggable.implementation",
  FormDataExtended: "applications.ux.FormDataExtended",
  HTMLSecret: "applications.ux.HTMLSecret",
  ProseMirrorEditor: "applications.ux.ProseMirrorEditor",
  SearchFilter: "applications.ux.SearchFilter",
  Tabs: "applications.ux.Tabs",
  TextEditor: "applications.ux.TextEditor.implementation",

  // Application v1
  ActorSheet: "appv1.sheets.ActorSheet",
  AdventureImporter: "appv1.sheets.AdventureImporter",
  ItemSheet: "appv1.sheets.ItemSheet",
  JournalSheet: "appv1.sheets.JournalSheet",
  JournalPageSheet: "appv1.sheets.JournalPageSheet",
  JournalTextPageSheet: "appv1.sheets.JournalTextPageSheet",
  JournalTextTinyMCESheet: "appv1.sheets.JournalTextTinyMCESheet",

  // Canvas
  Canvas: "canvas.Canvas",
  SceneManager: "canvas.SceneManager",
  TextureExtractor: "canvas.TextureExtractor",
  FramebufferSnapshot: "canvas.FramebufferSnapshot",
  TextureLoader: "canvas.TextureLoader",
  getTexture: "canvas.getTexture",
  loadTexture: "canvas.loadTexture",
  srcExists: "canvas.srcExists",

  CachedContainer: "canvas.containers.CachedContainer",
  UnboundContainer: "canvas.containers.UnboundContainer",
  FullCanvasObjectMixin: "canvas.containers.FullCanvasObjectMixin",
  PointSourceMesh: "canvas.containers.PointSourceMesh",
  QuadMesh: "canvas.containers.QuadMesh",
  SpriteMesh: "canvas.containers.SpriteMesh",
  ControlIcon: "canvas.containers.ControlIcon",
  ResizeHandle: "canvas.containers.ResizeHandle",
  PreciseText: "canvas.containers.PreciseText",
  GridMesh: "canvas.containers.GridMesh",
  GridHighlight: "canvas.containers.GridHighlight",
  Cursor: "canvas.containers.Cursor",
  DoorControl: "canvas.containers.DoorControl",
  ParticleEffect: "canvas.containers.ParticleEffect",
  AutumnLeavesWeatherEffect: "canvas.containers.AutumnLeavesWeatherEffect",

  CanvasGroupMixin: "canvas.groups.CanvasGroupMixin",
  EffectsCanvasGroup: "canvas.groups.EffectsCanvasGroup",
  EnvironmentCanvasGroup: "canvas.groups.EnvironmentCanvasGroup",
  HiddenCanvasGroup: "canvas.groups.HiddenCanvasGroup",
  InterfaceCanvasGroup: "canvas.groups.InterfaceCanvasGroup",
  OverlayCanvasGroup: "canvas.groups.OverlayCanvasGroup",
  PrimaryCanvasGroup: "canvas.groups.PrimaryCanvasGroup",
  RenderedCanvasGroup: "canvas.groups.RenderedCanvasGroup",
  CanvasVisibility: "canvas.groups.CanvasVisibility",

  CanvasLayer: "canvas.layers.CanvasLayer",
  InteractionLayer: "canvas.layers.InteractionLayer",
  PlaceablesLayer: "canvas.layers.PlaceablesLayer",

  ControlsLayer: "canvas.layers.ControlsLayer",
  CanvasBackgroundAlterationEffects: "canvas.layers.CanvasBackgroundAlterationEffects",
  CanvasColorationEffects: "canvas.layers.CanvasColorationEffects",
  CanvasDarknessEffects: "canvas.layers.CanvasDarknessEffects",
  CanvasIlluminationEffects: "canvas.layers.CanvasIlluminationEffects",
  WeatherEffects: "canvas.layers.WeatherEffects",
  GridLayer: "canvas.layers.GridLayer",

  CanvasDepthMask: "canvas.layers.CanvasDepthMask",
  CanvasOcclusionMask: "canvas.layers.CanvasOcclusionMask",
  CanvasVisionMask: "canvas.layers.CanvasVisionMask",

  DarknessLevelContainer: "canvas.layers.DarknessLevelContainer",

  DrawingsLayer: "canvas.layers.DrawingsLayer",
  NotesLayer: "canvas.layers.NotesLayer",
  SoundsLayer: "canvas.layers.SoundsLayer",
  TemplateLayer: "canvas.layers.TemplateLayer",
  TilesLayer: "canvas.layers.TilesLayer",
  WallsLayer: "canvas.layers.WallsLayer",
  RegionLayer: "canvas.layers.RegionLayer",
  LightingLayer: "canvas.layers.LightingLayer",
  TokenLayer: "canvas.layers.TokenLayer",

  PlaceableObject: "canvas.placeables.PlaceableObject",
  Drawing: "canvas.placeables.Drawing",
  Note: "canvas.placeables.Note",
  Region: "canvas.placeables.Region",
  Tile: "canvas.placeables.Tile",
  Token: "canvas.placeables.Token",
  MeasuredTemplate: "canvas.placeables.MeasuredTemplate",
  Wall: "canvas.placeables.Wall",
  AmbientLight: "canvas.placeables.AmbientLight",
  AmbientSound: "canvas.placeables.AmbientSound",

  Quadtree: "canvas.geometry.Quadtree",
  CanvasQuadtree: "canvas.geometry.CanvasQuadtree",
  UnboundTransform: "canvas.geometry.UnboundTransform",
  ObservableTransform: "canvas.geometry.ObservableTransform",
  LimitedAnglePolygon: "canvas.geometry.LimitedAnglePolygon",
  PolygonMesher: "canvas.geometry.PolygonMesher",
  Ray: "canvas.geometry.Ray",
  PointSourcePolygon: "canvas.geometry.PointSourcePolygon",
  ClockwiseSweepPolygon: "canvas.geometry.ClockwiseSweepPolygon",
  WeilerAthertonClipper: "canvas.geometry.WeilerAthertonClipper",

  CanvasAnimation: "canvas.animation.CanvasAnimation",
  ChatBubbles: "canvas.animation.ChatBubbles",
  SmoothNoise: "canvas.animation.SmoothNoise",

  MouseInteractionManager: "canvas.interaction.MouseInteractionManager",
  RenderFlagsMixin: "canvas.interaction.RenderFlagsMixin",
  RenderFlags: "canvas.interaction.RenderFlags",
  Ping: "canvas.interaction.Ping",
  PulsePing: "canvas.interaction.PulsePing",
  ChevronPing: "canvas.interaction.ChevronPing",
  AlertPing: "canvas.interaction.AlertPing",
  ArrowPing: "canvas.interaction.ArrowPing",
  Ruler: "canvas.interaction.Ruler",

  UserTargets: "canvas.placeables.tokens.UserTargets",
  TokenRing: "canvas.placeables.tokens.TokenRing",
  TokenRingConfig: "canvas.placeables.tokens.TokenRingConfig",
  DynamicRingData: "canvas.placeables.tokens.DynamicRingData",

  RegionGeometry: "canvas.placeables.regions.RegionGeometry",
  RegionPolygonTree: "data.regionShapes.RegionPolygonTree",
  RegionShape: "data.regionShapes.RegionShape",
  RegionMesh: "canvas.placeables.regions.RegionMesh",

  FogManager: "canvas.perception.FogManager",
  PerceptionManager: "canvas.perception.PerceptionManager",
  VisionMode: "canvas.perception.VisionMode",
  DetectionMode: "canvas.perception.DetectionMode",
  DetectionModeAll: "canvas.perception.DetectionModeAll",
  DetectionModeLightPerception: "canvas.perception.DetectionModeLightPerception",
  DetectionModeInvisibility: "canvas.perception.DetectionModeInvisibility",
  DetectionModeBasicSight: "canvas.perception.DetectionModeDarkvision",
  DetectionModeTremor: "canvas.perception.DetectionModeTremor",

  PrimaryCanvasContainer: "canvas.primary.PrimaryCanvasContainer",
  PrimaryGraphics: "canvas.primary.PrimaryGraphics",
  PrimaryParticleEffect: "canvas.primary.PrimaryParticleEffect",
  PrimarySpriteMesh: "canvas.primary.PrimarySpriteMesh",
  PrimaryOccludableObjectMixin: "canvas.primary.PrimaryOccludableObjectMixin",
  PrimaryCanvasObjectMixin: "canvas.primary.PrimaryCanvasObjectMixin",
  CanvasTransformMixin: "canvas.primary.CanvasTransformMixin",

  BatchShaderGenerator: "canvas.rendering.batching.BatchShaderGenerator",
  BatchRenderer: "canvas.rendering.batching.BatchRenderer",

  SMAAFilter: "canvas.rendering.filters.SMAAFilter",
  AbstractBaseFilter: "canvas.rendering.filters.AbstractBaseFilter",
  AbstractBaseMaskFilter: "canvas.rendering.filters.AbstractBaseMaskFilter",
  VisualEffectsMaskingFilter: "canvas.rendering.filters.VisualEffectsMaskingFilter",
  PrimaryCanvasGroupAmbienceFilter: "canvas.rendering.filters.PrimaryCanvasGroupAmbienceFilter",
  GlowOverlayFilter: "canvas.rendering.filters.GlowOverlayFilter",
  InvisibilityFilter: "canvas.rendering.filters.InvisibilityFilter",
  OutlineOverlayFilter: "canvas.rendering.filters.OutlineOverlayFilter",
  TextureTransitionFilter: "canvas.rendering.filters.TextureTransitionFilter",
  VisibilityFilter: "canvas.rendering.filters.VisibilityFilter",
  VisionMaskFilter: "canvas.rendering.filters.VisionMaskFilter",
  VoidFilter: "canvas.rendering.filters.VoidFilter",
  WeatherOcclusionMaskFilter: "canvas.rendering.filters.WeatherOcclusionMaskFilter",
  AlphaBlurFilter: "canvas.rendering.filters.AlphaBlurFilter",
  AlphaBlurFilterPass: "canvas.rendering.filters.AlphaBlurFilterPass",

  BaseShaderMixin: "canvas.rendering.mixins.BaseShaderMixin",
  AdaptiveFragmentChannelMixin: "canvas.rendering.mixins.AdaptiveFragmentChannelMixin",

  AbstractBaseShader: "canvas.rendering.shaders.AbstractBaseShader",

  GridShader: "canvas.rendering.shaders.GridShader",

  AdaptiveLightingShader: "canvas.rendering.shaders.AdaptiveLightingShader",
  AdaptiveBackgroundShader: "canvas.rendering.shaders.AdaptiveBackgroundShader",
  AdaptiveColorationShader: "canvas.rendering.shaders.AdaptiveColorationShader",
  AdaptiveDarknessShader: "canvas.rendering.shaders.AdaptiveDarknessShader",
  AdaptiveIlluminationShader: "canvas.rendering.shaders.AdaptiveIlluminationShader",

  BewitchingWaveColorationShader: "canvas.rendering.shaders.BewitchingWaveColorationShader",
  BewitchingWaveIlluminationShader: "canvas.rendering.shaders.BewitchingWaveIlluminationShader",
  BlackHoleDarknessShader: "canvas.rendering.shaders.BlackHoleDarknessShader",
  ChromaColorationShader: "canvas.rendering.shaders.ChromaColorationShader",
  EmanationColorationShader: "canvas.rendering.shaders.EmanationColorationShader",
  EnergyFieldColorationShader: "canvas.rendering.shaders.EnergyFieldColorationShader",
  FairyLightColorationShader: "canvas.rendering.shaders.FairyLightColorationShader",
  FairyLightIlluminationShader: "canvas.rendering.shaders.FairyLightIlluminationShader",
  FlameColorationShader: "canvas.rendering.shaders.FlameColorationShader",
  FlameIlluminationShader: "canvas.rendering.shaders.FlameIlluminationShader",
  FogColorationShader: "canvas.rendering.shaders.FogColorationShader",
  ForceGridColorationShader: "canvas.rendering.shaders.ForceGridColorationShader",
  GhostLightColorationShader: "canvas.rendering.shaders.GhostLightColorationShader",
  GhostLightIlluminationShader: "canvas.rendering.shaders.GhostLightIlluminationShader",
  HexaDomeColorationShader: "canvas.rendering.shaders.HexaDomeColorationShader",
  LightDomeColorationShader: "canvas.rendering.shaders.LightDomeColorationShader",
  MagicalGloomDarknessShader: "canvas.rendering.shaders.MagicalGloomDarknessShader",
  PulseColorationShader: "canvas.rendering.shaders.PulseColorationShader",
  PulseIlluminationShader: "canvas.rendering.shaders.PulseIlluminationShader",
  RadialRainbowColorationShader: "canvas.rendering.shaders.RadialRainbowColorationShader",
  RevolvingColorationShader: "canvas.rendering.shaders.RevolvingColorationShader",
  RoilingDarknessShader: "canvas.rendering.shaders.RoilingDarknessShader",
  SirenColorationShader: "canvas.rendering.shaders.SirenColorationShader",
  SirenIlluminationShader: "canvas.rendering.shaders.SirenIlluminationShader",
  SmokePatchColorationShader: "canvas.rendering.shaders.SmokePatchColorationShader",
  SmokePatchIlluminationShader: "canvas.rendering.shaders.SmokePatchIlluminationShader",
  StarLightColorationShader: "canvas.rendering.shaders.StarLightColorationShader",
  SunburstColorationShader: "canvas.rendering.shaders.SunburstColorationShader",
  SunburstIlluminationShader: "canvas.rendering.shaders.SunburstIlluminationShader",
  SwirlingRainbowColorationShader: "canvas.rendering.shaders.SwirlingRainbowColorationShader",
  TorchColorationShader: "canvas.rendering.shaders.TorchColorationShader",
  TorchIlluminationShader: "canvas.rendering.shaders.TorchIlluminationShader",
  VortexColorationShader: "canvas.rendering.shaders.VortexColorationShader",
  VortexIlluminationShader: "canvas.rendering.shaders.VortexIlluminationShader",
  WaveColorationShader: "canvas.rendering.shaders.WaveColorationShader",
  WaveIlluminationShader: "canvas.rendering.shaders.WaveIlluminationShader",

  AdaptiveVisionShader: "canvas.rendering.shaders.AdaptiveVisionShader",
  BackgroundVisionShader: "canvas.rendering.shaders.BackgroundVisionShader",
  IlluminationVisionShader: "canvas.rendering.shaders.IlluminationVisionShader",
  ColorationVisionShader: "canvas.rendering.shaders.ColorationVisionShader",

  AmplificationBackgroundVisionShader: "canvas.rendering.shaders.AmplificationBackgroundVisionShader",
  WaveBackgroundVisionShader: "canvas.rendering.shaders.WaveBackgroundVisionShader",
  WaveColorationVisionShader: "canvas.rendering.shaders.WaveColorationVisionShader",

  RegionShader: "canvas.rendering.shaders.RegionShader",
  HighlightRegionShader: "canvas.rendering.shaders.HighlightRegionShader",

  AbstractDarknessLevelRegionShader: "canvas.rendering.shaders.AbstractDarknessLevelRegionShader",
  AdjustDarknessLevelRegionShader: "canvas.rendering.shaders.AdjustDarknessLevelRegionShader",
  IlluminationDarknessLevelRegionShader: "canvas.rendering.shaders.IlluminationDarknessLevelRegionShader",

  BaseSamplerShader: "canvas.rendering.shaders.BaseSamplerShader",
  BaselineIlluminationSamplerShader: "canvas.rendering.shaders.BaselineIlluminationSamplerShader",
  ColorAdjustmentsSamplerShader: "canvas.rendering.shaders.ColorAdjustmentsSamplerShader",
  AmplificationSamplerShader: "canvas.rendering.shaders.AmplificationSamplerShader",
  FogSamplerShader: "canvas.rendering.shaders.FogSamplerShader",

  OccludableSamplerShader: "canvas.rendering.shaders.OccludableSamplerShader",
  DepthSamplerShader: "canvas.rendering.shaders.DepthSamplerShader",
  PrimaryBaseSamplerShader: "canvas.rendering.shaders.PrimaryBaseSamplerShader",
  TokenRingSamplerShader: "canvas.rendering.shaders.TokenRingSamplerShader",

  AbstractWeatherShader: "canvas.rendering.shaders.AbstractWeatherShader",
  WeatherShaderEffect: "canvas.rendering.shaders.WeatherShaderEffect",
  FogShader: "canvas.rendering.shaders.FogShader",
  RainShader: "canvas.rendering.shaders.RainShader",
  SnowShader: "canvas.rendering.shaders.SnowShader",

  BLEND_MODES: "canvas.rendering.BLEND_MODES",

  TextureCompressor: "canvas.rendering.workers.TextureCompressor",

  // Documents
  ClientDocumentMixin: "documents.abstract.ClientDocumentMixin",
  CanvasDocumentMixin: "documents.abstract.CanvasDocumentMixin",
  DirectoryCollectionMixin: "documents.abstract.DirectoryCollectionMixin",
  DocumentCollection: "documents.abstract.DocumentCollection",
  WorldCollection: "documents.abstract.WorldCollection",
  Actors: "documents.collections.Actors",
  CardStacks: "documents.collections.CardStacks",
  CombatEncounters: "documents.collections.CombatEncounters",
  FogExplorations: "documents.collections.FogExplorations",
  Folders: "documents.collections.Folders",
  Items: "documents.collections.Items",
  Journal: "documents.collections.Journal",
  Macros: "documents.collections.Macros",
  Messages: "documents.collections.ChatMessages",
  Playlists: "documents.collections.Playlists",
  RollTables: "documents.collections.RollTables",
  Scenes: "documents.collections.Scenes",
  WorldSettings: "documents.collections.WorldSettings",
  Users: "documents.collections.Users",
  CompendiumCollection: "documents.collections.CompendiumCollection",
  CompendiumFolderCollection: "documents.collections.CompendiumFolderCollection",
  CompendiumPacks: "documents.collections.CompendiumPacks",

  // New User Experience
  NewUserExperience: "nue.NewUserExperienceManager",
  Tours: "nue.ToursCollection",
  Tour: "nue.Tour",
  CanvasTour: "nue.tours.CanvasTour",
  SetupTour: "nue.tours.SetupTour",
  SidebarTour: "nue.tours.SidebarTour",

  // Packages
  ClientPackageMixin: "packages.ClientPackageMixin",
  Module: "packages.Module",
  System: "packages.System",
  World: "packages.World",
  PACKAGE_TYPES: "packages.PACKAGE_TYPES",

  // Helpers
  AsyncWorker: "helpers.AsyncWorker",
  ClientIssues: "helpers.ClientIssues",
  ClientKeybindings: "helpers.interaction.ClientKeybindings",
  ClientSettings: "helpers.ClientSettings",
  ClipboardHelper: "helpers.interaction.ClipboardHelper",
  DocumentIndex: "helpers.DocumentIndex",
  GameTime: "helpers.GameTime",
  GamepadManager: "helpers.interaction.GamepadManager",
  ImageHelper: "helpers.media.ImageHelper",
  KeyboardManager: "helpers.interaction.KeyboardManager",
  Localization: "helpers.Localization",
  MouseManager: "helpers.interaction.MouseManager",
  SocketInterface: "helpers.SocketInterface",
  TooltipManager: "helpers.interaction.TooltipManager.implementation",
  VideoHelper: "helpers.media.VideoHelper",
  WorkerManager: "helpers.WorkerManager",

  // Utils
  SortingHelpers: "utils.SortingHelpers",
  saveDataToFile: "utils.saveDataToFile",
  readTextFromFile: "utils.readTextFromFile"
}, {since: 13, until: 15});

/**
 * If modules or systems overwrite compatibility references added to globalThis, track them here.
 * @type {Record<string, any>}
 */
const compatibilityReferenceOverrides = {};

/**
 * Add Foundry Virtual Tabletop ESModule exports to the global scope for backwards compatibility
 * @param {object} mapping      A mapping of class name to ESModule export path
 * @param {object} [options]    Options which modify compatible references
 * @param {number} [options.since]  Deprecated since generation
 * @param {number} [options.until]  Backwards compatibility provided until generation
 */
function addBackwardsCompatibilityReferences(mapping, {since, until}={}) {
  const properties = Object.fromEntries(Object.entries(mapping).map(([name, path]) => {
    return [name, {
      get() {
        if ( name in compatibilityReferenceOverrides ) return compatibilityReferenceOverrides[name];
        logCompatibilityWarning(`You are accessing the global "${name}" which is now namespaced under `
          + `foundry.${path}`, {since, until, once: true});
        return getProperty(globalThis.foundry, path);
      },
      set(value) {
        logCompatibilityWarning(`You are assigning globalThis.${name} which is now namespaced under `
          + `foundry.${path}`, {since, until});
        compatibilityReferenceOverrides[name] = value;
      }
    }];
  }));
  Object.defineProperties(globalThis, properties);
}

/* ----------------------------------------- */
/*  Dispatch Ready Event                     */
/* ----------------------------------------- */

// Dispatch framework ready event
console.log(CONST$1.ASCII);
console.log(`${CONST$1.vtt} | Foundry Virtual Tabletop ESModule loaded`);

// Require form submission to transact submit events
HTMLFormElement.prototype.submit = HTMLFormElement.prototype.requestSubmit;
globalThis.dispatchEvent(new Event("FoundryFrameworkLoaded"));

window.addEventListener("DOMContentLoaded", async () => {
  // Establish a session
  const Game$1 = Game;
  const cookies = Game$1.getCookies();
  const sessionId = cookies.session ?? null;
  if ( !sessionId ) return window.location.href = getRoute("join");
  console.log(`${CONST$1.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 Game view controller
  if ( CONST$1.SETUP_VIEWS.includes(gameView) ) globalThis.game = await Setup.create(gameView, sessionId);
  else if ( CONST$1.GAME_VIEWS.includes(gameView) ) globalThis.game = await Game$1.create(gameView, sessionId);
  else throw new Error(`Unsupported view "${gameView}" has no defined Game controller.`);
  await globalThis.game.initialize();
}, {once: true, passive: true});

export { constants as CONST, Game, _module$S as abstract, _module$z as applications, _module$w as appv1, _module$t as audio, _module$u as av, _module$8 as canvas, config, _module$U as data, _module$6 as dice, _module$W as documents, _module$P as grid, _module$4 as helpers, _module$2 as nue, _module$T as packages, _module$Q as prosemirror, _module as setup, _types as types, ui$2 as ui, _module$R as utils };
