import { P as PluginKey, 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, o as Plugin, p as wrapIn, q as deleteTable, v as addColumnBefore, x as addColumnAfter, y as deleteColumn, z as addRowBefore, A as addRowAfter, B as deleteRow, C as mergeCells, D as splitCell, T as TextSelection, E as Transform, F as liftTarget, G as autoJoin, R as ResolvedPos, H as tableNodes, S as Schema, J as splitListItem, K as DOMParser$2, L as DOMSerializer, M as Slice, N as ReplaceStep, O as history$1, Q as gapCursor, U as dropCursor, V as AllSelection, W as EditorState, X as EditorView, Y as Step, Z as index, _ as index$1, $ as index$2, a0 as index$3, a1 as index$4, a2 as index$5, a3 as index$6, a4 as basicSetup, a5 as markdown, a6 as json, a7 as linter, a8 as javascript, a9 as lintGutter, aa as esLint, ab as Linter, ac as html, ad as syntaxHighlighting, ae as HighlightStyle, af as tags, ag as markdownLanguage, ah as jsonLanguage, ai as javascriptLanguage, aj as htmlLanguage, ak as jsonParseLinter, al as indentUnit, am as keymap$1, an as indentWithTab, ao as EditorView$1, ap as Compartment, aq as EditorSelection, ar as animejs$1 } from './vendor.mjs';

/**
 * 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$2(other) {
  if ( !Array.isArray(other) ) return false;
  if ( this.length !== other.length ) return false;
  return this.every((v0, i) => foundry.utils.equals(v0, other[i]));
}

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

/**
 * 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 + (w * (b - a));
}

/**
 * Used by {@link nextAfter}.
 * @type {DataView}
 */
const nextAfterView = new DataView(new ArrayBuffer(8));

/**
 * Returns the number adjacent to `x` in the direction of `y`.
 * @param {number} x
 * @param {number} y
 * @returns {number}
 */
function nextAfter(x, y) {
  x = Number(x);
  y = Number(y);
  if ( Number.isNaN(x) || Number.isNaN(y) ) return NaN;
  if ( x === y ) return x;
  if ( x === 0 ) return Math.sign(y) * Number.MIN_VALUE;
  nextAfterView.setFloat64(0, x, false);
  let bits = nextAfterView.getBigUint64(0, false);
  bits += BigInt(Math.sign(x) * Math.sign(y - x));
  nextAfterView.setBigUint64(0, bits, false);
  return nextAfterView.getFloat64(0, false);
}

/**
 * Returns the number adjacent to `x` in the direction of -Infinity.
 * @param {number} x
 * @returns {number}
 */
function nextDown(x) {
  return nextAfter(x, -Infinity);
}

/**
 * Returns the number adjacent to `x` in the direction of +Infinity.
 * @param {number} x
 * @returns {number}
 */
function nextUp(x) {
  return nextAfter(x, Infinity);
}

/**
 * Transform an angle in degrees to be bounded within the domain [0, 360)
 * @param {number} degrees  An angle in degrees
 * @returns {number}        The same angle on the range [0, 360)
 */
function normalizeDegrees(degrees) {
  const d = degrees % 360;
  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));
}

/**
 * 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
  },
  mix: {
    value: mix,
    configurable: true,
    writable: true
  },
  nextAfter: {
    value: nextAfter,
    configurable: true,
    writable: true
  },
  nextDown: {
    value: nextDown,
    configurable: true,
    writable: true
  },
  nextUp: {
    value: nextUp,
    configurable: true,
    writable: true
  },
  normalizeDegrees: {
    value: normalizeDegrees,
    configurable: true,
    writable: true
  },
  normalizeRadians: {
    value: normalizeRadians,
    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
 * @this {number}
 * @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
 * @this {number}
 * @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
 * @this {number}
 * @param {number} digits     The number of characters desired
 * @returns {string}          The zero-padded number
 */
function paddedString(digits) {
  const sign = this < 0 ? "-" : "";
  const padded = Math.abs(this).toString().padStart(digits, "0");
  return `${sign}${padded}`;
}


/** @type {Intl.NumberFormat} */
let signedStringFormatter;

/**
 * Return a locally formatted string prefaced by the explicit sign of the number (+) or (-).
 * @this {number}
 * @returns {string} The signed (except in case of zero) number as a locally formatted string
 */
function signedString() {
  signedStringFormatter ??= new Intl.NumberFormat(game.i18n.lang, {signDisplay: "exceptZero", useGrouping: false});
  return signedStringFormatter.format(this);
}

/**
 * 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.
 * @this {number}
 * @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.
 * @this {number}
 * @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 ( Array.isArray(n) ) 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
 * @template T
 * @this {Set<T>}
 * @param {Set<unknown>} other Some other set to compare against
 * @returns {boolean}       Are the sets equal?
 */
function equals$1(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
 * @this {Set<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.
 * @template T
 * @this {Set<T>}
 * @param {Set<unknown>} 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.
 * @template T
 * @this {Set<T>}
 * @param {Set<unknown>} 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
 * @this {Set<T>}
 * @returns {T[]}           The set elements as an array.
 */
function toObject() {
  return this.values().toArray();
}

/**
 * Test whether every element in this Set satisfies a certain test criterion.
 * @template T
 * @this {Set<T>}
 * @param {(element: T, index: number, set: Set<T>) => boolean} test The test criterion to apply.
 * @returns {boolean}  Does every element in the set satisfy the test criterion?
 */
function every(test) {
  return this.values().every((v, i) => test(v, i, this));
}

/**
 * Filter this set to create a subset of elements which satisfy a certain test criterion.
 * @template T
 * @this {Set<T>}
 * @param {(element: T, index: number, set: Set) => boolean} test The test criterion to apply.
 * @returns {Set<T>} A new Set containing only elements which satisfy the test criterion.
 * @see Array#filter
 */
function filter(test) {
  return new Set(this.values().filter((v, i) => test(v, i, this)));
}

/**
 * Find the first element in this set which satisfies a certain test criterion.
 * @template T
 * @this {Set<T>}
 * @param {(element: T, index: number, set: Set<T>) => boolean} test The test criterion to apply.
 * @returns {T|undefined} The first element in the set which satisfies the test criterion, or undefined.
 * @see Array#find
 */
function find(test) {
  return this.values().find((v, i) => test(v, i, this));
}

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

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

/**
 * Test whether any element in this Set satisfies a certain test criterion.
 * @template T
 * @this {Set<T>}
 * @param {(element: T, index: number, set: Set<T>) => boolean} test The test criterion to apply.
 * @returns {boolean} Does any element in the set satisfy the test criterion?
 * @see Array#some
 */
function some(test) {
  return this.values().some((v, i) => test(v, i, this));
}

// Assign primitives to Set prototype
Object.defineProperties(Set.prototype, {
  equals: {value: equals$1},
  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 diacritics from a string, converting accented characters to their base equivalents.
 * For example, "Bônus de Força" becomes "Bonus de Forca".
 * @returns {string}
 * @this {string}
 */
function stripDiacritics() {
  return this.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}

/**
 * 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},
  stripDiacritics: {value: stripDiacritics},
  stripScripts: {value: stripScripts},
  slugify: {value: slugify}
});

/**
 * @deprecated since v14 until v16
 * @ignore
 */
function parseSafe(url) {
  foundry.utils.logCompatibilityWarning("URL.parseSafe is deprecated. Please use the native URL.parse instead.", {
    since: 14, until: 16
  });
  return URL.parse(url);
}

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

/**
 * @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 ( Array.isArray(color) && (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)]);
  }
}

/**
 * A symbol used to reference the operator value which ensures it does not collide with a proxied key of that value.
 * @type {symbol}
 */
const OPERATOR_VALUE = Symbol("DataFieldOperatorValue");

/**
 * A unique string used in serialization to identify that an object should be deserialized to a DataFieldOperator.
 * @type {string}
 */
const OPERATOR_IDENTIFIER = "__$OPERATOR$__";

/**
 * A base class used for all special database operations.
 */
class DataFieldOperator {
  constructor(value) {
    /**
     * The value that a field should be assigned to.
     * @type {any}
     */
    this[OPERATOR_VALUE] = value;
  }

  /**
   * Represent the data of the DataFieldOperator as an object suitable for JSON serialization.
   * @returns {object}     Structured data that can be serialized into JSON
   */
  toJSON() {
    const v = this[OPERATOR_VALUE];
    const value = typeof v?.toJSON === "function" ? v.toJSON() : v;
    return {[OPERATOR_IDENTIFIER]: this.constructor.name, value};
  }

  /**
   * Create a DataFieldOperator using a provided value.
   * @param {any} value
   * @returns {DataFieldOperator}
   */
  static create(value) {
    return new this(value);
  }

  /**
   * Retrieve the inner value of the DataFieldOperator, or return the value passed if not a DataFieldOperator.
   * @param {any} value
   * @returns {any}
   */
  static get(value) {
    return value instanceof DataFieldOperator ? value[OPERATOR_VALUE] : value;
  }

  /**
   * Assign the inner value of the DataFieldOperator.
   * @param {DataFieldOperator} operator
   * @param {any} value
   * @returns {any}
   */
  static set(operator, value) {
    return operator[OPERATOR_VALUE] = value;
  }

  /**
   * A comparison helper function that asserts whether two values are equal when one or both values may be
   * DataFieldOperator instances.
   * @param {DataFieldOperator|any} a
   * @param {DataFieldOperator|any} b
   */
  static equals(a, b) {
    const av = a instanceof DataFieldOperator ? DataFieldOperator.get(a) : a;
    const bv = b instanceof DataFieldOperator ? DataFieldOperator.get(b) : b;
    return foundry.utils.equals(av, bv);
  }
}

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

/**
 * Force the deletion of a certain DataModel field, resetting its value back to undefined.
 */
class ForcedDeletion extends DataFieldOperator {
  constructor(_value) {
    super();
  }
}

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

/**
 * Force the replacement of a certain DataModel field, assigning it to some explicit value without inner recursion.
 */
class ForcedReplacement extends DataFieldOperator {
  constructor(value) {
    super(ForcedReplacement.get(value));
  }

  /**
   * Create a ForcedReplacement instance that is wrapped in a Proxy so that it can be inspected.
   * @param {any} value
   * @returns {ForcedReplacement}
   */
  static create(value) {
    return new Proxy(new ForcedReplacement(value), {
      get(target, prop, receiver) {
        if ( Reflect.has(target, prop) ) return Reflect.get(target, prop, receiver);
        const v = target[OPERATOR_VALUE];
        if ( v && (typeof v === "object") ) return Reflect.get(v, prop, receiver);
      },
      has(target, key) {
        if ( Reflect.has(target, key) ) return true;
        const v = target[OPERATOR_VALUE];
        if ( v && (typeof v === "object") ) return Reflect.has(v, key);
      },
      set(target, prop, value) {
        if ( Reflect.has(target, prop) ) return Reflect.set(target, prop, value);
        const v = target[OPERATOR_VALUE];
        if ( v && (typeof v === "object") ) return Reflect.set(v, prop, value);
      },
      ownKeys(target) {
        const v = target[OPERATOR_VALUE];
        return ( v && (typeof v === "object") ) ? Reflect.ownKeys(v) : [];
      },
      getOwnPropertyDescriptor(target, prop) {
        const v = target[OPERATOR_VALUE];
        if ( v && (typeof v === "object") ) {
          const descriptor = Reflect.getOwnPropertyDescriptor(v, prop);
          // Satisfy trap invariants.
          if ( descriptor?.configurable === false ) Object.defineProperty(target, prop, descriptor);
          return descriptor;
        }
      }
    });
  }
}

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

/**
 * Reconstruct a DataFieldOperator instance from a serialized object.
 * @param {{[OPERATOR_IDENTIFIER]: string, value: any}} obj
 */
function reconstructOperator(obj) {
  const operator = obj[OPERATOR_IDENTIFIER];
  if ( operator === "ForcedDeletion" ) return new ForcedDeletion();
  else if ( operator === "ForcedReplacement" ) return ForcedReplacement.create(obj.value);
  else throw new Error(`Unrecognized DataFieldOperator identifier "${operator}" provided`);
}

var operators = /*#__PURE__*/Object.freeze({
  __proto__: null,
  DataFieldOperator: DataFieldOperator,
  ForcedDeletion: ForcedDeletion,
  ForcedReplacement: ForcedReplacement,
  OPERATOR_IDENTIFIER: OPERATOR_IDENTIFIER,
  OPERATOR_VALUE: OPERATOR_VALUE,
  reconstructOperator: reconstructOperator
});

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

/** @module helpers */


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

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

/**
 * Recurse through an object, applying all special DataFieldOperator values.
 * ForcedDeletion values (or deprecated "-=" keys) are removed from the object
 * ForcedReplacement values (or deprecated "==" keys) are updated in the object
 * @param {*} obj
 * @returns {*}
 */
function applyDataOperators(obj) {
  if ( obj instanceof DataFieldOperator ) obj = DataFieldOperator.get(obj);
  if ( Array.isArray(obj) ) return obj.map(applyDataOperators);
  if ( !isPlainObject(obj) ) return obj;
  const clone = {};
  for ( let key in obj ) {
    let value = obj[key];
    if ( isDeletionKey(key) ) {
      const operator = _migrateDeletionKey(key, value);
      key = operator.key;
      value = operator.value;
    }
    if ( value instanceof ForcedDeletion ) delete clone[key];
    else clone[key] = applyDataOperators(value);
  }
  return clone;
}

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

/**
 * 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 and expose a cancel method.
 * Delay execution of the callback function until the function has not been called for delay milliseconds.
 *
 * @example Classic usage
 * const updateSearch = debounce(query => this.#runSearch(query), 250);
 * updateSearch("goblin");
 * updateSearch("goblin king");
 *
 * @example Cancel a pending call
 * const savePosition = debounce(() => this.#save(), 1500);
 * savePosition();
 *
 * // Later, if you need to cancel a pending call before the debounced callback fires
 * savePosition.cancel();
 *
 * @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 with a cancel method
 */
function debounce(callback, delay) {
  let timeoutId;
  function debounced(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      callback.apply(this, args);
    }, delay);
  }
  debounced.cancel = function() {
    clearTimeout(timeoutId);
  };
  return debounced;
}

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

/**
 * 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 ( Array.isArray(obj) ) obj.forEach(o => _deepFreeze(o, strict, _d));
  else if ( typeof obj === "object" ) {
    if ( obj === null ) return null;

    // Unsupported advanced objects
    if ( !isPlainObject(obj) ) {
      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 ( Array.isArray(obj) ) obj.forEach(o => _deepSeal(o, strict, _d));
  else if ( typeof obj === "object" ) {
    if ( obj === null ) return null;

    // Unsupported advanced objects
    if ( !isPlainObject(obj) ) {
      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
 * @param {boolean} [options.prune=false]   Delete object entries with undefined values
 * @returns {T}                             The clone of that data
 */
function deepClone(original, {strict=false, prune=false}={}) {
  return _deepClone(original, strict, prune, 0);
}

/**
 * An inner function does the work of the deepClone operation and is optimized to avoid object creation.
 * @param {unknown} original      Some sort of data
 * @param {boolean} strict        Throw an Error if deepClone is unable to clone something instead of returning the
 *                                original
 * @param {boolean} [prune=false] Delete entries with undefined values
 * @param {number} _d             The depth tracker
 */
function _deepClone(original, strict, prune, _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 ( Array.isArray(original) ) return original.map(o => _deepClone(o, strict, prune, _d));

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

  // Unsupported advanced objects
  if ( !isPlainObject(original) ) {
    if ( strict ) throw new Error("deepClone cannot clone advanced objects");
    return original;
  }

  // Other objects
  const clone = {};
  for ( const k of Object.keys(original) ) {
    if ( (original[k] === undefined) && prune ) continue;
    clone[k] = _deepClone(original[k], strict, prune, _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.
 *                                Supports values that are DataFieldOperator instances.
 * @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 {boolean} [options.bidirectional=false] Create a bidirectional diff (or "patch" in Unix parlance), setting a
 *                                                forced-deletion value where an entry is defined in the original object
 *                                                but not the other.
 * @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, bidirectional=false, _d=0}={}) {
  return _diffObject(original, other, inner, deletionKeys, bidirectional, _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 {boolean} bidirectional
 * @param {number} _d
 * @returns {object}
 */
function _diffObject(original, other, inner, deletionKeys, bidirectional, _d=0) {
  if ( _d > 100 ) throw new Error("Maximum diffObject depth exceeded. Be careful of cyclical data structures.");
  const diff = {};
  for ( let key in other ) {
    let value = other[key];

    // Backwards compatibility for legacy deletion keys
    if ( isDeletionKey(key) ) {
      const operator = _migrateDeletionKey(key, value);
      key = operator.key;
      value = operator.value;
    }

    // Forced Deletion or Replacement
    if ( value instanceof DataFieldOperator ) {
      if ( (value instanceof ForcedReplacement) || (key in original) ) diff[key] = value;
      continue;
    }

    // Recursive differencing
    const [isDifferent, difference] = _diffValue(original, key, other[key], inner, deletionKeys, bidirectional, _d);
    if ( isDifferent ) diff[key] = difference;
  }
  if ( bidirectional ) {
    for ( const key in original ) {
      if ( (other[key] === undefined) && (original[key] !== undefined) ) diff[key] = new ForcedDeletion();
    }
  }
  return diff;
}

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

/**
 * Identify differences in individual keys.
 * @param {object} original
 * @param {string} key
 * @param {any} v1
 * @param {boolean} inner
 * @param {boolean} deletionKeys
 * @param {boolean} bidirectional
 * @param {number} _d
 * @returns {[boolean, any]}
 */
function _diffValue(original, key, v1, inner, deletionKeys, bidirectional, _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, applyDataOperators(v1)];

  // Use an explicitly provided equality testing method, if available
  if ( typeof v0?.equals === "function" ) {
    if ( v0.equals(v1) ) return [false, undefined];
    return [true, applyDataOperators(v1)];
  }

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

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

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

/**
 * Test if two values are equivalent.
 *
 * This helper supports equality testing for:
 * 1. Primitive data types (number, string, boolean, undefined)
 * 2. Simple objects (Object prototype, null)
 * 3. Complex objects which expose an `equals` method (Array, Set, Color, etc...)
 *
 * This method compares object `b` with object `a`, so in cases where an equality testing method is used it is called
 * as `a.equals(b).
 *
 * @param {any} a  The first value
 * @param {any} b  The second value
 * @returns {boolean}
 */
function equals(a, b) {
  if ( a === b ) return true;                   // Strict equality
  const ta = getType(a);
  const tb = getType(b);
  if ( ta !== tb ) return false;                // Mismatched types are not equal

  // Complex objects cannot be treated as equal unless they expose an equality testing function
  if ( typeof a.equals === "function" ) return a.equals(b);
  if ( ta !== "Object" ) return false;

  // For simple objects, test that their keys are individually equivalent
  if ( Object.keys(a).length !== Object.keys(b).length ) return false;
  return iterateEntries(a).every(([k, v0]) => equals(v0, b[k]));
}

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

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


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

/**
 * Migrate a key, value pair to a new key and value using DataFieldOperator classes.
 * @param {string} k
 * @param {any} v
 * @returns {{key: string, value: ForcedDeletion|ForcedReplacement}}
 * @internal
 */
function _migrateDeletionKey(k, v) {
  const key = k.slice(2);
  let value;
  switch ( k[0] ) {
    case "-":
      if ( v !== null ) throw new Error("Removing a key using the deprecated -= deletion syntax requires the value "
        + " of that deletion key to be null, for example {-=key: null}");
      logCompatibilityWarning(`You are specifying a forced deletion key "${k}" using legacy syntax which should be `
        + `migrated to instead pass {${key}: _del} or {${key}: new foundry.data.operators.ForcedDeletion()}.`);
      value = new ForcedDeletion();
      break;
    case "=":
      logCompatibilityWarning(`You are specifying a forced replacement key "${k}" using legacy syntax which should be `
        + `migrated to instead pass {${key}: _replace(value)} or {${key}: foundry.data.operators.ForcedReplacement.create(value)}.`);
      value = ForcedReplacement.create(v);
      break;
  }
  return {key, value};
}

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

/**
 * 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.parse(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 ( !isPlainObject(value) ) 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);
}

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

/**
 * Expand dot-notation keys within a plain object, mutating it in place.
 * Use a lazy allocation strategy that only copies keys when some expansion is required.
 * Performs the dot-key check and expansion in a single forward pass.
 * Keys without dots are passed through via direct assignment.
 * Keys containing dots are expanded via setProperty.
 * @param {object} data                       The object to expand in place.
 * @param {object} [options]                  Options for how expansion occurs.
 * @param {boolean} [options.shallow=false]   Whether to only expand top-level keys.
 * @returns {boolean}                         Whether any expansion was performed at any level.
 */
function expandObjectInPlace(data, {shallow=false}={}) {
  let copy = null;
  let expanded = false;
  for ( const k in data ) {
    if ( !Object.hasOwn(data, k) ) continue;
    const v = data[k];
    if ( !shallow && (isPlainObject(v) || Array.isArray(v)) ) {
      expanded = expandObjectInPlace(v, {shallow: false}) || expanded;
    }

    // If we have already started expanding, continue directly in single forward pass.
    if ( copy !== null ) {
      if ( k.includes(".") ) setProperty(copy, k, v);
      else copy[k] = v;
    }

    // Identify the first dot-notation key and retroactively allocate copy.
    else if ( k.includes(".") ) {
      copy = {};
      for ( const j in data ) {
        if ( j === k ) break;
        if ( Object.hasOwn(data, j) ) copy[j] = data[j];
      }
      setProperty(copy, k, v);
    }
  }
  if ( copy === null ) return expanded;
  for ( const k in data ) delete data[k];
  Object.assign(data, copy);
  return true;
}

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

/**
 * 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.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, {templateValues=false}={}) {

  // Validate input
  if ( !isPlainObject(source) || !isPlainObject(template) ) {
    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];
      if ( has && isPlainObject(v) && isPlainObject(x) ) filtered[k] = _filter(v, x, {});
      else if ( has ) filtered[k] = templateValues ? x : 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) ) {
    if ( isPlainObject(v) ) {
      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            A 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("/");
}

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

/**
 * Test whether the given element is an instance of a given HTMLElement class in a cross-window way.
 * @param {HTMLElement} element            The element to test.
 * @param {string|Constructor} tagOrClass  The tag name or HTMLElement subclass to test.
 * @returns {boolean}
 */
function isElementInstanceOf(element, tagOrClass) {
  if ( typeof tagOrClass === "string" ) {
    return element.tagName === tagOrClass.toUpperCase();
  }

  let proto = element.constructor;
  do {
    if ( proto.name === tagOrClass.name ) return true;
    proto = Object.getPrototypeOf(proto);
  } while ( proto );
  return false;
}

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

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

const plainObjectPrototype = Object.getPrototypeOf({});

/**
 * Determine whether a value is a plain object; that is, one with a constructor of Object of null.
 * @param {unknown} value
 * @returns {boolean}
 */
function isPlainObject(value) {
  if ( !value ) return false;
  const prototype = Object.getPrototypeOf(value);
  return (prototype === plainObjectPrototype) || (prototype === null);
}

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

/**
 * 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 ( isPlainObject(variable) ) return "Object"; // Plain objects, including those with a null prototype.
  if ( Array.isArray(variable) ) return "Array";

  // Match prototype instances
  for ( const [cls, type] of typePrototypes ) {
    if ( variable instanceof cls ) return type;
  }
  if ( "HTMLElement" in globalThis ) {
    // Detect if a value is an HTMLElement in a way that is agnostic of whichever Document it was created in.
    if ( isElementInstanceOf(variable, 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;
  const parts = key.split(".");
  for ( let i = 0; i < parts.length; i++ ) {
    const p = parts[i];
    if ( !target || (typeof target !== "object") || !(p in target) ) return false;
    if ( i !== (parts.length - 1) ) target = target[p];
  }
  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 ( target[p] === undefined ) target[p] = {};
      return target[p];
    }, object);
  }

  // Update the target
  if ( 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|null|undefined} v1    The target version
 * @param {number|string|null|undefined} v0    The reference version
 * @param {object} [options]                   Additional options which affect the comparison.
 * @param {boolean} [options.majorOnly]        Compare only the major version numbers.
 * @returns {boolean}                          Is v1 a more advanced version than v0?
 */
function isNewerVersion(v1, v0, { majorOnly=false }={}) {
  if ( (v1 === null) || (v1 === undefined) ) return false;
  if ( (v0 === null) || (v0 === undefined) ) return true;

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

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

  // Optionally compare only the major version numbers.
  if ( majorOnly ) {
    v1Parts = v1Parts.slice(0, 1);
    v0Parts = v0Parts.slice(0, 1);
  }

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

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

/**
 * Object entries generator.
 * @param {object} obj
 * @yields {[string, any]}
 */
function *objectEntries(obj) {
  for ( const key in obj ) {
    if ( Object.hasOwn(obj, key) ) yield [key, obj[key]];
  }
}

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

/**
 * Stream object entries.
 * @param {object} obj
 * @returns {IteratorObject<[string, any], void, unknown>}
 */
function iterateEntries(obj) {
  if ( obj == null ) throw new TypeError("Cannot convert undefined or null to object");
  return Iterator.from(objectEntries(obj));
}

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

/**
 * Object keys generator.
 * @param {object} obj
 * @yields {string}
 */
function *objectKeys(obj) {
  for ( const key in obj ) {
    if ( Object.hasOwn(obj, key) ) yield key;
  }
}

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

/**
 * Stream object keys.
 * @param {object} obj
 * @returns {IteratorObject<string, void, unknown>}
 */
function iterateKeys(obj) {
  if ( obj == null ) throw new TypeError("Cannot convert undefined or null to object");
  return Iterator.from(objectKeys(obj));
}

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

/**
 * Object values generator.
 * @param {object} obj
 * @yields {any}
 */
function *objectValues(obj) {
  for ( const key in obj ) {
    if ( Object.hasOwn(obj, key) ) yield obj[key];
  }
}

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

/**
 * Stream object values.
 * @param {object} obj
 * @returns {IteratorObject<any, void, unknown>}
 */
function iterateValues(obj) {
  if ( obj == null ) throw new TypeError("Cannot convert undefined or null to object");
  return Iterator.from(objectValues(obj));
}

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

/**
 * 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.applyOperators=false]    Control whether to apply the effects of DataFieldOperator values
 *                                                    (if true) or retain those operators (if false) in the resulting
 *                                                    merged 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": new ForcedDeletion()}, {applyOperators: true});   // {k2: "v2"}
 * ```
 *
 * @example Explicitly replacing an inner object key
 * ```js
 * mergeObject({k1: {i1: "v1"}}, {"k1": ForcedReplacement.create({i2: "v2"})}, {applyOperators: true}); // {k1: {i2: "v2"}}
 * ```
 */
function mergeObject(original, other={}, {insertKeys=true, insertValues=true, overwrite=true, recursive=true,
  inplace=true, enforceTypes=false, applyOperators=false, performDeletions=false}={}, _d=0) {
  if (!(original instanceof Object) || !(other instanceof Object)) {
    throw new Error("One of original or other are not Objects!");
  }

  // Backwards compatibility for performDeletions option
  if ( performDeletions ) {
    logCompatibilityWarning("You have passed the performDeletions option to foundry.utils.mergeObject "
      + "which is deprecated and renamed to applyOperators.", {since: 14, until: 16});
    applyOperators ??= performDeletions;

  }
  const options = {insertKeys, insertValues, overwrite, recursive, inplace, enforceTypes, applyOperators};

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

  // Iterate over the other object
  for ( let k of Object.keys(other) ) {
    let v = other[k];
    if ( isDeletionKey(k) ) {
      const operator = _migrateDeletionKey(k, v);
      k = operator.key;
      v = operator.value;
    }
    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, options) {

  // Take no action if insertion is not possible or for ForcedDeletion operators
  const canInsert = ((_d <= 1) && options.insertKeys) || ((_d > 1) && options.insertValues);
  if ( !canInsert || (v instanceof ForcedDeletion) ) return;

  // Enact or retain ForcedReplacement operators
  if ( v instanceof ForcedReplacement ) {
    original[k] = options.applyOperators ? applyDataOperators(v) : v;
    return;
  }

  // Insert other values directly as clones
  original[k] = options.applyOperators ? applyDataOperators(v) : deepClone(v);
}

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

  // ForcedDeletion operator
  if ( v instanceof ForcedDeletion ) {
    if ( options.overwrite === false ) return; // A merge with {overwrite: false} protects against ForcedDeletion
    if ( options.applyOperators ) delete original[k];
    else original[k] = v;
    return;
  }

  // ForcedReplacement operator
  if ( v instanceof ForcedReplacement ) {
    if ( options.overwrite === false ) return; // A merge with {overwrite: false} protects against ForcedReplacement
    original[k] = options.applyOperators ? applyDataOperators(v) : v;
    return;
  }

  // Recursively merge an inner object
  if ( ov && ox && options.recursive ) return mergeObject(x, v, {...options, inplace: true}, _d);

  // Overwrite an existing value, if allowed
  if ( options.overwrite ) {
    if ( (tx !== "undefined") && (tv !== tx) && options.enforceTypes ) {
      throw new Error("Mismatched data types encountered during object merge.");
    }
    original[k] = options.applyOperators ? applyDataOperators(v) : deepClone(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.parse(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 ( typeof uuid !== "string" ) return null;

  // Relative UUID
  if ( uuid.startsWith(".") ) {
    if ( relative ) return _resolveRelativeUuid(uuid, relative);
    else return null;
  }

  // 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");
  }
  let k = 1;
  while ( uuid.at(k) === "." ) k++;
  uuid = uuid.substring(k);
  while ( --k && relative ) relative = relative.parent;
  if ( !relative ) return null;
  if ( !uuid ) return parseUuid(relative.uuid);
  const parts = uuid.split(".");
  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;
    type = relative.documentName;
    if ( parts.length > 1 ) {
      primaryType = type;
      primaryId = parts.shift();
      id = parts.at(-1);
      type = parts.at(-2);
      uuid = [primaryType, primaryId, ...parts].join(".");
    }
    else {
      id = parts.pop();
      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 the relative UUID of the target relative to the origin if possible.
 * @param {string|Document} target  The target UUID or Document
 * @param {string|Document} origin    The origin UUID or Document
 * @returns {string}                The relative UUID of the target relative to the origin if possible,
 *                                  otherwise the absolute UUID of the target
 */
function buildRelativeUuid(target, origin) {
  const targetUuid = _convertToUuid(target, "target");
  const originUuid = _convertToUuid(origin, "origin");
  const n = originUuid.startsWith("Compendium.") ? 1 : 0;
  let prefix = originUuid;
  const parts = originUuid.split(".");
  let relativeUuid;
  let lastType;
  let k = 0;
  while ( true ) {
    if ( targetUuid === prefix ) {
      relativeUuid = ".";
      break;
    }
    if ( targetUuid.startsWith(`${prefix}.`) ) {
      relativeUuid = targetUuid.slice(prefix.length);
      if ( lastType && relativeUuid.startsWith(`.${lastType}.`) ) {
        relativeUuid = relativeUuid.slice(lastType.length + 1);
        k--;
      }
      break;
    }
    lastType = parts.at(-2);
    parts.length -= 2;
    if ( parts.length <= n ) {
      if ( !targetUuid.startsWith(`${lastType}.`) ) return targetUuid;
      relativeUuid = targetUuid.slice(lastType.length);
      break;
    }
    prefix = parts.join(".");
    k++;
  }
  relativeUuid = `${".".repeat(k)}${relativeUuid}`;
  return relativeUuid;
}

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

/**
 * Convert an argument to a UUID.
 * @param {string|Document} argument
 * @param {string} parameterName
 * @returns {string}
 */
function _convertToUuid(argument, parameterName) {
  let uuid;
  if ( argument instanceof foundry.abstract.Document ) {
    uuid = argument.uuid;
    if ( !uuid ) throw new Error(`The ${parameterName} Document does not have a UUID`);
  } else {
    uuid = parseUuid(argument)?.uuid;
    if ( !uuid ) throw new Error(`The ${parameterName} UUID is not valid`);
  }
  return uuid;
}

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

/**
 * 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) ) return null;
  if ( pack ) documentName ??= game.packs.get(pack)?.documentName;
  const parentUuid = parent?.uuid;
  if ( parent ) {
    if ( !parentUuid ) return null;
    if ( !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;
        }
      }
    }
  }
  if ( !documentName ) return null;
  return [
    parentUuid,
    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]);
}

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

/**
 * @deprecated since v14
 * @ignore
 */
function applySpecialKeys(obj) {
  logCompatibilityWarning("foundry.utils.applySpecialKeys has been deprecated and renamed to "
    + "foundry.utils.applyDataOperators", {since: 14, until: 16});
  return applyDataOperators(obj);
}

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

/**
 * Is a string key of an object used for certain deletion or forced replacement operations.
 *
 * This function has become internal and undocumented. It can be deprecated and removed once support for
 * legacy deletion keys is fully removed.
 * @ignore
 * @internal
 */
function isDeletionKey(key) {
  if ( !(typeof key === "string") ) return false;
  return (key[1] === "=") && ((key[0] === "=") || (key[0] === "-"));
}

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

/**
 * @deprecated since v14
 * @ignore
 */
function objectsEqual(a, b) {
  logCompatibilityWarning("foundry.utils.objectsEqual has been deprecated and renamed to foundry.utils.equals, which"
    + " handles equality testing for more than just objects.", {since: 14, until: 16});
  return equals(a, b);
}

/**
 * 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";

/**
 * The static, public package index artifact.
 * @type {string}
 */
const PACKAGE_INDEX_URL = "https://r2.foundryvtt.com/package-api-public/index-latest.json";

/**
 * A fallback location for the package index, which will typically redirect to the above URL.
 * @type {string}
 */
const PACKAGE_INDEX_FALLBACK_URL = `${WEBSITE_URL}/_api/packages/index/`;

/**
 * The endpoint for checking user entitlements.
 * @type {string}
 */
const PACKAGE_OWNED_URL = `${WEBSITE_API_URL}/_api/packages/entitlements/`;

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

/**
 * Time-based units in which an ActiveEffect's duration can be expressed
 */
const ACTIVE_EFFECT_TIME_DURATION_UNITS = Object.freeze([
  "years",
  "months",
  "days",
  "hours",
  "minutes",
  "seconds"
]);

/**
 * All units in which an ActiveEffect's duration can be expressed
 */
const ACTIVE_EFFECT_DURATION_UNITS = Object.freeze([
  ...ACTIVE_EFFECT_TIME_DURATION_UNITS,
  "rounds",
  "turns"
]);

/**
 * @typedef {typeof ACTIVE_EFFECT_DURATION_UNITS[number]} ActiveEffectDurationUnit
 */

/**
 * Define the core ActiveEffect expiry events.
 * Other events can be defined by systems and modules, with their handling also left to them.
 */
const ACTIVE_EFFECT_EXPIRY_EVENTS = Object.freeze([
  "combatStart",
  "roundStart",
  "turnStart",
  "combatEnd",
  "roundEnd",
  "turnEnd"
]);

/**
 * Define the core ActiveEffect change-application phases.
 * Additional phases can be registered by systems and modules, with the registering package also responsible for
 * calling `Actor#applyActiveEffects("myNewPhase")` at the desired time.
 */
const ACTIVE_EFFECT_CHANGE_PHASES = Object.freeze(["initial", "final"]);

/**
 * Define the core ActiveEffect change types and their default priorities. Other arbitrary string types can be used by
 * systems and modules to identify special behaviors and are ignored.
 */
const ACTIVE_EFFECT_CHANGE_TYPES = 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: 10,

  /**
   * Sums two values, concatenates strings, pushes onto Arrays, or adds to Sets.
   * @example
   * 2 (base value) + 3 (effect value) = 5 (derived value)
   * @example
   * "Hello" (base value) + " World" (effect value) = "Hello World"
   */
  add: 20,

  /**
   * Subtracts a numeric change values from target values, splices values from Arrays, or deletes an element from Sets.
   * @example
   * 3 (base value) - 2 (effect value) = 1 (derived value)
   * @example
   * Set<"hello"|"world"> - "world" = Set<"hello">
   */
  subtract: 20,

  /**
   * Keeps the lower value of the base value and the effect value. The lower value of a Set is a subset.
   * @example
   * 2 (base value), 0 (effect value) = 0 (derived value)
   * @example
   * 2 (base value), 3 (effect value) = 2 (derived value)
   */
  downgrade: 30,

  /**
   * Keeps the greater value of the base value and the effect value. The higher value of a Set is a superset.
   * @example
   * 2 (base value), 4 (effect value) = 4 (derived value)
   * @example
   * 2 (base value), 1 (effect value) = 2 (derived value)
   */
  upgrade: 40,

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

/**
 * Possible values for ActiveEffectData#showIcon: the default is CONDITIONAL, dependent on whether the ActiveEffect has
 * a temporary duration.
 */
const ACTIVE_EFFECT_SHOW_ICON = Object.freeze({

  /**
   * The icon is never shown.
   */
  NEVER: 0,

  /**
   * The icon is showed if the ActiveEffect has a temporary duration.
   */
  CONDITIONAL: 1,

  /**
   * The icon is always shown.
   */
  ALWAYS: 2
});

/**
 * 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.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([
  "ActiveEffect",
  "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",
  "Note",
  "PlaylistSound",
  "Region",
  "RegionBehavior",
  "Level",
  "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", "Note", "Playlist",
 *   "PlaylistSound", "Region", "RegionBehavior", "RollTable", "Scene", "Level", "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([
  "ActiveEffect",
  "Actor",
  "Adventure",
  "Cards",
  "Item",
  "JournalEntry",
  "Macro",
  "Playlist",
  "RollTable",
  "Scene"
]);

/**
 * Define the Fog Exploration modes available for a given scene.
 */
const FOG_EXPLORATION_MODES = Object.freeze({
  /**
   * The fog of war exploration is disabled.
   */
  DISABLED: 0,

  /**
   * The fog of war exploration is enabled for this scene and each user has its own personal fog of war.
   */
  INDIVIDUAL: 1,

  /**
   * The fog of war exploration is enabled for this scene and shared among all users.
   */
  SHARED: 2
});

/**
 * 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 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(["ActiveEffect", "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", "create", "update", "quit"]);

/**
 * 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,

  /**
   * The Package is compatible with a newer version of Foundry than the currently installed version, and it is not known
   * whether that version is Stable
   */
  REQUIRES_CORE_UPGRADE_UNKNOWN: 10,

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

/**
 * 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,

  /**
   * Causes the tile to be partially revealed based on the occluded surfaces.
   */
  SURFACE: 2,

  /**
   * 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: 4,

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

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

/**
 * 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",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.ASSISTANT
  },
  BROADCAST_AUDIO: {
    label: "PERMISSION.BroadcastAudio",
    hint: "PERMISSION.BroadcastAudioHint",
    requiredRoles: [],
    defaultRole: USER_ROLES.TRUSTED
  },
  BROADCAST_VIDEO: {
    label: "PERMISSION.BroadcastVideo",
    hint: "PERMISSION.BroadcastVideoHint",
    requiredRoles: [],
    defaultRole: USER_ROLES.TRUSTED
  },
  CARDS_CREATE: {
    label: "PERMISSION.CardsCreate",
    hint: "PERMISSION.CardsCreateHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.ASSISTANT
  },
  DRAWING_CREATE: {
    label: "PERMISSION.DrawingCreate",
    hint: "PERMISSION.DrawingCreateHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.TRUSTED
  },
  ITEM_CREATE: {
    label: "PERMISSION.ItemCreate",
    hint: "PERMISSION.ItemCreateHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.ASSISTANT
  },
  FILES_BROWSE: {
    label: "PERMISSION.FilesBrowse",
    hint: "PERMISSION.FilesBrowseHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.TRUSTED
  },
  FILES_UPLOAD: {
    label: "PERMISSION.FilesUpload",
    hint: "PERMISSION.FilesUploadHint",
    requiredRoles: [USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.ASSISTANT
  },
  JOURNAL_CREATE: {
    label: "PERMISSION.JournalCreate",
    hint: "PERMISSION.JournalCreateHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.TRUSTED
  },
  MACRO_SCRIPT: {
    label: "PERMISSION.MacroScript",
    hint: "PERMISSION.MacroScriptHint",
    requiredRoles: [USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.PLAYER
  },
  MANUAL_ROLLS: {
    label: "PERMISSION.ManualRolls",
    hint: "PERMISSION.ManualRollsHint",
    requiredRoles: [],
    defaultRole: USER_ROLES.TRUSTED
  },
  MESSAGE_WHISPER: {
    label: "PERMISSION.MessageWhisper",
    hint: "PERMISSION.MessageWhisperHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.PLAYER
  },
  NOTE_CREATE: {
    label: "PERMISSION.NoteCreate",
    hint: "PERMISSION.NoteCreateHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.TRUSTED
  },
  PING_CANVAS: {
    label: "PERMISSION.PingCanvas",
    hint: "PERMISSION.PingCanvasHint",
    requiredRoles: [],
    defaultRole: USER_ROLES.PLAYER
  },
  PLAYLIST_CREATE: {
    label: "PERMISSION.PlaylistCreate",
    hint: "PERMISSION.PlaylistCreateHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.ASSISTANT
  },
  REGION_CREATE: {
    label: "PERMISSION.RegionCreate",
    hint: "PERMISSION.RegionCreateHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.PLAYER
  },
  SETTINGS_MODIFY: {
    label: "PERMISSION.SettingsModify",
    hint: "PERMISSION.SettingsModifyHint",
    requiredRoles: [USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.ASSISTANT
  },
  SHOW_CURSOR: {
    label: "PERMISSION.ShowCursor",
    hint: "PERMISSION.ShowCursorHint",
    requiredRoles: [],
    defaultRole: USER_ROLES.PLAYER
  },
  SHOW_RULER: {
    label: "PERMISSION.ShowRuler",
    hint: "PERMISSION.ShowRulerHint",
    requiredRoles: [],
    defaultRole: USER_ROLES.PLAYER
  },
  TOKEN_CREATE: {
    label: "PERMISSION.TokenCreate",
    hint: "PERMISSION.TokenCreateHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.ASSISTANT
  },
  TOKEN_DELETE: {
    label: "PERMISSION.TokenDelete",
    hint: "PERMISSION.TokenDeleteHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.ASSISTANT
  },
  TOKEN_CONFIGURE: {
    label: "PERMISSION.TokenConfigure",
    hint: "PERMISSION.TokenConfigureHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.TRUSTED
  },
  WALL_DOORS: {
    label: "PERMISSION.WallDoors",
    hint: "PERMISSION.WallDoorsHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.PLAYER
  },
  QUERY_USER: {
    label: "PERMISSION.QueryUser",
    hint: "PERMISSION.QueryUserHint",
    requiredRoles: [USER_ROLES.ASSISTANT, USER_ROLES.GAMEMASTER],
    defaultRole: USER_ROLES.PLAYER
  }
});

/**
 * The edge properties which restrict the way interaction occurs with a specific edge
 * @see {@link https://foundryvtt.com/article/walls/}
 */
const EDGE_RESTRICTION_TYPES = Object.freeze(["light", "darkness", "sight", "sound", "move"]);

/**
 * @typedef {typeof EDGE_RESTRICTION_TYPES[number]} EdgeRestrictionType
 */


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

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

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

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

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

/**
 * @typedef {typeof EDGE_SENSE_TYPES[keyof typeof EDGE_SENSE_TYPES]} EdgeSenseType
 */

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

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

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

/**
 * @typedef {typeof EDGE_DIRECTIONS[keyof typeof EDGE_DIRECTIONS]} EdgeDirection
 */

/**
 * The possible direction modes.
 */
const EDGE_DIRECTION_MODES = Object.freeze({
  /**
   * The edge direction applies normally.
   */
  NORMAL: 0,

  /**
   * The edge direction applies reversed.
   */
  REVERSED: 1,

  /**
   * The edge blocks in both directions always.
   */
  BOTH: 2
});

/**
 * @typedef {typeof EDGE_DIRECTION_MODES[keyof typeof EDGE_DIRECTION_MODES]} EdgeDirectionMode
 */

/**
 * 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 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: EDGE_SENSE_TYPES.NONE,

  /**
   * Movement collides with this wall.
   */
  NORMAL: EDGE_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 texture-type files, and their corresponding mime types.
 */
const TEXTURE_FILE_EXTENSIONS = Object.freeze({
  ...IMAGE_FILE_EXTENSIONS,
  ...VIDEO_FILE_EXTENSIONS,
  basis: "application/octet-stream",
  ktx2: "image/ktx2"
});

/**
 * 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,
  ...TEXTURE_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,
  TEXTURE: TEXTURE_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 retrieving the package index directly from the CDN.
   */
  PACKAGE_INDEX: 3000,

  /**
   * The specific timeout for retrieving the detail object of a single package.
   */
  PACKAGE_DETAILS: 2000,

  /**
   * 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(["ActiveEffect", "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",
  "formula-input" // 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: ["name", "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"],
  "formula-input": ["disabled", "name", "value", "placeholder", "readonly", "required", "context"],
  "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",
    CREATE_WORLD: "createWorld",
    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",
    IMPORT_ADVENTURE: "importAdventure"
  }
});

/**
 * 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, bottom/top elevation, levels, or restriction of the Region are changed.
   *
   * @see {@link foundry.documents.types.RegionRegionBoundaryEvent}
   */
  REGION_BOUNDARY: "regionBoundary",
  /**
   * Triggered when the animation state of the Region is changed.
   *
   * @see {@link foundry.documents.types.RegionRegionAnimationEvent}
   */
  REGION_ANIMATION: "regionAnimation",

  /**
   * Triggered when the Region Behavior becomes active, i.e. is enabled or created without being disabled
   * while its Region isn't hidden, or its Region becomes unhidden while it's enabled.
   *
   * 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
   * while its Region isn't hidden, or its Region becomes hidden while it's enabled.
   *
   * 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, depth, shape, or level
   *     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, depth, shape, or level
   *     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, depth, shape, or level 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, depth, shape, or level 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, depth, shape, or level 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 to Users with Observer permissions when unlocked.
   */
  LAYER_UNLOCKED: 4,

  /**
   * Only visible on the RegionLayer to Users with Observer permissions.
   */
  LAYER: 0,

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

  /**
   * Always visible to Observers.
   */
  OBSERVER: 3,

  /**
   * Always 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;

/**
 * A threshold of time in milliseconds after which a player is considered idle if they have no observed activity.
 */
const IDLE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes

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

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

/**
 * @deprecated since v14
 * @ignore
 */
const WALL_SENSE_TYPES = new Proxy(EDGE_SENSE_TYPES, {
  get(target, prop, receiver) {
    const msg = "CONST.WALL_SENSE_TYPES is deprecated in favor of CONST.EDGE_SENSE_TYPES.";
    foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
    return Reflect.get(...arguments);
  }
});

/**
 * @deprecated since v14
 * @ignore
 */
const WALL_DIRECTIONS = new Proxy(EDGE_DIRECTIONS, {
  get(target, prop, receiver) {
    const msg = "CONST.WALL_DIRECTIONS is deprecated in favor of CONST.EDGE_DIRECTIONS.";
    foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
    return Reflect.get(...arguments);
  }
});

/**
 * @deprecated since v14
 * @ignore
 */
const _MEASURED_TEMPLATE_TYPES = Object.freeze({
  CIRCLE: "circle",
  CONE: "cone",
  RECTANGLE: "rect",
  RAY: "ray"
});

/**
 * @deprecated since v14
 * @ignore
 */
const MEASURED_TEMPLATE_TYPES = new Proxy(_MEASURED_TEMPLATE_TYPES, {
  get(target, prop, receiver) {
    const msg = "CONST.MEASURED_TEMPLATE_TYPES is deprecated without replacement.";
    foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
    return Reflect.get(...arguments);
  }
});

/**
 * @deprecated since v14
 * @ignore
 */
const ACTIVE_EFFECT_MODES = new Proxy(
  Object.freeze({
    CUSTOM: 0,
    MULTIPLY: 1,
    ADD: 2,
    DOWNGRADE: 3,
    UPGRADE: 4,
    OVERRIDE: 5
  }),
  {
    get() {
      const message = "You are accessing CONST.ACTIVE_EFFECT_MODES."
        + " Changes now have string types (see CONST.ACTIVE_EFFECT_CHANGE_TYPES).";
      foundry.utils.logCompatibilityWarning(message, {since: 14, until: 16, once: true});
      return Reflect.get(...arguments);
    }
  }
);

/**
 * @deprecated since v14
 * @ignore
 */
const DICE_ROLL_MODES = {};
Object.entries({PUBLIC: "publicroll", PRIVATE: "gmroll", BLIND: "blindroll", SELF: "selfroll"}).forEach(([o, n]) => {
  Object.defineProperty(DICE_ROLL_MODES, o, {
    get() {
      const msg = "CONST.DICE_ROLL_MODES is deprecated in favor of configured CONFIG.ChatMessage.modes. Its"
        + " returned values are also deprecated in favor of new string values. Migration is facilitated by the"
        + " Roll._mapLegacyRollMode helper method";
      foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
      return n;
    },
    enumerable: true
  });
});

var constants = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ACTIVE_EFFECT_CHANGE_PHASES: ACTIVE_EFFECT_CHANGE_PHASES,
  ACTIVE_EFFECT_CHANGE_TYPES: ACTIVE_EFFECT_CHANGE_TYPES,
  ACTIVE_EFFECT_DURATION_UNITS: ACTIVE_EFFECT_DURATION_UNITS,
  ACTIVE_EFFECT_EXPIRY_EVENTS: ACTIVE_EFFECT_EXPIRY_EVENTS,
  ACTIVE_EFFECT_MODES: ACTIVE_EFFECT_MODES,
  ACTIVE_EFFECT_SHOW_ICON: ACTIVE_EFFECT_SHOW_ICON,
  ACTIVE_EFFECT_TIME_DURATION_UNITS: ACTIVE_EFFECT_TIME_DURATION_UNITS,
  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,
  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,
  DRAWING_FILL_TYPES: DRAWING_FILL_TYPES,
  EDGE_DIRECTIONS: EDGE_DIRECTIONS,
  EDGE_DIRECTION_MODES: EDGE_DIRECTION_MODES,
  EDGE_RESTRICTION_TYPES: EDGE_RESTRICTION_TYPES,
  EDGE_SENSE_TYPES: EDGE_SENSE_TYPES,
  EMBEDDED_DOCUMENT_TYPES: EMBEDDED_DOCUMENT_TYPES,
  FILE_CATEGORIES: FILE_CATEGORIES,
  FILE_PICKER_PUBLIC_DIRS: FILE_PICKER_PUBLIC_DIRS,
  FOG_EXPLORATION_MODES: FOG_EXPLORATION_MODES,
  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,
  IDLE_THRESHOLD_MS: IDLE_THRESHOLD_MS,
  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_INDEX_FALLBACK_URL: PACKAGE_INDEX_FALLBACK_URL,
  PACKAGE_INDEX_URL: PACKAGE_INDEX_URL,
  PACKAGE_OWNED_URL: PACKAGE_OWNED_URL,
  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,
  TEXTURE_FILE_EXTENSIONS: TEXTURE_FILE_EXTENSIONS,
  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
});

/**
 * 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 this.values().toArray();
  }

  /* -------------------------------------------- */
  /**
   * 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) {
    if ( !globalThis.Iterator?.prototype?.find ) {
      // Polyfill for older browsers to allow them to surface the minimum browser version warning.
      let i = 0;
      for ( const v of this.values() ) {
        if ( condition(v, i, this) ) return v;
        i++;
      }
      return;
    }
    return this.values().find((v, i) => condition(v, i, this));
  }

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

  /**
   * 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) {
    return this.values().filter((v, i) => condition(v, i, this)).toArray();
  }

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

  /**
   * Apply a function to each element of the collection
   * @see Array#forEach
   * @param {(value: V, index: number) => 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) {
    return this.values().forEach(fn);
  }

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

  /**
   * 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
   * @see {Array#map}
   * @template U
   * @param {(element: V, index: number, collection: Collection<K, V>) => U} transformer A transformation function
   *                                                                                     applied to each entry value.
   * @returns {U[]} An Array of transformed values
   */
  map(transformer) {
    return this.values().map((v, i) => transformer(v, i, this)).toArray();
  }

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

  /**
   * Reduce the Collection by applying an evaluator function and accumulating entries
   * @see {Array#reduce}
   * @template U
   * @param {(accum: U, element: T, index: number, collection: Collection<K, V>) => U} reducer A reducer function
   *                                                                                           applied to each entry
   *                                                                                           value.
   * @param {U} initial An initial value which accumulates with each iteration
   * @returns {U} 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) {
    return this.values().reduce((prev, v, i) => reducer(prev, v, i, this), initial);
  }

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

  /**
   * Test whether a condition is met by some entry in the Collection.
   * @see {Array#some}
   * @param {(element: V, index: number, set: Collection<K, V>) => boolean} condition The functional condition to test.
   * @returns {boolean}  Was the test condition passed by at least one entry?
   */
  some(condition) {
    return this.values().some((v, i) => condition(v, i, this));
  }

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

  /**
   * Test whether a condition is met by every entry in the Collection.
   * @see {Array#every}
   * @param {(element: V, index: number, set: Collection<K, V>) => boolean} condition  The functional condition to test.
   * @returns {boolean}  True if the test condition was truthy for every entry.
   */
  every(condition) {
    return this.values().every((v, i) => condition(v, i, this));
  }

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

  /**
   * Convert the Collection to a primitive array of its contents.
   * @returns {object[]}  An array of contained values
   */
  toJSON() {
    return this.map(e => typeof e.toJSON === "function" ? 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, configurable: false, writable: false},
      documentClass: {
        /** @deprecated since v14 */
        // `?` can be removed and `configurable` can be set to false once MeasuredTemplates are removed
        value: parent.constructor.hierarchy[name]?.model,
        configurable: true,
        writable: false
      },
      name: {value: name, configurable: false, writable: false},
      model: {value: parent, configurable: false, 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, 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 {object} data                    The invalid Document's data.
   * @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(data, err, {strict=true}={}) {
    const id = data._id;
    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, {uuid, 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, fallback: false});
  }

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

  /**
   * Does this SingletonEmbeddedCollection actively manage the Document with a specific ID.
   * @param {string} id     The Document ID to check
   * @returns {boolean}     Is the specified document managed by this collection?
   */
  manages(id) {
    return this._source.some(d => d._id === id);
  }

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

  /**
   * 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, options={}) {
    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, options);
  }

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

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

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

  /** @override */
  _delete(key) {
    this.model._source[this.name] = null;
    this.model[this.name] = this.model.actorLink
      ? null
      : new this.model[this.name].constructor({ _id: this.model.id }, { parent: this.model });
  }

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

  /** @override */
  manages(id) {
    return this.model._source[this.name]?._id === id;
  }
}

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

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

  /** @override */
  manages(id) {
    return this.#managedIds.has(id);
  }

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

  /**
   * 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._source, 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, {
        applyOperators: true,
        overwrite: false
      });
    }
  }

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

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

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

  /** @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={}) {
    const result = super.delete(key, options);
    this.syntheticCollection?.delete(key, options);
    return result;
  }

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

  /** @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
 * @see {@link https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_two_points}
 */
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)
  };
}

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

/**
 * Find a point that is closest to the path (open or closed).
 * @param {Point} c                    The reference point C
 * @param {Point[]|number[]} points    The points of the path P, which must not be empty
 * @param {boolean} close              If true, the edge from the last to the first point of the path is tested
 * @returns {Point}                    The closest point to C on the path P
 * @see {@link https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_two_points}
 */
function closestPointToPath(c, points, close) {
  const n = points.length;
  if ( n === 0 ) throw new Error("The path must not be empty");
  const {x: cx, y: cy} = c;
  let d0 = Infinity;
  let dx0;
  let dy0;
  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];
    }
    if ( n === 2 ) return {x: x0, y: y0};
    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;
      const d = (dx * dx) + (dy * dy);
      if ( d < d0 ) {
        d0 = d;
        dx0 = dx;
        dy0 = dy;
      }
      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]);
    }
    if ( n === 1 ) return {x: x0, y: y0};
    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;
      const d = (dx * dx) + (dy * dy);
      if ( d < d0 ) {
        d0 = d;
        dx0 = dx;
        dy0 = dy;
      }
      x0 = x1;
      y0 = y1;
    }
  }
  return {x: cx + dx0, y: cy + dy0};
}

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

/**
 * 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
 * @returns {LineIntersection[]}
 */
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),
      t0: 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),
      t0: t2
    });
  }
  return intersections;
}

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

/**
 * Calculate the centroid non-self-intersecting closed polygon.
 * If the polygon's area is zero, the first point of the polygon is returned, if it has at least on point,
 * otherwise the point `{x: 0, y: 0}` is returned.
 * @param {Point[]|number[]} points    The points of the polygon
 * @returns {Point}                    The centroid of the polygon
 * @see {@link https://en.wikipedia.org/wiki/Centroid#Of_a_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" ) {
    if ( n <= 4 ) return {x: points[0], y: points[1]};
    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;
    }
    if ( a === 0 ) return {x: points[0], y: points[1]};
  } else {
    if ( n <= 2 ) return {x: points[0].x, y: points[0].y};
    let {x: x0, y: y0} = points[n - 1];
    if ( n === 1 ) return {x: x0, y: y0};
    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;
    }
    if ( a === 0 ) return {x: points[0].x, y: points[0].y};
  }
  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 of the path 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
 * @param {number[]} [options.allowStatus]   Non-OK status codes to return rather than throw.
 * @returns {Promise<Response>}
 * @throws {HttpError}
 */
async function fetchWithTimeout(url, data={}, {timeoutMs=30000, onTimeout=() => {}, allowStatus=[]}={}) {
  const controller = new AbortController();
  data.signal = AbortSignal.any([data.signal, controller.signal].filter(_ => _));
  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
        ? _loc("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") && !allowStatus.includes(response.status) ) {
    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 {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<*>}
 */
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;
  }
}

/**
 * @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.
 * @template {WeakKey} K
 * @template V
 * @extends {WeakMap<K, V>}
 */
class IterableWeakMap extends WeakMap {

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

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

  /**
   * @param {Iterable<[K, V], undefined, unknown>} [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<K>} heldValue  The value held by the finalizer.
   */
  static #cleanup({ set, ref }) {
    set.delete(ref);
  }

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

  /**
   * Remove a key from the map.
   * @param {K} 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 {K} key  The value's key.
   * @returns {V|undefined}
   */
  get(key) {
    const entry = super.get(key);
    return entry && entry.value;
  }

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

  /**
   * Place a value in the map.
   * @param {K} key    The key.
   * @param {V} value  The value.
   * @returns {this}
   */
  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<K, V>} */ {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<[K, V], void, unknown>}
   */
  *[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<[K, V], void, unknown>}
   */
  entries() {
    return this[Symbol.iterator]();
  }

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

  /**
   * Enumerate the keys.
   * @returns {Generator<K, void, unknown>}
   */
  *keys() {
    for ( const [key] of this ) yield key;
  }

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

  /**
   * Enumerate the values.
   * @returns {Generator<V, void, unknown>}
   */
  *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.
 * @template {WeakKey} [T=WeakKey]
 * @implements {WeakSet<T>}
 */
class IterableWeakSet {
  /**
   * The backing iterable weak map.
   * @type {IterableWeakMap<T, T>}
   */
  #map = new IterableWeakMap();

  /**
   * @param {Iterable<T, void, unknown>} [entries]  The initial entries.
   */
  constructor(entries=[]) {
    for ( const entry of entries ) this.add(entry);
  }

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

  /**
   * Enumerate the values.
   * @returns {Generator<T, void, unknown>}
   */
  [Symbol.iterator]() {
    return this.values();
  }

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

  /**
   * Add a value to the set.
   * @param {T} value  The value to add.
   * @returns {this}
   */
  add(value) {
    this.#map.set(value, value);
    return this;
  }

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

  /**
   * Delete a value from the set.
   * @param {T} value  The value to delete.
   * @returns {boolean}
   */
  delete(value) {
    return this.#map.delete(value);
  }

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

  /**
   * Whether this set contains the given value.
   * @param {T} value  The value to test.
   * @returns {boolean}
   */
  has(value) {
    return this.#map.has(value);
  }

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

  /**
   * Enumerate the collection.
   * @returns {Generator<T, void, unknown>}
   */
  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, WordTreeEntry} from "./_types.mjs";
 */

/**
 * A data structure representing a tree of string nodes with arbitrary object leaves.
 * @template {object} [TEntry=object]
 */
class StringTree {
  /**
   * The key symbol that stores the leaves of any given node.
   * @type {symbol}
   */
  static get leaves() {
    return StringTree.#leaves;
  }

  static #leaves = Symbol("leaves");

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

  /**
   * 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 {Iterable<string>} strings The string parents for the entry.
   * @param {TEntry} 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 {Iterable<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<TEntry>} [options.filterEntries] A filter function to apply to each candidate entry.
   * @returns {WordTreeEntry[]}
   */
  lookup(strings, {limit=Infinity, filterEntries}={}) {
    const entries = [];
    const node = this.nodeAtPrefix(strings);
    if ( !node ) return []; // No matching entries.
    const queue = [node];
    while ( queue.length ) {
      if ( 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 ( typeof filterEntries === "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<WordTreeEntry>}
 */
class WordTree extends StringTree {

  /** @inheritDoc */
  addLeaf(strings, entry) {
    const string = typeof strings === "string" ? strings : Array.from(strings).join("");
    return super.addLeaf(string.toLocaleLowerCase(game.i18n.lang), 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));
  }
}

/**
 * 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 = [];

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

  /**
   * Whether the node appears inline or as a block.
   * @type {boolean}
   */
  #inline;

  /**
   * Whether the node appears inline or as a block.
   * @type {boolean}
   */
  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 {Error}                   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).
   * @param {object} [internal]
   * @param {number} [internal._depth]
   * @param {boolean} [internal._inlineParent]
   */
  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}="${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;
    });
  }

  /* -------------------------------------------- */
  /*  Parsing                                     */
  /* -------------------------------------------- */

  /**
   * Convert an HTMLCollection to a StringNode.
   * @param {StringNode} parent          The parent StringNode to append to.
   * @param {HTMLCollection} collection  The child collection to convert and append.
   * @returns {StringNode}               Returns the parent StringNode.
   */
  static #convertCollectionToStringNode(parent, collection) {
    collection.forEach(node => {
      const stringNode = StringNode.#convertNodeToStringNode(node);
      if ( stringNode ) parent.appendChild(stringNode);
    });
    return parent;
  }

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

  /**
   * Convert an HTML node to a StringNode.
   * @param {Node} node                 The HTML Node.
   * @returns {StringNode|string|null}  Returns null if the node cannot be converted.
   */
  static #convertNodeToStringNode(node) {
    if ( node.nodeType === Node.TEXT_NODE ) return node.textContent;
    if ( node.nodeType !== Node.ELEMENT_NODE ) return null;
    const attrs = Object.fromEntries(Array.from(node.attributes).map(attr => [attr.name, attr.value]));
    const inline = Array.from(node.childNodes).some(child => child.nodeType === Node.TEXT_NODE);
    const stringNode = new foundry.utils.StringNode(node.tagName.toLowerCase(), attrs, inline);
    return StringNode.#convertCollectionToStringNode(stringNode, node.childNodes);
  }

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

  /**
   * Convert an HTML string to a StringNode.
   * @param {string} html  The HTML string.
   * @returns {StringNode}
   * @throws {Error}       If unable to perform conversion.
   */
  static fromString(html) {
    if ( !globalThis.DOMParser ) throw new Error("StringNode.fromString is not available in this context.");
    const root = new this();
    const doc = new DOMParser().parseFromString(html, "text/html");
    return StringNode.#convertCollectionToStringNode(root, doc.body.childNodes);
  }
}

/**
 * The constructor of an async function.
 * @type {typeof AsyncFunction}
 */
const AsyncFunction = (async function() {}).constructor;

/**
 * @typedef DataModelValidationFailureElement
 * @property {string|number} id                    Either the element's index or some other identifier for it.
 * @property {string} [name]                       Optionally a user-friendly name for the element.
 * @property {DataModelValidationFailure} failure  The element's validation failure.
 */

/**
 * A class responsible for recording information about a validation failure.
 */
class DataModelValidationFailure {
  constructor(message="", {invalidValue, fallbackValue, fieldPath, dropped=false, unresolved=false, ...options}={}) {
    this.invalidValue = invalidValue;
    this.fallbackValue = fallbackValue;
    this.fieldPath = fieldPath;
    this.dropped = dropped;
    this.unresolved = unresolved;
    this.message = message;
    this.options = options;
  }

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

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

  /**
   * The path of the field responsible for the failure.
   * @type {string}
   */
  fieldPath;

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

  /**
   * 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 {DataModelValidationFailureElement[]}
   */
  elements = [];

  /**
   * If this field has a joint validation failure across multiple sub-fields, the failure message is recorded here.
   * @type {string}
   */
  joint;

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

  /**
   * Options forwarded to the Error constructor.
   * @type {Error}
   */
  options;

  /**
   * 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 = false;

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

  /**
   * Whether this failure contains other sub-failures.
   * @type {boolean}
   */
  get empty() {
    return isEmpty(this.fields) && isEmpty(this.elements) && !this.joint;
  }

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

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

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

  /**
   * Copy the data of this DataModeValidationFailure to another one.
   * @param {DataModelValidationFailure} failure
   */
  copyTo(failure) {
    failure.invalidValue = this.invalidValue;
    failure.fallbackValue = this.fallbackValue;
    failure.fieldPath = this.fieldPath;
    failure.dropped = this.dropped;
    failure.unresolved = this.unresolved;
    failure.fields = this.fields;
    failure.elements = this.elements;
    failure.joint = this.joint;
  }

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

  /**
   * Retrieve the leaf node failure that caused this, or a specific sub-failure via a path.
   * @param {string} [key]  The property key to the failure.
   * @returns {DataModelValidationFailure|null}
   *
   * @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(key) {
    if ( !key ) return this;
    const parts = key.split(".");
    const k = parts.shift();
    let failure;
    if ( k in this.fields ) failure = this.fields[k];
    else if ( this.elements.length ) failure = this.elements.find(f => f.id?.toString() === k);
    if ( failure instanceof DataModelValidationFailure ) return failure.getFailure(parts.join("."));
    return null;
  }

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

  /**
   * 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() {
    return DataModelValidationFailure.#aggregateFailures(this);
  }

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

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

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

  /**
   * Collect nested failures into an aggregate object.
   * @param {DataModelValidationFailure} failure            The failure instance
   * @returns {Record<string, DataModelValidationFailure>}  Returns the failure at the leaf of the tree, otherwise an
   *                                                        object of sub-failures.
   */
  static #aggregateFailures(failure) {
    const failures = {};
    if ( failure.empty ) return 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, DataModelValidationFailure.#aggregateFailures(subFailure));
    }
    for ( const element of failure.elements ) {
      recordSubFailures(element.id, DataModelValidationFailure.#aggregateFailures(element.failure));
    }
    return failures;
  }

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

  /**
   * 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 subMessage = DataModelValidationFailure.#formatString(subFailure, _d);
        messages.push(`${" ".repeat(2 * _d)}${key}: ${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");
    }
    if ( !isEmpty(failure.joint) ) message += `\n  Joint Validation: ${failure.joint}`;
    return message;
  }

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

  /**
   * 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.empty ) 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);
      }
      if ( failure.joint ) nodes.push(`<li>${foundry.utils.escapeHTML(failure.joint)}</li>`);
      return nodes.join("");
    };
    return `<ul class="summary-tree">${renderFailureNode(this)}</ul>`;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  isEmpty() {
    foundry.utils.logCompatibilityWarning(`The DataModelValidationFailure#isEmpty method is deprecated in favor of the
      DataModelValidationFailure#empty property.`, {since: 14, until: 16});
    return this.empty;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  get fallback() {
    foundry.utils.logCompatibilityWarning(`DataModelValidationFailure#fallback is renamed to
      DataModelValidationFailure#fallbackValue`, {since: 14, until: 16});
    return this.fallbackValue;
  }
}

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

/**
 * A specialized 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) {
    const message = typeof failure === "string" ? failure : failure.message;
    super(message, ...params);
    if ( failure instanceof DataModelValidationFailure ) this.#failure = failure;
  }

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

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

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

  /**
   * @see {@link DataModelValidationFailure#getFailure}
   * @param {string} [key]
   * @returns {DataModelValidationFailure|null}
   */
  getFailure(key) {
    return this.#failure?.getFailure(key) ?? null;
  }

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

  /**
   * @see {@link DataModelValidationFailure#getAllFailures}
   * @returns {Record<string, DataModelValidationFailure>}
   */
  getAllFailures() {
    return this.#failure?.getAllFailures() ?? {};
  }

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

  /**
   * Log the validation error as a table.
   */
  logAsTable() {
    this.#failure?.logAsTable();
  }

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

  /**
   * Generate a nested tree view of the error as an HTML string.
   * @returns {string}
   */
  asHTML() {
    return this.#failure?.asHTML() ?? "";
  }

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

  /** @inheritDoc */
  toString() {
    return this.#failure?.toString() ?? super.toString();
  }
}

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

/**
 * 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,
 *   DataModelCleaningOptions,
 *   DataModelSanitizationOptions,
 *   DataModelUpdateState,
 *   DocumentStats,
 *   DocumentUUIDFieldOptions,
 *   EmbeddedCollectionUpdateContext,
 *   FilePathFieldOptions,
 *   FormGroupConfig,
 *   FormInputConfig,
 *   GridOffsetFieldOptions,
 *   GridOffsetsFieldOptions,
 *   JavaScriptFieldOptions,
 *   NumberFieldOptions,
 *   StringFieldInputConfig,
 *   StringFieldOptions,
 *   TypedObjectFieldOptions
 * } from "./_types.mjs";
 * @import {Document} from "../abstract/_module.mjs";
 * @import {DataSchema, DataModelUpdateOptions} from "../abstract/_types.mjs";
 * @import {FormSelectOption} from "../../client/applications/forms/fields.mjs"
 * @import {Roll, ReplaceFormulaDataOptions} from "../../client/dice/_module.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];
    }
    if ( typeof this.migrateSource === "function" ) {
      foundry.utils.logCompatibilityWarning(`${this.constructor.name}#migrateSource is deprecated in favor of
        ${this.constructor.name}#_migrate which has an expanded signature passing options and cleaning state.`,
        {since: 14, until: 16, once: true});
      this.#legacyMigrateSource = true;
    }
  }

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

  /** @deprecated since v14 */
  #legacyMigrateSource = false;

  /**
   * Is a value of this field written to source data? A Non-persisted value is initialized (with its initial value), and
   * ActiveEffects can use the field for change application.
   * @type {boolean}
   */
  persisted = true;

  /**
   * 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,
      persisted: true,
      gmOnly: false,
      label: "",
      hint: "",
      placeholder: "",
      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);
  }

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

  /**
   * Recursively traverse a schema and retrieve a field specification by a given path/key
   * @param {string[]} parts             The field path or property key if `source` is passed as an array of strings
   *                                     (in reverse order)
   * @param {object} [options]           Additional options
   * @param {object} [options.source]    The source data of the field
   * @param {object} [options.type]      The Document type of the parent field
   * @returns {DataField|undefined}      The corresponding DataField definition for that field, or undefined
   * @protected
   */
  _getField(parts, {source, type}={}) {
    return parts.length ? undefined : this;
  }

  /* -------------------------------------------- */
  /*  Field Cleaning                              */
  /* -------------------------------------------- */

  static #cleanDefaultTrue = ["copy", "expand", "migrate", "model", "prune", "persisted"];

  /**
   * Preprocess and clean source data to ensure that it conforms to the correct data type for this field and is ready
   * to be used by data model construction or update operations.
   * Data cleaning is synchronous and automatically applied as the first step of construction and update operations.
   *
   * @param {any} value                           An initial requested value
   * @param {Readonly<DataModelCleaningOptions>} [options]  Additional options for how the field is cleaned
   * @param {DataModelUpdateState} [_state]       Internal state variables which are used during recursion
   * @returns {any}                               The cleaned value
   */
  clean(value, options={}, _state={}) {
    if ( !_state.cleanOptions && !Object.isFrozen(options) ) {
      for ( const key of DataField.#cleanDefaultTrue ) options[key] ??= true;
      _state.cleanOptions = true;
    }

    // Migrate a prior stored value to an appropriate value for this field
    if ( options.migrate ) value = this._migrate(value, options, _state);

    // Handle special values and initial defaults
    if ( value === undefined ) return this.getInitialValue(_state.modelSource ?? {});
    try {
      const isValid = this._validateSpecial(value);
      if ( isValid === true ) return value;
    } catch(err) {
      return this.getInitialValue(_state.modelSource ?? {});
    }

    // Cast a provided value to the correct type
    value = this._cast(value);

    // Cleaning logic specific to the DataField.
    value = this._cleanType(value, options, _state);

    // Sanitization of user input
    if ( options.sanitize ) value = this._sanitize(value, options.sanitize, _state);
    return value;
  }

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

  /**
   * Attempt to retrieve a valid initial value for the DataField.
   * @param {object} source   Model source data object within which an initial value is required for this field
   * @returns {*}             A proposed initial value
   */
  getInitialValue(source) {
    if ( typeof this.initial === "function" ) return this.initial(source);  // 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
  }

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

  /**
   * Migrate this field's candidate source data.
   * This workflow occurs as a component step of DataField#clean.
   *
   *
   * @param {any} value                           Candidate source value for the field
   * @param {Readonly<DataModelCleaningOptions>} [options]  Options for how the field is cleaned
   * @param {DataModelUpdateState} [_state]       Internal state variables transacted during cleaning recursion.
   * @returns {any}                               A migrated value suitable for cleaning
   * @protected
   */
  _migrate(value, options, _state) {
    /** @deprecated since v14 */
    if ( this.#legacyMigrateSource ) this.migrateSource(_state.modelSource, value); // Did not return any value
    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;
  }

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

  /**
   * Apply any cleaning logic specific to this DataField type.
   * @param {any} value         A candidate value that has been cast to the appropriate type
   * @param {Readonly<DataModelCleaningOptions>} [options]  Options for how the field is cleaned
   * @param {DataModelUpdateState} [_state] Internal state variables which are used during recursion
   * @returns {any}             The cleaned value
   * @protected
   */
  _cleanType(value, options, _state) {
    return value;
  }

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

  /**
   * Apply special sanitization workflows to this value.
   * This method is reserved for implementation in server-side workflows.
   * @param {any} value         A candidate value that has been cast and cleaned
   * @param {Readonly<DataModelSanitizationOptions>} [options]  Options for how the field is sanitized
   * @param {DataModelUpdateState} [state] Internal state variables which are used during recursion
   * @returns {any}             The sanitized value
   * @internal
   * @ignore
   */
  _sanitize(value, options, state) {

    // GM-only fields
    if ( this.gmOnly && (options.user?.isGM === false) ) {
      if ( state.creation && !this.required ) return undefined;
      throw new Error(`${this.fieldPath} may only be modified by a GM or Assistant GM user.`);
    }

    // Falseish values don't require sanitization
    if ( !value || (value instanceof ForcedDeletion) ) return value;

    // Custom sanitization method
    if ( typeof this._sanitizeType === "function" ) {
      let result = value;
      if ( value instanceof ForcedReplacement ) result = applyDataOperators(value);
      const sanitized = this._sanitizeType(result, options, state);
      if ( value instanceof ForcedReplacement ) ForcedReplacement.set(value, sanitized);
      else value = sanitized;
    }
    return value;
  }

  /* -------------------------------------------- */
  /*  Field Validation                            */
  /* -------------------------------------------- */

  /**
   * Iterate over validation functions applied to this field.
   * Validation happens in a depth-first way, for example consider an outerSchema with an innerField:
   *
   * @example Validator Invocation Order
   * ```
   * outerSchema
   *   _validateSpecial
   *   _validateType
   *   _validateRecursive
   *   outerSchema.options.validate
   *     innerField
   *       _validateSpecial
   *       _validateType
   *       innerField.options.validate
   *       _validateModel
   *   _validateModel
   * ```
   * @param {Pick<DataFieldValidationOptions,"phase"|"recursive">} options
   * @yields {(any) => boolean}
   */
  * validators({phase, recursive=true}={}) {
    if ( !phase || (phase === "pre") ) {
      yield this._validateSpecial;      // Special values that are always valid or invalid for the field
      yield this._validateType;         // Validate the expected type for the field
    }
    if ( this.constructor.recursive && recursive && (!phase || (phase === "recursive")) ) {
      yield this._validateRecursive;    // Recursive validation of child fields
    }
    if ( !phase || (phase === "post") ) {
      if ( typeof this.options.validate === "function" ) {
        yield this.options.validate;    // Custom instance-level validation rules
      }
      yield this._validateModel;        // Joint validations on this field and its children
    }
  }

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

  /**
   * Validate candidate input for this field, ensuring it meets the field requirements.
   *
   * This public method validates recursively, testing both the immediate value of this field as well as the validity
   * of any child values for fields with hierarchical data types.
   *
   * @param {any} value                               The candidate value to validate
   * @param {DataFieldValidationOptions} [options={}] Options which affect validation behavior
   * @returns {DataModelValidationFailure|void}       A failure that occurred if validation is non-strict.
   *                                                  The failure may have been resolved by fallback or dropping
   *                                                  invalid elements.
   * @throws {DataModelValidationFailure}             A failure error type including details of the failure if
   *                                                  validation is performed strictly.
   */
  validate(value, options={}) {
    // TODO: Remove the next line. value shouldn't be a data operator or contain data operators.
    // The latter case isn't even handled.
    value = DataFieldOperator.get(value);
    let failure;
    try {
      this.#validate(value, options);
    } catch(err) {
      failure = err;
    }
    if ( !failure ) return;

    // Wrap errors in the DataModelValidationFailure type
    if ( !(failure instanceof DataModelValidationFailure) ) {
      failure = new DataModelValidationFailure(failure.message, {
        invalidValue: value,
        fieldPath: this.fieldPath,
        unresolved: true,
        cause: failure
      });
    }

    // Return or throw
    if ( options.strict && failure.unresolved ) throw failure;
    return failure;
  }

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

  /**
   * A private function that guarantees that all validators are called using the same handling logic.
   * @param {any} value                         The candidate value that is desired as an update
   * @param {DataModelUpdateOptions} options    Options which modify how the model update workflow is performed
   * @throws {Error}                            An error if the proposed value is not valid
   */
  #validate(value, options) {
    for ( const validator of this.validators(options) ) {
      let result;
      try {
        result = validator.call(this, value, options);
      }
      finally {
        if ( result === true ) return;  // Guaranteed to be valid
        if ( result === false ) {       // Invalid but without specific Error
          throw new Error(this.validationError || `Invalid value "${JSON.stringify(value)}" for ${this.fieldPath}`);
        }
      }
    }
  }

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

  /**
   * 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
   * @throws {Error}                                   An error with a specific reason the value is invalid
   * @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
   * This method should validate only the value at the current hierarchy level, rather than validating recursively.
   * This method should throw if any validation error occurred, even if resolved by fallback or dropping.
   *
   * @param {*} value                                  The candidate value
   * @param {DataFieldValidationOptions} [options={}]  Options which affect validation behavior
   * @returns {boolean|void}                           A boolean to indicate with certainty whether the value is valid
   * @throws {Error}                                   An error with a specific reason the value is invalid
   * @protected
   */
  _validateType(value, options={}) {}

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

  /**
   * For fields which have hierarchical data structures, define how their inner fields should be validated.
   * This method is called after _validateSpecial and _validateType. It can be assumed that those tests passed.
   * This method should throw if any validation error occurred, even if resolved by fallback or dropping.
   *
   * @param {*} value                                  The candidate value
   * @param {DataFieldValidationOptions} [options={}]  Options which affect validation behavior
   * @returns {boolean|void}                           A boolean to indicate with certainty whether the value is valid
   * @throws {Error}                                   An error with a specific reason the value is invalid
   * @protected
   */
  _validateRecursive(value, options) {}

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

  /**
   * Certain fields may declare joint data validation criteria.
   * This method will only be called if the field is designated as recursive.
   * This method should throw if any validation error occurred, even if resolved by fallback or dropping.
   *
   * @param {object} data       Candidate data for joint model validation
   * @param {object} options    Options which modify joint model validation
   * @throws {Error}            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 {unknown} value             The source value of the field
   * @param {object} model              The DataModel instance that this field belongs to
   * @param {object} [options]          Initialization options
   * @returns {unknown}                 An initialized copy of the source data
   */
  initialize(value, model, options={}) {
    return value;
  }

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

  /**
   * Update the source data for a DataModel which includes this DataField.
   * Updates are performed depth-first; validated and recorded from leaf-nodes upwards.
   *
   * This method is responsible for preparing and validating the requested update.
   * If the update is valid, it is responsible for updating the pending source copy and recording the diff.
   * If the update is invalid it must avoid recording a diff or staging an update to the source copy.
   * If validation fails, this workflow may optionally permit `fallback` to a valid initial value.
   *
   *                                      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 {DataModelUpdateOptions} options Options which modify how this update workflow is performed.
   * @param {DataModelUpdateState} state  Stateful data that is transacted throughout the model hierarchy during an
   *                                      update operation.
   * @throws {Error}                      An error if the requested update cannot be performed.
   * @internal
   */
  _updateDiff(key, value, options, state) {
    const current = state.source[key];
    if ( value === current ) return;
    let result = value;

    // DataFieldOperator instances are applied directly to the new source but retained in the difference
    if ( value instanceof DataFieldOperator ) {
      result = applyDataOperators(value);
      if ( foundry.utils.equals(result, current) ) return;
    }

    // Validate requested value non-recursively
    const validationOptions = {model: options.model, recursive: false, fallback: options.fallback, strict: false};
    const failure = this.validate(result, validationOptions);
    if ( failure ) {
      state.failure.fields[key] = failure;
      SchemaField._handleValidationFailure(this, state.source, key, state.failure, failure, validationOptions);
      if ( failure.unresolved ) return; // Prevent assignment
    }

    // Valid values are applied to both difference and source
    state.diff[key] = value;
    state.source[key] = result;
  }

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

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

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

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

  /* -------------------------------------------- */
  /*  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|HTMLElement[]|HTMLCollection}  A rendered HTMLElement for the field
   */
  toInput(config={}) {
    const inputConfig = {name: this.fieldPath, ...config};
    inputConfig.placeholder ??= this.placeholder;
    if ( typeof inputConfig.input === "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|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 ( typeof groupConfig.widget === "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.
   * @template T
   * @param {T} value                       The field's current value.
   * @param {DataModel} model               The model instance.
   * @param {ActiveEffectChangeData} change The change to apply.
   * @param {object} options                Additional options to configure the change application.
   * @param {Record<string, unknown>} [options.replacementData] Data used to resolve "@" expressions.
   * @returns {T}                           The updated value.
   */
  applyChange(value, model, change, {replacementData={}}={}) {
    let delta;
    try {
      delta = this._castChangeDelta(change.value, replacementData);
    }
    catch(error) {
      const warningHeader = change.effect ? `Active Effect (${change.effect.uuid}) | ` : "";
      console.warn(`${warningHeader} "${change.type}" change to ${change.key} failed to resolve: ${error.message}`);
      return value;
    }
    let updated;
    switch ( change.type ) {
      case "add":
        updated = this._applyChangeAdd(value, delta, model, change);
        break;
      case "subtract":
        updated = this._applyChangeSubtract(value, delta, model, change);
        break;
      case "multiply":
        updated = this._applyChangeMultiply(value, delta, model, change);
        break;
      case "override":
        updated = this._applyChangeOverride(value, delta, model, change);
        break;
      case "upgrade":
        updated = this._applyChangeUpgrade(value, delta, model, change);
        break;
      case "downgrade":
        updated = this._applyChangeDowngrade(value, delta, model, change);
        break;
      default:
        updated = this._applyChangeCustom(value, delta, model, change);
    }

    // Clean and revalidate the updated value, returning the original value if there is a failure
    if ( !equals(updated, value) ) {
      updated = this.clean(updated, {persisted: false});
      const failure = this.validate(updated, {fallback: false, strict: false});
      if ( failure ) {
        const warningHeader = change.effect ? `Active Effect (${change.effect.uuid}) | ` : "";
        const {type, key} = change;
        const v = JSON.stringify(value);
        console.warn(`${warningHeader} "${type}" change to "${key}" with ${v} failed to validate: ${failure.message}`);
        updated = value;
      }
    }

    return this.initialize(updated, model);
  }

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

  /**
   * Cast a change delta into an appropriate type to be applied to this field.
   * @param {*} delta  The change delta.
   * @param {object} replacementData Data used to resolve "@" expressions.
   * @returns {*}
   * @protected
   */
  _castChangeDelta(delta, replacementData={}) {
    if ( typeof delta === "string" ) delta = this._replaceDataRefs(delta, replacementData);
    return this._cast(delta);
  }

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

  /**
   * Recursively replace data references in a string change value.
   * @param {string} raw
   * @param {Record<string, unknown>} data  An object providing replacements
   * @param {object} [options]              Additional options to configure the data replacement
   * @param {boolean} [options.strict=true] Throw an Error if data replacement fails
   * @returns {string} The expression with data references resolved
   * @protected
   */
  _replaceDataRefs(raw, data, {strict=true}={}) {
    const resolved = foundry.dice.Roll.defaultImplementation.replaceFormulaData(raw, data, {recursive: true});
    if ( strict && resolved.replace(/@\w+\[[^#\]]+]/g, "").includes("@") ) {
      throw new Error(`Unable to replace all data references in ${JSON.stringify(raw)}`);
    }
    return resolved;
  }

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

  /**
   * 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 "subtract" 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
   */
  _applyChangeSubtract(value, delta, model, change) {
    return value;
  }

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

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

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

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

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

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

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

  /**
   * 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 ( !isPlainObject(fields) ) {
      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;
  }

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

  /**
   * Extend this schema definition with additional fields.
   * @param {DataSchema} fields  The additional fields.
   */
  extendFields(fields) {
    Object.assign(this.fields, this._initialize(fields));
    this.#entries = null;
  }

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

  /**
   * Remove fields from this schema definition.
   * @param {string[]} fields  The fields to remove.
   */
  removeFields(fields) {
    for ( const fieldName of fields ) {
      delete this.fields[fieldName];
    }
    this.#entries = null;
  }

  /* -------------------------------------------- */
  /*  Schema Iteration                            */
  /* -------------------------------------------- */

  /**
   * Iterate over a SchemaField by iterating over its fields.
   * @yields {DataField}
   */
  *[Symbol.iterator]() {
    yield* objectValues(this.fields);
  }

  /**
   * An array of field names which are present in the schema.
   * @returns {string[]}
   */
  keys() {
    return this.entries().map(e => e[0]);
  }

  /**
   * An array of DataField instances which are present in the schema.
   * @returns {DataField[]}
   */
  values() {
    return this.entries().map(e => e[1]);
  }

  /**
   * An array of [name, DataField] tuples which define the schema.
   * @returns {Array<[string, DataField]>}
   */
  entries() {
    return this.#entries ??= Object.entries(this.fields);
  }

  /**
   * @type {Array<[string, DataField]>|null}
   */
  #entries = null;

  /**
   * 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} pathOrKey  A field path or property key if `options.source` is given.
   *                                     Examples: `["abilities", "strength"]` or `"abilities.strength"`.
   * @param {object} [options]           Additional options
   * @param {object} [options.source]    The (partial) source data of the field
   * @returns {DataField|undefined}      The corresponding DataField definition for that field, or undefined
   */
  getField(pathOrKey, {source}={}) {
    let parts;
    if ( typeof pathOrKey === "string" ) parts = pathOrKey.split(".");
    else if ( Array.isArray(pathOrKey) ) parts = pathOrKey.slice();
    else throw new Error("A field path / property path must be an array of strings or a dot-delimited string");
    return this._getField(parts.reverse(), {source});
  }

  /** @override */
  _getField(parts, {source}={}) {
    if ( !parts.length ) return this;
    const name = parts.pop();
    const field = this.get(name);
    if ( !field ) return undefined;
    if ( (source !== undefined) && (source !== null) ) {
      if ( typeof source !== "object" ) return undefined;
      source = source[name] ?? null;
    }
    return field._getField(parts, {source});
  }

  /* -------------------------------------------- */
  /*  Data Cleaning                               */
  /* -------------------------------------------- */

  /** @override */
  getInitialValue(source) {
    const initial = super.getInitialValue(source);
    if ( this.required && (initial === undefined) ) return this._cleanType({}, {persisted: true}, {});
    return initial;
  }

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

  /** @override */
  _cast(value) {
    return isPlainObject(value) ? value : {};
  }

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

  /** @inheritdoc */
  _cleanType(data, options, _state) {

    // Expand objects if dot-notation keys were used
    SchemaField.expandObject(data, options, _state);

    // Restrict to schema and process DataFieldOperator instances
    this.#cleanKeys(data, options.prune, options.persisted);

    // Clean each field of the schema
    for ( const [name, field] of this.entries() ) {
      if ( !field.persisted && options.persisted ) continue;
      const cleanField = (name in data) || !options.partial || !_state.source; // Clean all fields if going from empty -> *
      if ( !cleanField ) continue;
      const v = data[name];
      const _innerState = {..._state, source: _state.source?.[name]};
      if ( v instanceof DataFieldOperator ) {
        if ( v instanceof ForcedReplacement ) {
          const cv = DataFieldOperator.get(v);
          DataFieldOperator.set(v, field.clean(cv, Object.freeze({...options, partial: false}), _innerState));
        }
      }
      else data[name] = field.clean(v, options, _innerState);
    }
    return data;
  }

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

  /**
   * Restrict candidate input data to the fields defined by this schema.
   * Handle the presence of DataFieldOperator instances in serialized or legacy key format.
   * @param {object} data  Candidate data for the schema field
   * @param {boolean} prune Prune keys not specified in the schema
   * @param {boolean} persisted Prune keys corresponding with non-persisted fields
   */
  #cleanKeys(data, prune, persisted) {
    for ( let k in data ) {
      const v = data[k];

      // Backwards compatibility for legacy "special keys" into DataFieldOperator instances
      /** @deprecated since v14 until v16 */
      if ( k[1] === "=" ) {
        if ( k[0] === "-" ) {
          const k2 = k.slice(2);
          foundry.utils.logCompatibilityWarning(`The {${k}: null} syntax for forced deletion of a data field value` +
            ` is no longer supported in favor of {${k2}: new foundry.data.operators.ForcedDeletion()}`,
            {since: 14, until: 16});
          if ( v !== null ) throw new Error(`Removing the value of a data field using the ${k} deletion syntax
           requires the value of that deletion key to be null. For example {${k}: null}`);
          data[k2] = new ForcedDeletion();
          delete data[k];
          k = k2;
        }
        else if ( k[0] === "=" ) {
          const k2 = k.slice(2);
          foundry.utils.logCompatibilityWarning(`The {${k}: value} syntax for forced replacement of a data field ` +
            ` value is no longer supported in favor of {${k2}: new foundry.data.operators.ForcedReplacement(value)}`,
            {since: 14, until: 16});
          data[k2] = ForcedReplacement.create(v);
          delete data[k];
          k = k2;
        }
      }

      // Discard keys not present in the schema
      const field = this.fields[k];
      if ( (!field && prune) || (persisted && (field?.persisted === false)) ) {
        delete data[k];
        continue;
      }

      // Reconstruct serialized DataFieldOperator instances
      SchemaField.reconstructOperator(data, k, v);
    }
  }

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

  /**
   * Expand a transacted object.
   * @param {object} data                       The object.
   * @param {DataModelCleaningOptions} options  Cleaning operation options.
   * @param {DataModelUpdateState} _state       Cleaning operation state.
   */
  static expandObject(data, options, _state) {
    if ( options.expand === false ) return;
    expandObjectInPlace(data, {shallow: true});
  }

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

  /**
   * When iterating over the keys and values of an object, reconstruct serialized DataFieldOperator values.
   * @param {object} data
   * @param {string} k
   * @param {any} v
   */
  static reconstructOperator(data, k, v) {
    if ( v && (typeof v === "object") && (OPERATOR_IDENTIFIER in v) ) {
      try {
        data[k] = reconstructOperator(v);
      } catch(err) {
        console.warn(err.message);
        delete data[k];
      }
    }
  }

  /* -------------------------------------------- */
  /*  Data Initialization                         */
  /* -------------------------------------------- */

  /** @override */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    const data = {};
    for ( const [name, field] of this.entries() ) {
      const fieldValue = field.persisted ? value[name] : field.getInitialValue(value);
      const initialized = field.initialize(fieldValue, model, options);

      // Readonly fields
      const isFunction = typeof initialized === "function";
      const isClass = isFunction && initialized.prototype
        && Function.prototype.toString.call(initialized).startsWith("class ");
      if ( isFunction && !isClass ) {
        Object.defineProperty(data, name, {get: initialized, configurable: true, enumerable: true});
      }
      else if ( field.readonly ) {
        Object.defineProperty(data, name, {value: initialized, writable: false, enumerable: true});
      }

      // Writable fields
      else data[name] = initialized;
    }
    return data;
  }

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

  /** @inheritDoc */
  _updateDiff(key, value, options, state) {
    const result = DataFieldOperator.get(value);

    // * -> null
    if ( !result ) return super._updateDiff(key, value, options, state);

    // Pre-validation of the requested value
    const preFailure = this.validate(result, {strict: false, phase: "pre", partial: true});
    if ( preFailure?.unresolved ) {
      state.failure.fields[key] = preFailure;
      state.failure.unresolved = true;
      return;
    }

    // Recursive updates and validation of child fields
    const wasNullish = !state.source[key];
    const source = (state.source[key] ||= {});
    const diff = state.diff[key] = {};
    let failure = new DataModelValidationFailure("SchemaField#_updateDiff", {fieldPath: this.fieldPath});
    const fieldState = {...state, model: state.model, source, diff, failure};
    for ( const [k, v] of iterateEntries(result) ) {
      const field = this.get(k);
      field?._updateDiff(k, v, options, fieldState);
    }

    // Post-validation of the resulting schema
    if ( !failure.unresolved ) {
      const validationOptions = {strict: false, partial: !wasNullish};
      if ( !wasNullish ) validationOptions.phase = "post";
      failure = this.validate(source, validationOptions) ?? failure;
    }

    // Record failure
    if ( failure.unresolved ) {
      state.failure.fields[key] = failure;
      state.failure.unresolved = true;
      delete diff[key];
      return;
    }

    // Record final changes
    if ( value instanceof ForcedReplacement ) state.diff[key] = value;
    else if ( isEmpty(diff) ) delete state.diff[key];
  }

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

  /** @override */
  _updateCommit(source, key, value, diff, options) {
    const s = source[key];

    // * -> undefined, * -> null, undefined -> *, null -> *
    if ( !s || !value ) {
      source[key] = value;
      return;
    }

    // * -> *
    for ( const [k, d] of iterateEntries(diff) ) {
      const field = this.get(k);
      field?._updateCommit(s, k, value[k], d, options);
    }
  }

  /* -------------------------------------------- */
  /*  Data Validation                             */
  /* -------------------------------------------- */

  /** @override */
  _validateType(data, options={}) {
    if ( !(data instanceof Object) ) throw new Error("must be an object");
  }

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

  /** @override */
  _validateRecursive(data, options) {
    const schemaFailure = new DataModelValidationFailure("SchemaField#_validateRecursive",
      {fieldPath: this.fieldPath, unresolved: false});
    const validationOptions = {...options, strict: false};
    for ( const [key, field] of this.entries() ) {
      if ( !(key in data) && (options.partial || !field.persisted) ) continue;
      const failure = field.validate(data[key], validationOptions);
      if ( failure ) {
        schemaFailure.fields[key] = failure;
        SchemaField._handleValidationFailure(field, data, key, schemaFailure, failure, options);
      }
    }
    if ( !schemaFailure.empty ) throw schemaFailure; // Throw even if resolved
  }

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

  /**
   * An internal helper function that attempts fallback to a valid initial value in the case of validation failure.
   * @param {DataField} field                             The child data field being handled
   * @param {object} data                                 Data attempted for validation at the parent level
   * @param {string} key                                  The key of this field being validated
   * @param {DataModelValidationFailure} parentFailure    The validation failure at the parent level
   * @param {DataModelValidationFailure} fieldFailure     The validation failure at this field level
   * @param {Partial<DataModelValidationOptions>} options Handling options
   * @internal
   */
  static _handleValidationFailure(field, data, key, parentFailure, fieldFailure, options) {
    if ( !fieldFailure.unresolved ) return; // Fallback already applied
    if ( options.fallback ) {               // Allow fallback
      const initial = field.getInitialValue(options.model?._source ?? {});
      try {
        field.validate(initial, {...options, fallback: false, strict: true});
        data[key] = fieldFailure.fallbackValue = initial;
        fieldFailure.unresolved = false;    // Resolved via fallback
      } catch(err) {
        fieldFailure.unresolved = parentFailure.unresolved = true;
      }
    }
    else parentFailure.unresolved = fieldFailure.unresolved = true;
  }

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

  /** @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 ) continue;
      try {
        field._validateModel(change, options);
      } catch(err) {
        if ( err instanceof DataModelValidationFailure ) throw err;
        const failure = new DataModelValidationFailure(err.message, {
          fieldPath: field.fieldPath, unresolved: true, cause: err
        });
        failure.joint = err.message;
        throw failure;
      }
    }
  }

  /* -------------------------------------------- */
  /*  Field Transformations                       */
  /* -------------------------------------------- */

  /** @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 */
  _applyChangeAdd(value, delta, model, change) {
    return value;
  }
}

/* -------------------------------------------- */
/*  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, _options) {
    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 */
  _applyChangeSubtract(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 ( this.integer && this.positive ) this.min ||= 1;
    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);
  }

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

  /** @override */
  _cleanType(value, _options, _state) {
    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, _options) {
    if ( (typeof value !== "number") || Number.isNaN(value) ) 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 ( typeof choices === "function" ) choices = choices();
    if ( Array.isArray(choices) ) 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                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _castChangeDelta(raw, replacementData) {
    if ( typeof raw === "string" ) {
      raw = foundry.dice.Roll.create(this._replaceDataRefs(raw, replacementData)).evaluateSync().total;
    }
    return super._castChangeDelta(raw);
  }

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

  /** @override */
  _applyChangeSubtract(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 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, _state) {
    if ( (typeof value === "string") && this.trim ) value = value.trim(); // Trim input strings
    return super.clean(value, options, _state);
  }

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

  /** @override */
  getInitialValue(source) {
    const initial = super.getInitialValue(source);
    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, _options) {
    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 ( typeof choices === "function" ) choices = choices();
    if ( Array.isArray(choices) ) 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}} options
   * @returns {FormSelectOption}
   */
  static #getChoiceFromEntry(entry, {labelAttr="label", valueAttr}) {
    const choice = {};
    if ( isPlainObject(entry) ) {
      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);
    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}`);
    }
  }

  /* -------------------------------------------- */
  /*  Active Effect Integration                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _replaceDataRefs(raw, data, {strict=true}={}) {
    const resolved = super._replaceDataRefs(raw, data, {strict: false});
    if ( strict && /@\{[^}\s]+\}/.test(resolved) ) {
      throw new Error(`Unable to replace all data references in ${JSON.stringify(raw)}.`);
    }
    return resolved;
  }

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

  /**
   * @param {string} value
   * @param {string} delta
   * @override
   */
  _applyChangeSubtract(value, delta, model, change) {
    return value.replace(delta, "");
  }
}

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

/**
 * 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(source) {
    const initial = super.getInitialValue(source);
    if ( this.required && (initial === undefined) ) return {};
    return initial;
  }

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

  /** @override */
  _cast(value) {
    if ( typeof value.toObject === "function" ) value = value.toObject();
    else if ( typeof value.toJSON === "function" ) value = value.toJSON();
    return isPlainObject(value) ? value : {};
  }

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

  /** @override */
  _cleanType(data, options, _state) {
    return this.#reconstructOperators(data, options, _state);
  }

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

  /**
   * Deeply reconstruct DataFieldOperators within an ObjectField data structure.
   * @param {object}                   data
   * @param {DataModelCleaningOptions} options
   * @param {DataModelUpdateState}     _state
   * @returns {object}
   */
  #reconstructOperators(data, options, _state) {
    SchemaField.expandObject(data, options, _state);
    for ( const k in data ) {
      SchemaField.reconstructOperator(data, k, data[k]);
      let v = data[k];
      if ( v && (typeof v === "object") ) {
        if ( !Array.isArray(v) && !(v instanceof DataFieldOperator) ) v = data[k] = this._cast(v);
        this.#reconstructOperators(v, options, _state);
      }
    }
    return data;
  }

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

  /** @override */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    return deepClone(value);
  }

  /* ---------------------------------------- */
  /*  Validation and Updates                  */
  /* ---------------------------------------- */

  /** @override */
  _validateType(value, _options) {
    if ( !isPlainObject(value) ) throw new Error("must be an object");
  }

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

  /** @override */
  _updateDiff(key, value, options, state) {

    // * -> null or special data field operators
    if ( !value || (value instanceof DataFieldOperator) ) return super._updateDiff(key, value, options, state);

    // Pre-validation of the requested value
    let failure = this.validate(value, {strict: false, phase: "pre", partial: true});
    if ( failure?.unresolved ) {
      state.failure.fields[key] = failure;
      state.failure.unresolved = true;
      return; // Prevent assignment
    }

    // * -> {}
    if ( !isPlainObject(state.source[key]) ) state.source[key] = {};
    const diff = diffObject(state.source[key], value);
    if ( isEmpty(diff) ) return;
    const result = mergeObject(state.source[key], diff, {applyOperators: true, inplace: false});

    // Post-validation of the resulting value
    failure ||= this.validate(result, {strict: false, phase: "post", partial: true});
    if ( failure && !failure?.empty && failure.unresolved ) {
      state.failure.fields[key] = failure;
      state.failure.unresolved = true;
      return; // Prevent assignment
    }

    // Assign results to diff and source
    state.diff[key] = diff;
    state.source[key] = result;
  }

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

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

    // Replace object with new values
    for ( const k of iterateKeys(s) ) {
      if ( !(k in value) ) delete s[k];
    }
    Object.assign(s, value);
  }

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

  /** @override */
  toObject(value) {
    return deepClone(value);
  }

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

  /** @override */
  _applyChangeAdd(value, delta, model, change) {
    return value;
  }
}

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

/**
 * 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 {TypedObjectFieldOptions} [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, expandKeys: true});
  }

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

  /** @override */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    const object = {};
    for ( const key in value ) object[key] = this.element.initialize(value[key], model, options);
    return object;
  }

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

  /** @override */
  _getField(parts, {source}={}) {
    if ( !parts.length ) return this;
    const name = parts.pop();
    if ( source === undefined ) {
      if ( name !== this.element.name ) return undefined;
    } else {
      if ( this.validateKey?.(name) === false ) return undefined;
      if ( source !== null ) {
        if ( typeof source !== "object" ) return undefined;
        source = source[name] ?? null;
      }
    }
    return this.element._getField(parts, {source});
  }

  /* -------------------------------------------- */
  /*  Field Cleaning                              */
  /* -------------------------------------------- */

  /** @override */
  _cleanType(data, options, _state) {
    if ( this.expandKeys ) SchemaField.expandObject(data, options, _state);
    const clearObject = options.persisted && !this.element.persisted;
    for ( let key in data ) {
      if ( clearObject ) {
        delete data[key];
        continue;
      }

      // Backwards compatibility for converting legacy "special keys" into DataFieldOperator instances.
      /** @deprecated since v14 until v16 */
      if ( foundry.utils.isDeletionKey(key) ) {
        const k2 = key.slice(2);
        if ( key[0] === "-" ) {
          foundry.utils.logCompatibilityWarning(`The {${key}: null} syntax for forced deletion of a data field value is`
            + ` no longer supported in favor of {${k2}: new foundry.data.operators.ForcedDeletion()}`,
          {since: 14, until: 16});
          if ( data[key] !== null ) throw new Error(`Removing the value of a data field using the ${key} deletion`
            + ` syntax requires the value of that deletion key to be null. For example {${key}: null}`);
          data[k2] = new ForcedDeletion();
        } else {
          foundry.utils.logCompatibilityWarning(`The {${key}: value} syntax for forced replacement of a data field`
            + ` value is no longer supported in favor of {${k2}: new foundry.data.operators.ForcedReplacement(value)}`,
          {since: 14, until: 16});
          data[k2] = ForcedReplacement.create(data[key]);
        }
        delete data[key];
        key = k2;
      }

      const _innerState = {..._state, source: _state.source?.[key]};
      SchemaField.reconstructOperator(data, key, data[key]);
      const v = data[key];

      // Eliminate any keys which are invalid
      let valid;
      try {
        valid = this.validateKey?.(key);
      } catch {
        valid = false;
      }
      if ( valid === false ) {
        delete data[key];
        continue;
      }

      // Clean ForcedReplacement values
      if ( v instanceof DataFieldOperator ) {
        if ( v instanceof ForcedReplacement ) {
          const cv = DataFieldOperator.get(v);
          DataFieldOperator.set(v, this.element.clean(cv, Object.freeze({...options, partial: false}), _innerState));
        }
      }

      // Clean standard keys
      else data[key] = this.element.clean(v, options, _innerState);
    }
    return data;
  }

  /* -------------------------------------------- */
  /*  Field Validation                            */
  /* -------------------------------------------- */

  /** @override */
  _validateRecursive(data, options={}) {
    options.source = options.source || data;
    const objectFailure = new DataModelValidationFailure("TypedObjectField#_validateRecursive",
      {fieldPath: this.fieldPath, unresolved: false});
    const validationOptions = {...options, strict: false};
    for ( const [key, value] of iterateEntries(data) ) {
      if ( value instanceof ForcedDeletion ) continue; // Allow removing keys
      const failure = this.element.validate(value, validationOptions);
      if ( failure ) {
        objectFailure.fields[key] = failure;
        SchemaField._handleValidationFailure(this.element, data, key, objectFailure, failure, options);
      }
    }
    if ( !objectFailure.empty ) throw objectFailure; // Throw even if resolved
  }

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

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

  /* -------------------------------------------- */
  /*  Field Updates                               */
  /* -------------------------------------------- */

  /** @override */
  _updateDiff(key, value, options, state) {

    // * -> null or DataFieldOperator
    if ( !value || (value instanceof DataFieldOperator) ) return super._updateDiff(key, value, options, state);

    // Pre-validation of the requested value
    let failure = this.validate(value, {strict: false, phase: "pre", partial: true});
    if ( failure?.unresolved ) {
      state.failure.fields[key] = failure;
      state.failure.unresolved = true;
      return; // Prevent assignment
    }

    // * -> {}
    const source = (state.source[key] ||= {});
    const diff = {};
    failure ||= new DataModelValidationFailure("TypedObjectField#_updateDiff", {fieldPath: this.fieldPath});
    const fieldState = {...state, model: state.model, source, diff, failure};
    for ( const [k, v] of iterateEntries(value) ) {
      if ( v instanceof ForcedDeletion ) { // Keep the deletion in the diff, but remove it from source
        diff[k] = v;
        delete source[k];
        continue;
      }
      this.element._updateDiff(k, v, options, fieldState);
    }
    if ( isEmpty(diff) ) return; // No changes

    // Post-validation of the resulting value
    failure ||= this.validate(source, {strict: false, phase: "post", partial: true});
    if ( failure && !failure?.empty && failure.unresolved ) {
      state.failure.fields[key] = failure;
      state.failure.unresolved = true;
      return; // Prevent assignment
    }

    // Assign results to diff and source
    state.diff[key] = diff;
    state.source[key] = source;
  }

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

  /** @override */
  _updateCommit(source, key, value, diff, options) {
    const s = source[key];

    // * -> 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 iterateKeys(s) ) {
      if ( !(k in value) ) delete s[k];
    }

    // Update fields in source which changed in the diff
    for ( const [k, d] of iterateEntries(diff) ) {
      if ( d instanceof ForcedDeletion ) continue;
      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;
  }
}

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

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

  /** @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;

  /**
   * The data type of each element in this array
   * @type {ElementType}
   */
  element;

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

  /**
   * 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(source) {
    const initial = super.getInitialValue(source);
    if ( this.required && (initial === undefined) ) return [];
    return initial;
  }

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

  /** @override */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    return value.map(v => this.element.initialize(v, model, options));
  }

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

  /** @override */
  _getField(parts, {source}={}) {
    if ( !parts.length ) return this;
    const name = parts.pop();
    if ( source === undefined ) {
      if ( name !== this.element.name ) return undefined;
    } else if ( source !== null ) {
      if ( !Array.isArray(source) ) return undefined;
      const index = Number(name);
      if ( !Number.isInteger(index) || (index < 0) ) return undefined;
      source = source[index] ?? null;
    }
    return this.element._getField(parts, {source});
  }

  /* ---------------------------------------- */
  /*  Field Cleaning                          */
  /* ---------------------------------------- */

  /** @override */
  _cast(value) {
    if ( Array.isArray(value) ) return value;
    if ( isPlainObject(value) ) {
      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;
    }
    if ( value instanceof Set ) return Array.from(value);
    return [value];
  }

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

  /**
   * When cleaning an ArrayField we must force {partial: false} since arrays are always fully replaced.
   * We take care to preserve the provided array reference, cleaning its elements rather than replacing the array.
   * @override
   */
  _cleanType(value, options, _state) {
    if ( options.persisted && !this.element.persisted ) {
      value.length = 0;
      return value;
    }

    // Constrain allowed array length
    if ( value.length > this.max ) value.length = this.max;

    // Clean every array element, propagating the inner model when elements are embedded DataModels.
    const isInnerModel = this.element instanceof EmbeddedDataField;
    for ( let i = 0; i < value.length; i++ ) {
      const _innerState = {..._state, source: _state.source?.[i]};
      if ( isInnerModel ) {
        _innerState.model = _state.model?._getInnerModel?.(this, { value: value[i], index: i }, options) ?? null;
      }
      value[i] = this._cleanElement(value[i], options, _innerState);
    }
    return value;
  }

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

  /**
   * Clean data for an individual element in the ArrayField.
   * @param {object} value                      Unclean data for the array entry
   * @param {DataModelCleaningOptions} options  Options which control how data is cleaned
   * @param {DataModelUpdateState} _state       State used during data cleaning
   * @returns {object}                          Cleaned data for the array entry
   * @protected
   */
  _cleanElement(value, options, _state) {
    return this.element.clean(value, Object.freeze({...options, partial: false}), _state);
  }

  /* ---------------------------------------- */
  /*  Field Validation                        */
  /* ---------------------------------------- */

  /** @override */
  _validateType(value, options) {
    if ( !Array.isArray(value) ) 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`);
  }

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

  /** @override */
  _validateRecursive(value, options) {
    const arrayFailure = new DataModelValidationFailure("ArrayField#_validateRecursive",
      {fieldPath: this.fieldPath, unresolved: false});
    const validationOptions = {...options, partial: false, strict: false}; // Arrays are always fully replaced
    validationOptions.dropInvalidElements ??= false; // Array fields cannot drop elements unless explicitly allowed
    for ( let i=value.length-1; i>=0; i-- ) { // Iterate backwards so we can splice as we go
      const v = value[i];
      const failure = this.element.validate(v, validationOptions);
      if ( failure ) {
        arrayFailure.elements.push({id: i, failure});
        ArrayField._handleValidationFailure(this.element, value, i, arrayFailure, failure, validationOptions);
      }
    }

    // Throw for any failure (even if resolved)
    arrayFailure.unresolved = arrayFailure.elements.some(e => e.failure.unresolved);
    if ( !arrayFailure.empty ) {
      arrayFailure.elements.reverse();
      throw arrayFailure;
    }
  }

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

  /** @override */
  _validateModel(changes, options) {
    if ( !this.element.constructor.recursive ) return;
    for ( const element of changes ) {
      this.element._validateModel(element, options);
    }
  }

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

  /**
   * @see {@link SchemaField._handleValidationFailure}
   * @internal
   */
  static _handleValidationFailure(field, value, index, parentFailure, fieldFailure, options) {
    SchemaField._handleValidationFailure(field, value, index, parentFailure, fieldFailure, options);
    // Optionally allow dropping invalid elements from Array-like structures
    if ( fieldFailure.unresolved && options.dropInvalidElements ) {
      value.splice(index, 1);
      fieldFailure.dropped = true;
      fieldFailure.unresolved = false;
    }
  }

  /* ---------------------------------------- */
  /*  Field Updates                           */
  /* ---------------------------------------- */

  /** @override */
  _updateDiff(key, value, options, state) {

    // * -> null
    const result = DataFieldOperator.get(value);
    if ( !result ) return super._updateDiff(key, value, options, state);

    // * -> []
    const current = state.source[key];
    let isDiff = result.length !== current?.length;
    const update = [];
    for ( const [i, v] of result.entries() ) {
      const v1 = applyDataOperators(v);
      const v0 = current?.[i];
      update.push(v1);
      isDiff ||= !foundry.utils.equals(v1, v0);
    }
    if ( !isDiff ) return;

    // Validate the field recursively because arrays are fully replaced leaf nodes
    const failure = this.validate(update, {recursive: true, fallback: options.fallback, strict: false});
    if ( failure ) {
      state.failure.fields[key] = failure;
      if ( failure?.unresolved ) return; // Prevent assignment
    }

    // Assign results of the update
    state.diff[key] = value;    // Retain array element operators in the difference
    state.source[key] = update; // Source copy includes applied operators
  }

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

  /**
   * 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];
    // * -> undefined or * -> null
    if ( !s || !value ) {
      source[key] = value;
      return;
    }
    // * -> []
    s.length = 0;
    s.push(...value);
  }

  /* -------------------------------------------- */
  /*  Field Transformations                       */
  /* -------------------------------------------- */

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

  /* -------------------------------------------- */
  /*  Active Effect Integration                   */
  /* -------------------------------------------- */

  /** @override */
  _castChangeDelta(raw, replacementData) {
    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, replacementData));
  }

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

  /** @override */
  _applyChangeAdd(value, delta, model, change) {
    return value.concat(delta);
  }

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

  /**
   * @param {unknown[]} value
   * @param {unknown[]} delta
   * @override
   */
  _applyChangeSubtract(value, delta, model, change) {
    const changed = [...value];
    for ( const element of delta ) {
      changed.findSplice(e => equals(e, element));
    }
    return changed;
  }
}

/* -------------------------------------------- */
/*  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 */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    return new Set(super.initialize(value, model, options));
  }

  /* -------------------------------------------- */
  /*  Field Validation                            */
  /* -------------------------------------------- */

  /** @override */
  _validateRecursive(value, options={}) {
    const dropInvalidElements = options.dropInvalidElements ?? options.fallback;
    super._validateRecursive(value, {...options, dropInvalidElements});
  }

  /* -------------------------------------------- */
  /*  Form Field Integration                      */
  /* -------------------------------------------- */

  /** @override */
  _toInput(config) {
    const element = this.element;

    // Document UUIDs
    if ( element instanceof DocumentUUIDField ) {
      Object.assign(config, {type: element.type, single: false});
      if ( element.relative && config.model && config.value ) {
        config.value = config.value.map(uuid => parseUuid(uuid, {relative: config.model})?.uuid ?? uuid);
      }
      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`);
  }

  /* -------------------------------------------- */
  /*  Field Transformations                       */
  /* -------------------------------------------- */

  /** @override */
  toObject(value) {
    if ( !value ) return value;
    return Array.from(value).map(v => this.element.toObject(v));
  }

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

  /** @inheritDoc */
  _castChangeDelta(raw, replacementData) {
    return new Set(super._castChangeDelta(raw, replacementData));
  }

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

  /** @override */
  _applyChangeAdd(value, delta, model, change) {
    return value.union(delta);
  }

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

  /** @override */
  _applyChangeSubtract(value, delta, model, change) {
    return value.difference(delta);
  }

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

  /** @override */
  _applyChangeUpgrade(value, delta, model, change) {
    return delta.isSupersetOf(value) ? delta : value;
  }

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

  /** @override */
  _applyChangeDowngrade(value, delta, model, change) {
    return delta.isSubsetOf(value) ? delta : value;
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.SchemaField} that represents the root schema node of a DataModel.
 */
class DataModelSchemaField 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 = Object.freeze(model.defineSchema());
    super(fields, options, context);

    /**
     * The base DataModel definition which is contained in this field.
     * @type {typeof DataModel}
     */
    this.model = model;
  }

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

  /** @override */
  _getField(parts, {source}={}) {
    if ( !parts.length ) return this;
    const name = parts.pop();
    const field = this.get(name);
    if ( !field ) return undefined;
    let type;
    if ( (source !== undefined) && (source !== null) ) {
      if ( typeof source !== "object" ) return undefined;
      if ( this.fields.type instanceof DocumentTypeField ) type = source.type;
      source = source[name] ?? null;
    }
    return field._getField(parts, {source, type});
  }

  /* ---------------------------------------- */
  /*  Field Cleaning                          */
  /* ---------------------------------------- */

  /** @inheritDoc */
  clean(value, options={}, _state={}) {
    const modelCls = this.model.implementation ?? this.model;
    options = {...options};

    // Recreate and reset state to be based on this model node
    _state = {..._state};
    delete _state.documentType; // To be recorded during _preCleanData
    delete _state.documentId;

    if ( isPlainObject(value) ) SchemaField.expandObject(value, options, _state);

    modelCls._preCleanData(value, options, _state); // DataModel._preCleanData workflow
    Object.freeze(options); // Freeze after pre-cleaning which may alter cleaning options

    // SchemaField#clean workflow
    value = super.clean(value, options, _state);

    // DataModel._cleanData workflow
    if ( options.model ) modelCls._cleanData(value, options, _state);
    return value;
  }

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

  /** @override */
  _cast(value) {
    if ( typeof value.toObject === "function" ) value = value.toObject();
    return isPlainObject(value) ? value : {};
  }

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

  /** @override */
  _migrate(value, options, _state) {
    if ( isPlainObject(value) ) (this.model.implementation ?? this.model).migrateDataSafe(value, options);
    return value;
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.DataModelSchemaField} used for embedded data models.
 */
class EmbeddedDataField extends DataModelSchemaField {

  /** @override */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    // FIXME avoid reconstruction unless necessary #12001
    return new this.model(value, {parent: model, schema: this, ...options, clean: false});
  }

  /* ---------------------------------------- */
  /*  Field Validation                        */
  /* ---------------------------------------- */

  /** @override */
  _validateModel(changes, options) {
    this.model.validateJoint(changes);
  }

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

  /** @inheritDoc */
  _updateDiff(key, value, options, state) {
    let result = value;
    if ( value instanceof DataFieldOperator ) result = applyDataOperators(value);
    if ( !result ) return super._updateDiff(key, value, options, state);
    const priorSource = (state.source[key] || {});

    // Pre-validation of the requested value
    let failure = this.validate(result, {strict: false, phase: "pre", partial: true});
    if ( failure?.unresolved ) {
      state.failure.fields[key] = failure;
      state.failure.unresolved = true;
      return;
    }

    // Update a pre-existing model
    let model;
    if ( !isEmpty(priorSource) ) {
      model = this.model.fromSource(priorSource, {strict: false, clean: false});
      try {
        const modelDiff = model.updateSource(value, {clean: false});
        if ( isEmpty(modelDiff) ) return; // No changes
      } catch(err) {
        if ( err instanceof DataModelValidationError ) failure = err.getFailure();
        else return; // Unhandled error
      }
    }

    // Create a new model
    else {
      try {
        model = new this.model(result);
      } catch(err) {
        if ( err instanceof DataModelValidationError ) failure = err.getFailure();
        else return; // Unhandled error
      }
    }

    // Post-validation of the resulting value
    failure ||= this.validate(model._source, {strict: false, phase: "post", partial: true});
    if ( failure && !failure?.empty && failure.unresolved ) {
      state.failure.fields[key] = failure;
      state.failure.unresolved = true;
      return;
    }

    // Assign results to both diff and source
    state.diff[key] = value;
    state.source[key] = model._source;
  }

  /* ---------------------------------------- */
  /*  Field Transformation                    */
  /* ---------------------------------------- */

  /** @override */
  toObject(value) {
    if ( !value ) return value;
    return value.toObject(false);
  }
}

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

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

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

  /** @override */
  initialize(value, model, options={}) {
    const collection = model.collections[this.name];
    collection.initialize(options);
    return collection;
  }

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

  /**
   * Return the embedded document(s) as a Collection.
   * @param {Document} parent  The parent document.
   * @returns {DocumentCollection}
   */
  getCollection(parent) {
    return parent[this.name];
  }

  /* -------------------------------------------- */
  /*  Field Cleaning                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _cast(value) {
    if ( !(value instanceof 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, _state) {
    const sourceIdMap = {};
    if ( _state.source ) {
      for ( const record of _state.source ) sourceIdMap[record._id] = record;
    }
    const collection = _state.model?.[this.name] instanceof EmbeddedCollection ? _state.model[this.name] : null;
    for ( let i = 0; i < value.length; i++ ) {
      const v = value[i];
      const source = sourceIdMap[v._id];
      const model = collection?.get(v._id) ?? null;

      // Propagate inner state. Values without matching source are being created as part of a parent update.
      const _innerState = {..._state, source, modelSource: source, model, creation: _state.creation || !source};
      if ( this.model.hasTypeData ) {
        let documentType = DataFieldOperator.get(v.type) ?? source?.type;
        if ( !documentType && this.model.metadata.baseTypeAllowed ) documentType = BASE_DOCUMENT_TYPE;
        _innerState.documentId = source?._id;
        _innerState.documentType = documentType;
      }
      value[i] = this._cleanElement(v, options, _innerState);
    }
    return value;
  }

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

  /** @override */
  _cleanElement(value, options, _state) {
    if ( !options.partial ) value._id ||= randomID(16);
    return this.model.cleanData(value, {...options, copy: false}, _state);
  }

  /* -------------------------------------------- */
  /*  Field Validation                            */
  /* -------------------------------------------- */

  /** @override */
  _validateRecursive(value, options) {
    const collectionFailure = new DataModelValidationFailure("EmbeddedCollectionField#_validateRecursive",
      {fieldPath: this.fieldPath, unresolved: false});
    const collection = options.model?.[this.fieldPath];
    for ( let i=value.length-1; i>=0; i-- ) { // Iterate backwards so we can splice as we go
      const v = value[i];
      const m = collection?.get(v._id);
      const validationOptions = {...options, partial: false, model: m, strict: false};
      const failure = this.schema.validate(v, validationOptions);
      if ( failure ) {
        collectionFailure.elements.push({id: i, failure});
        EmbeddedCollectionField._handleValidationFailure(this.schema, value, i, collectionFailure, failure, {
          model: options.model,
          fallback: false, // Embedded documents cannot fallback
          dropInvalidEmbedded: !!options.dropInvalidEmbedded
        });
      }
    }

    // Throw for any failure (even if resolved)
    collectionFailure.unresolved = collectionFailure.elements.some(e => e.failure.unresolved);
    if ( !collectionFailure.empty ) {
      collectionFailure.elements.reverse();
      throw collectionFailure;
    }
  }

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

  /**
   * @see {@link SchemaField._handleValidationFailure}
   * @internal
   */
  static _handleValidationFailure(field, value, index, parentFailure, fieldFailure, options) {
    SchemaField._handleValidationFailure(field, value, index, parentFailure, fieldFailure, options);
    if ( fieldFailure.unresolved && options.dropInvalidEmbedded ) {
      // Do not drop elements from the source so that they can be included in the collection's invalid documents set
      // and later retrieved via getInvalid.
      fieldFailure.dropped = true;
      fieldFailure.unresolved = false;
    }
  }

  /* -------------------------------------------- */
  /*  Field Updates                               */
  /* -------------------------------------------- */

  /** @override */
  _updateDiff(key, value, options, state) {

    // * -> null
    const result = DataFieldOperator.get(value);
    if ( !result ) return super._updateDiff(key, value, options, state);

    // Pre-validation of the requested value
    let failure = this.validate(result, {strict: false, phase: "pre", partial: true});
    if ( failure?.unresolved ) {
      state.failure.fields[key] = failure;
      state.failure.unresolved = true;
      return; // Prevent assignment
    }

    // Otherwise create or diff individual array members
    const isReplacement = value instanceof ForcedReplacement;
    const source = (state.source[key] ||= []);
    const newSource = isReplacement ? [] : source;
    const sourceMap = {};
    for ( const obj of source.values() ) sourceMap[obj._id] = obj;
    const diff = [];
    failure ||= new DataModelValidationFailure("EmbeddedCollectionField#_updateDiff", {fieldPath: this.fieldPath});
    const collection = this.getCollection(state.model);

    // Recursive update and validation of embedded Documents
    const ctx = {source, newSource, isReplacement, diff, collection, state, failure};
    for ( const v of result ) {
      const existingSource = sourceMap[v._id];
      this._updateElement(v, existingSource, ctx);
    }
    if ( !diff.length && !isReplacement ) return; // No changes

    // Post-validation of the resulting value
    failure ||= this.validate(result, {strict: false, phase: "post", partial: true});
    if ( failure && !failure?.empty && failure.unresolved ) {
      state.failure.fields[key] = failure;
      state.failure.unresolved = true;
      return; // Prevent assignment
    }

    // Assign results to diff
    state.diff[key] = isReplacement ? value : diff;
    if ( isReplacement ) state.source[key] = newSource;
  }

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

  /**
   * Apply an embedded collection update for a single element of the requested value array. Each element is one of:
   * an update to an existing record, the creation of a new record, or (for subclasses such as
   * {@link EmbeddedCollectionDeltaField}) a tombstone marking a deletion. Subclasses may override this method to
   * intercept element-level update behavior.
   * @param {object} v                                The element being processed.
   * @param {object|undefined} existingSource         The existing source record matched by `_id`, if any.
   * @param {EmbeddedCollectionUpdateContext} ctx     Loop-local context shared across elements.
   * @protected
   */
  _updateElement(v, existingSource, ctx) {

    // Update an existing record
    if ( existingSource ) {
      const doc = ctx.collection.createDocument(existingSource);
      try {
        const d = doc.updateSource(v, {clean: false, recursive: !ctx.isReplacement,
          modifiedTime: ctx.state.modifiedTime, user: ctx.state.user});
        if ( !isEmpty(d) ) {
          d._id = v._id;
          ctx.diff.push(d);
        }
        if ( ctx.isReplacement ) ctx.newSource.push(existingSource);
      } catch(error) {
        if ( error instanceof DataModelValidationError ) {
          ctx.failure.elements.push({id: v._id, failure: error.getFailure()});
        }
        else throw new Error("Unexpected Error class", {cause: error});
      }
      return;
    }

    // Create a new record
    const createData = applyDataOperators(v);
    if ( !createData._id ) createData._id = randomID();
    try {
      const doc = ctx.collection.createDocument(createData, {strict: true, creation: true,
        modifiedTime: ctx.state.modifiedTime, user: ctx.state.user});
      ctx.diff.push(doc._source);
      ctx.newSource.push(doc._source);
    } catch(err) {
      ctx.failure.elements.push({id: createData._id, failure: err});
    }
  }

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

  /** @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 ) {
      if ( !obj ) continue;
      changed[obj._id] = obj;
    }

    // Reconstruct the source array, retaining object references
    src.length = 0;
    for ( const obj of value ) this._commitElement(obj, src, existing, changed, options);
  }

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

  /**
   * Commit a single element into the destination source array.
   * Subclasses may override this method to apply specialized logic for individual elements.
   * @param {object} obj                            The element being committed
   * @param {object[]} src                          The destination source array under construction
   * @param {Record<string, object>} existing       Map of pre-update source records
   * @param {Record<string, object>} changed        Map of diff entries
   * @param {DataModelUpdateOptions} options        Update options
   * @protected
   */
  _commitElement(obj, src, existing, changed, options) {
    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);
      return;
    }
    src.push(obj);
  }

  /* -------------------------------------------- */
  /*  Field Transformations                       */
  /* -------------------------------------------- */

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

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

/**
 * 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, _state) {
    if ( value._tombstone ) return foundry.data.TombstoneData.schema.clean(value, options, _state);
    return super._cleanElement(value, options, _state);
  }

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

  /** @override */
  _validateRecursive(value, options) {
    const collectionFailure = new DataModelValidationFailure("EmbeddedCollectionDeltaField#_validateRecursive",
      {fieldPath: this.fieldPath, unresolved: false});
    for ( const v of value ) {
      const schema = v._tombstone ? foundry.data.TombstoneData.schema : this.schema;
      const failure = 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;
  }

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

  /** @inheritDoc */
  _updateElement(v, existingSource, ctx) {

    // Ignore updates to an existing Tombstone unless made via ForcedReplacement
    if ( existingSource?._tombstone && !ctx.isReplacement ) return;

    // Create new Tombstones
    if ( v._tombstone ) {
      const tombstoneFailure = foundry.data.TombstoneData.schema.validate(v, {strict: false, source: v});
      if ( tombstoneFailure?.unresolved ) {
        ctx.failure.elements.push({id: v._id, failure: tombstoneFailure});
        return;
      }
      if ( existingSource && !ctx.isReplacement ) ctx.source.splice(ctx.source.indexOf(existingSource), 1);
      ctx.diff.push(v);
      ctx.newSource.push(v);
      return;
    }

    // Replace an existing Tombstone with a new Document
    if ( existingSource?._tombstone ) existingSource = null;
    return super._updateElement(v, existingSource, ctx);
  }

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

  /** @inheritDoc */
  _commitElement(obj, src, existing, changed, options) {
    if ( obj._tombstone || existing[obj._id]?._tombstone ) src.push(obj);
    else super._commitElement(obj, src, existing, changed, options);
  }
}

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

/**
 * 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];
    }
    return new this.model(value, {...options, parent: model, parentCollection: this.name, clean: false, schema: this});
  }

  /* -------------------------------------------- */
  /*  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, { modifySource: false });
    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 && this.nullable ) return null;
    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,
      relative: false
    });
  }

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

  /** @inheritDoc */
  _cleanType(value, options, state) {
    value = super._cleanType(value, options, state);
    if ( !value.startsWith(".") ) return value;
    if ( !this.relative || !state?.model ) return value;
    try {
      value = buildRelativeUuid(value, state.model);
    } catch(err) {}
    return value;
  }

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

  /** @override */
  _validateType(value, options={}) {
    if ( value.startsWith(".") ) {
      if ( !this.relative ) throw Error("must not be a relative document UUID");
      else if ( !(options.model instanceof foundry.abstract.Document) ) throw Error("cannot be resolved without a document instance");
    }
    const p = parseUuid(value, {relative: options.model});
    if ( !p ) throw new Error("must be a valid document UUID");
    if ( this.type ) {
      if ( p.type !== this.type ) throw new Error(`must reference document type "${this.type}"`);
    }
    else if ( p.type && !ALL_DOCUMENT_TYPES.includes(p.type) ) throw new Error("must reference a valid document type");
    if ( (this.embedded === true) && !p.embedded.length ) throw new Error("must reference an embedded document");
    if ( (this.embedded === false) && p.embedded.length ) throw new Error("may not reference an embedded document");
    if ( !isValidId(p.documentId) ) throw new Error("must contain a valid document ID");
  }

  /* -------------------------------------------- */
  /*  Form Field Integration                      */
  /* -------------------------------------------- */

  /** @override */
  _toInput(config) {
    Object.assign(config, {type: this.type, single: true});
    if ( this.relative && config.model && config.value ) {
      config.value = parseUuid(config.value, {relative: config.model})?.uuid ?? config.value;
    }
    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 ( !value && this.nullable ) return null;
    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 ( value === null ) return () => null;
    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 && typeof this.initial !== "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, _options) {

    // 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
    if ( config.type === undefined ) {
      if ( this.categories.length === 1 ) config.type = this.categories[0].toLowerCase();
      else if ( (this.categories.length === 2) && this.categories.includes("IMAGE") && this.categories.includes("VIDEO") ) {
        config.type = "imagevideo";
      }
      else config.type = "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 {
  /** @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);
    return value;
  }
}

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

/**
 * 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: false, // Server-side sanitization rules prevent changing authors
      initial: () => game.user?.id ?? null
    });
  }
}

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

/**
 * 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: false // Server-side sanitization rules prevent changing ownership
    });
  }

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

  /** @inheritDoc */
  _cleanType(data, options, _state) {
    super._cleanType(data, options, _state);
    if ( !options.partial ) data.default ??= DOCUMENT_OWNERSHIP_LEVELS.NONE;
    return data;
  }

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

  /** @override */
  _validateType(value, _options) {
    for ( const [k, v] of Object.entries(value) ) {
      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, _state) {
    if ( value === "" ) return '""';  // Special case for JSON fields
    return super.clean(value, options, _state);
  }

  /** @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. Setting the serializable option to true will apply some light cleaning
 * and validation, ensuring that a candidate value will not be appreciably changed when serialized for writing to disk.
 * It is not recommended to use this class except in very specific circumstances.
 */
class AnyField extends DataField {
  /** @inheritDoc */
  static get _defaults() {
    return Object.assign(super._defaults, {nullable: true, serializable: false});
  }

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

  /** @inheritDoc */
  clean(value, options, _state) {
    if ( !this.serializable ) return super.clean(value, options, _state);
    if ( typeof value === "string" ) value = value.trim();
    else if ( this.nullable && ((value === Infinity) || (value === -Infinity)) ) value = null;
    return super.clean(value, options, _state);
  }

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

  /** @override */
  _validateType(value) {
    if ( !this.serializable ) return;
    const stringified = JSON.stringify(value);
    if ( !equals(value, JSON.parse(stringified)) ) {
      throw new Error(`"${stringified}" ${this.validationError}`);
    }
  }

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

  /** @override */
  _toInput(config) {
    if ( !this.serializable ) throw new Error("Only serializable AnyField values have form inputs.");
    config.elementType ??= "code-mirror";
    if ( config.elementType !== "code-mirror" ) {
      config.value = typeof config.value === "string" ? config.value : JSON.stringify(config.value);
      return foundry.applications.fields.createTextInput(config);
    }
    config.language = "json";
    config.indent ??= 2;
    config.value = JSON.stringify(config.value, null, config.indent);
    return foundry.applications.elements.HTMLCodeMirrorElement.create(config);
  }
}

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

/**
 * 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({required: true}),
      modifiedTime: new NumberField({required: true}),
      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.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
    });
  }

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

  /** @ignore */
  _sanitizeType(value, options, _state) {
    if ( !options.deleteStats ) return value;
    const clean = this.clean({});
    if ( !isPlainObject(value) ) return clean;
    for ( const fieldName of this.constructor.managedFields ) value[fieldName] = clean[fieldName];
    return value;
  }
}

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

/**
 * 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, nullable: false});
  }

  /** @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 */
  _getField(parts, {source, type}={}) {
    if ( !parts.length ) return this;
    if ( !type ) return undefined;
    const model = this.getModelForType(type);
    return model?.schema?._getField(parts, {source});
  }

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

  /** @override */
  initialize(value, model, options={}) {
    const cls = this.getModelForType(model._source.type);
    if ( cls ) {
      const instance = new cls(value, {parent: model, ...options, clean: false});
      if ( !("modelProvider" in instance) ) Object.defineProperty(instance, "modelProvider", {
        value: this.constructor.getModelProvider(instance),
        writable: false
      });
      return instance;
    }
    return deepClone(value);
  }

  /* ---------------------------------------- */
  /*  Field Cleaning                          */
  /* ---------------------------------------- */

  /** @inheritDoc */
  clean(value, options, _state) {
    if ( !value ) return this.getInitialValue({type: _state.documentType});
    return super.clean(value, options, _state);
  }

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

  /** @override */
  _migrate(value, options, _state) {
    if ( !isPlainObject(value) ) return value;
    const cls = this.getModelForType(_state.documentType);
    if ( cls ) {
      const migrated = cls.migrateDataSafe(value, options);
      /** @deprecated since v14 */
      if ( migrated === undefined ) {
        foundry.utils.logCompatibilityWarning(`${cls.name} has a migrateData implementation that does not return a `
          + "value. Such implementations will be unsupported in a future version.", { since: 14, until: 16 });
        return value;
      }
      return migrated;
    }
    return value;
  }

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

  /** @override */
  getInitialValue(source) {
    const initial = super.getInitialValue(source) || {};
    return this._cleanType(initial, {partial: false}, {documentType: source.type});
  }

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

  /** @override */
  _cleanType(value, options, _state) {
    const type = _state.documentType;

    // Use a defined DataModel
    const cls = this.getModelForType(type);
    if ( cls ) {
      const _innerState = {..._state, model: _state.model?._getInnerModel?.(this, { value }, options) ?? null};
      return cls.cleanData(value, {...options, copy: false}, _innerState);
    }
    else super._cleanType(value, options, _state); // Clean as an object field
    if ( options.partial ) return value;

    // Use a defined template.json
    /** @deprecated since v14 until v16 */
    const template = game?.model[this.documentName]?.[type];
    if ( template ) {
      const insertKeys = (type === BASE_DOCUMENT_TYPE) || !game?.system?.strictDataCleaning;
      return mergeObject(template, value, {insertKeys, inplace: false});
    }
    return value;
  }

  /* ---------------------------------------- */
  /*  Field Validation                        */
  /* ---------------------------------------- */

  /** @override */
  _validateRecursive(value, options) {
    const source = options.model?._source;
    const cls = this.getModelForType(source?.type);
    if ( !cls ) return;
    return cls.schema.validate(value, {...options, source, strict: true});
  }

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

  /** @override */
  _validateModel(changes, options={}) {
    const cls = this.getModelForType(options.source?.type);
    return cls?.validateJoint(changes);
  }

  /* ---------------------------------------- */
  /*  Field Updates                           */
  /* ---------------------------------------- */

  /** @inheritDoc */
  _updateDiff(key, value, options, state) {
    const cls = this.getModelForType(state.source.type);
    if ( cls ) return cls.schema._updateDiff(key, value, options, state);
    super._updateDiff(key, value, options, state);

    // Without a registered model the value is diffed as a plain object, which omits a replacement matching the current
    // source. Retain it, as SchemaField does, so the type change it accompanies remains applicable.
    if ( value instanceof ForcedReplacement ) state.diff[key] = value;
  }

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

  /** @inheritDoc */
  _updateCommit(source, key, value, diff, options) {
    if ( diff instanceof ForcedReplacement ) {
      source[key] = applyDataOperators(diff);
      return;
    }
    const cls = this.getModelForType(source.type);
    if ( cls ) cls.schema._updateCommit(source, key, value, diff, options);
    else super._updateCommit(source, key, value, diff, options);
  }

  /* ---------------------------------------- */
  /*  Field Transformations                   */
  /* ---------------------------------------- */

  /** @override */
  toObject(value) {
    return typeof value.toObject === "function" ? value.toObject(false) : deepClone(value);
  }
}

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

/**
 * 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 ( isPlainObject(field) ) {
        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.`);

      // Ensure the final type is valid
      const valueFailure = typeField.validate(type, {fallback: false});
      if ( valueFailure ) {
        valueFailure.message = `"${type}" must be a valid type of "${typeField.fieldPath}".`;
        throw valueFailure;
      }
    }
    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(parts, {source}={}) {
    let schema;
    if ( source === undefined ) {
      if ( !parts.length ) return this;
      schema = this.#getTypeSchema(parts.pop());
    } else if ( source !== null ) {
      if ( typeof source !== "object" ) return undefined;
      schema = this.#getTypeSchema(source.type);
    }
    return schema?._getField(parts, {source});
  }

  /* -------------------------------------------- */
  /*  Data Field Methods                          */
  /* -------------------------------------------- */

  /** @override */
  _cast(value) {
    if ( typeof value.toObject === "function" ) value = value.toObject();
    return isPlainObject(value) ? value : {};
  }

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

  /** @override */
  _cleanType(value, options, _state) {
    const type = DataFieldOperator.get(value.type) ?? _state.source?.type;
    const schema = this.#getTypeSchema(type);
    if ( !schema ) return value;
    if ( options.addTypes ) value.type = type;
    if ( type !== _state.source?.type ) options = Object.freeze({...options, partial: false}); // Require full clean
    return schema.clean(value, options, _state);
  }

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

  /** @override */
  _validateType(value, options) {
    if ( typeof value !== "object" ) throw new Error("does not have a valid type");
    let typeValue = value.type || options.model?._source[this.fieldPath]?.type;
    typeValue = DataFieldOperator.get(typeValue);
    const schema = this.#getTypeSchema(typeValue);
    if ( !schema ) throw new Error("does not have a valid type");
    if ( options.recursive === false ) return;
    return schema.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(key, value, options, state) {

    // * -> null, or DataFieldOperator
    if ( !value || (value instanceof DataFieldOperator) ) return super._updateDiff(key, value, options, state);

    // Prevent type changes except by ForcedReplacement
    const existing = state.source[key];
    const typeValue = value.type instanceof DataFieldOperator
      ? DataFieldOperator.get(value.type)
      : (value.type ?? existing?.type);
    const typeChanged = ("type" in value) && existing && (typeValue !== existing.type);
    if ( typeChanged && !(value.type instanceof ForcedReplacement) ) {
      throw new Error("The type of a TypedSchemaField can be changed only by ForcedReplacement.");
    }

    // Call updateDiff for the new type schema
    const schema = this.#getTypeSchema(typeValue);
    if ( schema ) schema._updateDiff(key, value, options, state);
    else super._updateDiff(key, value, options, state);
  }

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

  /** @inheritDoc */
  _updateCommit(source, key, value, diff, options) {
    const schema = this.#getTypeSchema(value?.type ?? source[key]?.type);
    if ( schema && diff ) {
      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) ?? {};
  }
}

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

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

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

/**
 * A nullable set field used to reference a set of specific Scene Level documents.
 */
class SceneLevelsSetField extends SetField {
  /**
   * @param {ArrayFieldOptions} [options]  Options which configure the behavior of the field
   * @param {DataFieldContext} [context]   Additional context which describes the field
   */
  constructor(options={}, context={}) {
    super(new DocumentIdField({nullable: false, readonly: false}), options, context);
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {required: true, initial: []});
  }
}

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

/**
 * A subclass of {@link foundry.data.fields.ArrayField} for shapes.
 */
class ShapesField extends ArrayField {
  /**
   * @param {ArrayFieldOptions} [options]  Options which configure the behavior of the field
   * @param {DataFieldContext} [context]   Additional context which describes the field
   */
  constructor(options, context) {
    super(new TypedSchemaField(foundry.data.BaseShapeData.TYPES), options, context);
  }

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

  /** @override */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    return value.map((v, i) => {
      const shape = this.element.initialize(v, model, options);
      shape._index = i;
      return shape;
    });
  }
}

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

/**
 * The field for a grid offset.
 */
class GridOffsetField extends SchemaField {
  /**
   * @param {GridOffsetFieldOptions} [options]  Options which configure the behavior of the field
   * @param {DataFieldContext} [context]        Additional context which describes the field
   */
  constructor(options={}, context={}) {
    const {dimensions=2} = options;
    if ( ![2, 3].includes(dimensions) ) throw new Error("Invalid number of dimensions");
    const schema = {
      i: new NumberField({required: true, nullable: false, integer: true, initial: undefined}),
      j: new NumberField({required: true, nullable: false, integer: true, initial: undefined})
    };
    if ( dimensions === 3 ) schema.k = new NumberField({required: true, nullable: false, integer: true});
    super(schema, options, context);
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {dimensions: 2});
  }

  /** @inheritDoc */
  _cast(value) {
    if ( typeof value === "string" ) {
      const coords = value.split(".");
      if ( coords.length === this.dimensions ) {
        if ( this.dimensions === 2 ) return {i: coords[0], j: coords[1]};
        return {i: coords[0], j: coords[1], k: coords[2]};
      }
    }
    return super._cast(value);
  }
}

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

/**
 * The field of an array/set of grid offsets.
 */
class GridOffsetsField extends ArrayField {
  /**
   * @param {GridOffsetsFieldOptions} [options]  Options which configure the behavior of the field
   * @param {DataFieldContext} [context]         Additional context which describes the field
   */
  constructor(options, context) {
    super(new GridOffsetField({dimensions: options?.dimensions ?? 2}), options, context);
  }

  /** @inheritdoc */
  static get _defaults() {
    return Object.assign(super._defaults, {dimensions: 2});
  }

  /* -------------------------------------------- */
  /*  Form Field Integration                      */
  /* -------------------------------------------- */

  /** @override */
  _toInput(config) {
    if ( config.value ) config.value = config.value.map(offset => {
      if ( typeof offset === "string" ) return offset;
      if ( "k" in offset ) return `${offset.i}.${offset.j}.${offset.k}`;
      return `${offset.i}.${offset.j}`;
    });
    const elementClass = this.dimensions === 2 ? foundry.applications.elements.HTMLGridOffset2DTagsElement
      : foundry.applications.elements.HTMLGridOffset3DTagsElement;
    return elementClass.create(config);
  }
}

/**
 * @import {DataModelUpdateState, DataModelCleaningOptions} from "../data/_types.mjs";
 * @import {DataField} from "../data/fields.mjs";
 * @import {
 *  DataModelConstructionContext,
 *  DataModelFromSourceOptions,
 *  DataModelSanitizationOptions,
 *  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, schema, 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
    });

    // Explicit instance-level schema
    if ( schema ) {
      if ( !(schema instanceof DataModelSchemaField) ) {
        throw new Error(`The schema provided to the ${this.name}#constructor context is not a DataModelSchemaField
        instance.`);
      }
      Object.defineProperty(this, "schema", {value: schema, 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
    this.validate({
      strict,
      fallback: options.fallback ?? !strict,
      dropInvalidEmbedded: options.dropInvalidEmbedded ?? !strict
    });
    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 {DataModelSchemaField}
   * @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 {DataModelSchemaField}
   */
  static get schema() {
    if ( this.hasOwnProperty("_schema") ) return this._schema;
    Object.defineProperty(this, "_schema", {
      value: new DataModelSchemaField(this),
      writable: false, enumerable: false, configurable: true});
    return this.schema;
  }

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

  /**
   * Define the data schema for this document instance.
   * @type {DataModelSchemaField}
   */
  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 = [];

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

  /**
   * Traverse the data model instance, obtaining the DataField definition for a field of a particular property.
   * @param {string[]|string} key      A property key like ["abilities", "strength"] or "abilities.strength"
   * @returns {DataField|undefined}    The corresponding DataField definition for that field, or undefined
   */
  getFieldForProperty(key) {
    return this.schema.getField(key, {source: this._source});
  }

  /* ---------------------------------------- */
  /*  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 {ModelContext} [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 ( options.clean !== false ) {
      const cleanOptions = typeof options.clean === "object" ? options.clean : {};
      data = this.constructor.cleanData(data, {
        prune: true,        // Prune changes unless prevented
        migrate: true,      // Migrate data unless prevented
        model: true,        // Joint model cleaning unless prevented
        sanitize: false,    // Data at rest is already sanitized
        ...cleanOptions,    // User-provided cleaning options
        copy: false,        // Data provided for DataModel construction becomes owned by that model
        partial: false      // Source data must not be partial
      }, {creation: options.creation, model: this, source: data});
    }
    return this.constructor.shimData(data);
  }

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

  /**
   * Clean a data source object to conform to a specific provided schema.
   * @param {object} [data]                       Provided model data that requires cleaning
   * @param {DataModelCleaningOptions} [options]  Options that configure how data cleaning is performed
   * @param {Partial<DataModelUpdateState>} [_state]  Internal options used during cleaning recursion
   * @returns {object}                            Cleaned data which is suitable for validation and usage
   */
  static cleanData(data={}, {addTypes=false, copy=true, fields=true, expand=true, migrate=true, model=true,
    partial=false, prune=true, persisted=true, sanitize=true, source: _optionsSource}={}, _state={}) {

    // Re-pack options
    if ( _optionsSource ) {
      logCompatibilityWarning(`You have passed options.source to ${this.name}.cleanData. This source data should now
      be passed as part of the third _state parameter`, {since: 14, until: 16});
      _state.source ||= _optionsSource;
    }
    sanitize = typeof sanitize === "object" ? sanitize : (sanitize ? {} : false); // {...} or false
    const cleanOptions = {addTypes, expand, migrate, model, partial, prune, persisted, sanitize};
    _state.cleanOptions = true;

    // Accept a DataModel instance or a plain object
    if ( data instanceof DataModel ) data = copy ? data.toObject() : data._source;
    else {
      if ( !isPlainObject(data) ) throw new Error(`${this.name} must be constructed with a DataModel or Object.`);
      if ( copy ) data = deepClone(data);
    }

    // Perform cleaning, starting from the DataModel root
    return this.schema.clean(data, cleanOptions, _state);
  }

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

  /**
   * Apply preliminary model-specific cleaning rules or alter cleaning options or initial state.
   * Subclass models may implement this function to configure the cleaning workflow.
   * Any mutations to data, options, or _state parameters are performed inplace.
   * @param {object} data                               The provided input data for cleaning
   * @param {DataModelCleaningOptions} options          Options which define how cleaning should be performed
   * @param {DataModelUpdateState} _state               The data cleaning state
   * @protected
   */
  static _preCleanData(data, options, _state) {
    _state.creation ??= false;

    // Treat provided input data as "source" data for new creations
    if ( _state.creation ) {
      _state.source ??= data;
      _state.modelSource ??= data;
    }

    // Otherwise record existing model source
    else {
      _state.source ??= _state.model?._source;
      _state.modelSource ??= _state.source;
    }
  }

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

  /**
   * Apply final custom model-specific cleaning rules after data schema fields are cleaned.
   * Subclass models can implement this function as an ideal place to apply custom imputation or cleaning.
   * Cleaning must be done in-place rather than returning a different object.
   * @param {object} data                                 The provided input data for cleaning
   * @param {Readonly<DataModelCleaningOptions>} options  Options which define how cleaning was performed
   * @param {DataModelUpdateState} _state                 The data cleaning state
   * @returns {object}                                    The original data object, with cleaning performed inplace
   * @protected
   */
  static _cleanData(data, options, _state) {}

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

  /**
   * Resolve a previously-initialized embedded DataModel that corresponds to an element being cleaned. Called during
   * recursive data cleaning when a parent field contains EmbeddedDataField elements, or a single nested DataModel
   * field (such as a TypeDataField), in order to propagate the inner DataModel into _state.model for the recursive
   * clean operation. A single (non-collection) inner model resolves to itself; collection elements are matched by
   * stable _id when one is present, otherwise by positional index.
   *
   * Subclasses may override when data preparation reshapes the field property in a way that the default cannot
   * interpret, for example wrapping the container in a non-iterable type or replacing it with a derived view.
   *
   * @param {DataField} field                 The schema field being recursed into.
   * @param {object} element
   * @param {object} element.value            The cleaned candidate value for the element being processed.
   * @param {number} [element.index]          The positional index of the element within a container, if applicable.
   * @param {Readonly<DataModelCleaningOptions>} [options]  The cleaning options in effect for the operation.
   * @returns {DataModel|null}                The corresponding previously-initialized inner DataModel, or null.
   * @protected
   */
  _getInnerModel(field, { value, index }={}, options={}) {
    // Compute the field's path relative to this model's schema, walking up the parent chain until reaching it. This
    // ensures lookups traverse intermediate plain SchemaFields rather than accessing the field name directly on the
    // incorrect model.
    const parts = [];
    for ( let f = field; f; f = f.parent ) {
      if ( f.name ) parts.unshift(f.name);
      if ( f.parent === this.schema ) break;
    }
    const path = parts.join(".");
    const container = path ? getProperty(this, path) : null;
    if ( !container ) return null;

    // Case 0 - A single (non-collection) inner DataModel.
    if ( container instanceof DataModel ) return container;

    // Case 1 - A collection element identified by stable _id, which does not require a positional index.
    if ( value?._id ) {
      if ( typeof container.get === "function" ) {
        const m = container.get(value._id);
        return m instanceof DataModel ? m : null;
      }
      if ( !container[Symbol.iterator] ) return null;
      for ( const m of container ) {
        if ( m?._id === value._id ) return m instanceof DataModel ? m : null;
      }
      return null;
    }

    if ( index === undefined ) return null;

    // Case 2 - Basic Array.
    if ( Array.isArray(container) ) {
      const m = container[index];
      return m instanceof DataModel ? m : null;
    }

    // Case 3 - Any other Iterable.
    if ( !container[Symbol.iterator] ) return null;
    let i = 0;
    for ( const m of container ) {
      if ( i === index ) return m instanceof DataModel ? m : null;
      i++;
    }

    return null;
  }

  /* ---------------------------------------- */
  /*  Data Initialization                     */
  /* ---------------------------------------- */

  /**
   * A generator that orders the DataFields in the DataSchema into an expected initialization order.
   * @returns {Generator<[string,DataField]>}
   * @yields {DataField}
   * @protected
   */
  *_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._initializationOrder() ) {
      const sourceValue = field.persisted ? this._source[name] : field.getInitialValue(this._source);

      // Field initialization
      const value = field.initialize(sourceValue, this, options);
      const isFunction = typeof value === "function";
      const isClass = isFunction && value.prototype && Function.prototype.toString.call(value).startsWith("class ");

      // Special handling for Document IDs
      if ( name === "_id" ) {

        // Skip if the property is not configurable
        if ( Object.getOwnPropertyDescriptor(this, name)?.configurable === false ) continue;

        Object.defineProperty(this, name, {value, writable: false, configurable: !value, enumerable: true});
      }

      // Getter fields
      else if ( isFunction && !isClass ) {

        // Skip if the class defines instance getter already
        if ( name in this.constructor.prototype ) continue;

        // Skip if the instance property is not configurable
        if ( Object.getOwnPropertyDescriptor(this, name)?.configurable === false ) continue;

        Object.defineProperty(this, name, {get: value, configurable: true, enumerable: true});
      }

      // Readonly fields
      else if ( field.readonly ) {

        // Skip if the class defines instance getter already
        if ( name in this.constructor.prototype ) continue;

        // Skip if the instance property is not configurable
        if ( Object.getOwnPropertyDescriptor(this, name)?.configurable === false ) continue;

        // Readonly value fields
        Object.defineProperty(this, name, {value, writable: false, configurable: false, enumerable: true});
      }

      // Writable fields
      else {
        let propertyDescriptor = Object.getOwnPropertyDescriptor(this, name);
        if ( !propertyDescriptor ) {
          const cls = getDefiningClass(this, name);
          if ( cls ) propertyDescriptor = Object.getOwnPropertyDescriptor(cls.prototype, name);
        }

        // Skip if the property is not assignable
        if ( !propertyDescriptor || (propertyDescriptor.writable === true) || propertyDescriptor.set ) {
          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, applyOperators: true, inplace: true});
    return new this.constructor(data, {parent: this.parent, ...context, strict: false});
  }

  /* ---------------------------------------- */
  /*  Data Validation Methods                 */
  /* ---------------------------------------- */

  /**
   * Validate the data contained in the document to check for type and content.
   * This method is intended to validate complete model records, verifying both individual field validation as well
   * as joint model validity.
   *
   * For validating sets of partial model changes, it is preferred to call DataModel#updateSource as a `dryRun`. This
   * method provides a convenience alias for such a workflow if `changes` are provided.
   *
   * Warning: if fallback handling is allowed, this process will mutate provided changes or model source data.
   *
   * @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 {DataModelValidationError}             An error thrown if validation is strict and a failure occurs.
   */
  validate({changes, clean, dropInvalidEmbedded=false, strict=true, fallback=false, fields=true, joint=true}={}) {

    // Validate a set of changes via a dryRun of updateSource
    if ( changes ) {
      const cleanOptions = (clean === false) ? false : Object.assign({
        addTypes: clean === true, // Impute types into the cleaned changes
        copy: true,               // Copy provided changes unless prevented
        prune: true,              // Prune changes unless prevented
        persisted: true,          // Likewise with non-persisted data
        migrate: false,           // Validate changes without migrations
        model: clean === true,    // Model-level cleaning is not performed unless specifically requested
        sanitize: false           // Validate changes without sanitizations
      }, clean, {                 // User-provided inputs
        partial: true             // Always do partial cleaning in case required fields are missing in changes
      });
      try {
        this.updateSource(changes, {clean: cleanOptions, dropInvalidEmbedded, fallback, dryRun: true});
      } catch(err) {
        if ( strict ) throw err;
        return false;
      }
      return true;
    }

    // Otherwise, we are validating a complete set of model source
    const data = this._source;

    // Validate individual fields in the data or in a specific change-set, throwing errors if validation fails
    if ( fields ) {
      this.#validationFailures.fields = null;
      const options = {dropInvalidEmbedded, fallback, model: this, partial: false, strict: false}; // Non-strict so we capture errors
      const err = this.schema.validate(data, options);
      if ( err ) {
        const failure = this.#createValidationFailure(err);
        this.#validationFailures.fields = failure;
        if ( strict && failure.unresolved ) throw failure.asError();
        else logger.warn(failure);
      }
    }

    // Perform joint document-level validations which consider all fields together
    if ( joint ) {
      this.#validationFailures.joint = null;
      try {
        this.schema._validateModel(data);     // Validate inner models
        this.constructor.validateJoint(data); // Validate this model
      } catch ( err ) {
        const failure = this.#createValidationFailure(err, {joint: true});
        this.#validationFailures.joint = failure;
        if ( strict ) throw failure.asError();
        else logger.warn(failure);
      }
    }
    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) {}

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

  /**
   * Create a DataModelValidationFailure instance for use in this DataModel.
   * @param {Error} err         Some other error which represents an underlying root cause
   * @param {object} [options]  Options which inform error structure
   * @param {boolean} [options.joint=false] Is this a joint validation failure?
   * @returns {DataModelValidationFailure}
   */
  #createValidationFailure(err, {joint=false}={}) {

    // Construct the failure
    const id = this.uuid ?? [this.constructor.name, this._source.id].filterJoin(".");
    const msg = `[${id}] validation errors:`;
    const options = {fieldPath: "_source", unresolved: true};
    if ( err ) options.cause = err;
    const failure = new DataModelValidationFailure(msg, options);

    // Update content
    if ( err instanceof DataModelValidationFailure ) err.copyTo(failure);
    if ( joint ) failure.joint = err.message;
    else failure.message += ` ${err.message}`;
    failure.message = failure.toString(); // Finalize the message string
    return failure;
  }

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

    // Prepare data modification operation
    const _state = {model: this, source: this._source, modelSource: this._source, user: options.user};
    this._preUpdateSource(changes, options, _state);

    // Clean provided input data unless explicitly prevented
    if ( options.clean ) changes = this.constructor.cleanData(changes, options.clean, _state);

    // Root-level keys in non-recursive changes are handled as ForcedReplacement operations
    if ( options.recursive === false ) DataModel.#performNonRecursiveUpdate(changes);

    // Perform updates on the safe copy of source data
    const copy = this.#prepareSafeSource(changes);
    let diff;
    try {
      diff = this._updateDiff(copy, changes, options, _state);
    } catch(err) {
      if ( err instanceof DataModelValidationFailure ) throw err.asError();
      throw err;
    }

    // Model-level joint validation
    try {
      this.constructor.validateJoint(copy);
    } catch(err) {
      throw this.#createValidationFailure(err, {joint: true}).asError();
    }

    // Commit the final changes
    if ( isEmpty(diff) || options.dryRun ) return diff;
    this._updateCommit(copy, diff, options, _state);
    if ( this.invalid ) this.validate({joint: true}); // Determine whether an invalid state was cured?
    return diff;
  }

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

  /**
   * Prepare the state object that is transacted through an updateSource operation.
   * @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
   * @param {DataModelUpdateState} _state     Data model update state
   * @protected
   */
  _preUpdateSource(changes, options, _state) {

    // Define standard cleaning options
    const cleanOptions = Object.assign({
      addTypes: false,    // Require explicit polymorphic data types
      copy: true,         // Copy provided changes unless prevented
      prune: true,        // Prune changes unless prevented
      migrate: true,      // Migrate data unless prevented
      sanitize: true      // Sanitize input unless prevented
    }, options.clean, {   // User-provided inputs
      partial: true,      // Always do partial cleaning
      persisted: true     // Always prune non-persisted data
    });
    if ( typeof cleanOptions.sanitize !== "object" ) cleanOptions.sanitize = cleanOptions.sanitize ? {} : false;

    // Record pre-cleaning _state flags if the cleaning workflow is skipped
    if ( options.clean === false ) this.constructor._preCleanData(changes, cleanOptions, _state);
    else options.clean = cleanOptions;
  }

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

  /**
   * Perform the first step of the DataModel#_updateSource workflow which applies changes to a copy of model source
   * data and records the resulting diff.
   * @param {object} copy                     A mutable copy of model source 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
   * @param {DataModelUpdateState} _state     Data cleaning state
   * @returns {object}                        The resulting difference applied to source data
   * @throws {DataModelValidationFailure}     A failure if the proposed change is invalid
   * @protected
   */
  _updateDiff(copy, changes, options, _state) {

    // Prepare state to transact throughout the model
    const rootKey = "_source";
    const logId = (this.persisted ? this.uuid : this._id) ?? "";
    const msg = `${this.constructor.name} ${logId ? `[${logId}] ` : ""}validation errors: `;
    const failure = new DataModelValidationFailure(msg, {fieldPath: rootKey, unresolved: false});
    _state = {..._state, source: {[rootKey]: copy}, diff: {[rootKey]: {}}, failure};

    // Required by validation, TODO refactor to use _state.model instead
    options.model = this;

    // Recursive update starting at the root schema
    this.schema._updateDiff(rootKey, changes, options, _state);

    // Handle failure
    const f = failure.fields[rootKey];
    if ( f && !f.empty && f.unresolved ) {
      failure.message += f.toString();
      f.copyTo(failure);
      throw failure;
    }

    // Return the root diff
    return _state.diff[rootKey] || {};
  }

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

  /**
   * Perform the second step of the DataModel#_updateSource workflow which applies the prepared diff to the model.
   * @param {object} copy                     The prepared copy of source data with changes applied
   * @param {object} diff                     The differential changes that were applied to source
   * @param {DataModelUpdateOptions} options  Options which determine how the new data is merged
   * @param {DataModelUpdateState} _state     Data cleaning state which might include instructions for final commit
   * @protected
   */
  _updateCommit(copy, diff, options, _state) {
    this.schema._updateCommit(this, "_source", copy, diff, options);
    this._initialize();
  }

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

  /**
   * 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 iterateKeys(changes) ) {
      if ( k in this._source ) copy[k] = deepClone(this._source[k]);  // Copy for changed fields
    }
    for ( const k of iterateKeys(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 data is presumed trustworthy and is not strictly validated unless explicitly requested.
   * @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));
  }

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

  /**
   * Convert each value of a changes object to be a ForcedReplacement operator.
   * @param {object} changes        An object of changes which will be mutated inplace
   */
  static #performNonRecursiveUpdate(changes) {
    for ( const [k, v] of iterateEntries(changes) ) {
      if ( k === "_id" ) continue; // DataModel#_id can never be a ForcedReplacement
      if ( !(v instanceof DataFieldOperator) ) {
        changes[k] = ForcedReplacement.create(v);
      }
    }
  }

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

  /**
   * Wrap data migration in a try/catch which attempts it safely.
   *
   * @param {object} source                       Candidate source data for the module, before further cleaning
   * @param {Readonly<DataModelCleaningOptions>} [options]  Additional options for how the field is cleaned
   * @returns {object}                            Migrated source data, ready for further cleaning
   */
  static migrateDataSafe(source, options={}) {
    try {
      source = this.migrateData(source, options);
    } catch(err) {
      err.message = `Failed data migration for ${this.name}: ${err.message}`;
      logger.warn(err);
    }
    return source;
  }

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

  /**
   * Migrate candidate source data for this DataModel which may require initial cleaning or transformations.
   * @param {object} source                       Candidate source data for the module, before further cleaning
   * @param {Readonly<DataModelCleaningOptions>} [options]  Additional options for how the field is cleaned
   * @returns {object}                            Migrated source data, ready for further cleaning
   */
  static migrateData(source, options) {
    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;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  static *_initializationOrder() {
    throw new Error(`DataModel._initializationOrder is no longer a static generator and must be called at an instance
    level DataModel#_initializationOrder`);
  }
}

/**
 * @import DatabaseBackend from "./backend.mjs";
 * @import BaseUser from "../documents/user.mjs";
 * @import {DatabaseCreateOperation, DatabaseGetOperation, DatabaseUpdateOperation, DatabaseDeleteOperation,
 *   DocumentCloneOptions, DocumentClassMetadata, RecursiveFieldClearCallback} from "./_types.mjs";
 * @import {DocumentConstructionContext} from "./_types.mjs";
 * @import {DocumentOwnershipLevel, DocumentOwnershipNumber} from "../constants.mjs";
 * @import {DocumentFlags, DocumentStats} from "../data/_types.mjs";
 * @import Collection from "../utils/collection.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, configurable: false, enumerable: true});
    }

    /**
     * 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 {DataModelSchemaField}
   * @override
   */
  static get schema() {
    if ( this._schema ) return this._schema;
    const base = this.baseDocument;
    if ( !base.hasOwnProperty("_schema") ) {
      const schema = new DataModelSchemaField(base);
      Object.defineProperty(base, "_schema", {value: schema, writable: false});
    }
    Object.defineProperty(this, "_schema", {value: base._schema, writable: false});
    return base._schema;
  }

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

  /** @override */
  *_initializationOrder() {
    const hierarchy = this.constructor.hierarchy;
    const hierarchicalFields = [];

    // Initialize non-hierarchical fields first
    for ( const [name, field] of this.schema.entries() ) {
      if ( name in hierarchy ) hierarchicalFields.push(field);
      else yield [name, field];
    }

    // Initialize hierarchical fields last
    for ( const field of hierarchicalFields ) {
      yield [field.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|null}
   */
  get uuid() {
    if ( this._uuid ) return this._uuid;

    // Use source _id if the document id is undefined, which is the case if this property is accessed before
    // the first initialization
    const id = this.id !== undefined ? this.id : this._source._id;
    const {documentName, parent, pack} = this;
    return this._uuid = foundry.utils.buildUuid({id, documentName, parent, pack});
  }

  /* ---------------------------------------- */
  /*  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 ( typeof perm === "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 ( typeof perm === "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                           */
  /* ---------------------------------------- */

  /** @inheritDoc */
  static _preCleanData(data, options, _state) {
    super._preCleanData(data, options, _state);

    // Record document state
    _state.documentId ??= _state.source?._id;
    const {hasTypeData, baseTypeAllowed} = this.metadata;
    if ( hasTypeData ) {
      _state.documentType = DataFieldOperator.get(data.type) ?? _state.source?.type;
      if ( !_state.documentType && baseTypeAllowed ) _state.documentType = BASE_DOCUMENT_TYPE;
      if ( options.addTypes ) data.type = _state.documentType;
    }
  }

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

  /**
   * 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, discardInvalidEmbedded=false, ...remaining} = context;
    context = {parent: this.parent, pack: this.pack, ...remaining, strict: false};
    const source = this.toObject();
    if ( discardInvalidEmbedded ) this.#discardInvalidEmbedded(source);
    data = mergeObject(source, data, {insertKeys: false, applyOperators: true, inplace: true});
    if ( !keepId ) delete data._id;
    if ( addSource && data._stats ) {
      data._stats.duplicateSource = this.uuid;
      data._stats.exportSource = null;
    }
    return save ? this.constructor.create(data, context) : new this.constructor(data, context);
  }

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

  /**
   * Discard invalid Documents from the given source.
   * @param {object} source    The DataModel source
   */
  #discardInvalidEmbedded(source) {
    if ( !source ) return;
    for ( const [name, field] of Object.entries(this.constructor.hierarchy) ) {
      if ( !source[name] ) continue;
      if ( field instanceof foundry.data.fields.EmbeddedDocumentField ) {
        if ( this[name] ) this[name].#discardInvalidEmbedded(source[name]);
        continue;
      }
      const collection = this[name];
      const collectionData = source[name];
      let k = 0;
      for ( const embeddedData of collectionData ) {
        const id = embeddedData._id;
        if ( collection.invalidDocumentIds.has(id) ) continue;
        collectionData[k++] = embeddedData;
        if ( embeddedData._tombstone ) continue;
        const embedded = collection.get(id);
        if ( embedded ) embedded.#discardInvalidEmbedded(embeddedData);
      }
      collectionData.length = k;
    }
  }

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

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

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

  /** @inheritDoc */
  _updateDiff(copy, changes, options, _state) {
    // Require full replacement of system data if the document type changes
    if ( this.constructor.metadata.hasTypeData && ("type" in changes)
      && !ForcedReplacement.equals(changes.type, copy.type) ) {
      if ( !(changes.system instanceof ForcedReplacement) ) {
        throw new Error("The type of a Document may only be changed if the system field is also updated with a "
          + "ForcedReplacement operator.");
      }
    }
    return super._updateDiff(copy, changes, options, _state);
  }

  /* -------------------------------------------- */
  /*  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;
    return this.database.create(this.implementation, operation);
  }

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

  /**
   * 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;
    return this.database.update(this.implementation, operation);
  }

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

  /**
   * 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
   * @throws {Error}                    If an invalid operation is attempted.
   *
   * @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;
    if ( ids.length && operation.deleteAll ) throw new Error("A deleteAll operation must pass no IDs.");
    operation.ids = ids;
    return this.database.delete(this.implementation, operation);
  }

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

  /**
   * 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.implementation.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"|"deleteAll">>} [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 {Collection<string, Document>} 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`);
    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`);
    return this.update({
      flags: {
        [scope]: {
          [key]: _del
        }
      }
    });
  }

  /* -------------------------------------------- */
  /*  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 ( Object.hasOwn(data, oldKey) ) return;
    let oldTarget = data;
    let oldTargetKey = oldKey;
    if ( oldKey.includes(".") ) {
      const parts = oldKey.split(".");
      oldTargetKey = parts.pop();
      oldTarget = getProperty(data, parts.join("."));
    }

    // Verify object
    if ( !isPlainObject(oldTarget) || !Object.isExtensible(oldTarget) || Object.hasOwn(oldTarget, oldTargetKey) ) {
      return;
    }

    // Define the shim
    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,
      enumerable: false
    });
  }

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

  /**
   * 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 oldPath = oldKey.replaceAll(".", "#");
    const newPath = newKey.replaceAll(".", "#");
    const msg = `You are accessing ${this.name}#${oldPath}, which has been migrated to ${this.name}#${newPath}.`;
    logCompatibilityWarning(msg, options);
  }

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

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

/**
 * @import {ActiveEffectData, EffectDurationData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "../abstract/_types.mjs";
 * @import {SerializableBuiltin} 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,
    baseTypeAllowed: true,
    indexed: true,
    compendiumIndexFields: ["_id", "name", "img", "type", "sort", "folder"],
    label: "DOCUMENT.ActiveEffect",
    labelPlural: "DOCUMENT.ActiveEffects",
    schemaVersion: "14.353",
    permissions: {
      create: BaseActiveEffect.#canCreate,
      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"], initial: () => this.implementation.DEFAULT_ICON}),
      type: new DocumentTypeField(this, {initial: CONST.BASE_DOCUMENT_TYPE}),
      system: new TypeDataField(this),
      disabled: new BooleanField(),
      start: new SchemaField({
        combat: new ForeignDocumentField(foundry.documents.BaseCombat),
        combatant: new ForeignDocumentField(foundry.documents.BaseCombatant, {idOnly: true}),
        initiative: new NumberField({required: true}),
        round: new NumberField({required: true, integer: true, min: 0}),
        turn: new NumberField({required: true, integer: true, min: 0}),
        time: new NumberField({required: true, nullable: false, integer: true})
      }, {nullable: true}),
      duration: new SchemaField({
        value: new NumberField({required: true, nullable: true, integer: true, min: 0}),
        units: new StringField({required: true, choices: CONST.ACTIVE_EFFECT_DURATION_UNITS, initial: "seconds"}),
        expiry: new StringField({required: true, blank: false, nullable: true,
          initial: d => typeof d?.duration?.value === "number" ? "turnStart" : null
        }),
        expired: new BooleanField()
      }),
      description: new HTMLField({textSearch: true}),
      origin: new DocumentUUIDField({relative: true}),
      tint: new ColorField({nullable: false, initial: "#ffffff"}),
      transfer: new BooleanField({initial: true}),
      statuses: new SetField(new StringField({required: true, blank: false})),
      showIcon: new NumberField({
        required: true,
        nullable: false,
        choices: Object.values(CONST.ACTIVE_EFFECT_SHOW_ICON),
        initial: CONST.ACTIVE_EFFECT_SHOW_ICON.CONDITIONAL
      }),
      folder: new ForeignDocumentField(foundry.documents.BaseFolder),
      sort: new IntegerSortField(),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "EFFECT"];

  /**
   * The default icon used for newly created ActiveEffect documents
   * @type {string}
   */
  static DEFAULT_ICON = "icons/svg/aura.svg";

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

  /** @override */
  static canUserCreate(user) {
    return user.isGM;
  }

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

  /**
   * Is a User able to create a new ActiveEffect?
   * Embedded ActiveEffects depend on parent (viz. Actor or Item) ownership.
   * Otherwise, the User must be at least an Assistant Gamemaster.
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc) {
    if ( doc.parent ) return doc.parent.testUserPermission(user, "OWNER");
    return user.isGM;
  }

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

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;
    const updates = {};
    if ( !this.parent || (this.parent instanceof foundry.documents.BaseActor) ) {
      updates.transfer = false;
      if ( !this.parent ) updates.start = null;
    }
    this.updateSource(updates);
  }

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

  static #MODES_TO_TYPES = {
    0: "custom",
    1: "multiply",
    2: "add",
    3: "downgrade",
    4: "upgrade",
    5: "override"
  };

  static #TYPES_TO_MODES = Object.entries(BaseActiveEffect.#MODES_TO_TYPES).reduce((types, [mode, type]) => {
    types[type] = Number(mode);
    return types;
  }, {});

  /** @inheritDoc */
  static migrateData(source, options) {

    /**
     * Migrate origin
     * @deprecated since v14
     */
    if ( typeof source.origin === "string" ) {
      const parseOptions = source.origin.startsWith(".") ? {relative: new BaseActiveEffect({name: "ABC"})} : {};
      const parsed = parseUuid(source.origin, parseOptions);
      if ( !parsed || (parsed.type && !CONST.ALL_DOCUMENT_TYPES.includes(parsed.type)) ) {
        mergeObject(source.flags ??= {}, {core: {originText: source.origin}});
        source.origin = null;
      }
    }

    /**
     * Migrate changes
     * @deprecated since v14
     */
    if ( Array.isArray(source.changes) ) {
      source.system ??= {};
      this._addDataFieldMigration(source, "changes", "system.changes");
      if ( Array.isArray(source.system.changes) ) {
        for ( const change of source.system.changes ) {
          if ( !Object.hasOwn(change, "type") && (typeof change.mode === "number") ) {
            change.type = BaseActiveEffect.#MODES_TO_TYPES[change.mode] ?? `custom.${change.mode}`;
            delete change.mode;
          }
          if ( isPlainObject(change) && (typeof change.value === "string") ) {
            change.value = BaseActiveEffect.#migrateChangeValue(change.value);
          }
        }
      }
    }

    /**
     * Migrate start data
     * @deprecated since v14
     */
    const duration = source.duration;
    if ( isPlainObject(duration) && Object.hasOwn(duration, "startTime") && !Object.hasOwn(source, "start") ) {
      source.start = typeof duration.startTime === "number" ? {} : null;
      if ( source.start ) {
        this._addDataFieldMigration(source, "duration.combat", "start.combat");
        this._addDataFieldMigration(source, "duration.startRound", "start.round");
        this._addDataFieldMigration(source, "duration.startTime", "start.time");
        this._addDataFieldMigration(source, "duration.startTurn", "start.turn");
      }
    }
    BaseActiveEffect.#migrateDuration(source);

    return super.migrateData(source, options);
  }

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

  /**
   * Migrate a single change value
   * @param {unknown} value
   * @returns {SerializableBuiltin}
   */
  static #migrateChangeValue(value) {
    if ( (typeof value !== "string") || (value === "") ) return value;
    try {
      return BaseActiveEffect.#migrateChangeValue(JSON.parse(value));
    }
    catch{
      return value;
    }
  }

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

  /**
   * Migrate legacy duration data into its current schema-defined structure.
   * @param {object} source Possibly unmigrated source data
   */
  static #migrateDuration(source) {
    const duration = source.duration;
    if ( !isPlainObject(duration) ) return;
    for ( const unit of ["seconds", "turns", "rounds"] ) {
      const hasRealProperty = Object.hasOwn(duration, unit) && !Object.getOwnPropertyDescriptor(duration, unit)?.get;
      if ( hasRealProperty && (typeof duration[unit] === "number") ) {
        if ( !Object.hasOwn(duration, "value") ) duration.value = duration[unit];
        if ( !Object.hasOwn(duration, "units") ) duration.units = unit;
        break;
      }
    }
  }

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

  /** @inheritDoc */
  static shimData(data, options) {
    if ( Object.isSealed(data) ) return super.shimData(data, options);
    if ( !Object.hasOwn(data, "changes") && Object.hasOwn(data.system ?? {}, "changes") ) {
      Object.defineProperty(data, "changes", {
        get: () => data.system.changes,
        set: changes => {
          data.system.changes = changes;
        },
        configurable: true,
        enumerable: false
      });
    }
    const changes = Array.isArray(data.system?.changes) ? data.system.changes : [];
    this._shimChanges(changes);

    if ( !isPlainObject(data.duration) ) return super.shimData(data, options);
    if ( isPlainObject(data.start) ) {
      this._addDataFieldShim(data, "duration.combat", "start.combat", {since: 14, until: 16, once: true});
    }
    for ( const key of ["startTime", "startRound", "startTurn"] ) {
      const newKey = key.split(/(?=[A-Z])/).map(k => k.toLocaleLowerCase("en")).join(".");
      this._addDataFieldShim(data, `duration.${key}`, newKey, {since: 14, until: 16, once: true});
    }
    const getSeconds = () => {
      if ( typeof data.duration.value !== "number" ) return null;
      if ( data.duration.units === "seconds" ) return data.duration.value;
      if ( (game.view === "game") && CONST.ACTIVE_EFFECT_TIME_DURATION_UNITS.includes(data.duration.units) ) {
        const componentUnit = data.duration.units.replace(/s$/, "");
        return game.time.calendar.componentsToTime({[componentUnit]: data.duration.value});
      }
      return null;
    };
    BaseActiveEffect.#shimDurationField(data.duration, "seconds", getSeconds);
    BaseActiveEffect.#shimDurationField(data.duration, "rounds");
    BaseActiveEffect.#shimDurationField(data.duration, "turns");
    return super.shimData(data, options);
  }

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

  /**
   * Add shims to the changes array.
   * @param {EffectChangeData[]} changes
   * @internal
   */
  static _shimChanges(changes) {
    for ( const change of changes ) {
      if ( Object.getOwnPropertyDescriptor(change, "mode")?.get ) continue;
      Object.defineProperty(change, "mode", {
        get: () => {
          const message = "You are accessing the numeric #mode of an ActiveEffect change. Use the string #type instead.";
          foundry.utils.logCompatibilityWarning(message, {since: 14, until: 16, once: true});
          return BaseActiveEffect.#TYPES_TO_MODES[change.type]
            ?? (Number(/^custom\.(-?\d+)$/.exec(change.type)?.[1]) || 0);
        },
        set: mode => {
          mode = Number(mode);
          if ( Number.isInteger(mode) ) change.type = BaseActiveEffect.#MODES_TO_TYPES[mode] ?? `custom.${mode}`;
        },
        configurable: true,
        enumerable: false
      });
    }
  }

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

  /**
   * Shim several legacy duration properties for backward compatibility
   * @param {Partial<EffectDurationData>} duration
   * @param {string} oldKey The legacy property name
   * @param {() => unknown} [get] A getter function for special cases
   */
  static #shimDurationField(duration, oldKey, get) {
    if ( Object.hasOwn(duration, oldKey) ) return;
    get ??= () => duration.units === oldKey ? duration.value : null;
    const propertyPath = oldKey.replaceAll(".", "#");
    const message = `You are accessing ${this.name}#${propertyPath}. Duration data now has value and units fields.`;
    Object.defineProperty(duration, oldKey, {
      get: () => {
        foundry.utils.logCompatibilityWarning(message, {since: 14, until: 16, once: true});
        return get();
      },
      configurable: true,
      enumerable: false
    });
  }
}

/**
 * @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, options) {
    return foundry.documents.BaseActor.migrateData(source, options);
  }

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

  /**
   * 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={}) {
    options._deltaModel = this;
    if ( !changes ) return;
    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;
  }
}

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

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

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

  /* -------------------------------------------- */
  /*  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
   * @protected
   */
  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 ActiveEffect from "../documents/active-effect.mjs";
 * @import {EffectChangeData} from "../documents/_types.mjs";
 */

/**
 * A TypeDataModel for {@link ActiveEffect}s. A single ArrayField is defined for {@link EffectChangeData}. A system can
 * override the changes SchemaField but must preserve definitions for type, phase, and priority.
 */
class ActiveEffectTypeDataModel extends TypeDataModel {
  /** @override */
  static defineSchema() {
    return {
      changes: new ArrayField(new SchemaField({
        key: new StringField({required: true}),
        type: new StringField({required: true, blank: false, initial: "add",
          validate: ActiveEffectTypeDataModel.#validateType}),
        value: new AnyField({required: true, nullable: true, serializable: true, initial: ""}),
        phase: new StringField({required: true, blank: false, initial: "initial"}),
        priority: new NumberField()
      }))
    };
  }

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

  /**
   * Validate that an {@link EffectChangeData#type} string is well-formed.
   * @param {string} type The string to be validated
   * @returns {true}
   * @throws {Error} An error if the type string is malformed
   */
  static #validateType(type) {
    if ( type.length < 3 ) throw new Error("must be at least three characters long");
    if ( !/^custom\.-?\d+$/.test(type) && !type.split(".").every(s => /^[a-z0-9]+$/i.test(s)) ) {
      throw new Error(
        "A change type must either be a sequence of dot-delimited, alpha-numeric substrings or of the form"
                + ' "custom.{number}"'
      );
    }
    return true;
  }
}

/**
 * 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/embedded-collection-delta.mjs";
 * @import BaseActor from "../documents/actor.mjs";
 * @import {Point} from "@common/_types.mjs";
 * @import {GridOffset2D} from "@common/grid/_types.mjs";
 * @import {PrototypeTokenData} from "@common/documents/_types.mjs";
 * @import {TokenShapeType} from "@common/constants.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, placeholder: "0"}),
        max: new AlphaField({initial: 1, placeholder: "1"})
      }, {
        validate: d => (d.min ?? 0) <= (d.max ?? 1),
        validationError: "darkness.max may not be less than darkness.min"
      })
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["LIGHT"];
}

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

/**
 * 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}),
      height: new NumberField({required: true, integer: true, min: 0}),
      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            Is this shape a hole?
 * @property {number} rotation         The rotation
 */
class BaseShapeData extends DataModel {
  constructor(data, options) {
    super(data, options);
    this.rotation ??= 0;
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["SHAPE.TYPES.base"];

  /**
   * The possible shape types.
   * @type {Readonly<{
   *   rectangle: foundry.data.RectangleShapeData;
   *   circle: foundry.data.CircleShapeData;
   *   ellipse: foundry.data.EllipseShapeData;
   *   emanation: foundry.data.EmanationShapeData;
   *   cone: foundry.data.ConeShapeData;
   *   ring: foundry.data.RingShapeData;
   *   line: foundry.data.LineShapeData;
   *   polygon: foundry.data.PolygonShapeData;
   *   token: foundry.data.TokenShapeData;
   *   grid: foundry.data.GridShapeData;
   * }>}
   */
  static get TYPES() {
    return BaseShapeData.#TYPES ??= Object.freeze({
      [RectangleShapeData$1.TYPE]: foundry.data.RectangleShapeData,
      [CircleShapeData$1.TYPE]: foundry.data.CircleShapeData,
      [EllipseShapeData$1.TYPE]: foundry.data.EllipseShapeData,
      [EmanationShapeData$1.TYPE]: foundry.data.EmanationShapeData,
      [ConeShapeData$1.TYPE]: foundry.data.ConeShapeData,
      [RingShapeData$1.TYPE]: foundry.data.RingShapeData,
      [LineShapeData$1.TYPE]: foundry.data.LineShapeData,
      [PolygonShapeData$1.TYPE]: foundry.data.PolygonShapeData,
      [TokenShapeData$1.TYPE]: foundry.data.TokenShapeData,
      [GridShapeData$1.TYPE]: foundry.data.GridShapeData
    });
  }

  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 index of this shape within the array of shapes in its parent.
   * See {@link foundry.data.fields.ShapesField}.
   * @type {number|undefined}
   * @internal
   */
  _index;
}

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

/**
 * The data model for a rectangle shape.
 *
 * @property {number} x               The x-coordinate of the origin in pixels.
 * @property {number} y               The y-coordinate of the origin in pixels.
 * @property {number} width           The width of the rectangle in pixels.
 * @property {number} height          The height of the rectangle in pixels.
 * @property {number} anchorX         The x-coordinate of the anchor.
 * @property {number} anchorY         The y-coordinate of the anchor.
 * @property {number} rotation        The rotation around the origin of the rectangle in degrees.
 * @property {boolean} gridBased      If the shape is grid-based, its dimensions are converted into grid units
 *                                    by dividing each by the grid size and multiplying by the grid distance. The shape
 *                                    is then constructed using these dimensions conforming to the grid's metric.
 */
let RectangleShapeData$1 = class RectangleShapeData extends BaseShapeData {

  /** @override */
  static LOCALIZATION_PREFIXES = ["SHAPE.TYPES.rectangle", "SHAPE.TYPES.base"];

  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, min: 0}),
      height: new NumberField({required: true, nullable: false, initial: undefined, min: 0}),
      anchorX: new NumberField({required: true, nullable: false, initial: 0}),
      anchorY: new NumberField({required: true, nullable: false, initial: 0}),
      rotation: new AngleField(),
      gridBased: new BooleanField()
    });
  }
};

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

/**
 * 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.
 * @property {boolean} gridBased      If the shape is grid-based, its dimensions are converted into grid units
 *                                    by dividing each by the grid size and multiplying by the grid distance. The shape
 *                                    is then constructed using these dimensions conforming to the grid's metric.
 */
let CircleShapeData$1 = class CircleShapeData extends BaseShapeData {

  /** @override */
  static LOCALIZATION_PREFIXES = ["SHAPE.TYPES.circle", "SHAPE.TYPES.base"];

  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, min: 0}),
      gridBased: new BooleanField()
    });
  }
};

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

/**
 * 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.
 * @property {boolean} gridBased      If the shape is grid-based, its dimensions are converted into grid units
 *                                    by dividing each by the grid size and multiplying by the grid distance. The shape
 *                                    is then constructed using these dimensions conforming to the grid's metric.
 */
let EllipseShapeData$1 = class EllipseShapeData extends BaseShapeData {

  /** @override */
  static LOCALIZATION_PREFIXES = ["SHAPE.TYPES.ellipse", "SHAPE.TYPES.base"];

  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, min: 0}),
      radiusY: new NumberField({required: true, nullable: false, initial: undefined, min: 0}),
      rotation: new AngleField(),
      gridBased: new BooleanField()
    });
  }
};

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

/**
 * The data model for an emanation shape.
 *
 * @property {BaseShapeData} base     The base shape of the emanation.
 * @property {number} radius          The radius of the emanation in pixels.
 * @property {boolean} gridBased      If the shape is grid-based, its dimensions are converted into grid units
 *                                    by dividing each by the grid size and multiplying by the grid distance. The shape
 *                                    is then constructed using these dimensions conforming to the grid's metric.
 */
let EmanationShapeData$1 = class EmanationShapeData extends BaseShapeData {

  /** @override */
  static LOCALIZATION_PREFIXES = ["SHAPE.TYPES.emanation", "SHAPE.TYPES.base"];

  static {
    Object.defineProperty(this, "TYPE", {value: "emanation"});
  }

  /** @inheritdoc */
  static defineSchema() {
    const {emanation: _emanation, ring: _ring, ...baseTypes} = BaseShapeData.TYPES;
    return Object.assign(super.defineSchema(), {
      base: new TypedSchemaField(baseTypes),
      radius: new NumberField({required: true, nullable: false, min: 0, initial: undefined}),
      gridBased: new BooleanField()
    });
  }
};

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

/**
 * The data model for a cone 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 cone in pixels.
 * @property {number} angle           The angle of the cone in degrees.
 * @property {number} rotation        The direction of the cone in degrees.
 * @property {"round"|"flat"|"semicircle"} curvature    The curvature.
 * @property {boolean} gridBased      If the shape is grid-based, its dimensions are converted into grid units
 *                                    by dividing each by the grid size and multiplying by the grid distance. The shape
 *                                    is then constructed using these dimensions conforming to the grid's metric.
 */
let ConeShapeData$1 = class ConeShapeData extends BaseShapeData {

  /** @override */
  static LOCALIZATION_PREFIXES = ["SHAPE.TYPES.cone", "SHAPE.TYPES.base"];

  static {
    Object.defineProperty(this, "TYPE", {value: "cone"});
  }

  /** @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, min: 0}),
      angle: new AngleField({initial: undefined, min: 0, normalize: false}),
      rotation: new AngleField(),
      curvature: new StringField({required: true, initial: "round", choices: {
        round: "SHAPE.TYPES.cone.CURVATURES.round.label",
        flat: "SHAPE.TYPES.cone.CURVATURES.flat.label",
        semicircle: "SHAPE.TYPES.cone.CURVATURES.semicircle.label"
      }}),
      gridBased: new BooleanField()
    });
  }

  /** @override */
  static validateJoint(data) {
    switch ( data.curvature ) {
      case "flat":
        if ( data.angle > 90 ) throw new Error("The angle of a cone with a flat curvature must not be greater than 90 degrees");
        break;
      case "semicircle":
        if ( data.angle > 180 ) throw new Error("The angle of a cone with a semicircle curvature cannot be greater than 180 degrees");
        break;
    }
  }
};

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

/**
 * The data model for a ring shape.
 *
 * @property {number} x               The x-coordinate of the origin in pixels.
 * @property {number} y               The y-coordinate of the origin in pixels.
 * @property {number} radius          The radius of the ring in pixels.
 * @property {number} innerWidth      The inner width of the ring in pixels.
 * @property {number} outerWidth      The inner width of the ring in pixels.
 * @property {boolean} gridBased      If the shape is grid-based, its dimensions are converted into grid units
 *                                    by dividing each by the grid size and multiplying by the grid distance. The shape
 *                                    is then constructed using these dimensions conforming to the grid's metric.
 */
let RingShapeData$1 = class RingShapeData extends BaseShapeData {

  /** @override */
  static LOCALIZATION_PREFIXES = ["SHAPE.TYPES.ring", "SHAPE.TYPES.base"];

  static {
    Object.defineProperty(this, "TYPE", {value: "ring"});
  }

  /** @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, min: 0}),
      innerWidth: new NumberField({required: true, nullable: false, initial: undefined, min: 0}),
      outerWidth: new NumberField({required: true, nullable: false, initial: undefined, min: 0}),
      gridBased: new BooleanField()
    });
  }
};

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

/**
 * The data model for a line shape.
 *
 * @property {number} x               The x-coordinate of the origin in pixels.
 * @property {number} y               The y-coordinate of the origin in pixels.
 * @property {number} length          The length of the line in pixels.
 * @property {number} width           The width of the line in pixels.
 * @property {number} rotation        The rotation around the origin of the line in degrees.
 * @property {boolean} gridBased      If the shape is grid-based, its dimensions are converted into grid units
 *                                    by dividing each by the grid size and multiplying by the grid distance. The shape
 *                                    is then constructed using these dimensions conforming to the grid's metric.
 */
let LineShapeData$1 = class LineShapeData extends BaseShapeData {

  /** @override */
  static LOCALIZATION_PREFIXES = ["SHAPE.TYPES.line", "SHAPE.TYPES.base"];

  static {
    Object.defineProperty(this, "TYPE", {value: "line"});
  }

  /** @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}),
      length: new NumberField({required: true, nullable: false, initial: undefined, min: 0}),
      width: new NumberField({required: true, nullable: false, initial: undefined, min: 0}),
      rotation: new AngleField(),
      gridBased: new BooleanField()
    });
  }
};

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

/**
 * 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 if it is supposed to be filled.
 *                                  The polygon must not contained zero-length edges except for
 *                                  the edge from the last to the first point.
 * @property {Point|null} origin    The origin of the polygon. If null, it defaults to the center-of-mass.
 */
let PolygonShapeData$1 = class PolygonShapeData extends BaseShapeData {

  /** @override */
  static LOCALIZATION_PREFIXES = ["SHAPE.TYPES.polygon", "SHAPE.TYPES.base"];

  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}), {
        min: 4, validate: value => {
          if ( value.length % 2 !== 0 ) throw new Error("must have an even length");
        }}),
      origin: new SchemaField({
        x: new NumberField({required: true, nullable: false, initial: undefined}),
        y: new NumberField({required: true, nullable: false, initial: undefined})
      }, {nullable: true})
    });
  }
};

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

/**
 * The data model for a token shape.
 *
 * @property {number} x                  The top-left x-coordinate in pixels (integer).
 * @property {number} y                  The top-left y-coordinate in pixels (integer).
 * @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}).
 */
let TokenShapeData$1 = class TokenShapeData extends BaseShapeData {

  /** @override */
  static LOCALIZATION_PREFIXES = ["SHAPE.TYPES.token", "SHAPE.TYPES.base"];

  static {
    Object.defineProperty(this, "TYPE", {value: "token"});
  }

  /** @inheritdoc */
  static defineSchema() {
    return Object.assign(super.defineSchema(), {
      x: new NumberField({required: true, nullable: false, integer: true, initial: undefined}),
      y: new NumberField({required: true, nullable: false, integer: true, 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, choices: Object.entries(CONST.TOKEN_SHAPES)
        .reduce((shapes, [key, value]) => {
          shapes[value] = `TOKEN.SHAPES.${key}.label`;
          return shapes;
        }, {}), initial: undefined})
    });
  }
};

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

/**
 * The data model for a shape that is the union of grid spaces.
 *
 * @property {GridOffset2D[]} offsets    The grid offsets covered by this shape
 * @property {Point|null} origin         The optional grid space origin, which is by default
 *                                       the center point of the first grid space in `offsets`
 */
let GridShapeData$1 = class GridShapeData extends BaseShapeData {

  /** @override */
  static LOCALIZATION_PREFIXES = ["SHAPE.TYPES.grid", "SHAPE.TYPES.base"];

  static {
    Object.defineProperty(this, "TYPE", {value: "grid"});
  }

  /** @inheritdoc */
  static defineSchema() {
    return Object.assign(super.defineSchema(), {
      offsets: new GridOffsetsField(),
      origin: new SchemaField({
        x: new NumberField({required: true, nullable: false, initial: undefined}),
        y: new NumberField({required: true, nullable: false, initial: undefined})
      }, {nullable: true})
    });
  }
};

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

/**
 * 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 {TextureDataFitMode} fit     The texture fit mode.
 * @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=""}={}) {
    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}),
      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}),
      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 included = new Set(["name", "displayName", "actorLink", "width", "height", "depth", "texture", "lockRotation",
      "rotation", "alpha", "disposition", "displayBars", "bar1", "bar2", "light", "sight", "detectionModes",
      "occludable", "ring", "turnMarker", "movementAction", "flags"]);
    const schema = foundry.documents.BaseToken.defineSchema();
    for ( const fieldName in schema ) {
      if ( !included.has(fieldName) ) 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;
  }

  /** @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] ??= _loc(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] === undefined) ? baseValue : typed[path];
      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,
    baseTypeAllowed: false,
    label: "DOCUMENT.Actor",
    labelPlural: "DOCUMENT.Actors",
    permissions: {
      create: this.#canCreate,
      update: this.#canUpdate
    },
    schemaVersion: "14.353"
  }, {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, options) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source, options);
  }

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

  /** @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", "caption", "description", "img", "sort", "folder", "flags.core.sheetClass"],
    label: "DOCUMENT.Adventure",
    labelPlural: "DOCUMENT.Adventures",
    schemaVersion: "14.353"
  }, {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: "14.354"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      name: new StringField({textSearch: 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}),
      levels: new SceneLevelsSetField(),
      rotation: new AngleField(),
      walls: new BooleanField({initial: true}),
      vision: new BooleanField(),
      config: new EmbeddedDataField(LightData),
      hidden: new BooleanField(),
      locked: new BooleanField(),
      flags: new DocumentFlagsField()
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "AMBIENT_LIGHT"];

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

  /** @override */
  getUserLevel(user) {
    user ||= game.user;
    if ( this.pack ) return this.compendium.getUserLevel(user);
    if ( user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE;
  }
}

/**
 * @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: "14.354"
  }, {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(),
      name: new StringField({textSearch: 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}),
      levels: new SceneLevelsSetField(),
      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(),
      locked: 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"];

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

  /** @override */
  getUserLevel(user) {
    user ||= game.user;
    if ( this.pack ) return this.compendium.getUserLevel(user);
    if ( user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE;
  }
}

/**
 * @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,
    baseTypeAllowed: 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, options) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source, options);
  }

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

  /** @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,
    baseTypeAllowed: true,
    isPrimary: true,
    permissions: {
      create: this.#canCreate,
      delete: "OWNER"
    },
    schemaVersion: "14.352"
  }, {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: true, initial: null}),
      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 ( this.pack ) return this.compendium.getUserLevel(user);
    if ( user.id === this._source.author ) return DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return super.getUserLevel(user);
  }

  /* ---------------------------------------- */
  /*  Database Operation Handlers             */
  /* ---------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    const allowed = await super._preUpdate(changed, options, user);
    if ( allowed === false ) return false;

    // A null value cannot be persisted as timestamp
    if ( ("timestamp" in changed) && (typeof changed.timestamp !== "number") ) delete changed.timestamp;
  }
}

/**
 * @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,
    baseTypeAllowed: true,
    permissions: {
      update: this.#canUpdate
    },
    schemaVersion: "14.361"
  }, {inplace: false}));

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "COMBAT"];

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

  /** @inheritdoc */
  static defineSchema() {
    const {BaseScene, BaseCombatant, BaseCombatantGroup} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: true, textSearch: true}),
      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,
    baseTypeAllowed: 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, {idOnly: true}),
      tokenId: new ForeignDocumentField(BaseToken, {idOnly: true}),
      sceneId: new ForeignDocumentField(BaseScene, {idOnly: true}),
      name: new StringField({textSearch: true}),
      img: new FilePathField({categories: ["IMAGE"]}),
      initiative: new NumberField({required: true}),
      hidden: new BooleanField(),
      defeated: new BooleanField(),
      group: new DocumentIdField({readonly: false}),
      roundJoined: new NumberField({required: true, nullable: false, integer: true, positive: true, initial: 1}),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "COMBATANT"];

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

  /**
   * 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.pack ) return this.compendium.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,
    baseTypeAllowed: 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: "14.355"
  }, {inplace: false}));

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "DRAWING"];

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

  /** @inheritDoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      name: new StringField({textSearch: true}),
      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}),
      levels: new SceneLevelsSetField(),
      sort: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      rotation: new AngleField(),
      bezierFactor: new AlphaField({initial: 0, max: 0.5}),
      fillType: new NumberField({
        required: true,
        nullable: false,
        initial: DRAWING_FILL_TYPES.NONE,
        choices: Object.values(DRAWING_FILL_TYPES),
        validationError: "must be a value in CONST.DRAWING_FILL_TYPES"
      }),
      fillColor: new ColorField({nullable: false, initial: () => game.user?.color.css || "#ffffff"}),
      fillAlpha: new AlphaField({initial: 0.5}),
      strokeWidth: new NumberField({nullable: false, integer: true, initial: 8, min: 0}),
      strokeColor: new ColorField({nullable: false, initial: () => game.user?.color.css || "#ffffff"}),
      strokeAlpha: new AlphaField({initial: 1}),
      texture: new FilePathField({categories: ["IMAGE"]}),
      text: new StringField(),
      fontFamily: new StringField({required: true, blank: true}),
      fontSize: new NumberField({nullable: false, integer: true, min: 8, max: 256, initial: 48,
        validationError: "must be an integer between 8 and 256"}),
      textColor: new ColorField({nullable: false, initial: "#ffffff"}),
      textAlpha: new AlphaField(),
      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(_loc("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                           */
  /* ---------------------------------------- */

  /** @override */
  getUserLevel(user) {
    user ||= game.user;
    if ( this.pack ) return this.compendium.getUserLevel(user);
    if ( user.isGM || (user.id === this._source.author) ) return DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return DOCUMENT_OWNERSHIP_LEVELS.NONE;
  }
}

/**
 * @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: "14.357"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const {BaseScene, BaseUser} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      user: new ForeignDocumentField(BaseUser, {initial: () => game?.user?.id}),
      scene: new ForeignDocumentField(BaseScene, {initial: null}),
      level: new DocumentIdField({readonly: false, initial: null}),
      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,
    baseTypeAllowed: false,
    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, options) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source, options);
  }

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

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

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "JOURNAL"];

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

  /** @inheritDoc */
  _initialize(options) {
    super._initialize(options);
    DocumentStatsField._shimDocument(this);
  }

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

  /** @inheritDoc */
  static migrateData(source, options) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source, options);
  }

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

  /** @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";
 * @import {DocumentPermissionTest} from "@common/abstract/_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: this.#canCreate,
      delete: "OWNER"
    },
    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, {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({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}),
      category: new DocumentIdField({readonly: false}),
      sort: new IntegerSortField(),
      ownership: new DocumentOwnershipField({initial: {default: DOCUMENT_OWNERSHIP_LEVELS.INHERIT}}),
      flags: new DocumentFlagsField(),
      _stats: new DocumentStatsField()
    };
  }

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "JOURNALENTRYPAGE"];

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

  /**
   * Is a user able to create a new JournalEntryPage?
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc) {
    if ( doc.parent ) return doc.parent.testUserPermission(user, "OWNER");
    return doc.testUserPermission(user, "OWNER");
  }
}

/**
 * @import {LevelData} from "@common/documents/_types.mjs";
 */

/**
 * The Level Document.
 * Defines the DataSchema and common behaviors for a Level which are shared between both client and server.
 * @extends {Document<LevelData>}
 * @category Documents
 *
 * @example Create Scene Levels
 * ```js
 * await canvas.scene.createEmbeddedDocuments("Level", [
 *   {
 *     name: "Basement",
 *     elevation: {bottom: -10, top: 0},
 *     background: {
 *       src: "worlds/scene-levels-test/assets/cabin/BasementBackground.webp"
 *     },
 *     foreground: {
 *       src: "worlds/scene-levels-test/assets/cabin/BasementForeground.webp"
 *     }
 *   },
 *   {
 *     name: "Downstairs",
 *     elevation: {bottom: 0, top: 10},
 *     background: {
 *       src: "worlds/scene-levels-test/assets/cabin/DownstairsBackground.webp"
 *     },
 *     foreground: {
 *       src: "worlds/scene-levels-test/assets/cabin/DownstairsForeground.webp"
 *     }
 *   },
 *   {
 *     name: "Upstairs",
 *     elevation: {bottom: 10, top: 20},
 *     background: {
 *       src: "worlds/scene-levels-test/assets/cabin/UpstairsBackground.webp"
 *     },
 *     foreground: {
 *       src: "worlds/scene-levels-test/assets/cabin/UpstairsForeground.webp"
 *     }
 *   }
 * ]);
 * ```
 */
class BaseLevel extends Document {

  /* ---------------------------------------- */
  /*  Model Configuration                     */
  /* ---------------------------------------- */

  /** @inheritdoc */
  static metadata = Object.freeze(mergeObject(super.metadata, {
    name: "Level",
    collection: "levels",
    label: "DOCUMENT.Level",
    labelPlural: "DOCUMENT.Levels",
    isEmbedded: true,
    schemaVersion: "14.364"
  }, {inplace: false}));

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

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "SCENE_LEVEL"];

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

  /** @inheritDoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      name: new StringField({required: true, blank: false, textSearch: true}),
      elevation: new SchemaField({
        bottom: new NumberField({required: true, nullable: true, initial: 0}), // Treat null as -Infinity
        top: new NumberField({required: true, nullable: true, initial: 20}) // Treat null as +Infinity
      }, {
        validate: d => (d.bottom ?? -Infinity) <= (d.top ?? Infinity),
        validationError: "elevation.top may not be less than elevation.bottom"
      }),
      background: new SchemaField({
        color: new ColorField({nullable: false, initial: "#999999"}),
        src: new FilePathField({required: true, categories: ["TEXTURE"], initial: null, virtual: true}),
        tint: new ColorField({required: true, nullable: false, initial: "#ffffff"}),
        alphaThreshold: new AlphaField({nullable: false, initial: 0.75})
      }),
      foreground: new SchemaField({
        src: new FilePathField({required: true, categories: ["TEXTURE"], initial: null, virtual: true}),
        tint: new ColorField({required: true, nullable: false, initial: "#ffffff"}),
        alphaThreshold: new AlphaField({nullable: false, initial: 0.75})
      }),
      fog: new SchemaField({
        src: new FilePathField({required: true, categories: ["TEXTURE"], initial: null, virtual: true})
      }),
      textures: new SchemaField({
        anchorX: new NumberField({required: true, nullable: false, initial: 0.5}),
        anchorY: new NumberField({required: true, nullable: false, initial: 0.5}),
        offsetX: new NumberField({required: true, nullable: false, integer: true, initial: 0}),
        offsetY: new NumberField({required: true, nullable: false, integer: true, initial: 0}),
        fit: new StringField({required: true, initial: "fill", choices: CONST.TEXTURE_DATA_FIT_MODES}),
        scaleX: new NumberField({required: true, nullable: false, initial: 1}),
        scaleY: new NumberField({required: true, nullable: false, initial: 1}),
        rotation: new AngleField({initial: 0})
      }),
      visibility: new SchemaField({
        levels: new SceneLevelsSetField()
      }),
      sort: new IntegerSortField(),
      flags: new DocumentFlagsField()
    };
  }
}

/**
 * @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, {nullable: true}),
      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, options) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source, options);
  }

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

  /** @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});
    field.validate(data.command, {strict: true});
  }

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

  /** @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 ( foundry.data.operators.DataFieldOperator.equals(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 ( this.pack ) return this.compendium.getUserLevel(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;
    if ( user ) this.updateSource({author: user.id});
  }
}

/**
 * @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 ( let [k, v] of Object.entries(perms) ) {
      if ( typeof k !== "string" ) return false;
      if ( v instanceof ForcedDeletion ) continue;
      if ( v instanceof ForcedReplacement ) v = ForcedReplacement.get(v);
      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) {
    /** @deprecated since v14 */
    if ( action === "TEMPLATE_CREATE" ) {
      const msg = "The TEMPLATE_CREATE permission is deprecated. Use REGION_CREATE instead.";
      foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
      action = "REGION_CREATE";
    }
    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) {
    /** @deprecated since v14 */
    if ( permission === "TEMPLATE_CREATE" ) {
      const msg = "The TEMPLATE_CREATE permission is deprecated. Use REGION_CREATE instead.";
      foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
      permission = "REGION_CREATE";
    }

    if ( this.isBanned ) return false;

    // CASE 1: The permission cannot be revoked from the user
    const {requiredRoles} = USER_PERMISSIONS[permission];
    if ( requiredRoles.includes(this.role) ) return true;

    // CASE 2: The user has the permission set explicitly
    const explicit = this.permissions[permission];
    if ( explicit !== undefined ) return explicit;

    // CASE 3: 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 {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: "NOTE_CREATE",
      delete: "OWNER"
    },
    schemaVersion: "14.358"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const {BaseJournalEntry, BaseJournalEntryPage} = foundry.documents;
    return {
      _id: new DocumentIdField(),
      author: new DocumentAuthorField(BaseUser, {nullable: true}),
      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}),
      levels: new SceneLevelsSetField(),
      sort: new NumberField({required: true, integer: true, nullable: false, initial: 0}),
      locked: new BooleanField(),
      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, blank: true}),
      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 */
  static canUserCreate(user) {
    return user.hasPermission("NOTE_CREATE");
  }

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

  /** @override */
  getUserLevel(user) {
    user ||= game.user;
    if ( this.pack ) return this.compendium.getUserLevel(user);
    if ( user.isGM || (user.id === this._source.author) ) return DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return DOCUMENT_OWNERSHIP_LEVELS.NONE;
  }
}

/**
 * @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, options) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source, options);
  }

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

  /** @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, options) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source, options);
  }

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

  /** @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;
    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 rectangle polygon given the width and height in grid units for this grid.
   * The points of the polygon are returned ordered in positive orientation.
   * @param {Point} origin        The origin point of the rectangle.
   * @param {number} width        The width in grid units.
   * @param {number} height       The height in grid units.
   * @param {Point} anchor        The anchor.
   * @param {number} rotation     The rotation in degrees.
   * @returns {Point[]}           The points of the rectangle polygon.
   */
  getRectangle(origin, width, height, anchor, rotation) {
    if ( (width <= 0) || (height <= 0) ) return [];
    const b = this.getTranslatedPoint(origin, rotation, width);
    const d = this.getTranslatedPoint(origin, rotation + 90, height);
    const dx = (anchor.x * (b.x - origin.x)) + (anchor.y * (d.x - origin.x));
    const dy = (anchor.x * (b.y - origin.y)) + (anchor.y * (d.y - origin.y));
    b.x -= dx;
    b.y -= dy;
    d.x -= dx;
    d.y -= dy;
    const a = {x: origin.x - dx, y: origin.y - dy};
    const c = {x: d.x + b.x - a.x, y: d.y + b.y - a.y};
    return [a, b, c, d];
  }

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

  /**
   * Get the line polygon given the length and width in grid units for this grid.
   * The points of the polygon are returned ordered in positive orientation.
   * @param {Point} origin        The origin point of the line.
   * @param {number} length       The length in grid units.
   * @param {number} width        The width in grid units.
   * @param {number} direction    The direction in degrees.
   * @returns {Point[]}           The points of the line polygon.
   */
  getLine(origin, length, width, direction) {
    if ( (length <= 0) || (width <= 0) ) return [];
    const destination = this.getTranslatedPoint(origin, direction, length);
    let {x: dx, y: dy} = this.getTranslatedPoint({x: 0, y: 0}, direction + 90, width);
    dx /= 2;
    dy /= 2;
    const a = {x: origin.x - dx, y: origin.y - dy};
    const b = {x: destination.x - dx, y: destination.y - dy};
    const c = {x: destination.x + dx, y: destination.y + dy};
    const d = {x: origin.x + dx, y: origin.y + dy};
    return [a, b, c, d];
  }

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

  /**
   * 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.
   * If the angle less than 360 and the cone not empty, the first point of the polygon is the origin.
   * @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;
  }

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

  /**
   * Get the ellipse 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 ellipse with a deviation of less than 0.25 pixels is returned.
   * @param {Point} center      The center point of the ellipse.
   * @param {number} radiusX    The x-radius in grid units.
   * @param {number} radiusY    The y-radius in grid units.
   * @param {number} rotation   The rotation in degrees.
   * @returns {Point[]}         The points of the ellipse polygon.
   * @abstract
   */
  getEllipse(center, radiusX, radiusY, rotation) {
    if ( radiusX === radiusY ) return this.getCircle(center, radiusX);
    if ( (radiusX <= 0) || (radiusY <= 0) ) return [];
    const ratio = (radiusY / radiusX) - 1;
    const a = Math.toRadians(rotation);
    const cos = Math.cos(a);
    const sin = Math.sin(a);
    const points = [];
    for ( const point1 of this.getCircle(center, radiusX) ) {
      const dx = point1.x - center.x;
      const dy = point1.y - center.y;
      const t = ((cos * dy) - (sin * dx)) * ratio;
      points.push({
        x: point1.x - (t * sin),
        y: point1.y + (t * cos)
      });
    }
    return points;
  }

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

  /**
   * Get the ring polygon given the radius and width in grid units for this grid.
   * The points of the polygons are returned ordered in positive orientation.
   * In gridless grids an approximation of the true ring with a deviation of less than 0.25 pixels is returned.
   * @param {Point} center                                      The center point of the ring.
   * @param {number} radius                                     The radius in grid units.
   * @param {number} innerWidth                                 The inner width in grid units.
   * @param {number} outerWidth                                 The outer width in grid units.
   * @returns {[innerCircle: Point[], outerCircle: Point[]]}    The inner and outer circles of the ring polygon.
   */
  getRing(center, radius, innerWidth, outerWidth) {
    const innerCircle = this.getCircle(center, radius - innerWidth);
    const outerCircle = this.getCircle(center, radius + outerWidth);
    return [innerCircle, outerCircle];
  }
}

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

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

  /** @override */
  getEllipse({x, y}, radiusX, radiusY, rotation) {
    if ( (radiusX <= 0) || (radiusY <= 0) ) return [];
    const r1 = radiusX / this.distance * this.size;
    const r2 = radiusY / this.distance * this.size;
    const maxR = Math.max(r1, r2);
    const n = Math.max(Math.ceil(Math.PI / Math.acos(Math.max(maxR - 0.25, 0) / maxR)), 4);
    const points = new Array(n);
    const a = Math.toRadians(rotation);
    const cos = Math.cos(a);
    const sin = Math.sin(a);
    for ( let i = 0, j = 0; i < n; i++ ) {
      const angle = 2 * Math.PI * (i / n);
      const dx = Math.cos(angle) * r1;
      const dy = Math.sin(angle) * r2;
      points[j++] = x + ((cos * dx) - (sin * dy));
      points[j++] = y + ((sin * dx) + (cos * dy));
    }
    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--;
    }

    return this.#completeCircleFromQuarterAndScale(center, 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}];
  }

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

  /** @inheritDoc */
  getEllipse(center, radiusX, radiusY, rotation) {
    switch ( this.diagonals ) {
      case GRID_DIAGONALS.ALTERNATING_1:
      case GRID_DIAGONALS.ALTERNATING_2:
        if ( radiusX === radiusY ) return this.getCircle(center, radiusX);
        if ( (radiusX <= 0) || (radiusY <= 0) ) return [];
        const rays = [];
        for ( const radius of [radiusX, radiusY] ) {
          for ( const point of this.getCircle(center, radius) ) {
            const radians = Math.atan2(point.y - center.y, point.x - center.x);
            const direction = Math.normalizeDegrees(Math.toDegrees(radians));
            rays.push({point, radius, direction});
          }
        }
        rays.sort((a, b) => a.direction - b.direction);
        const a = Math.toRadians(rotation);
        const cos = Math.cos(a);
        const sin = Math.sin(a);
        const points = [];
        for ( const {point: point1, radius: radius1, direction} of rays ) {
          let radius2;
          let nx;
          let ny;
          if ( radius1 === radiusX ) {
            radius2 = radiusY;
            nx = cos;
            ny = sin;
          } else {
            radius2 = radiusX;
            nx = -sin;
            ny = cos;
          }
          const point2 = this.getTranslatedPoint(center, direction, radius2);
          const dx = point2.x - point1.x;
          const dy = point2.y - point1.y;
          const t = (nx * dy) - (ny * dx);
          const x = point1.x - (t * ny);
          const y = point1.y + (t * nx);
          if ( points.length ) {
            const {x: lastX, y: lastY} = points[points.length - 1];
            if ( x.almostEqual(lastX) && y.almostEqual(lastY) ) continue;
          }
          points.push({x, y});
        }
        return points;
      default: return super.getEllipse(center, radiusX, radiusY, rotation);
    }
  }
  /* -------------------------------------------- */

  /** @inheritDoc */
  getRing(center, radius, innerWidth, outerWidth) {
    switch ( this.diagonals ) {
      case GRID_DIAGONALS.ALTERNATING_1:
        return [
          this.#getInnerRingAlternating(center, radius, innerWidth, false),
          this.#getOuterRingAlternating(center, radius, outerWidth, false)
        ];
      case GRID_DIAGONALS.ALTERNATING_2:
        return [
          this.#getInnerRingAlternating(center, radius, innerWidth, true),
          this.#getOuterRingAlternating(center, radius, outerWidth, true)
        ];
      default: return super.getRing(center, radius, innerWidth, outerWidth);
    }
  }

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

  /**
   * Get the inner ring 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 {number} width           The width in grid units.
   * @param {boolean} firstDouble    2/1/2 instead of 1/2/1?
   * @returns {Point[]}              The points of the circle polygon.
   */
  #getInnerRingAlternating(center, radius, width, firstDouble) {
    if ( radius - width <= 0 ) return [];
    if ( (radius === 0) || (width === 0) ) return this.#getCircleAlternating(center, radius + width, firstDouble);
    const w = width / this.distance;
    const r = (radius / this.distance) - w;
    const s = firstDouble ? 0 : Math.min(w, 1);
    const points = [];
    let dx = 0;
    let dy = 0;

    // Generate points of the first quarter
    if ( !firstDouble ) dy++;
    for ( ;; ) {
      if ( r - dx <= dy - s ) {
        points.push({x: r - dx, y: r - dx});
        if ( dx !== 0 ) {
          points.push({x: dy - 1 - s, y: r - dx});
          [dx, dy] = [dy - 2, dx - 1];
        }
        break;
      }
      points.push({x: r - dx, y: dy - s});
      dx++;
      dy++;
      if ( r - dx <= dy - s ) {
        [dx, dy] = [dy - 1, dx - 1];
        break;
      }
      points.push({x: r - dx, y: dy - s});
      dy++;
    }
    for ( ;; ) {
      if ( dx === 0 ) break;
      points.push({x: dx - s, y: r - dy});
      dx--;
      if ( dx === 0 ) break;
      points.push({x: dx - s, y: r - dy});
      dx--;
      dy--;
    }

    return this.#completeCircleFromQuarterAndScale(center, points);
  }

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

  /**
   * Get the outer ring 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 {number} width           The width in grid units.
   * @param {boolean} firstDouble    2/1/2 instead of 1/2/1?
   * @returns {Point[]}              The points of the circle polygon.
   */
  #getOuterRingAlternating(center, radius, width, firstDouble) {
    if ( radius + width <= 0 ) return [];
    if ( (radius === 0) || (width === 0) ) return this.#getCircleAlternating(center, radius + width, firstDouble);
    const w = width / this.distance;
    const r1 = radius / this.distance;
    const r2 = r1 + w;
    const s = firstDouble ? 0 : Math.min(w, 1);
    const points = [];
    let dx = 0;
    let dy = 0;
    let dx0 = 0;
    let dy0 = 0;
    let dx1 = 0;
    let dy1 = 0;
    if ( firstDouble ) {
      dx0++;
      dy0++;
      dx1++;
      dy1++;
    }
    for ( ;; ) {
      if ( r1 - dx0 <= dy0 + 1 ) {
        dx0 = dy0 = r1 - dx0;
        break;
      }
      dy0++;
      if ( r1 - dx0 <= dy0 + 2 ) {
        dx0 = dy0 = (r1 - dx0 + dy0) / 2;
        break;
      }
      dx0++;
      dy0++;
    }
    for ( ;; ) {
      if ( w - dx1 <= dy1 + 1 ) {
        dx1 = dy1 = w - dx1;
        break;
      }
      dy1++;
      if ( w - dx1 <= dy1 + 2 ) {
        dx1 = w - dx1;
        break;
      }
      dx1++;
      dy1++;
    }
    const px = dx0 + dx1;
    const py = dy0 + dy1;

    // Generate points of the first quarter
    if ( firstDouble ) {
      points.push({x: r2 - dx, y: dy + s});
      dx++;
      dy++;
    }
    for ( ;; ) {
      if ( r1 - dx <= dy ) {
        const x1 = r1 - dx + 1 + dx1;
        const y1 = dy - 1 + dy1;
        const y0 = r2 - dx + dy + s - x1;
        points.push({x: x1, y: y0}, {x: x1, y: y1}, {x: y1, y: x1}, {x: y0, y: x1});
        [dx, dy] = [dy - 1, dx - 1];
        break;
      }
      points.push({x: r2 - dx, y: dy + s});
      dy++;
      if ( r1 - dx <= dy ) {
        const c2x = r2 - dx;
        const c2y = r1 - dx + s;
        let c = c2x + c2y;
        if ( dx !== 0 ) {
          const c1x = r1 - dx + dx1;
          const c1y = dy - 1 + dy1;
          c = Math.max(c, c1x + c1y);
        }
        const x1 = px;
        const y1 = c - x1;
        const x0 = r2 - dx;
        const y0 = c - x0;
        points.push({x: x0, y: y0}, {x: x1, y: y1}, {x: px, y: py});
        if ( px !== py ) points.push({x: py, y: px});
        points.push({x: y1, y: x1}, {x: y0, y: x0});
        if ( dx !== 0 ) {
          points.push({x: dy + s - 1, y: r2 - dx});
          [dx, dy] = [dy - 2, dx - 1];
        }
        break;
      }
      points.push({x: r2 - dx, y: dy + s});
      dx++;
      dy++;
    }
    for ( ;; ) {
      if ( dx === 0 ) break;
      points.push({x: dx + s, y: r2 - dy});
      dx--;
      if ( dx === 0 ) break;
      points.push({x: dx + s, y: r2 - dy});
      dx--;
      dy--;
    }

    return this.#completeCircleFromQuarterAndScale(center, points);
  }

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

  /**
   * Mirror the given quarter in normalized coordinates to a full circle and convert to pixels coordinates.
   * @param {Point} center       The center point.
   * @param {Point[]} points     The points of the quarter.
   * @returns {Point[]}         The points that were passed completed to a circle.
   */
  #completeCircleFromQuarterAndScale(center, points) {

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

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

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

/**
 * @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 : -EPS;
          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 : -EPS;
        }
        // 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 ? -EPS : 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 : -EPS;
          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 : -EPS;
        }
        // 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 ? -EPS : 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});
}

/**
 * @import BaseLevel from "./level.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",
      Note: "notes",
      Region: "regions",
      Level: "levels",
      Tile: "tiles",
      Token: "tokens",
      Wall: "walls"
    },
    label: "DOCUMENT.Scene",
    labelPlural: "DOCUMENT.Scenes",
    preserveOnImport: [...super.metadata.preserveOnImport, "active"],
    defaultLevelId: "defaultLevel0000",
    schemaVersion: "14.354"
  }, {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
      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}),
      shiftX: new NumberField({required: true, integer: true, initial: 0}),
      shiftY: new NumberField({required: true, integer: true, initial: 0}),
      initial: new SchemaField({
        x: new NumberField({integer: true, required: true}),
        y: new NumberField({integer: true, required: true}),
        scale: new NumberField({required: true, positive: true, step: .001})
      }),
      initialLevel: new DocumentIdField({readonly: false}),

      // 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({
        mode: new NumberField({required: true, choices: Object.values(FOG_EXPLORATION_MODES),
          initial: FOG_EXPLORATION_MODES.INDIVIDUAL, validationError: "must be a value in CONST.FOG_EXPLORATION_MODES"}),
        reset: new NumberField({required: false, initial: undefined}),
        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})
      }),

      // Transition Configuration
      transition: new SchemaField({
        type: new StringField({required: true, nullable: true, blank: false, initial: null}),
        duration: new NumberField({required: true, nullable: false, integer: true,
          initial: 1500, min: 500, max: 10000, step: 100}),
        activeOnly: new BooleanField()
      }),

      // Embedded Collections
      drawings: new EmbeddedCollectionField(documents.BaseDrawing),
      tokens: new EmbeddedCollectionField(documents.BaseToken),
      levels: new EmbeddedCollectionField(documents.BaseLevel),
      lights: new EmbeddedCollectionField(documents.BaseAmbientLight),
      notes: new EmbeddedCollectionField(documents.BaseNote),
      sounds: new EmbeddedCollectionField(documents.BaseAmbientSound),
      regions: new EmbeddedCollectionField(documents.BaseRegion),
      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"];

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

  /**
   * A mapping of top-level scene properties to their corresponding properties on the child level.
   * @type {Readonly<string[][]>}
   * @internal
   */
  static _LEVELS_PROPERTY_MAP = Object.freeze([
    ["background.src", "background.src"],
    ["background.tint", "background.tint"],
    ["background.alphaThreshold", "background.alphaThreshold"],
    ["background.anchorX", "textures.anchorX"],
    ["background.anchorY", "textures.anchorY"],
    ["background.fit", "textures.fit"],
    ["background.scaleX", "textures.scaleX"],
    ["background.scaleY", "textures.scaleY"],
    ["background.rotation", "textures.rotation"],
    ["fog.overlay", "fog.src"],
    ["foreground", "foreground.src"],
    ["foregroundElevation", "elevation.top"],
    ["backgroundColor", "background.color"]
  ]);

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

  /**
   * The default grid defined by the system.
   * @type {BaseGrid}
   */
  static get defaultGrid() {
    if ( !BaseScene.#defaultGrid ) BaseScene.#initializeDefaultGrids();
    return BaseScene.#defaultGrid;
  }

  static #defaultGrid;

  /**
   * The gridless version of the default grid defined by the system.
   * @type {GridlessGrid}
   */
  static get defaultGridlessGrid() {
    if ( !BaseScene.#defaultGridlessGrid ) BaseScene.#initializeDefaultGrids();
    return BaseScene.#defaultGridlessGrid;
  }

  static #defaultGridlessGrid;

  /**
   * Initialize both the grid instance and its gridless version.
   */
  static #initializeDefaultGrids() {
    const T = GRID_TYPES;
    const {type, ...config} = game.system.grid;
    config.size = 100;
    BaseScene.#defaultGridlessGrid = new GridlessGrid(config);

    // Gridless grid
    if ( type === T.GRIDLESS ) BaseScene.#defaultGrid = BaseScene.#defaultGridlessGrid;

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

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

  /**
   * The initial Level of the Scene. By default the first Level.
   * @type {BaseLevel}
   */
  get initialLevel() {
    const id = this._source.initialLevel;
    if ( !id ) return this.firstLevel;
    return this.levels.get(id) ?? this.firstLevel;
  }

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

  /**
   * A convenience getter for the Scene's first created Level. This should not be relied on in multi-level scenes to
   * mean the first level by sort order.
   * @type {BaseLevel}
   */
  get firstLevel() {
    return this.levels.values().next()?.value;
  }

  /* -------------------------------------------- */
  /*  Data Management                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initialize(options) {
    super._initialize(options);
    DocumentStatsField._shimDocument(this);
    BaseScene.#shimFogExploration(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              */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static migrateData(source, options) {
    DocumentStatsField._migrateData(this, source);
    return super.migrateData(source, options);
  }

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

  /** @inheritdoc */
  static shimData(source, options) {
    if ( Object.isSealed(source) ) return super.shimData(source, options);
    DocumentStatsField._shimData(this, source, options);
    const firstLevel = source.levels?.[0];

    // Foreground
    Object.defineProperty(source, "foreground", {
      get: () => firstLevel?.foreground?.src,
      configurable: true,
      enumerable: false
    });

    // Foreground Elevation
    Object.defineProperty(source, "foregroundElevation", {
      get: () => firstLevel?.elevation?.top,
      configurable: true,
      enumerable: false
    });

    // Fog Overlay
    if ( source.fog ) {
      Object.defineProperty(source.fog, "overlay", {
        get: () => firstLevel?.[0]?.fog?.src,
        configurable: true,
        enumerable: false
      });
    }

    // Background
    Object.defineProperty(source, "background", {
      get: () => {
        const background = {};
        for ( const [oldKey, newKey] of BaseScene._LEVELS_PROPERTY_MAP ) {
          if ( !oldKey.startsWith("background.") ) continue;
          const key = oldKey.slice(11);
          foundry.utils.setProperty(background, key, foundry.utils.getProperty(firstLevel, newKey));
        }
        background.offsetX = source.shiftX;
        background.offsetY = source.shiftY;
        return background;
      },
      configurable: true,
      enumerable: false
    });

    // Background Color
    Object.defineProperty(source, "backgroundColor", {
      get: () => firstLevel?.background?.color,
      configurable: true,
      enumerable: false
    });

    this._addDataFieldShim(source, "fog.exploration", "fog.mode", {since: 14, until: 16,
      warning: "BaseScene#fog.exploration is deprecated in favor of BaseScene#fog.mode"});
    return super.shimData(source, options);
  }

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

  /**
   * Put an alias for deprecated fog.exploration
   * @param {BaseScene} scene
   * @deprecated since v14
   */
  static #shimFogExploration(scene) {
    const fog = scene.fog;
    if ( !fog ) return;
    if ( Object.prototype.hasOwnProperty.call(fog, "exploration") ) return;
    Object.defineProperty(fog, "exploration", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "BaseScene#fog.exploration is deprecated in favor of BaseScene#fog.mode", {since: 14, until: 16}
        );
        return !!this.mode;
      },
      set(value) {
        foundry.utils.logCompatibilityWarning(
          "BaseScene#fog.exploration is deprecated in favor of BaseScene#fog.mode", {since: 14, until: 16}
        );
        this.mode = value ? FOG_EXPLORATION_MODES.INDIVIDUAL : FOG_EXPLORATION_MODES.DISABLED;
      },
      configurable: true,
      enumerable: false
    });
  }
}

/**
 * @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",
      delete: "TOKEN_DELETE"
    },
    schemaVersion: "14.357"
  }, {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),
      ...BaseToken.#defineMovementFields(),
      texture: new TextureData({}, {initial: {src: () => this.DEFAULT_ICON, anchorX: 0.5, anchorY: 0.5, fit: "contain",
        alphaThreshold: 0.75}, wildcard: true}),
      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 TypedObjectField(new SchemaField({
        enabled: new BooleanField({initial: true}),
        range: new NumberField({required: true, min: 0, step: 0.01})
      })),
      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({
        ...BaseToken.#defineMovementFields({noInitials: true}),
        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");
          }
        }),
        subpathId: 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()
    };
  }

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

  /**
   * Define the movement fields.
   * @param {object} options
   * @param {boolean} [options.noInitials=false]   If true, `initial` is undefined for all movement fields
   * @returns {object}
   */
  static #defineMovementFields({noInitials=false}={}) {
    const schema = {
      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}),
      width: new NumberField({required: true, nullable: false, positive: true, initial: 1}),
      height: new NumberField({required: true, nullable: false, positive: true, initial: 1}),
      depth: new NumberField({required: true, nullable: false, min: 0, initial: 1}),
      shape: new NumberField({initial: TOKEN_SHAPES.RECTANGLE_1,
        choices: Object.values(TOKEN_SHAPES)}),
      level: new DocumentIdField({required: true, nullable: false, readonly: false,
        initial: foundry.documents.BaseScene.metadata.defaultLevelId})
    };
    if ( noInitials ) {
      for ( const field of Object.values(schema) ) {
        field.initial = undefined;
        field.options.initial = undefined;
      }
    }
    return schema;
  }

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

  /** @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", "depth", "shape", "level"]>}
   * @readonly
   */
  static MOVEMENT_FIELDS = Object.freeze(["x", "y", "elevation", "width", "height", "depth", "shape", "level"]);

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

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

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

  /**
   * The default icon used for newly created Token documents
   * @type {string}
   */
  static DEFAULT_ICON = DEFAULT_TOKEN;

  /* -------------------------------------------- */
  /*  Data Management                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeSource(data, options) {
    data.shape ??= ((this.parent?.grid ?? BaseScene.defaultGrid).isHexagonal
      ? TOKEN_SHAPES.ELLIPSE_1 : TOKEN_SHAPES.RECTANGLE_1);
    data.level ??= this.parent?.initialLevel?.id;
    return super._initializeSource(data, options);
  }

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

  /**
   * 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 ( !("delta" in changes) || !this.delta ) return;
    // The delta is being updated, which is the point of divergence from the base Actor. Link the parent source
    // to the inner ActorDelta's source so that the partial update sees a non-nullish source and does not get
    // rejected by SchemaField#_updateDiff's null -> object full validation.
    if ( !this._source.delta && !this.actorLink ) this._source.delta = this.delta._source;
    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.
    // eslint-disable-next-line no-unused-expressions
    clone.delta; // Resolve lazy getter
    return clone;
  }

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

  /**
   * Get the snapped position of the Token.
   * @param {Partial<ElevatedPoint & Omit<TokenDimensions, "depth">>} [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.max(Math.round((data.width ?? this.width) * 2) / 2, 0.5);
    const height = Math.max(Math.round((data.height ?? this.height) * 2) / 2, 0.5);
    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 multiple of 0.5)
   * @param {number} height       The height in grid spaces (positive multiple of 0.5)
   * @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 multiple of 0.5)
   * @param {number} height         The height in grid spaces (positive multiple of 0.5)
   * @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 & Omit<TokenDimensions, "depth">>} [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.max(Math.round((data.width ?? this.width) * 2) / 2, 0.5);
    const height = Math.max(Math.round((data.height ?? this.height) * 2) / 2, 0.5);

    // 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<Omit<TokenDimensions, "depth">>} [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.max(Math.round((data.width ?? this.width) * 2) / 2, 0.5);
    const height = Math.max(Math.round((data.height ?? this.height) * 2) / 2, 0.5);

    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's base.
   *
   * This elevation of the center point is always equal to the elevation of the Token. The center point
   * is independent of the Token's depth.
   * @param {Partial<ElevatedPoint & Omit<TokenDimensions, "depth">>} [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<Omit<TokenDimensions, "depth">>} [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}];
  }

  /* -------------------------------------------- */
  /*  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 multiple of 0.5)
   * @param {number} height                                The height of the Token (positive multiple of 0.5)
   * @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
   * @internal
   */
  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                            */
  /* -------------------------------------------- */

  /** @override */
  getUserLevel(user) {
    user ||= game.user;
    if ( this.pack ) return this.compendium.getUserLevel(user);
    if ( this.actor ) return this.actor.getUserLevel(user);
    return user.isGM || !this.actorId ? DOCUMENT_OWNERSHIP_LEVELS.OWNER : DOCUMENT_OWNERSHIP_LEVELS.NONE;
  }

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

  /** @inheritdoc */
  toObject(source=true) {
    const obj = super.toObject(source);
    obj.delta = (this.delta && (this._source.delta || !source)) ? this.delta.toObject(source) : null;
    return obj;
  }

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

  /** @inheritDoc */
  static migrateData(data, options) {
    // 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, options);
  }

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

  /** @inheritdoc */
  static shimData(data, options) {
    // Remember that any shims defined here may also be required for the PrototypeToken model.
    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 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 ( model?.actorLink || !model?.actorId ) return null;
    const descriptor = Object.getOwnPropertyDescriptor(model, this.name);
    if ( (descriptor === undefined) || (!descriptor.get && !descriptor.value) ) {
      return () => {
        const context = {
          ...options, clean: value ? options.clean : true, parent: model, parentCollection: this.name, schema: this
        };
        value ??= model?.id ? { _id: model.id } : {};
        const m = new this.model(value, context);
        Object.defineProperty(model, this.name, {
          value: m,
          configurable: true,
          writable: true
        });
        model._onDeltaMaterialized?.();
        return m;
      };
    }
    else if ( typeof descriptor.get === "function" ) return descriptor.get;
    model[this.name]._initialize(options);
    return model[this.name];
  }

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

  /** @inheritDoc */
  _updateCommit(source, key, value, diff, options) {
    const parent = options?._deltaModel?.parent;
    const deltaSource = parent?._source[this.name];

    // Discard attempted updates to ActorDeltas if the parent is a linked token.
    if ( parent?.actorLink ) return;
    super._updateCommit(source, key, value, diff, options);

    // If a delta is updated from null, re-create the model.
    if ( options._deltaModel && !deltaSource && !parent?.actorLink ) {
      delete parent[this.name];
      options._deltaModelDeleted = options._deltaModel;
      options._deltaModel = this.initialize(value, parent, options)();
    }

    options._deltaModel?.updateSyntheticActor?.();
  }

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

  /** @inheritDoc */
  _updateDiff(key, value, options, state) {
    const parent = options._deltaModel?.parent; // TODO should _deltaModel move into state?
    if ( options._deltaModel && !options.partial && DataFieldOperator.get(value) && !value._id ) {
      value._id = options._deltaModel.id;
    }
    if ( !parent?.actorLink ) {
      return SchemaField.prototype._updateDiff.call(this, key, value, options, {
        ...state, model: options._deltaModel ?? state.model
      });
    }
  }
}

/**
 * @import {RegionData, UserData} from "./_types.mjs";
 * @import {DocumentPermissionTest} from "@common/abstract/_types.mjs";
 * @import BaseGrid from "@common/grid/base.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"
    },
    permissions: {
      create: BaseRegion.#canCreate,
      update: BaseRegion.#canUpdate,
      delete: "OWNER"
    },
    schemaVersion: "14.361"
  }, {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 ShapesField(),
      elevation: new SchemaField({
        bottom: new NumberField({required: true}), // Treat null as -Infinity
        top: new NumberField({required: true}), // Treat null as +Infinity
        topInclusive: new BooleanField()
      }, {
        validate: d => (d.bottom ?? -Infinity) <= (d.top ?? Infinity),
        validationError: "elevation.top may not be less than elevation.bottom"
      }),
      levels: new SceneLevelsSetField(),
      restriction: new SchemaField({
        enabled: new BooleanField(),
        type: new StringField({required: true, choices: CONST.EDGE_RESTRICTION_TYPES, initial: "move"}),
        priority: new NumberField({required: true, nullable: false, integer: true, initial: 0, min: 0})
      }),
      attachment: new SchemaField({
        token: new ForeignDocumentField(BaseToken, {idOnly: true, nullable: true, initial: null})
      }),
      behaviors: new EmbeddedCollectionField(BaseRegionBehavior),
      visibility: new NumberField({required: true,
        initial: CONST.REGION_VISIBILITY.LAYER_UNLOCKED,
        choices: Object.values(CONST.REGION_VISIBILITY)}),
      highlightMode: new StringField({required: true, initial: "shapes", choices:
        {shapes: "REGION.HIGHLIGHT_MODES.shapes.label", coverage: "REGION.HIGHLIGHT_MODES.coverage.label"}}),
      displayMeasurements: new BooleanField(),
      hidden: new BooleanField(),
      locked: new BooleanField(),
      ownership: new DocumentOwnershipField(),
      flags: new DocumentFlagsField(),
      /** @internal */
      _shapeConstraints: new ArrayField(
        new ArrayField(new NumberField({required: true, nullable: false, initial: undefined})),
        {nullable: true, initial: null}
      )
    };
  }

  /** @override */
  static LOCALIZATION_PREFIXES = ["DOCUMENT", "REGION"];

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

  /** @override */
  static canUserCreate(user) {
    return user.hasPermission("REGION_CREATE");
  }

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

  /**
   * Is a User able to create a new Region?
   * The REGION_CREATE permission is required.
   * Only GMs can create Regions with Behaviors.
   * @type {DocumentPermissionTest}
   */
  static #canCreate(user, doc, data) {
    if ( !user.hasPermission("REGION_CREATE") ) return false;
    if ( isEmpty(data.behaviors) ) return true;
    if ( !user.isGM ) return false;
    if ( data.behaviors.some(b => b.type === "executeScript") && !user.hasPermission("MACRO_SCRIPT") ) return false;
    return true;
  }

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

  /**
   * Is a User able to update a Region?
   * The OWNER permission is required.
   * Only GMs can update Behaviors.
   * @type {DocumentPermissionTest}
   */
  static #canUpdate(user, doc, data) {
    if ( !doc.testUserPermission(user, "OWNER") ) return false;
    if ( isEmpty(data.behaviors) ) return true;
    if ( !user.isGM ) return false;
    if ( data.behaviors.some(b => b.type === "executeScript") && !user.hasPermission("MACRO_SCRIPT") ) return false;
    return true;
  }

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

  /** @override */
  getUserLevel(user) {
    user ||= game.user;
    if ( this.pack ) return this.compendium.getUserLevel(user);
    const level = this.ownership[user.id] ?? this.ownership.default ?? CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE;
    if ( level === CONST.DOCUMENT_OWNERSHIP_LEVELS.INHERIT ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE;
    return level;
  }

  /* -------------------------------------------- */
  /*  Database Update Operations                  */
  /* -------------------------------------------- */

  /** @override */
  async _preUpdate(changes, options, user) {
    if ( !options._updateShapeConstraints?.[this.id] ) delete changes._shapeConstraints;
  }

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

  /**
   * Migrate MeasuredTemplate data to Region data.
   * @param {object} template                             The MeasuredTemplate data
   * @param {object} [context]                            The migration context
   * @param {BaseGrid} [context.grid]                     The grid
   * @param {boolean} [context.gridTemplates]             Grid-shaped?
   * @param {"round"|"flat"} [context.coneTemplateType]   The cone curvature
   * @param {UserData[]} [context.users]                  The users
   * @returns {RegionData}                                The Region data
   * @internal
   */
  static _migrateMeasuredTemplateData(template, {grid=BaseScene.defaultGrid, gridTemplates=false, coneTemplateType="round", users=[]}={}) {
    const authorId = isValidId(template.author) ? template.author : undefined;
    const authorName = authorId ? users.find(u => u._id === authorId)?.name : undefined;
    const t = template.t || "circle";
    const x = Math.round(template.x || 0);
    const y = Math.round(template.y || 0);
    const elevation = template.elevation || 0;
    const distance = Math.abs(template.distance || 0);
    const direction = Math.normalizeDegrees(template.direction || 0);
    const angle = Math.clamp(template.angle === null ? 90 : (template.angle || 0), 0, 360);
    const width = Math.abs(template.width || 0);
    const fillColor = template.fillColor || "#ff0000";
    const hidden = template.hidden || false;

    // Create the shape
    const gridBased = gridTemplates === true;
    if ( !gridBased && !grid.isGridless ) grid = new GridlessGrid({size: grid.size, distance: grid.distance});
    const distancePixels = grid.size / grid.distance;
    let shape;
    switch ( t ) {
      case "circle": {
        shape = {type: "circle", x, y, radius: distance * distancePixels, gridBased};
        break;
      }
      case "cone": {
        const curvature = gridBased || (coneTemplateType === "round") ? "round" : "flat";
        shape = {type: "cone", x, y, radius: distance * distancePixels, angle, rotation: direction, curvature, gridBased};
        break;
      }
      case "rect": {
        const {x: x1, y: y1} = grid.getTranslatedPoint({x, y}, direction, distance);
        let width = grid.measurePath([{x, y}, {x: x1, y}]).distance * distancePixels;
        let height = grid.measurePath([{x, y}, {x, y: y1}]).distance * distancePixels;
        const rotation = direction.toNearest(90, "floor");
        if ( (rotation === 90) || (rotation === 270) ) [width, height] = [height, width];
        shape = {type: "rectangle", x, y, width, height, anchorX: 0, anchorY: 0, rotation, gridBased};
        break;
      }
      case "ray": {
        shape = {type: "line", x, y, length: distance * distancePixels, width: width * distancePixels, rotation: direction, gridBased};
        break;
      }
    }

    // Create the Region data
    let name = `${shape.type.capitalize()} Template`;
    if ( authorName ) name += ` [${authorName}]`;
    const ownership = {default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE};
    if ( authorId ) ownership[authorId] = CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return {
      _id: template._id ?? null,
      name,
      color: fillColor,
      shapes: [shape],
      elevation: {bottom: elevation, top: null},
      levels: [],
      restriction: {enabled: false, type: "move", priority: 0},
      attachment: {token: null},
      behaviors: [],
      visibility: hidden ? CONST.REGION_VISIBILITY.OBSERVER : CONST.REGION_VISIBILITY.ALWAYS,
      highlightMode: "coverage",
      displayMeasurements: true,
      hidden: false,
      locked: false,
      ownership,
      flags: deepClone(template.flags || {})
    };
  }
}

/**
 * @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", "applyActiveEffect", "changeLevel", "defineSurface", "displayScrollingText", "executeMacro", "executeScript", "modifyMovementCost", "pauseGame", "suppressWeather", "teleportToken", "toggleBehavior"],
    hasTypeData: true,
    isEmbedded: true,
    permissions: {
      create: this.#canCreate,
      update: this.#canUpdate,
      delete: "OWNER"
    },
    schemaVersion: "14.352"
  }, {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 doc.testUserPermission(user, DOCUMENT_OWNERSHIP_LEVELS.OWNER);
  }

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

  /** @inheritDoc */
  getUserLevel(user) {
    let level = super.getUserLevel(user);
    if ( !user.isGM ) level = Math.min(level, DOCUMENT_OWNERSHIP_LEVELS.OBSERVER);
    return level;
  }
}

/**
 * @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[]}
   * @internal
   */
  static _GAMEMASTER_ONLY_KEYS = ["core.permissions"];

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

  /**
   * The settings that assistant GMs can modify regardless of their permission.
   * @type {string[]}
   * @internal
   */
  static _ALLOWED_ASSISTANT_KEYS = ["core.time", "core.combatTrackerConfig", "core.sheetClasses", "core.scrollingStatusText",
    "core.tokenAutoRotate", "core.tokenDragPreview", "core.adventureImports", "core.animateRollTable", "core.gridDiagonals",
    /** @deprecated since v14 */ "core.gridTemplates", /** @deprecated since v14 */ "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, options) {

    // Since V13, the "compendium" type has been dropped.
    if ( data.type === "pack" ) data.type = CONST.TABLE_RESULT_TYPES.DOCUMENT;
    BaseTableResult.#migrateDocumentUuid(data);

    return super.migrateData(data, options);
  }

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

  /**
   * 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: "14.355"
  }, {inplace: false}));

  /** @inheritdoc */
  static defineSchema() {
    const occlusionModes = Object.entries(OCCLUSION_MODES).reduce((modes, [mode, flag]) => {
      if ( flag !== OCCLUSION_MODES.NONE ) modes[flag] = `TILE.OcclusionMode${mode.titleCase()}`;
      return modes;
    }, {});
    return {
      _id: new DocumentIdField(),
      name: new StringField({textSearch: true}),
      texture: new TextureData({}, {categories: ["TEXTURE"], 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}),
      levels: new SceneLevelsSetField(),
      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({
        modes: new SetField(new NumberField({choices: occlusionModes,
          validationError: "must be a value in CONST.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"];

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

  /** @override */
  getUserLevel(user) {
    user ||= game.user;
    if ( this.pack ) return this.compendium.getUserLevel(user);
    if ( user.isGM ) return DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return DOCUMENT_OWNERSHIP_LEVELS.NONE;
  }

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

  /** @inheritDoc */
  static migrateData(data, options) {
    /** @deprecated since v14 */
    this._addDataFieldMigration(data, "occlusion.mode", "occlusion.modes",
      data => data.occlusion.mode > 0 ? [data.occlusion.mode] : []);
    return super.migrateData(data, options);
  }

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

  /** @inheritDoc */
  static shimData(data, options) {
    /** @deprecated since v14 */
    this._addDataFieldShim(data, "occlusion.mode", "occlusion.modes", {since: 14, until: 16, once: true,
      value: data.occlusion?.modes?.at(0) ?? OCCLUSION_MODES.NONE});
    return super.shimData(data);
  }
}

/**
 * @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: "14.353"
  }, {inplace: false}));

  /** @inheritDoc */
  static defineSchema() {
    const choices = [
      ["senseTypes", EDGE_SENSE_TYPES, "SenseTypes"],
      ["moveTypes", WALL_MOVEMENT_TYPES, "SenseTypes"],
      ["directions", EDGE_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"}),
      levels: new SceneLevelsSetField(),
      light: new NumberField({required: true, choices: choices.senseTypes,
        initial: EDGE_SENSE_TYPES.NORMAL,
        validationError: "must be a value in CONST.EDGE_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: EDGE_SENSE_TYPES.NORMAL,
        validationError: "must be a value in CONST.EDGE_SENSE_TYPES"}),
      sound: new NumberField({required: true, choices: choices.senseTypes,
        initial: EDGE_SENSE_TYPES.NORMAL,
        validationError: "must be a value in CONST.EDGE_SENSE_TYPES"}),
      dir: new NumberField({required: true, choices: choices.directions,
        initial: EDGE_DIRECTIONS.BOTH,
        validationError: "must be a value in CONST.EDGE_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"];

  /**
   * Colors for each category of wall.
   * @type {Record<string, Color>}
   */
  static CATEGORY_COLORS = {
    door: Color.from(0x6666EE),
    ethereal: Color.from(0xCA81FF),
    invisible: Color.from(0x77E7E8),
    normal: Color.from(0xFFFFBB),
    secret: Color.from(0xA612D4),
    terrain: Color.from(0x81B90C),
    window: Color.from(0xC7D8FF),
    blank: Color.from(0x555555)
  };

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

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

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

  /** @override */
  getUserLevel(user) {
    user ||= game.user;
    if ( this.pack ) return this.compendium.getUserLevel(user);
    if ( user.isGM ) return DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return DOCUMENT_OWNERSHIP_LEVELS.NONE;
  }
}

/**
 * @import {RegionMovementSegmentType, TokenShapeType} from "@common/constants.mjs";
 * @import {EffectChangeData, EffectDurationData, TokenPosition, TokenData} from "@common/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, ElevatedPoint, TokenConstrainMovementPathOptions, TokenCreateTerrainMovementPathOptions,
 *   TokenMovementActionConfig, TokenMeasureMovementPathOptions, TokenPanningOptions} from "../_types.mjs";
 * @import {ActiveEffect, Actor, Combat, Combatant, Folder, Item, RegionDocument, TableResult, TokenDocument,
 *   User} from "./_module.mjs";
 * @import {DataField} from "@common/data/fields.mjs";
 * @import {DataSchema} from "@common/abstract/_types.mjs";
 * @import ActiveEffectConfig from "@client/applications/sheets/active-effect-config.mjs";
 * @import {CanvasAnimationEasingFunction} from "@client/canvas/animation/_types.mjs";
 * @import {CompendiumCollection} from "./collections/_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
 * @property {number} importedTime                The server-side timestamp when the content was imported.
 */

/**
 * @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 _ActiveEffectChangeData
 * {@ignore}
 * @property {ActiveEffect} [effect] The parent Effect
 * @property {number} priority       The order in which this change is applied among other changes in a common phase
 */

/**
 * @typedef {EffectChangeData & _ActiveEffectChangeData} ActiveEffectChangeData
 * {@interface}
 */


/**
 * @callback ActiveEffectChangeRenderer
 * A function to render a stringified HTMLLIElement in the changes tab of {@link ActiveEffectConfig}
 * @param {object} context                 Contextual data for rendering the row
 * @param {object} context.change          A copy of the change from the ActiveEffect's source array
 * @param {number} context.index           The object's index in the changes Array
 * @param {DataSchema} context.fields
 * @param {number} context.defaultPriority The change type's default priority
 * @returns {Promise<string>}
 */

/**
 * @callback ActiveEffectChangeHandler
 * A function that applies the change to a document
 * @param {Actor|Item|TokenDocument} targetDoc The Document requesting the change application
 * @param {ActiveEffectChangeData} change      The change data
 * @param {object} [options]                   Additional options to configure the change application.
 * @param {DataField} [options.field]  The field. If not supplied, it will be retrieved from the supplied model.
 * @param {Record<string, unknown>} [options.replacementData] Data used to resolve "@" expressions.
 * @param {boolean} [options.modifyTarget]                    Modify the target Document with the updated value.
 * @returns {Promise<Record<string, unknown>|void>} A Promise resolving to either a record of Actor-data overrides made
 *                                                  or void
 */

/**
 * @typedef _ActiveEffectDuration
 * {@ignore}
 * @property {number|null} seconds       The total duration in seconds
 * @property {number} remaining          The remaining effect duration in a quantity of the configured unit
 * @property {number} [secondsRemaining] The remaining effect duration in seconds, given it is possible to express
 * @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
 * {@interface}
 */

/**
 * @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<Document>[]} 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<{}>} RegionRegionAnimationEvent
 * @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 SceneViewOptions
 * @property {string} [level]                The ID of the Level to view
 * @property {string[]} [controlledTokens]   The IDs of initially controlled tokens
 * @property {object} [transition]           The transition animation to used when viewing the scene
 * @property {string} [transition.type]      The type of the transition animation
 * @property {number} [transition.duration]  The duration of the transition animation
 */

/**
 * @typedef TrackedAttributesDescription
 * @property {string[][]} bar    A list of property key arrays to attributes with both a value and a max property.
 * @property {string[][]} value  A list of property key arrays to attributes that have only a value property.
 */

/**
 * @typedef TokenMovementWaypoint
 * @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 {number} depth                  The depth in grid spaces (nonnegative).
 *                                           Default: the previous or source depth.
 * @property {TokenShapeType} shape          The shape type (see {@link CONST.TOKEN_SHAPES}).
 *                                           Default: the previous or source shape.
 * @property {string} level                  The level ID. Default: the previous or source level ID.
 * @property {string} action                 The movement action from the previous to this waypoint.
 *                                           Default: the prepared movement action.
 * @property {boolean} snapped               Was this waypoint snapped to the grid? Default: `false`.
 * @property {boolean} explicit              Was this waypoint explicitly placed by the user? Default: `false`.
 * @property {boolean} checkpoint            Is this waypoint a checkpoint? There's an update/movement operation
 *                                           for each checkpoint in a movement path. At a checkpoint the movement
 *                                           can be stopped or paused. Default: `false`.
 */

/**
 * @typedef _TokenProcessedMovementWaypoint
 * {@ignore}
 * @property {DataModel|null} terrain         The terrain data of this segment. Default: `null`.
 * @property {boolean} intermediate           Is this waypoint intermediate? Default: `false`.
 */

/**
 * @typedef {TokenMovementWaypoint & _TokenProcessedMovementWaypoint} TokenProcessedMovementWaypoint
 * {@interface}
 */

/**
 * @typedef _TokenMeasuredMovementWaypoint
 * {@ignore}
 * @property {string} userId             The ID of the user that moved the token to from the previous to this waypoint.
 * @property {string|null} movementId    The ID of the movement from the previous to this waypoint, or null if pending
 *                                       but not planned.
 * @property {string} subpathId          The ID of the subpath, which is equal to the movement ID of the first waypoint
 *                                       in the subpath.
 * @property {number} cost               The movement cost from the previous to this waypoint (nonnegative).
 */

/**
 * @typedef {TokenProcessedMovementWaypoint & _TokenMeasuredMovementWaypoint} TokenMeasuredMovementWaypoint
 * {@interface}
 */

/**
 * @typedef TokenMovementSegmentData
 * @property {number} width              The width in grid spaces (positive).
 * @property {number} height             The height in grid spaces (positive).
 * @property {number} depth              The depth in grid spaces (nonnegative).
 * @property {TokenShapeType} shape      The shape type (see {@link CONST.TOKEN_SHAPES}).
 * @property {string} level              The level ID.
 * @property {string} action             The movement action of this segment.
 * @property {TokenMovementActionConfig} actionConfig  The config of the movement action.
 * @property {DataModel|null} terrain    The terrain data of this segment.
 * @property {boolean} teleport          Teleported?
 */

/**
 * @typedef TokenMeasurableMovementWaypointData
 * @property {number|TokenMovementCostFunction} [cost]    A predetermined cost (nonnegative) or cost function
 *                                                        to be used instead of `options.cost`.
 */

/**
 * @typedef {Partial<TokenProcessedMovementWaypoint>
 *   & TokenMeasurableMovementWaypointData} TokenMeasurableMovementWaypoint
 */

/**
 * @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 TokenRegionMovementSegment
 * @property {RegionMovementSegmentType} type     The type of this segment (see {@link CONST.REGION_MOVEMENT_SEGMENTS}).
 * @property {TokenPosition} from                 The waypoint that this segment starts from.
 * @property {TokenPosition} 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"|"hud"|"dragging"|"keyboard"|"paste"|"undo"} TokenMovementMethod
 */

/**
 * @typedef {"completed"|"paused"|"planned"|"pending"|"stopped"} TokenMovementState
 */

/**
 * @typedef TokenMovementData
 * The token movement data that describes the current/last movement of the token.
 * NONE of the properties are writable.
 * @property {string} id         The ID of the movement
 * @property {string} subpathId  The ID of the subpath, which is equal to the movement ID of the first
 *                               waypoint in the subpath
 * @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} split                       Was a new subpath started?
 * @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<TokenCreateTerrainMovementPathOptions, "preview">} terrainOptions
 *                                         The terrain movement options
 * @property {Omit<TokenConstrainMovementPathOptions, "preview"|"history"|"measureOptions">} constrainOptions
 *                                         The options to constrain movement
 * @property {Omit<TokenMeasureMovementPathOptions, "preview">} measureOptions
 *                                         The options to measure 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
 * @property {Promise<boolean>} finished   The promise that resolves once the entire movement is finished.
 *                                         It resolves to true if the movement completed, otherwise false.
 * @property {{started: Promise<void>; ended: Promise<void>; duration: number}} animation
 *   The promise that resolves when the animation of this movement starts, the promise that resolves when
 *   the animation of this movement ends, and the duration of the animation of this movement.
 */

/**
 * @typedef TokenPreMovementOperation
 * The pre-movement operation data of a token movement.
 * ONLY `autoRotate` and `showRuler` are writable.
 * @property {string} id         The ID of the movement.
 *                               This property is NOT writable.
 * @property {string} subpathId  The ID of the subpath, which is equal to the movement ID of the first
 *                               waypoint in the subpath.
 *                               This property is NOT writable.
 * @property {string[]} chain    The chain of prior movement IDs that this movement is a continuation of.
 *                               This property is NOT writable.
 * @property {TokenPosition} origin                The origin of movement.
 *                                                 This property is NOT writable.
 * @property {TokenPosition} destination           The destination of movement.
 *                                                 This property is NOT writable.
 * @property {TokenMovementSectionData} passed     The waypoints and measurements of the passed path.
 *                                                 This property is NOT writable.
 * @property {TokenMovementSectionData} pending    The waypoints and measurements of the pending path.
 *                                                 This property is NOT writable.
 * @property {TokenMovementHistoryData} history    The waypoints and measurements of the history path.
 *                                                 This property is NOT writable.
 * @property {boolean} split                       Was a new subpath started?
 *                                                 This property is NOT writable.
 * @property {boolean} constrained                 Was the movement constrained?
 *                                                 This property is NOT writable.
 * @property {boolean} recorded                    Was the movement recorded in the movement history?
 *                                                 This property is NOT writable.
 * @property {TokenMovementMethod} method          The method of movement.
 *                                                 This property is NOT writable.
 * @property {Omit<TokenCreateTerrainMovementPathOptions, "preview">} terrainOptions
 *                                         The terrain movement options.
 *                                         This property is NOT writable.
 * @property {Omit<TokenConstrainMovementPathOptions, "preview"|"history"|"measureOptions">} constrainOptions
 *                                         The options to constrain movement.
 *                                         This property is NOT writable.
 * @property {Omit<TokenMeasureMovementPathOptions, "preview">} measureOptions
 *                                         The options to measure movement.
 *                                         This property is NOT writable.
 * @property {boolean} autoRotate          Automatically rotate the token in the direction of movement?
 *                                         This property IS writable.
 * @property {boolean} showRuler           Show the ruler during the movement animation of the token?
 *                                         This property IS writable.
 */

/**
 * @typedef TokenMovementOperation
 * The (post-)movement operation data of a token movement.
 * NONE of the properties are writable.
 * @property {string} id         The ID of the movement
 * @property {string} subpathId  The ID of the subpath, which is equal to the movement ID of the first
 *                               waypoint in the subpath.
 * @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} split                       Was a new subpath started?
 * @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<TokenCreateTerrainMovementPathOptions, "preview">} terrainOptions
 *                                         The terrain movement options
 * @property {Omit<TokenConstrainMovementPathOptions, "preview"|"history"|"measureOptions">} constrainOptions
 *                                         The options to constrain movement
 * @property {Omit<TokenMeasureMovementPathOptions, "preview">} measureOptions
 *                                         The options to measure 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 {Promise<boolean>} finished   The promise that resolves once the entire movement is finished.
 *                                         It resolves to true if the movement completed, otherwise false.
 * @property {{started: Promise<void>; ended: Promise<void>; duration: number}} animation
 *   The promise that resolves when the animation of this movement starts, the promise that resolves when
 *   the animation of this movement ends, and the duration of the animation of this movement.
 */

/**
 * @typedef TokenMovementInstructionOptions
 * @property {string} [id]                   The movement ID, which must be unique. Default: auto-generated.
 * @property {TokenMovementMethod} [method]  The method of movement. Default: {@link TokenMovementOptions#method}.
 * @property {boolean} [autoRotate]  Automatically rotate the token in the direction of movement?
 *   Default: {@link TokenMovementOptions#autoRotate}.
 * @property {boolean} [showRuler]  Show the ruler during the movement animation of the token?
 *   Default: {@link TokenMovementOptions#showRuler}.
 * @property {Omit<TokenCreateTerrainMovementPathOptions, "preview">} [terrainOptions]
 *   The terrain movement options. Default: {@link TokenMovementOptions#terrainOptions}.
 * @property {Omit<TokenConstrainMovementPathOptions, "preview"|"history"|"measureOptions">} [constrainOptions]
 *   The options to constrain movement. Default: {@link TokenMovementOptions#constrainOptions}.
 * @property {Omit<TokenMeasureMovementPathOptions, "preview">} [measureOptions]  The options to measure movement.
 *   Default: {@link TokenMovementOptions#measureOptions}.
 * @property {boolean} [split]      Start a new subpath? Default: {@link TokenMovementOptions#split}.
 * @property {boolean} [planned]    Don't start the movement yet? Default: {@link TokenMovementOptions#planned}.
 */

/**
 * @typedef TokenMovementInstructionDestination
 * @property {Partial<TokenData & TokenMovementWaypoint>} destination  The destination which may include additional
 *  token data.
 */

/**
 * @typedef TokenMovementInstructionWaypoints
 * @property {Partial<TokenMovementWaypoint>[]} waypoints  The waypoints to move the token through.
 */

/**
 * @typedef {(TokenMovementInstructionDestination | TokenMovementInstructionWaypoints)
 *   & TokenMovementInstructionOptions} TokenMovementInstruction
 */

/**
 * @typedef TokenResizingInstruction
 * Resize the Token such that its center point remains (almost) unchanged. The center point might change
 * slightly because the new (x, y) position is rounded.
 * @property {Partial<Omit<TokenData & TokenMovementWaypoint, "x"|"y"|"elevation">>} dimensions
 *                                           The new dimensions and additional data.
 * @property {string} [id]                   The movement ID, which must be unique. Default: auto-generated.
 * @property {TokenMovementMethod} [method]  The method of movement. Default: {@link TokenMovementOptions#method}.
 * @property {false} [autoRotate]                                        Resizing never auto rotates.
 * @property {false} [showRuler]                                         Resizing never shows the ruler.
 * @property {{}} [terrainOptions]                                       Resizing doesn't move through terrain.
 * @property {{ignoreWalls: true, ignoreCost: true}} [constrainOptions]  Resizing always ignores walls, surfaces and
 *                                                                       impassable terrain.
 * @property {{}} [measureOptions]                                       Resizing doesn't cost anything.
 * @property {boolean} [split]      Start a new subpath? Default: {@link TokenMovementOptions#split}.
 * @property {boolean} [planned]    Don't start the movement yet? Default: {@link TokenMovementOptions#planned}.
 */

/**
 * @typedef TokenMovementOptions
 * @property {string} [id]                         The movement ID, which must be unique. Default: auto-generated.
 * @property {TokenMovementMethod} [method="api"]  The method of movement. Default: `"api"`.
 * @property {boolean} [autoRotate]  Automatically rotate the token in the direction of movement? Default:
 *   `game.settings.get("core", "tokenAutoRotate")` if `method` is `"dragging"` or `"keyboard"` otherwise `false`.
 * @property {boolean} [showRuler]  Show the ruler during the movement animation of the token? Default: `true` if
 *   `method` is `"dragging"` otherwise `false`.
 *   duration of movement. Default: `false`.
 * @property {Omit<TokenCreateTerrainMovementPathOptions, "preview">} [terrainOptions]  The terrain movement options.
 * @property {Omit<TokenConstrainMovementPathOptions, "preview"|"history"|"measureOptions">} [constrainOptions]
 *   The options to constrain movement.
 * @property {Omit<TokenMeasureMovementPathOptions, "preview">} [measureOptions]  The options to measure movement.
 * @property {boolean} [split]                           Start a new subpath? Default: `false` if it's movement
 *                                                       without history or keyboard movement that follows keyboard
 *                                                       movement, otherwise `true`.
 * @property {boolean} [planned=false]                   Don't start the movement yet? Default: `false`.
 * @property {boolean|TokenPanningOptions} [pan=true]    Pan the canvas (with transition animation) to the destination
 *                                                       if the token is controlled? Default: `true`.
 * @property {boolean} [animate=true]                    Animate movement. Default: `true`.
 * @property {object} [animation]                        The animation options.
 * @property {number} [animation.duration]               The duration of the animation in milliseconds (nonnegative).
 * @property {number} [animation.movementSpeed]          The base movement speed in grid size per second (positive).
 * @property {boolean} [animation.linkToMovement=false]  Set the duration of non-movement properties to the animation.
 * @property {CanvasAnimationEasingFunction} [animation.easing]  The easing function of the animation of non-movement
 *                                                       properties. Default: `undefined` (linear).
 */

/**
 * @typedef TokenMovementContinuationData
 * @property {string} id                                The movement continuation ID, which is equal to the movement ID
 *                                                      unless planned, in which case the continuation ID is the
 *                                                      movement ID with the ".planned" suffix
 * @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|null, TokenMovementContinuationHandle>;
 *   callbacks: Array<(continued: boolean) => void>; pending: Set<string>}}} states    The movement continuation states
 */

/**
 * @typedef TokenMovementContinuationHandle
 * @property {string} continuationID                         The continuation ID
 * @property {() => Promise<boolean>} callback               The continuation callback
 * @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.
 */

/**
 * @typedef {"door"|"ethereal"|"invisible"|"normal"|"secret"|"terrain"|"window"|"blank"} WallCategory
 */

/**
 * @typedef RegionSurface
 * @property {string} key                     A key that uniquely identifies the surface
 * @property {RegionDocument} region          The region of the surface
 * @property {number} elevation               The elevation of the surface
 * @property {boolean} light                  Does the surface restrict light?
 * @property {boolean} move                   Does the surface restrict movement?
 * @property {boolean} sight                  Does the surface restrict sight?
 * @property {boolean} sound                  Does the surface restrict sound?
 * @property {boolean} occlusion              Does the surface cause occlusion?
 * @property {boolean} exposure               Does the surface cause exposure?
 * @property {boolean} culling                Does the surface cause culling?
 */

var _types$l = /*#__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}={}) {
    if ( CONFIG.debug.hooks ) 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);
    }
    if ( CONFIG.debug.hooks ) 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.isError(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 {FromCompendiumOptions, ToCompendiumOptions, 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();
    Object.defineProperty(this, "_source", {value: data, configurable: false, writable: false});

    // Initialize data
    this._initialize();
  }

  /**
   * The source data array from which the Documents in WorldCollections are created.
   * @type {object[]}
   * @internal
   */
  _source;

  /**
   * An Array of application references which will be automatically updated when the collection content changes
   * @type {(Application|ApplicationV2)[]}
   */
  apps = [];

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

  /**
   * 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,
          clean: {expand: false} // Server data is already expanded
        });
        super.set(doc.id, doc);
      } catch(err) {
        this.invalidDocumentIds.add(d._id);
        const uuid = `${this.documentName}.${d._id}`;
        const msg = `Failed to initialize ${this.documentName} [${uuid}]:\n${err.message}`;
        const error = new Error(msg, {cause: err});
        Hooks$1.onError(`${this.constructor.name}#_initialize`, error, {
          log: "error",
          uuid,
          id: d._id,
          documentName: this.documentName
        });
      }
    }
  }

  /* -------------------------------------------- */
  /*  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), {fallback: false});
  }

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

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

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

  /**
   * @override
   * @param {string} id
   * @param {TDocument} document
   * @returns {this}
   */
  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());
    return this;
  }

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

  /** @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) ) {
        return foundry.utils.mergeObject(searchFields, systemFields, {inplace: false});
      }
    }
    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                         */
  /* -------------------------------------------- */

  /**
   * Import a Document into this collection, persisting the result.
   * If the document ID already exists in the collection, it should be replaced with an optional confirmation dialog.
   * If the document ID is undefined or does not already exist, a new Document will be created in the collection.
   * @param {Document} document       A source Document to be imported. The document will be safely copied.
   * @param {FromCompendiumOptions|ToCompendiumOptions} options  Options which modify import behavior
   * @returns {Promise<Document|undefined>} The imported Document instance
   */
  async importDocument(document, options) {
    if ( !(document instanceof this.documentClass) ) {
      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;
    }

    // Prepare import data and modify options
    options.keepId ??= true; // Prefer retaining the document ID unless explicitly false
    const data = this._prepareImportDocument(document, options);

    // Replace an existing Document
    const existingDoc = this.get(data._id) ?? this.index?.get(data._id);
    if ( existingDoc ) {
      const link = this.#createDocumentLink(existingDoc).outerHTML;
      const type = this.documentName;
      const response = options.dialog ? await foundry.applications.api.DialogV2.wait({
        window: {title: `${_loc("DOCUMENT.Replace", {type})}: ${existingDoc.name}`},
        position: {width: 420},
        content: `<p>${_loc("DOCUMENT.ReplaceWarning", {id: existingDoc._id, link})}</p>`,
        buttons: [
          {action: "replace", label: _loc("DOCUMENT.Replace", {type}), icon: "fa-solid fa-file-import", default: true},
          {action: "copy", label: _loc("DOCUMENT.New", {type}), icon: "fa-solid fa-copy"},
          {action: "cancel", label: _loc("COMMON.Cancel"), icon: "fa-solid fa-times"}
        ]
      }) : "replace";
      switch ( response ) {
        case "replace": {
          if ( !options.clearFolder ) delete data.folder; // Preserve the destination folder
          const pack = options.pack;
          const updated = await this.documentClass.updateDocuments([data], {pack, recursive: false, diff: false,
            noHook: true});
          return updated[0];
        }
        case "copy":
          delete data._id;
          options.keepId = false;
          return this.documentClass.create(data, options);
      }
      return;
    }

    // Create a new Document
    return this.documentClass.create(data, options);
  }

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

  /**
   * Create a content link from a Document or index data.
   * @param {Document|object} data
   * @returns {HTMLAnchorElement}
   */
  #createDocumentLink(data) {
    if ( data instanceof foundry.abstract.Document ) return data.toAnchor();
    const {uuid, _id: id, name} = data;
    const docConfig = CONFIG[this.documentName];
    const typeLabel = docConfig.typeLabels[data.type];
    const typeName = game.i18n.has(typeLabel) ? `${_loc(typeLabel)}` : "";
    const documentName = _loc(`DOCUMENT.${this.documentName}`);
    const ariaLabel = typeName
      ? _loc("DOCUMENT.TypePageFormat", {type: typeName, page: documentName})
      : documentName;
    return foundry.applications.ux.TextEditor.createAnchor({
      classes: ["content-link"],
      attrs: {"aria-label": ariaLabel},
      dataset: {link: "", uuid, id, type: this.documentName, pack: this.collection, tooltip: ""},
      name,
      icon: docConfig.typeIcons?.[data.type] ?? docConfig.sidebarIcon ?? "fa-solid fa-link"
    });
  }

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

  /**
   * Translate a provided Document into data ready for import into this collection.
   * @param {Document} document       A source Document to be imported. The document should be safely copied.
   * @param {object} options          Options which modify import behavior
   * @returns {object}                Data ready for import
   * @throws {Error}                  An error if the import should be disallowed
   * @protected
   */
  _prepareImportDocument(document, options) {
    return document.toObject();
  }

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

  /**
   * 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 = typeof transformation === "function";
    if ( !hasTransformer && !foundry.utils.isPlainObject(transformation) ) {
      throw new Error("You must provide a data object or transformation function");
    }
    const hasCondition = typeof condition === "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 */
  _prepareImportDocument(document, options) {
    options.pack = this.collection;
    return document.toCompendium(this, options);
  }

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

  /** @override */
  _onModifyContents(action, documents, result, operation, user) {
    this.pack._onModifyContents(action, documents, result, operation, user);
  }
}

/**
 * @import Folder from "../folder.mjs";
 * @import {ManageCompendiumResponse, ToCompendiumOptions, WorldCompendiumConfiguration,
 *   WorldCompendiumPackConfiguration} from "@client/_types.mjs";
 * @import {DocumentOwnershipLevel} from "@common/constants.mjs";
 * @import WorldCollection from "../abstract/world-collection.mjs";
 * @import Document from "@common/abstract/document.mjs";
 * @import Collection from "@common/utils/collection.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}
     */
    metadata.label = _loc(metadata.label);
    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})
    }), {expandKeys: false});
  }

  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 ) {
      this.#folders.set(id, document);
      return this;
    }
    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") || (this.documentName === "Item");
    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 ) indexed.img = game.compendiumArt.get(indexed.uuid)?.img ?? indexed.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 if ( !this.has(d.id) ) 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 {DocumentOwnershipLevel} The ownership level in {@link CONST.DOCUMENT_OWNERSHIP_LEVELS}
   */
  getUserLevel(user=game.user) {
    const levels = CONST.DOCUMENT_OWNERSHIP_LEVELS;
    if ( user.role === CONST.USER_ROLES.GAMEMASTER ) return levels.OWNER;
    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 = this.getUserLevel(user);
    const target = (typeof permission === "string") ? (perms[permission] ?? perms.OWNER) : permission;
    return exact ? level === target : level >= target;
  }

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

  /** @inheritDoc */
  async importDocument(document, options={}) {
    if ( document instanceof foundry.documents.Folder ) return this.folders.importDocument(document, options);
    return super.importDocument(document, options);
  }

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

  /** @override */
  _prepareImportDocument(document, options) {
    options.pack = this.collection;
    options.clearOwnership = options.clearOwnership ?? (this.metadata.packageType === "world");
    return document.toCompendium(this, options);
  }

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

  /**
   * 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(_loc("COMPENDIUM.ImportFolder.InvalidDocument", {
        type: folder.constructor.name,
        collection: this.collection
      }));
      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(_loc("COMPENDIUM.ImportFolder.InvalidDocuments", {collection: this.collection}));
      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: _loc(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: _loc(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: _loc("COMPENDIUM.ImportAll.Title", {compendium: this.title}),
        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,
            clearOwnership: false
          });
        }
      },
      no: {label: "COMMON.Cancel"}
    }, options));
  }

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

  /**
   * Add a Document to the index, capturing its relevant index attributes
   * @param {TDocument} document       The document to index
   */
  indexDocument(document) {
    const data = document._source;
    const {id, uuid} = document;
    const baseIndexData = {_id: id, uuid};
    const index = this.#indexedFields.values().reduce((obj, field) => {
      foundry.utils.setProperty(obj, field, foundry.utils.getProperty(data, field));
      return obj;
    }, baseIndexData);
    index.img = data.thumb ?? data.img;
    this.index.set(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 = {
      "": _loc("COMPENDIUM.OwnershipInheritBelow"),
      NONE: _loc("OWNERSHIP.NONE"),
      LIMITED: _loc("OWNERSHIP.LIMITED"),
      OBSERVER: _loc("OWNERSHIP.OBSERVER"),
      OWNER: _loc("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-${this.collection}`,
      window: {
        title: _loc("OWNERSHIP.Title", {object: this.metadata.label}),
        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-user-lock",
          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("COMPENDIUM.ModifyPermissionDenied", {localize: true});
    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={}) {
    if ( !game.user.isGM || (this.getUserLevel() !== CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER) ) {
      throw new Error("You must be at least an assistant GM and have ownership over this Compendium to configure it.");
    }
    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", CompendiumCollection.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(_loc("COMPENDIUM.ModifyPermissionDenied"));
    if ( requireUnlocked && config.locked ) {
      err = new Error(_loc("COMPENDIUM.ModifyLocked"));
    }
    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 {object} [options]                   Additional options.
   * @param {ClientDocument} [options.document]  A document associated with this editor.
   * @param {boolean} [options.collaborate]      Whether this is a collaborative editor.
   */
  constructor(uuid, view, options={}, _collaborate, _options={}) {
    /** @deprecated since v14 */
    if ( !foundry.utils.isPlainObject(options) ) {
      foundry.utils.logCompatibilityWarning("The ProseMirrorEditor constructor signature has changed. "
        + "The isDirtyPlugin argument is no longer required, and the collaborate argument has moved "
        + "into the options argument in third position.", { since: 14, until: 16 });
      options = _options;
      options.collaborate = _collaborate;
    }

    /**
     * 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: options.collaborate, writable: false});

    this.options = options;
  }

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

  /**
   * A list of active editor instances by their UUIDs.
   * @type {Map<string, ProseMirrorEditor>}
   */
  static #editors = new Map();

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

  /**
   * 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 foundry.prosemirror.plugins.ProseMirrorDirtyPlugin.key.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;
    const document = existing.ownerDocument;
    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);
  }

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

  /**
   * 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({}, this.buildDefaultPlugins(), 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, {document, collaborate});
    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,
      nodeViews: ProseMirror.nodeViews,
      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, nodeViews: ProseMirror.nodeViews, state: ProseMirror.EditorState.create({doc: state.doc, plugins})
    });
  }

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

  /**
   * Instantiate a list of default editor plugins.
   * @returns {Record<string, Plugin>}
   */
  static buildDefaultPlugins() {
    const { commands, defaultSchema, plugins, tables } = foundry.prosemirror;
    const tablePlugin = tables.tableEditing();
    const tableHandleKeyDown = tablePlugin.props.handleKeyDown;
    tablePlugin.props.handleKeyDown = (view, event) => {
      if ( tables.isInTable(view.state) ) return tableHandleKeyDown(view, event);
      // Workaround for bug in @massifrg/prosemirror-tables-sections: when the cursor is outside a table, the ArrowUp
      // handler unconditionally sets a new selection to the position before the cursor's parent node, even when no
      // table is above. We check whether the node immediately preceding the cursor's parent is a table; if not, return
      // false so the browser handles it.
      if ( event.key !== "ArrowUp" ) return tableHandleKeyDown(view, event);
      const { $anchor } = view.state.selection;
      const prevNode = view.state.doc.resolve($anchor.before()).nodeBefore;
      if ( prevNode?.type.spec.tableRole !== "table" ) return false;
      return tableHandleKeyDown(view, event);
    };
    return {
      // FIXME: Need a way to control plugin ordering. For now reserve a slot for the chat input plugin.
      chatInput: new foundry.prosemirror.Plugin({}),
      inputRules: plugins.ProseMirrorInputRules.build(defaultSchema),
      keyMaps: plugins.ProseMirrorKeyMaps.build(defaultSchema),
      menu: plugins.ProseMirrorMenu.build(defaultSchema),
      isDirty: plugins.ProseMirrorDirtyPlugin.build(defaultSchema),
      clickHandler: plugins.ProseMirrorClickHandler.build(defaultSchema),
      pasteTransformer: plugins.ProseMirrorPasteTransformer.build(defaultSchema),
      baseKeyMap: plugins.keymap(commands.baseKeymap),
      dropCursor: plugins.dropCursor(),
      gapCursor: plugins.gapCursor(),
      history: plugins.history(),
      columnResizing: tables.columnResizing(),
      tables: tablePlugin
    };
  }

  /* -------------------------------------------- */
  /*  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} label                             The context menu label. Can be localized.
 * @property {string} [icon]                            A string containing a className. A full HTML element may also be
 *                                                      provided.
 * @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 {ContextMenuCallback} [onClick]            The function to call when the menu item is clicked.
 * @property {ContextMenuCondition|boolean} [visible]    A function to call or boolean value to determine if this entry
 *                                                      appears in the menu.
 */

/**
 * @callback ContextMenuCondition
 * @param {HTMLElement} target                          The element that the context menu has been triggered for.
 * @returns {boolean}                                   Whether the entry should be rendered in the context menu.
 */

/**
 * @callback ContextMenuCallback
 * @param {PointerEvent} event                          The triggering event.
 * @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.
 * @param {PointerEvent} event                          The triggering event.
 * @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 {"target"|"cursor"} [relative="cursor"]  For fixed context menus, control whether the menu is positioned
 *                                                    relative to the target or to the mouse cursor. Non-fixed context
 *                                                    menus are always positioned relative to the target.
 * @property {boolean} [jQuery=true]            If true, callbacks will be passed jQuery objects instead of HTMLElement
 *                                              instances.
 * @property {boolean} [closeOnSelect=true]     Close the context menu when one of the options is selected.
 */

/**
 * @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, relative="cursor", closeOnSelect=true
  }={}) {
    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 ( foundry.utils.getType(container) !== "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.#closeOnSelect = closeOnSelect;
    this.#container = container;
    this.#selector = selector || container.id;
    this.#eventName = eventName;
    this.menuItems = menuItems;
    this.onOpen = onOpen;
    this.onClose = onClose;
    this.#fixed = fixed;
    this.#relative = relative;
    /** @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                                  */
  /* -------------------------------------------- */

  /**
   * Close the context menu when one of the options is selected.
   * @type {boolean}
   */
  #closeOnSelect;

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

  /**
   * For fixed context menus, control whether the menu is positioned relative to the target or to the mouse cursor.
   * Non-fixed context menus are always positioned relative to the target.
   * @returns {"target"|"cursor"}
   */
  get relative() {
    return this.#relative;
  }

  #relative;

  /**
   * 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" });
    const win = this.#element.ownerDocument.defaultView;
    if ( !win || win.closed ) return;
    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();
    foundry.applications.detached.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={}) {}

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

  /**
   * Called before the menu's entries are rendered.
   * @param {ContextMenuRenderOptions} [options]
   * @returns {Promise<void>}
   * @protected
   */
  async _preRenderEntries(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 document = target.ownerDocument;
    const html = this.#element = document.createElement("nav");
    html.id = "context-menu";
    const menu = await this.#renderEntries(options);

    // 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, options);

    // Deactivate global tooltip
    game.tooltip.deactivate();

    // Animate open the menu
    if ( options.animate !== false ) await this._animate(true);
    return this._onRender(options);
  }

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

  /**
   * Render context menu entries.
   * @param {ContextMenuRenderOptions} [options]
   * @returns {Promise<HTMLMenuElement>}  The rendered menu.
   */
  async #renderEntries(options={}) {
    await this._preRenderEntries(options);
    const document = this.element.ownerDocument;
    const menu = document.createElement("menu");
    menu.classList.add("context-items");
    this.element.replaceChildren(menu);
    if ( !this.menuItems.length ) return menu;

    /** @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 ( ("condition" in entry) && !("visible" in entry) ) {
        foundry.utils.logCompatibilityWarning("ContextMenuEntry#condition is deprecated. "
          + "Use ContextMenuEntry#visible instead.", { since: 14, until: 16, once: true });
      }
      const visibilityCheck = "visible" in entry ? entry.visible : entry.condition;
      if ( visibilityCheck !== undefined ) {
        if ( typeof visibilityCheck === "function" ) {
          display = visibilityCheck(this.#jQuery ? $(this.#target) : this.#target);
        }
        else display = visibilityCheck;
      }
      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
        if ( ("name" in item) && !("label" in item) ) {
          foundry.utils.logCompatibilityWarning("ContextMenuEntry#name is deprecated. "
            + "Use ContextMenuEntry#label instead.", { since: 14, until: 16, once: true });
        }
        const name = _loc("label" in item ? item.label : item.name);
        const classes = ["context-item", item.classes].filterJoin(" ");
        const entry = document.createElement("li");
        entry.className = classes;
        item.icon &&= item.icon.trim();
        if ( item.icon ) {
          let icon;
          if ( item.icon.startsWith("<") ) { // A string parseable as an HTMLElement
            icon = foundry.utils.parseHTML(item.icon);
            if ( foundry.utils.getType(icon) !== "HTMLElement" ) continue;
          }
          else { // A className
            icon = document.createElement("i");
            icon.className = item.icon.trim();
          }
          if ( item.icon.includes("fa-") ) icon.classList.add("fa-fw");
          icon.inert = true;
          entry.append(icon);
        }
        const span = document.createElement("span");
        span.append(name);
        entry.append(span);
        parent.append(entry);

        // Record a reference to the element.
        item.element = entry;
      }
    }

    await this._onRenderEntries(menu, options);
    return menu;
  }

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

  /**
   * Called after the context menu has finished rendering and animating open.
   * @param {ContextMenuRenderOptions} [options]
   * @returns {Promise<void>}
   * @protected
   */
  async _onRender(options={}) {}

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

  /**
   * Called after the context menu has finished rendering its entries.
   * @param {HTMLMenuElement} menu  The rendered menu.
   * @param {ContextMenuRenderOptions} [options]
   * @returns {Promise<void>}
   * @protected
   */
  async _onRenderEntries(menu, 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 { innerHeight } = target.ownerDocument.defaultView;
    const containerUp = (menuBottom > containerRect.bottom) && (menuTop >= containerRect.top);
    const windowUp = (menuBottom > 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.
    let needsCoords = [clientX, clientY].includes(undefined) || (!event.isTrusted && [clientX, clientY].includes(0));
    needsCoords ||= this.relative === "target";
    if ( needsCoords && !target.checkVisibility() ) return;

    const document = target.ownerDocument;
    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`;

    // Expand in the direction with the most available space, and clamp a max-height to allow scrolling.
    const spaceAbove = clientY;
    const spaceBelow = clientHeight - clientY;
    this.#expandUp = (height > spaceBelow) && (spaceAbove > spaceBelow);
    if ( this.#expandUp ) {
      menu.style.bottom = `${clientHeight - clientY}px`;
      menu.style.maxHeight = `${spaceAbove}px`;
    } else {
      menu.style.top = `${clientY}px`;
      menu.style.maxHeight = `${spaceBelow}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.
   * @param {ContextMenuRenderOptions} [options]
   */
  activateListeners(menu, options={}) {
    menu.addEventListener("click", event => this._onClickItem(event, options));
  }

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

  /**
   * 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
   * @param {ContextMenuRenderOptions} [options]
   * @protected
   */
  _onClickItem(event, options={}) {
    event.preventDefault();
    event.stopPropagation();
    const element = event.target.closest(".context-item");
    if ( !element ) return;
    const item = this.menuItems.find(i => i.element === element);
    if ( item?.onClick ) item.onClick(event, this.#target);
    else if ( item?.callback ) {
      foundry.utils.logCompatibilityWarning("ContextMenuEntry#callback is deprecated. "
        + "Use ContextMenuEntry#onClick instead.", { since: 14, until: 16, once: true });
      item.callback(this.#jQuery ? $(this.#target) : this.#target, event);
    }
    if ( this.#closeOnSelect ) this.close();
    else this.#renderEntries(options);
  }

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

  /**
   * Bind global context menu listeners to a given document.
   * @param {Document} document  The document.
   */
  static activateListeners(document, { _deprecated=false }={}) {
    if ( !_deprecated && (foundry.utils.getDefiningClass(this, "eventListeners") !== ContextMenu) ) {
      foundry.utils.logCompatibilityWarning("ContextMenu.eventListeners is deprecated. "
        + "Please use ContextMenu.activateListeners instead.", { since: 14, until: 16 });
      this.eventListeners();
      return;
    }
    document ??= window.document;
    document.addEventListener("click", () => ui.context?.close(), { passive: true });
  }

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

  /**
   * Retrieve the configured ContextMenu 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");
  }

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

  /**
   * @deprecated since v14 until v16
   * @ignore
   */
  static eventListeners() {
    foundry.utils.logCompatibilityWarning("ContextMenu.eventListeners is deprecated. "
      + "Please use ContextMenu.activateListeners instead.", { since: 14, until: 16 });
    this.activateListeners(document, { _deprecated: true });
  }
}

/**
 * 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 ProseMirror 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 ProseMirror 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 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 ( Array.isArray(value) ) 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 editorIds = Object.values(this.editors).map(e => e.instance?.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") || editorIds.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 editor instances which are present in the form.
   */
  #processEditors() {
    for ( const [name, editor] of Object.entries(this.editors) ) {
      if ( editor.instance && (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 ( foundry.utils.isElementInstanceOf(field, 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 ( Array.isArray(value) ) 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 ( typeof window[dataType] === "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";
 * @import {ContextMenuEntry} from "../ux/context-menu.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: [{
        icon: "fa-solid fa-arrow-up-right-from-square",
        label: "APPLICATION.ACTIONS.Detach",
        action: "detach",
        visible: ApplicationV2.#canDetach
      }, {
        icon: "fa-solid fa-arrow-down-to-square",
        label: "APPLICATION.ACTIONS.Attach",
        action: "attach",
        visible: ApplicationV2.#canAttach
      }],
      minimizable: true,
      resizable: false,
      contentTag: "section",
      contentClasses: []
    },
    actions: {
      attach: ApplicationV2.#onAttach,
      detach: ApplicationV2.#onDetach
    },
    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(["prerender", "render", "close", "position"]);

  /**
   * Initial values of the #window object.
   * @type {object}
   */
  static #INITIAL_WINDOW_VALUES = {
    windowId: undefined,
    header: undefined,
    title: undefined,
    icon: undefined,
    resize: undefined,
    close: undefined,
    content: undefined,
    controls: 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);

  /**
   * Child Applications registered via renderChild, rendered in the same window as this one.
   * @type {Map<string, ApplicationV2>}
   */
  #children = new Map();

  /**
   * The parent Application of this Application, if registered via renderChild.
   * @type {ApplicationV2|null}
   */
  #parent = null;

  /**
   * A weak reference to the prior parent, retained so a manually-detached child can attempt to re-join its parent's
   * window when re-attaching.
   * @type {WeakRef<ApplicationV2>|null}
   */
  #priorParent = null;

  /**
   * Convenience references to window header elements.
   * @type {{
   *  windowId: string,
   *  header: HTMLElement,
   *  resize: HTMLElement,
   *  title: HTMLHeadingElement,
   *  icon: HTMLElement,
   *  close: HTMLButtonElement,
   *  controls: HTMLButtonElement,
   *  content: HTMLElement,
   *  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 _loc(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;
  }

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

  /**
   * The child Applications registered under this one via renderChild.
   * @type {Map<string, ApplicationV2>}
   */
  get children() {
    return this.#children;
  }

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

  /**
   * The parent Application of this Application, if registered via renderChild.
   * @type {ApplicationV2|null}
   */
  get parent() {
    return this.#parent;
  }

  /* -------------------------------------------- */
  /*  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.isPlainObject(v0) ) {                             // 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
    options.isFirstRender = this.#state <= states.NONE;
    if ( options.isFirstRender && !options.force ) return this;
    try {
      const canRender = this._canRender(options);
      if ( canRender === false ) return this;
    } catch(err) {
      ui.notifications.warn(err.message);
      return this;
    }

    // Prepare rendering context data
    this._configureRenderOptions(options);

    // A detach or re-attach request moves an already-rendered application between windows without re-rendering its
    // content, and is handled by a dedicated workflow rather than the render path below. An application which has never
    // been rendered has no element to move, so it takes the full render path and is inserted directly into its host
    // window by _insertElement.
    if ( !options.isFirstRender && (options.window?.detach || options.window?.attach) ) return this.#move(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", eventName: "prerender", hookName: "preRender",
      hookArgs: handlerArgs
    });

    // 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 the application HTML and replace the existing 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 });
    }

    // On first render, register the Application and insert its element into the DOM.
    if ( options.isFirstRender ) {
      const existing = foundry.applications.instances.get(this.#id);
      // Check if there is another instance with the same ID that is also currently rendering. If so, bail.
      if ( existing && (existing !== this) && (existing.state === states.RENDERING) ) {
        this.#state = states.NONE;
        return this;
      }
      foundry.applications.instances.set(this.#id, this);
      await this._insertElement(this.#element, options);
    }
    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;
  }

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

  /**
   * Move an already-rendered application between windows in response to a detach or re-attach request.
   * @param {RenderOptions} options  Options which configure application rendering behavior
   * @returns {Promise<this>}        A Promise which resolves to the moved Application instance
   */
  async #move(options) {
    const states = ApplicationV2.RENDER_STATES;
    this.#state = states.RENDERING;

    const existing = foundry.applications.instances.get(this.#id);
    // Check if there is another instance with the same ID that is also currently rendering. If so, bail.
    if ( existing && (existing !== this) && (existing.state === states.RENDERING) ) {
      this.#state = states.NONE;
      return this;
    }
    foundry.applications.instances.set(this.#id, this);

    // Relocate the existing element into its new host window and refresh the window frame.
    await this._insertElement(this.#element, options);
    if ( this.hasFrame ) this._updateFrame(options);
    this.#state = states.RENDERED;

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

    // Host window.
    const changeWindow = options.window?.windowId && (options.window.windowId !== this.window.windowId);
    if ( ("detached" in (options.window ?? {})) || changeWindow ) {
      const alreadyDetached = (this.window.windowId === this.id) && !changeWindow;
      options.window.detach = !alreadyDetached && (options.window.detached || options.window.windowId);
      options.window.attach = this.window.windowId && !options.window.windowId && !options.window.detached;
      options.window.host = foundry.applications.detached.windows.get(options.window.windowId)?.window;
    }

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

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

  /**
   * Return an array of header button config entries to render into the Application frame.
   * Subclasses may override this method to add, remove, or replace frame header buttons.
   * @param {RenderOptions} options  Options which configure application rendering behavior.
   * @returns {ApplicationHeaderControlsEntry[]}
   * @protected
   */
  _getFrameButtons(options) {
    return [];
  }

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

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

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

  /**
   * Generate context menu entries based on the header control specification.
   * @returns {Generator<ContextMenuEntry, void, *>}
   * @yields {ContextMenuEntry}
   * @protected
   */
  *_headerControlContextEntries() {
    for ( const { action, icon, label, onClick } of this._headerControlButtons() ) {
      let handler = this.options.actions[action];
      if ( typeof handler === "object" ) {
        if ( handler.buttons && !handler.buttons.includes(0) ) continue;
        handler = handler.handler;
      }
      yield {
        label, icon,
        onClick: (event, li) => {
          if ( typeof onClick === "function" ) onClick(event, li);
          else if ( handler ) handler.call(this, event, li);
          else this._onClickAction(event, li);
        }
      };
    }
  }

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

  /**
   * 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 document = (options.window?.host ?? window).document;
    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 = {
      toggleControls: _loc("APPLICATION.TOOLS.ToggleControls"),
      close: _loc("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>`;
    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]");

    // Add frame buttons.
    await this._renderFrameButtons(options);
    return frame;
  }

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

  /**
   * Render buttons that are inserted directly into the frame header. Header controls should be preferred over frame
   * buttons, which should be used sparingly.
   * @param {RenderOptions} options  Options which configure application rendering behavior.
   * @protected
   */
  async _renderFrameButtons(options) {
    const buttons = this._getFrameButtons(options);
    if ( !buttons.length ) return;
    const html = await foundry.applications.handlebars.renderTemplate("templates/generic/frame-buttons.hbs", {
      buttons
    });
    this.#window.close.insertAdjacentHTML("beforebegin", html);
  }

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

  /**
   * Render a header control button.
   * @param {ApplicationHeaderControlsEntry} control
   * @returns {HTMLLIElement}
   * @protected
   */
  _renderHeaderControl(control) {
    const document = this.element.ownerDocument;
    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 = _loc(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
    this.#window.controls.classList.toggle("hidden", !Array.from(this._headerControlButtons()).length);
    const isPrimaryDetached = (window.detached === true) || (this.window.windowId === this.id);
    const resizable = !isPrimaryDetached && (window.resizable ?? this.options.window.resizable);
    this.#window.resize?.toggleAttribute("hidden", !resizable);
    this.#window.close?.toggleAttribute("hidden", isPrimaryDetached);
  }

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

  /**
   * 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
   * @param {RenderOptions} [options]             Render options.
   * @protected
   */
  async _insertElement(element, options={}) {
    const document = (options?.window?.host ?? window).document;
    if ( options.window?.detach ) {
      if ( options?.window?.host ) {
        if ( !options?.isFirstRender ) return this.#adoptIntoWindow(options.window.host);
        // Track that this application is hosted in the detached window on first render.
        this.#window.windowId = options.window.host.id;
        foundry.applications.detached.windows.get(this.window.windowId)?.applications.set(this.id, this);
        // If detach is boolean true, this was a request to detach this application from a specific window, rather than
        // the main workspace. We forward this request on so that the window.open() call is fired from the correct
        // execution context.
        if ( options.window.detach === true ) this.#semaphore.add(this.#detach.bind(this, options.window.host));
      } else {
        const source = this.#element?.ownerDocument?.defaultView;
        this.#semaphore.add(this.#detach.bind(this, source));
      }
    }
    else if ( options.window?.attach ) return this.#attach();
    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"});

    // 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) {
    // Close registered children if this application was in a detached window, otherwise just unlink them.
    for ( const child of this.#children.values() ) {
      if ( this.window.windowId ) child.close();
      else child.#parent = null;
    }
    this.#children.clear();

    // Remove this application from its parent's registry.
    if ( this.#parent ) this.#parent.#children.delete(this.id);
    this.#parent = null;
    this.#priorParent = null;

    foundry.applications.detached.windows.get(this.window.windowId)?.applications.delete(this.id);
    foundry.applications.detached.checkEmpty(this.#element.ownerDocument.defaultView);

    // If the app was in a detached window, its stored position is relative to that window's viewport. Reset it so
    // the next render in the main workspace falls back to the centered default.
    if ( this.window.windowId ) {
      this.#position.top = undefined;
      this.#position.left = undefined;
    }

    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;
    // Computed style already includes scale, so we need to calculate the 'real' sizes again.
    if ( scale !== 1 ) {
      minWidth /= scale;
      maxWidth /= scale;
      minHeight /= scale;
      maxHeight /= scale;
    }
    let bounds = el.getBoundingClientRect();
    const {clientWidth, clientHeight} = this.element.ownerDocument.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})`
    });
  }

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

  /**
   * Re-fit an application to its content following a change in its natural size.
   * @param {Partial<ApplicationPosition>} [positionUpdate]  Position data forwarded to setPosition.
   * @protected
   */
  _refit(positionUpdate={}) {
    // Resizable applications manage their own dimensions, so there is nothing to auto-fit.
    if ( this.options.window.resizable ) return;
    if ( this.options.position.width === "auto" ) positionUpdate.width ??= "auto";
    if ( this.options.position.height === "auto" ) positionUpdate.height ??= "auto";

    // With no automatic dimensions there is nothing to re-measure.
    if ( foundry.utils.isEmpty(positionUpdate) ) return;

    // If this app is the primary application in a detached window, clear the inline max constraints before measuring so
    // setPosition can observe the natural dimensions, then resize the window and re-apply constraints.
    const isPrimaryDetached = this.window.windowId === this.id;
    if ( isPrimaryDetached ) {
      Object.assign(this.element.style, { maxWidth: "", maxHeight: "" });
      // Snap the primary application back to the top left in case it was moved, otherwise resizing the window will
      // obscure it.
      Object.assign(positionUpdate, { left: 0, top: 0 });
    }
    this.setPosition(positionUpdate);
    if ( isPrimaryDetached ) {
      const win = this.element.ownerDocument.defaultView;
      const { maxInnerW, maxInnerH, browserW, browserH } = this.#applyDetachedConstraints(win);
      win.resizeTo(maxInnerW + browserW, maxInnerH + browserH);
    }
  }

  /* -------------------------------------------- */
  /*  Detached Window API                         */
  /* -------------------------------------------- */

  /**
   * Re-attach a detached application to the main workspace.
   * If this application was previously a child and was manually detached, breaking its parent link, this method first
   * attempts to re-join the prior parent's window before falling back to the main workspace.
   * @param {RenderOptions} [options]  Render options.
   */
  attachWindow(options={}) {
    if ( this.#priorParent ) {
      const prior = this.#priorParent.deref();
      this.#priorParent = null;
      if ( prior?.rendered && prior?.window?.windowId ) return prior.renderChild(this);
    }
    options.window ??= {};
    options.window.detached = false;
    return this.render(options);
  }

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

  /**
   * Detach an application from the main workspace, and render it in a separate browser window.
   * If this application is a registered child, detaching it breaks the parent link and gives it its own window.
   * @param {RenderOptions} [options]  Render options.
   */
  detachWindow(options={}) {
    if ( this.#parent ) {
      this.#parent.#children.delete(this.id);
      this.#priorParent = new WeakRef(this.#parent);
      this.#parent = null;
    }
    options.window ??= {};
    options.window.detached = true;
    return this.render(options);
  }

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

  /**
   * Render another Application as a child of this one.
   * The child is rendered in the same window as this application and moves with it when it is detached or re-attached.
   * Closing this application will also close the child.
   * If the child was previously registered under a different parent, that link is replaced.
   * @param {ApplicationV2} app        The child application to render.
   * @param {RenderOptions} [options]  Render options passed to the child's render method.
   * @returns {Promise<ApplicationV2>} A Promise which resolves to the rendered child Application.
   */
  renderChild(app, options={}) {
    if ( app.#parent ) app.#parent.#children.delete(app.id);
    app.#parent = this;
    app.#priorParent = null;
    this.#children.set(app.id, app);
    options.window ??= {};
    if ( this.window.windowId ) options.window.windowId ??= this.window.windowId;
    return app.render({ force: true, ...options });
  }

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

  /**
   * Move this application back into the main workspace.
   */
  #attach() {
    const windowId = this.window.windowId;  // Save before clearing.
    const detached = this.element.ownerDocument.defaultView;
    document.adoptNode(this.element);
    document.body.append(this.element);
    this.#window.windowId = undefined;

    // Map screen co-ordinates to the main workspace's co-ordinates.
    const { innerHeight, outerHeight, innerWidth, outerWidth, screenX, screenY } = window;

    // Clear max-width and max-height overrides.
    Object.assign(this.element.style, { maxHeight: "", maxWidth: "" });

    // Preserve the app's current position within the detached window when mapping back to the main workspace.
    this.setPosition({
      top: detached.screenY + 62 - screenY - (outerHeight - innerHeight) + (this.#position.top ?? 0),
      left: detached.screenX - screenX - (outerWidth - innerWidth) + (this.#position.left ?? 0)
    });

    const descriptor = foundry.applications.detached.windows.get(windowId);
    descriptor?.applications.delete(this.id);
    foundry.applications.detached.checkEmpty(descriptor?.window ?? detached);
    this._doEvent(this._onAttach, {handlerArgs: [detached.document, document], debugText: "Re-attached"});
    for ( const child of this.#children.values() ) child.attachWindow();
  }

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

  /**
   * Have this app be adopted by a different window.
   * @param {WindowProxy} win  The window to adopt into.
   */
  #adoptIntoWindow(win) {
    const prev = this.element.ownerDocument.defaultView;
    win.document.adoptNode(this.element);
    win.document.body.append(this.element);
    this.setPosition();
    this.#window.windowId = win.id;
    foundry.applications.detached.windows.get(prev.id)?.applications.delete(this.id);
    foundry.applications.detached.windows.get(win.id).applications.set(this.id, this);
    foundry.applications.detached.checkEmpty(prev);
  }

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

  /**
   * Whether this Application is permitted to re-attach to the main workspace.
   * @this {ApplicationV2}
   * @returns {boolean}
   */
  static #canAttach() {
    return this._canAttach();
  }

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

  /**
   * Whether this Application is permitted to re-attach to the main workspace.
   * Managed children (those with an active parent) cannot re-attach independently; their parent controls placement.
   * @returns {boolean}
   * @protected
   */
  _canAttach() {
    return !!this.window.windowId && !this.#parent;
  }

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

  /**
   * Whether this Application is permitted to detach from the main workspace.
   * @this {ApplicationV2}
   * @returns {boolean}
   */
  static #canDetach() {
    return this._canDetach();
  }

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

  /**
   * Whether this Application is permitted to detach from the main workspace.
   * Managed children (those with an active parent) may detach to break the parent link and claim their own window.
   * @returns {boolean}
   */
  _canDetach() {
    return !this.window.windowId || !!this.#parent;
  }

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

  /**
   * Move this application into a detached window.
   * @param {WindowProxy} [source]  The source of the request for detaching this application. Must be forwarded to the
   *                                detached window API to ensure the request to open a popup comes from the same
   *                                execution context as the user's gesture.
   * @returns {Promise<void>}
   */
  async #detach(source=window) {
    const bb = this.#getVisibleBoundingBox();
    const position = { ...bb, top: bb.y, left: bb.x };
    const { innerHeight, outerHeight, innerWidth, outerWidth, screenX, screenY, screen } = source;
    position.top += outerHeight - innerHeight + screenY - 62; // Roughly account for popup window chrome.
    position.top -= Math.max(0, position.y + position.height - screen.height);
    position.left += screenX + outerWidth - innerWidth;
    position.left -= Math.max(0, position.x + position.width - screen.width);

    // Set the detached window size to the fully rendered size of the app.
    const detached = await foundry.applications.detached.openWindow({ id: this.id, position, source });
    detached.document.title = this.title;
    detached.document.adoptNode(this.element);
    detached.document.body.append(this.element);
    foundry.applications.detached.windows.get(source.id)?.applications.delete(this.id);
    foundry.applications.detached.windows.get(this.id).applications.set(this.id, this);
    this.#window.windowId = this.id;

    // Update the app's position within the window, re-resolving any auto dimensions now that the element is rendered
    // in the new window to get an accurate measurement.
    const positionUpdate = { left: this.#position.left - bb.x, top: this.#position.top - bb.y };
    if ( this.#position.width === "auto" ) positionUpdate.width = "auto";
    if ( this.#position.height === "auto" ) positionUpdate.height = "auto";
    this.setPosition(positionUpdate);

    // Constrain the app to the screen size in case the OS capped the popup window smaller than the app's natural size.
    this.#applyDetachedConstraints(detached);
    // Close the source window if this app leaving it left it empty.
    foundry.applications.detached.checkEmpty(source);
    this._doEvent(this._onDetach, {handlerArgs: [source.document, detached.document], debugText: "Detached"});
    for ( const child of this.#children.values() ) {
      child.render({ window: { windowId: this.window.windowId } }).then(() => child.bringToFront());
    }
  }

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

  /**
   * Measure the application's rendered size, clamp it to the screen, and apply inline max-width/max-height constraints
   * to prevent overflow in the detached popup window.
   * @param {WindowProxy} win  The detached window.
   * @returns {{ maxInnerW: number, maxInnerH: number, browserW: number, browserH: number }|void}
   */
  #applyDetachedConstraints(win) {
    // Resizable apps are already given fixed sizes by the ResizeManager.
    if ( this.options.window.resizable ) return;
    const bb = this.element.getBoundingClientRect();
    const browserW = win.outerWidth - win.innerWidth;
    const browserH = win.outerHeight - win.innerHeight;
    const maxInnerW = Math.min(bb.width, win.screen.width - browserW);
    const maxInnerH = Math.min(bb.height, win.screen.height - browserH);
    const { scale } = this.#position;
    Object.assign(this.element.style, {
      maxWidth: `min(${maxInnerW / scale}px, 100vw)`,
      maxHeight: `min(${maxInnerH / scale}px, 100vh)`
    });
    return { maxInnerW, maxInnerH, browserW, browserH };
  }

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

  /**
   * Determine the size of this element by unioning the bounding boxes of it and its visible children to account for
   * overhangs.
   * @returns {Rectangle}
   */
  #getVisibleBoundingBox() {
    let bb = null;

    /**
     * Recursively union bounding boxes.
     * @param {HTMLElement} element                       The element whose bounding box to include.
     * @param {object} [options]
     * @param {HTMLElement} [options.positionedAncestor]  The nearest positioned ancestor.
     * @param {boolean} [options.visibleAncestor]         None of this element's ancestors have hidden overflow.
     */
    function unionBoundingBoxes(element, { positionedAncestor, visibleAncestor=true }={}) {
      const style = getComputedStyle(element);
      const isPositioned = style.position !== "static";
      const overflowVisible = style.overflow === "visible";
      if ( isPositioned && overflowVisible ) positionedAncestor = element;
      // An absolutely positioned child might be visible if its closest positioned ancestor has visible overflow.
      // A relatively positioned child might be visible if none of its parents have hidden overflow.
      const isVisible = (positionedAncestor && (style.position === "absolute")) || (isPositioned && visibleAncestor);
      const { x, y, width, height } = element.getBoundingClientRect();
      if ( isVisible && width && height ) {
        const rect = new PIXI.Rectangle(x, y, width, height);
        if ( bb ) bb.enlarge(rect);
        else bb = rect;
      }
      // If an element's children are visible outside its bounding box, compute their bounding boxes too and add them to
      // the union.
      const unionChildren = (!isPositioned && positionedAncestor) || (isPositioned && overflowVisible);
      if ( !overflowVisible ) visibleAncestor = false;
      if ( unionChildren ) {
        for ( const child of element.children ) unionBoundingBoxes(child, { positionedAncestor, visibleAncestor });
      }
    }

    unionBoundingBoxes(this.element);
    return bb;
  }

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

  /**
   * Handle re-attaching to the main workspace.
   * @this {ApplicationV2}
   */
  static #onAttach() {
    return this.attachWindow();
  }

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

  /**
   * Handle detaching from the main workspace.
   * @this {ApplicationV2}
   */
  static #onDetach() {
    return this.detachWindow();
  }

  /* -------------------------------------------- */
  /*  Other Public Methods                        */
  /* -------------------------------------------- */

  /**
   * 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");
    // Apps in detaching windows do not have a max-width and max-height to transition away from. We give them this via
    // the .minimizing class, but we must wait at least one frame for it to apply before we then override it on the
    // following lines.
    await new Promise(resolve => requestAnimationFrame(resolve));
    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 ( this.id === this.window.windowId ) return this.element.ownerDocument.defaultView.focus();
    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
    this.element.ownerDocument.defaultView?.focus();
  }

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

  /**
   * 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 ( foundry.utils.isElementInstanceOf(t, "button") ) 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;

    // Re-fit the application to the newly-activated tab's automatic dimensions.
    if ( updatePosition ) this._refit();
  }

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

  /**
   * 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 ( this.constructor.emittedEvents.includes(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 += "{}";
    for ( const cls of this.constructor.inheritanceChain() ) {
      if ( !cls.name ) continue;
      Hooks$1.callAll(hookName.replace("{}", cls.name), this, ...hookArgs);
      if ( !parentClassHooks ) break;
    }
  }

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

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

  /**
   * Actions performed after the Application has been re-attached to the main workspace.
   * Registered child Applications are re-attached automatically after this method returns.
   * @param {Document} from  The Application's former host document. This document's window may have been closed.
   * @param {Document} to    The main workspace document.
   * @protected
   */
  _onAttach(from, to) {}

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

  /**
   * Actions performed after the Application has been detached from the main workspace.
   * Registered child Applications are moved into the same detached window automatically after this method returns.
   * @param {Document} from  The main workspace document.
   * @param {Document} to    The Application's new host document.
   * @protected
   */
  _onDetach(from, to) {}

  /* -------------------------------------------- */
  /*  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("auxclick", 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));
      new ContextMenu.implementation(this.#element, '.header-control[data-action="toggleControls"]', [], {
        eventName: "click", fixed: true, jQuery: false, relative: "target",
        onOpen: () => ui.context.menuItems = Array.from(this._headerControlContextEntries())
      });
    }

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

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

  /**
   * 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();
        break;
      case "tab":
        this._onClickTab(event);
        break;
      case "toggleControls":
        event.stopPropagation();
        event.preventDefault();
        break;
      default: {
        let handler = this.options.actions[action];

        // 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 ( typeof handler === "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                              */
  /* -------------------------------------------- */

  /**
   * Iterate over the instances of this Application.
   * @returns {Generator<typeof this>}
   * @yields {typeof this}
   */
  static *instances() {
    for ( const app of foundry.applications.instances.values() ) {
      if ( app instanceof this ) yield app;
    }
  }

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

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

/**
 * @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;
      const partStates = {};
      const entries = Object.entries(result);

      // Sync part states first while they are still in the DOM.
      for ( const [partId, htmlElement] of entries ) {
        const part = partInfo[partId];
        const state = (partStates[partId] ||= {});
        state.priorElement = part.root ? content : content.querySelector(`[data-application-part="${partId}"]`);
        if ( state.priorElement ) this._preSyncPartState(partId, htmlElement, state.priorElement, state);
      }

      // Replace each part with its newly-rendered version.
      for ( const [partId, htmlElement] of entries ) {
        const part = partInfo[partId];
        const state = partStates[partId];
        const element = content.contains(state.priorElement) ? state.priorElement :
          content.querySelector(`[data-application-part="${partId}"]`);

        // Append or replace the rendered HTML element
        if ( !element ) content.append(htmlElement);
        else if ( part.root ) element.replaceChildren(...htmlElement.children);
        else element.replaceWith(htmlElement);

        // Sync with prior state and attach interactivity
        if ( state.priorElement ) {
          this._syncPartState(partId, part.root ? element : htmlElement, state.priorElement, state);
        }
        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
      let focus = priorElement.querySelector(":focus");
      if ( focus?.id ) state.focus = `#${CSS.escape(focus.id)}`;
      else {
        focus = focus?.closest("[name]");
        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 el = selector === "" ? priorElement : priorElement.querySelector(selector);
        if ( el ) state.scrollPositions.push([selector, el.scrollTop, el.scrollLeft]);
      }

      // Details disclosure
      state.details = {};
      for ( const details of priorElement.querySelectorAll("details[data-sync]") ) {
        state.details[details.dataset.sync] = details.open;
      }
    }

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

    /**
     * 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 [selector, scrollTop, scrollLeft] of state.scrollPositions ) {
        const el = selector === "" ? newElement : newElement.querySelector(selector);
        if ( el ) Object.assign(el, {scrollTop, scrollLeft});
      }
      for ( const [id, open] of Object.entries(state.details) ) {
        const details = newElement.querySelector(`details[data-sync="${id}"]`);
        if ( details ) details.open = open;
      }
    }

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

    /** @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().stripDiacritics();
  }

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

  /**
   * 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 ( typeof docValue.equals === "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"|"texture"|"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 favorite folders
 * @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.
 * @property {ClientDocument} [document]   A Document instance associated with this FilePicker.
 */

/**
 * @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", "texture", "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 _loc(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 "texture": return Object.keys(CONST.TEXTURE_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, _loc(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 = _loc("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 = _loc("FILES.DirectoryName.Label");
    const placeholder = _loc("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: "COMMON.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
    let safeTarget;
    if ( target === CONST.DEFAULT_TOKEN ) {
      const remembered = this.constructor.LAST_BROWSED_DIRECTORY;
      const s3Match = this.constructor.matchS3URL(remembered);
      safeTarget = s3Match ? s3Match.groups.key : remembered;
    }
    else safeTarget = 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;
    const endpoint = game.data.files.s3?.endpoint;
    this.constructor.LAST_BROWSED_DIRECTORY = (source === "s3") && endpoint && result.bucket
      ? `${endpoint.protocol}//${result.bucket}.${endpoint.host}/${result.target}`
      : 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);
    context.tabClasses = "top-tabs";
    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;
      let icon;
      if ( foundry.helpers.media.VideoHelper.hasVideoExtension(path) ) {
        icon = "fa-solid fa-file-video";
      } else if ( foundry.audio.AudioHelper.hasAudioExtension(path) ) {
        icon = "fa-solid fa-file-audio";
      } else if ( foundry.helpers.media.ImageHelper.hasImageExtension(path) ) {
        img = path;
        icon = "fa-solid fa-file-image";
      } else if ( foundry.data.validators.hasFileExtension(path, Object.keys(CONST.TEXTURE_FILE_EXTENSIONS)) ) {
        icon = "fa-solid fa-file-image";
      } else {
        icon = "fa-solid fa-file";
      }
      return {
        name: decodeURIComponent(path.split("/").pop()),
        url: path,
        img,
        icon
      };
    }).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 ( !foundry.utils.isElementInstanceOf(button, "button") ) 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 {ReplaceFormulaDataOptions, 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(_loc("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(_loc("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={}) {
    return new this.defaultImplementation(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.dice.RollResolver.instances() ) {
      if ( 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(_loc("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);
    });
  }

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

  /**
   * Regular expression patterns used to match \@attr expressions and isolate terms in Roll.replaceFormulaData
   * @type {{expression: RegExp; term: RegExp}}
   */
  static #replaceFormulaPatterns = {expression: /@{[-.\w]+}|@[-.\w]+/g, term: /^@{?|}?$/g};

  /**
   * 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 {ReplaceFormulaDataOptions} [options] Options which modify formula replacement
   * @param {number} _r                      An internal recursion tracker
   * @returns {string}
   */
  static replaceFormulaData(formula, data, options={}, _r=0) {
    const {missing, warn=false, recursive=false} = options;
    return formula.replace(Roll.#replaceFormulaPatterns.expression, match => {
      const term = match.replace(Roll.#replaceFormulaPatterns.term, "");
      let value = foundry.utils.getProperty(data, term);
      if ( value == null ) {
        if ( warn ) console.warn(_loc("DICE.WarnMissingData", {match}));
        return (missing !== undefined) ? String(missing) : match;
      }
      switch ( foundry.utils.getType(value) ) {
        case "string": {
          value = value.trim();
          if ( recursive && (_r < 3) && value.includes("@") ) {
            return this.replaceFormulaData(value, data, options, _r + 1);
          }
          return value;
        }
        case "boolean": return String(Number(value));
        case "number": return String(value);
        case "Array": break;
        case "Set": value = Array.from(value); break;
        case "Map": value = Object.fromEntries(Array.from(value)); break;
        default:
          if ( (typeof value.toString === "function") && (value.toString !== Object.prototype.toString) ) {
            return value.toString();
          }
      }
      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(new RegExp(/@([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.default || 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.messageMode]    A message visibility mode to apply to the resulting message.
   * @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={}, {messageMode, rollMode, create=true}={}) {
    if ( rollMode ) {
      foundry.utils.logCompatibilityWarning("The rollMode option of Roll#toMessage is deprecated in favor of"
        + " messageMode, a string value in CONFIG.ChatMessage.modes", {since: 14, until: 16});
      messageMode = Roll._mapLegacyRollMode(rollMode);
    }
    messageMode ||= game.settings.get("core", "messageMode");

    // Perform the roll, if it has not yet been rolled
    if ( !this._evaluated ) await this.evaluate({allowInteractive: messageMode !== "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.applyMode(messageMode);

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

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

  /**
   * Map a legacy rollMode into a message mode string.
   * @param {string} rollMode
   * @returns {string}
   * @deprecated since v14
   */
  static _mapLegacyRollMode(rollMode) {
    if ( rollMode === "roll" ) return game.settings.get("core", "messageMode");
    return {publicroll: "public", gmroll: "gm", blindroll: "blind", selfroll: "self"}[rollMode] || rollMode;
  }
}

/**
 * @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 ProseMirror.
   * @param {object} options                       Configuration options provided to the Editor init
   * @param {string} [options.engine=prosemirror]  Which rich text editor engine to use.
   * @param {string} content                       Initial HTML or text content to populate the editor with
   * @returns {ProseMirrorEditor}                  The editor instance.
   */
  static async create({engine="prosemirror", ...options}={}, content="") {
    if ( engine === "prosemirror" ) {
      const {target, ...rest} = options;
      return ProseMirrorEditor.create(target, content, rest);
    }
    const config = CONFIG.TextEditor.engines[engine];
    const instance = await config?.create?.(options, content);
    if ( instance ) return instance;
    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
  ]);

  /* -------------------------------------------- */
  /*  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 = typeof rollData === "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 && (typeof onRender === "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.parentElement?.matches("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;
    replaceParent &&= Iterator.from(parent.childNodes).every(node => {
      return (node === target) || ((node.nodeType === Node.TEXT_NODE) && !node.data.trim());
    });
    if ( parent.parentElement && 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 ??= _loc("COMMON.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, _loc("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;
    const cfg = ChatLog.CHAT_COMMANDS[cmd];
    if ( !cfg?.isRoll ) return null;

    // Extract components of the matched command
    const matchedCommand = cfg.isMultiline ? 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]);
    if ( cfg.mode ) a.dataset.mode = cfg.mode;
    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.
   * @param {Document} document  The document to bind listeners to.
   */
  static activateListeners(document) {
    document ??= window.document;
    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);
    const messageMode = foundry.dice.Roll._mapLegacyRollMode(a.dataset.mode); // Legacy mode support until v16
    return roll.toMessage({flavor: a.dataset.flavor, speaker}, {messageMode});
  }

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

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

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

  /**
   * 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 doc = await cls.fromDropData(eventData);
    if ( !doc ) return null;

    // Only request a relative link when the dropped document shares the same root as the relativeTo document
    if ( options.relativeTo ) {
      let docRoot = doc;
      while ( docRoot.parent ) docRoot = docRoot.parent;
      let relRoot = options.relativeTo;
      while ( relRoot.parent ) relRoot = relRoot.parent;
      if ( docRoot !== relRoot ) options = {...options, relativeTo: undefined};
    }

    // Create a UUID link for the document
    return doc._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 {DocumentOwnershipNumber} from "@common/constants.mjs";
 * @import {Application, ApplicationV2} from "@client/applications/api/_module.mjs";
 * @import {ApplicationRenderOptions} from "@client/applications/_types.mjs";
 * @import {DatabaseCreateOperation, DatabaseDeleteOperation} from "@common/abstract/_types.mjs";
 * @import Collection from "@common/utils/collection.mjs";
 * @import {DocumentConstructionContext} from "@common/abstract/_types.mjs";
 * @import {ToCompendiumOptions} from "@client/_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/Document instance that contains/is this Document.
     * @this {ClientDocument}
     * @type {DocumentCollection|EmbeddedCollection|Document|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.
     * @type {boolean}
     * @override
     */
    get inCompendium() {
      return this.parent?.inCompendium ?? (this.documentName === game.packs.get(this.pack)?.documentName);
    }

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

    /**
     * Is this Document persisted?
     *
     * A document is persisted if it has a nonnull UUID that resolves to a document with `fromUuid`.
     * In particular, clones of persisted Documents are also persisted Documents if they have the same ID as the
     * original.
     *
     * This property is false until this document and all its ancestors up to the root document have been initialized
     * and added to their collections.
     * @type {boolean}
     */
    get persisted() {

      // Persisted documents must have an ID
      const id = this.id;
      if ( !id ) return false;

      // A document that doesn't have a collection is not persisted
      const collection = this.collection;
      if ( !collection ) return false;

      // If the document is embedded in an embedded document field...
      if ( collection instanceof foundry.abstract.Document ) {

        // ... the ID must match the parent's embedded document ID
        if ( collection.id !== id ) return false;
      }

      // If the document is in a compendium pack...
      else if ( collection instanceof CompendiumCollection ) {

        // ... the document can be persisted only if its ID is in the index
        if ( !collection.index.has(id) ) return false;
      }

      // If the document ID is not in its world or embedded collection, it is not persisted
      else if ( !collection.has(id) && !collection.invalidDocumentIds.has(id) ) return false;

      // The parent, if it has one, must be persisted for the document to be persisted
      return this.parent?.persisted !== false;
    }

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

    /**
     * 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 {@link CONST.DOCUMENT_OWNERSHIP_LEVELS} object for an enumeration of these levels.
     * @type {DocumentOwnershipNumber}
     *
     * @example Get the permission level the current user has for a document
     * ```js
     * game.user.id; // "dkasjkkj23kjf"
     * actor.ownership; // {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.parent && !this.parent.visible ) return false;
      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;
    }

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

    /**
     * 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[${foundry.utils.buildRelativeUuid(this, 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);

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

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

    /** @override */
    static async _onCreateOperation(documents, operation, user) {

      // Render the sheet for each created document
      if ( !operation.renderSheet || (user.id !== game.user.id) ) return;
      for ( const [i, document] of documents.entries() ) {
        document.sheet?.render(true, {
          renderContext: `create${this.documentName}`,
          renderData: operation.data[i]
        });
      }
    }

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

    /** @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
        };
        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) {
      closingOptions ??= {
        submit: false,
        renderContext: `delete${this.documentName}`,
        renderData: 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 ( typeof fn !== "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 = _loc(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.
     * @param {ApplicationRenderOptions} [renderOptions]  Options to forward to the document sheet's render call.
     * @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
    }={}, renderOptions={}) {
      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 = [];
      const config = CONFIG[this.documentName] ?? {};
      let defaultType = config.defaultType ?? this.schema.fields.type?.getInitialValue();
      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) && !this.metadata.baseTypeAllowed ) continue;
          if ( types && !types.includes(type) ) continue;
          let label = config.typeLabels?.[type];
          label = label && game.i18n.has(label) ? _loc(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 = _loc(this.metadata.label);
      const title = _loc("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,
        typeHint: _loc(config.typeHints?.[type]),
        ...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},
        position: {width: 360},
        render: (event, dialog) => {
          if ( !hasTypes ) return;
          dialog.element.querySelector('[name="type"]').addEventListener("change", e => {
            const type = e.target.value;
            const typeFormGroup = dialog.element.querySelector('[name="type"]')?.closest(".form-group");
            if ( typeFormGroup ) {
              let typeHint = typeFormGroup.querySelector(".hint");
              if ( !typeHint ) {
                typeHint = document.createElement("p");
                typeHint.className = "hint";
                typeFormGroup.append(typeHint);
              }
              typeHint.textContent = _loc(config.typeHints?.[type]);
            }
            const nameInput = dialog.element.querySelector('[name="name"]');
            nameInput.placeholder = cls.defaultName({type, parent, pack});
          });
        },
        ok: {
          label: title,
          callback: async (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});
            const doc = await cls.create(data, { renderSheet: false, ...createOptions });
            renderOptions.renderContext ??= `create${this.documentName}`;
            renderOptions.renderData ??= data;
            doc.sheet.render(true, renderOptions);
            return doc;
          }
        }
      }, 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 = _loc(this.constructor.metadata.label);
      if ( !content ) {
        const question = _loc("COMMON.AreYouSure");
        const warning = _loc("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: `${_loc("DOCUMENT.Delete", {type})}: ${this.name}`
          }
        },
        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), "application/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
      const {_stats: stats, uuid, compendium} = document;
      if ( stats && uuid && !stats.compendiumSource && compendium ) {
        document.updateSource({"_stats.compendiumSource": 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, dropInvalidEmbedded: 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: `${_loc("DOCUMENT.ImportData")}: ${this.name}`},
        position: {width: 400},
        content: await foundry.applications.handlebars.renderTemplate("templates/apps/import-data.hbs", {
          hint1: _loc("DOCUMENT.ImportDataHint1", {document: this.documentName}),
          hint2: _loc("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: "COMMON.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 {ToCompendiumOptions} [options] Additional options which modify how the document is converted
     * @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 ( clearOwnership ) fieldsToClearRecursively.push("ownership", "author");
      if ( clearState ) delete data.active;
      this.constructor._clearFieldsRecursively(data, fieldsToClearRecursively, {callback: (obj, key) => {
        if ( key === "ownership" ) {
          const defaultOwnership = data.ownership?.default;
          if ( typeof defaultOwnership === "number" ) data.ownership = {default: defaultOwnership};
          else delete data.ownership;
        }
        else foundry.utils.deleteProperty(obj, key);
      }});
      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 = _loc(`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) ? `${_loc(typeLabel)}` : "";
        attrs["aria-label"] ??= typeName
          ? _loc("DOCUMENT.TypePageFormat", {type: typeName, page: documentName})
          : documentName;
        dataset.tooltip = "";
        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={}) {
      let content = await this._buildEmbedHTML(config, options);
      for ( const handler of CONFIG[this.documentName].embedHandlers ?? [] ) {
        content = await handler(this, content, 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 ( foundry.utils.isElementInstanceOf(content, 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) {
      if ( this.system instanceof foundry.abstract.TypeDataModel ) this.system.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);
    }

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

    /**
     * @deprecated since v14
     * @ignore
     */
    getRelativeUUID(relative) {
      foundry.utils.logCompatibilityWarning("ClientDocument#getRelativeUUID has been deprecated in favor of foundry.utils.buildRelativeUuid",
        {since: 14, until: 16, once: true});
      return foundry.utils.buildRelativeUuid(this, relative);
    }

  }
  return ClientDocument;
}

/**
 * @import {ApplicationConfiguration, ApplicationRenderOptions} 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 {string} [tooltip]                   The tooltip of the button.
 * @property {boolean} [default]                  Whether this button is autofocused
 * @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.
 * @property {ApplicationRenderOptions} [renderOptions]  Options forwarded to the dialog's render call.
 */

/**
 * 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
 * const fd = await foundry.applications.api.DialogV2.input({
 *   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"
 *   }
 * });
 * // `fd` will be null if the user canceled out of the dialog.
 * if (!fd) {
 *   console.error("User did not make a guess");
 *   return;
 * }
 * const n = Math.ceil(CONFIG.Dice.randomUniform() * 10);
 * // The `name` attributes of the dialog content become the keys of the returned `fd` variable.
 * if ( n === fd.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 });
 * ```
 *
 * ## Constructing the content property
 *
 * The above examples all pass an HTML string for the `content` property of the dialog options; this is a good solution
 * when the text that needs to be rendered is simple, but is subject to HTML cleaning and difficult to maintain for
 * larger dialogs. The following two methods are more powerful and flexible ways of providing the necessary context.
 *
 * 1. Construct HTML elements with JS.
 *
 * Using methods like `const content = document.createElement("div")`, you can provide an
 * [HTMLDivElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDivElement) as the value of `content`.
 * The outermost element must be a plain div, then you can use methods like
 * [`content.insertAdjacentElement`](https://developer.mozilla.org/en-US/docs/Web/API/Element/insertAdjacentElement)
 * or [`content.insertAdjacentHTML`](https://developer.mozilla.org/en-US/docs/Web/API/Element/insertAdjacentHTML).
 * These methods are especially helpful when working with the form field helpers found in
 * {@link foundry.applications.fields}, which return HTML elements.
 *
 * The biggest advantage of this method is that the content is automatically trusted by Foundry and is not subject to
 * HTML cleaning. However, do keep in mind that the element will get stringified, so any listeners or other similar
 * adjustments will not carry forward to the dialog; you must still use the `render` option to attach listeners to the
 * dialog content.
 *
 * 2. Using {@link foundry.applications.handlebars.renderTemplate}
 *
 * For very complex dialogs, you can use `renderTemplate` to turn a handlebars template file into a string that can be
 * passed back to the `content`. If you directly pass the string, Foundry will clean the HTML, so you may still need to
 * use some of the above method to pass in a proper `HTMLDivElement` so Foundry can trust the generated HTML. Consider
 * also if you should instead be using a custom application instead of relying on a dialog.
 */
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 ( foundry.utils.getType(options.content) === "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, tooltip } = 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);
      if ( tooltip ) {
        button.setAttribute("data-tooltip", "");
        button.setAttribute("aria-label", _loc(tooltip));
      }
      button.toggleAttribute("autofocus", isDefault);
      if ( icon ) {
        const i = document.createElement("i");
        i.className = icon;
        button.appendChild(i);
      }
      const span = document.createElement("span");
      span.innerText = _loc(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: "COMMON.Yes", icon: "fa-solid fa-check", callback: () => true
    }, yes), mergeObject({
      action: "no", label: "COMMON.No", icon: "fa-solid fa-xmark", type: "button", 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: "COMMON.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, renderOptions={}, ...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 = typeof close === "function" ? close(event, dialog) : undefined;
        if ( rejectClose ) reject(new Error("Dialog was dismissed without pressing a button."));
        else resolve(result ?? null);
      }, {once: true});
      if ( typeof render === "function" ) {
        dialog.addEventListener("render", event => render(event, dialog));
      }
      dialog.render({ ...renderOptions, 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);
    }
  };
}

/**
 * An HTMLElement implementation which provides extra resilience when being adopted across Documents, working around a
 * Firefox bug that causes them to lose their custom prototypes.
 * https://bugzilla.mozilla.org/show_bug.cgi?id=1502814
 */
class AdoptableHTMLElement extends HTMLElement {
  /**
   * Called when the element is moved into a new Document.
   */
  adoptedCallback() {
    AdoptableHTMLElement.#reapplyPrototype(this);
  }

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

  /**
   * Called when the element is first removed from a Document. In cases where it is then moved into another Document,
   * this callback is invoked before adoptedCallback, so we must also re-apply the prototype here so that subclasses
   * have any disconnectedCallback overrides invoked appropriately.
   */
  disconnectedCallback() {
    AdoptableHTMLElement.#reapplyPrototype(this);
  }

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

  /**
   * Re-apply a custom prototype to an element that may have lost it.
   * @param {HTMLElement} element  The element instance.
   */
  static #reapplyPrototype(element) {
    if ( element instanceof AdoptableHTMLElement ) return;
    const constructor = customElements.get(element.localName);
    Object.setPrototypeOf(element, constructor.prototype);
  }
}

/**
 * A custom HTML element used to wrap secret blocks in HTML content in order to provide additional interactivity.
 */
class HTMLSecretBlockElement extends AdoptableHTMLElement {
  /**
   * 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");
  }

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

  /**
   * Show the button to toggle the revealed state?
   * @type {boolean}
   */
  get revealable() {
    return this.#revealable;
  }

  set revealable(value) {
    this.#revealable = value;
    if ( this.#button ) this.#button.hidden = !this.#revealable;
  }

  #revealable = true;

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

  /** @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");
    button.type = "button";
    button.className = "reveal";
    button.hidden = !this.#revealable;
    button.append(_loc(`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} canImport          Can the Document be imported into the World from a Compendium Collection?
 * @property {boolean} sheetConfig        Allow sheet configuration as a header button
 * @property {boolean} ownershipConfig    Allow ownership 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,
    canImport: true,
    sheetConfig: true,
    ownershipConfig: true,
    actions: {
      configureSheet: DocumentSheetV2.#onConfigureSheet,
      configureOwnership: DocumentSheetV2.#onConfigureOwnership,
      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
      }, {
        icon: "fa-solid fa-user-lock",
        label: "OWNERSHIP.Configure",
        action: "configureOwnership",
        visible: DocumentSheetV2.#canConfigureOwnership
      }]
    }
  };

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

  /**
   * 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 `${_loc(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 document = options.document;
    const suffix = document.uuid ?? foundry.utils.randomID();
    options.uniqueId = `${this.constructor.name}-${suffix.replaceAll(".", "-")}`;
    options.canImport &&= document.inCompendium && !document.isEmbedded
      && CONST.WORLD_DOCUMENT_TYPES.includes(document.documentName) && document.canUserModify(game.user, "create");

    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,
      model: 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 */
  _getFrameButtons(options) {
    const buttons = super._getFrameButtons(options);
    if ( this.document.id ) buttons.push({
      icon: "fa-solid fa-passport",
      label: "APPLICATION.ACTIONS.CopyUuid",
      action: "copyUuid"
    });
    if ( this.options.canImport ) buttons.push({
      icon: "fa-solid fa-download",
      label: "APPLICATION.ACTIONS.ImportDocument",
      action: "importDocument"
    });
    return buttons;
  }

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

  /** @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";

    // Make the copyUuid frame button a drag source for the Document
    const copyUuidButton = frame.querySelector("button[data-action=copyUuid]");
    if ( copyUuidButton ) {
      copyUuidButton.draggable = true;
      copyUuidButton.addEventListener("dragstart", DocumentSheetV2.#onDragStartCopyUuid.bind(this));
    }
    return frame;
  }

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

  /**
   * Disable or reenable all form fields in this application.
   * @param {boolean} disabled Should the fields be disabled?
   * @protected
   */
  _toggleDisabled(disabled) {
    this.element.querySelectorAll("secret-block").forEach(b => b.revealable = !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(_loc("SHEETS.DocumentSheetPrivate", {
      type: _loc(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;
  }

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

  /**
   * Whether it's possible to configure the ownership of this Document.
   * @this {DocumentSheetV2}
   * @returns {boolean}
   */
  static #canConfigureOwnership() {
    if ( !game.user.isGM || !this.options.ownershipConfig || !this.isEditable ) return false;
    const document = this.#document;
    return !!document.collection?.has(document.id) && document.schema.has("ownership");
  }

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

  /**
   * 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;
    this.renderChild(new foundry.applications.apps.DocumentSheetConfig({
      document: this.document,
      position: {
        top: this.position.top + 40,
        left: this.position.left + ((this.position.width - docSheetConfigWidth) / 2)
      }
    }));
  }

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

  /**
   * Handle click events to configure the ownership of this document.
   * @param {PointerEvent} event
   * @this {DocumentSheetV2}
   */
  static #onConfigureOwnership(event) {
    event.stopPropagation(); // Don't trigger other events
    if ( event.detail > 1 ) return; // Ignore repeated clicks

    this.renderChild(new foundry.applications.apps.DocumentOwnershipConfig({
      document: this.document
    }));
  }


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

  /**
   * 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 = _loc(this.document.constructor.metadata.label);
    game.clipboard.copyPlainText(id);
    ui.notifications.info("DOCUMENT.IdCopiedClipboard", {format: {label, type, id}});
  }

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

  /**
   * Handle dragstart events on the copyUuid button, recording drag transfer data for this Document.
   * @param {DragEvent} event
   * @this {DocumentSheetV2}
   */
  static #onDragStartCopyUuid(event) {
    event.dataTransfer.setData("text/plain", JSON.stringify(this.document.toDragData()));
  }

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

  /**
   * 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
      },
      document: this.document
    });
    await fp.browse();
  }

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

  /**
   * Handle importing a document from a compendium pack.
   * Documents imported using this header button specifically keep their existing IDs.
   * @this {DocumentSheetV2}
   */
  static async #onImportDocument() {
    await this.close();
    const {documentName, collection, id} = this.document;
    const options = {clearOwnership: false, dialog: true};
    return game.collections.get(documentName).importFromCompendium(collection, id, {}, options);
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    if ( foundry.utils.isElementInstanceOf(event.target, 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, {applyOperators: true});
      foundry.utils.mergeObject(submitData, updateData, {applyOperators: false});
    }
    this.document.validate({changes: submitData, clean: {addTypes: true, copy: false}, 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<{created?: Document, updated?: Document}>} The result of the form submission that communicates
   *                                              whether a Document was created or updated. It is possible that
   *                                              neither creation nor update occurred.
   * @throws {Error}                              An Error if Document creation or update was prohibited
   * @protected
   */
  async _processSubmitData(event, form, submitData, options={}) {
    const document = this.#document;
    if ( document.collection?.has(document.id) ) {
      const updated = await document.update(submitData, options);
      return updated ? {updated} : {};
    }
    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;
        return {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, 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",
      scrollable: [".scrollable"]
    }
  };

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

  /**
   * 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$15 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Application: ApplicationV2,
  ApplicationV2: ApplicationV2,
  CategoryBrowser: CategoryBrowser,
  Dialog: DialogV2,
  DialogV2: DialogV2,
  DocumentSheet: DocumentSheetV2,
  DocumentSheetV2: DocumentSheetV2,
  HandlebarsApplicationMixin: HandlebarsApplicationMixin
});

/**
 * @import Document from "@common/abstract/document.mjs"
 */

/**
 * The Application responsible for configuring a Placeable document within a parent Scene.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
class PlaceableConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    preview: true
  };

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

  /**
   * The preview of this config.
   * @type {Document|null}
   * @protected
   */
  _preview = null;

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

  /**
   * Was the object controlled when the config was rendered?
   * If so, the controlled state of the object is restored when the config is closed.
   * @type {boolean|null}
   */
  #wasControlled = null;

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

  /** @inheritDoc */
  async _prepareContext(options) {
    if ( options.isFirstRender ) await this._initializePreview();
    const context = await super._prepareContext(options);
    const document = this._preview;
    const scene = document.parent;
    return Object.assign(context, {
      document,
      model: document,
      source: document._source,
      gridUnits: scene?.grid.units || _loc("MEASUREMENT.GridUnits"),
      selectableLevels: scene?.levels.reduce((levels, id) => {
        const level = scene.levels.get(id);
        if ( level ) levels.push(level);
        return levels;
      }, Array.from(scene.availableLevels).reverse()).map(l => ({ value: l.id, label: l.name })) ?? [],
      inputs: {
        createMultiSelectInput: (field, config) => foundry.applications.fields.createMultiSelectInput(config)
      }
    });
  }

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

  /** @inheritDoc */
  async _postRender(context, options) {
    await super._postRender(context, options);

    // Update the preview to match the form data on re-render
    if ( !options.isFirstRender ) {
      const formData = new FormDataExtended(this.form);
      const submitData = this._prepareSubmitData(event, this.form, formData);
      this._previewChanges(submitData);
    }
  }

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

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    if ( !this.options.preview ) return;
    const formData = new FormDataExtended(this.form);
    const submitData = this._prepareSubmitData(event, this.form, formData);
    this._previewChanges(submitData);
  }

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

  /** @inheritDoc */
  async _preClose(options) {
    await super._preClose(options);
    this._resetPreview();
  }

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

  /**
   * Initialize the preview.
   * @returns {Promise<void>}
   * @protected
   */
  async _initializePreview() {
    if ( this.document.rendered && this.document.object.isPreview ) {
      this._preview = this.document;
    }
    else this._preview = await this._createPreview(this._preview?.toObject());
  }

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

  /**
   * Create the preview.
   * @param {object} [data={}]  Additional data which overrides current document data at the time of creation
   * @returns {Promise<Document>}
   * @protected
   */
  async _createPreview(data={}) {
    let preview;
    if ( !this.options.preview || !this.document.rendered ) {
      preview = this.document.clone(data);
      preview._destroyed = true;
    } else {
      const object = this.document.object;
      this.#wasControlled ??= object.controlled; // Keep original state on re-initialization
      const clone = object.clone();
      clone._previewType = "config";
      preview = clone.document;
      preview.updateSource(data);
      await preview.object.draw();
      preview.object.layer._configPreview.addChild(preview.object);
      object.renderFlags.set({refreshState: true});
      preview.object.layer.releaseAll();
    }
    return preview;
  }

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

  /**
   * Destroy the preview.
   * @protected
   */
  _destroyPreview() {
    if ( !this._preview.rendered ) return;
    this._preview.object.destroy({children: true});
    this._preview._original?.renderFlags.set({refreshState: true});
  }

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

  /**
   * Preview changes.
   * @param {object} changes  The changes to preview.
   * @protected
   */
  _previewChanges(changes) {
    this._preview.updateSource(changes);
    if ( this._preview.rendered ) this._preview.object.renderFlags.set({refresh: true});
  }

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

  /**
   * Reset the preview.
   * @protected
   */
  _resetPreview() {
    this._destroyPreview();
    this._preview = null;
    if ( this.options.preview && this.document.rendered && !this.document.object.isPreview ) {
      const object = this.document.object;
      object.renderFlags.set({refreshState: true});
      if ( this.#wasControlled && !object.layer.controlled.length ) {
        object.control({force: true, releaseOthers: false});
      }
    }
    this.#wasControlled = null;
  }
}

/**
 * @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";
 * @import Level from "../level.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.viewed || !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?.destroyed === false;
    }

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

    /** @override */
    get visible() {
      if ( game.user.isGM ) return true;
      if ( this.schema.has("author") && this.author?.isSelf ) return true;
      if ( this.hidden ) return false;
      return this.testUserPermission(game.user, "OBSERVER");
    }

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

    /**
     * Is this CanvasDocument viewed, i.e. included in the viewed Level?
     * @type {boolean}
     */
    get viewed() {
      return !!this.parent?._view && this.includedInLevel(this.parent._view);
    }

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

    /**
     * Is this CanvasDocument included in a given Level?
     * @param {string|Level} level    The Level or ID
     * @returns {boolean}
     */
    includedInLevel(level) {
      if ( !this.schema.has("levels") || !this.levels.size ) return true;
      let id;
      if ( typeof level === "string" ) id = level;
      else if ( this.parent && (level.parent === this.parent) ) id = level.id;
      else return false;
      return this.levels.has(id);
    }

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

    /**
     * Is this CanvasDocument located in a given Level?
     * @param {string|Level} level    The Level or ID
     * @returns {boolean}
     */
    locatedInLevel(level) {
      return this.includedInLevel(level);
    }

    /* -------------------------------------------- */
    /*  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) {

      // Create placeable object before the sheet is rendered in super._onCreate so that the preview of
      // the config is created because this.rendered is already true
      const object = this.object;
      super._onCreate(data, options, userId);
      if ( !object ) return;
      this.layer.objects?.addChild(object);
      object.draw().then(() => {
        object._onCreate(data, options, userId);
      });
    }

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

    /** @inheritDoc */
    static async _onCreateOperation(documents, operation, user) {

      // Control the object of each created document
      if ( operation.controlObject && (user.id === game.user.id) ) {
        let controlledObject = false;
        for ( const document of documents ) {
          if ( !document.rendered ) continue;
          document.object.control({isNew: true, releaseOthers: !controlledObject, renderSidebar: false});
          controlledObject = true;
        }
        if ( controlledObject ) {
          ui.placeables.render();
          if ( game.activeTool === "select" ) ui.placeablesPalette?.render();
        }
      }

      return super._onCreateOperation(documents, operation, user);
    }

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

    /** @inheritDoc */
    _onUpdate(changed, options, userId) {
      for ( const app of Object.values(this.apps) ) {
        if ( !(app instanceof PlaceableConfig) || !app._preview ) continue;
        app._previewChanges(changed);
        options.animate = false;
      }
      super._onUpdate(changed, options, userId);
      this._object?._onUpdate(changed, options, userId);
      if ( "levels" in changed ) this._refreshViewedState();
    }

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

    /** @inheritDoc */
    static async _onUpdateOperation(documents, operation, user) {

      // Release control if the object becomes hidden/locked and isn't visible/interactable while hidden/locked
      let releasedObject = false;
      let renderPalette = false;
      for ( let i = 0; i < documents.length; i++ ) {
        const document = documents[i];
        const changed = operation.updates[i];
        if ( !document.rendered ) continue;
        const object = document.object;
        if ( !object.controlled ) continue;
        const lockedChanged = "locked" in changed;
        if ( lockedChanged ) renderPalette = true;
        if ( (("hidden" in changed) && document.hidden && !object.isVisible)
          || (("locked" in changed) && document.locked && !object.isInteractable) ) {
          object.release({renderSidebar: false});
          releasedObject = true;
        }
      }
      if ( releasedObject ) {
        ui.placeables.render();
        renderPalette = true;
      }
      if ( renderPalette && (game.activeTool === "select") ) ui.placeablesPalette?.render();

      return super._onUpdateOperation(documents, operation, user);
    }
    /* -------------------------------------------- */

    /**
     * Handle the Document becoming (un)viewed.
     * @returns {Promise<void>}
     * @internal
     */
    async _refreshViewedState() {
      const isViewed = this.viewed;
      const wasViewed = this.rendered;
      if ( isViewed === wasViewed ) return;
      if ( isViewed ) {
        this._destroyed = false;
        const object = this.object;
        this.layer.objects?.addChild(object);
        await object.draw();
      }
      else this._object.destroy({children: true});
    }

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

    /** @inheritDoc */
    _onDelete(options, userId) {
      super._onDelete(options, userId);
      this._object?._onDelete(options, userId);
    }

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

    /** @inheritDoc */
    static async _onDeleteOperation(documents, operation, user) {

      // Release the object of each deleted document
      let releasedObject = false;
      for ( const document of documents ) {
        if ( !document.rendered ) continue;
        if ( document.object.controlled ) {
          document.object.release({renderSidebar: false});
          releasedObject = true;
        }
        document.object.destroy({children: true});
      }
      if ( releasedObject ) {
        ui.placeables.render();
        if ( game.activeTool === "select" ) ui.placeablesPalette?.render();
      }

      // Remove deleted documents from clipboard
      if ( documents.length ) {
        const layer = documents[0].layer;
        layer.clipboard.objects = layer.clipboard.objects.filter(o => !o.document._destroyed);
      }

      return super._onDeleteOperation(documents, operation, user);
    }
  }
  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.parse(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.parse(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);

  // For brevity's sake, especially in languages where Intl.DurationFormat can be verbose, omit seconds after the first
  // minute
  if ( components.second && (components.minute || components.hour || components.day || components.year) ) {
    delete components.second;
  }

  return game.time.earthCalendar.format(components, "ago", {maxTerms: 2, style: "narrow"});
}

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

/**
 * 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.
 * If no element was parsable, the return is `null`.
 * @param {string} htmlString
 * @returns {HTMLCollection|HTMLElement|null}
 */
function parseHTML$1(htmlString) {
  const div = document.createElement("div");
  div.innerHTML = htmlString;
  const children = div.children;
  if ( !children.length ) return null;
  if ( children.length === 1 ) return children[0];
  return children;
}

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

/**
 * 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.parse(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.
   * This method is a convenience wrapper around the underlying {@link WorldCollection#importDocument} workflow.
   * @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 {FromCompendiumOptions|DatabaseCreateOperation} [options] Options passed to
   *                                                                  {@link WorldCollection#importDocument}
   * @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`);
    }
    let document = await pack.getDocument(id);
    if ( !foundry.utils.isEmpty(updateData) ) document = document.clone(updateData, {keepId: true});
    const imported = await this.importDocument(document, options);
    this.directory.activate();
    console.log(`${CONST.vtt} | Importing ${cls.documentName} ${document.name} from ${pack.collection}`);
    return imported;
  }

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

  /** @override */
  _prepareImportDocument(document, options) {
    options.fromCompendium = true;
    return this.fromCompendium(document, 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}={}) {

    // 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 ( clearState ) delete data.active;
    if ( clearOwnership ) {
      const hadOwnership = "ownership" in data;
      this.documentClass._clearFieldsRecursively(data, ["ownership"]);
      if ( hadOwnership ) data.ownership = {default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE};
    }
    else {
      data.ownership &&= Object.entries(data.ownership).reduce((ownership, [key, level]) => {
        if ( (key === "default") || game.users.has(key) ) ownership[key] = level;
        return ownership;
      }, {});
    }
    data.ownership ??= {};
    data.ownership[game.user.id] = CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return data;
  }

  /* -------------------------------------------- */
  /*  Sheet Registration Methods                  */
  /* -------------------------------------------- */

  /**
   * Register a Document sheet class as a candidate which can be used to display Documents of a given type.
   * See {@link 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$14 = /*#__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";
}

/** @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 = _loc("COMMON.AreYouSure");
    const warning = _loc("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();
      }
    });
  }
}

/**
 * @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";
 * @import {DataField} from "@client/data/fields.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,
    ownershipConfig: 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 _loc("SHEETS.ConfigureTitle", {prefix: _loc(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 ) {
      themes.defaults ??= {};
      themes.defaults[documentName] ??= {};
      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");
        setting[documentName] ??= {};
        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 ( !isSubclass(sheetClass, foundry.appv1.api.Application)
      && !isSubclass(sheetClass, foundry.applications.api.DocumentSheetV2)) {
      throw new Error(`${sheetClass.name} must be either a foundry.appv1.api.Application or a`
        + " foundry.applications.api.DocumentSheetV2");
    }
    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 ( typeof label === "function" ) label = label();
    else if ( label ) label = _loc(label);
    else label = id;
    const classes = CONFIG[documentClass.documentName]?.sheetClasses;
    const defaultSheets = game.settings.get("core", "sheetClasses");
    if ( !foundry.utils.isPlainObject(classes) ) 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.isPlainObject(classes) ) 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;
      }
    }
  }
}

/**
 * @import {AutocompleteCallback, AutocompleteEntry} from "./_types.mjs";
 * @import {ApplicationPosition} from "../_types.mjs";
 */

/**
 * A widget that provides a drop-down list of autocompletion options.
 */
class Autocomplete {
/**
 * @param {object} [options]
 * @param {AutocompleteCallback} [options.onSelect]  Callback to fire when an entry is selected and committed.
 */
  constructor({onSelect}={}) {
    this.#onCommit = onSelect;
  }

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

  /**
   * The auto-complete menu.
   * @type {HTMLMenuElement}
   */
  get element() {
    return foundry.applications.detached.querySelector("#autocomplete");
  }

  /**
   * Callback to fire when an entry is committed.
   * @type {AutocompleteCallback}
   */
  #onCommit;

  /**
   * The matched prefix that triggered the autocomplete menu.
   * @type {string}
   */
  #prefix;

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

  /**
   * Commit a choice.
   */
  commit() {
    const item = this.element.querySelector("li.active");
    if ( item ) this.#onCommit?.(item.dataset.identifier, item.textContent, {prefix: this.#prefix});
    this.dismiss();
  }

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

  /**
   * Dismiss the auto-complete menu.
   */
  dismiss() {
    this.element?.remove();
  }

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

  /**
   * Move the current auto-complete selection.
   * @param {number} increment  The number of entries to advance the selection. A negative number moves up, and a
   *                            positive number moves down.
   */
  select(increment=0) {
    const selected = this.element.querySelector("li.active");
    const nonDisabled = Array.from(this.element.children).filter(c => !("disabled" in c.dataset));

    // Case 1 - Nothing currently selected.
    if ( !selected ) {
      nonDisabled[increment < 0 ? nonDisabled.length - 1 : 0]?.classList.add("active");
      return;
    }

    // Case 2 - No change in selection.
    if ( !increment ) return;

    // Case 3 - Move selection up or down from current selection.
    selected.classList.remove("active");
    const selectedIndex = (nonDisabled.indexOf(selected) + increment) % nonDisabled.length;
    nonDisabled.at(selectedIndex)?.classList.add("active");
  }

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

  /**
   * Activate an auto-complete menu.
   * @param {HTMLElement} target                               The element the autocomplete is being rendered for.
   * @param {AutocompleteEntry[]} entries                      The entries to render.
   * @param {object} [options]
   * @param {string} [options.prefix]                          The matched prefix that triggered the autocomplete menu.
   * @param {Partial<ApplicationPosition>} [options.position]  Position the menu. If omitted, the menu is positioned
   *                                                           underneath the target.
   */
  activate(target, entries, { prefix, position }={}) {
    if ( !target || !entries?.length ) return;
    const document = target.ownerDocument;
    ui.autocomplete = this;
    if ( this.element ) document.body.append(this.element);
    else this.#createElement(document);
    this.element.classList.add("scrollable");
    this.element.classList.remove("themed", "theme-light", "theme-dark");
    const themed = target.closest(".themed") ?? document.body;
    const [, theme] = themed.className.match(/(?:^|\s)(theme-\w+)/) ?? [];
    if ( theme ) this.element.classList.add("themed", theme);
    position ??= {};
    const { bottom, left } = target.getBoundingClientRect();
    position.top ??= bottom;
    position.left ??= left;
    this.element.style.top = `${position.top}px`;
    this.element.style.left = `${position.left}px`;
    this.#render(entries);
    this.#prefix = prefix;
    this.element.showPopover();
  }

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

  /**
   * Insert the autocomplete element into the DOM.
   * @param {Document} document  The document to append the menu to.
   */
  #createElement(document) {
    const menu = document.createElement("menu");
    Object.assign(menu, { id: "autocomplete", popover: "manual" });
    menu.addEventListener("click", this.#onClickEntry.bind(this));
    menu.addEventListener("mouseover", this.#onMouseOverEntry.bind(this));
    document.body.append(menu);
  }

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

  /**
   * Render menu entries.
   * @param {AutocompleteEntry[]} entries  The entries.
   */
  #render(entries) {
    this.element.replaceChildren(...entries.map(({label, identifier, disabled}) => {
      const item = document.createElement("li");
      item.dataset.identifier = identifier;
      if ( disabled ) {
        item.dataset.disabled = "";
        item.classList.add("disabled");
      }
      item.append(label);
      return item;
    }));
  }

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

  /**
   * Handle clicking an entry.
   */
  #onClickEntry() {
    this.commit();
  }

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

  /**
   * Handle hovering an entry.
   * @param {PointerEvent} event  The originating event.
   */
  #onMouseOverEntry(event) {
    const target = event.target.closest("li");
    if ( !target ) return;
    for ( const item of this.element.children ) {
      item.classList.toggle("active", (item === target) && !("disabled" in item.dataset));
    }
  }

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

  /**
   * Handle keyboard navigation of the menu.
   * @param {KeyboardEvent} event  The originating event.
   */
  static #onKeyDown(event) {
    if ( !ui.autocomplete?.element ) return;

    switch ( event.key ) {
      case "Escape":
        ui.autocomplete.dismiss();
        break;

      case "Enter": case "Tab":
        ui.autocomplete.commit();
        break;

      case "ArrowDown": case "ArrowUp":
        ui.autocomplete.select(event.key === "ArrowDown" ? 1 : -1);
        break;

      default: return;
    }

    event.preventDefault();
    event.stopPropagation();
  }

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

  /**
   * Bind global autocomplete listeners to a given document.
   * @param {Document} document  The document.
   */
  static activateListeners(document) {
    document ??= window.document;
    document.addEventListener("keydown", Autocomplete.#onKeyDown, { capture: true });
  }

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

  /**
   * Retrieve the configured Autocomplete implementation.
   * @type {typeof Autocomplete}
   */
  static get implementation() {
    let Class = CONFIG.ux.Autocomplete;
    if ( !foundry.utils.isSubclass(Class, Autocomplete) ) {
      console.warn("Configured Autocomplete override must be a subclass of Autocomplete.");
      Class = Autocomplete;
    }
    return Class;
  }
}

/**
 * @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 ( typeof fn === "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 ( typeof fn === "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;
  }
}

/**
 * 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 AdoptableHTMLElement {

  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() {
    super.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?.();
  }

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

  /** @inheritDoc */
  focus(options) {
    if ( this._primaryInput ) this._primaryInput.focus?.(options);
    else super.focus?.(options);
  }
}

/**
 * @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
  if ( input instanceof HTMLCollection ) input = Array.from(input);
  else if ( !Array.isArray(input) ) input = [input];
  const formInputs = input.filter(el => (["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)
    || (el instanceof AbstractFormInputElement)));
  if ( rootId ) {
    for ( const [i, el] of formInputs.entries() ) {
      if ( el.hasAttribute("id") ) continue;
      const id = [rootId, el.name, formInputs.length > 1 ? i : ""].filterJoin("-");
      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 ? _loc(label) : label;
  const labelFor = formInputs.at(0)?.getAttribute("id");
  if ( labelFor ) lbl.setAttribute("for", labelFor);
  if ( units ) lbl.insertAdjacentHTML("beforeend", ` <span class="units">(${_loc(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 ? _loc(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";
  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 editorConfig = CONFIG.TextEditor.engines[engine];
  if ( typeof editorConfig?.render === "function" ) return editorConfig.render(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-edit"></i></a>';
  editorHTML += `<div class="editor-content" ${dataset}>${config.value ?? ""}</div>`;
  editor.innerHTML = editorHTML;
  return editor;
}

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

/**
 * Create a `<multi-select>`, `<multi-checkbox>`, or `<autocomplete-tags>` 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) {
  let tagName;
  switch ( config.type ) {
    case "autocomplete": tagName = "autocomplete-tags"; break;
    case "checkboxes": tagName = "multi-checkbox"; break;
    default: tagName = "multi-select"; break;
  }
  const select = document.createElement(tagName);
  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";

  // 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"|"autocomplete"} [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");
  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.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.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 = _loc(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 ( Array.isArray(config.groups) ) {
    for ( let group of config.groups ) {
      const options = groups[group] ?? [];
      delete groups[group];
      if ( config.localize ) group = _loc(group);
      result.push({group, options});
    }
  }

  // Add remaining groups
  for ( let [groupName, options] of Object.entries(groups) ) {
    if ( groupName && config.localize ) groupName = _loc(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 ? _loc(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.name ) {
    if ( config.named === false ) input.dataset.name = config.name;
    else input.name = config.name;
  }
  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 = foundry.utils.getType(element) === "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;
  }
}

/**
 * @import {ContextMenuEntry, ContextMenuOptions} from "./context-menu.mjs";
 */

/**
 * @typedef _FilterMenuOptions
 * @property {() => ContextMenuEntry[]} [menuItems]  The menu item generator.
 */

/**
 * @typedef {ContextMenuOptions & _FilterMenuOptions} FilterMenuOptions
 */

/**
 * A specialized subclass of ContextMenu designed for displaying a menu of filter options.
 * @extends {ContextMenu}
 */
class FilterMenu extends ContextMenu {
  /**
   * @param {HTMLElement} container        The HTML element that contains the context menu targets.
   * @param {string} selector              A CSS selector which activates the context menu.
   * @param {FilterMenuOptions} [options]  Additional options to configure the context menu.
   */
  constructor(container, selector, options={}) {
    options = foundry.utils.mergeObject({
      eventName: "click", fixed: true, jQuery: false, relative: "target", closeOnSelect: false
    }, options);
    super(container, selector, [], options);
    this.#options = options;
  }

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

  /**
   * Configuration options.
   * @type {FilterMenuOptions}
   */
  #options;

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

  /** @inheritDoc */
  async _preRenderEntries(options={}) {
    await super._preRenderEntries(options);
    this.menuItems = this.#options.menuItems?.() ?? [];
  }

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

  /**
   * Retrieve the configured FilterMenu implementation.
   * @type {typeof FilterMenu}
   */
  static get implementation() {
    let Class = CONFIG.ux.FilterMenu;
    if ( !foundry.utils.isSubclass(Class, FilterMenu) ) {
      console.warn("Configured FilterMenu override must be a subclass of FilterMenu.");
      Class = FilterMenu;
    }
    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});
  }
}

/**
 * @callback AutocompleteCallback
 * @param {string} identifier      The identifier of the entry that was selected.
 * @param {string} label           The label of the entry that was selected.
 * @param {object} options
 * @param {string} options.prefix  The matched prefix that originally triggered this autocomplete menu.
 */

/**
 * @typedef AutocompleteEntry
 * @property {string} identifier  A unique identifier for the entry.
 * @property {string} label       A human-readable label for the entry.
 * @property {boolean} [disabled] Is this entry disabled?
 */

var _types$k = /*#__PURE__*/Object.freeze({
  __proto__: null
});

var _module$13 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Autocomplete: Autocomplete,
  ContextMenu: ContextMenu,
  DragDrop: DragDrop,
  Draggable: Draggable,
  FilterMenu: FilterMenu,
  FormDataExtended: FormDataExtended,
  HTMLSecret: HTMLSecret,
  ProseMirrorEditor: ProseMirrorEditor,
  SearchFilter: SearchFilter,
  Tabs: Tabs,
  TextEditor: TextEditor$1,
  types: _types$k
});

/**
 * @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 _loc(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: "APPLICATION.ACTIONS.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 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 && (typeof input.focus === "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.");
  }

  /* -------------------------------------------- */
  /*  Editors                                     */
  /* -------------------------------------------- */

  /**
   * Activate a named 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<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 ?? "prosemirror");
    editor.changed = false;
    editor.active = true;
    Hooks.callAll("activateEditorLegacy", editor, options, initialContent);
    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 || "prosemirror";
    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});
    else if ( !(engine in CONFIG.TextEditor.engines) ) {
      console.warn(`Unrecognized text editor engine '${engine}'`);
      return;
    }

    // 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: 16, once: true});
    return this.#filepickers;
  }

  #filepickers = [];

  /**
   * @deprecated since v12
   * @ignore
   */
  _activateFilePicker(event) {
    foundry.utils.logCompatibilityWarning("FormApplication#_activateFilePicker is deprecated without replacement",
      {since: 12, until: 16, 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: 16, 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 `${_loc(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 = _loc("SHEETS.DocumentSheetPrivate", {
        type: _loc(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 = _loc(this.object.constructor.metadata.label);
    const idLink = document.createElement("a");
    idLink.classList.add("document-id-link");
    idLink.ariaLabel = _loc("APPLICATION.ACTIONS.CopyUuid");
    idLink.dataset.tooltip = "";
    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();
          const {collection, id} = this.document;
          const options = {clearOwnership: false, dialog: true};
          return game.collections.get(documentName).importFromCompendium(collection, id, {}, options);
        }
      });
    }

    // Sheet Configuration
    if ( this.options.sheetConfig && this.isEditable && (this.document.getFlag("core", "sheetLock") !== true) ) {
      buttons.unshift({
        label: "DOCUMENT.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";
 * @import {ApplicationRenderOptions} from "@client/applications/_types.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.
   * @param {object} [options]
   * @param {ApplicationV2} [options.parent]                    A parent Application to register the dialog as a child
   *                                                            of.
   * @param {ApplicationRenderOptions} [options.renderOptions]  Options to forward to the dialog render call.
   * @returns {Promise<void>}
   */
  static async showDialog(doc, { parent, renderOptions={} }={}) {
    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;
    }
    const dialog = new foundry.applications.sheets.journal.ShowToPlayersDialog({ document: doc });
    if ( parent ) parent.renderChild(dialog, renderOptions);
    else dialog.render({ ...renderOptions, 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(_loc("JOURNAL.ShowBadPermissions"));
    const strings = Object.fromEntries(["all", "authorized", "selected"].map(k => [k, _loc(`JOURNAL.SHOW.${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, _loc(`JOURNAL.SHOW.${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);
  }
}

const HEADINGS = new Set(["H1", "H2", "H3", "H4", "H5", "H6"]);

/**
 * @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 ( foundry.utils.getType(heading) === "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 ( HEADINGS.has(element.tagName) && !("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 hasAnchor = eventData.anchor?.slug && eventData.anchor?.name;
    if ( hasAnchor ) label ??= foundry.utils.escapeHTML(eventData.anchor.name);
    const link = super._createDocumentLink(eventData, {relativeTo, label});
    return hasAnchor ? link.replace("]", `#${eventData.anchor.slug}]`) : link;
  }

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

  /** @inheritDoc */
  _onClickDocumentLink(event) {
    const { sheet } = this.parent;
    if ( !sheet ) return;
    const isV2 = sheet instanceof foundry.applications.api.ApplicationV2;
    const isDetached = (window.document !== event.target.ownerDocument);
    const target = event.target.closest("a[data-link]");
    if ( !sheet.rendered && isV2 && isDetached ) return sheet.detachWindow({
      force: true, pageId: this.id, anchor: target.dataset.hash
    });
    return sheet.render(true, {pageId: this.id, anchor: target.dataset.hash});
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( "text.content" in foundry.utils.flattenObject(changed) ) this._toc = null;
    if ( !canvas.ready ) return;
    if ( ["name", "ownership"].some(k => k in changed) ) {
      canvas.notes.placeables.filter(n => n.page === this).forEach(n => n.draw());
    }
  }

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

  /** @inheritDoc */
  async _buildEmbedHTML(config, options={}) {
    const embed = await super._buildEmbedHTML(config, options);
    if ( !embed ) {
      if ( this.type === "text" ) return this._embedTextPage(config, options);
      else if ( this.type === "image" ) return this._embedImagePage(config, options);
    }
    return embed;
  }

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

  /** @inheritDoc */
  async _createFigureEmbed(content, config, options) {
    const figure = await super._createFigureEmbed(content, config, options);
    if ( (this.type === "image") && config.caption && !config.label && this.image.caption ) {
      const caption = figure.querySelector("figcaption > .embed-caption");
      if ( caption ) caption.innerText = this.image.caption;
    }
    return figure;
  }

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

  /**
   * Embed text page content.
   * @param {DocumentHTMLEmbedConfig & EnrichmentOptions} config  Configuration for embedding behavior. This can include
   *                                                              enrichment options to override those passed as part of
   *                                                              the root enrichment process.
   * @param {EnrichmentOptions} [options]     The original enrichment options to propagate to the embedded text page's
   *                                          enrichment.
   * @returns {Promise<HTMLElement|HTMLCollection|null>}
   * @protected
   *
   * @example Embed the content of the Journal Entry Page as a figure.
   * ```@Embed[.yDbDF1ThSfeinh3Y classes="small right"]{Special caption}```
   * becomes
   * ```html
   * <figure class="content-embed small right" data-content-embed
   *         data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y">
   *   <p>The contents of the page</p>
   *   <figcaption>
   *     <strong class="embed-caption">Special caption</strong>
   *     <cite>
   *       <a class="content-link" draggable="true" data-link
   *          data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y"
   *          data-id="yDbDF1ThSfeinh3Y" data-type="JournalEntryPage" data-tooltip="Text Page">
   *         <i class="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) ) {
      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 [{
      label: "SIDEBAR.Edit",
      icon: "fa-solid fa-edit",
      visible: li => this.isEditable && getPage(li)?.canUserModify(game.user, "update"),
      onClick: (event, li) => getPage(li)?.sheet.render(true)
    }, {
      label: "SIDEBAR.Delete",
      icon: "fa-solid fa-trash",
      visible: li => this.isEditable && getPage(li)?.canUserModify(game.user, "delete"),
      onClick: (event, li) => {
        const bounds = li.getBoundingClientRect();
        return getPage(li)?.deleteDialog({position: {top: bounds.top, left: bounds.right}});
      }
    }, {
      label: "SIDEBAR.Duplicate",
      icon: "fa-regular fa-copy",
      visible: this.isEditable,
      onClick: (event, li) => {
        const page = getPage(li);
        return page.clone({name: _loc("DOCUMENT.CopyOf", {name: page.name})}, {
          save: true, addSource: true
        });
      }
    }, {
      label: "OWNERSHIP.Configure",
      icon: "fa-solid fa-user-lock",
      visible: () => game.user.isGM,
      onClick: (event, 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});
      }
    }, {
      label: "JOURNAL.ActionShow",
      icon: "fa-solid fa-eye",
      visible: li => getPage(li)?.isOwner,
      onClick: (event, li) => {
        const page = getPage(li);
        if ( page ) return Journal.showDialog(page);
      }
    }, {
      label: "SIDEBAR.JumpPin",
      icon: "fa-solid fa-crosshairs",
      visible: li => {
        const page = getPage(li);
        return !!page?.sceneNote;
      },
      onClick: (event, 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);
  }

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

  /**
   * The playlist / playlist sound of the active scene.
   * @type {{playlistId: string|null; playlistSoundId: string|null}}
   */
  #sceneState = {playlistId: null, playlistSoundId: null};

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

  /**
   * Perform one-time initialization to begin playback of audio.
   * @returns {Promise<void>}
   */
  async initialize() {
    const activeScene = game.scenes.active;
    if ( activeScene ) {
      this.#sceneState.playlistId = activeScene.playlist?.id ?? null;
      this.#sceneState.playlistSoundId = activeScene.playlistSound?.id ?? null;
    }
    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
   * @internal
   */
  async _onChangeScene(scene) {
    const {playlist, playlistSound} = scene ?? {};
    const playlistId = playlist?.id ?? null;
    const playlistSoundId = playlistSound?.id ?? null;
    const {playlistId: priorPlaylistId, playlistSound: priorPlaylistSoundId} = this.#sceneState;
    const soundChange = (playlistId !== priorPlaylistId) || (playlistSoundId !== priorPlaylistSoundId);
    if ( !soundChange ) return;
    this.#sceneState.playlistId = playlistId;
    this.#sceneState.playlistSoundId = playlistSoundId;
    const priorPlaylist = this.get(priorPlaylistId);
    if ( priorPlaylist ) {
      const priorPlaylistSound = priorPlaylist.sounds.get(priorPlaylistSoundId);
      if ( priorPlaylistSound ) await priorPlaylistSound.update({playing: false});
      else if ( !priorPlaylistSoundId ) await priorPlaylist.stopAll();
    }
    if ( playlistSound ) await playlistSound.update({playing: true});
    else if ( playlist ) await playlist.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 {object} [options]             Additional options
   * @param {string} [options.level]       The Level ID to begin loading. Defaults to the initial level.
   * @param {boolean} [options.broadcast]  Trigger other connected clients to also preload Scene/Level resources.
   *                                       Default: `false`.
   */
  async preload(sceneId, options={}) {
    if ( typeof options === "boolean" ) {
      const msg = "You are passing the legacy \"push\" boolean to Scenes#preload. This is replaced by the"
        + " \"broadcast\" option, for example game.scenes.preload(sceneId, {broadcast: true}).";
      foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16});
      options = {broadcast: /** @type {boolean} */ options};
    }
    const {level: levelId, broadcast=false} = options;
    if ( broadcast && game.user.isGM ) game.socket.emit("preloadScene", sceneId, levelId);
    const scene = this.get(sceneId);
    if ( !scene ) return;
    const level = scene.levels.get(levelId);
    if ( levelId && !level ) return;
    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(level ?? scene, {expireCache: false}));
    return Promise.all(promises);
  }

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

  /** @override */
  static _activateSocketListeners(socket) {
    socket.on("preloadScene", (sceneId, levelId) => this.instance.preload(sceneId, {level: levelId}));
    socket.on("pullToScene", this.#pullToScene.bind(this));
  }

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

  /**
   * Handle requests pulling the current User to a specific Scene
   * @param {string} sceneId
   * @param {object} [viewOptions]
   */
  static #pullToScene(sceneId, viewOptions) {
    const scene = game.scenes.get(sceneId);
    if ( scene ) scene.view(viewOptions);
  }

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

    // Update User idle status
    if (typeof activityData.idle === "boolean") {
      const stateChanged = user.idle !== activityData.idle;
      Object.defineProperty(user, "idle", {value: activityData.idle});
      if ( stateChanged ) ui.players?._onChangeIdleStatus(user);
    }
    if ( user.isSelf ) return;

    // Track the last observed activity time for the user
    user.lastActivityTime = activityData.lastActivityTime;
    let renderNav = false;

    // Update User active state
    const active = 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;
    }
    const levelChange = ("levelId" in activityData) && (activityData.levelId !== user.viewedLevel);
    if ( levelChange ) {
      user.viewedLevel = activityData.levelId;
      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 || (user.viewedScene !== canvas.scene.id) ) {
      canvas.controls.updateCursor(user, null);
      canvas.controls.updateRuler(user, []);
      canvas.tokens._updatePlannedMovements(user, null);
      user._onUpdateTokenTargets([]);
      return;
    }

    // Token targets
    if ( "targets" in activityData ) {
      user._onUpdateTokenTargets(activityData.targets);
    }

    // Everything below requires the same level to be viewed
    if ( user.viewedLevel !== canvas.level.id ) {
      canvas.controls.updateCursor(user, null);
      canvas.controls.updateRuler(user, []);
      canvas.tokens._updatePlannedMovements(user, null);
      return;
    }

    // Cursor position
    if ( "cursor" in activityData ) {
      canvas.controls.updateCursor(user, activityData.cursor);
    }

    // Was it a ping?
    if ( "ping" in activityData ) {
      canvas.controls.handlePing(user, activityData.cursor, activityData.ping);
    }

    // Ruler measurement
    if ( "ruler" in activityData ) {
      canvas.controls.updateRuler(user, activityData.ruler);
    }

    // Token planned movements
    if ( "plannedMovements" in activityData ) {
      canvas.tokens._updatePlannedMovements(user, activityData.plannedMovements);
    }
  }

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

  /**
   * 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 queryContext = {...queryOptions, user};
      const value = await queryHandler(queryData, queryContext);
      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$12 = /*#__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 {ActiveEffect, Combat, Item, Scene, TokenDocument} from "./_module.mjs";
 * @import {ActiveEffectChangeData} from "./_types.mjs";
 * @import {IterableWeakMap, IterableWeakSet} from "@common/utils/_module.mjs";
 * @import {DocumentConstructionContext} from "@common/abstract/_types.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);
    Object.defineProperty(this, "statuses", {value: new Set(), configurable: true, enumerable: true});

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

    /**
     * Track completed core ActiveEffect application phases as a backward compatibility measure for packages calling
     * Actor#applyActiveEffects without a phase argument.
     * @type {Set<string>}
     * @private
     */
    Object.defineProperty(this, "_completedActiveEffectPhases", {value: new Set()});
  }

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

  /** @inheritDoc */
  _initializeSource(source, options={}) {
    source = super._initializeSource(source, options);
    return game.compendiumArt.applyArt(this.constructor, source, options.pack);
  }

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

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

  /**
   * ActiveEffect changes to be applied to Tokens instead of Actors, with each key being a phase
   * @type {Record<string, ActiveEffectChangeData[]>}
   */
  tokenActiveEffectChanges = this.tokenActiveEffectChanges ?? {};

  /* -------------------------------------------- */
  /*  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.
   * @param {string} phase The application phase under which changes are to be applied.
   */
  applyActiveEffects(phase) {
    /** @type {typeof foundry.documents.ActiveEffect} */
    const ActiveEffect = foundry.documents.ActiveEffect.implementation;
    if ( typeof phase !== "string" ) {
      phase = this._completedActiveEffectPhases.has("initial") ? "final" : "initial";
      const message = 'Actor#applyActiveEffects must be called with a string phase identifier, with "initial"'
        + " as the first phase.";
      foundry.utils.logCompatibilityWarning(message, {since: 14, until: 16, once: true});
    }
    else if ( !(phase in ActiveEffect.CHANGE_PHASES) ) {
      const error = new Error(`"${phase}" is not a registered ActiveEffect application phase.`);
      Hooks$1.onError("Actor#applyActiveEffects", error, {log: "error"});
    }
    if ( this._completedActiveEffectPhases.has(phase) ) {
      const error = new Error(`ActiveEffect application phase "${phase}" has already completed and cannot be run again`
        + " in this Actor's data-preparation cycle.");
      Hooks$1.onError("Actor#applyActiveEffects", error, {log: "error"});
      return;
    }
    this._completedActiveEffectPhases.add(phase);

    // Organize non-disabled effects by their application priority
    /** @type {ActiveEffectChangeData[]} */
    const changes = [];
    /** @type {ActiveEffectChangeData[]} */
    const tokenChanges = [];
    const rollData = this.getRollData();
    const dataByEffect = new Map();
    for ( const effect of this.allApplicableEffects() ) {
      if ( !effect.active ) continue;
      const replacementData = effect.getReplacementData(rollData);
      dataByEffect.set(effect, replacementData);
      for ( const change of effect.system.changes ) {
        if ( (change.key === "") || !effect.shouldApplyChange(change, {phase, replacementData}) ) continue;
        const copy = foundry.utils.deepClone(change);
        copy.effect = effect;
        if ( copy.key?.startsWith("token.") ) { // Keep Token changes separate for later application
          copy.key = copy.key.slice(6);
          tokenChanges.push(copy);
        }
        else changes.push(copy);
      }
      if ( phase === "initial" ) {
        for ( const statusId of effect.statuses ) this.statuses.add(statusId);
      }
    }
    changes.sort((a, b) => a.priority - b.priority);
    ActiveEffect._shimChanges(changes);
    this.tokenActiveEffectChanges[phase] = tokenChanges;

    // Apply all changes
    const overrides = {};
    for ( const change of changes ) {
      const replacementData = dataByEffect.get(change.effect) ?? rollData;
      const result = ActiveEffect.applyChange(this, change, {replacementData});
      if ( foundry.utils.isPlainObject(result) ) Object.assign(overrides, result);
    }

    // Expand the set of final overrides
    foundry.utils.mergeObject(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. 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;
    }
    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, that represents the Actor.
   * @param {object} [data={}]            Additional data, such as x, y, rotation, etc. for the created token data
   * @param {Partial<DocumentConstructionContext>} [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;
    options.parent ??= canvas.scene;

    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.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}`;
      }

      if ( tokenData.appendNumber ) {
        // Append the lowest number not in use by a token linked to this actor
        const tokens = options.parent?.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})`;
      }
    }

    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
    const specialStatuses = new Map();
    for ( const statusId of Object.values(CONFIG.specialStatusEffects) ) {
      specialStatuses.set(statusId, this.statuses.has(statusId));
    }

    super.prepareData();
    this.applyActiveEffects("final");

    // 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 */
  prepareBaseData() {
    this._clearData();
  }

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

  /**
   * Clear or replace properties not automatically reset by upstream initialization.
   * @protected
   */
  _clearData() {
    this.overrides = {};
    this.tokenActiveEffectChanges = {};
    this.statuses.clear();
    this._completedActiveEffectPhases.clear();
  }

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

  /** @inheritDoc */
  prepareEmbeddedDocuments() {
    super.prepareEmbeddedDocuments();
    this.applyActiveEffects("initial");
  }

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

  /**
   * 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[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, {parent: this});
    if ( overlay ) effect.updateSource({"flags.core.overlay": true});
    return ActiveEffect.create(effect.toObject(), {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.
   * @param {boolean} [options.concreteOnly]  Limit the results to tokens that exist in their parent scene, excluding
   *                                          ephemeral/unpersisted tokens. This will become the default in v15.
   * @returns {TokenDocument[]}
   */
  getDependentTokens({scenes, linked=false, concreteOnly=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 ) continue;
        if ( concreteOnly && (scene.tokens.get(token.id) !== token) ) continue;
        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 */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    foundry.documents.ActiveEffect.registry.addFromParent(this);
  }

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    if ( "prototypeToken" in changed ) {
      for ( const app of Object.values(this.apps) ) {
        if ( !(app instanceof foundry.applications.sheets.PrototypeTokenConfig) || !app._preview ) continue;
        app._previewChanges(DataFieldOperator.get(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) && !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 */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    foundry.documents.ActiveEffect.registry.deleteFromParent(this);
  }

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

  /** @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 ( parent instanceof foundry.documents.Item ) this.reset();

    // Register created ActiveEffects
    const methodName = (collection === "effects") ? "add" : "addFromParent";
    for ( const descendant of documents ) {
      foundry.documents.ActiveEffect.registry[methodName](descendant);
    }

    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 ( parent instanceof foundry.documents.Item ) this.reset();

    // Register updated ActiveEffects
    const methodName = (collection === "effects") ? "add" : "addFromParent";
    for ( const descendant of documents ) {
      foundry.documents.ActiveEffect.registry[methodName](descendant);
    }

    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 ( 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);
    }
  }

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

  /**
   * Workflows to perform following the update of ActiveEffect durations. This method is called for all users.
   * @param {ActiveEffect[]} effects Effects whose durations were updated
   * @param {string} event The identifier of the event that triggered the duration refresh
   * @param {object} [context] Additional contextual information associated with the duration refresh
   */
  async onUpdateEffectDurations(effects, event, context) {}
};

/**
 * @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) {

  /**
   * Create a shallow Adventure document from a compendium index entry.
   * @param {string} id                   The adventure's document ID.
   * @param {CompendiumCollection} pack   The compendium pack containing the adventure.
   * @returns {Adventure}
   */
  static fromIndex(id, pack) {
    const indexEntry = { ...pack.index.get(id) };
    return this.fromSource(indexEntry, { pack: pack.collection });
  }

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

  /** @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} [importOptions]  Options which configure and customize the import process
   * @returns {Promise<AdventureImportResult>}        The import result
   */
  async import(importOptions={}) {
    const options = { ...importOptions };
    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 = _loc("COMMON.Warning");
      const warning = _loc("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");
    const descriptor = imports[this.uuid] = {
      coreVersion: game.release.version,
      systemVersion: game.system.version,
      options: importOptions,
      importedTime: result.importedTime
    };
    if ( this.collection.metadata.packageType === "module" ) {
      descriptor.moduleVersion = game.modules.get(this.collection.metadata.packageName).version;
    }
    await game.settings.set("core", "adventureImports", imports);

    // Invoke custom postImport 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});
    let importedTime;

    // Create new documents
    let nImported = 0;
    for ( const [documentName, createData] of Object.entries(toCreate) ) {
      const cls = getDocumentClass$1(documentName);
      const options = {
        keepId: true,       // Keep adventure document IDs
        render: false,      // Do not re-render related applications
        renderSheet: false  // Do not render new sheets
      };
      const docs = await cls.createDocuments(createData, options);
      created[documentName] = docs;
      nImported += docs.length;
      importedTime = options.modifiedTime;
      bar.update({pct: nImported / documentCount});
    }

    // Update existing documents
    for ( const [documentName, updateData] of Object.entries(toUpdate) ) {
      const cls = getDocumentClass$1(documentName);
      const options = {
        diff: false,
        recursive: false,
        noHook: true,
        render: false      // Do not re-render related applications
      };
      const docs = await cls.updateDocuments(updateData, options);
      updated[documentName] = docs;
      nImported += docs.length;
      importedTime = options.modifiedTime;
      bar.update({pct: nImported / documentCount});
    }

    // Re-prepare Documents after all have imported.
    for ( const docs of foundry.utils.iterateValues(created) ) {
      for ( const doc of docs ) doc.reset();
    }
    for ( const docs of foundry.utils.iterateValues(updated) ) {
      for ( const doc of docs ) doc.reset();
    }

    bar.update({pct: 1});
    return {created, updated, importedTime};
  }
};

/**
 * @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 _loc("TYPES.Cards.deck");
      case "hand": return _loc("TYPES.Cards.hand");
      case "pile": return _loc("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>${_loc(action, context)}</p>
      </div>`
    };
    const messageMode = game.settings.get("core", "messageMode"); // Apply the current message mode to card actions
    return foundry.documents.ChatMessage.implementation.create(messageData, {messageMode});
  }

  /* -------------------------------------------- */
  /*  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(options);
    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 = Array.isArray(fd.to) ? 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>${_loc(`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 = `${_loc("DOCUMENT.Delete", {type})}: ${this.name}`;
    return foundry.applications.api.DialogV2.wait(foundry.utils.mergeObject({
      window: {title},
      content: `
        <strong>${_loc("CARDS.DeleteCannot")}</strong>
        <p>${_loc("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: "COMMON.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 = _loc(this.metadata.label);
    const title = _loc("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) ? _loc(label) : type];
      }).sort((a, b) => a[1].localeCompare(b[1], game.i18n.lang))),
      typeHint: _loc(CONFIG.Cards.typeHints?.[type]),
      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},
      content,
      render: (event, dialog) => {
        dialog.element.querySelector('[name="type"]').addEventListener("change", e => {
          const type = e.target.value;
          const typeHint = dialog.element.querySelector('[name="type"]')?.closest(".form-group")?.querySelector(".hint");
          if ( typeHint ) typeHint.textContent = _loc(CONFIG.Cards.typeHints?.[type]);
          const nameInput = dialog.element.querySelector('[name="name"]');
          nameInput.placeholder = this.implementation.defaultName({type, parent, pack});
        });
      },
      ok: {
        label: title,
        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 {ChatMessageData, ChatSpeakerData} from "@common/documents/_types.mjs";
 * @import {Actor, Scene, TokenDocument, User} from "./_module.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 ?? _loc("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() {

    // The content of a blind message is not visible to the author unless GM
    if ( this.isAuthor ) {
      if ( this.blind && !game.user.isGM ) return false;
    }

    // The content is not visible to non-recipients of the message
    else if ( this.whisper.length && !this.whisper.includes(game.user.id) ) return false;

    // For the content to be visible the message itself must be visible
    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.includes(game.user.id);
    }
    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;
    }, []);
  }

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

  /**
   * Resolve a mode given message-creation data and a proposed mode.
   * @param {Partial<ChatMessageData>} chatData
   * @param {string} mode The proposed message mode
   * @returns {string} The resolved mode
   */
  static #resolveMode(chatData, mode) {
    if ( !(mode in CONFIG.ChatMessage.modes) ) return game.settings.get("core", "messageMode");
    const hasSpeaker = !!(chatData.speaker?.actor || chatData.speaker?.token);
    return (mode === "ic") && (!hasSpeaker || chatData.rolls?.length) ? "public" : mode;
  }

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

  /**
   * 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`.
   *  - `gm`: `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 {Partial<ChatMessageData>} chatData  Candidate ChatMessage data which should be customized
   * @param {string} [mode]                      The message visibility mode to apply, otherwise apply the default mode
   *                                             stored in client settings.
   * @returns {Partial<ChatMessageData>}         Modified ChatMessage data with the message visibility mode applied
   */
  static applyMode(chatData, mode=game.settings.get("core", "messageMode")) {
    mode = ChatMessage.#resolveMode(chatData, mode);

    // Custom mode handler
    const cfg = CONFIG.ChatMessage.modes[mode];
    if ( typeof cfg.handler === "function" ) {
      cfg.handler(chatData);
      return chatData;
    }

    // Configure whisper targets
    let whisper = chatData.whisper || [];
    if ( mode === "public" ) whisper.length = 0;
    else if ( mode === "self" ) whisper = [game.user.id];
    else if ( !whisper.length && (mode === "gm" || (mode === "blind")) ) whisper = game.users.reduce((arr, u) => {
      if ( u.isGM ) arr.push(u.id);
      return arr;
    }, []);
    chatData.whisper = whisper;
    chatData.blind = mode === "blind";

    // In-Character messages
    if ( mode === "ic" ) chatData.style = CONST.CHAT_MESSAGE_STYLES.IC;

    return chatData;
  }

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

  /**
   * Update the data of a ChatMessage instance to apply a message visibility mode
   * This function calls {@link ChatMessage.applyMode} and updates the source of the ChatMessage.
   * @param {keyof typeof CONFIG.ChatMessage.modes} mode     The message visibility mode to apply to this message
   */
  applyMode(mode) {
    const data = this.toObject();
    this.constructor.applyMode(data, mode);
    this.updateSource(data);
  }

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

  /**
   * Return the HTML content to display for this message when its content is not visible to the current user.
   * Document subtype owners may override this in their system data model to customize the display.
   * @returns {string}
   * @protected
   */
  _getHiddenContent() {
    return this.system._getHiddenContent?.() ?? "<p>???</p>";
  }

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

  /**
   * 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);
    else if ( !this.isContentVisible ) messageData.message.content = this._getHiddenContent();

    // 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 ?? _loc("CHAT.UnknownUser");
      data.flavor = _loc("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 ( typeof 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});
    }

    // Roll-specific handling
    if ( this.isRoll ) {
      if ( !("sound" in data) ) this.updateSource({sound: CONFIG.sounds.dice});
      /** @deprecated since v14 */
      if ( options.rollMode ) {
        foundry.utils.logCompatibilityWarning("The rollMode option provided as an option to ChatMessage creation is"
          + " deprecated in favor of the `messageMode` option, a string key of CONFIG.ChatMessage.modes",
        {since: 14, until: 16});
        options.messageMode = foundry.dice.Roll._mapLegacyRollMode(options.rollMode);
        delete options.rollMode;
      }
    }

    // Apply requested message visibility
    if ( options.messageMode ) {
      options.messageMode = ChatMessage.#resolveMode(this._source, options.messageMode);
      this.applyMode(options.messageMode);
      options.chatBubble ??= options.messageMode === "ic";
    }
  }

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

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    ui.chat.postOne(this, {notify: options.notify ?? true, scroll: options.scroll});
    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, {notify: options?.notify});
    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));
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  applyRollMode(mode) {
    foundry.utils.logCompatibilityWarning("ChatMessage#applyRollMode is deprecated in favor of ChatMessage#applyMode.",
      {since: 14, until: 16, once: true});
    return this.applyMode(foundry.dice.Roll._mapLegacyRollMode(mode));
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  static applyRollMode(chatData, mode) {
    foundry.utils.logCompatibilityWarning("ChatMessage.applyRollMode is deprecated in favor of ChatMessage.applyMode.",
      {since: 14, until: 16, once: true});
    return this.applyMode(chatData, foundry.dice.Roll._mapLegacyRollMode(mode));
  }
};

/**
 * @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) {

  /** @inheritDoc */
  _initializeSource(source, options={}) {
    source = super._initializeSource(source, options);
    return game.compendiumArt.applyArt(this.constructor, source, options.pack);
  }

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

  /**
   * 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) {
    const {ActiveEffect, Actor, ActorDelta} = foundry.documents;
    if ( (this.parent instanceof Actor) || (this.parent instanceof ActorDelta) ) {
      for ( const effect of this.effects ) {
        if ( effect.transfer ) effect.updateSource(ActiveEffect.implementation.getEffectStart());
      }
    }
    return super._preCreate(data, options, user);
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    foundry.documents.ActiveEffect.registry.deleteFromParent(this);
  }
};

/**
 * @import {ActiveEffect, Actor, Combat, Combatant, Item} from "@client/documents/_module.mjs";
 */

/**
 * A singleton helper class that tracks the duration and expiry of {@link ActiveEffect}s
 * @extends IterableWeakSet<ActiveEffect>
 */
class ActiveEffectRegistry extends IterableWeakSet {

  /**
   * Has the registry been populated for the first time?
   * @type {boolean}
   */
  get initialized() {
    return this.#initialized;
  }

  #initialized = false;

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

  /**
   * Populate the registry for the first time.
   * @internal
   */
  _initialize() {
    if ( !game._documentsReady || this.#initialized ) return;

    // Register effects for base actors.
    // Effects for synthetic ActorDelta actors are registered lazily in TokenDocument#_onDeltaMaterialized
    for ( const actor of game.actors ) this.addFromParent(actor);

    // Re-initialize tracked durations
    for ( const effect of this ) effect.updateDuration();
    this.#initialized = true;
  }

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

  /**
   * Register a single ActiveEffect document. If the document is already registered but no longer eligible for
   * registration, it will be deleted.
   * @param {ActiveEffect} effect
   * @override
   */
  add(effect) {
    if ( !game._documentsReady ) return this;
    if ( effect.isExpiryTrackable ) super.add(effect);
    else this.delete(effect);
    return this;
  }

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

  /**
   * Register the ActiveEffects embedded on an Actor or Item.
   * @param {Actor|Item} document
   * @returns {this}
   */
  addFromParent(document) {
    if ( !game._documentsReady ) return this;
    for ( const effect of document.effects ) {
      this.add(effect);
    }
    if ( document.documentName === "Actor" ) {
      for ( const item of document.items ) {
        this.addFromParent(item);
      }
    }
    return this;
  }

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

  /**
   * Unregister the ActiveEffects embedded on an Actor or Item.
   * @param {Actor|Item} document
   * @returns {boolean} Did any deletions occur?
   */
  deleteFromParent(document) {
    if ( !game._documentsReady ) return false;
    let deletionsOccurred = false;
    for ( const effect of document.effects ) {
      if ( this.delete(effect) ) deletionsOccurred = true;
    }
    if ( document.documentName === "Actor" ) {
      for ( const item of document.items ) {
        if ( this.deleteFromParent(item) ) deletionsOccurred = true;
      }
    }
    return deletionsOccurred;
  }

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

  /**
   * Refresh the durations of registered ActiveEffects and perform the configured action for expired effects.
   * @param {string} event                The expiry or other event that triggered this call
   * @param {object} [context]            Additional contextual data relevant to the event
   * @param {Combat} [context.combat]     The Combat associated with this event
   * @param {Set<Actor>} [context.actors] Limit the refresh to effects belonging to the provided list of actors.
   * @see {@link CONFIG.ActiveEffect.expiryAction}
   */
  async refresh(event, context) {

    // Setup per-parent groupings for batched modifications and callbacks
    /** @type {Map<Actor, ActiveEffect[]>} */
    const refreshed = new Map();
    /** @type {Map<Actor|Item, ActiveEffect[]>} */
    const expired = new Map();

    for ( const effect of this ) {
      if ( context?.actors && !context.actors.has(effect.actor) ) continue;

      // These events never entail a change in remaining duration
      // Combat start and end also don't but can lead to a duration getting reframed as time-based or combat-based
      if ( !["roundEnd", "turnEnd"].includes(event) ) {
        const oldRemaining = effect.duration.secondsRemaining ?? effect.duration.remaining;
        const oldUnits = effect.duration.units;
        const updated = effect.updateDuration(context);
        const newRemaining = updated.secondsRemaining ?? updated.remaining;
        const remainingChanged = oldRemaining !== newRemaining;
        const unitsChanged = oldUnits !== updated.units;
        if ( remainingChanged || unitsChanged || (newRemaining <= 0) || (newRemaining === Infinity) ) {
          if ( !refreshed.has(effect.actor) ) refreshed.set(effect.actor, []);
          refreshed.get(effect.actor).push(effect);
        }
      }
      const durationReached = (effect.duration.remaining <= 0) || !Number.isFinite(effect.duration.remaining);
      if ( durationReached && effect.isExpiryEvent(event, context) ) {
        this.delete(effect);
        if ( !expired.has(effect.parent) ) expired.set(effect.parent, []);
        expired.get(effect.parent).push(effect);
      }
    }

    // Notify actors of updated durations
    for ( const [actor, effects] of refreshed.entries() ) {
      actor.onUpdateEffectDurations(effects, event, context);
    }

    // Perform the configured action for expired effects
    if ( game.users.activeGM?.isSelf ) {
      if ( CONFIG.ActiveEffect.expiryAction === "update" ) await this.#updateExpiredEffects(expired);
      else if ( CONFIG.ActiveEffect.expiryAction === "delete" ) await this.#deleteExpiredEffects(expired);
    }
  }

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

  /**
   * Update the duration.expired value for the provided expired effects.
   * @param {Map<Actor|Item, ActiveEffect[]>} effects
   */
  async #updateExpiredEffects(effects) {
    const operations = effects.entries().map(([parent, effects]) => ({
      action: "update",
      documentName: "ActiveEffect",
      parent,
      updates: effects.map(e => ({_id: e._id, duration: {expired: true}}))
    })).toArray();
    await foundry.documents.modifyBatch(operations);
  }

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

  /**
   * Delete the provided expired effects.
   * @param {Map<Actor|Item, ActiveEffect[]>} effects
   */
  async #deleteExpiredEffects(effects) {
    const operations = effects.entries().map(([parent, effects]) => ({
      action: "delete",
      documentName: "ActiveEffect",
      parent,
      ids: effects.map(e => e._id)
    })).toArray();
    await foundry.documents.modifyBatch(operations);
  }
}

var _types$j = /*#__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$11 = /*#__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,
  StringNode: StringNode,
  StringTree: StringTree,
  WordTree: WordTree,
  applyDataOperators: applyDataOperators,
  applySpecialKeys: applySpecialKeys,
  benchmark: benchmark,
  buildRelativeUuid: buildRelativeUuid,
  buildUuid: buildUuid,
  circleCircleIntersects: circleCircleIntersects,
  cleanHTML: cleanHTML,
  closestPointToPath: closestPointToPath,
  closestPointToSegment: closestPointToSegment,
  debounce: debounce,
  debouncedReload: debouncedReload,
  deepClone: deepClone,
  deepFreeze: deepFreeze,
  deepSeal: deepSeal,
  deleteProperty: deleteProperty,
  diffObject: diffObject,
  duplicate: duplicate,
  encodeURL: encodeURL,
  equals: equals,
  escapeHTML: escapeHTML,
  expandObject: expandObject,
  expandObjectInPlace: expandObjectInPlace,
  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,
  isElementInstanceOf: isElementInstanceOf,
  isEmpty: isEmpty,
  isNewerVersion: isNewerVersion,
  isPlainObject: isPlainObject,
  isSubclass: isSubclass,
  iterateEntries: iterateEntries,
  iterateKeys: iterateKeys,
  iterateValues: iterateValues,
  lineCircleIntersection: lineCircleIntersection,
  lineLineIntersection: lineLineIntersection,
  lineSegmentIntersection: lineSegmentIntersection,
  lineSegmentIntersects: lineSegmentIntersects,
  logCompatibilityWarning: logCompatibilityWarning,
  mergeObject: mergeObject,
  objectEntries: objectEntries,
  objectKeys: objectKeys,
  objectValues: objectValues,
  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$j,
  unescapeHTML: unescapeHTML
});

/**
 * @import {ActiveEffectChangeData, ActiveEffectChangeHandler, ActiveEffectData,
 *   ActiveEffectDuration, EffectDurationData} from "./_types.mjs";
 * @import {DocumentConstructionContext} from "@common/abstract/_types.mjs";
 * @import Document from "@common/abstract/document.mjs";
 * @import {DataField} from "@common/data/fields.mjs";
 * @import {ActiveEffectChangeTypeConfig} from "@client/config.mjs";
 * @import {Combat, Combatant, TokenDocument} from "./_module.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 Actor}: The Actor document which contains ActiveEffect embedded documents
 * @see {@link Item}: The Item document which contains ActiveEffect embedded documents
 *
 * @property {ActiveEffectDuration} duration        Expanded effect duration data.
 */
let ActiveEffect$1 = class ActiveEffect extends ClientDocumentMixin(BaseActiveEffect) {
  /**
   * A cached compilation of core and registered application phases, along with their labels
   * @type {Record<string, {label: string; hint: string}>}
   */
  static get CHANGE_PHASES() {
    if ( ActiveEffect.#CHANGE_PHASES ) return ActiveEffect.#CHANGE_PHASES;
    return ActiveEffect.#CHANGE_PHASES = [
      ...Object.entries(CONFIG.ActiveEffect.phases).map(([value, {label, hint=""}]) => ({value, label, hint})),
      ...CONST.ACTIVE_EFFECT_CHANGE_PHASES.map(value => ({
        value, label: `EFFECT.CHANGES.PHASES.${value}.label`, hint: `EFFECT.CHANGES.PHASES.${value}.hint`
      }))
    ].reduce((phases, phase) => {
      phases[phase.value] = {label: phase.label, hint: phase.hint};
      return phases;
    }, {});
  }

  /** @type {Record<string, {label: string; hint: string}>} */
  static #CHANGE_PHASES;

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

  /**
   * A cached compilation of core and registered change types, along with their labels and default priorities
   * @type {Record<string, ActiveEffectChangeTypeConfig>}
   */
  static get CHANGE_TYPES() {
    if ( ActiveEffect.#CHANGE_TYPES ) return ActiveEffect.#CHANGE_TYPES;
    return ActiveEffect.#CHANGE_TYPES = [
      ...Object.entries(CONST.ACTIVE_EFFECT_CHANGE_TYPES).map(([value, defaultPriority]) => ({
        value, defaultPriority, label: `EFFECT.CHANGES.TYPES.${value}`, handler: null, render: null
      })),
      ...Object.entries(CONFIG.ActiveEffect.changeTypes).map(([value, config]) => ({
        value,
        label: config.label ?? value,
        defaultPriority: config.defaultPriority ?? 0,
        handler: config.handler ?? null,
        render: config.render ?? null
      }))
    ].reduce((types, type) => {
      types[type.value] = {label: type.label, defaultPriority: type.defaultPriority, handler: type.handler,
        render: type.render};
      return types;
    }, {});
  }

  /** @type {Record<string, ActiveEffectChangeTypeConfig>} */
  static #CHANGE_TYPES;

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

  /**
   * A cached compilation of core and registered expiry events
   * @type {Record<string, string>}
   */
  static get EXPIRY_EVENTS() {
    if ( ActiveEffect.#EXPIRY_EVENTS ) return ActiveEffect.#EXPIRY_EVENTS;
    return ActiveEffect.#EXPIRY_EVENTS = [
      ...Object.entries(CONFIG.ActiveEffect.expiryEvents).map(([value, label]) => ({value, label})),
      ...ACTIVE_EFFECT_EXPIRY_EVENTS.map(value => ({value, label: `EFFECT.DURATION.EXPIRY_EVENTS.${value}`}))
    ].reduce((events, event) => {
      events[event.value] = event.label;
      return events;
    }, {});
  }

  /** @type {Record<string, string>} */
  static #EXPIRY_EVENTS;

  /**
   * A helper class that accepts registration of ActiveEffects and manages their prepared duration and expiry data.
   * @type {ActiveEffectRegistry}
   */
  static registry = new ActiveEffectRegistry();

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

  /**
   * 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[statusId];
    if ( !status ) throw new Error(`Invalid status ID "${statusId}" provided to ActiveEffect#fromStatusEffect`);
    const {id, hud: _hud, ...effectData} = foundry.utils.deepClone(status);
    effectData.name = _loc(effectData.name);
    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");
    }
    effectData.showIcon ??= CONST.ACTIVE_EFFECT_SHOW_ICON.ALWAYS;
    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                                  */
  /* -------------------------------------------- */

  /**
   * The Actor in which this ActiveEffect is embedded, either directly or as a grandchild Document
   * @type {Actor|null}
   */
  get actor() {
    if ( !this.isEmbedded ) return null;
    if ( this.parent instanceof Actor$1 ) return this.parent;
    if ( this.parent instanceof Item$1 ) return this.parent.parent;
    return null;
  }

  /**
   * The Item in which this ActiveEffect is embedded
   * @type {Item|null}
   */
  get item() {
    return this.parent instanceof Item$1 ? this.parent : null;
  }

  /**
   * Provide a thumbnail image path used to represent this document.
   * @type {string}
   */
  get thumbnail() {
    return this.img;
  }

  /**
   * Is there some system logic (or, absent that, an expired status) that makes this Active Effect ineligible for
   * application?
   * @type {boolean}
   */
  get isSuppressed() {
    return !!(this.system.isSuppressed ?? this.duration.expired);
  }

  /**
   * Retrieve the Document that this ActiveEffect targets for modification.
   * @type {Document|null}
   */
  get target() {
    if ( !this.isEmbedded ) return null;
    if ( this.parent instanceof Actor$1 ) return this.parent;
    return this.transfer ? (this.parent.parent ?? null) : this.parent;
  }

  /**
   * Whether this Active Effect is currently applying its changes to the target
   * @type {boolean}
   */
  get active() {
    return !this.disabled && !this.isSuppressed;
  }

  /**
   * Whether this Active Effect currently modify an Actor
   * @type {boolean}
   */
  get modifiesActor() {
    return this.isEmbedded && this.active && (this.target?.documentName === "Actor");
  }

  /**
   * Whether this Active Effect has a temporary duration
   * @type {boolean}
   */
  get isTemporary() {
    return !!this.duration.expiry || Number.isFinite(this.duration.value);
  }

  /**
   * Whether this Active Effect is eligible to be registered with the {@link ActiveEffectRegistry}
   */
  get isExpiryTrackable() {
    return this.persisted && !this.inCompendium && this.isEmbedded && this.active && !!this.start && this.isTemporary;
  }

  /**
   * 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 _loc("COMMON.None");
    let name;
    try {
      name = foundry.utils.fromUuidSync(this.origin)?.name;
    } catch(e) {}
    return name || _loc("COMMON.Unknown");
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initialize(options={}) {
    super._initialize(options);
    if ( !Object.hasOwn(this, "changes") ) Object.defineProperty(this, "changes", {get: () => this.system.changes});
    this.constructor._shimChanges(this.system.changes);
  }

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

  /** @override */
  prepareBaseData() {
    this.img ??= this.constructor.DEFAULT_ICON;
    this.duration.value ??= Infinity;
    for ( const change of this.system.changes ) {
      change.effect = this;
      change.priority ??= ActiveEffect.CHANGE_TYPES[change.type]?.defaultPriority ?? 0;
    }
  }

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

  /** @override */
  prepareDerivedData() {
    this.updateDuration();
  }

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

  /**
   * Update derived Active Effect duration data.
   * @param {object} [context] Contextual information indicating what lead to this call
   * @returns {ActiveEffectDuration}
   */
  updateDuration(context) {
    const unprepared = this.duration.units === this._source.duration.units
      ? this.duration
      : foundry.utils.deepClone(this._source.duration);
    unprepared.value ??= Infinity;
    const duration = this.duration = this._prepareDuration(unprepared, context);
    if ( !Object.hasOwn(duration, "type") || !Object.hasOwn(duration, "duration") ) {
      Object.defineProperties(duration, {
        type: {
          get: () => {
            const message = "You are accessing ActiveEffectDuration#type, which is now at ActiveEffectDuration#units.";
            foundry.utils.logCompatibilityWarning(message, {since: 14, until: 16, once: true});
            return typeof duration.value === "number" ? duration.units : "none";
          },
          configurable: true
        },
        duration: {
          get() {
            foundry.utils.logCompatibilityWarning("You are accessing ActiveEffectDuration#duration, "
              + "which is now at ActiveEffectDuration#seconds.", {since: 14, until: 16, once: true});
            return this.start ? duration.seconds - (game.time.worldtime - this.start.time) : null;
          },
          configurable: true
        }
      });
    }
    return duration;
  }

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

  /**
   * Compute derived data related to active effect duration.
   * @param {EffectDurationData} [duration] Unprepared duration data
   * @param {object} [context]              Contextual information indicating what lead to this call
   * @returns {ActiveEffectDuration}
   * @protected
   */
  _prepareDuration(duration, context) {
    duration ??= this.duration;
    const now = game.time.worldTime;
    duration._worldTime = now;

    // Indefinite duration
    if ( !this.isTemporary ) duration.expired = false;
    if ( !Number.isFinite(duration.value) ) {
      return Object.assign(duration, {seconds: Infinity, remaining: Infinity, secondsRemaining: Infinity,
        label: _loc("COMMON.None")});
    }

    return CONST.ACTIVE_EFFECT_TIME_DURATION_UNITS.includes(duration.units)
      ? this._prepareTimeBasedDuration(duration, context)
      : this._prepareCombatBasedDuration(duration, context);
  }

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

  /**
   * Prepare duration data from time-based (minutes, seconds, etc.) source data.
   * @param {EffectDurationData} duration Unprepared duration data
   * @param {object} [context]            Contextual information indicating what lead to this call
   * @returns {ActiveEffectDuration}
   * @protected
   */
  _prepareTimeBasedDuration(duration, context) {
    const calendar = game.time.calendar;
    const durationInMonths = duration.units === "months";
    const unitsSingular = durationInMonths ? "day": duration.units.slice(0, -1);
    const avgDaysPerMonth = durationInMonths && calendar.months.values.length
      ? calendar.days.daysPerYear / calendar.months.values.length
      : 0;
    const durationValue = durationInMonths ? Math.ceil(duration.value * avgDaysPerMonth) : duration.value;
    const seconds = calendar.componentsToTime({[unitsSingular]: durationValue});
    const worldTime = game.time.worldTime;

    // Use the current world time as the start time effects lacking one
    const start = this.start ?? {time: worldTime};

    // Handle remaining value and label given the duration is exceeded
    const secondsRemaining = start.time + seconds - worldTime;
    let remainingComponents;
    let formatter;
    let roundFn;
    if ( secondsRemaining < 0 ) {
      // Work around limitations of calendar API when there is a negative difference
      remainingComponents = calendar.difference(worldTime, worldTime + secondsRemaining);
      formatter = "formatAgo";
      roundFn = "floor";
    }
    else {
      if ( secondsRemaining > 0 ) duration.expired = false;
      remainingComponents = calendar.difference(start.time + seconds);
      formatter = "formatDuration";
      roundFn = "ceil";
    }
    let remaining;
    if ( durationInMonths ) {

      // Months may not have a constant number of days and require approximation favoring later rather than earlier
      // expiration
      if ( avgDaysPerMonth === 0 ) remaining = 0;
      else {
        const {secondsPerMinute, minutesPerHour, hoursPerDay} = calendar.days;
        const secondsPerMonth = avgDaysPerMonth * hoursPerDay * minutesPerHour * secondsPerMinute;
        remaining = Math.sign(secondsRemaining) * Math[roundFn](Math.abs(secondsRemaining / secondsPerMonth));
      }
    }
    else {
      const absRemaining = calendar.componentsToUnit(remainingComponents, unitsSingular, {roundFn});
      remaining = Math.sign(secondsRemaining) * absRemaining;
    }
    const formattableComponents = iterateEntries(remainingComponents).reduce((components, [unit, value]) => {
      if ( durationInMonths && (unit === "day") && (value > avgDaysPerMonth) ) return components;
      if ( value && foundry.data.CalendarData._DURATION_FORMAT_UNITS.has(unit) ) components[unit] = value;
      return components;
    }, {});
    const label = calendar.format(formattableComponents, formatter, {style: "short", maxTerms: 2}) || _loc("TIME.Now");
    return Object.assign(duration, {seconds, remaining, secondsRemaining, label});
  }

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

  /**
   * Prepare duration data from combat-based (rounds or turns) source data.
   * @param {EffectDurationData} duration Unprepared duration data
   * @param {object} [context]            Contextual information indicating what lead to this call
   * @returns {ActiveEffectDuration}
   * @protected
   */
  _prepareCombatBasedDuration(duration, context) {
    const unitsSingular = duration.units.replace(/s$/, "");
    /** @type {number} */
    const timeConversion = CONFIG.time[`${unitsSingular}Time`] || 0;
    const seconds = duration.seconds = timeConversion ? Math.trunc(duration.value * timeConversion) : null;
    const worldTime = game.time.worldTime;
    const start = this.start ?? {time: worldTime};
    const combat = game.combats.get(start.combat?.id) ?? game.combat;
    const combatant = combat?.combatants.get(start.combatant) ?? combat?.getCombatantsByActor(this.actor)[0];

    // If no combat information is available, reframe the presented duration as time-based
    if ( !combatant || !combat.started || !combat.turns.length ) {
      return (game._documentsReady && Number.isFinite(seconds))
        ? this._prepareTimeBasedDuration({...duration, units: "seconds", value: seconds})
        : Object.assign(duration, {remaining: Infinity, secondsRemaining: Infinity, label: _loc("COMMON.None")});
    }

    // Acquire the start round and turn number from the combatant if the current combat is not the same as the starting
    // one.
    const [startRound, startTurn] = combat === start.combat
      ? [start.round ?? combatant.roundJoined, (start.turn ?? 0) + 1]
      : [combatant.roundJoined, combatant.turnNumber + 1];

    const currentRound = context?.round ?? combat.round;
    const timeElapsed = timeConversion ? worldTime - start.time : 0;
    switch ( duration.units ) {
      case "rounds": {
        const elapsedInCombat = currentRound - startRound;
        const totalElapsedInCombat = (currentRound - 1);
        const elapsedBeforeCombat = timeConversion
          ? Math.max(0, Math.floor(timeElapsed / timeConversion) - totalElapsedInCombat)
          : 0;
        const remaining = duration.value - (elapsedInCombat + elapsedBeforeCombat);
        const pluralRule = game.i18n.pluralRules.select(Math.abs(remaining));
        const locKey = remaining >= 0 ? "EFFECT.DURATION.ROUNDS" : "EFFECT.DURATION.ROUNDS_AGO";
        Object.assign(duration, {remaining, label: _loc(`${locKey}.${pluralRule}`, {rounds: Math.abs(remaining)})});
        break;
      }
      case "turns": {
        const currentTurn = 1 + (context?.turn ?? combat.turn);
        const turnsPerRound = combat.turns.length;
        const elapsedPriorRounds = Math.max(0,
          (turnsPerRound - startTurn) + (turnsPerRound * (currentRound - startRound - 1)));
        const elapsedThisRound = startRound === currentRound ? Math.max(0, currentTurn - startTurn) : currentTurn;
        const elapsedInCombat = elapsedPriorRounds + elapsedThisRound;
        const elapsedBeforeCombat = timeConversion ? Math.floor(timeElapsed / timeConversion) - elapsedInCombat : 0;
        const remaining = duration.value - (elapsedInCombat + elapsedBeforeCombat);
        const pluralRule = game.i18n.pluralRules.select(Math.abs(remaining));
        const locKey = remaining >= 0 ? "EFFECT.DURATION.TURNS" : "EFFECT.DURATION.TURNS_AGO";
        Object.assign(duration, {remaining, label: _loc(`${locKey}.${pluralRule}`, {turns: Math.abs(remaining)})});
        break;
      }
    }
    if ( timeConversion ) duration.secondsRemaining = duration.remaining * timeConversion;
    else delete duration.secondsRemaining;
    return duration;
  }

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

  /** @inheritDoc */
  toCompendium(pack, options) {
    const data = super.toCompendium(pack, options);
    if ( options?.clearState !== false ) {
      data.origin = null;
      data.start = null;
    }
    return data;
  }

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

  /**
   * Determine whether a change from this ActiveEffect should be applied during the current phase. Systems and modules
   * may override this method to introduce additional conditions under which a change is applied.
   * @param {ActiveEffectChangeData} change    The change being considered.
   * @param {object} [options]                 Options which affect whether the change is applied.
   * @param {string} [options.phase]           The application phase currently being evaluated.
   * @param {string} [options.replacementData] Replacement data to be used as part of the change's application
   * @returns {boolean}                        Should the change be applied during this phase (or at all)?
   */
  shouldApplyChange(change, options) {
    return change.phase === options?.phase;
  }

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

  /**
   * Acquire replacement data for use in the application of this effect's changes.
   * @param {object} baseData Base data sourced from elsewhere (by default from `Actor#getRollData`)
   * @returns {object}        Data used to resolve "@" expressions in string {@link ActiveEffectChangeData} values
   */
  getReplacementData(baseData) {
    return baseData;
  }

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

  /**
   * Apply this ActiveEffect to a target Document.
   * @param {Actor|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {ActiveEffectChangeData} change      The change data being applied
   * @param {object} [options]                   Options affecting the change application
   * @param {object} [options.replacementData]   Data used to resolve "@" expressions in a string value
   * @param {boolean} [options.modifyTarget]     Modify the target Document with the updated value.
   * @returns {Record<string, unknown>} An object of property keys and their updated values
   */

  static applyChange(targetDoc, change, {replacementData={}, modifyTarget=true}={}) {
    let field;
    const changes = {};
    if ( (typeof change.key === "string") && change.key.startsWith("system.") ) {
      if ( targetDoc.system instanceof foundry.abstract.DataModel ) {
        field = targetDoc.system.getFieldForProperty(change.key.slice(7));
      }
    }
    else field = targetDoc.getFieldForProperty(change.key ?? "");
    const configuredHandler = ActiveEffect.CHANGE_TYPES[change.type]?.handler;
    if ( typeof configuredHandler === "function" ) {
      configuredHandler(targetDoc, change, {field, replacementData, modifyTarget});
    }
    else if ( field ) {
      if ( foundry.utils.getDefiningClass(this, "applyField") !== ActiveEffect ) {
        foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides applyField, which is"
          + " deprecated. Please override applyChangeField instead.", {since: 14, until: 16, once: true});
        changes[change.key] = this.applyField(targetDoc, change, field);
      }
      else changes[change.key] = this.applyChangeField(targetDoc, change, {field, replacementData, modifyTarget});
    }
    else {
      if ( change.effect && (foundry.utils.getDefiningClass(change.effect, "_applyLegacy") !== ActiveEffect) ) {
        foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides _applyLegacy, which is"
          + " deprecated. Please override static _applyChangeUnguided instead.", {since: 14, until: 16, once: true});
        change.effect._applyLegacy(targetDoc, change, changes);
      }
      else this._applyChangeUnguided(targetDoc, change, changes, {replacementData, modifyTarget});
    }
    return changes;
  }

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

  /**
   * Apply EffectChangeData to a field within a Document.
   * @param {Actor|Item|TokenDocument} targetDoc The model instance.
   * @param {EffectChangeData} change            The change to apply.
   * @param {object} [options]                   Additional options to configure the change application.
   * @param {DataField} [options.field]          The field: if not supplied, it will be retrieved from the supplied
   *                                             Document.
   * @param {Record<string, unknown>} [options.replacementData] Data used to resolve "@" expressions.
   * @param {boolean} [options.modifyTarget]     Modify the target Document with the updated value.
   * @returns {unknown} The updated value.
   */
  static applyChangeField(targetDoc, change, {field, replacementData={}, modifyTarget=true}={}) {
    field ??= targetDoc.getFieldForProperty(change.key);
    const current = foundry.utils.getProperty(targetDoc, change.key);
    const update = field.applyChange(current, targetDoc, change, {replacementData});
    if ( modifyTarget && (update !== undefined) ) foundry.utils.setProperty(targetDoc, change.key, update);
    return update;
  }

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

  /**
   * 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|Item|TokenDocument|DataModel} targetDoc  The Document or DataModel to which this effect should be
   *                                                        applied
   * @param {ActiveEffectChangeData} change                 The change data being applied.
   * @param {Record<string, unknown>} changes               The aggregate update paths and their updated values.
   * @param {object} [options]
   * @param {Record<string, unknown>} [options.replacementData] Data used to resolve "@" expressions.
   * @param {boolean} [options.modifyTarget]                    Modify the target Document with the updated value.
   * @protected
   */
  static _applyChangeUnguided(targetDoc, change, changes, {replacementData={}, modifyTarget=true}={}) {
    // Determine the data type of the target field
    const {getProperty, getType} = foundry.utils;
    const current = getProperty(targetDoc, change.key) ?? null;
    let targetData = current;
    if ( (current === null) && targetDoc.documentName ) {
      const model = game.model[targetDoc.documentName]?.[targetDoc.type] ?? {};
      targetData = getProperty(model, change.key) ?? null;
    }
    const targetType = getType(targetData);

    // Cast the effect change value to the correct type
    let delta;
    try {
      if ( ["Array", "Set"].includes(targetType) ) {
        const innerType = targetData.length
          ? getType(targetData[0])
          : targetData.size
            ? getType(targetData.first())
            : "string";
        delta = ActiveEffect.#castArray(change.value, innerType, replacementData);
        if ( targetType === "Set" ) delta = new Set(delta);
      }
      else delta = ActiveEffect.#castDelta(change.value, targetType, replacementData);
    } catch(error) {
      const header = change.effect ? `Active Effect (${change.effect.uuid}) |` : "";
      console.warn(`${header} "${change.type}" change to "${change.key}" failed to resolve: ${error.message}`);
      return;
    }

    // Apply the change depending on the application mode
    switch ( change.type ) {
      case "add":
        if ( change.effect && (foundry.utils.getDefiningClass(change.effect, "_applyAdd") !== ActiveEffect) ) {
          foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides _applyAdd, which is"
            + " deprecated. Please override static _applyChangeAdd instead.", {since: 14, until: 16, once: true});
          change.effect._applyAdd(targetDoc, change, current, delta, changes);
        }
        else this._applyChangeAdd(targetDoc, change, current, delta, changes);
        break;
      case "subtract":
        this._applyChangeSubtract(targetDoc, change, current, delta, changes);
        break;
      case "multiply":
        if ( change.effect && (foundry.utils.getDefiningClass(change.effect, "_applyMultiply") !== ActiveEffect) ) {
          foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides _applyMultiply, which is"
            + " deprecated. Please override static _applyChangeMultiply instead.", {since: 14, until: 16, once: true});
          change.effect._applyMultiply(targetDoc, change, current, delta, changes);
        }
        else this._applyChangeMultiply(targetDoc, change, current, delta, changes);
        break;
      case "override":
        if ( change.effect && (foundry.utils.getDefiningClass(change.effect, "_applyOverride") !== ActiveEffect) ) {
          foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides _applyOverride, which is"
            + " deprecated. Please override static _applyChangeOverride instead.", {since: 14, until: 16, once: true});
          change.effect._applyOverride(targetDoc, change, current, delta, changes);
        }
        else this._applyChangeOverride(targetDoc, change, current, delta, changes);
        break;
      case "upgrade":
      case "downgrade":
        if ( change.effect && (foundry.utils.getDefiningClass(change.effect, "_applyUpgrade") !== ActiveEffect) ) {
          foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides _applyUpgrade, which is"
            + " deprecated. Please override static _applyChangeUpgrade instead.", {since: 14, until: 16, once: true});
          change.effect._applyUpgrade(targetDoc, change, current, delta, changes);
        }
        else this._applyChangeUpgrade(targetDoc, change, current, delta, changes);
        break;
      default:
        if ( change.effect && (foundry.utils.getDefiningClass(change.effect, "_applyCustom") !== ActiveEffect) ) {
          foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides _applyCustom, which is"
            + " deprecated. Please override static _applyChangeCustom instead.", {since: 14, until: 16, once: true});
          change.effect._applyCustom(targetDoc, change, current, delta, changes);
        }
        else this._applyChangeCustom(targetDoc, change, current, delta, changes);
        break;
    }

    // Apply all changes to the Actor data
    if ( modifyTarget ) foundry.utils.mergeObject(targetDoc, changes);
  }

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

  /**
   * Cast raw EffectChangeData change data to the desired data type.
   * @param {unknown} raw              The raw value
   * @param {string} type              The target data type that the raw value should be cast to match
   * @param {Record<string, unknown>} [replacementData] Data used to resolve "@" expressions
   * @returns {unknown}                The parsed delta cast to the target data type
   * @throws {Error}                   An error in case of data-replacement failure
   */
  static #castDelta(raw, type, replacementData={}) {
    let delta;
    if ( typeof raw === "string" ) raw = ActiveEffect.implementation._replaceDataRefs(raw, replacementData);
    switch ( type ) {
      case "boolean":
        delta = !!raw;
        break;
      case "number":
        delta = Number(raw) || 0;
        break;
      case "string":
        delta = String(raw);
        break;
      default:
        delta = raw;
    }
    return delta;
  }

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

  /**
   * Recursively replace data references in a string change value.
   * @param {string} raw
   * @param {Record<string, unknown>} data An object providing replacements
   * @returns {string|null} The string with all data references resolved
   * @throws {Error} An Error if data replacement failed
   * @protected
   */
  static _replaceDataRefs(raw, data) {
    return Roll.defaultImplementation.replaceFormulaData(raw, data, {recursive: true, warn: true});
  }

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

  /**
   * Cast a raw EffectChangeData change data to an Array of an inner type.
   * @param {unknown} data             The change data
   * @param {string} type              The target data type of inner array elements
   * @param {Record<string, unknown>} [replacementData] Data used to resolve "@" expressions
   * @returns {unknown[]}              The parsed delta cast as a typed array
   */
  static #castArray(data, type, replacementData={}) {
    const delta = Array.isArray(data) ? data : [data];
    return delta.map(d => ActiveEffect.#castDelta(d, type, replacementData));
  }

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

  /**
   * Apply an ActiveEffect that uses an "add" change type.
   * 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|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {EffectChangeData} change            The change data being applied
   * @param {unknown} current                    The current value being modified
   * @param {unknown} delta                      The parsed value of the change object
   * @param {object} changes                     An object which accumulates changes to be applied
   * @protected
   */
  static _applyChangeAdd(targetDoc, 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;
      case "Set":
        update = current.union(delta);
        break;
      default:
        update = current + delta;
        break;
    }
    if ( update !== current ) changes[change.key] = update;
  }

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

  /**
   * Apply an ActiveEffect that uses a "subtract" change type.
   * 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 replaced in the current value with the empty string.
   * If the current type is a number, the change value is cast to numeric and subtracted.
   * If the current type is an array, the change value is spliced out of the array if present.
   *
   * @param {Actor|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {EffectChangeData} change            The change data being applied
   * @param {unknown} current                    The current value being modified
   * @param {unknown} delta                      The parsed value of the change object
   * @param {object} changes                     An object which accumulates changes to be applied
   * @protected
   */
  static _applyChangeSubtract(targetDoc, 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 "string":
        update = current.replace(delta, "");
        break;
      case "Array":
        update = [...current].findSplice(e => foundry.utils.equals(e, delta));
        break;
      case "Set":
        update = current.difference(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|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {EffectChangeData} change            The change data being applied
   * @param {unknown} current                    The current value being modified
   * @param {unknown} delta                      The parsed value of the change object
   * @param {object} changes                     An object which accumulates changes to be applied
   * @protected
   */
  static _applyChangeMultiply(targetDoc, 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) && (update !== undefined) ) 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|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {EffectChangeData} change            The change data being applied
   * @param {unknown} current                    The current value being modified
   * @param {unknown} delta                      The parsed value of the change object
   * @param {object} changes                     An object which accumulates changes to be applied
   * @protected
   */
  static _applyChangeOverride(targetDoc, 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|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {EffectChangeData} change            The change data being applied
   * @param {unknown} current                    The current value being modified
   * @param {unknown} delta                      The parsed value of the change object
   * @param {object} changes                     An object which accumulates changes to be applied
   * @protected
   */
  static _applyChangeUpgrade(targetDoc, change, current, delta, changes) {
    let update;
    const ct = foundry.utils.getType(current);
    switch ( ct ) {
      case "boolean":
      case "number":
        if ( (change.type === "upgrade") && (delta > current) ) update = delta;
        else if ( (change.type === "downgrade") && (delta < current) ) update = delta;
        break;
      case "Set": {
        if ( (change.type === "upgrade") && delta.isSupersetOf(current) ) update = delta;
        else if ( (change.type === "downgrade") && delta.isSubsetOf(current) ) update = delta;
        break;
      }
    }
    if ( (update !== current) && (update !== undefined) ) changes[change.key] = update;
  }

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

  /**
   * Apply an ActiveEffect that uses a CUSTOM change type.
   * @param {Actor|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {EffectChangeData} change            The change data being applied
   * @param {unknown} current                    The current value being modified
   * @param {unknown} delta                      The parsed value of the change object
   * @param {object} changes                     An object which accumulates changes to be applied
   * @protected
   */
  static _applyChangeCustom(targetDoc, change, current, delta, changes) {
    const preHook = foundry.utils.getProperty(targetDoc, change.key);
    Hooks$1.call("applyActiveEffect", targetDoc, change, current, delta, changes);
    const postHook = foundry.utils.getProperty(targetDoc, change.key);
    if ( (postHook !== preHook) && (postHook !== undefined) ) changes[change.key] = postHook;
  }

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

  /**
   * A determination of whether the ActiveEffect's expiry event was reached. This check is independent of whether the
   * duration was also reached.
   * @param {string} event     The event that triggered this check
   * @param {object} [context] Contextual information for use in the determination
   * @returns {boolean}
   */
  isExpiryEvent(event, context) {
    const expiry = this.duration.expiry;
    if ( event === "updateWorldTime" ) {
      // Expiry is determined by duration alone
      if ( !expiry ) return true;
      // Outside of combat, treat any time advancement as satisfying in-combat expiry events
      return !this.actor?.inCombat && ACTIVE_EFFECT_EXPIRY_EVENTS.includes(expiry) && expiry !== "combatStart";
    }
    if ( event !== expiry ) return false;
    if ( !ACTIVE_EFFECT_EXPIRY_EVENTS.includes(expiry) ) return true;

    /** @type {Combat|null} */
    const combat = context.combat ?? game.combat;
    /** @type {Combatant|null|undefined} */
    const effectCombatant = combat?.started
      ? combat === this.start.combat
        ? combat.combatants.get(this.start.combatant)
        : combat.getCombatantsByActor(this.actor ?? "")[0]
      : null;
    switch ( event ) {
      case "combatStart":
      case "combatEnd":
      case "roundStart":
      case "roundEnd":
        return !!effectCombatant;
      case "turnStart":
        return !!combat?.started && (combat.combatant === effectCombatant);
      case "turnEnd": {
        if ( !combat?.started || !effectCombatant ) return false;
        const previousCombatantId = combat.previous.combatantId;
        if ( previousCombatantId ) return effectCombatant.id === previousCombatantId; // Prefer matching on combatant
        else return effectCombatant.turnNumber === combat.previous.turn;              // Otherwise match turn number
      }
      default:
        return false;
    }
  }

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

  /**
   * Retrieve the initial duration configuration.
   * @param {Combat|null} [combat]
   * @returns {EffectStartData}
   */
  static getEffectStart(combat=game.combat) {
    if ( !combat?.started ) combat = null;
    const combatant = combat?.combatant;
    return {
      time: game.time.worldTime,
      combat: combat?.id ?? null,
      combatant: combatant?.id ?? null,
      initiative: combatant?.initiative ?? null,
      round: combat?.round ?? null,
      turn: combat?.turn ?? null
    };
  }

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

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;

    // Set initial start data for Actor-owned effects
    if ( this.parent instanceof Actor$1 ) {
      const start = this.constructor.getEffectStart();
      for ( const key of Object.keys(start) ) {
        if ( data.start?.[key] !== undefined ) delete start[key]; // Prefer user-defined duration data
      }
      this.updateSource({start});
    }
  }

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

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( this.modifiesActor && (options.animate !== false) ) this._displayScrollingStatus(true);
  }

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

  /** @inheritDoc */
  async _preUpdate(data, options, user) {
    const allowed = await super._preUpdate(data, options, user);
    if ( allowed === false ) return false;
    if ( !data.duration ) return;

    // Reset duration units to seconds when the value is wiped
    if ( data.duration.value === null ) data.duration.units = "seconds";
  }

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


  /** @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);
    ActiveEffect.registry.delete(this);
    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.system.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
      });
    }
  }

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

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  apply(actor, change) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#apply, which has been moved to"
      + " ActiveEffect.applyChange", {since: 14, until: 16, once: true});
    return this.constructor.applyChange(actor, change);
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  static applyField(model, change, field) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect.applyField, which has been moved to"
      + " ActiveEffect.applyChangeField.", {since: 14, until: 16, once: true});
    return this.applyChangeField(model, change, {field});
  }

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

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  _applyLegacy(actor, change, changes) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#_applyLegacy, which has been moved to"
    + " ActiveEffect._applyChangeUnguided", {since: 14, until: 16, once: true});
    return this.constructor._applyChangeUnguided(actor, change, changes);
  }

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

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  _applyAdd(actor, change, current, delta, changes) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#_applyAdd, which has been moved to"
    + " ActiveEffect._applyChangeAdd", {since: 14, until: 16, once: true});
    return this.constructor._applyChangeAdd(actor, change, current, delta, changes);
  }

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

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  _applyMultiply(actor, change, current, delta, changes) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#_applyMultiply, which has been moved to"
    + " ActiveEffect._applyChangeMultiply", {since: 14, until: 16, once: true});
    return this.constructor._applyChangeMultiply(actor, change, current, delta, changes);
  }

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

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  _applyOverride(actor, change, current, delta, changes) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#_applyOverride, which has been moved to"
    + " ActiveEffect._applyChangeOverride", {since: 14, until: 16, once: true});
    return this.constructor._applyChangeOverride(actor, change, current, delta, changes);
  }

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

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  _applyUpgrade(actor, change, current, delta, changes) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#_applyUpgrade, which has been moved to"
    + " ActiveEffect._applyChangeUpgrade", {since: 14, until: 16, once: true});
    return this.constructor._applyChangeUpgrade(actor, change, current, delta, changes);
  }

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

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  _applyCustom(actor, change, current, delta, changes) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#_applyCustom, which has been moved to"
    + " ActiveEffect._applyChangeCustom", {since: 14, until: 16, once: true});
    return this.constructor._applyChangeCustom(actor, change, current, delta, changes);
  }

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

  /**
   * @returns {{start: EffectStartData}}
   * @deprecated since v14
   * @ignore
   */
  static getInitialDuration() {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect.getInitialDuration,"
      + " which has been moved to ActiveEffect.getEffectStart.", {since: 14, until: 16, once: true});
    return {start: this.getEffectStart()};
  }
};

Object.defineProperty(ActiveEffect$1, "registry", {writable: false, configurable: false});

/**
 * @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);
    await ActiveEffect$1.registry.refresh("combatStart", {combat: this});
    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: {icon: "fa-solid fa-xmark", title: "COMBAT.EndTitle"},
      content: `<p>${_loc("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});
    return this;
  }

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

  /**
   * 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 {string} [options.messageMode]          A message visibility mode to apply to the resulting chat message,
   *                                                a key of {@link CONFIG.ChatMessage.modes}
   * @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, messageMode, messageOptions={}}={}) {

    // Structure input data
    ids = typeof ids === "string" ? [ids] : ids;
    if ( "rollMode" in messageOptions ) {
      foundry.utils.logCompatibilityWarning("The rollMode option of Combat#rollInitiative messageOptions is"
        + " deprecated in favor of the `messageMode` option, a string key of CONFIG.ChatMessage.modes",
      {since: 14, until: 16});
      messageMode = foundry.dice.Roll._mapLegacyRollMode(messageOptions.rollMode);
      delete messageOptions.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});

      // 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: _loc("COMBAT.RollsInitiative", {name: foundry.utils.escapeHTML(combatant.name)}),
        flags: {"core.initiativeRoll": true}
      }, messageOptions);
      const chatData = await roll.toMessage(messageData, {
        messageMode: messageMode ?? (combatant.hidden ? "gm" : undefined), // Private rolls for hidden combatants
        create: false
      });
      if ( i > 0 ) chatData.sound = null; // Only 1 sound for whole rolled set
      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++;
      }
      turns.forEach((c, i) => c.turnNumber = i);
    }

    // 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. By default, combatants are sorted into descending order of
   * initiative scores and, in the case of ties, by comparison of combatant IDs.
   * @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
      if ( (priorState.round === 0) && this.started ) this.turns.forEach((c, i) => c.turnNumber = i);
    }

    // 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();
    ActiveEffect$1.registry.refresh("combatEnd", {combat: this});
    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(documents, options);
    if ( this.started ) {
      this._updateTurnMarkers();
      const actors = new Set(documents.flatMap(c => c.actor ?? []));
      ActiveEffect$1.registry.refresh("combatStart", {combat: this, actors});
    }
  }

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

  /** @inheritDoc */
  _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
    if ( collection !== "combatants" ) return;
    this.#onModifyCombatants(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(documents, options);
  }

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

  /**
   * Shared actions taken when Combatants are modified within this Combat document.
   * @param {Combatant[]} combatants The array of created Combatant documents
   * @param {object} options         Options modifying the operation
   */
  #onModifyCombatants(combatants, options) {
    const {combatTurn, turnEvents, render} = options;
    this._refreshTokenHUD(combatants);
    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 ( this.isView && (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;
        }
      }
    }
    else {

      // Dispatch events when the turn order is changed
      const combatantChanged = current.combatantId !== previous.combatantId;
      const roundOrTurnChanged = (current.round !== previous.round) || (current.turn !== previous.turn);
      if ( combatantChanged && !roundOrTurnChanged ) {
        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});
      }

      // Refresh the ActiveEffect registry on round or turn rewind
      else if ( roundOrTurnChanged ) {
        const context = {
          combat: this,
          round: current.round,
          turn: current.turn,
          actors: new Set(this.combatants.map(c => c.actor))
        };
        ActiveEffect$1.registry.refresh("combatRewind", context);
      }
    }
  }

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

  /**
   * 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);
    await ActiveEffect$1.registry.refresh("turnEnd", {...context, combat: this});
    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);
    await ActiveEffect$1.registry.refresh("roundEnd", {...context, combat: this});
    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);
    await ActiveEffect$1.registry.refresh("roundStart", {...context, combat: this});
    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);
    await this._clearMovementHistoryOnStartTurn(combatant, context);
    await ActiveEffect$1.registry.refresh("turnStart", {...context, combat: this});
    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.
   * The default implementation clears the movement histories of all Combatants.
   * @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 this.clearMovementHistories();
  }

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

  /**
   * 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 v14
   * @ignore
   */
  getCombatantByActor(actor) {
    foundry.utils.logCompatibilityWarning("Combat#getCombatantByActor is deprecated. "
      + "Please use Combat#getCombatantsByActor instead.", {since: 14, until: 15});
    const combatants = this.getCombatantsByActor(actor);
    return combatants?.[0] || null;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  getCombatantByToken(token) {
    foundry.utils.logCompatibilityWarning("Combat#getCombatantByToken is deprecated. "
      + "Please use Combat#getCombatantsByToken instead.", {since: 14, until: 15});
    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, Level, and User.
   * @param {object} [query]
   * @param {string|Scene} [query.scene]
   * @param {string|User} [query.user]
   * @param {object} [options={}]
   * @returns {Promise<FogExploration|null>}
   */
  static async load({scene, user}={}, options={}) {
    const sceneId = (typeof scene === "string") ? scene : (scene?.id ?? canvas.scene?.id ?? null);
    const sceneDoc = (typeof scene === "string") ? game.scenes.get(scene) : (scene ?? canvas.scene);
    user ??= game.user;
    if ( !sceneId || !sceneDoc || !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");
    }

    const level = sceneDoc._view;
    const userId = user.id;
    const collection = game.collections.get("FogExploration");

    // Union is only required in SHARED mode
    const requireUnion = user.isSelf && (sceneDoc.fog.mode === CONST.FOG_EXPLORATION_MODES.SHARED);

    const matchesTriple = d => {
      const dUserId = d?.user?.id ?? d?.user;
      const dSceneId = d?.scene?.id ?? d?.scene;
      return (dUserId === userId) && (dSceneId === sceneId) && (d.level === level);
    };

    // Return cached exploration only when we don't need shared union docs
    if ( user.isSelf && !requireUnion ) {
      const cached = collection.find(matchesTriple);
      if ( cached ) return cached;
    }

    // Request exploration documents (server may return multiple docs when sharedFog is enabled)
    const query = {scene: sceneId, level, user: userId};
    const docs = await this.database.get(this, {query, ...options});

    // Cache returned documents for self loads
    if ( user.isSelf ) {

      // Replace cached docs for this scene+level when unionization is requested
      if ( requireUnion ) {
        for ( const doc of collection.filter(d => {
          const dSceneId = d?.scene?.id ?? d?.scene;
          return (dSceneId === sceneId) && (d.level === level);
        }) ) {
          collection.delete(doc.id);
        }
      }
      for ( const doc of docs ) {
        if ( doc?.id ) collection.set(doc.id, doc);
      }
    }

    // Return the requested user doc and prefer newest if duplicates exist
    const candidates = docs.filter(matchesTriple);
    if ( !candidates.length ) return null;

    let exploration = candidates[0];
    for ( const doc of candidates ) {
      if ( (doc.timestamp ?? 0) > (exploration.timestamp ?? 0) ) exploration = doc;
    }

    if ( exploration?.id && 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 ) return;

    const dUserId = this.user?.id ?? this.user;
    const dSceneId = this.scene?.id ?? this.scene;

    if ( (dUserId === game.user.id)
      && (dSceneId === canvas.scene?.id)
      && (this.level === canvas.scene?._view) ) {
      canvas.fog.load();
    }
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( options.loadFog === false ) return;

    const dUserId = this.user?.id ?? this.user;
    const dSceneId = this.scene?.id ?? this.scene;

    if ( (dUserId === game.user.id)
      && (dSceneId === canvas.scene?.id)
      && (this.level === canvas.scene?._view) ) {
      canvas.fog.load();
    }
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( options.loadFog === false ) return;

    const dUserId = this.user?.id ?? this.user;
    const dSceneId = this.scene?.id ?? this.scene;

    if ( (dUserId === game.user.id)
      && (dSceneId === canvas.scene?.id)
      && (this.level === canvas.scene?._view) ) {
      canvas.fog.load();
    }
  }
};

/**
 * @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";
 * @import {DatabaseWriteOperation} from "@common/abstract/_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(_loc("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;
      const FolderConfig = folder._getSheetClass();
      new 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: _loc(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 scene = foundry.documents.Scene.implementation.fromSource(data);
          const { thumb } = await scene.createThumbnail();
          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
    /** @type {DatabaseWriteOperation[]} */
    const batch = [];
    const updateOptions = {pack: pack.collection, diff: false, recursive: false, render: false};
    const createOptions = {pack: pack.collection, keepId: true, render: false};
    if ( foldersToUpdate.length ) {
      batch.push({action: "update", documentName: "Folder", updates: foldersToUpdate, ...updateOptions});
    }
    if ( foldersToCreate.length ) {
      batch.push({action: "create", documentName: "Folder", data: foldersToCreate, ...createOptions});
    }

    // Create and update Documents
    const documentName = pack.documentName;
    if ( documentsToUpdate.length ) {
      batch.push({action: "update", documentName, updates: documentsToUpdate, ...updateOptions});
    }
    if ( documentsToCreate.length ) {
      batch.push({action: "create", documentName, data: documentsToCreate, ...createOptions, keepId: options.keepId});
    }
    await foundry.documents.modifyBatch(batch);

    // Re-render the pack
    ui.notifications.info("FOLDER.ExportDone", {format: {compendium: pack.collection,
      type: _loc(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|CompendiumCollection|null} [pack=null]  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=null, 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: _loc(getDocumentClass$1(this.type).metadata.label)
      }});
    }

    // Render the HTML form
    const packId = typeof pack === "string" ? pack : pack?.collection;
    const selectedPack = packs.find(p => p.collection === packId);
    const folders = selectedPack?._formatFolderSelectOptions() ?? [];
    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: selectedPack?.collection ?? packId,
      merge: options.merge ?? true,
      keepId: options.keepId ?? true,
      keepFolders: options.keepFolders ?? true,
      hasFolders: folders.length > 0,
      folders
    });
    const content = document.createElement("div");
    content.innerHTML = html;

    // Display it as a dialog prompt
    return foundry.applications.sidebar.apps.FolderExport.prompt({
      window: {title: `${_loc("FOLDER.ExportTitle")}: ${this.name}`},
      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 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                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickDocumentLink(event) {
    if ( !this.sheet ) return;
    const isV2 = this.sheet instanceof foundry.applications.api.ApplicationV2;
    const isDetached = (window.document !== event.target.ownerDocument);
    if ( !this.sheet.rendered && isV2 && isDetached ) return this.sheet.detachWindow({ force: true });
    return this.sheet.render({ force: true });
  }

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

  /**
   * 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"].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.isPlainObject(scope) ) {
          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>${_loc("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 !== false ) {
      data.playing = false;
      data.seed = null;
      for ( const s of data.sounds ) {
        s.playing = false;
        s.pausedTime = null;
      }
    }
    return data;
  }
};

/**
 * @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 ( typeof fn === "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 ( typeof fn === "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";
 * @import {TokenPanningOptions} from "@client/_types.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|null}
     */
    this.mouseInteractionManager = null;

    // Allow objects to be culled when off-screen
    this.cullable = true;

    // Initialize preview type
    this._previewType = !this.document.id ? "api" : null;
  }

  /* -------------------------------------------- */
  /*  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: {propagate: ["refreshVisibility"]},
    refreshVisibility: {}
  };

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

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

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

  /**
   * Is this placeable currently visible?
   * @type {boolean}
   */
  get isVisible() {
    if ( this.isPreview ) return true;
    if ( this.hasPreview ) return false;
    if ( this.controlled ) return true;
    if ( this.isFilteredOut ) return false;
    if ( game.user.isGM ) return true;
    if ( this.document.schema.has("author") && this.document.author?.isSelf ) return true;
    if ( this.document.hidden ) return false;
    return true;
  }

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

  /**
   * Is the placeable currently interactable?
   * @type {boolean}
   */
  get isInteractable() {
    if ( this.isPreview ) return false;
    if ( !this.layer.active ) return false;
    if ( !ui.controls.tool?.interaction ) return false;
    if ( this.document.locked ) return false;
    if ( this.document.schema.has("author") && !this.document.author?.isSelf && !game.user.isGM ) return false;
    return true;
  }

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

  /**
   * The mouse interaction state of this placeable.
   * @type {MouseInteractionManager.INTERACTION_STATES|undefined}
   */
  get interactionState() {
    return this._original?.mouseInteractionManager?.state ?? this.mouseInteractionManager?.state;
  }

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

  /**
   * Is this object is excluded by the current filter in the placeable tab.
   * @type {boolean}
   */
  get isFilteredOut() {
    return !this.isPreview && this.layer.active && this.document.visible
      && (ui.placeables?.isEntryVisible(this) === false);
  }

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

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

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

  /**
   * The preview type, if any.
   * @type {"dragging"|"controls|"wheel"|"creation"|"config"|"api"|null}
   */
  get previewType() {
    return this._previewType;
  }

  /**
   * The preview type, if any.
   * @type {"dragging"|"controls"|"wheel"|"creation"|"config"|"api"|null}
   * @internal
   */
  _previewType;

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

  /**
   * Is this placeable object a temporary preview?
   * @type {boolean}
   */
  get isPreview() {
    return this._previewType !== null;
  }

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

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

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

  /**
   * 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.
   * @param {boolean} [options.cut=false]       Is cut operation?
   * @returns {object}                          The create or update data
   * @internal
   */
  _pasteObject(offset, {hidden=false, snap=true, cut=false}={}) {
    const {x, y} = this.document;
    let position = {x: x + offset.x, y: y + offset.y};
    if ( snap ) {
      if ( this.scene === canvas.scene ) position = this.getSnappedPosition(position);
      else {
        const document = this.document.clone({}, {parent: canvas.scene});
        const object = new this.layer.constructor.placeableClass(document);
        document._object = object;
        position = object.getSnappedPosition(position);
        object.destroy({children: true});
      }
    }
    let levels;
    if ( this.document.schema.has("levels") ) {
      if ( !this.document.levels.size || (this.scene !== canvas.scene) ) levels = [];
      else if ( this.document.levels.has(canvas.level.id) ) levels = [...this.document.levels];
      else levels = [canvas.level.id];
    }
    const d = canvas.dimensions;
    return {
      x: Math.clamp(position.x, 0, d.width - 1),
      y: Math.clamp(position.y, 0, d.height - 1),
      hidden: hidden || this.document._source.hidden,
      levels
    };
  }

  /* -------------------------------------------- */
  /*  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) {
    if ( flags.refreshState ) this._refreshState();
    if ( flags.refreshVisibility ) this._refreshVisibility();
  }

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

  /**
   * Refresh the visibility of the placeable.
   * @protected
   */
  _refreshVisibility() {
    const wasVisible = this.visible;
    this.visible = this.isVisible;
    if ( this.visible !== wasVisible ) MouseInteractionManager.emulateMoveEvent();
  }

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

  /**
   * Refresh the displayed state of the placeable.
   * Used to update aspects of the placeable which change based on the user interaction state.
   * @protected
   */
  _refreshState() {
    const oldEventMode = this.eventMode;
    this.eventMode = this.isInteractable ? "static" : "none";
    if ( this.eventMode !== oldEventMode ) MouseInteractionManager.emulateMoveEvent();
    this.alpha = this._getTargetAlpha();
    this.zIndex = this.isPreview ? 3 : this.controlled ? 2 : this.hover ? 1 : 0;
  }

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

  /**
   * Clear the display of the existing object on redraw.
   * This function is called in {@link PlaceableObject#draw} before the {@link PlaceableObject#_draw} call.
   * @protected
   */
  _clear() {
    this.removeChildren().forEach(c => c.destroy({children: true}));
  }

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

  /** @inheritdoc */
  destroy(options) {
    if ( !this.isPreview ) this.release();
    const layer = this.layer;
    if ( layer.hover === this ) layer.hover = null;
    if ( this.mouseInteractionManager?.state > MouseInteractionManager.INTERACTION_STATES.HOVER ) {
      this.mouseInteractionManager.interactionData.cancelled = true;
      this.mouseInteractionManager.cancel();
    }
    MouseInteractionManager.emulateMoveEvent();
    if ( this._original?._preview === this ) 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;
      if ( this.destroyed ) return;
      const wasVisible = this.visible;
      const wasRenderable = this.renderable;
      this.visible = false;
      this.renderable = false;
      this._clear();
      if ( this.hasActiveHUD ) this.layer.hud.close();
      if ( this.mouseInteractionManager?.state > MouseInteractionManager.INTERACTION_STATES.HOVER ) {
        this.mouseInteractionManager.interactionData.cancelled = true;
        this.mouseInteractionManager.cancel();
      }
      MouseInteractionManager.emulateMoveEvent();
      await this._draw(options);
      if ( this.destroyed ) return;
      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;

    // Cancel drag workflow if the object becomes locked
    if ( ("locked" in changed) && this.document.locked
      && (this.mouseInteractionManager.state >= this.mouseInteractionManager.states.DRAG) ) {
      this.mouseInteractionManager.cancelled = true;
      this.mouseInteractionManager.cancel();
      ui.notifications.warn("CONTROLS.ObjectIsLocked", {format: {type: _loc(this.document.constructor.metadata.label)}});
    }
  }

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

  /**
   * Define additional steps taken when an existing placeable object of this type is deleted
   * @param {object} options
   * @param {string} userId
   * @protected
   */
  _onDelete(options, userId) {}

  /* -------------------------------------------- */
  /*  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. Default: `true`.
   * @param {boolean} [options.renderSidebar=true]    Re-render the sidebar. Default: `true`.
   * @param {boolean} [options.isNew=false]           Was this object just created? Default: `false`.
   * @param {boolean} [options.force=false]           Control the object even if it is not interactable? Default: `false`.
   * @param {boolean|TokenPanningOptions} [options.pan=false]  Pan to this Token? Only applies to Token. Default: `false`.
   * @param {boolean} [options.chain=false]           Control all Walls that are linked with this Wall. Only applies to Wall. Default: `false`.
   * @returns {boolean}                               A flag denoting whether control was successful.
   */
  control(options={}) {
    if ( this.isPreview ) throw new Error("Preview objects cannot be controlled");
    if ( !this.layer.options.controllableObjects ) return false;
    if ( !this.layer.active ) return false;
    let controlledOrReleasedObject = false;

    // Release other controlled objects
    try {
      if ( options.releaseOthers !== false ) {
        for ( const object of this.layer.controlled ) {
          if ( object === this ) continue;
          object.release({renderSidebar: false});
          controlledOrReleasedObject = true;
        }
      }

      // Bail out if this object is already controlled, or not controllable
      if ( this.#controlled ) return true;
      const ignoreInteractable = (options.force === true) || (options.isNew === true);
      if ( (!ignoreInteractable && !this.isInteractable) || !this._canControl(game.user) ) return false;

      // Toggle control status
      this.#controlled = true;
      this.layer.controlledObjects.set(this.id, this);
      controlledOrReleasedObject = true;

      // 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;
    } finally {
      if ( (options.renderSidebar !== false) && controlledOrReleasedObject ) {
        ui.placeables.render();
        if ( game.activeTool === "select" ) ui.placeablesPalette?.render();
      }
    }
  }

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

  /**
   * 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});
    MouseInteractionManager.emulateMoveEvent();
  }

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

  /**
   * Release control over a PlaceableObject, removing it from the controlled set
   * @param {object} options                   Options which modify the releasing workflow
   * @param {boolean} [options.renderSidebar]  Re-render the sidebar.
   * @returns {boolean}                        A Boolean flag confirming the object was released.
   */
  release(options={}) {
    if ( this.isPreview ) throw new Error("Preview objects cannot be released");
    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);
    if ( options.renderSidebar !== false ) {
      ui.placeables.render();
      if ( game.activeTool === "select" ) ui.placeablesPalette?.render();
    }
    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});
    MouseInteractionManager.emulateMoveEvent();
  }

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

  /**
   * 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._previewType = "api";
    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 + 1.5 ) direction |= D.LEFT;
    } else if ( dx > 0 ) {
      if ( position.x >= snapped.x - 1.5 ) direction |= D.RIGHT;
    }
    if ( dy < 0 ) {
      if ( position.y <= snapped.y + 1.5 ) direction |= D.UP;
    } else if ( dy > 0 ) {
      if ( position.y >= snapped.y - 1.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);
    if ( (dx === 0) && (dy === 0) ) {
      shifted.x = position.x;
      shifted.y = position.y;
    } else {
      shifted.x = Math.round(shifted.x - biasX);
      shifted.y = Math.round(shifted.y - biasY);
    }
    if ( dz === 0 ) shifted.elevation = position.elevation;
    return shifted;
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /**
   * Activate interactivity for the Placeable Object
   */
  activateListeners() {
    this.mouseInteractionManager = this._createInteractionManager();
    this.mouseInteractionManager?.activate();
  }

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

  /**
   * Create a standard MouseInteractionManager for the PlaceableObject
   * @returns {MouseInteractionManager|null}
   * @protected
   */
  _createInteractionManager() {
    if ( this.isPreview ) return null;

    // 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.layer.active && 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.layer.active && 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) {
    return this.layer.active && !!ui.controls.tool?.control && 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.layer.active && 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.locked ) {
      if ( notify ) ui.notifications.warn("CONTROLS.ObjectIsLocked",
        {format: {type: _loc(this.document.constructor.metadata.label)}});
      return false;
    }

    // Prevent dragging another object if currently being previewed
    const manager = event.interactionData.object?.mouseInteractionManager;
    if ( (!manager || (manager.state < manager.states.DRAG)) && this.layer.preview?.children.length ) {
      if ( notify ) ui.notifications.warn("CONTROLS.ObjectConfigured", {localize: true});
      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} [event]  The DOM interaction event or 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} event          The triggering canvas or DOM interaction event
   * @param {object} [options]                         Options which customize event handling
   * @param {boolean} [options.hoverOutOthers=false]   Trigger hover-out behavior on sibling objects
   * @param {boolean} [options.updateLegend=true]      Highlight corresponding entry in the sidebar legend.
   * @returns {boolean|void}
   * @protected
   */
  _onHoverIn(event, {hoverOutOthers=false, updateLegend=true}={}) {
    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);
    if ( updateLegend ) ui.placeables.hoverEntry(this, true);
  }

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

  /**
   * Actions that should be taken for this Placeable Object when a mouseout event occurs
   * @param {PIXI.FederatedEvent|Event} event          The triggering canvas or DOM interaction event
   * @param {object} [options]                         Options which customize event handling
   * @param {boolean} [options.updateLegend=true]      Highlight corresponding entry in the sidebar legend.
   * @protected
   */
  _onHoverOut(event, {updateLegend=true}={}) {
    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);
    if ( updateLegend ) ui.placeables.hoverEntry(this, false);
  }

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

  /**
   * 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();
      c._previewType = "dragging";
      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;
    event.interactionData.cancelOnPause = true;

    // Notes are temporarily not interactable
    canvas.notes.interactiveChildren = false;
  }

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

  /**
   * 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() {
    this._original.renderFlags.set({refreshState: true});
    if ( this._original.sheet.rendered && this._original.sheet._preview.rendered ) {
      this.document.updateSource(this._original.sheet._preview.toObject());
      this._original.sheet._destroyPreview();
    }
  }

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

  /**
   * 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() {
    if ( !this._original ) return;
    this._original.renderFlags.set({refreshState: true});
    if ( this._original.sheet.rendered && !this._original.sheet._preview.rendered ) {
      this._original.sheet._initializePreview();
    }
  }

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

  /**
   * 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} = event.interactionData;
    const origin = this.center;

    // 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.updateSource(position);
      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) {
    const {clones} = event.interactionData;
    if ( !clones ) return false;

    // Perform database updates using dropped data
    let result = this._prepareDragLeftDropUpdates(event);
    if ( !result ) return;
    if ( !Array.isArray(result[0]) ) result = [result];
    event.interactionData.clearPreviewContainer = false;
    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 ) {
      const {x, y} = clone.document;
      updates.push({_id: clone._original.id, x, y});
    }
    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) {
    try {
      await canvas.scene.updateEmbeddedDocuments(this.document.documentName, updates, options);
    } finally {
      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();
    }

    // Reenable notes interactivity
    canvas.notes.interactiveChildren = canvas.notes.objects.visible;
  }

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

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

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

  /**
   * @deprecated since v14
   * @ignore
   */
  clear() {
    foundry.utils.logCompatibilityWarning("PlaceableObject#clear has been deprecated without replacement. "
      + "It no longer performs any action.", {since: 14, until: 16, once: true});
    return this;
  }
}

/**
 * @import {Point} from "@common/_types.mjs";
 * @import {EdgeRestrictionType, EdgeDirection, EdgeSenseType} from "@common/constants.mjs";
 * @import {LineIntersection} from "@common/utils/_types.mjs";
 * @import PolygonVertex from "./vertex.mjs";
 * @import {EdgeCreationOptions, EdgeThresholdData} 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 {EdgeCreationOptions} [options]   Additional options which describe the edge
   */
  constructor(a, b, {id, object, direction, type, light, darkness, move, sight, sound, threshold=null, priority}={}) {
    this.a = new PIXI.Point(a.x, a.y);
    this.b = new PIXI.Point(b.x, b.y);
    this.type = type || "wall";
    if ( id ) this.id = id;
    else if ( object instanceof PlaceableObject ) this.id = `${object.sourceId}`;
    else if ( object instanceof Document ) this.id = `${object.documentName}.${object.id}`;
    this.object = object;
    this.direction = direction ?? CONST.EDGE_DIRECTIONS.BOTH;
    this.light = light ?? CONST.EDGE_SENSE_TYPES.NONE;
    this.darkness = darkness ?? CONST.EDGE_SENSE_TYPES.NONE;
    this.move = move ?? CONST.EDGE_SENSE_TYPES.NONE;
    this.sight = sight ?? CONST.EDGE_SENSE_TYPES.NONE;
    this.sound = sound ?? CONST.EDGE_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).normalize();
  }

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

  /**
   * 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 {EdgeDirection}
   */
  direction;

  /**
   * A string used to uniquely identify this edge, if any.
   * @type {string|undefined}
   */
  id;

  /**
   * The Document/PlaceableObject the edge belongs to, if any.
   * @type {Document|PlaceableObject|undefined}
   */
  object;

  /**
   * How this edge restricts light.
   * @type {EdgeSenseType}
   */
  light;

  /**
   * How this edge restricts darkness.
   * @type {EdgeSenseType}
   */
  darkness;

  /**
   * How this edge restricts movement.
   * @type {EdgeSenseType}
   */
  move;

  /**
   * How this edge restricts sight.
   * @type {EdgeSenseType}
   */
  sight;

  /**
   * How this edge restricts sound.
   * @type {EdgeSenseType}
   */
  sound;

  /**
   * Specialized threshold data for this edge.
   * @type {EdgeThresholdData|null}
   */
  threshold;

  /**
   * Record other edges which this one intersects with by level.
   * @type {{[level: string]: {edge: Edge; intersection: LineIntersection}[]}}
   */
  intersections = {};

  /**
   * A PolygonVertex instance.
   * Used as part of ClockwiseSweepPolygon computation.
   * @type {PolygonVertex|undefined}
   */
  vertexA;

  /**
   * A PolygonVertex instance.
   * Used as part of ClockwiseSweepPolygon computation.
   * @type {PolygonVertex|undefined}
   */
  vertexB;

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

  /**
   * Is this edge limited for a particular type?
   * @param {EdgeRestrictionType} type
   * @returns {boolean}
   */
  isLimited(type) {
    return this[type] === CONST.EDGE_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 = Object.entries(this.intersections).reduce((obj, [level, intersections]) => {
      obj[level] = [...intersections];
      return obj;
    }, {});
    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.EDGE_SENSE_TYPES.PROXIMITY) ) return false; // Threshold behavior does not apply
    const proximity = t === CONST.EDGE_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.EDGE_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.EDGE_DIRECTIONS.BOTH;
    return orientation < 0 ? CONST.EDGE_DIRECTIONS.LEFT : CONST.EDGE_DIRECTIONS.RIGHT;
  }

  /* -------------------------------------------- */
  /*  Intersection Management                     */
  /* -------------------------------------------- */

  /**
   * Identify intersections between a provided iterable of edges.
   * @param {Iterable<Edge>} edges    An iterable of edges
   * @param {string} level            The ID of the Level the edges are in
   */
  static identifyEdgeIntersections(edges, level) {

    // Sort edges by their north-west x value, breaking ties with the south-east x value
    const sorted = [];
    for ( const edge of edges ) {
      delete edge.intersections[level]; // 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, level);
      }
    }
  }

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

  /**
   * Record the intersections between two edges.
   * @param {Edge} other          Another edge to test and record
   * @param {string} level        The ID of the Level the edges are in
   */
  recordIntersections(other, level) {
    if ( other === this ) return;
    const i = this.getIntersection(other);
    if ( !i ) return;
    this.intersections[level] ??= [];
    this.intersections[level].push({edge: other, intersection: i});
    other.intersections[level] ??= [];
    other.intersections[level].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.
   * @param {string} level    The ID of the Level the edges are in
   */
  removeIntersections(level) {
    const intersections = this.intersections[level];
    if ( !intersections ) return;
    delete this.intersections[level];
    for ( const {edge: other} of intersections ) {
      const otherIntersections = other.intersections[level];
      otherIntersections.findSplice(e => e.edge === this);
      if ( !otherIntersections.length ) delete other.intersections[level];
    }
  }
}

/**
 * @import {CanvasViewPosition, DeepReadonly, ElevatedPoint} from "@client/_types.mjs";
 * @import {SceneDimensions, SceneViewOptions, RegionSurface, TokenData, TokenMovementInstruction, TokenMovementOptions,
 *   TokenMovementWaypoint, TokenResizingInstruction} from "./_types.mjs";
 * @import TokenDocument from "./token.mjs";
 * @import {BaseShapeData} from "@common/data/_module.mjs";
 * @import {EdgeRestrictionType, WallRestrictionType} from "@common/constants.mjs";
 * @import Region from "@client/canvas/placeables/region.mjs";
 * @import RegionDocument from "./region.mjs";
 * @import User from "./user.mjs";
 * @import SceneManager from "@client/canvas/scene-manager.mjs";
 * @import {DatabaseCreateOperation, DatabaseUpdateOperation} from "@common/abstract/_types.mjs";
 * @import {LevelTexture, SceneData} from "@common/documents/_types.mjs";
 * @import Level from "./level.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$2 = 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 and which level is viewed
   * @type {string|null}
   * @internal
   */
  _view = null;

  /**
   * The grid instance.
   * @type {BaseGrid}
   */
  grid = this.grid; // Workaround for subclass property instantiation issue.

  /**
   * The gridless version of the grid instance.
   * @type {GridlessGrid}
   */
  gridlessGrid = this.gridlessGrid; // 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.

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

  /**
   * The restriction types that are pending to be updated.
   * @type {Set<string>}
   */
  #pendingRegionRestrictionTypes = new Set();

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

  /**
   * The regions that are pending to have their shape constraints updated.
   * @type {Set<Region>}
   */
  #regionShapeConstraintsToUpdate = new Set();

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

  /**
   * A throttled function that updates the shape constraints of all Regions the current User is designated for
   * for all pending restriction types.
   * @type {() => void}
   */
  #throttleUpdateRegionShapeConstraints = foundry.utils.throttle(this.#updateRegionShapeConstraints.bind(this), 250);

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

  /**
   * The surfaces in this Scene.
   * @type {DeepReadonly<RegionSurface[]>|undefined}
   */
  #surfaces;

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

  /**
   * The cache of surfaces in this Scene.
   * @type {Record<string, DeepReadonly<RegionSurface[]>>|undefined}
   */
  #surfacesCache;

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

  /**
   * Have the edges of this Scene been initialized already?
   *
   * The property becomes true we moment {@link Scene#initializeEdges} is called.
   * @type {boolean}
   */
  get initializedEdges() {
    return this.#initializedEdges;
  }

  #initializedEdges = false;

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

  /**
   * Edges representing the outer boundaries of the game canvas.
   * @type {Edge[]}
   */
  #outerBoundaryEdges = [];

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

  /**
   * Edges representing the inner boundaries of the scene rectangle.
   * @type {Edge[]}
   */
  #innerBoundaryEdges = [];

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

  /**
   * The levels that are available to this User. By default GMs and scenes without token vision can access all levels;
   * players can only access levels where they have OBSERVER of a Token.
   * A SceneManager may override this via {@link SceneManager#_getAvailableLevels}.
   * @type {Set<Level>}
   */
  get availableLevels() {
    const manager = canvas.scene === this ? canvas.manager : foundry.canvas.Canvas.getSceneManager(this);
    return this._getAvailableLevels({ manager });
  }

  /**
   * @type {Set<Level>|null}
   * @protected
   */
  _availableLevels = null;

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

  /**
   * 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                               */
  /* -------------------------------------------- */

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

    /**
     * Maintain a list of BaseShapeData instances that are placed in this Scene.
     * @type {IterableWeakSet<BaseShapeData>}
     * @internal
     */
    Object.defineProperty(this, "_dependentShapes", {value: new foundry.utils.IterableWeakSet()});
  }

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

  /** @inheritDoc */
  _updateCommit(copy, diff, options, state) {
    super._updateCommit(copy, diff, options, state);
    if ( "grid" in diff ) {
      for ( const region of this.regions ) region._onGridChange(diff.grid);
      for ( const shape of this._dependentShapes ) shape._onGridChange(diff.grid);
    }
    if ( "regions" in diff ) {
      for ( const region of this.regions ) region._clearPolygonTree();
    }
  }

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

  /**
   * Pull the specified users to this Scene.
   * @param {Iterable<User|string>} users     The User documents or IDs.
   * @param {SceneViewOptions} [viewOptions]  The view options.
   * @example Pull all users to the viewed scene.
   * ```js
   * canvas.scene.pullUsers(game.users);
   * ```
   */
  pullUsers(users, viewOptions={}) {
    if ( !game.user.isGM ) throw new Error("You must be a GM to pull players to a scene");
    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, viewOptions);
    }
  }

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

  /**
   * Set this Scene as currently active.
   * @param {object} [options]  Additional options
   * @param {SceneViewOptions} [options.viewOptions]  The view options
   * @param {boolean} [options.pullUsers]  Pull all Users to this Scene if it is already active?
   * @param {Partial<Omit<SceneData, "active">>} [options.updateData]                   Additional update data
   * @param {Partial<Omit<DatabaseUpdateOperation, "updates">>} [options.updateOptions] The update operation options
   * @returns {Promise<this>}  A Promise which resolves to this Scene once it has been successfully activated
   */
  async activate({viewOptions={}, pullUsers=false, updateData, updateOptions}={}) {
    if ( !game.user.isGM ) throw new Error("You must be a GM to activate a scene");
    if ( !this.active ) pullUsers = false;
    updateData = {...updateData, active: true};
    updateOptions = {...updateOptions, viewOptions};
    await this.update(updateData, updateOptions);
    if ( pullUsers ) this.pullUsers(game.users, viewOptions);
    return this;
  }

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

  /**
   * Set this scene as the current view
   * @param {SceneViewOptions} [options]   The view options
   * @returns {Promise<Scene>}
   */
  async view(options={}) {

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

    // 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
    const sceneChanged = canvas.scene?.id !== this.id;
    const levelChanged = (options.level !== undefined) && (options.level !== canvas.level?.id);
    if ( canvas.initialized && canvas.scene && sceneChanged ) {
      delete canvas.scene.apps[ui.placeables.id];
      ui.placeables._onDeactivate();
    }
    if ( canvas.initialized && (sceneChanged || levelChanged) ) {
      console.log(`Foundry VTT | Viewing Scene ${this.name}`);
      canvas._viewOptions = options;
      await canvas.draw(this);
      canvas.scene?.render();
      this.render();
    }
    if ( levelChanged ) ui.placeablesPalette?.render();

    // Render apps for the collection
    this.collection.render();
    ui.combat.render({ combat: null });
    return this;
  }

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

  /**
   * Unview this Scene, if it is the viewed Scene, clearing the game canvas.
   */
  async unview() {
    if ( !this._view ) return;
    delete canvas.scene.apps[ui.placeables.id];
    ui.placeables._onDeactivate();

    // 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() {
    Scene.#prepareGrids(this);
    this.dimensions = this.getDimensions();
    this.playlistSound = this.playlist ? this.playlist.sounds.get(this._source.playlistSound) : null;
  }

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

  /** @inheritDoc */
  prepareEmbeddedDocuments() {
    super.prepareEmbeddedDocuments();
    this._availableLevels = null;

    // Derived properties of all levels
    const levelsSorted = this.levels.contents.sort((a, b) => a.sort - b.sort);
    const levelsReverseSorted = levelsSorted.toReversed();
    Object.defineProperties(this.levels, {
      sorted: {
        value: levelsSorted,
        configurable: true,
        writable: false
      },
      reverseSorted: {
        value: levelsReverseSorted,
        configurable: true,
        writable: false
      }
    });
    for ( const [i, level] of this.levels.sorted.entries() ) {
      Object.defineProperty(level, "index", {value: i, configurable: true, enumerable: true, writable: false});
    }
  }

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

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();

    Object.defineProperty(this.fog, "overlay", {
      get: () => this.firstLevel?.fog?.src,
      configurable: true,
      enumerable: false
    });
  }

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

  /**
   * Prepare the grid instances from the grid config of this scene if it doesn't exist yet.
   * @param {Scene} scene
   */
  static #prepareGrids(scene) {
    const grid = scene.grid;
    if ( grid instanceof BaseGrid ) return;

    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
    scene.gridlessGrid = new GridlessGrid(config);
    if ( type === T.GRIDLESS ) scene.grid = scene.gridlessGrid;

    // Square grid
    else if ( type === T.SQUARE ) {
      config.diagonals = game.settings.get("core", "gridDiagonals");
      scene.grid = new SquareGrid(config);
    }

    // Hexagonal grid
    else 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);
      scene.grid = new HexagonalGrid(config);
    }

    else throw new Error("Invalid grid type");
  }

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

  /**
   * The levels that are available to this User in ascending order. By default GMs and scenes without token vision
   * can access all levels; players can only access levels where they have OBSERVER of a Token.
   * @param {object} [options]
   * @param {SceneManager|null} [options.manager]    A SceneManager whose {@link SceneManager#_getAvailableLevels}
   *                                                 override should be applied to the base set.
   * @returns {Set<Level>}
   * @protected
   */
  _getAvailableLevels({manager=null}={}) {
    if ( !this._availableLevels ) {
      if ( game.user.isGM || !this.tokenVision ) this._availableLevels = new Set(this.levels.sorted);
      else {
        this._availableLevels = new Set(this.tokens.reduce((arr, t) => {
          if ( !t.testUserPermission(game.user, "OBSERVER") ) return arr;
          const level = this.levels.get(t._source.level);
          if ( level ) arr.push(level);
          return arr;
        }, []).sort((a, b) => a.index - b.index));
      }
    }
    const managerLevels = manager?._getAvailableLevels(this._availableLevels);
    return managerLevels instanceof Set ? managerLevels : this._availableLevels;
  }

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

  /**
   * 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 = 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.shiftX;
    const sceneY = dimensions.y - this.shiftY;

    // 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 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}.
   * @param {Iterable<TokenDocument>} [tokens]  The Tokens whoses regions should be updates: if not provided, all Tokens
   *                                            will be updated.
   * @returns {Promise<TokenDocument[]>}        The array of Tokens whose regions changed
   */
  async updateTokenRegions(tokens=this.tokens) {
    const updates = [];
    for ( const token of 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});
  }

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

  /**
   * Update the shape constraints of all Regions the current User is designated for
   * (for the given restriction types).
   * @param {Iterable<EdgeRestrictionType>} [types]    The types to update. Default: all.
   */
  updateRegionShapeConstraints(types=CONST.EDGE_RESTRICTION_TYPES) {
    if ( !this.persisted ) throw new Error("A nonpersisted Document cannot be updated.");
    let added = false;
    for ( const type of types ) {
      this.#pendingRegionRestrictionTypes.add(type);
      added = true;
    }
    if ( added ) this.#throttleUpdateRegionShapeConstraints();
  }

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

  /**
   * Update the shape constraints of the given Regions if the current User is designated for it.
   * @param {Region} region
   * @internal
   */
  _updateRegionShapeConstraints(region) {
    this.#regionShapeConstraintsToUpdate.add(region);
    this.#throttleUpdateRegionShapeConstraints();
  }

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

  /**
   * Update the shape constraints of all Regions the current User is designated for
   * for the pending restriction types.
   */
  #updateRegionShapeConstraints() {
    canvas.app.ticker.addOnce(() => {
      const updates = [];
      const options = {noHook: true, _updateShapeConstraints: {}};
      for ( const region of this.regions ) {
        if ( !region.restriction.enabled || (!this.#pendingRegionRestrictionTypes.has(region.restriction.type)
          && !this.#regionShapeConstraintsToUpdate.has(region)) ) continue;
        const designatedUser = game.users.getDesignatedUser(u => u.active && region.canUserModify(u, "update")
          && (u.viewedScene === this.id) && region.includedInLevel(u.viewedLevel))
          ?? game.users.getDesignatedUser(u => u.active && region.canUserModify(u, "update"));
        if ( game.user !== designatedUser ) continue;
        updates.push({_id: region.id, _shapeConstraints: region._computeShapeConstraints()});
        options._updateShapeConstraints[region.id] = true;
      }
      this.#pendingRegionRestrictionTypes.clear();
      this.#regionShapeConstraintsToUpdate.clear();
      this.updateEmbeddedDocuments("Region", updates, options);
    }, undefined, PIXI.UPDATE_PRIORITY.PERCEPTION - 1);
  }

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

  /**
   * Move/resize multiple Tokens.
   * @param {{[id: string]: TokenMovementInstruction|TokenResizingInstruction}} instructions
   *   The movement/resizing instructions.
   * @param {Partial<Omit<DatabaseUpdateOperation, "updates"> & Omit<TokenMovementOptions, "id">} [options]
   *   Parameters of the update and movement operation.
   * @returns {Promise<{[id: string]: boolean}>} A Promise that resolves once all movement instructions are finished.
   *   The resolved value is an object with token IDs as keys and booleans as values that indicate whether the movement
   *   the token with the corresponding ID was completed (`true`) or stopped/prevented (`false`).
   * @see {@link foundry.documents.TokenDocument#move}
   * @see {@link foundry.documents.TokenDocument#resize}
   * @example
   * ```js
   * const results = await scene.moveTokens({
   *    // Moving the token to new position including additional token data
   *   "cGYT0rR0YbtFkhzT": {
   *     destination: {x: 100, y: 200, rotation: 45, texture: {tint: "#ff0000"}},
   *     showRuler: false, // This overrides `options.showRuler`
   *   },
   *   // Moving the token to along a path with multiple waypoints
   *   "wBFpJuZuleEtVNw1": {
   *     waypoints: [
   *       {x: 100, y: 200}, // Move to the position (100, 200)
   *       {elevation: 5, explicit: true}, // Move to elevation 5 indicating that the user placed this waypoint
   *       {x: 500, y: 500, checkpoint: true}, // Move to (500, 500): the movement can be stopped/paused here
   *       {width: 2, height: 2, depth: 2}, // Change size
   *       {x: 1000, action: "swim"}, // Swim to (1000, 500)
   *       {x: 0, y: 0, snapped: true}, // Move to (0, 0) indicating that (0, 0) is a snapped position for the token
   *       {elevation: 10} // Move to elevation 10 (the last waypoint is always a checkpoint automatically)
   *     ],
   *     autoRotate: true,
   *     constrainOptions: {ignoreWalls: true, ignoreCost: true} // Allow the token to move through walls, surfaces, and
   *                                                             // impassable terrain
   *   },
   *   // Resizing the token including additional token data
   *   "VupAIbzpX6SHqtaH": {
   *     dimensions: {width: 3, height: 3, depth: 3, rotation: 45, texture: {tint: "#ff0000"}}
   *   }
   * }, {
   *   showRuler: true // This applies to all instructions that do not define `showRuler`
   * })
   * if ( results["cGYT0rR0YbtFkhzT"] ) {
   *   // The movement of Token [cGYT0rR0YbtFkhzT] was completed: it arrived at the destination
   * } else {
   *   // The movement of Token [cGYT0rR0YbtFkhzT] was stopped or prevented
   * }
   * if ( results["wBFpJuZuleEtVNw1"] ) {
   *   // The movement of Token [wBFpJuZuleEtVNw1] was completed: it arrived at the destination
   * } else {
   *   // The movement of Token [wBFpJuZuleEtVNw1] was stopped or prevented
   * }
   * if ( results["VupAIbzpX6SHqtaH"] ) {
   *   // The resizing of Token [VupAIbzpX6SHqtaH] was completed
   * } else {
   *   // The resizing of Token [VupAIbzpX6SHqtaH] was prevented
   * }
   * ```
   */
  async moveTokens(instructions, options) {
    const toUpdate = [];
    const movement = {};
    for ( const [tokenId, instruction] of Object.entries(instructions) ) {
      if ( instruction.dimensions ) {
        const {dimensions, id, method, split, planned} = instruction;
        let {x, y, elevation, width, height, depth, shape, level, action, snapped, explicit, checkpoint,
          ...updateData} = dimensions;
        const token = this.tokens.get(tokenId, {strict: true});
        width ??= token._source.width;
        height ??= token._source.height;
        depth ??= token._source.depth;
        shape ??= token._source.shape;
        level ??= token._source.level;
        const center = token.getCenterPoint(token._source);
        const pivot = token.getCenterPoint({x: 0, y: 0, elevation: 0, width, height, shape});
        x = Math.round(center.x - pivot.x);
        y = Math.round(center.y - pivot.y);
        toUpdate.push({...updateData, _id: tokenId});
        movement[tokenId] = {waypoints: [{x, y, elevation, width, height, depth, shape, level, action, snapped,
          explicit, checkpoint}], id, method, split, planned, autoRotate: false, showRuler: false, constrainOptions:
          {ignoreWalls: true, ignoreCost: true}};
      } else {
        let {waypoints, id, method, split, planned, autoRotate, showRuler, constrainOptions, terrainOptions,
          measureOptions} = instruction;
        let updateData;
        if ( !waypoints ) {
          const {x, y, elevation, width, height, depth, shape, level, action, snapped, explicit, checkpoint,
            ...remaining} = instruction.destination;
          waypoints = [{x, y, elevation, width, height, depth, shape, level, action, snapped, explicit, checkpoint}];
          updateData = remaining;
        }
        toUpdate.push({...updateData, _id: tokenId});
        movement[tokenId] = {waypoints, id, method, split, planned, autoRotate, showRuler, constrainOptions,
          terrainOptions, measureOptions};
      }
    }
    const args = {result: {}};
    const updated = await this.updateEmbeddedDocuments("Token", toUpdate, {...options, movement, _movementArguments: args,
      _returnMovementPromise: true});
    const updatedById = updated.reduce((obj, token) => {
      obj[token.id] = token;
      return obj;
    }, {});
    const promises = [];
    const results = {};
    for ( const id of Object.keys(instructions) ) {
      const updatedToken = updatedById[id];
      const initialMovementId = args.result[id];
      if ( !initialMovementId || !updatedToken ) results[id] = false;
      else {
        const promise = updatedToken._returnedMovementPromises.get(initialMovementId);
        updatedToken._returnedMovementPromises.delete(initialMovementId);
        promises.push(promise.then(result => {
          results[id] = result;
        }));
      }
    }
    await Promise.all(promises);
    return results;
  }

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

  /**
   * Invalidate cached surface data.
   * @internal
   */
  _invalidateSurfaces() {
    this.#surfaces = undefined;
    this.#surfacesCache = undefined;
  }

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

  /**
   * Update surfaces if invalidated.
   */
  #updateSurfaces() {
    if ( this.#surfaces ) return;
    this.#surfaces = [];
    this.#surfacesCache = {};
    const attrs = ["light", "darkness", "move", "sight", "sound", "occlusion", "exposure", "culling"];
    for ( const region of this.regions ) {
      if ( region.hidden ) continue;
      const bottomSurface = {key: `${region.id},${region.elevation.bottom}`, region, elevation: region.elevation.bottom};
      const topSurface = {key: `${region.id},${region.elevation.top}`, region, elevation: region.elevation.top};
      for ( const a of attrs ) bottomSurface[a] = topSurface[a] = false;
      const flat = region.elevation.bottom === region.elevation.top;
      for ( const behavior of region.behaviors ) {
        if ( behavior.disabled || (behavior.type !== "defineSurface") ) continue;
        const placement = behavior.system.placement;
        if ( (placement === "bottom") || (placement === "both") || flat ) {
          for ( const a of attrs ) bottomSurface[a] ||= behavior.system[a];
        }
        if ( (placement === "top") || (placement === "both") || flat ) {
          for ( const a of attrs ) topSurface[a] ||= behavior.system[a];
        }
      }
      if ( attrs.some(a => bottomSurface[a]) ) this.#surfaces.push(bottomSurface);
      if ( !flat && attrs.some(a => topSurface[a]) ) this.#surfaces.push(topSurface);
    }
    this.#surfaces.sort((a, b) => a.elevation - b.elevation);
    Object.freeze(this.#surfaces);
  }

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

  /**
   * Get all surfaces or surfaces matching the filter, ordered by elevation in ascending order.
   * @param {object} [options]                    Additional options
   * @param {EdgeRestrictionType} [options.type]  Only return surfaces that restrict this type
   * @param {Level|string} [options.level]        Only return surfaces that are included in this Level
   * @param {boolean} [options.occlusion]   Only return surfaces that have this value as {@link RegionSurface#occlusion}
   * @param {boolean} [options.exposure]    Only return surfaces that have this value as {@link RegionSurface#exposure}
   * @param {boolean} [options.culling]     Only return surfaces that have this value as {@link RegionSurface#culling}
   * @returns {DeepReadonly<RegionSurface[]>}
   */
  getSurfaces({type, level, occlusion, exposure, culling}={}) {
    this.#updateSurfaces();
    const key = `${type || ""},${!level || (typeof level === "string") ? level || "" : level.id},`
      + `${occlusion === true ? "+" : occlusion === false ? "-" : ""},`
      + `${exposure === true ? "+" : exposure === false ? "-" : ""},`
      + `${culling === true ? "+" : culling === false ? "-" : ""}`;
    return this.#surfacesCache[key] ??= Object.freeze(this.#surfaces.filter(surface => {
      if ( (level !== undefined) && !surface.region.includedInLevel(level) ) return false;
      if ( (type !== undefined) && !surface[type] ) return false;
      if ( (occlusion !== undefined) && (surface.occlusion !== occlusion) ) return false;
      if ( (exposure !== undefined) && (surface.exposure !== exposure) ) return false;
      if ( (culling !== undefined) && (surface.culling !== culling) ) return false;
      return true;
    }));
  }

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

  /**
   * Test for surface collision for a movement between two points.
   * @param {ElevatedPoint} origin                 The origin.
   * @param {ElevatedPoint} destination            The destination.
   * @param {object} config                        Configuration.
   * @param {EdgeRestrictionType} [config.type]    The restriction type. Default: `"move"`.
   * @param {"any"|"all"|"closest"} [config.mode]  The collision mode. Default: `"any"`.
   * @param {"below"|"above"} [config.side]        The side of the surface that counts as colliding
   *                                               when the ray originates on the surface. Default: `"below"`.
   *                                               - `"below"`: Treats the surface as solid in the negative z-direction.
   *                                                 Rays originating on the surface will collide if they point downward
   *                                                 (z < 0) and will not collide if they point upward.
   *                                               - `"above"`: Treats the surface as solid in the positive z-direction.
   *                                                 Rays originating on the surface will collide if they point upward
   *                                                 (z > 0) and will not collide if they point downward.
   * @param {number} [config.tMin]                 Intersections of the ray and a surface with t-value less than
   *                                               `tMin` are not considered collisions. Default: `0`.
   * @param {number} [config.tMax]                 Intersections of the ray and a surface with t-value greater than
   *                                               `tMax` are not considered collisions. Default: `1`.
   * @param {Level|string} config.level            The Level or Level ID to test collision in.
   * @returns {boolean|ElevatedPoint|ElevatedPoint[]|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 `ElevatedPoint` instances.
   *                                                 - `"closest"`: Returns an `ElevatedPoint` instance or null.
   */
  testSurfaceCollision(origin, destination, {type="move", mode="any", side="below", tMin=0, tMax=1, level}) {
    if ( (origin.elevation === destination.elevation) || (tMin >= tMax) ) return mode === "any" ? false : (mode === "all" ? [] : null);

    const collisions = mode !== "any" ? [] : undefined;
    const down = origin.elevation > destination.elevation;
    if ( down ) [tMin, tMax] = [1 - tMax, 1 - tMin];
    const [lower, upper] = down ? [destination, origin] : [origin, destination];
    const deltaX = upper.x - lower.x;
    const deltaY = upper.y - lower.y;
    const bounds = new PIXI.Rectangle(
      lower.x + (deltaX * tMin),
      lower.y + (deltaY * tMin),
      deltaX * (tMax - tMin),
      deltaY * (tMax - tMin)
    ).normalize().pad(1);
    for ( const {region, elevation} of this.getSurfaces({type, level}) ) {
      if ( side === "above" ) {
        if ( elevation < lower.elevation ) continue;
        if ( elevation >= upper.elevation ) break;
      } else {
        if ( elevation <= lower.elevation ) continue;
        if ( elevation > upper.elevation ) break;
      }
      if ( !region.bounds.intersects(bounds) ) continue;
      const t = (elevation - lower.elevation) / (upper.elevation - lower.elevation);
      if ( (t < tMin) || (t > tMax) ) continue;
      const x = Math.mix(lower.x, upper.x, t);
      const y = Math.mix(lower.y, upper.y, t);
      const position = {x, y, elevation};
      if ( region.polygonTree.testPoint(position, 0.75) ) {
        if ( !collisions ) return true; // Mode is "any"
        collisions.push(position);
      }
    }

    if ( mode === "any" ) return false;

    if ( mode === "all" ) {
      return collisions.sort(down ? (a, b) => b.elevation - a.elevation : (a, b) => a.elevation - b.elevation);
    }

    // Mode is "closest"
    if ( !collisions.length ) return null;
    let collision = collisions[0];
    if ( down ) {
      for ( const c of collisions ) {
        if ( c.elevation > collision.elevation ) collision = c;
      }
    } else {
      for ( const c of collisions ) {
        if ( c.elevation < collision.elevation ) collision = c;
      }
    }
    return collision;
  }

  /* -------------------------------------------- */
  /*  Scene Levels                                */
  /* -------------------------------------------- */

  /**
   * Cycle the currently viewed Level for this Scene.
   * @param {-1|1} direction
   * @returns {Promise<void>}
   */
  async cycleLevel(direction) {
    direction = Math.sign(direction);
    if ( direction === 0 ) return;
    const availableLevels = Array.from(this.availableLevels);
    const currentIndex = availableLevels.findIndex(l => l.id === this._view) ?? 0;
    const nextIndex = Math.clamp(currentIndex + direction, 0, availableLevels.length - 1);
    const nextLevel = availableLevels[nextIndex];
    if ( !nextLevel ) return;
    await this.view({level: nextLevel.id});
  }

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

  /**
   * Get textures that should be used for the currently active level.
   * @returns {(LevelTexture & {level: Level; name: string; elevation: number; sort: number; zIndex: number;
   *   isBackground: boolean; isUpper: boolean})[]}
   * @internal
   */
  _configureLevelTextures() {
    if ( !this._view ) return [];
    const viewedLevel = this.levels.get(this._view);
    const textures = [];
    for ( const level of this.levels.sorted ) {
      const {isView, isVisible} = level;
      let {background, foreground} = level;
      background ??= {};
      foreground ??= {};
      if ( isView || (background.src && isVisible) ) {
        const isUpper = !isView && (level.elevation.bottom > viewedLevel.elevation.bottom);
        textures.push({level, name: "background", elevation: level.elevation.bottom, sort: 0, zIndex: 0, isBackground: true, isUpper, ...background});
      }
      if ( isView || (foreground.src && isVisible) ) {
        const isUpper = !isView && (level.elevation.top > viewedLevel.elevation.bottom);
        textures.push({level, name: "foreground", elevation: level.elevation.top, sort: 0, zIndex: 0, isBackground: false, isUpper, ...foreground});
      }
    }
    return textures;
  }

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

  /**
   * Reset the edges of this Scene.
   * @internal
   */
  _resetEdges() {
    if ( !this.#initializedEdges ) return;
    this.#initializedEdges = false;
    for ( const level of this.levels ) level.edges.clear();
    if ( this.isView ) this.initializeEdges();
  }

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

  /**
   * Initialize the edges of this Scene unless they already have been inititalized.
   */
  initializeEdges() {
    if ( this.#initializedEdges ) return;
    this.#initializedEdges = true;
    this.#initializeBoundaryEdges();
    for ( const wall of this.walls ) wall.initializeEdge();
    Hooks.callAll("initializeEdges", this);
  }

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

  /**
   * Initialize the boundary edges for this Scene.
   */
  #initializeBoundaryEdges() {

    // Delete existing boundary edges
    for ( const edge of this.#outerBoundaryEdges ) {
      for ( const level of this.levels ) level.edges.delete(edge.id);
    }
    for ( const edge of this.#innerBoundaryEdges ) {
      for ( const level of this.levels ) level.edges.delete(edge.id);
    }

    // Create boundary edges
    const d = this.dimensions;
    const createBoundaryEdges = (type, r) => {
      const top = new Edge({x: r.x, y: r.y}, {x: r.right, y: r.y}, {id: `${type}.top`, object: this, type});
      const right = new Edge({x: r.right, y: r.y}, {x: r.right, y: r.bottom}, {id: `${type}.right`, object: this, type});
      const bottom = new Edge({x: r.right, y: r.bottom}, {x: r.x, y: r.bottom}, {id: `${type}.bottom`, object: this, type});
      const left = new Edge({x: r.x, y: r.bottom}, {x: r.x, y: r.y}, {id: `${type}.left`, object: this, type});
      return [top, right, bottom, left];
    };
    this.#outerBoundaryEdges = createBoundaryEdges("outerBounds", d.rect);
    this.#innerBoundaryEdges = createBoundaryEdges("innerBounds", d.sceneRect);

    // Add new boundary edges
    for ( const edge of this.#outerBoundaryEdges ) {
      for ( const level of this.levels ) level.edges.set(edge.id, edge);
    }
    for ( const edge of this.#innerBoundaryEdges ) {
      for ( const level of this.levels ) level.edges.set(edge.id, edge);
    }
  }

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

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;

    if ( !this.levels.size && !data.levels?.length ) {
      const { background, backgroundColor, foreground, foregroundElevation, fog } = data;
      const level = {
        _id: this.constructor.metadata.defaultLevelId,
        name: foundry.documents.Level.defaultName({ parent: this })
      };
      const changes = { levels: [level], initialLevel: level._id };
      if ( foundry.utils.isPlainObject(background) || backgroundColor ) level.background = {};
      if ( backgroundColor ) level.background.color = backgroundColor;
      if ( foundry.utils.isPlainObject(background) ) {
        level.background.src = background.src;
        if ( ("offsetX" in background) && !("shiftX" in data) ) changes.shiftX = background.offsetX;
        if ( ("offsetY" in background) && !("shiftY" in data) ) changes.shiftY = background.offsetY;
      }
      if ( foreground ) level.foreground = { src: foreground };
      if ( foregroundElevation ) level.elevation = { top: foregroundElevation };
      if ( fog?.overlay ) level.fog = { src: fog.overlay };
      this.updateSource(changes);
    }

    // Create a base64 thumbnail for the scene
    if ( !("thumb" in data) && canvas.ready && this.initialLevel.background?.src ) {
      try {
        const t = await this.createThumbnail();
        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);

    // Register the ActiveEffects of any pre-included Token Actors
    for ( const token of this.tokens ) {
      const syntheticActor = token.delta?.syntheticActor;
      if ( syntheticActor ) foundry.documents.ActiveEffect.registry.addFromParent(syntheticActor);
    }

    // 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, options);
      if ( game.userId === userId ) game.playlists._onChangeScene(this);
    }
  }

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

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

    if ( Array.isArray(changed.levels) || !this.levels.size ) return;
    const levelDelta = { _id: this.firstLevel.id };
    for ( const [oldKey, newKey] of BaseScene._LEVELS_PROPERTY_MAP ) {
      const value = foundry.utils.getProperty(changed, oldKey);
      if ( value !== undefined ) foundry.utils.setProperty(levelDelta, newKey, value);
    }
  }

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

  /**
   * 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 shift
    const shiftX = sceneUpdateData.shiftX !== undefined ? (this.shiftX - sceneUpdateData.shiftY) : 0;
    const shiftY = sceneUpdateData.shiftY !== undefined ? (this.shiftY - sceneUpdateData.shiftY) : 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(_loc("SCENE.GridSizeError"));
      foundry.utils.setProperty(sceneUpdateData, "grid.size", gridSize);
    }

    const adjustPoint = (x, y, applyOffset=true) => {
      return {
        x: Math.round((x * translationScaleX) + (applyOffset ? paddingOffsetX + shiftX : 0) ),
        y: Math.round((y * translationScaleY) + (applyOffset ? paddingOffsetY + shiftY : 0) )
      };
    };

    // Placeables that have just a Position
    for ( const collection of ["tokens", "lights", "sounds"] ) {
      sceneUpdateData[collection] = this[collection].map(p => {
        const {x, y} = adjustPoint(p.x, p.y);
        return {_id: p.id, x, y, _movementHistory: null};
      });
    }

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

    // Region shapes
    sceneUpdateData.regions = this.regions.map(r => {
      return {_id: r.id, shapes: r.shapes.map(s => {
        s = s.clone();
        s.move(adjustPoint(s.origin.x, s.origin.y));
        return s.toObject();
      }), _shapeConstraints: null};
    });

    return sceneUpdateData;
  }

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

  /** @inheritDoc */
  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, operation);
    else nowInactive?._onActivate(false, operation);
  }

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

  /** @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"));
    const embeddedCollections = Object.values(this.constructor.metadata.embedded);

    // Reinitialize edges when required.
    // This reinitialization is only needed because https://github.com/foundryvtt/foundryvtt/issues/5241
    // has not been implemented. We proactively uninitialize edges for any modified embedded
    // collections so that systems/modules which attach edges to placeable objects do not
    // need to handle this edge uninitialization logic themselves.
    if ( embeddedCollections.some(k => k in changed) ) this._resetEdges();

    // Changes to the scene's dimensions and grid may change the geometry of regions or containment of tokens
    this.#onUpdateRegions(changed, options, userId);

    // Change audio
    const audioChange = changed.active || (this.active && ["playlist", "playlistSound"].some(k => k in changed));
    if ( audioChange && (game.userId === userId) ) game.playlists._onChangeScene(this);

    // If the Thumbnail was updated, bust the image cache
    if ( ("thumb" in changed) && this.thumb ) {
      this.thumb = `${this.thumb.split("?")[0]}?${Date.now()}`;
    }

    // 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 = { level: this._viewPosition.level };

      // Fully redraw the canvas
      const redraw = [
        "width", "height", "padding", "shiftX", "shiftY",         // Scene Dimensions
        "grid.type", "grid.size", "grid.distance", "grid.units",  // Grid Configuration
        ...embeddedCollections,                                   // Embedded Collections
        "weather"                                                 // Ambience
      ];
      if ( redraw.some(k => changedKeys.has(k)) ) return canvas.draw();

      // Update grid mesh
      if ( "grid" in changed ) canvas.interface.grid.initializeMesh(this.grid);

      // Modify vision conditions
      const fogModeChanged = changedKeys.has("fog.mode");
      const perceptionAttrs = ["globalLight", "tokenVision"];
      if ( perceptionAttrs.some(k => changedKeys.has(k)) ) canvas.perception.initialize();
      if ( ("tokenVision" in changed) && !fogModeChanged ) {
        for ( const token of canvas.tokens.placeables ) token.initializeVisionSource();
      }

      // Reload fog when the exploration mode changes.
      if ( fogModeChanged ) {
        ui.notifications.info("SCENE.FogModeChangeMessage", {localize: true});
        canvas.fog.load({preserve: true}).then(() => {
          canvas.perception.initialize();
          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});
      }
    }
  }

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

  /**
   * Handle Region-specific _onUpdate behavior
   * @param {object} changed   The changes to the scene.
   * @param {object} options  The update operation options.
   * @param {string} userId    The user that performed the update.
   */
  #onUpdateRegions(changed, options, userId) {

    // Update Region shape constraints if necessary
    if ( ["width", "height", "padding", "lights", "regions", "tokens", "walls"].some(k => k in changed) ) {
      this.updateRegionShapeConstraints();
    }

    const gridTypeOrSizeChanged = ("grid" in changed) && (("type" in changed.grid) || ("size" in changed.grid));
    if ( !gridTypeOrSizeChanged ) return;

    // Update region containment of tokens
    if ( game.user.isActiveGM ) {
      this.updateRegionShapeConstraints();
      this.updateTokenRegions();
    }

    // Emit boundary changed events
    const user = game.users.get(userId);
    for ( const region of this.regions ) {
      const affectedByGrid = region.shapes.some(shape => shape.isAffectedByGrid);
      if ( !affectedByGrid ) continue;
      region._handleEvent({
        name: CONST.REGION_EVENTS.REGION_BOUNDARY,
        data: {},
        region,
        user
      });
    }
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    this.unview();
    for ( const token of this.tokens ) {
      token.baseActor?._unregisterDependentScene(this);
    }

    // Change audio state
    if ( this.active && (game.userId === userId) ) {
      game.playlists._onChangeScene(null);
    }

    // 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?
   * @param {DatabaseCreateOperation|DatabaseUpdateOperation} operation  The database operation
   * @protected
   */
  _onActivate(active, operation) {
    if ( active ) this.view(operation.viewOptions);
    else this.unview();
  }

  /* -------------------------------------------- */
  /*  Descendant Document Operations              */
  /* -------------------------------------------- */

  /** @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);
    if ( parent !== this ) return;
    const documentCollection = this.getEmbeddedCollection(collection);

    // Record layer history for child embedded documents
    if ( (userId === game.userId) && this.isView && !options.isUndo ) {
      const originals = [];
      for ( const change of changes ) {
        const doc = documentCollection.get(change._id);
        const source = doc.toObject();
        const original = foundry.utils.filterObject(source, change);
        if ( "flags" in change ) { // Special handling of flag additions
          original.flags ??= {};
          for ( const flag in change.flags ) {
            if ( !(flag in original.flags) ) original.flags[flag] = _del;
          }
        }
        originals.push(original);
      }
      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 */
  _onCreateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    super._onCreateDescendantDocuments(parent, collection, documents, changes, options, userId);
    if ( (parent === this) && (collection === "levels") ) {
      this.#handleDescendantLevelChanges("create", documents, changes);
    }
  }

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

  /** @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();
    }
    if ( (parent === this) && (collection === "levels") ) {
      this.#handleDescendantLevelChanges("update", documents, changes);
    }
  }

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

  /** @inheritDoc */
  _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
    super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
    if ( (parent === this) && (collection === "levels") ) this.#handleDescendantLevelChanges("delete", documents);
  }

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

  /**
   * When a Level changes as an embedded document operation, determine what actions we need to take for the Scene.
   * @param {DatabaseAction} operation
   * @param {Level[]} documents
   * @param {object[]} [changes]
   */
  #handleDescendantLevelChanges(operation, documents, changes) {

    // Determine whether it is necessary to redraw the canvas
    let redraw = false;
    switch ( operation ) {
      case "create":
        redraw = (this.levels.size - changes.length) <= 0; // There were no levels previously
        break;
      case "update":
        for ( const change of changes ) {
          const level = this.levels.get(change._id);
          if ( !level.isVisible ) continue;
          const redrawKeys = ["background", "elevation", "foreground", "fog", "textures", "visibility"];
          redraw = redrawKeys.some(k => k in change);
          if ( redraw ) break;
        }
        break;
      case "delete":
        redraw = documents.some(level => level.isVisible);
        break;
    }
    if ( redraw ) {
      canvas.draw();
      return;
    }

    // Re-render Scene Navigation
    ui.nav.render();
  }

  /* -------------------------------------------- */
  /*  Importing and Exporting                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  toCompendium(pack, options={}) {
    const data = super.toCompendium(pack, options);
    if ( options.clearState !== false ) delete data.fog.reset;
    if ( options.clearSort !== false ) {
      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|Level} [options.level]  The Level to generated the thumbnail for. Defaults to the initial level.
   * @param {number} [options.width=300]    The desired thumbnail width. Default is 300px
   * @param {number} [options.height=100]   The desired thumbnail height. Default is 100px;
   * @param {string} [options.format="image/webp"]  Which image format should be used? image/png, image/jpeg,
   *                                                or image/webp. Default is image/webp.
   * @param {number} [options.quality=0.8]  What compression quality should be used for jpeg or webp, between 0 and 1.
   *                                        Default is 0.8.
   * @returns {Promise<{thumb: string; width: number; height: number; format: string; quality: number}>}
   *   The created thumbnail data.
   */
  async createThumbnail(options={}) {
    if ( game.settings.get("core", "noCanvas") ) throw new Error(_loc("SCENE.GenerateThumbNoCanvas"));
    if ( !game.user.can("FILES_UPLOAD") ) {
      throw new Error(_loc("SCENE.GenerateThumbUploadDenied"), {cause: {thumbUploadDenied: true}});
    }
    let {level, width=300, height=100, format="image/webp", quality=0.8} = options;
    if ( typeof level === "string" ) level = this.levels.get(level);
    else level ??= this.initialLevel;
    if ( level?.parent !== this ) throw new Error("The Level doesn't belong to this Scene");

    // Clone the scene and simulate view of the level
    const scene = this.clone();
    scene._view = level.id; // Simulate view
    level = scene.levels.get(level.id);

    /** @deprecated since v14 */
    let img;
    if ( "img" in options ) {
      foundry.utils.logCompatibilityWarning("The img parameter of Scene#createThumbnail is deprecated. "
        + "Instead create a clone of the Scene with the necessary changes to the Level textures and "
        + "then use Scene#createThumbnail on the cloned Scene.", {since: 14, until: 16, once: true});
      img = options.img ?? undefined; // Be sure to turn null to undefined
    }
    const newBackgroundTexture = img !== undefined ? await foundry.canvas.loadTexture(img) : null;
    if ( newBackgroundTexture ) {
      scene.updateSource({width: newBackgroundTexture.width, height: newBackgroundTexture.height});
      level.updateSource({background: {src: img}});
    }

    // Get all visible tiles
    const tiles = scene.tiles.filter(t => t.texture.src && !t.hidden && t.viewed);

    // Load required textures to create the thumbnail
    const toLoad = tiles.map(t => t.texture.src);
    for ( const lt of scene._configureLevelTextures() ) toLoad.push(lt.src);
    await foundry.canvas.TextureLoader.loader.load(toLoad, {message: "SCENE.GenerateThumbProgress", format: {name: this.name}});

    // Create a container and enforce the size
    const sceneRect = scene.dimensions.sceneRect;
    const baseContainer = new PIXI.Container();
    const sceneRectMask = baseContainer.addChild(new PIXI.LegacyGraphics());
    sceneRectMask.beginFill().drawShape(sceneRect).endFill();
    baseContainer.mask = sceneRectMask;
    const sceneRectFrame = baseContainer.addChild(new PIXI.Sprite(PIXI.Texture.EMPTY));
    sceneRectFrame.x = sceneRect.x;
    sceneRectFrame.y = sceneRect.y;
    sceneRectFrame.width = sceneRect.width;
    sceneRectFrame.height = sceneRect.height;

    // Simulate the way a sprite is drawn
    const drawMesh = ({x, y, rotation, width, height, elevation, sort, sortLayer, zIndex,
      src, tint, anchorX, anchorY, offsetX, offsetY, fit, scaleX, scaleY}) => {
      const texture = foundry.canvas.getTexture(src);
      if ( !texture ) return;
      const mesh = new PIXI.Sprite(texture);
      mesh.anchor.set(anchorX, anchorY);
      mesh.position.set(x + offsetX, y + offsetY);
      foundry.canvas.primary.PrimarySpriteMesh.prototype.resize.call(mesh, width, height, {fit, scaleX, scaleY});
      mesh.angle = rotation;
      mesh.elevation = elevation;
      mesh.sort = sort;
      mesh.sortLayer = sortLayer;
      mesh.zIndex = zIndex;
      if ( tint ) mesh.tint = tint;
      return mesh;
    };

    // Draw Level texture meshes
    for ( const lt of scene._configureLevelTextures() ) {
      const {center: {x, y}, width, height} = scene.dimensions.sceneRect;
      const sortLayer = foundry.canvas.groups.PrimaryCanvasGroup.SORT_LAYERS.SCENE;
      const sprite = drawMesh({...lt, ...lt.level.textures, x, y, width, height, sortLayer, zIndex: -Infinity});
      if ( sprite ) baseContainer.addChild(sprite);
    }

    // Draw Tile meshes
    for ( const tile of tiles ) {
      const {x, y, rotation, width, height, elevation, sort} = tile;
      const {src, tint, anchorX, anchorY, fit, scaleX, scaleY} = tile.texture;
      const sortLayer = foundry.canvas.groups.PrimaryCanvasGroup.SORT_LAYERS.TILES;
      const sprite = await drawMesh({x, y, rotation, width, height, elevation, sort, sortLayer, zIndex: 0, src, tint,
        anchorX, anchorY, offsetX: 0, offsetY: 0, fit, scaleX, scaleY});
      if ( sprite ) baseContainer.addChild(sprite);
    }

    // Sort meshes
    baseContainer.children.sort(foundry.canvas.groups.PrimaryCanvasGroup._compareObjects);

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

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

  /** @inheritDoc */
  static getCollectionName(name) {
    /** @deprecated since v14 */
    if ( name === "MeasuredTemplate" ) {
      foundry.utils.logCompatibilityWarning("Scene#templates is deprecated without replacement.",
        {since: 14, until: 16, once: true});
      return "templates";
    }
    return super.getCollectionName(name);
  }

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

  /** @inheritDoc */
  getEmbeddedCollection(embeddedName) {
    /** @deprecated since v14 */
    if ( embeddedName === "MeasuredTemplate" ) return this.templates;
    return super.getEmbeddedCollection(embeddedName);
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  get templates() {
    foundry.utils.logCompatibilityWarning("Scene#templates is deprecated because the MeasuredTemplate document has been merged into the functionality of the Region document.",
      {since: 14, until: 16, once: true});
    const {MeasuredTemplateDocument, RegionDocument} = foundry.documents;
    const sourceArray = [];
    for ( const regionData of this.regions._source ) {
      if ( !foundry.utils.getProperty(regionData, "flags.core.MeasuredTemplate") ) continue;
      const region = RegionDocument.implementation.fromSource(regionData, {parent: this});
      const template = MeasuredTemplateDocument._fromRegion(region);
      sourceArray.push(template.toObject());
    }
    const templates = new foundry.abstract.EmbeddedCollection("templates", this, sourceArray);
    Object.defineProperties(templates, {
      documentClass: {value: MeasuredTemplateDocument.implementation, writable: false},
      documentsByType: {value: {base: [MeasuredTemplateDocument.implementation]}}
    });
    templates.initialize();
    return templates;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  get background() {
    foundry.utils.logCompatibilityWarning("Scene#background is deprecated. Use Level#background and Level#textures instead.",
      {since: 14, until: 16, once: true});
    const background = {};
    const firstLevel = this.firstLevel ?? {};
    for ( const [oldKey, newKey] of BaseScene._LEVELS_PROPERTY_MAP ) {
      if ( !oldKey.startsWith("background.") ) continue;
      const key = oldKey.slice(11);
      foundry.utils.setProperty(background, key, foundry.utils.getProperty(firstLevel, newKey));
    }
    background.offsetX = this.shiftX;
    background.offsetY = this.shiftY;
    return background;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  get backgroundColor() {
    foundry.utils.logCompatibilityWarning("Scene#backgroundColor is deprecated. Use Level#background#color instead.", {
      since: 14, until: 16
    });
    return this.firstLevel?.background?.color;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  get foreground() {
    foundry.utils.logCompatibilityWarning("Scene#foreground is deprecated. Use Level#foreground.src instead.",
      {since: 14, until: 16, once: true});
    return this.firstLevel?.foreground?.src;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  get foregroundElevation() {
    foundry.utils.logCompatibilityWarning("Scene#foregroundElevation is deprecated. Use Level#elevation.top instead.",
      {since: 14, until: 16, once: true});
    return this.firstLevel?.elevation.top;
  }
};

/**
 * 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 ( typeof onChange === "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) && (typeof onChange === "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 ( typeof type !== "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.messageMode ??= game.settings.get("core", "messageMode");

    // Construct chat data
    const flavorKey = `TABLE.DrawFlavor${results.length > 1 ? "Plural" : ""}`;
    messageData = foundry.utils.mergeObject({
      flavor: _loc(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.messageMode]          A chat message visibility mode to apply to the resulting message
   * @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, messageMode, rollMode}={}) {

    // If an array of results was 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.uuid === this.uuid) || 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 ) {
      if ( rollMode ) {
        foundry.utils.logCompatibilityWarning("The rollMode option of RollTable#draw is deprecated in favor of"
          + " the `messageMode` option, a string key of CONFIG.ChatMessage.modes", {since: 14, until: 16});
        messageMode = foundry.dice.Roll._mapLegacyRollMode(rollMode);
      }
      await this.toMessage(results, {roll, messageOptions: {messageMode}});
    }

    // 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.messageMode]          A chat message visibility mode to apply to the resulting message
   * @returns {Promise<{RollTableDraw}>}  The drawn results
   */
  async drawMany(number, {roll=null, recursive=true, displayChat=true, messageMode, 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 ) {
      if ( rollMode ) {
        foundry.utils.logCompatibilityWarning("The rollMode option of RollTable#drawMany is deprecated in favor of"
          + " the `messageMode` option, a string key of CONFIG.ChatMessage.modes", {since: 14, until: 16});
        messageMode = foundry.dice.Roll._mapLegacyRollMode(rollMode);
      }
      await this.toMessage(results, {roll, messageOptions: {messageMode}});
    }

    // 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
   * @param {object} [options]
   * @param {boolean} [options.save=true] Save the clone to the database? If false, an updated clone is returned.
   * @returns {Promise<this>}
   */
  async normalize({save=true}={}) {
    let totalWeight = 0;
    let counter = 1;
    const changes = {results: []};
    for ( const result of this.results ) {
      const weight = result.weight;
      totalWeight += weight;
      changes.results.push({...result.toObject(), range: [counter, counter + weight - 1]});
      counter += weight;
    }
    changes.formula = `1d${totalWeight}`;
    return save ? this.update(changes) : this.clone(changes, {keepId: true});
  }

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

  /**
   * 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 {boolean} [options.normalize=true]   Normalize the table before rolling.
   * @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, normalize=true, _depth=0}={}) {
    if ( _depth > 5 ) {
      throw new Error(`Maximum recursion depth exceeded when attempting to draw from RollTable ${this.id}`);
    }

    // Normalize the result distribution, rolling a clone of this table if necessary
    if ( !this._source.formula && normalize ) {
      const save = this.canUserModify(game.user, "update") && !this.compendium?.locked;
      const table = await this.normalize({save});
      if ( !save ) return table.roll({_depth, roll, recursive, normalize: false});
    }

    roll ??= Roll.defaultImplementation.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(_loc("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(_loc("TABLE.NoPossibleResults"));
      return {roll, results};
    }

    // Continue rolling until one or more results are recovered
    let iter = 0;
    while ( !results.length ) {
      if ( iter >= 10000 ) {
        ui.notifications.error("TABLE.DrawMaximumIterations", {format: {name: this.name}});
        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));
  }

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

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    if ( !this.formula ) {
      const totalWeight = this.results.reduce((sum, r) => sum + r.weight, 0);
      this.formula = `1d${totalWeight}`;
    }
  }

  /* -------------------------------------------- */
  /*  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-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, secrets: options.secrets ?? this.isOwner };
    const { rangeLabel, resultLabel } = config;
    const rollable = config.rollable || config.values.includes("rollable");
    const includeDescription = config.description || config.values.includes("description");
    const results = this.results.toObject();
    results.sort((a, b) => a.range[0] - b.range[0]);
    const context = {
      rollable,
      description: includeDescription ? await TextEditor$1.implementation.enrichHTML(this.description, options) : "",
      labels: {
        range: rangeLabel ?? _loc("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),
          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);

    // Add the table description as the caption if it was not already included at the head of the table
    const includedDescription = config.description || config.values.includes("description");
    if ( !includedDescription && config.caption && !config.label ) {
      const figure = embed.querySelector("figure.content-embed");
      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 !== false ) {
      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) {
  /** @inheritDoc */
  constructor(data, options) {
    super(data, options);
    Object.defineProperty(this, "idle", {value: false, writable: false, configurable: true, enumerable: true});
  }

  /**
   * Is the User currently logged into the game World?
   * @type {boolean}
   */
  active = false;

  /**
   * Is this User currently considered idle?
   * @type {boolean}
   */
  idle;

  /**
   * 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 ID of the Scene Level that is currently being viewed by the User
   * @type {string|null}
   */
  viewedLevel = 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 _loc(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 ( dt > CONST.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.");
    Object.defineProperty(this, "idle", {value: false});
    ui.players?._onChangeIdleStatus(this);
    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, queryOptions) {
    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");
    return this.#query(queryName, queryData, queryOptions);
  }

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

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

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

  /**
   * Query many Users.
   * @param {Iterable<User>} users    The users that are queried.
   * @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<Map<User, PromiseSettledResult<*>>>}  The query results
   */
  static async queryMany(users, queryName, queryData, queryOptions) {
    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 ( !Array.isArray(users) ) users = [...users];
    const queryPromises = users.map(user => user.#query(queryName, queryData, queryOptions));
    const queryResults = await Promise.allSettled(queryPromises);
    const queryResult = new Map();
    for ( let i = 0; i < users.length; i++ ) queryResult.set(users[i], queryResults[i]);
    return queryResult;
  }

  /* -------------------------------------------- */
  /*  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);
      if ( user.isSelf ) game.users.current = 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();
  }
};

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

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

  /** @override */
  get id() {
    return this._id ?? this.parent.id;
  }

  /**
   * 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.
    // TODO this workflow to update the syntheticActor can be better integrated with the validation/diff/commit sequence by extending _updateCommit instead
    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.
    // TODO we don't really have the "non-recursive case" anymore with ForcedReplacement operators taking their place
    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.values(foundry.utils.flattenObject(changes)).some(v => {
      return v instanceof foundry.data.operators.ForcedDeletion;
    });
    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: true, dropInvalidEmbedded: true}), 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 && this.syntheticActor ) {
      await Promise.all(Object.values(this.syntheticActor.apps).map(app => app.close()));
    }
    await this.delete();
    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?.isToken && (doc.parent.parent?.delta === this) ) {
      const collection = this.getEmbeddedCollection(doc.parentCollection);
      if ( !collection.manages(doc.id) ) collection.set(doc.id, doc);
    }
    else return this._handleDeltaCollectionUpdates(doc.parent);
  }

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

  /** @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 || !this.syntheticActor || !this.parent.baseActor ) {
      return super._preDelete(options, user);
    }
    // Emulate a synthetic actor update.
    const data = this.parent.baseActor.toObject();
    const actorOptions = { ...options, documentName: "Actor" };
    let allowed = await this.syntheticActor._preUpdate(data, actorOptions, user) ?? true;
    allowed &&= (actorOptions.noHook || Hooks$1.call("preUpdateActor", this.syntheticActor, data, actorOptions, 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 || !this.syntheticActor ) return;
    const actorOptions = { ...options, documentName: "Actor" };
    this.syntheticActor._onUpdate(changed, actorOptions, userId);
    Hooks$1.callAll("updateActor", this.syntheticActor, changed, actorOptions, userId);
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( !this.parent.baseActor ) return;
    this.parent.delta._createSyntheticActor({ reinitializeCollections: true });
    // Emulate synthetic actor update.
    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)
      || _loc("CARD.Unknown", {source: this.source?.name || _loc("COMMON.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;
  }

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

  /**
   * The combatant's turn number if the parent Combat exists and has started
   * @type {number|null}
   */
  turnNumber = null;

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

  /**
   * 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() {
    // We pay materialization cost for the handful of ActorDeltas that happen to be in Combat at World load.
    if ( this.token ) return this.token.isLazyDelta ? this.token._forceDeltaActor() : 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 || _loc("COMBATANT.Unknown");

    this.updateResource();
    this._prepareGroup();
  }

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

  /**
   * Update the value of the tracked resource for this Combatant.
   * @returns {null|object}
   */
  updateResource() {
    if ( !this.combat || !this.actor ) 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                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    await super._preCreate(data, options, user);
    if ( this.parent?.started ) this.updateSource({roundJoined: this.parent.round});
  }

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

  /** @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 ||= _loc("JOURNAL.UnnamedCategory");
  }
};

/**
 * @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 quadtree.
   * @type {PIXI.Rectangle}
   */
  get bounds() {
    return this._bounds;
  }

  set bounds(bounds) {
    if ( ["x", "y", "width", "height"].every(k => this._bounds[k] === bounds[k]) ) return;
    this._bounds = bounds.clone();
    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(width) {
    this.setDimensions(width, this._bounds.height);
  }

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

  /**
   * The height of the bounding rectangle
   * @type {number}
   */
  get height() {
    return this._bounds.height;
  }

  set height(height) {
    this.setDimensions(this._bounds.width, height);
  }

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

  /**
   * The x-coordinate of the bounding rectangle
   * @type {number}
   */
  get x() {
    return this._bounds.x;
  }

  set x(x) {
    this.setPosition(x, this._bounds.y);
  }

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

  /**
   * The y-coordinate of the bounding rectangle
   * @type {number}
   */
  get y() {
    return this._bounds.y;
  }

  set y(y) {
    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) {
    if ( (x === this._bounds.x) && (y === this._bounds.y) ) return this;
    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) {
    if ( (width === this._bounds.width) && (height === this._bounds.height) ) return this;
    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 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(level) {
    super();
    if ( !(level instanceof foundry.documents.Level) ) {
      throw new Error("The CanvasEdges may only be constructed with a Level instance.");
    }
    this.#level = level;
    this.#quadtree = new Quadtree(new PIXI.Rectangle(), {
      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;

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

  /**
   * The Level these edges belong to.
   * @type {Level}
   */
  get level() {
    return this.#level;
  }

  #level;

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

  /**
   * Edges representing the outer boundaries of the Scene.
   * @type {Set<Edge>}
   */
  #outerBounds = new Set();

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

  /**
   * Edges representing the inner boundaries of the Scene.
   * @type {Set<Edge>}
   */
  #innerBounds = new Set();

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

  /**
   * Internal Quadtree instance for spatial indexing of edges.
   * @type {Quadtree}
   */
  #quadtree;

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

  /**
   * Do edge intersections need to be re-computed?
   * @type {boolean}
   */
  #dirty = false;

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

  /** @inheritDoc */
  set(id, edge) {
    if ( !this.level.parent.initializedEdges ) throw new Error(`The edges of ${this.level.parent.uuid} haven't been initialized yet.`);

    // Delete the old edge first to remove it from the quadtree
    this.delete(id);
    super.set(id, edge);
    if ( edge.type === "outerBounds" ) this.#outerBounds.add(edge);
    else if ( edge.type === "innerBounds" ) this.#innerBounds.add(edge);
    else {
      this.#quadtree.bounds = this.#level.parent.dimensions.rect;
      this.#quadtree.insert({t: edge, r: edge.bounds});
    }
    this.#dirty = true;
    return this;
  }

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

  /** @inheritDoc */
  delete(id) {
    const edge = this.get(id);
    if ( !edge ) return false;
    if ( edge.type === "outerBounds" ) this.#outerBounds.delete(edge);
    else if ( edge.type === "innerBounds" ) this.#innerBounds.delete(edge);
    else this.#quadtree.remove(edge);
    this.#dirty = true;
    return super.delete(id);
  }

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

  /** @inheritDoc */
  clear() {
    super.clear();
    this.#quadtree.clear();
    this.#outerBounds.clear();
    this.#innerBounds.clear();
    this.#dirty = false;
    return this;
  }


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

  /**
   * Retrieves edges that overlap with a given rectangle.
   * Utilizes the Quadtree for efficient spatial querying.
   * This function computes edge intersections if necessary.
   * @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 {(edge: Edge) => boolean} [options.collisionTest] Collision function to test edge inclusion.
   * @param {boolean} [options.collisionTestBounds=false] Apply collision test to bounds?
   * @returns {Set<Edge>} A set of Edge instances that intersect with the provided rectangle.
   */
  getEdges(rect, {includeInnerBounds=false, includeOuterBounds=true, collisionTest, collisionTestBounds=false}={}) {
    this.#level.parent.initializeEdges();
    this.#quadtree.bounds = this.#level.parent.dimensions.rect;
    const edges = this.#quadtree.getObjects(rect, collisionTest ? {collisionTest: o => collisionTest(o.t)} : {});
    if ( !collisionTestBounds ) collisionTest = undefined;
    if ( includeInnerBounds ) {
      for ( const inner of this.#innerBounds ) {
        if ( !collisionTest || collisionTest(inner) ) edges.add(inner);
      }
    }
    if ( includeOuterBounds ) {
      for ( const outer of this.#outerBounds ) {
        if ( !collisionTest || collisionTest(outer) ) edges.add(outer);
      }
    }
    if ( edges.size ) this.identifyIntersections();
    return edges;
  }

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

  /**
   * Identify all edge intersections.
   */
  identifyIntersections() {
    if ( !this.#dirty ) return;
    Edge.identifyEdgeIntersections(this.values(), this.#level.id);
    this.#dirty = false;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  initialize() {
    foundry.utils.logCompatibilityWarning("CanvasEdges#inititalize has been deprecated. Use Scene#initializeEdges instead.",
      {since: 14, until: 16, once: true});
    this.level.parent.initializeEdges();
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  refresh() {
    foundry.utils.logCompatibilityWarning("CanvasEdges#refresh has been deprecated. CanvasEdges#getEdges computes edge intersections automatically if necessary.",
      {since: 14, until: 16, once: true});
    Edge.identifyEdgeIntersections(this.values(), this.#level.id);
  }
}

/**
 * The client-side Level document which extends the common BaseTile document model.
 * @extends BaseLevel
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Scene}: The Scene document type which contains Level documents
 */
let Level$1 = class Level extends ClientDocumentMixin(BaseLevel) {

  /**
   * The integer index of the Level, assigned during Scene data preparation.
   * @type {number}
   */
  index;

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

  /**
   * Is this level currently viewed?
   * @type {boolean}
   */
  get isView() {
    return this.parent?._view === this.id;
  }

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

  /**
   * Is this level currently visible?
   * @type {boolean}
   */
  get isVisible() {
    const view = this.parent?._view;
    if ( !view ) return false;
    if ( view === this.id ) return true;
    const viewedLevel = this.parent.levels.get(view);
    return viewedLevel.visibility.levels.has(this.id);
  }

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

  /**
   * The edges of this Level.
   * @type {CanvasEdges}
   */
  get edges() {
    return this.#edges ??= new CanvasEdges(this);
  }

  #edges;

  /* -------------------------------------------- */
  /*  Document Preparation                        */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    this.elevation.bottom ??= -Infinity;
    this.elevation.top ??= Infinity;
    this.elevation.base = Number.isFinite(this.elevation.bottom) ? this.elevation.bottom
      : Math.min(this.elevation.top, 0);
  }

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

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( this.parent?.firstLevel?.background && !foundry.utils.hasProperty(data, "background.color") ) {
      this.updateSource({ "background.color": this.parent.firstLevel.background.color });
    }
    return super._preCreate(data, options, user);
  }

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

  /** @inheritDoc */
  static async _onCreateOperation(documents, operation, user) {
    await super._onCreateOperation(documents, operation, user);
    operation.parent._resetEdges();
  }

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

  /** @inheritDoc */
  static async _onDeleteOperation(documents, operation, user) {
    await super._onDeleteOperation(documents, operation, user);
    const scene = operation.parent;
    for ( const level of documents ) {
      for ( const edge of level.edges.values() ) edge.removeIntersections(level.id);
    }
    // If the currently-viewed level was deleted, navigate to the initial level.
    if ( scene.isView ) {
      const deletedIds = new Set(documents.map(l => l.id));
      if ( deletedIds.has(scene._view) ) {
        const { initialLevel } = scene;
        if ( initialLevel ) await scene.view({ level: initialLevel.id });
        else scene.unview();
      } else if ( canvas.level?.visibility.levels.intersects(deletedIds) ) {
        canvas.draw();
      }
    }
  }

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

  /**
   * Clamp the given elevation (of a token with a depth) to the elevation range of this Level.
   *
   * The elevation is clamped such that the head of the token is in the range if possible, but
   * the feet are never outside of the range.
   * @param {number} elevation    The elevation (of the token)
   * @param {number} [depth=0]    The depth of the token
   * @returns {number}            The clamped elevation
   */
  clampElevation(elevation, depth=0) {

    // Same code as in RegionDocument#clampElevation
    const depthDistance = depth * (this.parent?.grid ?? foundry.documents.BaseScene.defaultGrid).distance;
    return Math.clamp(
      Math.clamp(
        elevation + depthDistance,
        this.elevation.bottom,
        this.elevation.top
      ) - depthDistance,
      this.elevation.bottom,
      this.elevation.top
    );
  }

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

  /**
   * Update the shape constraints of all Regions in this Level the current User is designated for
   * (for the given restriction types).
   * @param {Iterable<EdgeRestrictionType>} [types]    The types to update. Default: all.
   */
  updateRegionShapeConstraints(types=CONST.EDGE_RESTRICTION_TYPES) {
    if ( !this.persisted ) throw new Error("A nonpersisted Document cannot be updated.");
    types = types === CONST.EDGE_RESTRICTION_TYPES ? null : (types instanceof Set ? types : new Set(types));
    if ( types && !types.size ) return;
    for ( const region of this.parent.regions ) {
      if ( !region._source.restriction.enabled && !region._source._shapeConstraints ) continue;
      if ( !region.includedInLevel(this.id) ) continue;
      if ( types && !types.has(region._source.restriction.type) ) continue;
      this.parent._updateRegionShapeConstraints(region);
    }
  }
};

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

/**
 * @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, isn't disabled,
   * and its Region isn't hidden.
   * @type {boolean}
   */
  get active() {
    if ( this.disabled ) return false;
    const region = this.parent;
    if ( !region ) return false;
    if ( region.hidden ) return false;
    const scene = region.parent;
    if ( !scene ) return false;
    if ( region.behaviors.get(this.id) !== this ) return false;
    if ( scene.regions.get(region.id) !== region ) return false;
    return true;
  }

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

  /**
   * A RegionBehavior is viewed if and only if it is active and the Region is viewed.
   * @type {boolean}
   */
  get viewed() {
    return this.active && (this.parent?.viewed === true);
  }

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

  /** @override */
  prepareBaseData() {
    this.name ||= _loc(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 = _loc(documentConfig?.typeLabels?.[type] ?? "");
    const documentNameLabel = documentName ? _loc(`DOCUMENT.${documentName}`) : "";
    const tooltip = typeName
      ? _loc("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 || _loc("COMMON.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;
  }

};

/**
 * @import {DeepReadonly, ElevatedPoint, Point, TokenConstrainMovementPathOptions} from "../_types.mjs";
 * @import {RegionEventType, TokenShapeType} from "@common/constants.mjs";
 * @import {ActiveEffectChangeData, TokenMeasuredMovementWaypoint, TokenResumeMovementCallback,
 *   TokenMovementContinuationData, TokenMovementCostFunction, TokenMovementData, TokenMovementOperation,
 *   TokenMovementSegmentData, TokenMovementWaypoint, TokenRegionMovementSegment, TokenProcessedMovementWaypoint,
 *   TrackedAttributesDescription, TokenMovementContinuationHandle, TokenMovementMethod, TokenMovementCostAggregator,
 *   TokenPreMovementOperation, TokenMovementOptions, TokenMeasurableMovementWaypoint} from "./_types.mjs";
 * @import {TokenData, TokenDimensions, TokenPosition, TokenSightData} from "@common/documents/_types.mjs";
 * @import {Actor, Combat, Combatant, RegionDocument, Scene, User} from "./_module.mjs";
 * @import {DatabaseUpdateOperation, DatabaseWriteOperation} from "@common/abstract/_types.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, GridOffset3D} 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`, `depth`, `shape`, `level`.
 *
 * ### Hook Events
 * - {@link hookEvents.moveToken}
 * - {@link hookEvents.pauseToken}
 * - {@link hookEvents.preMoveToken}
 * - {@link hookEvents.stopToken}
 * - {@link hookEvents.recordToken}
 *
 * @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) {
  /**
   * Keys targetable by a ActiveEffectChanges. Width, height, depth, and shape are included but not automatically
   * applied to Token data.
   * @readonly
   */
  static #ACTIVE_EFFECT_TARGETABLE_KEYS = Object.freeze(["name", "width", "height", "depth", "shape", "texture",
    "alpha", "disposition", "light", "sight", "detectionModes", "ring", "turnMarker", "movementAction", "flags"]);

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

  /**
   * A global guard which prevents ActorDelta documents from materializing until after all world documents are first
   * initialized. While true, the `TokenDocument#actor` getter will return null instead of the synthetic Actor instance.
   * This guard is released upon completion of `Game#initializeDocuments`.
   * @type {boolean}
   * @internal
   */
  static _preventActorDeltaAccess = true;

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

  /**
   * The key that is used to start (resume) planned (initially paused) movement.
   * @type {symbol}
   */
  static #START_MOVEMENT_KEY = Symbol("");

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

  /**
   * Shared rectangle instance.
   * @type {PIXI.Rectangle}
   */
  static #SHARED_RECTANGLE = new PIXI.Rectangle();

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

  /**
   * Property overrides copied from this TokenDocument's associated Actor
   * @type {Record<string, unknown>}
   * @protected
   */
  overrides = this.overrides ?? {};

  /**
   * Old overrides for synthetic actors, kept separately due to particularities of ActorDelta data re-initialization
   * @type {Record<string, unknown>|null}
   * @private
   */
  _priorOverrides = this._priorOverrides ?? null;

  /**
   * A semantically-intuitive alias of {@link TokenDocument#parent}
   * @type {Scene|null}
   */
  get scene() {
    return this.parent;
  }

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

  /**
   * The current movement data of this Token document.
   * @type {TokenMovementData}
   * @see {@link TokenDocument#move}
   */
  get movement() {
    if ( this._movement ) return this._movement;
    const {x, y, elevation, width, height, depth, shape, level} = this._source;
    return deepFreeze({
      id: "",
      subpathId: "",
      chain: [],
      origin: {x, y, elevation, width, height, depth, shape, level},
      destination: {x, y, elevation, width, height, depth, shape, level},
      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
      },
      split: false,
      constrained: false,
      recorded: false,
      method: "api",
      constrainOptions: {},
      terrainOptions: {},
      measureOptions: {},
      autoRotate: false,
      showRuler: false,
      user: game.user,
      state: "completed",
      updateOptions: {},
      finished: Promise.resolve(true),
      animation: {
        started: Promise.resolve(),
        ended: Promise.resolve(),
        duration: 0
      }
    });
  }

  /**
   * @type {TokenMovementData}
   * @internal
   */
  _movement;

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

  /**
   * The movement continuation state of this Token document.
   * @type {TokenMovementContinuationData}
   * @internal
   */
  _movementContinuation = {
    id: "",
    continueCounter: 0,
    continued: false,
    continuePromise: Promise.resolve(false),
    waitPromise: Promise.resolve(),
    resolveWaitPromise: undefined,
    postWorkflowPromise: Promise.resolve(),
    states: {}
  };

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

  /**
   * The movement promises with their resolvers.
   * @type {Map<string, PromiseWithResolver<boolean>>}
   */
  #movementPromisesWithResolvers = new Map();

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

  /**
   * The movement promises with their resolvers.
   * @type {Map<string, Promise<boolean>>}
   * @internal
   */
  _returnedMovementPromises = new Map();

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

  /**
   * The attachments of this Token.
   * @type {Readonly<{regions: ReadonlySet<RegionDocument>}>}
   * @readonly
   */
  attachments = Object.freeze({regions: new Set()});

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

  /**
   * 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.
   * This synthetic Actor instance is only available after `Game#initializeDocuments` is complete.
   * @type {Actor|null}
   */
  get actor() {
    if ( this.isLinked ) return this.baseActor;
    if ( this.isLazyDelta && TokenDocument._preventActorDeltaAccess ) return null;
    return this.delta?.syntheticActor ?? null;
  }

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

  /**
   * A reference to the base, World-level Actor this token represents.
   * @type {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;
  }

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

  /**
   * Test whether this TokenDocument would produce an ActorDelta if materialized.
   * @type {boolean}
   */
  get isLazyDelta() {
    return !this.actorLink && !!Object.getOwnPropertyDescriptor(this, "delta")?.get;
  }

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

  /**
   * Force construction of the ActorDelta for this unlinked TokenDocument, bypassing the initialization guard.
   * @returns {Actor|null}
   * @internal
   */
  _forceDeltaActor() {
    if ( this.isLazyDelta ) {
      if ( CONFIG.debug.documents ) console.debug(`${CONST.vtt} | Forcing eager ActorDelta materialization for `
        + `TokenDocument [${this.uuid}]`);
      return this.delta?.syntheticActor ?? null;
    }
    throw new Error("TokenDocument#_forceDeltaActor may only be called for a lazy delta.");
  }

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

  /**
   * A workflow which occurs when the ActorDelta for an unlinked TokenDocument is materialized for the first time.
   * At the point this method is called, the delta property has transitioned from a lazy getter to a concrete value.
   * @protected
   */
  _onDeltaMaterialized() {
    if ( CONFIG.debug.documents ) console.debug(`${CONST.vtt} | Materialized ActorDelta for TokenDocument `
      + `[${this.uuid}]`);

    // Re-prepare the TokenDocument since it may now depend on actor attributes
    this.reset();

    // Register ongoing active effects
    // Their durations are already correctly computed because the synthetic Actor was just prepared
    const effectsRegistry = foundry.documents.ActiveEffect.registry;
    if ( effectsRegistry.initialized && this.actor ) effectsRegistry.addFromParent(this.actor);
  }

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

  /**
   * 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                                     */
  /* -------------------------------------------- */

  /** @override */
  includedInLevel(level) {
    if ( typeof level === "string" ) {
      if ( level === this._source.level ) return true;
      level = this.parent.levels.get(level);
      if ( !level ) return false;
    }
    else if ( level.parent !== this.parent ) return false;
    else if ( level.id === this._source.level ) return true;
    return level.visibility.levels.has(this._source.level);
  }

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

  /** @override */
  locatedInLevel(level) {
    if ( typeof level === "string" ) return level === this._source.level;
    if ( !this.parent || (level.parent !== this.parent) ) return false;
    return level.id === this._source.level;
  }

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

  /** @inheritDoc */
  _initializeSource(data, options) {
    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);
  }

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

  /** @inheritDoc */
  prepareData() {
    if ( this.isLazyDelta && this.actor ) return; // #_onDeltaMaterialized just called
    super.prepareData();
    this.applyActiveEffects("final");
  }

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

  /** @override */
  prepareBaseData() {
    this.overrides = {};
    if ( !this.actorLink ) this._priorOverrides ??= this.overrides;

    // 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 || _loc("COMMON.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._prepareBars();
    this._prepareDetectionModes();
  }

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

  /** @inheritDoc */
  prepareEmbeddedDocuments() {
    if ( this.isLazyDelta ) return; // Don't trigger ActorDelta preparation
    super.prepareEmbeddedDocuments();
    this.applyActiveEffects("initial");
  }

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

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

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

  /**
   * Extend data in attribute-bar properties.
   * @protected
   */
  _prepareBars() {
    for ( const key of ["bar1", "bar2"] ) {
      const attribute = this[key].attribute;
      this[key] = this.getBarAttribute(key) ?? {attribute, type: null, value: null};
    }
  }

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

  /**
   * 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 Object.values(this.detectionModes) ) {
      mode.enabled ??= true;   // In case of being partial data from an override
      mode.range ??= Infinity; // A null value is treated as unlimited range
    }
    if ( !this.sight.enabled ) return;
    this.detectionModes.lightPerception ??= {enabled: true, range: Infinity};
    this.detectionModes.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.getFieldForProperty(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.getFieldForProperty(`${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).
   *
   * ## Movement API
   *
   * ### Movement Control
   * - {@link TokenDocument#movement}
   * - {@link TokenDocument#movementHistory}
   * - {@link TokenDocument#move}
   * - {@link TokenDocument#resize}
   * - {@link TokenDocument#startMovement}
   * - {@link TokenDocument#pauseMovement}
   * - {@link TokenDocument#resumeMovement}
   * - {@link TokenDocument#stopMovement}
   * - {@link TokenDocument#clearMovementHistory}
   * - {@link TokenDocument#revertRecordedMovement}
   * - {@link TokenDocument#_shouldRecordMovementHistory}
   * - {@link foundry.documents.Scene#moveTokens}
   *
   * ### Hook Events / Document Event Handlers
   * - {@link hookEvents.preMoveToken} / {@link TokenDocument#_preUpdateMovement}
   * - {@link hookEvents.moveToken} / {@link TokenDocument#_onUpdateMovement}
   * - {@link hookEvents.pauseToken} / {@link TokenDocument#_onMovementPaused}
   * - {@link hookEvents.stopToken} / {@link TokenDocument#_onMovementStopped}
   * - {@link hookEvents.recordToken} / {@link TokenDocument#_onMovementRecorded}
   * - {@link hookEvents.planToken} / {@link TokenDocument#_onMovementPlanned}
   *
   * ### Movement Action
   * - {@link CONFIG.Token.movement | CONFIG.Token.movement.actions}
   * - {@link CONFIG.Token.movement | CONFIG.Token.movement.defaultAction}
   * - {@link TokenData#movementAction}
   * - {@link TokenDocument#_inferMovementAction}
   * - {@link foundry.canvas.placeables.Token#_getDragMovementAction}
   * - {@link foundry.canvas.placeables.Token#_getHUDMovementAction}
   * - {@link foundry.canvas.placeables.Token#_getKeyboardMovementAction}
   *
   * ### Movement Cost
   * - {@link CONFIG.Token.movement | CONFIG.Token.movement.costAggregator}
   * - {@link TokenDocument#measureMovementPath}
   * - {@link foundry.canvas.placeables.Token#measureMovementPath}
   * - {@link foundry.canvas.placeables.Token#_getMovementCostFunction}
   *
   * ### Movement Constraints, Terrain, and Pathfinding
   * - {@link CONFIG.Token.movement | CONFIG.Token.movement.TerrainData}
   * - {@link TokenDocument#getCompleteMovementPath}
   * - {@link TokenDocument#segmentizeRegionMovementPath}
   * - {@link foundry.canvas.placeables.Token#findMovementPath}
   * - {@link foundry.canvas.placeables.Token#constrainMovementPath}
   * - {@link foundry.canvas.placeables.Token#recalculatePlannedMovementPath}
   * - {@link foundry.canvas.placeables.Token#createTerrainMovementPath}
   * - {@link foundry.data.TerrainData}
   * - {@link foundry.data.regionBehaviors.ModifyMovementCostRegionBehaviorType}
   *
   * ### Movement Animation
   * - {@link CONFIG.Token.movement | CONFIG.Token.movement.defaultSpeed}
   * - {@link foundry.documents.types.TokenMovementData#animation}
   * - {@link foundry.canvas.placeables.Token#movementAnimationName}
   * - {@link foundry.canvas.placeables.Token#movementAnimationPromise}
   * - {@link foundry.canvas.placeables.Token#_getAnimationMovementSpeed}
   * - {@link foundry.canvas.placeables.Token#_modifyAnimationMovementSpeed}
   *
   * @param {Partial<TokenData & TokenMovementWaypoint> | Partial<TokenMovementWaypoint>[]} waypoints
   *   The waypoint(s) to move the Token through. If a single waypoint is provided, it may include addition token data.
   * @param {Partial<Omit<DatabaseUpdateOperation, "updates"> & TokenMovementOptions} [options]
   *   Parameters of the update and movement operation.
   * @returns {Promise<boolean>}  A Promise that resolves to true if the entire movement was completed, and otherwise
   *   resolves to false if the movement was stopped or prevented. While the movement is paused, this promise does not
   *   resolve.
   * @example Moving the token to new position including additional token data
   * ```js
   * const completed = await token.move({x: 100, y: 200, rotation: 45, texture: {tint: "#ff0000"}}, {showRuler: true});
   * if ( completed ) {
   *   // Movement was completed: the token arrived at the destination
   * } else {
   *   // Movement was stopped or prevented
   * }
   *
   * // The following is equivalent except for the return value
   * await token.update({x: 100, y: 200, rotation: 45,  texture: {tint: "#ff0000"}, {showRuler: true});
   * ```
   * @example Moving the token to along a path with multiple waypoints
   * ```js
   * const completed = await token.move(
   *   [
   *     {x: 100, y: 200}, // Move to the position (100, 200)
   *     {elevation: 5, explicit: true}, // Move to elevation 5 indicating that the user placed this waypoint
   *     {x: 500, y: 500, checkpoint: true}, // Move to (500, 500): the movement can be stopped/paused here
   *     {width: 2, height: 2, depth: 2}, // Change size
   *     {x: 1000, action: "swim"}, // Swim to (1000, 500)
   *     {x: 0, y: 0, snapped: true}, // Move to (0, 0) indicating that (0, 0) is a snapped position for the token
   *     {elevation: 10} // Move to elevation 10 (the last waypoint is always a checkpoint automatically)
   *   ],
   *   {
   *     autoRotate: true, // Enable auto rotation
   *     constrainOptions: {ignoreWalls: true, ignoreCost: true} // Allow the token to move through walls, surfaces, and
   *                                                             // impassable terrain
   *   }
   * );
   * if ( completed ) {
   *   // Movement was completed: the token arrived at the destination
   * } else {
   *   // Movement was stopped or prevented
   * }
   * ```
   */
  async move(waypoints, options={}) {
    const {id, ...rest} = options;
    const instruction = Array.isArray(waypoints) ? {waypoints, id} : {destination: waypoints, id};
    const results = await this.parent.moveTokens({[this.id]: instruction}, rest);
    return results[this.id];
  }

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

  /**
   * 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.
   * @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, depth, shape, level} = history[Math.max(index - 1, 0)];
    const args = {result: {}};
    const updated = await this.update({x, y, elevation, width, height, depth, shape, level, action: "displace",
      _movementHistory: history.slice(0, index)}, {isUndo: true, diff: false, animate: false,
      _movementArguments: args});
    return !!args.result[this.id] && !!updated;
  }

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

  /**
   * Resize the 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<Omit<TokenData & TokenMovementWaypoint, "x"|"y"|"elevation">} dimensions
   *   The new dimensions and additional data.
   * @param {Partial<Omit<DatabaseUpdateOperation, "updates">& Omit<TokenMovementOptions, "autoRotate"
   *   |"showRuler"|"terrainOptions"|"constrainOptions"|"measureOptions">} [options]
   *                              Parameters of the update operation.
   * @returns {Promise<boolean>}  A Promise that resolves to true if the Token was resized, otherwise resolves to false.
   * @see {@link TokenDocument#move}
   */
  async resize(dimensions, options={}) {
    const {id, ...rest} = options;
    const results = await this.parent.moveTokens({[this.id]: {dimensions, id}}, rest);
    return results[this.id];
  }

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

  /**
   * 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);
    if ( this._movement.state !== "pending" ) {
      const initialMovementId = this._movement.chain.at(0) ?? this._movement.id;
      const {resolve} = this.#movementPromisesWithResolvers.get(initialMovementId);
      this.#movementPromisesWithResolvers.delete(initialMovementId);
      resolve(false);
    }
    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 This is an Execute Script Region Behavior that makes the token invisible
   * ```js
   * // 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 This is an Execute Script Region Behavior of a pressure plate that activates a trap
   * ```js
   * // 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);
   * }
   * ```
   * @see {@link TokenDocument#resumeMovement}
   */
  pauseMovement(key) {
    const paused = this.#pauseMovement(true);
    if ( !paused ) return null;
    return this.#registerMovementContinuation(key);
  }

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

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

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

  /**
   * Returns the current movement ID if not planned. If planned, the movement ID with the ".planned" suffix is returned.
   * @returns {string}
   */
  #getMovementContinuationId() {
    return this.movement.state === "planned" ? `${this.movement.id}.planned` : this.movement.id;
  }

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

  /**
   * Register the movement continuation.
   * @overload
   * @returns {TokenResumeMovementCallback|null}  The callback to resume movement if the movement was or is paused,
   *                                              otherwise null
   */
  /**
   * @overload
   * @param {string|symbol} key        The key to resume movement with {@link TokenDocument#resumeMovement}
   * @returns {Promise<boolean>|null}  The continuation promise if the movement was paused, otherwise null
   */
  #registerMovementContinuation(key) {
    const continuation = this._movementContinuation;
    if ( continuation.continued ) return null;
    continuation.continueCounter++;
    const continuationId = this.#getMovementContinuationId();
    const state = continuation.states[continuationId] ??= {handles: new Map(), callbacks: [], pending: new Set()};
    /** @type {TokenMovementContinuationHandle} */
    const handle = {continuationId, callback: undefined, promise: undefined};
    if ( (key !== undefined) && 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.id === handle.continuationId ) {
          return handle.continuePromise = continuation.continuePromise;
        }
        return handle.continuePromise = new Promise(resolve => {
          continuation.states[handle.continuationId].callbacks.push(resolve);
        });
      }
      return handle.continuePromise = this.#continueMovement(handle.continuationId);
    };
    if ( key === undefined ) return handle.callback = callback;
    const {promise, resolve, reject} = Promise.withResolvers();
    let called = false;
    handle.callback = () => {
      if ( called ) return promise;
      called = true;
      callback().then(resolve).catch(reject);
      return promise;
    };
    if ( state.pending.delete(key) ) handle.callback();
    return promise;
  }

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

  /**
   * Start the currently planned movement or the planned movement corresponding to given movement ID.
   * Only owners of the Token can start the movement.
   * @param {string} [movementId]  The movement ID
   * @returns {Promise<boolean>}   True if the movement was started, false otherwise
   */
  async startMovement(movementId) {
    if ( !this.isOwner ) throw new Error("Only owners of the Token can start the movement.");
    if ( movementId && (this.movement.id !== movementId) ) return false;
    if ( this.movement.state !== "planned" ) return false;
    const continuation = this._movementContinuation;
    const state = continuation.states[this.#getMovementContinuationId()];
    const handle = state.handles.get(TokenDocument.#START_MOVEMENT_KEY);
    const result = await handle.callback();
    return result;
  }

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

  /**
   * Resume the movement given its ID and the key that was passed to {@link TokenDocument#pauseMovement}.
   * Only owners of this Token can resume the movement.
   * @param {string} movementId    The movement ID
   * @param {string} key           The key that was passed to {@link TokenDocument#pauseMovement}
   * @see {@link TokenDocument#pauseMovement}
   */
  resumeMovement(movementId, key) {
    if ( !this.isOwner ) throw new Error("Only owners of the Token can resume the movement.");
    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} continuationId    The movement continuation ID, which is equal to the movement ID for nonplanned
   *                                   movement
   * @returns {Promise<boolean>}       True if the movement was continued, otherwise false
   */
  async #continueMovement(continuationId) {
    const continuation = this._movementContinuation;

    // Check whether the token movement is still in the position we expect it to be
    if ( this.#getMovementContinuationId() !== continuationId ) {
      return this.#resolveMovementContinuation(continuationId, false);
    }

    // Don't continue movement twice
    if ( continuation.continuePromise ) {
      if ( continuation.id !== continuationId ) {
        continuation.id = continuationId;
        continuation.continuePromise = null;
        continuation.continuePromise = this.#continueMovement(continuationId);
      }
      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.#getMovementContinuationId() !== continuationId ) {
      return this.#resolveMovementContinuation(continuationId, 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(continuationId, false);

    // Check whether the token document was deleted
    if ( this.parent?.tokens.get(this.id) !== this ) return this.#resolveMovementContinuation(continuationId, false);

    // Continue token movement
    const waypoints = this.movement.pending.waypoints.filter(waypoint => !waypoint.intermediate);
    const {method, autoRotate, showRuler, terrainOptions, constrainOptions, measureOptions} = this.movement;
    const updateOptions = deepClone(this.movement.updateOptions);
    updateOptions.movement = {[this.id]: {waypoints, method, terrainOptions, constrainOptions, measureOptions,
      autoRotate, showRuler}};
    const planned = this.movement.state === "planned";
    const args = {movementId: this.movement.id, continuationId, planned, unrecorded: null, chain: planned
      ? [] : [...this.movement.chain, this.movement.id], result: {}};
    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(continuationId, !!args.result[this.id] && !!updated);
  }

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

  /**
   * Resolve the movement continuation.
   * @param {string} continuationId   The movement continuation ID
   * @param {boolean} result          True if the movement was continued, otherwise false
   * @returns {boolean}               The result that was passed
   */
  #resolveMovementContinuation(continuationId, result) {
    const continuation = this._movementContinuation;
    const state = continuation.states[continuationId];
    if ( !state ) return result;
    delete continuation.states[continuationId];
    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.id ) this.#resolveMovementContinuation(continuation.id, false);
    continuation.continueCounter = 0;
    continuation.resolveWaitPromise?.();
    continuation.resolveWaitPromise = undefined;
  }

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

  /**
   * Measure the movement path for this Token.
   * @param {TokenMeasurableMovementWaypoint[]} 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, depth: previousDepth, shape: previousShape, level: previousLevel} = 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,
        depth=previousDepth, shape=previousShape, level=previousLevel, action=previousAction, terrain=null,
        cost: c} = waypoints[i];
      x = Math.round(x);
      y = Math.round(y);
      const waypoint = {x, y, elevation, width, height, depth, shape, level: previousLevel};

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

      // Round width and height to nearest multiple of 0.5
      const roundedWidth = Math.max(Math.round(width * 2) / 2, 0.5);
      const roundedHeight = Math.max(Math.round(height * 2) / 2, 0.5);

      // Calculate the anchor
      if ( grid.isGridless ) {
        anchorX = 0;
        anchorY = 0;
      } else if ( grid.isSquare ) {
        anchorX = grid.size * (Number.isInteger(roundedWidth) ? 0.5 : 0.25);
        anchorY = grid.size * (Number.isInteger(roundedHeight) ? 0.5 : 0.25);
      } else {
        const {anchor} = BaseToken._getHexagonalOffsets(roundedWidth, roundedHeight, shape, grid.columns);
        anchorX = grid.sizeX * anchor.x;
        anchorY = grid.sizeY * anchor.y;
      }

      const resize = (i > 0) && ((width !== previousWidth) || (height !== previousHeight) || (depth !== previousDepth)
        || (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, width, height, depth, shape, level: previousLevel};
        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, depth,
        shape);

      path.push(waypoint);

      previousX = x;
      previousY = y;
      previousElevation = elevation;
      previousWidth = width;
      previousHeight = height;
      previousDepth = depth;
      previousShape = shape;
      previousLevel = level;
      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 {number} depth                                     The depth in grid spaces (nonnegative)
   * @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, depth, 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 cubic tokens equal to or less than 1x1x1 we can use the cost function directly
    if ( (width === height) && (height === depth) && (depth <= 1) ) return cost;

    // Square grid
    if ( grid.isSquare ) return TokenDocument.#createSquareMovementCostFunction(cost, aggregator, width, height, depth);

    // Hexagonal grid
    return TokenDocument.#createHexagonalMovementCostFunction(grid, cost, aggregator, width, height, depth, 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)
   * @param {number} depth                           The depth in grid spaces (nonnegative)
   * @returns {TokenMovementCostFunction}            The combined cost function
   */
  static #createSquareMovementCostFunction(cost, aggregator, width, height, depth) {
    const w = Math.ceil(Math.max(Math.round(width * 2) / 2, 0.5));
    const h = Math.ceil(Math.max(Math.round(height * 2) / 2, 0.5));
    const d = Math.ceil(Math.max(Math.round(depth * 2) / 2, 0.5));
    const results = [];
    for ( let l = w * h * d; 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, k: k0} = from;
      const {i: i1, j: j1, k: k1} = to;
      for ( let di = 0, l = 0; di < h; di++ ) {
        for ( let dj = 0; dj < w; dj++ ) {
          for ( let dk = 0; dk < d; dk++, l++ ) {
            const result = results[l];
            const {from: o0, to: o1} = result;
            o0.i = i0 + di;
            o0.j = j0 + dj;
            o0.k = k0 + dk;
            o1.i = i1 + di;
            o1.j = j1 + dj;
            o1.k = k1 + dk;
            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 {number} depth                             The depth in grid spaces (nonnegative)
   * @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, depth, shape) {
    const {columns, even} = grid;
    const {even: offsetsEven, odd: offsetsOdd} = BaseToken._getHexagonalOffsets(
      Math.max(Math.round(width * 2) / 2, 0.5),
      Math.max(Math.round(height * 2) / 2, 0.5),
      shape,
      columns
    );
    const n = offsetsEven.length;
    const d = Math.ceil(Math.max(Math.round(depth * 2) / 2, 0.5));
    const results = [];
    for ( let l = n * d; 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, k: k0} = from;
      const isEven0 = ((columns ? j0 : i0) % 2 === 0) === even;
      const offsets0 = isEven0 ? offsetsEven : offsetsOdd;
      const {i: i1, j: j1, k: k1} = to;
      const isEven1 = ((columns ? j1 : i1) % 2 === 0) === even;
      const offsets1 = isEven1 ? offsetsEven : offsetsOdd;
      for ( let o = 0, l = 0; o < n; o++ ) {
        const {i: di0, j: dj0} = offsets0[o];
        const {i: di1, j: dj1} = offsets1[o];
        for ( let dk = 0; dk < d; dk++, l++ ) {
          const result = results[l];
          const {from: o0, to: o1} = result;
          o0.i = i0 + di0;
          o0.j = j0 + dj0;
          o0.k = k0 + dk;
          o1.i = i1 + di1;
          o1.j = j1 + dj1;
          o1.k = k1 + dk;
          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 {Partial<TokenProcessedMovementWaypoint>[]} waypoints  The waypoints of movement
   * @returns {TokenProcessedMovementWaypoint[]}                   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,
      depth: previousDepth, shape: previousShape, level: previousLevel} = 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,
        depth=previousDepth, shape=previousShape, level=previousLevel, 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) || (depth !== previousDepth)
          || (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, 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, depth, shape, level: previousLevel,
            action, terrain: terrain ? terrain.clone() : null, snapped: true, explicit: false, checkpoint: false,
            intermediate: true});
        }
      }

      path.push({x, y, elevation, width, height, depth, shape, level, action, terrain, snapped, explicit, checkpoint,
        intermediate});

      from = to;
      previousX = x;
      previousY = y;
      previousElevation = elevation;
      previousWidth = width;
      previousHeight = height;
      previousDepth = depth;
      previousShape = shape;
      previousLevel = level;
      previousAction = action;
    }
    return path;
  }

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

  /**
   * 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.
   * Walls and surfaces are considered.
   * Returns an empty array in gridless grids.
   * @param {Partial<Point & TokenDimensions>} [data] The position and dimensions
   * @returns {GridOffset3D[]}                        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;

    // Test visibility of grid spaces
    let origin;
    let testVisibility;
    const scene = this.parent;
    const level = scene?.levels.get("level" in data ? data.level : this.level);
    if ( level ) {
      origin = this.getMovementOrigin(data);
      if ( (origin.x < 0) || (origin.x > scene.dimensions.width)
        || (origin.y < 0) || (origin.y > scene.dimensions.height) ) return offsets;
      const x = data.x ?? this.x;
      const y = data.y ?? this.y;
      const {width, height} = this.getSize(data);
      const boundingBox = new PIXI.Rectangle(x, y, width, height);
      const polygon = foundry.canvas.geometry.ClockwiseSweepPolygon.create(origin, {type: "move", level, boundingBox});
      const options = {type: "move", mode: "any", level};
      const deltas = [{x: 0, y: 0, elevation: grid.distance * 0.5}];
      for ( const vertex of grid.getShape() ) {
        const x = vertex.x * 0.5;
        const y = vertex.y * 0.5;
        deltas.push({x, y, elevation: grid.distance * 0.25}, {x, y, elevation: grid.distance * 0.75});
      }
      const point = {x: 0, y: 0, elevation: 0};
      const polygonTestCache = {};
      testVisibility = offset => {
        const {x, y, elevation} = grid.getCenterPoint(offset);
        for ( const {x: dx, y: dy, elevation: de} of deltas ) {
          point.x = x + dx;
          point.y = y + dy;
          point.elevation = elevation + de;
          const inPolygon = polygonTestCache[`${point.x},${point.y}`] ??= polygon.contains(point.x, point.y);
          if ( inPolygon && !level.parent.testSurfaceCollision(origin, point, options) ) return true;
        }
        return false;
      };
    }

    // Get the top-left grid offset
    const {i: i0, j: j0, k: k0} = this._positionToGridOffset(data);

    // Round width and height to nearest multiple of 0.5
    const width = Math.max(Math.round((data.width ?? this.width) * 2) / 2, 0.5);
    const height = Math.max(Math.round((data.height ?? this.height) * 2) / 2, 0.5);
    const depth = Math.max(Math.round((data.depth ?? this.depth) * 2) / 2, 0.5);
    const k1 = k0 + Math.ceil(depth);

    // 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++ ) {
          for ( let k = k0; k < k1; k++ ) {
            const offset = {i, j, k};
            if ( testVisibility?.(offset) === false ) continue;
            offsets.push(offset);
          }
        }
      }
    }

    // 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) ) {
        for ( let k = k0; k < k1; k++ ) {
          const offset = {i: i0 + di, j: j0 + dj, k};
          if ( testVisibility?.(offset) === false ) continue;
          offsets.push(offset);
        }
      }
    }

    // If none of the offsets are visible, include the offset of the origin
    if ( !offsets.length ) offsets.push(grid.getOffset(origin));

    return offsets;
  }

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

  /**
   * Get the number the grid spaces that this Token can occupy at most with the current or given dimensions.
   * Returns 0 in gridless grids.
   * @param {Partial<TokenDimensions>} [data]  The dimensions
   * @returns {number}                         The number the grid spaces that this Token can occupy at most
   */
  getMaxOccupiedGridSpaceCount(data={}) {
    const grid = this.parent?.grid ?? BaseScene.defaultGrid;

    // No grid spaces in gridless grids
    if ( grid.isGridless ) return 0;

    // Round width and height to nearest multiple of 0.5
    const width = Math.max(Math.round((data.width ?? this.width) * 2) / 2, 0.5);
    const height = Math.max(Math.round((data.height ?? this.height) * 2) / 2, 0.5);
    const depth = Math.max(Math.round((data.depth ?? this.depth) * 2) / 2, 0.5);

    // Square grid
    if ( grid.isSquare ) return Math.ceil(width) * Math.ceil(height) * Math.ceil(depth);

    // Hexagonal grid
    const {even: offsets} = BaseToken._getHexagonalOffsets(width, height, data.shape ?? this.shape, grid.columns);
    return offsets.length * Math.ceil(depth);
  }

  /* -------------------------------------------- */
  /*  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(_loc("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) {
    const actor = this.actor;
    switch ( embeddedName ) {
      case "Actor":
        if ( actor ) this.actors.set(this.actorId, actor);
        return this.actors;
      case "Item":
        return actor?.items ?? new Collection();
      case "ActiveEffect":
        return actor?.effects ?? new Collection();
    }
    return super.getEmbeddedCollection(embeddedName);
  }

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

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    const baseActor = game.actors.get(this.actorId);
    if ( !baseActor || this.isLinked ) return;
    if ( this.name !== baseActor.name ) this.updateSource({delta: {name: this.name}});
  }

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

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    const syntheticActor = this.delta?.syntheticActor;
    if ( syntheticActor ) foundry.documents.ActiveEffect.registry.addFromParent(syntheticActor);

    // 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) {
    const moveArgs = options._movementArguments || {};

    // Prevent movement history from being changed directly except for explicit reset
    if ( !options.isUndo ) delete changed._movementHistory;
    if ( options._clearMovementHistory ) changed._movementHistory = [];

    let {waypoints, id: movementId, method, split, planned, constrainOptions, measureOptions, terrainOptions,
      autoRotate, showRuler} = options.movement?.[this.id] ?? {};

    // Prepare movement waypoints
    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;
    }

    // Generate movement ID, preserving a planned movement ID when executing planned movement
    movementId ||= (moveArgs.planned ? moveArgs.movementId : foundry.utils.randomID());
    if ( !isValidId(movementId) ) throw new Error("Invalid movement ID");
    method ??= options.method ?? (options.isPaste ? "paste" : (options.isUndo ? "undo" : "api"));
    if ( !["api", "config", "hud", "dragging", "keyboard", "paste", "undo"].includes(method)
      || ((method === "paste") !== !!options.isPaste)
      || ((method === "undo") !== !!options.isUndo) ) {
      throw new Error("Invalid movement method");
    }
    planned ??= options.planned ?? false;
    terrainOptions = deepClone(terrainOptions ?? options.terrainOptions) ?? {};
    constrainOptions = deepClone(constrainOptions ?? options.constrainOptions) ?? {};
    measureOptions = deepClone(measureOptions ?? options.measureOptions) ?? {};
    autoRotate ??= options.autoRotate;
    if ( autoRotate !== undefined ) autoRotate = !!autoRotate;
    showRuler ??= options.showRuler;
    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, options);

    // 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,
      depth: this._source.depth,
      shape: this._source.shape,
      level: this._source.level
    });

    // Set movement speed based on the animation duration
    foundry.canvas.placeables.Token._configureAnimationMovementSpeed(options, origin, waypoints, this);

    const recorded = !planned && !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 ? [] : moveArgs.unrecorded ?? []);

    // Set path ID based on whether this movement splits off a new subpath.
    // We split by default unless it's keyboard movement followed by keyboard movement.
    const chain = moveArgs.chain ?? [];
    if ( !combined.length || chain.length ) split = false; // No previous subpath or chained, no split
    else split ??= (options.split ?? ((method !== "keyboard") || (combined.at(-1).method === "keyboard")));
    const subpathId = split || !combined.length ? movementId : combined.at(-1).subpathId;

    // 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, depth, shape, level} = origin;
      combined.push({x, y, elevation, width, height, depth, shape, level, action: previous ? "displace" : waypoints[0].action,
        terrain: null, snapped: false, explicit: false, checkpoint: true, intermediate: false,
        userId: game.user.id, movementId, subpathId, 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 ( planned ) [passed, pending] = [[], waypoints];
    else if ( options.isPaste || options.isUndo ) [passed, pending] = [waypoints, []];
    else ({passed, pending} = this.#splitMovementPath(origin, waypoints, changed));

    // Regionalize and constrain movement
    if ( !planned && !(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, measureOptions, preview: false, history: combined});
      if ( wasConstrained ) {
        constrainedPath.shift();
        passed = constrainedPath;
        pending.length = 0;
        constrained = true;
      }
    }

    // Movement was constrained and impossible entirely
    if ( !planned && (passed.length === 0) ) {
      for ( const k of TokenDocument.MOVEMENT_FIELDS ) delete changed[k];
      if ( moveArgs.continuationId === this._movementContinuation.id ) {
        this.#stopMovement(options._movement._postWorkflow.promise);
      }
      return;
    }

    // Force destination to be a checkpoint
    const destination = !planned ? passed.at(-1) : origin;
    if ( !planned ) destination.checkpoint = true;

    // Assign the checkpoint target to the changes object
    if ( !planned ) {
      for ( const k of TokenDocument.MOVEMENT_FIELDS ) changed[k] = destination[k];
    } else {
      for ( const k of TokenDocument.MOVEMENT_FIELDS ) delete changed[k];
    }

    // Regionalize the pending movement
    if ( (pending.length !== 0) && this.rendered ) {
      pending = this.object.createTerrainMovementPath([destination, ...pending], {...terrainOptions, 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
    if ( !planned ) {
      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;
      waypoint.subpathId = subpathId;
      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
      if ( !planned ) {
        measurement = this.object.measureMovementPath(combined, {...measureOptions, preview: false});
        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 = planned ? movementId : null;
          waypoint.subpathId = subpathId;
          combined.push(waypoint);
        }
        measurement = this.object.measureMovementPath(combined, {...measureOptions, 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 = planned ? movementId : null;
        waypoint.subpathId = subpathId;
        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 {TokenPreMovementOperation}
     */
    const move = Object.seal({
      id: movementId,
      subpathId,
      chain,
      origin,
      destination: ({x: destination.x, y: destination.y, elevation: destination.elevation,
        width: destination.width, height: destination.height, depth: destination.depth, shape: destination.shape,
        level: destination.level}),
      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
      },
      split,
      constrained,
      recorded,
      method,
      terrainOptions,
      constrainOptions,
      measureOptions,
      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: deepFreeze(value)});
    }

    // Allow systems/modules to reject the movement and to modify the operation as needed
    if ( !planned ) {
      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 ( moveArgs.continuationId === this._movementContinuation.id ) {
          this.#stopMovement(options._movement._postWorkflow.promise);
        }
        return;
      }
    }

    // Freeze writeable movement operation properties
    for ( const key of TokenDocument.#WRITEABLE_MOVEMENT_OPERATION_PROPERTIES ) {
      Object.defineProperty(move, key, {value: deepFreeze(move[key])});
    }

    // Automatically set the rotation based of the last nonzero segment if the autoRotate option is set to true
    if ( move.autoRotate && !planned ) this.#rotateInMovementDirection(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 ( moveArgs.result ) moveArgs.result[this.id] = movementId;
    if ( moveArgs.continuationId !== 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) ) {
      this.actors.clear();
      const previousActor = game.actors.get(options.previousActorId);
      if ( previousActor ) {
        Object.values(previousActor.apps).forEach(app => app.close({submit: false}));
        previousActor._unregisterDependentToken(this);
      }
      if ( this.actorLink ) this._priorOverrides = null;
      else 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);

    // Post-update the Token itself
    super._onUpdate(changed, options, userId);
  }

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

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

      // Stop current movement unless chained or started plan
      if ( this.movement.id !== (movement.chain.at(-1) ?? movement.id) ) this.#stopMovement(false);

      // Update movement data
      this.#resetMovementContinuation();
      const postWorkflowPromise = options._movement._postWorkflow.promise;
      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
        ? (movement.passed.waypoints.length === 0 ? "planned" : "pending") : "completed");
      const finished = (state === "completed") || (state === "stopped");
      if ( !finished ) user.movingTokens.add(this);
      const {animate, animation, diff, noHook, pan, render, renderSheet, isPaste, isUndo} = options;
      const updateOptions = deepFreeze({animate, animation, diff, noHook, pan, render, renderSheet,
        isPaste, isUndo});
      const initialMovementId = movement.chain.at(0) ?? movement.id;
      let movementPromiseWithResolver = this.#movementPromisesWithResolvers.get(initialMovementId);
      if ( !movementPromiseWithResolver ) {
        movementPromiseWithResolver = Promise.withResolvers();
        if ( !finished ) this.#movementPromisesWithResolvers.set(movement.id, movementPromiseWithResolver);
        if ( options._returnMovementPromise ) {
          this._returnedMovementPromises.set(movement.id, movementPromiseWithResolver.promise);
        }
      }
      if ( finished ) {
        const {resolve} = movementPromiseWithResolver;
        const completed = state === "completed";
        postWorkflowPromise.then(() => resolve(completed));
      }
      this._movement = Object.freeze({...movement, user, state, updateOptions,
        finished: movementPromiseWithResolver.promise,
        animation: {started: Promise.resolve(), ended: Promise.resolve(), duration: 0}});
      movement.finished = this._movement.finished;
      movement.animation = this._movement.animation;

      // Freeze movement data
      for ( const [key, value] of Object.entries(movement) ) {
        if ( key === "animation" ) continue;
        movement[key] = deepFreeze(value);
      }
      Object.freeze(movement);

      // Handle planned movement
      if ( (state === "planned") && this.isOwner ) this.#registerMovementContinuation(TokenDocument.#START_MOVEMENT_KEY);
    }

    // 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);
    this.delta?.syntheticActor?._onDelete({...options, parent: this}, userId);
  }

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

  /**
   * 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 ) {
      let isInside;
      if ( this.attachments.regions.has(region) ) isInside = this._source._regions.includes(region.id);
      else 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);

    // Update hidden state of attached regions
    for ( const [i, token] of documents.entries() ) {
      if ( !token.attachments.regions.size ) continue;
      const changes = operation.updates[i];
      if ( !("hidden" in changes) ) continue;
      const regionUpdates = operation._regionUpdates ??= {};
      for ( const region of token.attachments.regions ) {
        const updates = regionUpdates[region.id] ??= {};
        updates.hidden = !!DataFieldOperator.get(changes.hidden);
      }
    }
  }

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

  /** @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", "depth", "shape", "level",
    "action", "snapped", "explicit", "checkpoint"]);

  /**
   * Clean and validate movement waypoints.
   * @param {Partial<TokenMovementWaypoint>[]} waypoints    The waypoints
   * @param {Partial<DatabaseUpdateOperation>} options      The database operation that was performed
   * @returns {TokenMovementWaypoint[]}                     The cleaned waypoints
   */
  #cleanAndValiateMovementWaypoints(waypoints, options) {
    waypoints = waypoints.slice();
    if ( !waypoints.length ) return waypoints;
    const {x, y, elevation, width, height, depth, shape, level} = this._source;
    let previousWaypoint = {x, y, elevation, width, height, depth, shape, level, action: this.movementAction};
    if ( (waypoints[0].level === undefined) && !this.parent.levels.has(previousWaypoint.level) ) throw new Error("level must exist");
    if ( (options.isUndo === true) || (options.isPaste === true) ) previousWaypoint.action = "displace";
    else if ( (waypoints[0].action === undefined) && !(previousWaypoint.action in CONFIG.Token.movement.actions) ) {
      throw new Error("action is invalid");
    }
    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.depth === undefined ) waypoint.depth = previousWaypoint.depth;
      else if ( typeof waypoint.depth !== "number" ) throw new Error("depth must be a number");
      else if ( !Number.isFinite(waypoint.depth) ) throw new Error("depth must be finite");
      else if ( !(waypoint.depth >= 0) ) throw new Error("depth must be nonnegative");
      if ( waypoint.shape === undefined ) waypoint.shape = previousWaypoint.shape;
      else if ( !TokenDocument.#VALID_SHAPES.includes(waypoint.shape) ) throw new Error("shape is invalid");
      if ( waypoint.level === undefined ) waypoint.level = previousWaypoint.level;
      else if ( !isValidId(waypoint.level) ) throw new Error("level must be a valid ID");
      else if ( !this.parent.levels.has(waypoint.level) ) throw new Error("level must exist");
      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) {

    // Compute updates to attached regions
    for ( const [i, document] of documents.entries() ) {
      const changes = operation.updates[i];
      if ( operation._movement?.[document.id] || ("rotation" in changes) ) {
        document.#computeAttachedRegionUpdates(changes, operation);
      }
    }

    delete operation.movement;
    operation._movement?._postWorkflow.resolve();
    if ( 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 depth = changes.depth ?? document._source.depth;
    const shape = changes.shape ?? document._source.shape;
    const level = changes.level ?? document._source.level;
    /** @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 = ["teleport", "isUndo", "isPaste"].some(k => operation[k] === true) ? "displace" : undefined;
    return [{x, y, elevation, width, height, depth, shape, level, 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, depth, shape, level, action, snapped, explicit, checkpoint,
      intermediate} of waypoints ) {
      if ( intermediate ) continue;
      path.push({x, y, elevation, width, height, depth, shape, level, 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 {Partial<TokenData>} changes
   * @param {TokenPreMovementOperation} movement
   */
  #rotateInMovementDirection(changes, movement) {
    if ( "rotation" in changes ) return;
    const {origin, destination, passed, method} = movement;
    const isUndo = method === "undo";
    let c1 = this.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 = this.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;
    }
  }

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

  /**
   * Compute the updates to attached regions.
   * @param {Partial<TokenData>} changes
   * @param {Partial<Omit<DatabaseUpdateOperation, "updates">>} options
   */
  #computeAttachedRegionUpdates(changes, options) {
    if ( !this.attachments.regions.size ) return;
    const regionUpdates = options._regionUpdates ??= {};
    const movement = options._movement?.[this.id];
    const origin = movement?.origin ?? this._source;
    const destination = movement?.destination ?? this._source;
    const positionChanged = !TokenDocument.arePositionsEqual(origin, destination);
    const dr = (changes.rotation ?? this._source.rotation) - this._source.rotation;
    if ( !positionChanged && !dr && (origin.level === destination.level) ) return;
    const from = this.getMovementOrigin(origin);
    const to = movement ? this.getMovementOrigin(destination) : from;
    const feet = origin.elevation;
    const head = origin.elevation + (origin.depth * this.parent.grid.distance);
    const elevationOrDepthChanged = (origin.elevation !== destination.elevation)
      || (origin.depth !== destination.depth);
    for ( const region of this.attachments.regions ) {
      const updates = regionUpdates[region.id] ??= {};
      const intersectsTokenElevationRange = (region.elevation.bottom <= head) && (region.elevation.top >= feet);
      if ( positionChanged || dr ) {
        const shapes = [];
        for ( let shape of region.shapes ) {
          shape = shape.clone();
          if ( dr ) shape.rotate(dr, {pivot: from});
          if ( positionChanged ) {
            if ( intersectsTokenElevationRange && (shape.type === "emanation") && (shape.base.type === "token")
              && (origin.x === shape.base.x)
              && (origin.y === shape.base.y)
              && (origin.width === shape.base.width)
              && (origin.height === shape.base.height)
              && (origin.shape === shape.base.shape) ) {
              shape.updateSource({base: destination});
            }
            else shape.move({x: shape.origin.x + (to.x - from.x), y: shape.origin.y + (to.y - from.y)});
          }
          shapes.push(shape.toObject());
        }
        updates.shapes = shapes;
      }
      if ( elevationOrDepthChanged ) {
        const emanatesFromToken = intersectsTokenElevationRange
          && region.shapes.every(shape => (shape.type === "emanation") && (shape.base.type === "token")
            && (origin.x === shape.base.x)
            && (origin.y === shape.base.y)
            && (origin.width === shape.base.width)
            && (origin.height === shape.base.height)
            && (origin.shape === shape.base.shape));
        if ( emanatesFromToken ) {
          const {top, bottom} = region._source.elevation;
          updates.elevation = {
            bottom: Number.isFinite(bottom) ? (bottom <= feet ? bottom + (destination.elevation - feet)
              : destination.elevation + ((bottom - feet) * (destination.depth / origin.depth))) : null,
            top: Number.isFinite(top) ? (top >= head ? top + ((destination.elevation - origin.elevation)
              + ((destination.depth - origin.depth) * this.parent.grid.distance))
              : destination.elevation + ((top - feet) * (destination.depth / origin.depth))) : null
          };
        } else {
          const de = to.elevation - from.elevation;
          if ( de ) {
            const {top, bottom} = region._source.elevation;
            updates.elevation = {
              bottom: Number.isFinite(bottom) ? bottom + de : null,
              top: Number.isFinite(top) ? top + de : null
            };
          }
        }
      }
      if ( origin.level !== destination.level ) updates.levels = [destination.level];
      updates._shapeConstraints = region._computeShapeConstraints(updates);
    }
  }

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

  /**
   * 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
   * @param {Partial<TokenData>} changed                                               The token data changes
   * @returns {{passed: TokenMovementWaypoint[]; pending: TokenMovementWaypoint[]}}    The split movement path
   */
  #splitMovementPath(origin, waypoints, changed) {
    const regions = [];

    // Determine the initially active regions and where to add checkpoints
    for ( const region of this.parent.regions ) {
      if ( region.hidden ) continue;

      // Ignore attached regions because they move with the token
      if ( this.attachments.regions.has(region) ) continue;

      // 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.getMovementOrigin(previous);
        const segment = [previous, current];
        for ( const data of regions ) {
          const {region, mask} = data;
          if ( !region.includedInLevel(previous.level) ) continue;
          for ( const {type, to} of this.segmentizeRegionMovementPath(region, segment) ) {
            if ( mask & (1 << (type + 1)) ) {
              const center = this.getMovementOrigin(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, depth: regionCheckpoint.depth,
              shape: regionCheckpoint.shape, level: regionCheckpoint.level, 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 {TokenPreMovementOperation} movement                           The pending movement of this Token
   * @param {Partial<Omit<DatabaseUpdateOperation, "updates">>} 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 {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 current movement is planned.
   * @protected
   */
  _onMovementPlanned() {}

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

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

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

  /** @inheritDoc */
  static async _onCreateOperation(documents, operation, user) {
    for ( const token of documents ) {
      for ( const region of token.regions ) {
        region._handleEvent({
          name: REGION_EVENTS.TOKEN_ENTER,
          data: {token, movement: null},
          region,
          user
        });
      }
    }

    // Between TokenDocument#preCreate and here a region could have been created:
    // therefore we need to update the regions again
    if ( user.isSelf ) operation.parent.updateTokenRegions(documents);

    return super._onCreateOperation(documents, operation, 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");
        }
      }
    });
  }

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

  /** @inheritDoc */
  static async _onUpdateOperation(documents, operation, user) {
    await super._onUpdateOperation(documents, operation, user);
    TokenDocument._addTeleportAndForcedShims(operation);
    TokenDocument.#onUpdateOperationMovement(documents, operation, user);
    TokenDocument.#onUpdateHandleMoveWithinRegionEvents(documents, operation, user);
    TokenDocument.#onUpdateHandleEnterExitMoveInOutRegionEvents(documents, operation, user);
    TokenDocument.#onUpdateContinueMovement(documents, operation, user);

    for ( const update of operation.updates ) {
      if ( "level" in update ) {
        ui.nav.render();
        break;
      }
    }
  }

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

  /**
   * 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 ) {
        region._handleEvent({
          name: REGION_EVENTS.TOKEN_EXIT,
          data: {token, movement},
          region,
          user
        });
      }
      for ( const region of addedRegions ) {
        region._handleEvent({
          name: REGION_EVENTS.TOKEN_ENTER,
          data: {token, movement},
          region,
          user
        });
      }
      if ( movement?.passed.waypoints.length ) {
        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;
    const moves = [];
    for ( const document of documents ) {
      const movement = operation._movement[document.id];
      if ( !movement ) continue;
      if ( movement.passed.waypoints.length === 0 ) {
        if ( document.movement.id === movement.id ) {
          document._onMovementPlanned();
          Hooks$1.callAll("planToken", document);
        }
        continue;
      }
      moves.push({document, movement});
      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);
      }
    }
    TokenDocument.#handleMovementOperation(moves, operation, user);
  }

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

  /**
   * Handles changes in level.
   * @param {{document: TokenDocument; movement: TokenMovementOperation}[]} moves  The moves
   * @param {Partial<DatabaseUpdateOperation>} operation  The update operation
   * @param {User} user                                   The User that requested the update operation
   * @returns {Promise<void>}
   */
  static async #handleMovementOperation(moves, operation, user) {
    if ( !moves.length ) return;
    const scene = operation.parent;
    if ( !scene.isView ) return;
    const viewedLevel = canvas.level;

    // Refresh the viewed state
    await Promise.all(moves.map(async ({document, movement}) => {

      // The token changes the level at the end of the segment
      if ( movement.origin.level !== movement.destination.level ) {
        await movement.animation.ended;
        await canvas.initializing;
        if ( !canvas.ready ) return;
      }

      // Handle the token becoming (un)viewed
      await document._refreshViewedState();
    }));

    // GMs that didn't move the tokens themselves do not have their view switched
    if ( !user.isSelf && game.user.isGM ) return;

    // Don't switch the view if the current user no longer viewed the origin level
    if ( !viewedLevel.isView ) return;

    const choices = {};
    let noOwnedNonhiddenToken;
    for ( const {document, movement} of moves ) {
      if ( movement.origin.level !== viewedLevel.id ) continue;

      // Only switch the view if the moved token is observable to the user
      const isObservable = document.hidden ? game.user.isGM : document.testUserPermission(game.user, "OBSERVER");
      if ( !isObservable ) continue;

      // Switch the view only if ...
      //   - the moving user is not a GM and no longer owns a nonhidden token in the origin level, or
      //   - the moving user is self and made the movement via config, HUD, dragging, or keyboard.
      noOwnedNonhiddenToken ??= (!game.user.isGM && !scene.tokens.some(t => !t.hidden && t.isOwner
        && (t._source.level === viewedLevel.id) && (!scene.tokenVision || t.sight.enabled)));
      const switchView = noOwnedNonhiddenToken || (user.isSelf && ["config", "hud", "dragging", "keyboard"].includes(movement.method));
      if ( !switchView ) continue;

      const level = movement.destination.level;
      const choice = choices[level] ??= {level, tokens: [], numOwned: 0, hasCharacter: false};
      choice.tokens.push(document.id);
      if ( document.isOwner ) choice.numOwned++;
      if ( game.user.character ) choice.hasCharacter ||= (document.actor === game.user.character);
    }

    const isBetterChoice = (candidate, currentBest) => {
      if ( candidate.hasCharacter !== currentBest.hasCharacter ) return candidate.hasCharacter;
      const candidateViewed = candidate.level === viewedLevel.id;
      const currentBestViewed = currentBest.level === viewedLevel.id;
      if ( candidateViewed !== currentBestViewed ) return candidateViewed;
      if ( candidate.numOwned !== currentBest.numOwned ) return candidate.numOwned > currentBest.numOwned;
      return candidate.tokens.length > currentBest.tokens.length;
    };
    let bestChoice;
    for ( const choice of Object.values(choices) ) {
      if ( !bestChoice || isBetterChoice(choice, bestChoice) ) bestChoice = choice;
    }
    if ( !bestChoice || (bestChoice.level === viewedLevel.id) ) return;
    await scene.view({level: bestChoice.level, controlledTokens: bestChoice.tokens});
  }

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

  /**
   * 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 || !movement.passed.waypoints.length ) continue;
      const priorRegionIds = operation._priorRegions?.[token.id];
      const ignoredRegionIds = new Set(operation._attachedRegions?.[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 || ignoredRegionIds.has(region.id) ) continue;
          token.#handleMoveRegionEvent(REGION_EVENTS.TOKEN_MOVE_WITHIN, region, user, movement);
        }
      } else {
        for ( const region of token.regions ) {
          if ( ignoredRegionIds.has(region.id) ) continue;
          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";
          }
        }
      });
    }
    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);
      };
      if ( token._movementContinuation.waitPromise ) token._movementContinuation.waitPromise.then(maybeContinue);
      else maybeContinue();
    }
  }

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

  /** @inheritDoc */
  static async _onDeleteOperation(documents, operation, user) {
    const attachedRegions = [];

    // 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 region of token.attachments.regions ) {
        region.attachment.token = null;
        attachedRegions.push(region);
      }
      token.attachments.regions.clear();
    }
    for ( const event of regionEvents ) {
      event.region._handleEvent(event);
    }

    // Delete attached Regions
    const regionsToDelete = attachedRegions.reduce((ids, region) => {
      if ( game.user.isDesignated(user => user.active && region.canUserModify(user, "delete")) ) ids.push(region.id);
      return ids;
    }, []);
    if ( regionsToDelete.length ) {
      // noinspection ES6MissingAwait
      operation.parent.deleteEmbeddedDocuments("Region", regionsToDelete);
    }

    // Update/delete Combatants
    if ( user.isSelf ) foundry.documents.Combat._onDeleteTokens(documents, operation, user);

    return super._onDeleteOperation(documents, operation, user);
  }

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

  /**
   * Are these changes moving the Token (from the given origin if provided)?
   * @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.
   * @param {RegionDocument} region                              The region.
   * @param {(Partial<ElevatedPoint & Omit<TokenPosition, "x"|"y"|"elevation">)} [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 level = "level" in data ? data.level : this._source.level;
    if ( !region.includedInLevel(level) ) return false;
    const {bottom, top, topInclusive} = region.elevation;
    const x = data.x ?? this._source.x;
    const y = data.y ?? this._source.y;
    const elevation = data.elevation ?? this._source.elevation;
    const width = data.width ?? this._source.width;
    const height = data.height ?? this._source.height;
    const depth = data.depth ?? this._source.depth;
    const shape = data.shape ?? this._source.shape;
    if ( topInclusive ) {
      if ( elevation > top ) return false;
    } else {
      if ( (elevation !== bottom) && (elevation >= top) ) return false;
    }
    const head = elevation + (depth * this.parent.grid.distance);
    if ( depth === 0 ) {
      if ( head < bottom ) return false;
    } else {
      if ( head <= bottom ) return false;
    }
    data = {x, y, elevation, width, height, depth, shape};
    const size = this.getSize(data);
    const bounds = TokenDocument.#SHARED_RECTANGLE;
    bounds.x = x;
    bounds.y = y;
    bounds.width = size.width;
    bounds.height = size.height;
    if ( !region.bounds.intersects(bounds) ) return false;
    const points = this.getContainmentTestPoints(data);
    return points.some(point => region.polygonTree.testPoint(point, 0.75));
  }

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

  /**
   * Get the movement origin of this Token.
   * This point is used to test collision with walls and surfaces.
   * @param {Partial<ElevatedPoint & TokenDimensions>} [data]  The position and dimensions
   * @returns {ElevatedPoint}                                  The movement origin
   */
  getMovementOrigin(data={}) {
    const origin = this.getCenterPoint(data);
    origin.elevation += ((data.depth ?? this.depth) * (this.parent?.grid ?? BaseScene.defaultGrid).distance / 2);
    return origin;
  }

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

  /**
   * Get the origin of the light source of this Token.
   *
   * The default implementation returns the movement origin.
   * @param {Partial<ElevatedPoint & TokenDimensions>} [data]  The position and dimensions
   * @returns {ElevatedPoint}                                  The vision origin
   */
  getLightOrigin(data={}) {
    return this.getMovementOrigin(data);
  }

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

  /**
   * Get the origin of the vision source of this Token.
   *
   * The default implementation returns the movement origin.
   * @param {Partial<ElevatedPoint & TokenDimensions>} [data]  The position and dimensions
   * @returns {ElevatedPoint}                                  The light origin
   */
  getVisionOrigin(data={}) {
    return this.getMovementOrigin(data);
  }

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

  /**
   * Get the origin of the sound source of this Token.
   *
   * The default implementation returns the movement origin.
   * @param {Partial<ElevatedPoint & TokenDimensions>} [data]  The position and dimensions
   * @returns {ElevatedPoint}                                  The light origin
   */
  getSoundOrigin(data={}) {
    return this.getMovementOrigin(data);
  }

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

  /**
   * Get the listener position of this Token.
   *
   * The default implementation returns the movement origin.
   * @param {Partial<ElevatedPoint & TokenDimensions>} [data]  The position and dimensions
   * @returns {ElevatedPoint}                                  The listener position
   */
  getListenerPosition(data={}) {
    return this.getMovementOrigin(data);
  }

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

  /**
   * Get the points that are used to test region containment/segmentation (unless overridden)
   * for this Token. The test points are within the shape of the Token.
   *
   * Implementations of this function must use the prepared position and dimensions of this Token.
   * @param {(Partial<ElevatedPoint & TokenPosition>)} [data]    The position and dimensions. Defaults to the values of
   *                                                             the prepared document, not the document source.
   * @returns {Point[]}                                          The test points.
   */
  getContainmentTestPoints(data={}) {
    const points = this.#getUnconstrainedTestPoints(data);
    this._constrainTestPoints(points, data);
    return points;
  }

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

  /**
   * Get the points that are used to test visibility for this Token. The test points are within the shape of the Token.
   *
   * Implementations of this function must use the prepared position and dimensions of this Token.
   * @param {(Partial<ElevatedPoint & TokenPosition>)} [data]    The position and dimensions. Defaults to the values of
   *                                                             the prepared document, not the document source.
   * @returns {ElevatedPoint[]}                                  The test points.
   */
  getVisibilityTestPoints(data={}) {
    const points = this.#getUnconstrainedTestPoints(data, {dense: true});
    this._constrainTestPoints(points, data);
    const elevation = this.getMovementOrigin(data).elevation;
    for ( const point of points ) point.elevation = elevation;
    return points;
  }

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

  /**
   * Get the points that are used to test occlusion for this Token. The test points are within the shape of the Token.
   *
   * Implementations of this function must use the prepared position and dimensions of this Token.
   * @param {(Partial<Point & Omit<TokenPosition, "elevation"|"depth">>)} [data]  The position and dimensions. Defaults
   *                                                                              to the values of the prepared
   *                                                                              document, not the document source.
   * @returns {Point[]}                                                           The test points.
   */
  getOcclusionTestPoints(data={}) {
    const points = this.#getUnconstrainedTestPoints(data, {dense: true});
    this._constrainTestPoints(points, data);
    return points;
  }

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

  /**
   * Get the points that can be used to test visibility, occlusion, and region containment/segmentation
   * for this Token without constraints.
   *
   * Implementations of this function must use the prepared position and dimensions of this Token.
   * The test points must be within the shape of the Token.
   * @param {(Partial<Point & Omit<TokenPosition, "elevation"|"depth">>)} [data]  The position and dimensions.
   *                                                                          Defaults to the values of the prepared
   *                                                                          document, not the document source.
   * @param {object} [options]                                                Additional options.
   * @param {boolean} [options.dense=false]                                   Add extra test points?
   * @returns {Point[]}                                                       The unconstrained test points.
   */
  #getUnconstrainedTestPoints(data={}, {dense=false}={}) {
    const grid = this.parent?.grid ?? BaseScene.defaultGrid;
    const x = data.x ?? this.x;
    const y = data.y ?? this.y;
    const width = data.width ?? this.width;
    const height = data.height ?? this.height;
    const shape = data.shape ?? this.shape;
    data = {x, y, width, height, shape};

    // Dense test point arrangements
    if ( dense && (width === height) && (width >= 1) ) {
      if ( width === 1 ) return this.#getGridUnconstrainedTestPoints(data);
      if ( !grid.isHexagonal ) {
        if ( width < 1.5 ) return this.#getGridUnconstrainedTestPoints(data);
        if ( width < 2.5 ) return this.#getNineUnconstrainedTestPoints(data);
      }
    }

    const center = this.getCenterPoint(data);

    // If at most 1x1, test center point
    if ( (width <= 1) && (height <= 1) ) return [{x: center.x, y: center.y}];

    // If hexagonal shape, test center points of hexes and the center point
    if ( grid.isHexagonal ) {
      const offsets = BaseToken._getHexagonalShape(width, height, shape, grid.columns)?.offsets;
      if ( offsets ) {
        const offset = this._positionToGridOffset(data);
        const {i: i0, j: j0} = offset;
        const isEven = ((grid.columns ? j0 : i0) % 2 === 0) === grid.even;
        const position = this._gridOffsetToPosition(offset, data);
        const dx = x - Math.round(position.x);
        const dy = y - Math.round(position.y);
        const points = [{x: center.x, y: center.y}];
        for ( const {i: di, j: dj} of (isEven ? offsets.even : offsets.odd) ) {
          const point = grid.getCenterPoint({i: i0 + di, j: j0 + dj});
          point.x += dx;
          point.y += dy;
          if ( point.x.almostEqual(center.x) && point.y.almostEqual(center.y) ) continue;
          points.push(point);
        }
        return points;
      }
    }

    const points = [{x: center.x, y: center.y}];
    const size = this.getSize(data);
    const padX = Math.min(grid.sizeX, size.width) / 2;
    const padY = Math.min(grid.sizeY, size.height) / 2;
    const [deltaX, deltaY] = grid.isHexagonal ? (grid.columns ? [grid.sizeX * 0.75, grid.sizeY]
      : [grid.sizeX, grid.sizeY * 0.75]) : [grid.size, grid.size];
    const innerX = size.width - (padX * 2);
    const innerY = size.height - (padY * 2);
    const m = innerX ? Math.max(Math.round((innerX / deltaX) + 1e-6), 1) : 0;
    const n = innerY ? Math.max(Math.round((innerY / deltaY) + 1e-6), 1) : 0;
    const stepX = m ? innerX / m : 0;
    const stepY = n ? innerY / n : 0;

    // If the shape is an ellipse, arrange test points elliptically
    if ( grid.isGridless && [CONST.TOKEN_SHAPES.ELLIPSE_1, CONST.TOKEN_SHAPES.ELLIPSE_2].includes(shape) ) {
      const numRings = Math.ceil(Math.min(n, m) / 2);
      const maxDensity = Math.max((2 * (n + m)).toNearest(4), 4);
      for ( let k = 0; k < numRings; k++ ) {
        const radiusX = (innerX / 2) - (stepX * k);
        const radiusY = (innerY / 2) - (stepY * k);
        const density = maxDensity - (8 * k);
        const deltaAngle = Math.PI * 2 / density;
        for ( let s = 0; s < density; s++ ) {
          const angle = deltaAngle * s;
          points.push({
            x: center.x + (Math.cos(angle) * radiusX),
            y: center.y + (Math.sin(angle) * radiusY)
          });
        }
      }
      return points;
    }

    // Otherwise distribute the test points over the rectangular shape including the center point
    for ( let i = 0; i <= n; i++ ) {
      for ( let j = 0; j <= m; j++ ) {
        const point = {x: x + padX + (stepX * j), y: y + padY + (stepY * i)};
        if ( point.x.almostEqual(center.x) && point.y.almostEqual(center.y) ) continue;
        points.push(point);
      }
    }
    return points;
  }

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

  /**
   * Get test points arranged in the shape of a grid space.
   * @param {(Partial<Point & Omit<TokenPosition, "elevation"|"depth">>)} [data]  The position and dimensions.
   *                                                                          Defaults to the values of the prepared
   *                                                                          document, not the document source.
   * @returns {Point[]}                                                       The test points.
   */
  #getGridUnconstrainedTestPoints(data) {
    const grid = this.parent?.grid ?? BaseScene.defaultGrid;
    if ( grid.isGridless ) return this.#getNineUnconstrainedTestPoints(data);
    const width = data.width ?? this.width;
    const height = data.height ?? this.height;
    const center = this.getCenterPoint(data);
    const points = [{x: center.x, y: center.y}];
    for ( const point of grid.getShape() ) {
      point.x = center.x + (point.x * 0.5 * width);
      point.y = center.y + (point.y * 0.5 * height);
      points.push(point);
    }
    return points;
  }

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

  /**
   * Get nine test points.
   * @param {(Partial<Point & Omit<TokenPosition, "elevation"|"depth">>)} [data]  The position and dimensions.
   *                                                                          Defaults to the values of the prepared
   *                                                                          document, not the document source.
   * @returns {Point[]}                                                       The test points.
   */
  #getNineUnconstrainedTestPoints(data) {
    const grid = this.parent?.grid ?? BaseScene.defaultGrid;
    const shape = data.shape ?? this.shape;
    const isEllipse = grid.isGridless && [CONST.TOKEN_SHAPES.ELLIPSE_1, CONST.TOKEN_SHAPES.ELLIPSE_2].includes(shape);
    const center = this.getCenterPoint(data);
    const points = [{x: center.x, y: center.y}];
    const size = this.getSize(data);
    const tx = size.width / 4;
    const ty = size.height / 4;
    for ( let dx = -1; dx <= 1; dx++ ) {
      for ( let dy = -1; dy <= 1; dy++ ) {
        if ( (dx === 0) && (dy === 0) ) continue;
        const s = isEllipse ? Math.hypot(dx, dy) : 1;
        points.push({
          x: center.x + (dx / s * tx),
          y: center.y + (dy / s * ty)
        });
      }
    }
    return points;
  }

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

  /**
   * Constrain the test points by walls and surfaces. The passed array of test points are modified in place.
   * If all points are discarded, the movement origin is added to the array of test points.
   * @param {(Point|ElevatedPoint)[]} points                     The test points, which are modified in place.
   * @param {(Partial<ElevatedPoint & TokenPosition>)} [data]    The position and dimensions. Defaults to the values of
   *                                                             the prepared document, not the document source.
   * @protected
   */
  _constrainTestPoints(points, data) {
    const scene = this.parent;
    const level = scene?.levels.get("level" in data ? data.level : this.level);
    if ( !level ) return;
    const origin = this.getMovementOrigin(data);
    if ( (points.length === 1) && (points[0].x === origin.x) && (points[0].y === origin.y)
      && ((points[0].elevation === undefined) || (points[0].elevation === origin.elevation)) ) return;
    if ( (origin.x < 0) || (origin.x > scene.dimensions.width)
      || (origin.y < 0) || (origin.y > scene.dimensions.height) ) {
      points.length = 0;
      points.push(origin);
      return;
    }
    const x = data.x ?? this.x;
    const y = data.y ?? this.y;
    const {width, height} = this.getSize(data);
    const boundingBox = new PIXI.Rectangle(x, y, width, height);
    const polygon = foundry.canvas.geometry.ClockwiseSweepPolygon.create(origin, {type: "move", level, boundingBox});
    const options = {type: "move", mode: "any", level};
    for ( let i = points.length - 1; i >= 0; i-- ) {
      const point = points[i];
      if ( polygon.contains(point.x, point.y) && ((point.elevation === undefined)
        || !level.parent.testSurfaceCollision(origin, point, options)) ) continue;
      points[i] = points[points.length - 1];
      points.length--;
    }

    // If all test points are behind a wall/surface, the origin becomes the single test point
    if ( !points.length ) points.push(origin);
  }

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

  /**
   * 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 {Partial<TokenProcessedMovementWaypoint>[]} 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 segments = [];
    const source = this._source;
    let {x=source.x, y=source.y, elevation=source.elevation, width: previousWidth=source.width,
      height: previousHeight=source.height, depth: previousDepth=source.depth,
      shape: previousShape=source.shape, level: previousLevel=source.level,
      action: previousAction=this.movementAction} = waypoints[0];
    let previousIncludedInLevel = region.includedInLevel(previousLevel);
    let from = {x, y, elevation};
    for ( let i = 1; i < waypoints.length; i++ ) {
      const {x: previousX, y: previousY, elevation: previousElevation} = from;
      let {x=previousX, y=previousY, elevation=previousElevation, width=previousWidth, height=previousHeight,
        depth=previousDepth, shape=previousShape, level=previousLevel, action=previousAction, terrain=null,
        snapped=false} = waypoints[i];
      x = Math.round(x);
      y = Math.round(y);
      const includedInLevel = region.includedInLevel(level);
      const {x: pivotX, y: pivotY} = this.getMovementOrigin({x: 0, y: 0, elevation, width, height, depth, shape});
      const to = {x, y, elevation, teleport: CONFIG.Token.movement.actions[action].teleport};

      if ( (width !== previousWidth) || (height !== previousHeight) || (shape !== previousShape) ) {
        const previousCenter = this.getMovementOrigin({x: previousX, y: previousY, elevation: previousElevation,
          width: previousWidth, height: previousHeight, shape: previousShape});
        from.x = Math.round(previousCenter.x - pivotX);
        from.y = Math.round(previousCenter.y - pivotY);
      }

      if ( previousIncludedInLevel && !((from.x === to.x) && (from.y === to.y) && (from.elevation === to.elevation)) ) {

        // We only consider the center point (movement origin) not TokenDocument#getContainmentTestPoints, because
        // we cannot handle wall and surface constraints along the segment
        // FIXME: This doesn't work correctly for nonzero depth tokens. Any token is treated as if it had zero depth.
        let firstWaypoint;
        let lastSegment;
        for ( const segment of region.segmentizeMovementPath([from, to], [{x: pivotX, y: pivotY}], 0.75) ) {
          delete segment.teleport;
          segment.action = action;
          segment.terrain = terrain ? terrain.clone() : null;
          segment.snapped = snapped;
          const {from, to} = segment;
          from.width = to.width = width;
          from.height = to.height = height;
          from.depth = to.depth = depth;
          from.shape = to.shape = shape;
          from.level = to.level = previousLevel;
          segments.push(segment);
          firstWaypoint ??= segment.from;
          lastSegment = segment;
        }

        // If the first waypoint matches the from-position, correct its size adjustment
        if ( firstWaypoint && (firstWaypoint.x === from.x) && (firstWaypoint.y === from.y)
          && (firstWaypoint.elevation === from.elevation) ) {
          firstWaypoint.x = previousX;
          firstWaypoint.y = previousY;
          firstWaypoint.elevation = previousElevation;
          firstWaypoint.width = previousWidth;
          firstWaypoint.height = previousHeight;
          firstWaypoint.depth = previousDepth;
        }

        // If the last waypoint matches the to-position, correct its level or add an EXIT segments if necessary
        if ( lastSegment && (lastSegment.to.x === to.x) && (lastSegment.to.y === to.y)
          && (lastSegment.to.elevation === to.elevation) ) {
          if ( (lastSegment.type === REGION_MOVEMENT_SEGMENTS.EXIT) || includedInLevel ) {
            lastSegment.to.level = level;
          } else {
            segments.push({
              type: REGION_MOVEMENT_SEGMENTS.EXIT,
              from: {x, y, elevation, width, height, depth, shape, level: previousLevel},
              to: {x, y, elevation, width, height, depth, shape, level},
              action,
              terrain: terrain ? terrain.clone() : null,
              snapped
            });
          }
        }
      }

      // Add an ENTER segment if we enter the region at this waypoint
      // Add an EXIT segment if we exit the region at this waypoint
      else if ( (previousIncludedInLevel !== includedInLevel)
        && region.testPoint({x: x + pivotX, y: y + pivotY, elevation}) ) {
        segments.push({
          type: includedInLevel ? REGION_MOVEMENT_SEGMENTS.ENTER : REGION_MOVEMENT_SEGMENTS.EXIT,
          from: {x, y, elevation, width, height, depth, shape, level: previousLevel},
          to: {x, y, elevation, width, height, depth, shape, level},
          action,
          terrain: terrain ? terrain.clone() : null,
          snapped
        });
      }

      from = to;
      previousWidth = width;
      previousHeight = height;
      previousDepth = depth;
      previousShape = shape;
      previousLevel = level;
      previousAction = action;
      previousIncludedInLevel = includedInLevel;
    }
    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.
    this.#relinkActorDelta();
    if ( parent !== this.delta ) this.delta?._handleDeltaCollectionUpdates(parent);
  }

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

  /** @inheritDoc */
  _preUpdateDescendantDocuments(parent, collection, changes, options, userId) {
    this.#relinkActorDelta();
    if ( parent !== this.delta ) this.delta?._handleDeltaCollectionUpdates(parent);
  }

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

  /** @inheritDoc */
  _preDeleteDescendantDocuments(parent, collection, ids, options, userId) {
    this.#relinkActorDelta();
    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<DatabaseWriteOperation>} [operation] The database operation that was performed
   * @protected
   */
  _onRelatedUpdate(update={}, operation={}) {

    // Determine whether Token-modifying ActiveEffects changed and render on the canvas if so.
    const priorOverrides = this._priorOverrides ?? this.overrides;
    const hadChanges = !isEmpty(priorOverrides);
    const hasChanges = foundry.utils.iterateValues(this.actor.tokenActiveEffectChanges).some(c => c.length);
    if ( hadChanges || hasChanges ) {
      if ( this.actorLink || (operation.documentName !== "ActorDelta") ) this.reset();
      this._renderActiveEffectChanges(priorOverrides);
    }
    else this._prepareBars(); // Otherwise done as part of the reset
    if ( !this.actorLink ) this._priorOverrides = this.overrides;

    // 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({redrawEffects: true});
      const name = `${this.object?.objectId}.animateBars`;
      const easing = foundry.canvas.animation.CanvasAnimation.easeInOutCosine;
      this.object?.animate({bar1: this.bar1, bar2: this.bar2}, {name, easing});
      for ( const app of foundry.applications.sheets.TokenConfig.instances() ) {
        app._preview?.updateSource({delta: this.toObject().delta}, {diff: false, recursive: false});
        app._preview?.object?.renderFlags.set({refreshBars: true, redrawEffects: true});
      }
    }
  }

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

  /**
   * Refresh this TokenDocument's overrides and transmit changes, if any, to its PlaceableObject for rendering.
   * @param {string} phase The application phase under which changes are to be applied
   */
  applyActiveEffects(phase) {
    if ( !this.actor ) return;
    const overrides = {};
    const replacementData = this._getReplacementData();
    const targetableKeys = TokenDocument.#ACTIVE_EFFECT_TARGETABLE_KEYS;
    const requiresUpdateKeys = ["width", "height", "depth", "shape"]; // Change keys requiring a Document update
    const changes = this.actor.tokenActiveEffectChanges[phase]?.toSorted((a, b) => a.priority - b.priority);
    if ( !changes ) return;
    for ( const change of changes ) {
      if ( (typeof change.key === "string") && !targetableKeys.some(k => change.key.startsWith(k)) ) continue;
      const modifyTarget = !requiresUpdateKeys.includes(change.key);
      const ActiveEffect = foundry.documents.ActiveEffect.implementation;
      const result = ActiveEffect.applyChange(this, change, {replacementData, modifyTarget});
      if ( foundry.utils.isPlainObject(result) ) {
        mergeObject(overrides, result);
        if ( (change.key === "sight.visionMode") && (change.type === "override") ) {
          const sightData = this._inflateVisionModeChange(change.value);
          Object.assign(overrides.sight, sightData);
          Object.assign(this.sight, sightData);
        }
      }
    }
    const prunedOverrides = deepClone(overrides, {prune: true});
    mergeObject(this.overrides, prunedOverrides);
  }

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

  /**
   * Acquire the defaults of a vision mode set by an Active Effect change and create additional overrides from them.
   * @param {string} modeId
   * @returns {Pick<TokenSightData, "color"|"attenuation"|"brightness"|"saturation"|"contrast">|Record<string, never>}
   * @private
   */
  _inflateVisionModeChange(modeId) {
    if ( this.sight.visionMode !== modeId ) return {}; // The change was not applied
    const mode = CONFIG.Canvas.visionModes[modeId];
    return foundry.utils.iterateEntries(mode?.vision.defaults ?? {}).reduce((sight, [key, value]) => {
      if ( value === undefined ) {
        value = this._source.sight[key];
        if ( (key === "color") && value ) value = Color.from(value);
      }
      sight[key] = value;
      return sight;
    }, {});
  }

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

  /**
   * Send emulated update data to the Token PlaceableObject
   * @param {object} priorOverrides Overrides prior to data reinitialization
   * @protected
   */
  _renderActiveEffectChanges(priorOverrides) {
    if ( equals(priorOverrides, this.overrides) ) return;
    if ( canvas.ready && (canvas.scene === this.scene) ) {
      const {width: _w, height: _h, depth: _d, shape: _s, ...changes} = mergeObject(
        mergeObject(priorOverrides, this, {insertKeys: false, insertValues: false}),
        this.overrides
      );
      this.object?._onUpdate(changes, {}, game.user.id);
    }

    // Hand off size changes to a secondary handler requiring downstream implementation.
    const {width, height, depth, shape} = this.overrides;
    const sizeChanges = deepClone(diffObject(this._source, {width, height, depth, shape}), {prune: true});
    if ( !isEmpty(sizeChanges) ) this._onOverrideSize(sizeChanges);
  }

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

  /**
   * Callback invoked when {@link _onRelatedUpdate} detects overrides of at least one Token dimension. Enacting such
   * changes requires a server update and may involve nuances particular to a given system. While this method is async,
   * it is not awaited by the caller.
   * @param {Partial<TokenDimensions>} changes
   * @protected
   */
  async _onOverrideSize(changes) {}

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

  /**
   * Get replacement data for ActiveEffect change application to this Token.
   * @returns {object}
   * @protected
   */
  _getReplacementData() {
    return this.actor?.getRollData() ?? {};
  }

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

  /**
   * 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 ( Array.isArray(data) || foundry.utils.isPlainObject(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("."))
    };
  }

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

  /**
   * Handle re-linking the ActorDelta source once a change has been made for the first time.
   */
  #relinkActorDelta() {
    if ( !this._source.delta && !this.actorLink ) this._source.delta = this.delta._source;
  }

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

  /**
   * 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 ( isEmpty(CONFIG.Actor.trackableAttributes) ) return;

    // If the system defines trackableAttributes per type
    let config = deepClone(CONFIG.Actor.trackableAttributes[type]);

    // Otherwise union all configured trackable attributes
    if ( 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 = _loc("TOKEN.BarAttributes");
    const valueGroup = _loc("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);
  }
}

/**
 * @import {PointEffectSource} from "@client/canvas/sources/_module.mjs";
 * @import {PlaceableObject} from "@client/canvas/placeables/_module.mjs";
 * @import {Point} from "@common/_types.mjs";
 * @import {EdgeRestrictionType, EdgeSenseType, EdgeDirection, EdgeDirectionMode} from "@common/constants.mjs";
 * @import PolygonVertex from "./edges/vertex.mjs";
 * @import Level from "@client/documents/level.mjs";
 * @import Document from "@common/abstract/document.mjs";
 */


/**
 * @typedef ClipperPoint
 * @property {number} X
 * @property {number} Y
 */

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

/**
 * @typedef {"wall"|"source"|"innerBounds"|"outerBounds"} EdgeType
 */

/**
 * @typedef EdgeThresholdData
 * @property {number} [light]        Minimum distance in pixels from a light source for which this edge blocks light
 * @property {number} [darkness]     Minimum distance in pixels from a light source for which this edge blocks darkness
 * @property {number} [sight]        Minimum distance in pixels from a vision source for which this edge blocks vision
 * @property {number} [sound]        Minimum distance in pixels from a sound source for which this edge blocks sound
 * @property {boolean} [attenuation=true] Whether to attenuate the source radius when passing through the edge
 */

/**
 * @typedef EdgeCreationOptions             Additional options which describe the edge
 * @property {string} [id]                   A string used to uniquely identify this edge
 * @property {Document|PlaceableObject} [object]  A Document/PlaceableObject that is responsible for this edge, if any
 * @property {EdgeType} [type]               The type of edge
 * @property {EdgeSenseType} [light]         How this edge restricts light
 * @property {EdgeSenseType} [darkness]      How this edge restricts darkness
 * @property {EdgeSenseType} [move]          How this edge restricts movement
 * @property {EdgeSenseType} [sight]         How this edge restricts sight
 * @property {EdgeSenseType} [sound]         How this edge restricts sound
 * @property {EdgeDirection} [direction=0]   A direction of effect for the edge
 * @property {EdgeThresholdData|null} [threshold] Configuration of threshold data for this edge
 * @property {number} [priority=0]           A source priority for this edge. Default: `0`.
 */

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

// TODO: "universal" type will be deprecated in v14
/**
 * @typedef {EdgeRestrictionType|"universal"} PointSourcePolygonType
 */

/**
 * @typedef PointSourcePolygonConfig
 * @property {PointSourcePolygonType} type  The type of polygon being computed
 * @property {Level} [level]        The Level the polygon is computed in. Defaults to the viewed Level.
 * @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} [externalRadius=0]  The external radius
 * @property {number} [rotation]    The direction of facing, required if the angle is limited
 * @property {ElevatedSurfaceExposureOptions} [surfaceExposure]  Additional options passed through to surface
 *                                                               exposure generator
 * @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 ElevatedSurfaceExposureOptions
 * @property {number} [threshold]  Points with at most this distance (grid units) from the surface are exposed.
 *                                 Default: `0`.
 */

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

/**
 * @typedef {{mode: 0|1|2, priority: number}} ClockwiseSweepEdgeConfig
 *   Modes:
 *    - Never (`0`): The edges of this type are never included.
 *    - Maybe (`1`): The edges of this type are tested for inclusion.
 *    - Always (`2`): The edges of this type are always included.
 */

/**
 * @typedef _ClockwiseSweepPolygonConfig
 * @property {EdgeDirectionMode} [edgeDirectionMode] Customize how edge direction of one-way edges is applied
 * @property {number} [priority=0]    Edges with priority less than this priority are ignored
 * @property {Partial<Record<EdgeType, boolean|Partial<ClockwiseSweepEdgeConfig>>>} [edgeTypes]
 *   Edge types configured as `false` is equivalent to those edges never being included.
 *   Edge types configured as `true` are included conditionally depending on the type of polygon and the type of edge.
 */

/**
 * @typedef {PointSourcePolygonConfig & _ClockwiseSweepPolygonConfig} ClockwiseSweepPolygonConfig
 */

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

/**
 * @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$i = /*#__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;
}

/**
 * @import {LineIntersection} from "@common/utils/_types.mjs";
 * @import {EdgeSenseType} from "@common/constants.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 {EdgeSenseType}
   */
  restriction = CONST.EDGE_SENSE_TYPES.NONE;

  /**
   * 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.EDGE_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, 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);
  }
}

var _module$10 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CanvasEdges: CanvasEdges,
  CollisionResult: CollisionResult,
  Edge: Edge,
  PolygonVertex: PolygonVertex
});

/**
 * 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++;
    }
  }
}

/**
 * @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]});
  }
}

/**
 * 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 {ClipperPoint} from "@client/_types.mjs";
 */

/**
 * A helper class used to construct triangulated polygon meshes
 * Allow to add padding and a specific depth value.
 */
class PolygonMesher {
  /**
   * @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, ...
   */
  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]                   The number of dimensions.
   * @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 {ClipperPoint[]} path         The path to convert
   * @param {number} depth                The depth to add to the flattened vertices
   * @returns {number[]}                  The 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;

      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) {
    // This 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 {DeepReadonly, Point, ClipperPoint} from "@client/_types.mjs";
 */

/**
 * The node of a {@link foundry.data.PolygonTree}.
 */
class PolygonTreeNode {

  /**
   * Create a PolygonTreeNode.
   * @param {PolygonTreeNode|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 {ClipperPoint[]} clipperPath The clipper path of this node.
   * @param {PolygonTreeNode|null} parent    The parent node or `null` if root.
   * @internal
   */
  static _fromClipperPath(clipperPath, parent) {
    const node = new PolygonTreeNode(parent);
    if ( parent ) node.#clipperPath = clipperPath;
    return node;
  }

  /* -------------------------------------------- */

  /**
   * The parent of this node or `null` if this is the root node.
   * @type {PolygonTreeNode|null}
   */
  get parent() {
    return this.#parent;
  }

  #parent;

  /* -------------------------------------------- */

  /**
   * The children of this node.
   *
   * The value of this property must not be mutated.
   * @type {ReadonlyArray<PolygonTreeNode>}
   */
  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;

  /* -------------------------------------------- */

  /**
   * Is the (sub)tree empty?
   * @type {boolean}
   */
  get isEmpty() {
    return !this.#children.length;
  }

  /* -------------------------------------------- */

  /**
   * The Clipper path of this node.
   * It is empty in case of the root node.
   *
   * The value of this property must not be mutated.
   * @type {DeepReadonly<ClipperPoint[]>|null}
   */
  get clipperPath() {
    return this.#clipperPath;
  }

  #clipperPath;

  /* -------------------------------------------- */

  /**
   * The polygon of this node.
   * It is `null` in case of the root node.
   *
   * The value of this property must not be mutated.
   * @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.
   *
   * The value of this property must not be mutated.
   * @type {ReadonlyArray<number>|null}
   */
  get points() {
    const polygon = this.polygon;
    if ( !polygon ) return null;
    return polygon.points;
  }

  /* -------------------------------------------- */

  /**
   * The path of the polygon ([{x: x0, y: y0}, {x: x1, y: y1}, ...]).
   * They are `null` in case of the root node.
   *
   * The value of this property must not be mutated.
   * @type {ReadonlyArray<Point>|null}
   */
  get path() {
    let path = this.#path;
    if ( path === undefined ) path = this.#path = this.#createPath();
    return path;
  }

  #path;

  /* -------------------------------------------- */

  /**
   * The bounds of the polygon, or the combined bounds of all children in case of the root node.
   *
   * The value of this property must not be mutated.
   * @type {PIXI.Rectangle}
   */
  get bounds() {
    let bounds = this.#bounds;
    if ( bounds === undefined ) {
      if ( this.polygon ) bounds = this.polygon.getBounds();
      else {
        const nodes = this.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 area of this node.
   * @type {number}
   */
  get area() {
    if ( this.#area !== undefined ) return this.#area;
    this.#area = this.polygon ? Math.abs(this.polygon.signedArea()) : 0;
    const children = this.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      this.#area -= children[i].area;
    }
    this.#area = Math.abs(this.#area);
    return this.#area;
  }

  #area;

  /* -------------------------------------------- */

  /**
   * Iterate over recursively over the children in depth-first order.
   * @returns {Generator<PolygonTreeNode>}
   * @yields {PolygonTreeNode}
   */
  *[Symbol.iterator]() {
    for ( const child of this.children ) {
      yield child;
      yield *child;
    }
  }

  /* -------------------------------------------- */

  /**
   * Find the node in this (sub)tree that contains the given point.
   * @param {Point} point              The point.
   * @returns {PolygonTreeNode|null}   The (sub)node that contains the point, if any.
   *                                   Cannot return null for {@link PolygonTree}; instead
   *                                   the root node (the tree itself) is returned.
   */
  findContainingNode(point) {
    const {x, y} = point;
    if ( this.parent && (!this.bounds.contains(x, y) || !this.polygon.contains(x, y)) ) return null;
    const children = this.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      const result = children[i].findContainingNode(point);
      if ( result ) return result;
    }
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Test whether given point is contained within this (sub)tree.
   * If `distance` is is nonzero, true is returned if and only if the signed distance from the point to the boundary of
   * the (sub)tree is less than or equal to `distance`. The signed distance is positive for points in the exterior of
   * the (sub)tree and negative for points within the interior of the (sub)tree.
   * @param {Point} point           The point.
   * @param {number} [distance=0]   The tolerance of the containment test.
   * @returns {boolean}
   * @example Test whether the point (x, y) is contained within the polygon tree
   * ```js
   * polygonTree.testPoint({x, y});
   * ```
   * @example Test whether the circle at (x, y) with radius r (positive) intersects the polygon tree
   * ```js
   * polygonTree.testPoint({x, y}, r);
   * ```
   * @example Test whether the circle at (x, y) with radius r (positive) is contained within the polygon tree
   * ```js
   * polygonTree.testPoint({x, y}, -r);
   * ```
   */
  testPoint(point, distance=0) {
    const negative = distance < 0;
    let node = this.findContainingNode(point);
    let outside;

    // If containing node is not a hole, the point is contained
    if ( node ) {
      if ( node.isHole === negative ) return !negative;
      outside = false;
    } else {
      if ( negative ) return false;
      node = this;
      outside = true;
    }

    // Without tolerance circle intersection test are not necessary
    if ( distance === 0 ) return false;
    distance = Math.abs(distance);

    // If there's a containing (or root) node not is not a hole, ...
    if ( node.parent && !(outside && node.isHole) ) {

      // ... test whether the circle intersects any edge of the polygon of this node
      const intersects = foundry.utils.pathCircleIntersects(node.points, true, point, distance);
      if ( intersects ) return !negative;
      if ( outside ) return false;
    }

    // Test whether any of the children's paths of the containing node intersect
    const {x, y} = point;
    const children = node.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      const child = children[i];

      // Test whether the circle intersects the bounds of this child node
      const {left, right, top, bottom} = child.bounds;
      if ( (x < left - distance) || (x > right + distance) || (y < top - distance) || (y > bottom + distance) ) {
        continue;
      }

      // Test whether the circle intersects any edge of the polygon of this child node
      const intersects = foundry.utils.pathCircleIntersects(child.points, true, point, distance);
      if ( intersects ) return !negative;
    }
    return negative;
  }

  /* -------------------------------------------- */

  /**
   * Test circle containment/intersection with this (sub)tree.
   * @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) {
    let node = this.findContainingNode(center);
    let outside;

    // If containing node is not a hole, the point is contained
    if ( node ) outside = false;
    else {
      node = this;
      outside = true;
    }

    // If there's a containing (or root) node not is not a hole, ...
    if ( node.parent && !(outside && node.isHole) ) {

      // ... test whether the circle intersects any edge of the polygon of this node
      const intersects = foundry.utils.pathCircleIntersects(node.points, true, center, radius);
      if ( intersects ) return 0;
      if ( outside ) return -1;
    }

    // Test whether any of the children's paths of the containing node intersect
    const {x, y} = center;
    const children = node.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      const child = children[i];

      // Test whether the circle intersects the bounds of this child node
      const {left, right, top, bottom} = child.bounds;
      if ( (x < left - radius) || (x > right + radius) || (y < top - radius) || (y > bottom + radius) ) continue;

      // Test whether the circle intersects any edge of the polygon of this child node
      const intersects = foundry.utils.pathCircleIntersects(child.points, true, center, radius);
      if ( intersects ) return 0;
    }
    return node.isHole ? -1 : 1;
  }

  /* -------------------------------------------- */

  /**
   * Find a point inside this polygon tree that is closest to the given reference point.
   * @param {Point} point    The reference point.
   * @returns {Point}        The closest point to the reference point in the polygon tree.
   */
  findClosestPoint(point) {
    let node = this.findContainingNode(point);
    let outside;
    if ( node ) {
      if ( !node.isHole ) return {x: point.x, y: point.y};
      outside = false;
    } else {
      node = this;
      outside = true;
    }
    let distance = Infinity;
    let closest;
    if ( node.parent && !(outside && node.isHole) ) {
      closest = foundry.utils.closestPointToPath(point, node.points, true);
      const dx = closest.x - point.x;
      const dy = closest.y - point.y;
      distance = (dx * dx) + (dy * dy);
      if ( outside ) return closest;
    }
    const children = node.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      const result = foundry.utils.closestPointToPath(point, children[i].points, true);
      const dx = result.x - point.x;
      const dy = result.y - point.y;
      const d = (dx * dx) + (dy * dy);
      if ( d < distance ) {
        distance = d;
        closest = result;
      }
    }
    return closest;
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * Create the path of this node.
   * @returns {Point[]|null}
   */
  #createPath() {
    if ( !this.parent ) return null;
    const path = [];
    const points = this.points;
    for ( let i = 0; i < points.length; i += 2 ) {
      const x = points[i];
      const y = points[i + 1];
      path.push({x, y});
    }
    return path;
  }
}

/* -------------------------------------------- */

/**
 * A polygon tree.
 */
class PolygonTree extends PolygonTreeNode {

  /**
   * Create a PolygonTree.
   */
  constructor() {
    super(null);
  }

  /* -------------------------------------------- */

  /**
   * Create the tree from a Clipper polygon tree.
   * @param {ClipperLib.PolyTree} clipperPolyTree
   * @returns {PolygonTree}
   */
  static fromClipperPolyTree(clipperPolyTree) {
    const visit = (clipperPolyNode, parent) => {
      const clipperPath = clipperPolyNode.Contour();
      const node = PolygonTreeNode._fromClipperPath(clipperPath, parent);
      clipperPolyNode.Childs().forEach(child => visit(child, node));
      return node;
    };
    const tree = new PolygonTree();
    clipperPolyTree.Childs().forEach(child => visit(child, tree));
    return tree;
  }

  /* -------------------------------------------- */

  /**
   * The polygons of this polygon tree.
   *
   * The value of this property must not be mutated.
   * @type {ReadonlyArray<PIXI.Polygon>}
   */
  get polygons() {
    return this.#polygons ??= Object.freeze(Array.from(this, node => node.polygon));
  }

  #polygons;

  /* -------------------------------------------- */

  /**
   * The Clipper paths of this polygon tree.
   *
   * The value of this property must not be mutated.
   * @type {DeepReadonly<ClipperPoint[][]>}
   */
  get clipperPaths() {
    return this.#clipperPaths ??= Object.freeze(Array.from(this, node => node.clipperPath));
  }

  #clipperPaths;

  /* -------------------------------------------- */

  /**
   * The triangulation of this polygon tree.
   *
   * The value of this property must not be mutated.
   * @type {Readonly<{vertices: Float32Array; indices: Uint16Array|Uint32Array}>}
   */
  get triangulation() {
    if ( this.#triangulation ) return this.#triangulation;
    let vertexIndex = 0;
    let vertexDataSize = 0;
    for ( const node of this ) vertexDataSize += node.points.length;
    const vertexData = new Float32Array(vertexDataSize);
    const indices = [];
    for ( const node of this ) {
      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);
    return this.#triangulation = {vertices: vertexData, indices: indexData};
  }

  #triangulation;

  /* -------------------------------------------- */

  /**
   * Draw the polygon tree into the Graphics element.
   * @param {PIXI.Graphics} graphics   The Graphics element.
   */
  drawShape(graphics) {
    for ( const node of this ) {
      if ( node.isHole ) continue;
      graphics.drawShape(node.polygon);
      graphics.beginHole();
      for ( const hole of node.children ) graphics.drawShape(hole.polygon);
      graphics.endHole();
    }
  }

  /* -------------------------------------------- */

  /**
   * Compute the intersection of the polygon tree with the given polygon.
   * @param {PIXI.Polygon} polygon       The polygon to intersect the polygon tree with.
   * @param {object} [options]           Additional options.
   * @param {number} [options.clipType]  The Clipper clip type. Default: `ClipperLib.ClipType.ctIntersection`.
   * @param {number} [options.fillType]  The Clipper fill type used for the polygon.
   *                                     Default: `ClipperLib.PolyFillType.pftEvenOdd`.
   * @returns {PolygonTree}              The result of the intersection.
   */
  intersectPolygon(polygon, options) {
    return this.intersectClipper(polygon.toClipperPoints({scalingFactor: CLIPPER_SCALING_FACTOR}), options);
  }

  /* -------------------------------------------- */

  /**
   * Compute the intersection of the polygon tree with the given Clipper path.
   * @param {ClipperPoint[]} path        The Clipper path to intersect the polygon tree with
   *                                     (the scaling factor must be {@link CONST.CLIPPER_SCALING_FACTOR}).
   * @param {object} [options]           Additional options.
   * @param {number} [options.clipType]  The Clipper clip type. Default: `ClipperLib.ClipType.ctIntersection`.
   * @param {number} [options.fillType]  The Clipper fill type used for the Clipper path.
   *                                     Default: `ClipperLib.PolyFillType.pftEvenOdd`.
   * @returns {PolygonTree}              The result of the intersection.
   */
  intersectClipper(path, {clipType=ClipperLib.ClipType.ctIntersection,
    fillType=ClipperLib.PolyFillType.pftEvenOdd}={}) {
    const clipper = new ClipperLib.Clipper();
    const solution = new ClipperLib.PolyTree();
    clipper.AddPaths(this.clipperPaths, ClipperLib.PolyType.ptSubject, true);
    clipper.AddPath(path, ClipperLib.PolyType.ptClip, true);
    clipper.Execute(clipType, solution, ClipperLib.PolyFillType.pftEvenOdd, fillType);
    return PolygonTree.fromClipperPolyTree(solution);
  }

  /* -------------------------------------------- */
  /*  Sampling API                                */
  /* -------------------------------------------- */

  /**
   * The compiled data used to sample points inside the polygon tree.
   * @type {{
   *   weights: Float64Array;
   *   totalWeight: number;
   *   vertices: Float32Array;
   *   indices: Uint16Array|Uint32Array;
   * }}
   */
  #interiorSamplingData;

  /**
   * The compiled data used to sample points on the polygon tree boundary.
   * @type {{
   *   weights: Float64Array;
   *   totalWeight: number;
   *   x: Float64Array;
   *   y: Float64Array;
   *   dx: Float64Array;
   *   dy: Float64Array;
   * }}
   */
  #boundarySamplingData;

  /* -------------------------------------------- */

  /**
   * Sample a point from the polygon tree interior.
   * @param {Point} [out]  A point to write to.
   * @returns {Point}      The sampled point.
   * @throws {Error}       If the polygon tree interior is empty.
   */
  sampleInterior(out) {
    const data = this.#interiorSamplingData ??= this.#createInteriorSamplingData();
    const {weights, totalWeight, vertices, indices} = data;
    if ( totalWeight <= 0 ) throw new Error("Cannot sample an empty PolygonTree.");

    const point = out ?? {x: 0, y: 0};
    const k = PolygonTree.#selectSamplingIndex(weights, totalWeight) * 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 r1 = Math.sqrt(Math.random());
    const r2 = Math.random();
    const s = r1 * (1 - r2);
    const t = r1 * r2;
    point.x = x0 + ((x1 - x0) * s) + ((x2 - x0) * t);
    point.y = y0 + ((y1 - y0) * s) + ((y2 - y0) * t);
    return point;
  }

  /* -------------------------------------------- */

  /**
   * Sample a point from the polygon tree boundary.
   * @param {Point} [out]  A point to write to.
   * @returns {Point}      The sampled point.
   * @throws {Error}       If the polygon tree boundary is empty.
   */
  sampleBoundary(out) {
    const data = this.#boundarySamplingData ??= this.#createBoundarySamplingData();
    const {weights, totalWeight, x, y, dx, dy} = data;
    if ( totalWeight <= 0 ) throw new Error("Cannot sample an empty PolygonTree.");

    const point = out ?? {x: 0, y: 0};
    const index = PolygonTree.#selectSamplingIndex(weights, totalWeight);
    const t = Math.random();
    point.x = x[index] + (dx[index] * t);
    point.y = y[index] + (dy[index] * t);
    return point;
  }

  /* -------------------------------------------- */

  /**
   * Create the compiled interior sampling data.
   * @returns {{
   *   weights: Float64Array;
   *   totalWeight: number;
   *   vertices: Float32Array;
   *   indices: Uint16Array|Uint32Array;
   * }}
   */
  #createInteriorSamplingData() {
    const {vertices, indices} = this.triangulation;
    const triangleCount = Math.floor(indices.length / 3);
    const weights = new Float64Array(triangleCount);
    let totalWeight = 0;
    for ( let i = 0, k = 0; i < triangleCount; i++, 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];
      totalWeight += Math.abs(((x1 - x0) * (y2 - y0)) - ((x2 - x0) * (y1 - y0))) / 2;
      weights[i] = totalWeight;
    }
    return {weights, totalWeight, vertices, indices};
  }

  /* -------------------------------------------- */

  /**
   * Create the compiled boundary sampling data.
   * @returns {{
   *   weights: Float64Array;
   *   totalWeight: number;
   *   x: Float64Array;
   *   y: Float64Array;
   *   dx: Float64Array;
   *   dy: Float64Array;
   * }}
   */
  #createBoundarySamplingData() {
    let segmentCount = 0;
    for ( const node of this ) segmentCount += (node.points.length / 2);

    const weights = new Float64Array(segmentCount);
    const x = new Float64Array(segmentCount);
    const y = new Float64Array(segmentCount);
    const dx = new Float64Array(segmentCount);
    const dy = new Float64Array(segmentCount);
    let totalWeight = 0;
    let j = 0;
    for ( const node of this ) {
      const points = node.points;
      let x0 = points[points.length - 2];
      let y0 = points[points.length - 1];
      for ( let i = 0; i < points.length; i += 2 ) {
        const x1 = points[i];
        const y1 = points[i + 1];
        const ddx = x1 - x0;
        const ddy = y1 - y0;
        x[j] = x0;
        y[j] = y0;
        dx[j] = ddx;
        dy[j] = ddy;
        totalWeight += Math.hypot(ddx, ddy);
        weights[j++] = totalWeight;
        x0 = x1;
        y0 = y1;
      }
    }
    return {weights, totalWeight, x, y, dx, dy};
  }

  /* -------------------------------------------- */

  /**
   * Pick a sampling element from a cumulative weight table.
   * @param {Float64Array} weights  The cumulative weights.
   * @param {number} totalWeight    The total cumulative weight.
   * @returns {number}              The selected element index.
   */
  static #selectSamplingIndex(weights, totalWeight) {
    const target = Math.random() * totalWeight;
    let lo = 0;
    let hi = weights.length - 1;
    while ( lo < hi ) {
      const mid = (lo + hi) >>> 1;
      if ( target < weights[mid] ) hi = mid;
      else lo = mid + 1;
    }
    return lo;
  }
}

/**
 * @import {ClipperPoint} from "@client/_types.mjs";
 * @import {ElevatedSurfaceExposureOptions} from "../_types.mjs";
 * @import PointSourcePolygon from "./source-polygon.mjs";
 */

/**
 * This class computes the elevated surface exposure polygon tree.
 */
class ElevatedSurfaceExposureGenerator {
  /**
   * @param {PointSourcePolygon} polygon                  The source polygon the exposure is computed for
   * @param {ElevatedSurfaceExposureOptions} [options]    The surface exposure options
   */
  constructor(polygon, options={}) {
    this.#polygon = polygon;
    this.#threshold = options.threshold ?? 0;
  }

  /* -------------------------------------------- */

  /**
   * Compute the elevated surface exposure for the given source polygon using the
   * {@link ElevatedSurfaceExposureGenerator}.
   * @param {PointSourcePolygon} polygon                  The source polygon the exposure is computed for
   * @param {ElevatedSurfaceExposureOptions} [options]    The surface exposure options
   * @returns {PolygonTree|null}                          The computed elevated surface exposure or null if empty
   */
  static compute(polygon, options) {
    return new this(polygon, options).compute();
  }

  /* -------------------------------------------- */

  /**
   * The Clipper instance.
   * @type {ClipperLib.Clipper}
   */
  #clipper = new ClipperLib.Clipper();

  /* -------------------------------------------- */

  /**
   * The source polygon the exposure is computed for.
   * @type {PointSourcePolygon}
   */
  get polygon() {
    return this.#polygon;
  }

  #polygon;

  /* -------------------------------------------- */

  /**
   * The clipper path of the polygon offset by 0.5 pixels.
   * @type {ClipperPoint[]|undefined}
   */
  #polygonPath;

  /* -------------------------------------------- */

  /**
   * The polygon bounds padded by 0.5 pixels.
   * @type {PIXI.Bounds|undefined}
   */
  #polygonBounds;

  /* -------------------------------------------- */

  /**
   * Points with at most this distance (grid units) from the surface are exposed.
   * @type {number}
   */
  get threshold() {
    return this.#threshold;
  }

  #threshold;

  /* -------------------------------------------- */

  /**
   * The result of the computation, which is null if the surface exposure is empty.
   * @type {PolygonTree|null}
   * @throws {Error} If {@link ElevatedSurfaceExposureGenerator#compute} wasn't called yet.
   */
  get result() {
    if ( this.#result === undefined ) throw new Error("Not computed yet");
    return this.#result;
  }

  #result;

  /* -------------------------------------------- */

  /**
   * Compute the surface exposure.
   * @returns {PolygonTree|null}    The computed elevated surface exposure or null if empty
   */
  compute() {
    if ( !this.#polygon.points.length || !(this.#threshold > 0) ) return this.#result = null;

    // Compute all visible surface areas
    const elevationThreshold = this.#polygon.level.elevation.base;
    const type = this.#polygon.config.type;
    const visibleAreas = [];
    const relevantSurfaces = this.#polygon.scene.getSurfaces({type, level: this.#polygon.level.id, exposure: true});
    const occludedSurfaces = this.#polygon.scene.isView ? canvas.masks.occlusion.occludedSurfaces : null;
    for ( let i = relevantSurfaces.length - 1; i >= 0; i-- ) {
      const surface = relevantSurfaces[i];

      // We only consider surfaces that are above the elevation of this polygon's origin
      if ( surface.elevation <= elevationThreshold ) break;

      // If the surface is at infinity elevation, it cannot be exposed
      if ( surface.elevation === Infinity ) continue;

      // If we didn't skip occluded surfaces, tokens could see where they shouldn't if they are underneath the surface
      // because of an light source outside of the building
      const occluded = occludedSurfaces?.has(surface);
      if ( occluded ) continue;

      // We unionize the exposure of all surfaces ignoring that a surface could be shadowed by another surface.
      // This may lead to incorrect surface exposure, but usually doesn't because surfaces are expected to have walls,
      // which already block line of sight to a surface that would be shadowed by the surface the walls are under.
      const visibleArea = this.#computeVisibleFacingSurfaceArea(surface.region.polygonTree, surface.elevation);
      if ( visibleArea.length ) visibleAreas.push(visibleArea);
    }

    // We don't need those anymore at this point. Clear for next compute call because the polygon could change.
    this.#polygonPath = undefined;
    this.#polygonBounds = undefined;

    return this.#computeSurfaceExpose(visibleAreas);
  }

  /* -------------------------------------------- */

  /**
   * Compute the union of the visibility shapes of all applicable surfaces.
   * If this polygon is constrained, constrain the surface exposure by the same shapes so that no more is exposed
   * than what would be visible without any surfaces or walls.
   * @param {ClipperPoint[][]} visibleAreas   The visible facing surface areas
   * @returns {PolygonTree|null}              The computed elevated surface exposure or null if empty
   */
  #computeSurfaceExpose(visibleAreas) {
    if ( !visibleAreas.length ) return this.#result = null;
    for ( const visibleArea of visibleAreas ) this.#clipper.AddPaths(visibleArea, ClipperLib.PolyType.ptSubject, true);
    const constrainedVisibleArea = new ClipperLib.PolyTree();
    const {boundaryShapes} = this.#polygon.config;
    if ( boundaryShapes.length ) {
      for ( let [i, boundaryShape] of boundaryShapes.entries() ) {
        if ( i > 0 ) {
          const intermediate = [];
          this.#clipper.Execute(ClipperLib.ClipType.ctIntersection, intermediate, ClipperLib.PolyFillType.pftPositive,
            ClipperLib.PolyFillType.pftEvenOdd);
          this.#clipper.Clear();
          this.#clipper.AddPaths(intermediate, ClipperLib.PolyType.ptSubject, true);
        }
        if ( !(boundaryShape instanceof PIXI.Polygon) ) {
          boundaryShape = boundaryShape.toPolygon({density: this.#polygon.config.density});
        }
        const boundaryShapePath = boundaryShape.toClipperPoints({scalingFactor: CLIPPER_SCALING_FACTOR});
        this.#clipper.AddPath(boundaryShapePath, ClipperLib.PolyType.ptClip, true);
      }
      this.#clipper.Execute(ClipperLib.ClipType.ctIntersection, constrainedVisibleArea,
        ClipperLib.PolyFillType.pftPositive, ClipperLib.PolyFillType.pftEvenOdd);
    } else {
      this.#clipper.Execute(ClipperLib.ClipType.ctUnion, constrainedVisibleArea, ClipperLib.PolyFillType.pftPositive,
        ClipperLib.PolyFillType.pftEvenOdd);
    }
    this.#clipper.Clear();
    if ( !constrainedVisibleArea.ChildCount() ) return this.#result = null;
    return this.#result = PolygonTree.fromClipperPolyTree(constrainedVisibleArea);
  }

  /* -------------------------------------------- */

  /**
   * Compute the area of the surface that is visible.
   *
   * We convert each visible boundary path to a shape that covers the area of the surface where the distance
   * between a ray going through the boundary path from the origin has a distance equal to or less than `expose`
   * in z-direction
   * ```
   *      / ray through boundary
   *     /| z-distance to surface
   *    /_|_______ surface
   *   / ^ visible boundary shape
   *  /
   * origin
   * ```
   * @param {PolygonTree} polygonTree  The polygon tree of the surface
   * @param {number} elevation         The elevation of the surface
   * @returns {ClipperPoint[][]}       The visible surface boundary shapes
   */
  #computeVisibleFacingSurfaceArea(polygonTree, elevation) {
    const visibleBoundary = this.#computeVisibleSurfaceBoundary(polygonTree);
    if ( !visibleBoundary.length ) return [];
    const origin = this.#polygon.origin;
    const X = Math.round(origin.x * CLIPPER_SCALING_FACTOR);
    const Y = Math.round(origin.y * CLIPPER_SCALING_FACTOR);
    const d = this.#polygon.level.parent.dimensions;
    const r = ClipperLib.ClipperBase.hiRange;
    const factor = Math.min(1 + (this.#threshold / Math.max(elevation - origin.elevation, 0)),
      (r / (Math.max(d.width, d.height) * CLIPPER_SCALING_FACTOR)) - 1);
    for ( const path of visibleBoundary ) {
      for ( let i = path.length - 1; i >= 0; i-- ) {
        const p = path[i];
        path.push({
          X: Math.clamp(Math.round(X + ((p.X - X) * factor)), -r, r),
          Y: Math.clamp(Math.round(Y + ((p.Y - Y) * factor)), -r, r)
        });
      }
      if ( !ClipperLib.Clipper.Orientation(path) ) path.reverse();
      this.#clipper.AddPath(path, ClipperLib.PolyType.ptSubject, true);
    }
    this.#clipper.AddPaths(polygonTree.clipperPaths, ClipperLib.PolyType.ptClip, true);
    const visibleArea = [];
    this.#clipper.Execute(ClipperLib.ClipType.ctIntersection, visibleArea, ClipperLib.PolyFillType.pftPositive,
      ClipperLib.PolyFillType.pftEvenOdd);
    this.#clipper.Clear();
    if ( this.#polygon.scene.isView ) {
      let hasOccludedArea = false;
      for ( const surface of canvas.masks.occlusion.occludedSurfaces ) {
        if ( surface.elevation > elevation ) continue;
        if ( !surface.region.bounds.intersects(polygonTree.bounds) ) continue;
        if ( surface.region.polygonTree === polygonTree ) continue;
        this.#clipper.AddPaths(surface.region.clipperPaths, ClipperLib.PolyType.ptClip, true);
        hasOccludedArea = true;
      }
      if ( hasOccludedArea ) {
        this.#clipper.AddPaths(visibleArea, ClipperLib.PolyType.ptSubject, true);
        this.#clipper.Execute(ClipperLib.ClipType.ctDifference, visibleArea, ClipperLib.PolyFillType.pftPositive,
          ClipperLib.PolyFillType.pftPositive);
        this.#clipper.Clear();
      }
    }
    return visibleArea;
  }

  /* -------------------------------------------- */

  /**
   * Compute the boundary of the surface that is visible.
   *
   * We intersect the boundary paths that are facing the origin with the LOS polygon to compute
   * the parts of the boundary paths that are visible from the origin
   * @param {PolygonTree} polygonTree  The polygon tree of the surface
   * @returns {ClipperPoint[][]}       The visible surface boundary shapes
   */
  #computeVisibleSurfaceBoundary(polygonTree) {
    const facingBoundary = this.#computeFacingSurfaceBoundary(polygonTree);
    if ( !facingBoundary.length ) return [];
    this.#clipper.AddPaths(facingBoundary, ClipperLib.PolyType.ptSubject, false);
    this.#clipper.AddPath(this.#getPolygonPath(), ClipperLib.PolyType.ptClip, true);
    const visibleBoundary = new ClipperLib.PolyTree();
    this.#clipper.Execute(ClipperLib.ClipType.ctIntersection, visibleBoundary, ClipperLib.PolyFillType.pftPositive,
      ClipperLib.PolyFillType.pftEvenOdd);
    this.#clipper.Clear();
    return ClipperLib.Clipper.OpenPathsFromPolyTree(visibleBoundary);
  }

  /* -------------------------------------------- */

  /**
   * Compute the boundary of the surface that is facing the origin.
   * @param {PolygonTree} polygonTree  The polygon tree of the surface
   * @returns {ClipperPoint[][]}       The visible surface boundary shapes
   */
  #computeFacingSurfaceBoundary(polygonTree) {
    const {origin} = this.#polygon;
    const bounds = this.#getPolygonBounds();
    const paths = [];

    // Iterate all nodes of the polygon tree to find the boundary path that face the origin
    for ( const node of polygonTree ) {
      if ( !node.bounds.intersects(bounds) ) continue;
      const n = node.path.length;
      let i0 = n - 1;
      let i1 = 0;

      // Find the first edge that is not facing the origin
      while ( i1 < n ) {
        const p0 = node.path[i0];
        const p1 = node.path[i1];
        const facing = orient2dFast(p0, p1, origin) > 0;
        if ( !facing ) break;
        i0 = i1;
        i1++;
      }

      // If all edges are facing the origin, return the full closed path
      if ( i1 === n ) {
        paths.push([...node.clipperPath, node.clipperPath[0]]);
        continue;
      }

      // Otherwise find all subpaths that are facing the origin
      i0 = i1;
      i1 = (i1 + 1) % n;
      const i2 = i0;
      let current = [];
      while ( i1 !== i2 ) {
        const p0 = node.path[i0];
        const p1 = node.path[i1];
        const facing = orient2dFast(p0, p1, origin) > 0;
        if ( facing ) {
          if ( !current.length ) current.push(node.clipperPath[i0]);
          current.push(node.clipperPath[i1]);
        } else if ( current.length ) {
          paths.push(current);
          current = [];
        }
        i0 = i1;
        i1 = (i1 + 1) % n;
      }
      if ( current.length ) paths.push(current);
    }
    return paths;
  }

  /* -------------------------------------------- */

  /**
   * Get the clipper path of the polygon offset by 0.5 pixels.
   * @returns {ClipperPoint[]}
   */
  #getPolygonPath() {

    // Create the clipper path of the polygon offset by 0.5 pixels, because we need it to be
    // a bit bigger to make sure almost collinear surface boundaries are not discarded
    if ( !this.#polygonPath ) {
      const polygonPath = this.#polygon.toClipperPoints({scalingFactor: CLIPPER_SCALING_FACTOR});
      const offsetter = new ClipperLib.ClipperOffset();
      offsetter.AddPath(polygonPath, ClipperLib.JoinType.jtMiter, ClipperLib.EndType.etClosedPolygon);
      const solution = [];
      offsetter.Execute(solution, CLIPPER_SCALING_FACTOR / 2);
      this.#polygonPath = solution.at(0) ?? [];
    }

    return this.#polygonPath;
  }

  /* -------------------------------------------- */

  /**
   * Get the bounds of the polygon padded by 0.5 pixels.
   * @returns {PIXI.Rectangle}
   */
  #getPolygonBounds() {
    return this.#polygonBounds ??= this.#polygon.bounds.clone().pad(0.5);
  }
}

/**
 * @import {Point, ElevatedPoint, ClipperPoint} from "@client/_types.mjs";
 * @import {PointSourcePolygonType, PointSourcePolygonConfig} from "../_types.mjs";
 * @import Scene from "@client/documents/scene.mjs";
 * @import Level from "@client/documents/level.mjs";
 */

/**
 * An extension of Polygon which is used to represent the line of sight for a point source.
 * @template {PointSourcePolygonConfig} [PolygonConfig=PointSourcePolygonConfig]
 */
class PointSourcePolygon extends PIXI.Polygon {

  /**
   * 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 = {};

  /**
   * The area of surfaces that is visible to this polygon, if any.
   * @type {PolygonTree|null}
   */
  surfaceExposure;

  /* -------------------------------------------- */

  /**
   * The level the polygon is computed it.
   * @type {Level}
   */
  get level() {
    return this.config.level;
  }

  /* -------------------------------------------- */

  /**
   * The level the polygon is computed it.
   * @type {Scene}
   */
  get scene() {
    return this.level.parent;
  }

  /* -------------------------------------------- */

  /**
   * 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 polygon = new this();
    polygon.initialize(origin, config);
    polygon.compute();
    this.applyThresholdAttenuation(polygon);
    polygon.surfaceExposure = ElevatedSurfaceExposureGenerator.compute(polygon, polygon.config.surfaceExposure);
    return polygon;
  }

  /* -------------------------------------------- */

  /**
   * 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();
    poly.surfaceExposure = this.surfaceExposure;
    return poly;
  }

  /* -------------------------------------------- */
  /*  Polygon Computation                         */
  /* -------------------------------------------- */

  /**
   * Compute the polygon using the origin and configuration options.
   * @returns {PointSourcePolygon}    The computed polygon
   */
  compute() {
    const t0 = performance.now();
    const {x, y} = this.origin;
    const {angle, debug, radius, externalRadius, level} = this.config;
    const {width, height} = level.parent.dimensions;

    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) && (externalRadius === 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) {

    // Configure level
    const cfg = this.config = config;
    cfg.level ??= canvas.level;

    // Polygon origin
    this.origin = {
      x: Math.round(origin.x),
      y: Math.round(origin.y),
      elevation: origin.elevation ?? config.source?.elevation ?? cfg.level.elevation.base
    };

    // Configure radius
    const maxR = cfg.level.parent.dimensions.maxR;
    cfg.radius = Math.min(cfg.radius ?? maxR, maxR);
    cfg.hasLimitedRadius = (cfg.radius > 0) && (cfg.radius < maxR);
    cfg.externalRadius = Math.min(cfg.externalRadius ?? 0, cfg.radius);
    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;

    // Configure surface expose
    cfg.surfaceExposure ??= {};

    // 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;
        poly.surfaceExposure = null;
        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();
    if ( poly.surfaceExposure ) {
      poly.surfaceExposure = poly.surfaceExposure.intersectPolygon(constraint instanceof PIXI.Polygon ? constraint
        : constraint.toPolygon({density: this.config.density}));
      if ( poly.surfaceExposure.isEmpty ) poly.surfaceExposure = null;
    }
    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"
   * @param {number} [config.tMin]                  Intersections of the ray and an edge with t-value at most
   *                                                `tMin` are not considered collisions. Default: `0`.
   * @param {number} [config.tMax]                  Intersections of the ray and an edge with t-value greater than
   *                                                `tMax` are not considered collisions. Default: `1`.
   * @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", tMin=0, tMax=1, ...config}={}) {
    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 dx = x1 - x0;
    const dy = y1 - y0;
    const ray = new Ray({x: x0, y: y0}, {x: x1, y: y1});
    config.boundaryShapes = config.boundaryShapes ? [...config.boundaryShapes] : [];
    config.boundaryShapes.push(new PIXI.Rectangle(x0 + (dx * tMin), y0 + (dy * tMin),
      dx * (tMax - tMin), dy * (tMax - tMin)).normalize());
    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, tMin, tMax);
  }

  /* -------------------------------------------- */

  /**
   * 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
   * @param {number} tMin                       Intersections of the ray and an edge with t-value at most
   *                                            `tMin` are not considered collisions. Default: `0`.
   * @param {number} tMax                       Intersections of the ray and an edge with t-value greater than
   *                                            `tMax` are not considered collisions. Default: `1`.
   * @returns {boolean|PolygonVertex|PolygonVertex[]|null} The collision test result
   * @protected
   * @abstract
   */
  _testCollision(ray, mode, destination, tMin, tMax) {
    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;
    if ( !this.scene.isView ) 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();
    if ( this.surfaceExposure ) {
      dg.lineStyle(0).beginFill(0xFFFF00, 0.25);
      this.surfaceExposure.drawShape(dg);
      dg.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.
   * @param {Level} config.level The Level the polygon is in
   * @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 = config.level.edges.getEdges(bounds, {includeOuterBounds: false, collisionTest});

    // Iterate over each candidate edge
    for ( const edge of edges ) nAttenuated += (edge.threshold?.attenuation ?? false);
    return {edges, nAttenuated};
  }

  /* -------------------------------------------- */

  /**
   * 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 {ClipperPoint[][]}                    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.EDGE_SENSE_TYPES.PROXIMITY;

    // Find the closest point on the threshold wall to the source.
    // Calculate the proportion of the source radius that is "inside" and "outside" the threshold wall.
    const pt = foundry.utils.closestPointToSegment(origin, edge.a, edge.b);
    const inside = Math.hypot(pt.x - origin.x, pt.y - origin.y);
    const outside = radius - inside;
    if ( (outside < 0) || outside.almostEqual(0) ) return { inside, outside: 0 };

    // Attenuate the radius outside the threshold wall based on source proximity to the wall.
    const sourceDistance = proximity ? Math.max(inside - externalRadius, 0) : (inside + externalRadius);
    const percentDistance = sourceDistance / d;
    const pInv = proximity ? 1 - percentDistance : Math.min(1, percentDistance - 1);
    const a = (pInv / (2 * (1 - pInv))) * CONFIG.Wall.thresholdAttenuationMultiplier;
    return { inside, outside: Math.min(a * d, outside) };
  }

  /* -------------------------------------------- */

  /**
   * Union the attenuated shape-LOS intersections with the closed LOS.
   * The portion of the light sources "inside" the threshold walls are not modified from their default radius or shape.
   * Clipper can union everything at once. Use a positive fill to avoid checkerboard; fill any overlap.
   * @param {PointSourcePolygon} los    The LOS polygon with threshold walls inactive
   * @param {ClipperPoints[]} shapes    Attenuation shapes for threshold walls
   * @returns {PIXI.Polygon}            The combined LOS polygon with threshold shapes
   */
  static #combineThresholdShapes(los, shapes) {
    const c = new ClipperLib.Clipper();
    const combined = [];
    const cPaths = [los.toClipperPoints(), ...shapes];
    c.AddPaths(cPaths, ClipperLib.PolyType.ptSubject, true);
    const p = ClipperLib.PolyFillType.pftPositive;
    c.Execute(ClipperLib.ClipType.ctUnion, combined, p, p);
    return PIXI.Polygon.fromClipperPoints(combined.length ? combined[0] : []);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  static get WALL_DIRECTION_MODES() {
    foundry.utils.logCompatibilityWarning("PointSourcePolygon.WALL_DIRECTION_MODES has been deprecated"
      + " in favor of CONST.EDGE_DIRECTION_MODES.", {since: 14, until: 16, once: true});
    return CONST.EDGE_DIRECTION_MODES;
  }
}

/**
 * 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 ( typeof callback !== "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 {ClockwiseSweepEdgeConfig, EdgeType, VertexMap} from "./_types.mjs"
 * @import {EdgeSet} from "./_types.mjs"
 * @import Edge from "./edges/edge.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<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 edge types.
   * @type {Record<EdgeType, ClockwiseSweepEdgeConfig>}
   * @protected
   */
  _edgeTypes;

  /**
   * The squared maximum distance of a ray that is needed for this Scene.
   * @type {number}
   * @protected
   */
  _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(this.scene.dimensions.maxR, 2);

    // Customize wall direction
    if ( "wallDirectionMode" in config ) {
      foundry.utils.logCompatibilityWarning("ClockwiseSweepPolygon#wallDirectionMode has been deprecated"
        + " in favor of ClockwiseSweepPolygon#edgeDirectionMode.", {since: 14, until: 16, once: true});
      config.edgeDirectionMode = config.wallDirectionMode;
      delete config.wallDirectionMode;
    }
    config.edgeDirectionMode ??= CONST.EDGE_DIRECTION_MODES.NORMAL;

    // 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
    this._edgeTypes = this._determineEdgeTypes(config.type, config.priority, config);

    // For convenience and speed up access
    this.#useInnerBounds = (this._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, ClockwiseSweepEdgeConfig>}
   * @protected
   */
  _determineEdgeTypes(type, priority, config={}) {
    /** @deprecated since v14 */
    this.#handleEdgeTypesAndOptionsDeprecations(type, priority, config);

    const et = config.edgeTypes ?? {};
    const edgeTypes = {};
    const addEdgeType = (name, fallback, defaultMode, defaultPriority) => {
      const v = et[name] ?? fallback;
      if ( v === true ) edgeTypes[name] = {mode: defaultMode, priority: defaultPriority};
      else if ( v === false ) edgeTypes[name] = {mode: 0, priority: -Infinity};
      else {
        const {mode=defaultMode, priority=defaultPriority} = v;
        edgeTypes[name] = {mode, priority: mode === 1 ? priority : -Infinity};
      }
    };

    /** @deprecated since v13 */
    let useInnerBounds = false;
    if ( "useInnerBounds" in config ) {
      foundry.utils.logCompatibilityWarning(
        "config.useInnerBounds is now deprecated, replaced by config.edgeTypes polygon configuration behaviors.",
        {since: 13, until: 15, once: true}
      );
      if ( config.useInnerBounds ) useInnerBounds = true;
      delete config.useInnerBounds;
    }

    if ( (type === "sight") && (et.innerBounds === undefined) && (et.outerBounds === undefined) && !useInnerBounds ) {
      const insideScene = this.scene.dimensions.sceneRect.contains(this.origin.x, this.origin.y);
      if ( insideScene ) {
        edgeTypes.innerBounds = {mode: 2, priority: -Infinity};
        edgeTypes.outerBounds = {mode: 0, priority: -Infinity};
      } else {
        edgeTypes.innerBounds = {mode: 0, priority: -Infinity};
        edgeTypes.outerBounds = {mode: 2, priority: -Infinity};
      }
    } else {
      addEdgeType("innerBounds", useInnerBounds, 2, -Infinity);
      addEdgeType("outerBounds", !useInnerBounds, 2, -Infinity);
    }

    /** @deprecated since v14 */
    if ( type === "universal" ) {
      foundry.utils.logCompatibilityWarning(
        "The \"universal\" polygon type is now deprecated, replaced by {edgeTypes: {wall: false}}.",
        {since: 14, until: 16, once: true}
      );
      edgeTypes.wall = {mode: 0, priority: -Infinity};
    }
    else addEdgeType("wall", true, 1, -Infinity);

    let includeSources = ["sight", "light", "darkness"].includes(type);

    /** @deprecated since v13 */
    if ( "includeDarkness" in config ) {
      foundry.utils.logCompatibilityWarning(
        "config.includeDarkness is now deprecated, replaced by config.edgeTypes polygon configuration behaviors.",
        {since: 13, until: 15, once: true}
      );
      if ( config.includeDarkness ) includeSources = true;
      delete config.includeDarkness;
    }

    addEdgeType("source", includeSources, 1, priority);

    return edgeTypes;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  clone() {
    const poly = super.clone();
    for ( const attr of ["vertices", "edges", "rays"] ) { // Shallow clone only
      poly[attr] = this[attr];
    }
    poly._edgeTypes = this._edgeTypes;
    poly._rayDistance2 = this._rayDistance2;
    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() {

    // Prepare collision function
    const collisionTest = edge => this._testEdgeInclusion(edge, this._edgeTypes);

    // Retrieve only the edges that intersect with the bounding box and pass the collision test
    const matchedEdges = this.level.edges.getEdges(this.config.boundingBox, {
      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, ClockwiseSweepEdgeConfig>} edgeTypes Which types of edges are being used?
   * @returns {boolean}    Should the edge be included?
   * @protected
   */
  _testEdgeInclusion(edge, edgeTypes) {
    const { type, boundaryShapes, useThreshold, edgeDirectionMode, 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;

    // Ignore edges which do not block this polygon type
    if ( edge[type] === CONST.EDGE_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 edm = CONST.EDGE_DIRECTION_MODES;
    if ( edge.direction && (edgeDirectionMode !== edm.BOTH) ) {
      if ( (edgeDirectionMode === edm.NORMAL) === (side === edge.direction) ) return false;
    }

    // Ignore threshold walls which do not satisfy their required proximity
    if ( useThreshold && edge.applyThreshold(type, this.origin, externalRadius) ) 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;
    }

    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() {
    const d = this.scene.dimensions;
    let b = this.useInnerBounds ? d.sceneRect : d.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[this.level.id] ?? [] ) {

        // 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
   * @protected
   */
  _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, destination, tMin, tMax) {
    const {debug, type} = this.config;

    // Identify candidate edges
    if ( ((ray.dx !== 0) || (ray.dy !== 0)) && (tMin < tMax) ) 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 <= tMin) || (x.t0 > tMax) ) 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() {
    if ( !this.scene.isView ) return;
    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.EDGE_SENSE_TYPES.NONE]: 0x77E7E8,
      [CONST.EDGE_SENSE_TYPES.NORMAL]: 0xFFFFBB,
      [CONST.EDGE_SENSE_TYPES.LIMITED]: 0x81B90C,
      [CONST.EDGE_SENSE_TYPES.PROXIMITY]: 0xFFFFBB,
      [CONST.EDGE_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.lineStyle(0).beginFill(0x00AAFF, 0.25).drawShape(this).endFill();

    // Draw surface exposure
    if ( this.surfaceExposure ) {
      dg.lineStyle(0).beginFill(0x0000FF, 0.25);
      this.surfaceExposure.drawShape(dg);
      dg.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.EDGE_SENSE_TYPES.NONE]: 0x77E7E8,
      [CONST.EDGE_SENSE_TYPES.NORMAL]: 0xFFFFBB,
      [CONST.EDGE_SENSE_TYPES.LIMITED]: 0x81B90C,
      [CONST.EDGE_SENSE_TYPES.PROXIMITY]: 0xFFFFBB,
      [CONST.EDGE_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;
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  #handleEdgeTypesAndOptionsDeprecations(type, priority, config) {
    /** @deprecated since v14 */
    if ( "edgeOptions" in config ) {
      foundry.utils.logCompatibilityWarning(
        "config.edgeOptions was deprecated in favor of config.edgeTypes.",
        {since: 14, until: 16, once: true}
      );
      config.edgeTypes ??= {...config.edgeOptions};
      delete config.edgeOptions;
    }

    /** @deprecated since v14 */
    if ( config.edgeTypes?.darkness ) {
      foundry.utils.logCompatibilityWarning(
        "config.edgeTypes.darkness was deprecated in favor of config.edgeTypes.source.",
        {since: 14, until: 16, once: true}
      );
      config.edgeTypes.source ??= config.edgeTypes.darkness;
      delete config.edgeTypes.darkness;
    }

    /** @deprecated since v14 */
    if ( config.edgeTypes?.light ) {
      foundry.utils.logCompatibilityWarning(
        "config.edgeTypes.light was deprecated in favor of config.edgeTypes.source.",
        {since: 14, until: 16, once: true}
      );
      const {mode, priority} = config.edgeTypes.light;
      config.edgeTypes.source ??= {mode, priority: priority - 1};
      delete config.edgeTypes.light;
    }
  }
}

/**
 * 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;
  }
}

var _module$$ = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CanvasQuadtree: CanvasQuadtree,
  ClockwiseSweepPolygon: ClockwiseSweepPolygon,
  ElevatedSurfaceExposureGenerator: ElevatedSurfaceExposureGenerator,
  LimitedAnglePolygon: LimitedAnglePolygon,
  ObservableTransform: ObservableTransform,
  PointSourcePolygon: PointSourcePolygon,
  PolygonMesher: PolygonMesher,
  Quadtree: Quadtree,
  Ray: Ray,
  UnboundTransform: UnboundTransform,
  WeilerAthertonClipper: WeilerAthertonClipper,
  edges: _module$10,
  types: _types$i
});

/**
 * @import {DeepReadonly, Point, ClipperPoint} from "@client/_types.mjs";
 * @import {ClientShapeData} from "@common/data/data.mjs";
 * @import BaseGrid from "@common/grid/base.mjs";
 * @import GridlessGrid from "@common/grid/gridless.mjs";
 */

/* -------------------------------------------- */

/**
 * Mixin a BaseShapeData subclass.
 * @param {typeof data.BaseShapeData} ShapeData
 */
function ClientShapeDataMixin(ShapeData) {
  class ClientShapeData extends ShapeData {

    /**
     * The distance snapping precision.
     * @type {number}
     */
    static #DISTANCE_SNAPPING_PRECISION = 2;

    /* -------------------------------------------- */

    /**
     * The rotation snapping interval.
     * @type {number}
     */
    static #ROTATION_SNAPPING_INTERVAL = 5;

    /* -------------------------------------------- */

    /**
     * Convert a path to a clipper path.
     * @param {ClipperPoint[]|Point[]|number[]} path    A path
     * @returns {ClipperPoint[]}
     * @internal
     */
    static _toClipperPath(path) {
      let clipperPath;

      // The path is of type number[]
      if ( path.length && (typeof path[0] === "number") ) {
        clipperPath = [];
        for ( let i = 0; i < path.length; i += 2 ) {
          clipperPath.push({
            X: Math.round(path[i] * CLIPPER_SCALING_FACTOR),
            Y: Math.round(path[i + 1] * CLIPPER_SCALING_FACTOR)
          });
        }
      }

      // The path is of type Point[]
      else if ( path.length && ("x" in path[0]) ) {
        clipperPath = [];
        for ( const point of path ) {
          clipperPath.push({
            X: Math.round(point.x * CLIPPER_SCALING_FACTOR),
            Y: Math.round(point.y * CLIPPER_SCALING_FACTOR)
          });
        }
      }

      // The path is of type ClipperPoint[]
      else clipperPath = path;

      return clipperPath;
    }

    /* -------------------------------------------- */

    /**
     * The scene that this shape is placed in, if any.
     * @type {Scene|null}
     */
    get scene() {
      if ( this.#scene !== undefined ) return this.#scene;
      let scene = this.parent;
      while ( scene && !(scene instanceof Scene$2) ) scene = scene.parent;
      if ( scene ) scene._dependentShapes.add(this);
      return this.#scene = scene;
    }

    #scene;

    /* -------------------------------------------- */

    /**
     * The grid that this shape is placed in.
     * @type {BaseGrid}
     */
    get grid() {
      return this.scene?.grid ?? Scene$2.defaultGrid;
    }

    /* -------------------------------------------- */

    /**
     * The gridless version of the grid that this shape is placed in.
     * @type {GridlessGrid}
     */
    get gridlessGrid() {
      return this.scene?.gridlessGrid ?? Scene$2.defaultGridlessGrid;
    }

    /* -------------------------------------------- */

    /**
     * Is this shape empty?
     * @type {boolean}
     */
    get isEmpty() {
      return this.area === 0;
    }

    /* -------------------------------------------- */

    /**
     * The polygons of this shape.
     *
     * The value of this property must not be mutated.
     * @type {ReadonlyArray<PIXI.Polygon>}
     */
    get polygons() {
      return this.polygonTree.polygons;
    }

    /* -------------------------------------------- */

    /**
     * The polygon tree of this shape.
     *
     * The value of this property must not be mutated.
     * @type {PolygonTree}
     */
    get polygonTree() {
      return this.#polygonTree ??= PolygonTree.fromClipperPolyTree(this.clipperPolyTree);
    }

    #polygonTree;

    /* -------------------------------------------- */

    /**
     * The Clipper paths of this shape.
     * The winding numbers are 1 or 0.
     *
     * The value of this property must not be mutated.
     * @type {DeepReadonly<ClipperPoint[][]>}
     */
    get clipperPaths() {
      return this.polygonTree.clipperPaths;
    }

    /* -------------------------------------------- */

    /**
     * The Clipper polygon tree of this shape.
     *
     * The value of this property must not be mutated.
     * @type {ClipperLib.PolyTree}
     */
    get clipperPolyTree() {
      let clipperPolyTree = this.#clipperPolyTree;
      if ( !clipperPolyTree ) {
        const result = this._createClipperPolyTree();
        let clipperPath;

        if ( Array.isArray(result) ) clipperPath = this.constructor._toClipperPath(result);

        // The result is ClipperPoint[]
        if ( clipperPath ) {
          clipperPolyTree = new ClipperLib.PolyTree();
          if ( clipperPath.length ) {
            const clipperPolyNode = new ClipperLib.PolyNode();
            clipperPolyNode.m_polygon = clipperPath;
            clipperPolyTree.AddChild(clipperPolyNode);
            clipperPolyTree.m_AllPolys.push(clipperPolyNode);
          }
        }

        // The result is ClipperPolyTree
        else clipperPolyTree = result;

        this.#clipperPolyTree = clipperPolyTree;
      }
      return clipperPolyTree;
    }

    #clipperPolyTree;

    /* -------------------------------------------- */

    /**
     * The triangulation of this shape.
     *
     * The value of this property must not be mutated.
     * @type {Readonly<{vertices: Float32Array; indices: Uint16Array|Uint32Array}>}
     */
    get triangulation() {
      return this.polygonTree.triangulation;
    }

    /* -------------------------------------------- */

    /**
     * The bounds of this Region.
     *
     * The value of this property must not be mutated.
     * @type {PIXI.Rectangle}
     */
    get bounds() {
      return this.polygonTree.bounds;
    }

    /* -------------------------------------------- */

    /**
     * The origin of this shape.
     * @type {Readonly<Point>}
     */
    get origin() {
      return this.#origin ??= Object.freeze(this._createOrigin());
    }

    #origin;

    /* -------------------------------------------- */

    /**
     * The center point of this shape.
     * @type {Readonly<Point>}
     */
    get center() {
      return this.#center ??= Object.freeze(this._createCenter());
    }

    #center;

    /* -------------------------------------------- */

    /**
     * The area of this shape.
     * @type {number}
     */
    get area() {
      return this.#area ??= Object.freeze(this._calculateArea());
    }

    #area;

    /* -------------------------------------------- */

    /**
     * The measured segments of this shape.
     * Each segment consist of a ray, winding order, distance in grid units, and the angle in degrees if it has one.
     * The ray represents the measured segment. If the winding order is ...
     *  - 1, the segment is an edge in positive orientation.
     *  - -1, the segment is an edge in negative orientation.
     *  - 0, the segment is not an edge.
     *
     * The distance is the actual grid distance if the shape is grid-based.
     * Otherwise the distance is the distance in pixels divided by of the ratio of grid distance and grid size.
     * @type {DeepReadonly<{ray: Ray; winding: -1|0|1; distance: number; angle?: number}[]>}
     */
    get measuredSegments() {
      return this.#measuredSegments ??= foundry.utils.deepFreeze(this._createMeasuredSegments());
    }

    #measuredSegments;

    /* -------------------------------------------- */

    /**
     * The control handles of this shape.
     * Each handle has a position and a rotation in radians.
     * @type {DeepReadonly<{[name: string]: {position: Point; rotation: number; visible: boolean}}>}
     */
    get controlHandles() {
      return this.#controlHandles ??= foundry.utils.deepFreeze(this._createControlHandles());
    }

    #controlHandles;

    /* -------------------------------------------- */

    /** @inheritDoc */
    _updateCommit(copy, diff, options, state) {
      super._updateCommit(copy, diff, options, state);
      for ( const key in diff ) {
        if ( key === "hole" ) continue;
        if ( (key !== "gridBased") || !this.grid.isGridless ) {
          this._onShapeChange();
          break;
        }
      }
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    clone(data, context) {
      const clone = super.clone(data, context);
      if ( clone.parent === this.parent ) clone._index = this._index;
      if ( (clone.scene === this.scene) && foundry.utils.equals(clone._source, this._source) ) {
        clone.#polygonTree = this.#polygonTree;
        clone.#clipperPolyTree = this.#clipperPolyTree;
        clone.#origin = this.#origin;
        clone.#measuredSegments = this.#measuredSegments;
        clone.#controlHandles = this.#controlHandles;
      }
      return clone;
    }

    /* -------------------------------------------- */

    /**
     * Called when the shape was changed.
     * This function is not called when just the hole state is changed.
     * This function is not called if grid-based is changed and the grid is gridless.
     * @protected
     */
    _onShapeChange() {
      this.#polygonTree = undefined;
      this.#clipperPolyTree = undefined;
      this.#origin = undefined;
      this.#center = undefined;
      this.#measuredSegments = undefined;
      this.#controlHandles = undefined;
    }

    /* -------------------------------------------- */

    /**
     * Called when the grid this shape is placed in changes.
     * @param {object} changed    The changes to the grid.
     * @protected
     */
    _onGridChange(changed) {
      if ( ("size" in changed) || (this.gridBased && (("type" in changed)
        || (this.grid.isSquare && ("diagonals" in changed)))) ) this._onShapeChange();
      else if ( "distance" in changed ) this.#measuredSegments = undefined;
    }

    /* -------------------------------------------- */

    /**
     * Is this shape currently affected by the grid?
     * @returns {boolean}
     */
    get isAffectedByGrid() {
      return this.gridBased === true;
    }

    /* -------------------------------------------- */

    /**
     * Whether the shape is identical to itself after a rotation around its origin.
     * @returns {boolean}
     */
    get hasRotationalSymmetry() {
      return !this.schema.has("rotation");
    }

    /* -------------------------------------------- */

    /**
     * Create a ray.
     * @param {number} x             The x-coordinate of the origin of the ray.
     * @param {number} y             The y-coordinate of the origin of the ray.
     * @param {number} direction     The direction of the ray in degrees.
     * @param {number} length        The length of the ray in pixels.
     * @param {number} [alignment=0] The alignment to ray.
     * @returns {Ray}
     * @internal
     */
    _createRay(x, y, direction, length, alignment=0) {
      let ray;
      if ( this.gridBased && !this.grid.isGridless ) {
        const distancePixels = this.grid.size / this.grid.distance;
        const from = {x, y};
        const to = this.grid.getTranslatedPoint(from, direction, length / distancePixels);
        ray = new Ray(from, to);
      }
      else ray = Ray.fromAngle(x, y, Math.toRadians(direction), length);
      if ( alignment !== 0 ) {
        const dx = ray.dx * alignment;
        const dy = ray.dy * alignment;
        ray.A.x -= dx;
        ray.A.y -= dy;
        ray.B.x -= dx;
        ray.B.y -= dy;
      }
      return ray;
    }

    /* -------------------------------------------- */

    /**
     * Snap the given point.
     * @param {Point} point    The point that is to be snapped.
     * @returns {Point}        The snapped point.
     * @internal
     */
    _getSnappedPoint(point) {
      if ( this.grid.isGridless ) return {x: Math.round(point.x), y: Math.round(point.y)};
      const M = CONST.GRID_SNAPPING_MODES;
      return this.grid.getSnappedPoint(point, {mode: M.CENTER | M.VERTEX | M.EDGE_MIDPOINT, resolution: 1});
    }

    /* -------------------------------------------- */

    /**
     * Get the size for the given ray defined by a length and direction.
     * @param {number} length       The length of the ray in pixels.
     * @param {number} direction    The direction of the ray in radians.
     * @param {object} [options]                   Additional options.
     * @param {boolean} [options.snap=false]       Snap the size to with defined grid snapping precision?
     * @param {boolean} [options.round]            Round the size to integer?
     * @param {boolean} [options.allowZero=false]  Allow the size to be zero?
     * @returns {number}            The snapped size in pixels.
     * @internal
     */
    _calculateSize(length, direction, {snap=false, round=!snap, allowZero=false}={}) {
      let size = Math.max(length, 0);
      const distancePixels = this.grid.size / this.grid.distance;
      if ( this.gridBased && !this.grid.isGridless ) {
        const {distance} = this.grid.measurePath([{x: 0, y: 0},
          {x: Math.cos(direction) * size, y: Math.sin(direction) * size}]);
        size = distance * distancePixels;
      }
      if ( snap ) {
        const interval = this.grid.distance / ClientShapeData.#DISTANCE_SNAPPING_PRECISION;
        let distance = size / distancePixels;
        distance = distance.toNearest(interval);
        if ( !allowZero ) distance = Math.max(distance, interval);
        size = distance * distancePixels;
      }
      if ( !allowZero ) size = Math.max(size, 1);
      if ( round || size.almostEqual(Math.round(size)) ) size = Math.round(size);
      return size;
    }

    /* -------------------------------------------- */

    /**
     * Snap the given rotation.
     * @param {number} rotation    The rotation to be snapped in degrees.
     * @returns {number}           The snapped rotation in degrees.
     * @internal
     */
    _getSnappedRotation(rotation) {
      return rotation.toNearest(ClientShapeData.#ROTATION_SNAPPING_INTERVAL);
    }

    /* -------------------------------------------- */

    /**
     * Test whether given point is contained within this shape.
     * @param {Point} point    The point.
     * @returns {boolean}
     */
    testPoint(point) {
      return this.polygonTree.testPoint(point);
    }

    /* -------------------------------------------- */

    /**
     * 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|ClipperPoint[]|Point[]|number[]}
     * @protected
     * @abstract
     */
    _createClipperPolyTree() {
      throw new Error("A subclass of the ClientShapeData must implement the _createClipperPolyTree method.");
    }

    /* -------------------------------------------- */

    /**
     * Create the origin point of this shape.
     * @returns {Point}
     * @protected
     */
    _createOrigin() {
      return {x: this.x, y: this.y};
    }

    /* -------------------------------------------- */

    /**
     * Create the center point of this shape.
     * @returns {Point}
     * @protected
     */
    _createCenter() {
      return this.origin;
    }

    /* -------------------------------------------- */

    /**
     * Calculate the area of this shape.
     * @returns {number}
     * @protected
     */
    _calculateArea() {
      return this.polygonTree.area;
    }

    /* -------------------------------------------- */

    /**
     * Move the shape to the given origin.
     * @param {Point} origin                    The (unsnapped) origin.
     * @param {object} [options]                Additional options.
     * @param {boolean} [options.snap=false]    Snap the origin?
     */
    move(origin, {snap=false}={}) {
      const {x, y} = snap ? this._getSnappedPoint(origin) : origin;
      this.updateSource({x, y});
    }

    /* -------------------------------------------- */

    /**
     * Rotate the shape by the given angle in degrees around the origin (or pivot).
     * @param {number} angle           The angle in degrees.
     * @param {object} [options]       Additional options.
     * @param {Point} [options.pivot]  The pivot of rotation. Default: origin.
     */
    rotate(angle, {pivot}={}) {
      if ( angle % 360 === 0 ) return;
      if ( pivot ) {
        const grid = this.gridBased ? this.grid : this.gridlessGrid;
        const direction = Math.toDegrees(Math.atan2(this.origin.y - pivot.y, this.origin.x - pivot.x)) + angle;
        const distance = grid.measurePath([{x: pivot.x, y: pivot.y}, this.origin]).distance;
        const origin = grid.getTranslatedPoint({x: pivot.x, y: pivot.y}, direction, distance);
        this.move(origin);
      }
      this._rotate(angle);
    }

    /* -------------------------------------------- */

    /**
     * Rotate the shape by the given angle in degrees around the origin.
     * @param {number} angle    The angle in degrees.
     * @protected
     */
    _rotate(angle) {
      this.updateSource({rotation: this.rotation + angle});
    }

    /* -------------------------------------------- */

    /**
     * Draw the shape into the Graphics element.
     * @param {PIXI.Graphics} graphics   The Graphics element
     */
    drawShape(graphics) {
      this.polygonTree.drawShape(graphics);
    }

    /* -------------------------------------------- */

    /**
     * Draw reference lines of the shape into the Graphics element, if it has any.
     * @param {PIXI.Graphics} graphics   The Graphics element
     */
    drawReferenceLines(graphics) {}

    /* -------------------------------------------- */

    /**
     * Create a measured segment.
     * @param {number} x           The x-coordinate of the origin of the ray.
     * @param {number} y           The y-coordinate of the origin of the ray.
     * @param {number} direction   The direction of the ray in degrees.
     * @param {number} length      The length of the ray in pixels.
     * @param {number} alignment   The alignment of the ray.
     * @param {-1|0|1} winding     The winding order.
     * @param {number} [angle]     The angle in degrees.
     * @returns {{ray: Ray; winding: -1|0|1; distance: number; angle?: number}}
     * @internal
     */
    _createMeasuredSegment(x, y, direction, length, alignment, winding, angle) {
      const distancePixels = this.grid.size / this.grid.distance;
      const distance = length / distancePixels;
      const segment = {ray: this._createRay(x, y, direction, length, alignment), winding, distance};
      if ( angle !== undefined ) segment.angle = angle;
      return segment;
    }

    /* -------------------------------------------- */

    /**
     * Create the measured segments of this shape.
     * @returns {{ray: Ray; winding: -1|0|1; distance: number; angle?: number}[]}
     * @protected
     * @abstract
     */
    _createMeasuredSegments() {
      throw new Error("A subclass of the ClientShapeData must implement the _createMeasuredSegments method.");
    }

    /* -------------------------------------------- */

    /**
     * Get the control handles for this shape.
     * @returns {{[name: string]: {position: Point; rotation: number; visible: boolean}}}
     *   The position, rotation in radians, and visible state for each handle.
     * @abstract
     */
    _createControlHandles() {
      throw new Error("A subclass of the ClientShapeData must implement the _createControlHandles method.");
    }

    /* -------------------------------------------- */

    /**
     * Move the control handle to the destination position.
     * @param {string} name                       The handle name.
     * @param {Point} destination                 The destination of the handle.
     * @param {object} [options]                  Additional options.
     * @param {boolean} [options.snap=false]      Snapping?
     * @param {boolean} [options.unlinked=false]  Unlinked scaling?
     *
     */
    moveControlHandle(name, destination, options) {
      throw new Error("A subclass of the ClientShapeData must implement the moveControlHandle method.");
    }

    /* -------------------------------------------- */

    /**
     * Transform this shape by moving a scale handle.
     * @param {string} fieldName    The field name of the axis that is scaled.
     * @param {Point} origin        The origin.
     * @param {number} direction    The direction of the axis in degrees.
     * @param {number} alignment    The alignment of the axis.
     * @param {Point} destination   The handle destination.
     * @param {boolean} snap        Snap?
     * @param {boolean} [allowZero=false]    Allow zero size?
     * @param {number} [max]                 The maximum value.
     * @internal
     */
    _moveScaleHandle(fieldName, origin, direction, alignment, destination, snap, allowZero, max) {
      const a = Math.toRadians(this.rotation + direction);
      let size = Math.max((Math.cos(a) * (destination.x - origin.x)) + (Math.sin(a) * (destination.y - origin.y)), 0);
      size *= (1 / (1 - alignment));
      size = this._calculateSize(size, a, {snap, allowZero});
      if ( max !== undefined ) size = Math.min(size, max);
      this.updateSource({[fieldName]: size});
    }

    /* -------------------------------------------- */

    /**
     * Transform this shape by moving a rotation handle.
     * @param {number} direction    The direction of the rotation handle in degrees.
     * @param {Point} destination   The handle destination.
     * @param {boolean} snap        Snap?
     * @internal
     */
    _moveRotationHandle(direction, destination, snap) {
      let rotation = Math.toDegrees(Math.atan2(destination.y - this.origin.y, destination.x - this.origin.x))
        - direction;
      if ( snap ) rotation = this._getSnappedRotation(rotation);
      else rotation = rotation.toNearest(0.01);
      this.rotate(rotation - this.rotation);
    }

    /* -------------------------------------------- */

    /**
     * Transform this shape by moving the sweep handle.
     * @param {number} maxAngle     The maximum angle possible.
     * @param {Point} destination   The handle destination.
     * @param {boolean} snap        Snap?
     * @internal
     */
    _moveSweepHandle(maxAngle, destination, snap) {
      const a = Math.toRadians(-this.rotation);
      const cos = Math.cos(a);
      const sin = Math.sin(a);
      const dx = (cos * (destination.x - this.x)) - (sin * (destination.y - this.y));
      const dy = (sin * (destination.x - this.x)) + (cos * (destination.y - this.y));
      let angle;
      if ( dy <= 0 ) angle = dx >= 0 ? 0 : 360;
      else angle = Math.toDegrees(Math.atan2(dy, dx)) * 2;
      const interval = snap ? ClientShapeData.#ROTATION_SNAPPING_INTERVAL : 1;
      angle = Math.clamp(angle.toNearest(interval), interval, maxAngle);
      this.updateSource({angle});
    }

    /* -------------------------------------------- */

    /**
     * Handle the drag start event for the creation of this shape.
     * @param {PIXI.FederatedEvent} event    The pointer event.
     * @internal
     */
    _onDragStart(event) {
      const interaction = event.interactionData;
      let {origin} = interaction;
      let snap = !event.shiftKey;
      if ( canvas.forceSnapVertices ) {
        origin = this.grid.getSnappedPoint(origin, {mode: CONST.GRID_SNAPPING_MODES.VERTEX});
        snap = false;
      }
      this.move(origin, {snap});
      interaction.origin = this.origin;
    }

    /* -------------------------------------------- */

    /**
     * Handle the drag move event for the creation of this shape.
     * @param {PIXI.FederatedEvent} event    The pointer event.
     * @abstract
     * @internal
     */
    _onDragMove(event) {
      throw new Error("A subclass of the ClientShapeData must implement the _onDragMove method.");
    }

    /* -------------------------------------------- */
    /*  Sampling                                    */
    /* -------------------------------------------- */

    /**
     * Sample a point from the shape interior.
     * @param {Point} [out]    A point to write to.
     * @returns {Point}        The sampled point.
     * @throws {Error}         If the shape is empty.
     */
    sampleInterior(out) {
      if ( this.isEmpty ) throw new Error("Cannot sample an empty shape.");
      return this.polygonTree.sampleInterior(out);
    }

    /* -------------------------------------------- */

    /**
     * Sample a point from the shape boundary.
     * @param {Point} [out]    A point to write to.
     * @returns {Point}        The sampled point.
     * @throws {Error}         If the shape is empty.
     */
    sampleBoundary(out) {
      if ( this.isEmpty ) throw new Error("Cannot sample an empty shape.");
      return this.polygonTree.sampleBoundary(out);
    }
  }
  return ClientShapeData;
}

/* -------------------------------------------- */

/**
 * The data model for a rectangle shape.
 */
class RectangleShapeData extends ClientShapeDataMixin(RectangleShapeData$1) {

  /** @type {{axisX: Ray; axisY: Ray}} */
  #rays;

  /* -------------------------------------------- */

  /**
   * Get the rays for both axes.
   * @returns {{axisX: Ray; axisY: Ray}}
   * @internal
   */
  _getRays() {
    if ( this.#rays ) return this.#rays;
    const {x, y, width, height, anchorX, anchorY, rotation} = this;
    return this.#rays = {
      axisX: this._createRay(x, y, rotation, width, anchorX),
      axisY: this._createRay(x, y, rotation + 90, height, anchorY)
    };
  }

  /* -------------------------------------------- */

  /** @override */
  get isEmpty() {
    return (this.width <= 0) || (this.height <= 0);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  clone(data, context) {
    const clone = super.clone(data, context);
    if ( this.#rays && (clone.scene === this.scene) && foundry.utils.equals(clone._source, this._source) ) {
      clone.#rays = this.#rays;
    }
    return clone;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onShapeChange() {
    super._onShapeChange();
    this.#rays = undefined;
  }

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    const grid = this.gridBased ? this.grid : this.gridlessGrid;
    const distancePixels = grid.size / grid.distance;
    return grid.getRectangle({x: this.x, y: this.y}, this.width / distancePixels,
      this.height / distancePixels, {x: this.anchorX, y: this.anchorY}, this.rotation);
  }

  /* -------------------------------------------- */

  /** @override */
  _createCenter() {
    const a = Math.toRadians(this.rotation);
    const cos = Math.cos(a);
    const sin = Math.sin(a);
    const dx = (0.5 - this.anchorX) * this.width;
    const dy = (0.5 - this.anchorY) * this.height;
    return {x: this.x + ((cos * dx) - (sin * dy)), y: this.y + ((sin * dx) + (cos * dy))};
  }

  /* -------------------------------------------- */

  /** @override */
  _calculateArea() {
    const {axisX, axisY} = this._getRays();
    return axisX.distance * axisY.distance;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  sampleInterior(out) {
    const {axisX, axisY} = this._getRays();
    const x = axisX.A.x + (axisY.A.x - this.x);
    const y = axisX.A.y + (axisY.A.y - this.y);
    return sampleRectangleInterior(out, x, y, axisX, axisY);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  sampleBoundary(out) {
    const {axisX, axisY} = this._getRays();
    const x = axisX.A.x + (axisY.A.x - this.x);
    const y = axisX.A.y + (axisY.A.y - this.y);
    return sampleRectangleBoundary(out, x, y, axisX, axisY);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  drawShape(graphics) {
    if ( this.rotation || (this.gridBased && !this.grid.isGridless) ) super.drawShape(graphics);
    else {
      const {x, y, width, height, anchorX, anchorY} = this;
      graphics.drawRect(x - (anchorX * width), y - (anchorY * height), width, height);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _createMeasuredSegments() {
    const {width, height, anchorX, anchorY} = this;
    const {axisX, axisY} = this._getRays();
    const ray1 = new Ray({x: axisX.A.x, y: axisX.A.y}, {x: axisX.B.x, y: axisX.B.y});
    const ray2 = new Ray({x: axisY.A.x, y: axisY.A.y}, {x: axisY.B.x, y: axisY.B.y});
    const dx1 = anchorX * axisX.dx;
    const dy1 = anchorX * axisX.dy;
    const dx2 = anchorY * axisY.dx;
    const dy2 = anchorY * axisY.dy;
    ray1.A.x -= dx2;
    ray1.A.y -= dy2;
    ray1.B.x -= dx2;
    ray1.B.y -= dy2;
    ray2.A.x -= dx1;
    ray2.A.y -= dy1;
    ray2.B.x -= dx1;
    ray2.B.y -= dy1;
    const distancePixels = this.grid.size / this.grid.distance;
    return [
      {ray: ray1, winding: 1, distance: width / distancePixels},
      {ray: ray2, winding: -1, distance: height / distancePixels}
    ];
  }

  /* -------------------------------------------- */

  /** @override */
  _createControlHandles() {
    const {x, y, anchorX, anchorY} = this;
    const {axisX, axisY} = this._getRays();
    const s1 = anchorX <= 0.5 ? 1 - anchorX : -anchorX;
    const s2 = anchorY <= 0.5 ? 1 - anchorY : -anchorY;
    return {
      translate: {position: {x, y}, rotation: axisX.angle, visible: true},
      scale: {position: {x: x + (s1 * axisX.dx) + (s2 * axisY.dx), y: y + (s1 * axisX.dy) + (s2 * axisY.dy)},
        rotation: axisX.angle, visible: true},
      rotate: {position: (anchorX < 1) && (anchorY >= 0) && (anchorY <= 1) ? axisX.B : {x, y}, rotation: axisX.angle,
        visible: true}
    };
  }

  /* -------------------------------------------- */

  /** @override */
  moveControlHandle(name, destination, {snap=false, unlinked=false}={}) {
    switch ( name ) {
      case "translate": this.move(destination, {snap}); break;
      case "scale": {
        let {x, y, width, height, anchorX, anchorY, rotation} = this;
        const priorWidth = width;
        const priorHeight = height;
        const a = Math.toRadians(rotation);
        const cos = Math.cos(a);
        const sin = Math.sin(a);
        width = Math.max(((cos * (destination.x - x)) + (sin * (destination.y - y)))
          * (anchorX <= 0.5 ? 1 : -1), 0);
        height = Math.max(((cos * (destination.y - y)) - (sin * (destination.x - x)))
          * (anchorY <= 0.5 ? 1 : -1), 0);
        width /= (anchorX <= 0.5 ? 1 - anchorX : anchorX);
        height /= (anchorY <= 0.5 ? 1 - anchorY : anchorY);
        if ( unlinked ) {
          width = this._calculateSize(width, a, {snap});
          height = this._calculateSize(height, a + (Math.PI / 2), {snap});
        } else {
          const {axisX, axisY} = this._getRays();
          if ( width * axisY.distance >= height * axisX.distance ) {
            width = this._calculateSize(width, a, {snap});
            height = width / priorWidth * priorHeight;
            if ( height.almostEqual(Math.round(height)) ) height = Math.round(height);
          } else {
            height = this._calculateSize(height, a + (Math.PI / 2), {snap});
            width = height / priorHeight * priorWidth;
            if ( width.almostEqual(Math.round(width)) ) width = Math.round(width);
          }
        }
        this.updateSource({width, height});
        break;
      }
      case "rotate": this._moveRotationHandle(0, destination, snap); break;
      default: throw new Error("Invalid handle");
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragMove(event) {
    const {origin, destination} = event.interactionData;
    const snap = !event.shiftKey;
    const a1 = Math.toRadians(this.rotation);
    const a2 = a1 + (Math.PI / 2);

    // Calculate the dimensions based on the drag-origin and drag-destination and the rotation of the rectangle
    const cos = Math.cos(a1);
    const sin = Math.sin(a1);
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;
    let width = (cos * dx) + (sin * dy);
    let height = (cos * dy) - (sin * dx);
    const invertedX = width < 0;
    const invertedY = height < 0;
    width = this._calculateSize(Math.abs(width), a1, {snap, allowZero: true});
    height = this._calculateSize(Math.abs(height), a2, {snap, allowZero: true});

    // Calculate the origin
    let {x, y} = origin;
    if ( (width !== 0) || (height !== 0) ) {

      // If either dimension is nonzero, make the other dimensions nonzero as well
      width ||= this._calculateSize(0, a1, {snap});
      height ||= this._calculateSize(0, a2, {snap});

      // Force to square
      if ( event.altKey ) width = height = Math.min(width, height);

      // Calculate the origin based on the anchor
      const ray1 = this._createRay(0, 0, this.rotation, width, 0);
      const ray2 = this._createRay(0, 0, this.rotation + 90, height, 0);
      const s1 = invertedX ? (this.anchorX - 1) : this.anchorX;
      const s2 = invertedY ? (this.anchorY - 1) : this.anchorY;
      x += ((s1 * ray1.dx) + (s2 * ray2.dx));
      y += ((s1 * ray1.dy) + (s2 * ray2.dy));
    }

    // Round origin coordinates to integers if almost integer
    if ( x.almostEqual(Math.round(x)) ) x = Math.round(x);
    if ( y.almostEqual(Math.round(y)) ) y = Math.round(y);

    this.updateSource({x, y, width, height});
  }
}

/* -------------------------------------------- */

/**
 * The data model for a circle shape.
 */
class CircleShapeData extends ClientShapeDataMixin(CircleShapeData$1) {

  /** @override */
  get isEmpty() {
    return this.radius <= 0;
  }

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    const grid = this.gridBased ? this.grid : this.gridlessGrid;
    const distancePixels = grid.size / grid.distance;
    return grid.getCircle({x: this.x, y: this.y}, this.radius / distancePixels);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _calculateArea() {
    if ( !this.gridBased || this.grid.isGridless ) return Math.PI * (this.radius * this.radius);
    return super._calculateArea();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  sampleInterior(out) {
    if ( this.gridBased && !this.grid.isGridless ) return super.sampleInterior(out);
    if ( this.isEmpty ) throw new Error("Cannot sample an empty shape.");
    const point = out ?? {x: 0, y: 0};
    const angle = Math.random() * (Math.PI * 2);
    const radius = Math.sqrt(Math.random()) * this.radius;
    point.x = this.x + (Math.cos(angle) * radius);
    point.y = this.y + (Math.sin(angle) * radius);
    return point;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  sampleBoundary(out) {
    if ( this.gridBased && !this.grid.isGridless ) return super.sampleBoundary(out);
    if ( this.isEmpty ) throw new Error("Cannot sample an empty shape.");
    const point = out ?? {x: 0, y: 0};
    const angle = Math.random() * (Math.PI * 2);
    point.x = this.x + (Math.cos(angle) * this.radius);
    point.y = this.y + (Math.sin(angle) * this.radius);
    return point;
  }

  /* -------------------------------------------- */

  /** @override */
  _rotate(angle) {}

  /* -------------------------------------------- */

  /** @inheritDoc */
  drawShape(graphics) {
    if ( this.gridBased && !this.grid.isGridless ) super.drawShape(graphics);
    else graphics.drawCircle(this.x, this.y, this.radius);
  }

  /* -------------------------------------------- */

  /** @override */
  _createMeasuredSegments() {
    return [this._createMeasuredSegment(this.x, this.y, 0, this.radius, 0, 0)];
  }

  /* -------------------------------------------- */

  /** @override */
  _createControlHandles() {
    const ray = this._createRay(this.x, this.y, 0, this.radius);
    return {
      translate: {position: ray.A, rotation: 0, visible: true},
      scale: {position: ray.B, rotation: 0, visible: true}
    };
  }

  /* -------------------------------------------- */

  /** @override */
  moveControlHandle(name, destination, {snap=false}={}) {
    switch ( name ) {
      case "translate": this.move(destination, {snap}); break;
      case "scale": this._moveScaleHandle("radius", this.origin, 0, 0, destination, snap); break;
      default: throw new Error("Invalid handle");
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragMove(event) {
    const {origin, destination} = event.interactionData;
    const snap = !event.shiftKey;
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;
    const radius = this._calculateSize(Math.hypot(dx, dy), Math.atan2(dy, dx), {snap, allowZero: true});
    this.updateSource({x: origin.x, y: origin.y, radius});
  }
}

/* -------------------------------------------- */

/**
 * The data model for an ellipse shape.
 */
class EllipseShapeData extends ClientShapeDataMixin(EllipseShapeData$1) {

  /** @type {{axisX: Ray; axisY: Ray}} */
  #rays;

  /* -------------------------------------------- */

  /**
   * Get the rays for both axes.
   * @returns {{axisX: Ray; axisY: Ray}}
   * @internal
   */
  _getRays() {
    if ( this.#rays ) return this.#rays;
    const {x, y, radiusX, radiusY, rotation} = this;
    return this.#rays = {
      axisX: this._createRay(x, y, rotation, radiusX),
      axisY: this._createRay(x, y, rotation + 90, radiusY)
    };
  }

  /* -------------------------------------------- */

  /** @override */
  get isEmpty() {
    return (this.radiusX <= 0) || (this.radiusY <= 0);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  clone(data, context) {
    const clone = super.clone(data, context);
    if ( this.#rays && (clone.scene === this.scene) && foundry.utils.equals(clone._source, this._source) ) {
      clone.#rays = this.#rays;
    }
    return clone;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onShapeChange() {
    super._onShapeChange();
    this.#rays = undefined;
  }

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    const grid = this.gridBased ? this.grid : this.gridlessGrid;
    const distancePixels = grid.size / grid.distance;
    return grid.getEllipse({x: this.x, y: this.y}, this.radiusX / distancePixels,
      this.radiusY / distancePixels, this.rotation);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _calculateArea() {
    if ( !this.gridBased || this.grid.isGridless ) return Math.PI * (this.radiusX * this.radiusY);
    return super._calculateArea();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  sampleInterior(out) {
    if ( this.gridBased && !this.grid.isGridless ) return super.sampleInterior(out);
    if ( this.isEmpty ) throw new Error("Cannot sample an empty shape.");
    const {axisX, axisY} = this._getRays();
    const point = out ?? {x: 0, y: 0};
    const angle = Math.random() * (Math.PI * 2);
    const radius = Math.sqrt(Math.random());
    const s = Math.cos(angle) * radius;
    const t = Math.sin(angle) * radius;
    point.x = this.x + (axisX.dx * s) + (axisY.dx * t);
    point.y = this.y + (axisX.dy * s) + (axisY.dy * t);
    return point;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  drawShape(graphics) {
    if ( this.rotation || (this.gridBased && !this.grid.isGridless) ) super.drawShape(graphics);
    else graphics.drawEllipse(this.x, this.y, this.radiusX, this.radiusY);
  }

  /* -------------------------------------------- */

  /** @override */
  _createMeasuredSegments() {
    const {axisX, axisY} = this._getRays();
    const distancePixels = this.grid.size / this.grid.distance;
    return [
      {ray: axisX, winding: 0, distance: this.radiusX / distancePixels},
      {ray: axisY, winding: 0, distance: this.radiusY / distancePixels}
    ];
  }

  /* -------------------------------------------- */

  /** @override */
  _createControlHandles() {
    const {axisX, axisY} = this._getRays();
    return {
      translate: {position: axisX.A, rotation: axisX.angle, visible: true},
      scaleX: {position: axisX.B, rotation: axisX.angle, visible: true},
      scaleY: {position: axisY.B, rotation: axisY.angle, visible: true},
      rotate: {position: axisX.B, rotation: axisX.angle, visible: true}
    };
  }

  /* -------------------------------------------- */

  /** @override */
  moveControlHandle(name, destination, {snap=false}={}) {
    switch ( name ) {
      case "translate": this.move(destination, {snap}); break;
      case "scaleX": this._moveScaleHandle("radiusX", this.origin, 0, 0, destination, snap); break;
      case "scaleY": this._moveScaleHandle("radiusY", this.origin, 90, 0, destination, snap); break;
      case "rotate": this._moveRotationHandle(0, destination, snap); break;
      default: throw new Error("Invalid handle");
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragMove(event) {
    const {origin, destination} = event.interactionData;
    const snap = !event.shiftKey;
    const a1 = Math.toRadians(this.rotation);
    const a2 = a1 + (Math.PI / 2);

    // Calculate the dimensions based on the drag-origin and drag-destination and the rotation of the ellipse
    const cos = Math.cos(a1);
    const sin = Math.sin(a1);
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;
    let radiusX = this._calculateSize(Math.abs((cos * dx) + (sin * dy)), a1, {snap, allowZero: true});
    let radiusY = this._calculateSize(Math.abs((cos * dy) - (sin * dx)), a2, {snap, allowZero: true});

    if ( (radiusX !== 0) || (radiusY !== 0) ) {

      // If either dimension is nonzero, make the other dimensions nonzero as well
      radiusX ||= this._calculateSize(0, a1, {snap});
      radiusY ||= this._calculateSize(0, a2, {snap});

      // Force to circle
      if ( event.altKey ) radiusX = radiusY = Math.min(radiusX, radiusY);
    }

    this.updateSource({x: origin.x, y: origin.y, radiusX, radiusY});
  }
}

/* -------------------------------------------- */

/**
 * The data model for a cone shape.
 */
class ConeShapeData extends ClientShapeDataMixin(ConeShapeData$1) {

  /** @type {{left: Ray; center: Ray; right: Ray}} */
  #rays;

  /* -------------------------------------------- */

  /**
   * Get the left, center, and right rays of this cone.
   * @returns {{left: Ray; center: Ray; right: Ray}}
   * @internal
   */
  _getRays() {
    if ( this.#rays ) return this.#rays;
    const {x, y, radius, angle, rotation, curvature} = this;
    const center = this._createRay(x, y, rotation, radius);

    // Flat curvature
    if ( curvature === "flat" ) {
      const halfAngle = Math.toRadians(angle) / 2;
      const length = center.distance / Math.cos(halfAngle);
      const left = Ray.fromAngle(x, y, center.angle - halfAngle, length);
      const right = Ray.fromAngle(x, y, center.angle + halfAngle, length);
      return this.#rays = {left, center, right};
    }

    // Semicircle curvature
    if ( (curvature === "semicircle") && (angle < 180) ) {
      const halfAngle = Math.toRadians(angle) / 2;
      const normalizedRight = Ray.fromAngle(x, y, center.angle + halfAngle, 1);
      const grid = this.gridBased ? this.grid : this.gridlessGrid;
      let intersectingAngle = center.angle + (0.5 * Math.PI);
      if ( !grid.isHexagonal ) intersectingAngle += (0.25 * Math.PI);
      else {
        const v = grid.getTranslatedPoint({x: 0, y: 0}, rotation, 1);
        const w = grid.getTranslatedPoint({x: 0, y: 0}, rotation + 90, 1);
        intersectingAngle += Math.atan(Math.hypot(v.x, v.y) / Math.hypot(w.x, w.y));
      }
      const intersectingLine = Ray.fromAngle(center.B.x, center.B.y, intersectingAngle, 1);
      const {t0: length} = foundry.utils.lineLineIntersection(normalizedRight.A, normalizedRight.B,
        intersectingLine.A, intersectingLine.B);
      const left = Ray.fromAngle(x, y, center.angle - halfAngle, length);
      const right = Ray.fromAngle(x, y, center.angle + halfAngle, length);
      return this.#rays = {left, center, right};
    }

    // Round curvature
    return this.#rays = {
      left: this._createRay(x, y, rotation - (angle / 2), radius),
      center,
      right: this._createRay(x, y, rotation + (angle / 2), radius)
    };
  }

  /* -------------------------------------------- */

  /** @override */
  get isEmpty() {
    return (this.radius <= 0) || (this.angle <= 0);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  clone(data, context) {
    const clone = super.clone(data, context);
    if ( this.#rays && (clone.scene === this.scene) && foundry.utils.equals(clone._source, this._source) ) {
      clone.#rays = this.#rays;
    }
    return clone;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onShapeChange() {
    super._onShapeChange();
    this.#rays = undefined;
  }

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    if ( (this.radius <= 0) || (this.angle <= 0) ) return new ClipperLib.PolyTree();

    // Flat curvature
    if ( this.curvature === "flat" ) {
      const {left, center, right} = this._getRays();
      return [center.A, left.B, right.B];
    }

    const grid = this.gridBased ? this.grid : this.gridlessGrid;

    // Semicircle curvature
    if ( (this.curvature === "semicircle") && (this.angle < 180) ) {
      const {left, center, right} = this._getRays();
      const intersection = foundry.utils.lineLineIntersection(center.A, center.B, left.B, right.B);
      const distance = grid.measurePath([intersection, center.B]).distance;
      const points = grid.getCone(intersection, distance, this.rotation, 180);
      points[0].x = this.x;
      points[0].y = this.y;
      return points;
    }

    // Round curvature
    const distancePixels = grid.size / grid.distance;
    return grid.getCone({x: this.x, y: this.y}, this.radius / distancePixels, this.rotation, this.angle);
  }

  /* -------------------------------------------- */

  /** @override */
  _createCenter() {
    if ( this.angle === 360 ) return this.origin;
    return foundry.utils.polygonCentroid(this.polygons[0].points);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _calculateArea() {
    if ( (this.curvature === "round") && (!this.gridBased || this.grid.isGridless) ) {
      return Math.PI * (this.radius * this.radius) * (this.angle / 360);
    }
    return super._calculateArea();
  }

  /* -------------------------------------------- */

  /** @override */
  _createMeasuredSegments() {
    const distancePixels = this.grid.size / this.grid.distance;
    return [{ray: this._getRays().center, winding: 0, distance: this.radius / distancePixels, angle: this.angle}];
  }

  /* -------------------------------------------- */

  /** @override */
  _createControlHandles() {
    const {center, right} = this._getRays();
    return {
      translate: {position: center.A, rotation: center.angle, visible: true},
      scale: {position: center.B, rotation: center.angle, visible: true},
      sweep: {position: right.project(0.5), rotation: right.angle, visible: true},
      rotate: {position: center.B, rotation: center.angle, visible: true}
    };
  }

  /* -------------------------------------------- */

  /** @override */
  moveControlHandle(name, destination, {snap=false}={}) {
    switch ( name ) {
      case "translate": this.move(destination, {snap}); break;
      case "scale": this._moveScaleHandle("radius", this.origin, 0, 0, destination, snap); break;
      case "rotate": this._moveRotationHandle(0, destination, snap); break;
      case "sweep": {
        const maxAngle = this.curvature === "flat" ? 90 : (this.curvature === "semicircle" ? 180 : 360);
        this._moveSweepHandle(maxAngle, destination, snap);
        break;
      }
      default: throw new Error("Invalid handle");
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragMove(event) {
    const {origin, destination} = event.interactionData;
    const snap = !event.shiftKey;
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;
    let rotation = Math.toDegrees(Math.atan2(dy, dx));
    if ( snap ) rotation = this._getSnappedRotation(rotation);
    else rotation = rotation.toNearest(0.01);
    const radius = this._calculateSize(Math.hypot(dx, dy), Math.toRadians(rotation), {snap, allowZero: true});
    this.updateSource({x: origin.x, y: origin.y, radius, rotation});
  }
}

/* -------------------------------------------- */

/**
 * The data model for a ring shape.
 */
class RingShapeData extends ClientShapeDataMixin(RingShapeData$1) {

  /**
   * The cached circle polygon for this ring.
   * @type {Point[]}
   */
  #circlePolygon;

  /**
   * The cached Euclidean point-sampling data for this ring.
   * @type {{
   *   innerRadius: number;
   *   outerRadius: number;
   *   innerRadiusSquared: number;
   *   outerRadiusSquared: number;
   *   totalRadius: number;
   * }}
   */
  #euclideanSamplingData;

  /* -------------------------------------------- */

  /**
   * Get the circle polygon of this ring.
   * @returns {Point[]}
   */
  #getCirclePolygon() {
    if ( this.#circlePolygon ) return this.#circlePolygon;
    const grid = this.gridBased ? this.grid : this.gridlessGrid;
    const distancePixels = this.grid.size / grid.distance;
    return this.#circlePolygon = grid.getCircle({x: this.x, y: this.y}, this.radius / distancePixels);
  }

  /* -------------------------------------------- */

  /**
   * Get the cached Euclidean point-sampling data.
   * @returns {{
   *   innerRadius: number;
   *   outerRadius: number;
   *   innerRadiusSquared: number;
   *   outerRadiusSquared: number;
   *   totalRadius: number;
   * }}
   */
  #getEuclideanSamplingData() {
    if ( this.#euclideanSamplingData ) return this.#euclideanSamplingData;
    const innerRadius = Math.max(this.radius - this.innerWidth, 0);
    const outerRadius = this.radius + this.outerWidth;
    return this.#euclideanSamplingData = {
      innerRadius,
      outerRadius,
      innerRadiusSquared: innerRadius * innerRadius,
      outerRadiusSquared: outerRadius * outerRadius,
      totalRadius: innerRadius + outerRadius
    };
  }

  /* -------------------------------------------- */

  /** @override */
  get isEmpty() {
    const innerRadius = Math.max(this.radius - this.innerWidth, 0);
    const outerRadius = this.radius + this.outerWidth;
    return outerRadius <= innerRadius;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  clone(data, context) {
    const clone = super.clone(data, context);
    if ( (this.#circlePolygon || this.#euclideanSamplingData) && (clone.scene === this.scene)
      && foundry.utils.equals(clone._source, this._source) ) {
      clone.#circlePolygon = this.#circlePolygon;
      clone.#euclideanSamplingData = this.#euclideanSamplingData;
    }
    return clone;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onShapeChange() {
    super._onShapeChange();
    this.#circlePolygon = undefined;
    this.#euclideanSamplingData = undefined;
  }

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    const grid = this.gridBased ? this.grid : this.gridlessGrid;
    const distancePixels = grid.size / grid.distance;
    const [innerCircle, outerCircle] = grid.getRing({x: this.x, y: this.y}, this.radius / distancePixels,
      this.innerWidth / distancePixels, this.outerWidth / distancePixels);
    const clipperPolyTree = new ClipperLib.PolyTree();
    if ( outerCircle.length !== 0 ) {
      const clipperOuterPolyNode = new ClipperLib.PolyNode();
      const clipperOuterPath = clipperOuterPolyNode.Contour();
      for ( const point of outerCircle ) {
        clipperOuterPath.push({
          X: Math.round(point.x * CLIPPER_SCALING_FACTOR),
          Y: Math.round(point.y * CLIPPER_SCALING_FACTOR)
        });
      }
      clipperPolyTree.AddChild(clipperOuterPolyNode);
      clipperPolyTree.m_AllPolys.push(clipperOuterPolyNode);
      if ( innerCircle.length !== 0 ) {
        const clipperInnerPolyNode = new ClipperLib.PolyNode();
        const clipperInnerPath = clipperInnerPolyNode.Contour();
        for ( const point of innerCircle ) {
          clipperInnerPath.push({
            X: Math.round(point.x * CLIPPER_SCALING_FACTOR),
            Y: Math.round(point.y * CLIPPER_SCALING_FACTOR)
          });
        }
        clipperInnerPath.reverse();
        clipperOuterPolyNode.AddChild(clipperInnerPolyNode);
        clipperPolyTree.m_AllPolys.push(clipperInnerPolyNode);
      }
    }
    return clipperPolyTree;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _calculateArea() {
    if ( !this.gridBased || this.grid.isGridless ) {
      const innerRadius = Math.max(this.radius - this.innerWidth, 0);
      const outerRadius = this.radius + this.outerWidth;
      return Math.PI * ((outerRadius * outerRadius) - (innerRadius * innerRadius));
    }
    return super._calculateArea();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  sampleInterior(out) {
    if ( this.gridBased && !this.grid.isGridless ) return super.sampleInterior(out);
    const {innerRadiusSquared, outerRadiusSquared} = this.#getEuclideanSamplingData();
    if ( outerRadiusSquared <= innerRadiusSquared ) throw new Error("Cannot sample an empty shape.");
    const point = out ?? {x: 0, y: 0};
    const angle = Math.random() * (Math.PI * 2);
    const radius = Math.sqrt(innerRadiusSquared + (Math.random() * (outerRadiusSquared - innerRadiusSquared)));
    point.x = this.x + (Math.cos(angle) * radius);
    point.y = this.y + (Math.sin(angle) * radius);
    return point;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  sampleBoundary(out) {
    if ( this.gridBased && !this.grid.isGridless ) return super.sampleBoundary(out);
    const {innerRadius, outerRadius, totalRadius} = this.#getEuclideanSamplingData();
    if ( this.isEmpty ) throw new Error("Cannot sample an empty shape.");
    const point = out ?? {x: 0, y: 0};
    const angle = Math.random() * (Math.PI * 2);
    const radius = ((Math.random() * totalRadius) < outerRadius) ? outerRadius : innerRadius;
    point.x = this.x + (Math.cos(angle) * radius);
    point.y = this.y + (Math.sin(angle) * radius);
    return point;
  }

  /* -------------------------------------------- */

  /** @override */
  _rotate(angle) {}

  /* -------------------------------------------- */

  /** @inheritDoc */
  drawShape(graphics) {
    if ( this.gridBased && !this.grid.isGridless ) {
      super.drawShape(graphics);
      return;
    }
    const innerRadius = this.radius - this.innerWidth;
    const outerRadius = this.radius + this.outerWidth;
    graphics.drawCircle(this.x, this.y, outerRadius);
    if ( innerRadius > 0 ) {
      graphics.beginHole();
      graphics.drawCircle(this.x, this.y, innerRadius);
      graphics.endHole();
    }
  }

  /* -------------------------------------------- */

  /** @override */
  drawReferenceLines(graphics) {
    if ( (this.radius === 0) || (this.innerWidth === 0) || (this.outerWidth === 0) ) return;
    if ( this.gridBased && !this.grid.isGridless ) graphics.drawPolygon(this.#getCirclePolygon());
    else graphics.drawCircle(this.x, this.y, this.radius);
  }

  /* -------------------------------------------- */

  /** @override */
  _createMeasuredSegments() {
    const distancePixels = this.grid.size / this.grid.distance;
    const {B: {x: x0, y: y0}} = this._createRay(this.x, this.y, 90, this.radius);
    const segments = [
      this._createMeasuredSegment(this.x, this.y, 0, this.radius, 0, 0),
      {ray: this._createRay(x0, y0, 90, this.outerWidth), winding: 0, distance: this.outerWidth / distancePixels}
    ];
    if ( this.innerWidth <= this.radius ) {
      segments.push({ray: this._createRay(x0, y0, 270, this.innerWidth), winding: 0,
        distance: this.innerWidth / distancePixels});
    }
    return segments;
  }

  /* -------------------------------------------- */

  /** @override */
  _createControlHandles() {
    const ray1 = this._createRay(this.x, this.y, 0, this.radius);
    const {B: {x: x0, y: y0}} = this._createRay(this.x, this.y, 90, this.radius);
    const ray2 = this._createRay(x0, y0, 270, this.innerWidth);
    const ray3 = this._createRay(x0, y0, 90, this.outerWidth);
    return {
      translate: {position: ray1.A, rotation: 0, visible: true},
      scale: {position: ray1.B, rotation: ray1.angle, visible: true},
      inset: {position: ray2.B, rotation: ray3.angle, visible: true},
      offset: {position: ray3.B, rotation: ray3.angle, visible: true}
    };
  }

  /* -------------------------------------------- */

  /** @override */
  moveControlHandle(name, destination, {snap=false}={}) {
    switch ( name ) {
      case "translate": this.move(destination, {snap}); break;
      case "scale": this._moveScaleHandle("radius", this.origin, 0, 0, destination, snap); break;
      case "inset": this._moveScaleHandle("innerWidth", this._createRay(this.x, this.y, 90, this.radius).B, 270, 0, destination, snap, this.outerWidth > 0, this.radius); break;
      case "offset": this._moveScaleHandle("outerWidth", this._createRay(this.x, this.y, 90, this.radius).B, 90, 0, destination, snap, this.innerWidth > 0); break;
      default: throw new Error("Invalid handle");
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragMove(event) {
    const {origin, destination} = event.interactionData;
    const snap = !event.shiftKey;
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;
    const radius = this._calculateSize(Math.hypot(dx, dy), Math.atan2(dy, dx), {snap, allowZero: true});
    this.updateSource({x: origin.x, y: origin.y, radius});
  }
}

/* -------------------------------------------- */

/**
 * The data model for a line shape.
 */
class LineShapeData extends ClientShapeDataMixin(LineShapeData$1) {

  /** @type {{axisX: Ray; axisY: Ray}} */
  #rays;

  /* -------------------------------------------- */

  /**
   * Get the rays for both axes.
   * @returns {{axisX: Ray; axisY: Ray}}
   * @internal
   */
  _getRays() {
    if ( this.#rays ) return this.#rays;
    const {x, y, length, width, rotation} = this;
    return this.#rays = {
      axisX: this._createRay(x, y, rotation, length),
      axisY: this._createRay(x, y, rotation + 90, width, 0.5)
    };
  }

  /* -------------------------------------------- */

  /** @override */
  get isEmpty() {
    return (this.length <= 0) || (this.width <= 0);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  clone(data, context) {
    const clone = super.clone(data, context);
    if ( this.#rays && (clone.scene === this.scene)
      && foundry.utils.equals(clone._source, this._source) ) {
      clone.#rays = this.#rays;
    }
    return clone;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onShapeChange() {
    super._onShapeChange();
    this.#rays = undefined;
  }

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    const grid = this.gridBased ? this.grid : this.gridlessGrid;
    const distancePixels = grid.size / grid.distance;
    return grid.getLine({x: this.x, y: this.y}, this.length / distancePixels,
      this.width / distancePixels, this.rotation);
  }

  /* -------------------------------------------- */

  /** @override */
  _createCenter() {
    return this._getRays().axisX.project(0.5);
  }

  /* -------------------------------------------- */

  /** @override */
  _calculateArea() {
    const {axisX, axisY} = this._getRays();
    return axisX.distance * axisY.distance;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  sampleInterior(out) {
    const {axisX, axisY} = this._getRays();
    return sampleRectangleInterior(out, axisY.A.x, axisY.A.y, axisX, axisY);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  sampleBoundary(out) {
    const {axisX, axisY} = this._getRays();
    return sampleRectangleBoundary(out, axisY.A.x, axisY.A.y, axisX, axisY);
  }

  /* -------------------------------------------- */

  /** @override */
  _createMeasuredSegments() {
    const distancePixels = this.grid.size / this.grid.distance;
    const {axisX, axisY} = this._getRays();
    return [
      {ray: axisX, winding: 0, distance: this.length / distancePixels},
      {ray: axisY, winding: -1, distance: this.width / distancePixels}
    ];
  }

  /* -------------------------------------------- */

  /** @override */
  _createControlHandles() {
    const {axisX, axisY} = this._getRays();
    return {
      translate: {position: axisX.A, rotation: axisX.angle, visible: true},
      scaleX: {position: axisX.B, rotation: axisX.angle, visible: true},
      scaleY: {position: {x: axisY.B.x + (axisX.dx / 2), y: axisY.B.y + (axisX.dy / 2)}, rotation: axisY.angle,
        visible: true},
      rotate: {position: axisX.B, rotation: axisX.angle, visible: true}
    };
  }

  /* -------------------------------------------- */

  /** @override */
  moveControlHandle(name, destination, {snap=false}={}) {
    switch ( name ) {
      case "translate": this.move(destination, {snap}); break;
      case "scaleX": this._moveScaleHandle("length", this.origin, 0, 0, destination, snap); break;
      case "scaleY": this._moveScaleHandle("width", this.origin, 90, 0.5, destination, snap); break;
      case "rotate": this._moveRotationHandle(0, destination, snap); break;
      default: throw new Error("Invalid handle");
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragMove(event) {
    const {origin, destination} = event.interactionData;
    const snap = !event.shiftKey;
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;
    let rotation = Math.toDegrees(Math.atan2(dy, dx));
    if ( snap ) rotation = this._getSnappedRotation(rotation);
    else rotation = rotation.toNearest(0.01);
    const length = this._calculateSize(Math.hypot(dx, dy), Math.toRadians(rotation), {snap, allowZero: true});
    this.updateSource({x: origin.x, y: origin.y, length, rotation});
  }
}

/* -------------------------------------------- */

/**
 * The data model for an emanation shape.
 */
class EmanationShapeData extends ClientShapeDataMixin(EmanationShapeData$1) {

  /** @type {Ray} */
  #ray;

  /* -------------------------------------------- */

  /**
   * Get the ray.
   * @returns {Ray}
   */
  #getRay() {
    if ( this.#ray ) return this.#ray;
    const grid = this.gridBased ? this.grid : this.gridlessGrid;
    const base = this.base.type === "token" ? this.base._getTokenShape() : this.base;
    let x;
    let y;
    let direction = base.rotation;
    switch ( base.type ) {
      case "rectangle":
      case "ellipse":
      case "line":
        ({x, y} = base._getRays().axisX.B);
        break;
      case "circle":
        ({x, y} = base._createRay(base.x, base.y, base.rotation, base.radius).B);
        break;
      case "cone":
        ({x, y} = base._getRays().center.B);
        break;
      case "polygon": {
        // Try to pick the most pleasing location
        const {left, right} = base.bounds;
        const epsilon = 1 / CLIPPER_SCALING_FACTOR;
        const points = base.polygons[0].points;
        for ( let i = 0; i < points.length; i += 2 ) {
          const x0 = points[i];
          if ( (x0 > left + epsilon) && (x0 < right - epsilon) ) continue;
          const i1 = (i + 2) % points.length;
          let y0;
          if ( points[i1] !== x0 ) y0 = points[i + 1];
          else {
            y0 = (points[i + 1] + points[i1 + 1]) / 2;
            i += 2;
          }
          const dy = y - this.origin.y;
          const dy0 = y0 - this.origin.y;
          if ( (x === undefined) || (Math.abs(dy0) < Math.abs(dy) - epsilon)
            || (Math.abs(dy0).almostEqual(Math.abs(dy), 2 * epsilon)
            && (x0 > this.origin.x) && ((x < this.origin) || (Math.sign(dy0) < Math.sign(dy)))) ) {
            [x, y] = [x0, y0];
          }
        }
        direction = x >= this.origin.x ? 0 : 180;
        break;
      }
      default: throw new Error("Invalid base shape type");
    }
    const distancePixels = grid.size / grid.distance;
    const A = {x, y};
    const B = grid.getTranslatedPoint(A, direction, this.radius / distancePixels);
    return this.#ray = new Ray(A, B);
  }

  /* -------------------------------------------- */

  /** @override */
  get isEmpty() {
    return this.base.isEmpty;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onShapeChange() {
    super._onShapeChange();
    this.#ray = undefined;
  }

  /* -------------------------------------------- */

  /** @override */
  get isAffectedByGrid() {
    return this.gridBased || this.base.isAffectedByGrid;
  }

  /* -------------------------------------------- */

  /** @override */
  get hasRotationalSymmetry() {
    return this.base.hasRotationalSymmetry;
  }

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    if ( this.base.isEmpty ) return new ClipperLib.PolyTree();
    if ( this.radius <= 0 ) return this.base.hole ? new ClipperLib.PolyTree() : this.base.clipperPolyTree;
    const grid = this.gridBased ? this.grid : this.gridlessGrid;
    const base = this.base.type === "token" ? this.base._getTokenShape() : this.base;
    const baseGrid = base.gridBased ? base.grid : base.gridlessGrid;

    // Circle
    if ( (base.type === "circle") && (baseGrid.isGridless === grid.isGridless)
      && (!grid.isSquare || ((grid.diagonals !== CONST.GRID_DIAGONALS.ALTERNATING_1)
      && (grid.diagonals !== CONST.GRID_DIAGONALS.ALTERNATING_2))) ) {
      const distancePixels = grid.size / grid.distance;
      if ( base.hole ) {
        const ring = new RingShapeData({x: base.x, y: base.y, radius: base.radius, innerWidth: 0,
          outerWidth: this.radius, gridBased: this.gridBased}, {parent: this.parent});
        return ring.clipperPolyTree;
      }
      return grid.getCircle({x: base.x, y: base.y}, (base.radius + this.radius) / distancePixels);
    }

    let minkowskiSum;

    // Euclidean ellipse
    if ( (base.type === "ellipse") && baseGrid.isGridless && grid.isGridless ) {
      const {x, y, rotation} = base;
      const r1 = base.radiusX;
      const r2 = base.radiusY;
      const r3 = this.radius;
      const maxR = Math.max(r1, r2) + r3;
      const n = Math.max(Math.ceil(Math.PI / Math.acos(Math.max(maxR - 0.25, 0) / maxR)), 4);
      const points = new Array(n);
      const angle = Math.toRadians(rotation);
      const cos = Math.cos(angle);
      const sin = Math.sin(angle);
      for ( let i = 0, j = 0; i < n; i++ ) {
        const a = 2 * Math.PI * (i / n);
        const c = Math.cos(a);
        const s = Math.sin(a);
        const nx = c * r2;
        const ny = s * r1;
        const nd = Math.hypot(nx, ny);
        const dx = (c * r1) + (nx / nd * r3);
        const dy = (s * r2) + (ny / nd * r3);
        points[j++] = x + ((cos * dx) - (sin * dy));
        points[j++] = y + ((sin * dx) + (cos * dy));
      }
      minkowskiSum = points;
    }

    // Rounded rectangle / line
    else if ( (base.type === "rectangle") || (base.type === "line") ) {
      const distancePixels = grid.size / grid.distance;
      const range = this.radius / distancePixels;
      const [x0, y0, x1, y1, x2, y2, x3, y3] = base.polygons[0].points;
      const rotation = base.rotation;
      minkowskiSum = [
        ...grid.getCone({x: x0, y: y0}, range, rotation + 225, 90).slice(1),
        ...grid.getCone({x: x1, y: y1}, range, rotation + 315, 90).slice(1),
        ...grid.getCone({x: x2, y: y2}, range, rotation + 45, 90).slice(1),
        ...grid.getCone({x: x3, y: y3}, range, rotation + 135, 90).slice(1)
      ];
    }

    // Any other shape type
    else {
      const tokenPath = base.clipperPaths[0];
      const distancePixels = grid.size / grid.distance;
      const range = this.radius / distancePixels;

      // Compute the path of the emanation circle
      const emanationPath = [];
      for ( const {x, y} of grid.getCircle({x: 0, y: 0}, range) ) {
        emanationPath.push({
          X: Math.round(x * CLIPPER_SCALING_FACTOR),
          Y: Math.round(y * CLIPPER_SCALING_FACTOR)
        });
      }

      // Compute the Minkowski sum of the token's shape and the emanation circle
      const intermediateSolution = ClipperLib.Clipper.MinkowskiSum(emanationPath, tokenPath, true);

      // The intermediate solution is broken, we need to fix it
      const solution = new ClipperLib.PolyTree();
      const clipper = new ClipperLib.Clipper();
      clipper.StrictlySimple = true;
      clipper.AddPaths(intermediateSolution, ClipperLib.PolyType.ptSubject, true);
      clipper.Execute(ClipperLib.ClipType.ctUnion, solution, ClipperLib.PolyFillType.pftEvenOdd,
        ClipperLib.PolyFillType.pftEvenOdd);
      minkowskiSum = solution.Childs()[0].Contour();
    }

    // Subtract base shape if hole
    if ( !base.hole ) return minkowskiSum;
    const solution = new ClipperLib.PolyTree();
    const clipper = new ClipperLib.Clipper();
    clipper.AddPath(this.constructor._toClipperPath(minkowskiSum), ClipperLib.PolyType.ptSubject, true);
    clipper.AddPaths(base.clipperPaths, ClipperLib.PolyType.ptClip, true);
    clipper.Execute(ClipperLib.ClipType.ctDifference, solution, ClipperLib.PolyFillType.pftEvenOdd,
      ClipperLib.PolyFillType.pftEvenOdd);
    return solution;
  }

  /* -------------------------------------------- */

  /** @override */
  _createOrigin() {
    return this.base.origin;
  }

  /* -------------------------------------------- */

  /** @override */
  move(origin, options) {
    const base = this.base.clone();
    base.move(origin, options);
    this.updateSource({base: base.toObject()});
  }

  /* -------------------------------------------- */

  /** @override */
  _rotate(angle) {
    const base = this.base.clone();
    base.rotate(angle);
    this.updateSource({base: base.toObject()});
  }

  /* -------------------------------------------- */

  /** @override */
  drawReferenceLines(graphics) {
    this.base.drawReferenceLines(graphics);
    if ( (this.radius > 0) && !this.base.hole ) this.base.drawShape(graphics);
  }

  /* -------------------------------------------- */

  /** @override */
  _createMeasuredSegments() {
    if ( this.radius <= 0 ) return [];
    const distancePixels = this.grid.size / this.grid.distance;
    const range = this.radius / distancePixels;
    return [...this.base.measuredSegments, {ray: this.#getRay(), winding: 0, distance: range}];
  }

  /* -------------------------------------------- */

  /** @override */
  _createControlHandles() {
    const ray = this.#getRay();
    return {...this.base.controlHandles, offset: {position: ray.B, rotation: ray.angle, visible: true}};
  }

  /* -------------------------------------------- */

  /** @override */
  moveControlHandle(name, destination, options={}) {
    if ( name === "offset" ) {
      const {snap=false} = options;
      const x0 = this.#getRay().A.x;
      const radius = this._calculateSize(Math.max(Math.sign(x0 - this.origin.x) * (destination.x - x0), 0), 0,
        {snap, allowZero: !this.base.hole});
      this.updateSource({radius});
      return;
    }
    const base = this.base.clone();
    base.moveControlHandle(name, destination, options);
    this.updateSource({base: base.toObject()});
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragMove(event) {

    // This implementation is not exact but good enough
    const {destination} = event.interactionData;
    const snap = !event.shiftKey;
    let intersection;
    let radius = 0;
    if ( !this.base.testPoint(destination) ) {
      const points = this.base.polygons[0].points;
      const x0 = points.at(-2);
      const y0 = points.at(-1);
      let p0 = {x: x0, y: y0};
      for ( let i = 0; i < points.length; i += 2 ) {
        const x1 = points[i];
        const y1 = points[i + 1];
        const p1 = {x: x1, y: y1};
        intersection = foundry.utils.lineSegmentIntersection(this.origin, destination, p0, p1);
        if ( intersection ) break;
        p0 = p1;
      }
      if ( intersection ) {
        const dx = destination.x - intersection.x;
        const dy = destination.y - intersection.y;
        const length = Math.hypot(dx, dy);
        const direction = Math.atan2(dy, dx);
        radius = this._calculateSize(length, direction, {snap, allowZero: true});
      }
    }
    this.updateSource({radius});
  }
}

/* -------------------------------------------- */

/**
 * The data model for a polygon shape.
 */
class PolygonShapeData extends ClientShapeDataMixin(PolygonShapeData$1) {

  /**
   * A shared point instance.
   * @type {PIXI.Point}
   */
  static #SHARED_POINT = new PIXI.Point();

  /* -------------------------------------------- */

  /** @override */
  get hasRotationalSymmetry() {
    return !this.controlHandles.rotate.visible;
  }

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    const polygon = new PIXI.Polygon(this.points);
    if ( !polygon.isPositive ) polygon.reverseOrientation();
    return polygon.points;
  }

  /* -------------------------------------------- */

  /** @override */
  _createOrigin() {
    if ( this._source.origin ) return {...this._source.origin};
    return foundry.utils.polygonCentroid(this.points);
  }

  /* -------------------------------------------- */

  /** @override */
  _getSnappedPoint(point) {
    if ( this.grid.isGridless ) return {x: Math.round(point.x), y: Math.round(point.y)};
    const M = CONST.GRID_SNAPPING_MODES;
    return this.grid.getSnappedPoint(point, {
      mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT,
      resolution: this.grid.size >= 128 ? 8 : (this.grid.size >= 64 ? 4 : 2)
    });
  }

  /* -------------------------------------------- */

  /** @override */
  _calculateSize(length, direction, {snap=false, round=!snap, allowZero=false}={}) {
    let size = Math.max(length, 0);
    if ( snap ) {
      let interval;
      if ( this.grid.isGridless ) interval = 1;
      else {
        let precision = this.grid.size >= 128 ? 16 : (this.grid.size >= 64 ? 8 : 4);
        if ( this.grid.isSquare ) {
          const c = Math.abs(Math.cos(direction));
          const s = Math.abs(Math.sin(direction));
          precision *= Math.max(c, s);
        } else {
          if ( this.grid.columns ) direction += (Math.PI / 6);
          const c0 = Math.abs(Math.cos(direction));
          const c1 = Math.abs(Math.cos(direction - (Math.PI / 3)));
          const c2 = Math.abs(Math.cos(direction + (Math.PI / 3)));
          precision *= Math.max(c0, c1, c2);
        }
        interval = this.grid.size / precision;
      }
      size = size.toNearest(interval);
      if ( !allowZero ) size = Math.max(size, interval);
    }
    if ( !allowZero ) size = Math.max(size, 1);
    if ( round || size.almostEqual(Math.round(size)) ) size = Math.round(size);
    return size;
  }

  /* -------------------------------------------- */

  /** @override */
  move(origin, {snap=false}={}) {
    if ( snap ) origin = this._getSnappedPoint(origin);
    const dx = origin.x - this.origin.x;
    const dy = origin.y - this.origin.y;
    const points = this.points.slice();
    for ( let i = 0; i < points.length; i += 2 ) {
      points[i] += dx;
      points[i + 1] += dy;
    }
    const data = {points};
    if ( this._source.origin ) data.origin = {x: origin.x, y: origin.y};
    this.updateSource(data);
  }

  /* -------------------------------------------- */

  /** @override */
  _rotate(angle) {
    const points = this.points.slice();
    const matrix = PIXI.Matrix.TEMP_MATRIX.identity()
      .translate(-this.origin.x, -this.origin.y)
      .rotate(Math.toRadians(angle))
      .translate(this.origin.x, this.origin.y);
    for ( let i = 0; i < points.length; i += 2 ) {
      const point = PolygonShapeData.#SHARED_POINT.set(points[i], points[i + 1]);
      matrix.apply(point, point);
      points[i] = point.x;
      points[i + 1] = point.y;
    }
    this.updateSource({points});
  }

  /* -------------------------------------------- */

  /** @override */
  _createMeasuredSegments() {
    return [];
  }

  /* -------------------------------------------- */

  /**
   * Get the target point for scaling and rotation.
   * @returns {Point|null}
   */
  #getTarget() {
    const {x: x0, y: y0} = this.origin;
    for ( let i = 0; i < this.points.length; i += 2 ) {
      const x1 = this.points[i];
      const y1 = this.points[i + 1];
      if ( (x1 !== x0) || (y1 !== y0) ) return {x: x1, y: y1};
    }
    return null;
  }

  /* -------------------------------------------- */

  /** @override */
  _createControlHandles() {
    const target = this.#getTarget();
    const ray = new Ray(this.origin, target ?? this.origin);
    return {
      translate: {position: ray.A, rotation: ray.angle, visible: true},
      scale: {position: ray.B, rotation: ray.angle, visible: !!target},
      rotate: {position: ray.B, rotation: ray.angle, visible: !!target}
    };
  }

  /* -------------------------------------------- */

  /** @override */
  moveControlHandle(name, destination, {snap=false}={}) {
    switch ( name ) {
      case "translate": this.move(destination, {snap}); break;
      case "scale": {
        const points = this.points.slice();
        const {x: x0, y: y0} = this.origin;
        const {x: x1, y: y1} = this.controlHandles.scale.position;
        let dx = x1 - x0;
        const dy = y1 - y0;
        if ( (dx === 0) && (dy === 0) ) dx = 1;
        const d = Math.hypot(dx, dy);
        const a = Math.atan2(dy, dx);
        let size = (((destination.x - x0) * dx) + ((destination.y - y0) * dy)) / d;
        size = this._calculateSize(size, a, {snap});
        const scale = size / d;
        const matrix = PIXI.Matrix.TEMP_MATRIX.identity()
          .translate(-x0, -y0)
          .scale(scale, scale)
          .translate(x0, y0);
        for ( let i = 0; i < points.length; i += 2 ) {
          const point = PolygonShapeData.#SHARED_POINT.set(points[i], points[i + 1]);
          matrix.apply(point, point);
          points[i] = point.x;
          points[i + 1] = point.y;
        }
        this.updateSource({points});
        break;
      }
      case "rotate": this._moveRotationHandle(Math.toDegrees(this.controlHandles.rotate.rotation), destination, snap); break;
      default: throw new Error("Invalid handle");
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragStart(event) {
    const interaction = event.interactionData;
    let {origin} = interaction;
    let snap = !event.shiftKey;
    if ( interaction.freehand ) {
      interaction.freehandSampleRate ??= 100;
      interaction.freehandDrawTime = Date.now();
      snap = false;
    } else if ( canvas.forceSnapVertices ) {
      origin = this.grid.getSnappedPoint(origin, {mode: CONST.GRID_SNAPPING_MODES.VERTEX});
      snap = false;
    }
    this.move(origin, {snap});
    interaction.origin = this.origin;
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragMove(event) {
    const interaction = event.interactionData;
    let {destination} = interaction;
    const points = this.points.slice();
    if ( interaction.freehand ) {
      const now = Date.now();
      if ( (now - interaction.freehandDrawTime) >= interaction.freehandSampleRate ) {
        interaction.drawTime = now;
        if ( !(destination.x.almostEqual(points.at(-4)) && destination.y.almostEqual(points.at(-3))) ) {
          points.splice(-2, 0, destination.x, destination.y);
        }
      }
    } else if ( canvas.forceSnapVertices ) {
      destination = this.grid.getSnappedPoint(destination, {mode: CONST.GRID_SNAPPING_MODES.VERTEX});
    } else {
      const snap = !event.shiftKey;
      if ( snap ) destination = this._getSnappedPoint(destination);
    }
    points[points.length - 2] = destination.x;
    points[points.length - 1] = destination.y;
    this.updateSource({points});
  }
}

/* -------------------------------------------- */

/**
 * The data model for a token shape.
 */
class TokenShapeData extends ClientShapeDataMixin(TokenShapeData$1) {

  /** @type {TokenDocument} */
  #token;

  /* -------------------------------------------- */

  /** @type {CircleShapeData|EllipseShapeData|PolygonShapeData} */
  #tokenShape;

  /* -------------------------------------------- */

  /**
   * Get the token.
   * @returns {TokenDocument}
   */
  #getToken() {
    if ( this.#token ) return this.#token;
    const scene = this.scene ?? new Scene$2.implementation({name: "Default Scene"});
    const {x, y, width, height, shape} = this;
    return this.#token = new TokenDocument.implementation({x, y, width, height, shape}, {parent: scene});
  }

  /* -------------------------------------------- */

  /**
   * Get the token shape.
   * @type {CircleShapeData|EllipseShapeData|PolygonShapeData}
   * @internal
   */
  _getTokenShape() {
    if ( this.#tokenShape ) return this.#tokenShape;
    const token = this.#getToken();
    if ( token.parent.grid.isGridless ) {
      const {width, height} = token.getSize();
      const shape = token.shape;
      if ( (shape === TOKEN_SHAPES.ELLIPSE_1) || (shape === TOKEN_SHAPES.ELLIPSE_2) ) {
        if ( width === height ) {
          const radius = width / 2;
          this.#tokenShape = new CircleShapeData({x: token.x + radius, y: token.y + radius, radius, hole: this.hole},
            {parent: token.parent});
        } else {
          const radiusX = width / 2;
          const radiusY = height / 2;
          this.#tokenShape = new EllipseShapeData({x: token.x + radiusX, y: token.y + radiusY, radiusX, radiusY,
            hole: this.hole}, {parent: token.parent});
        }
      } else {
        const halfWidth = width / 2;
        const halfHeight = height / 2;
        this.#tokenShape = new RectangleShapeData({x: token.x + halfWidth, y: token.y + halfHeight, width, height,
          anchorX: 0.5, anchorY: 0.5, hole: this.hole}, {parent: token.parent});
      }
    } else if ( token.parent.grid.isSquare ) {
      const {width, height} = token.getSize();
      const halfWidth = width / 2;
      const halfHeight = height / 2;
      this.#tokenShape = new RectangleShapeData({x: token.x + halfWidth, y: token.y + halfHeight, width, height,
        anchorX: 0.5, anchorY: 0.5, hole: this.hole}, {parent: token.parent});
    } else {
      const points = [];
      for ( const {x, y} of token.getGridSpacePolygon() ) {
        points.push(token.x + x, token.y + y);
      }
      this.#tokenShape = new PolygonShapeData({points, hole: this.hole}, {parent: token.parent});
    }
    return this.#tokenShape;
  }

  /* -------------------------------------------- */

  /** @override */
  get isEmpty() {
    return false;
  }

  /* -------------------------------------------- */

  /** @override */
  get polygonTree() {
    return this._getTokenShape().polygonTree;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  sampleInterior(out) {
    return this._getTokenShape().sampleInterior(out);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  sampleBoundary(out) {
    return this._getTokenShape().sampleBoundary(out);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onShapeChange() {
    super._onShapeChange();
    this.#token = undefined;
    this.#tokenShape = undefined;
  }

  /* -------------------------------------------- */

  /** @override */
  get isAffectedByGrid() {
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    return this._getTokenShape().clipperPolyTree;
  }

  /* -------------------------------------------- */

  /** @override */
  _createOrigin() {
    const {x, y} = this.#getToken().getCenterPoint();
    return {x, y};
  }

  /* -------------------------------------------- */

  /** @override */
  move(origin, {snap=false}={}) {
    const token = this.#getToken();
    const pivot = token.getCenterPoint({x: 0, y: 0});
    const position = {x: origin.x - pivot.x, y: origin.y - pivot.y};
    const {x, y} = snap ? token.getSnappedPosition(position) : position;
    this.updateSource({x, y});
  }

  /* -------------------------------------------- */

  /** @override */
  _rotate(angle) {}

  /* -------------------------------------------- */

  /** @override */
  _createMeasuredSegments() {
    return [];
  }

  /* -------------------------------------------- */

  /** @override */
  _createControlHandles() {
    return {translate: {position: this.origin, rotation: 0, visible: true}};
  }

  /* -------------------------------------------- */

  /** @override */
  moveControlHandle(name, destination, {snap=false}={}) {
    switch ( name ) {
      case "translate": this.move(destination, {snap}); break;
      default: throw new Error("Invalid handle");
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragMove(event) {}
}

/* -------------------------------------------- */

/**
 * The data model for a shape that is the union of grid spaces.
 */
class GridShapeData extends ClientShapeDataMixin(GridShapeData$1) {

  /** @type {Set<string>} */
  #offsetKeys;

  /* -------------------------------------------- */

  /**
   * Get the offset keys.
   * @returns {Set<string>}
   */
  #getOffsetKeys() {
    if ( !this.#offsetKeys ) {
      this.#offsetKeys = new Set();
      for ( const {i, j} of this.offsets ) this.#offsetKeys.add(`${i}.${j}`);
    }
    return this.#offsetKeys;
  }

  /* -------------------------------------------- */

  /** @override */
  get isEmpty() {
    return !this.offsets.length || this.grid.isGridless;
  }

  /* -------------------------------------------- */

  /** @override */
  get isAffectedByGrid() {
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  get hasRotationalSymmetry() {
    return !this.controlHandles.rotate.visible;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onShapeChange() {
    super._onShapeChange();
    this.#offsetKeys = undefined;
  }

  /* -------------------------------------------- */

  /** @override */
  testPoint(point) {
    const {i, j} = this.grid.getOffset(point);
    return this.#getOffsetKeys().has(`${i}.${j}`);
  }

  /* -------------------------------------------- */

  /** @override */
  _createClipperPolyTree() {
    if ( this.isEmpty ) return new ClipperLib.PolyTree();
    if ( this.offsets.length === 1 ) return this.grid.getVertices(this.offsets[0]);

    // Clipper handles collinear edges so badly we have to scale the grid space shapes slightly (1 Clipper pixel)
    // to eliminate them. If we didn't do this, the performance would be abysmal.
    const scale = 1 + (2 / (CLIPPER_SCALING_FACTOR * Math.max(this.grid.sizeX, this.grid.sizeY)));
    const vertices = this.grid.getShape();
    for ( const vertex of vertices ) {
      vertex.x *= scale;
      vertex.y *= scale;
    }
    const clipper = new ClipperLib.Clipper();
    for ( const offset of this.offsets ) {
      const center = this.grid.getCenterPoint(offset);
      const path = [];
      for ( const vertex of vertices ) {
        path.push({
          X: Math.round((center.x + vertex.x) * CLIPPER_SCALING_FACTOR),
          Y: Math.round((center.y + vertex.y) * CLIPPER_SCALING_FACTOR)
        });
      }
      clipper.AddPath(path, ClipperLib.PolyType.ptSubject, true);
    }
    const solution = new ClipperLib.PolyTree();
    clipper.Execute(ClipperLib.ClipType.ctUnion, solution, ClipperLib.PolyFillType.pftPositive,
      ClipperLib.PolyFillType.pftEvenOdd);
    return solution;
  }

  /* -------------------------------------------- */

  /** @override */
  _createOrigin() {
    if ( this._source.origin ) return {...this._source.origin};
    if ( this.isEmpty ) return {x: 0, y: 0};
    return this.grid.getCenterPoint(this.offsets[0]);
  }

  /* -------------------------------------------- */

  /** @override */
  move(origin, {snap=false}={}) {
    const from = this.grid.getOffset(this.origin);
    const to = this.grid.getOffset(origin);
    if ( snap ) origin = this.grid.getCenterPoint(to);
    const offsets = [];
    if ( !this.grid.isHexagonal ) {
      const di = to.i - from.i;
      const dj = to.j - from.j;
      for ( const offset of this.offsets ) offsets.push({i: offset.i + di, j: offset.j + dj});
    } else {
      const a = this.grid.getCube(from);
      const b = this.grid.getCube(to);
      const dq = b.q - a.q;
      const dr = b.r - a.r;
      const ds = b.s - a.s;
      for ( const offset of this.offsets ) {
        const cube = this.grid.getCube(offset);
        cube.q += dq;
        cube.r += dr;
        cube.s += ds;
        offsets.push(this.grid.getOffset(cube));
      }
    }
    const data = {offsets};
    if ( this._source.origin ) data.origin = {x: origin.x, y: origin.y};
    this.updateSource(data);
  }

  /* -------------------------------------------- */

  /**
   * @override
   * @see {@link https://www.redblobgames.com/grids/hexagons/implementation.html#rotation}
   */
  _rotate(angle) {
    if ( this.grid.isEmpty ) return;
    const offsets = [];
    if ( this.grid.isSquare ) {
      const origin = this.grid.getOffset(this.origin);
      angle = Math.toRadians(angle.toNearest(90));
      const cos = Math.round(Math.cos(angle));
      const sin = Math.round(Math.sin(angle));
      for ( const offset of this.offsets ) {
        const di = offset.i - origin.i;
        const dj = offset.j - origin.j;
        offsets.push({
          i: (sin * dj) + (cos * di) + origin.i,
          j: (cos * dj) - (sin * di) + origin.j
        });
      }
    } else {
      const origin = this.grid.getCube(this.grid.getOffset(this.origin));
      const n = Math.normalizeDegrees(angle.toNearest(60)) / 60;
      for ( const offset of this.offsets ) {
        const cube = this.grid.getCube(offset);
        cube.q -= origin.q;
        cube.r -= origin.r;
        cube.s -= origin.s;
        for ( let i = 0; i < n; i++ ) [cube.q, cube.r, cube.s] = [-cube.r, -cube.s, -cube.q];
        cube.q += origin.q;
        cube.r += origin.r;
        cube.s += origin.s;
        offsets.push(this.grid.getOffset(cube));
      }
    }
    this.updateSource({offsets});
  }

  /* -------------------------------------------- */

  /** @override */
  _createMeasuredSegments() {
    return [];
  }

  /* -------------------------------------------- */

  /**
   * Get the rotation of the shape, if any.
   * @returns {number|void}
   */
  #getDirection() {
    const origin = this.grid.getOffset(this.origin);
    for ( const offset of this.offsets ) {
      if ( (offset.i !== origin.i) || (offset.j !== origin.j) ) {
        return new Ray(this.grid.getCenterPoint(origin), this.grid.getCenterPoint(offset)).angle;
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _createControlHandles() {
    const direction = this.#getDirection();
    return {
      translate: {position: this.origin, rotation: direction ?? 0, visible: true},
      rotate: {position: this.origin, rotation: direction ?? 0, visible: direction !== undefined}
    };
  }

  /* -------------------------------------------- */

  /** @override */
  moveControlHandle(name, destination, {snap=false}={}) {
    switch ( name ) {
      case "translate": this.move(destination, {snap}); break;
      case "rotate": this._moveRotationHandle(Math.toDegrees(this.controlHandles.rotate.rotation), destination, snap); break;
      default: throw new Error("Invalid handle");
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragMove(event) {}
}

/* -------------------------------------------- */

/**
 * Sample a point from a rectangle interior.
 * @param {Point} [out]  A point to write to.
 * @param {number} x     The x-coordinate of the rectangle corner.
 * @param {number} y     The y-coordinate of the rectangle corner.
 * @param {Ray} axisX    The rectangle width axis.
 * @param {Ray} axisY    The rectangle height axis.
 * @returns {Point}
 * @throws {Error} If the rectangle is empty.
 */
function sampleRectangleInterior(out, x, y, axisX, axisY) {
  const width = axisX.distance;
  const height = axisY.distance;
  if ( (width <= 0) || (height <= 0) ) throw new Error("Cannot sample an empty shape.");
  const point = out ?? {x: 0, y: 0};
  const s = Math.random();
  const t = Math.random();
  point.x = x + (axisX.dx * s) + (axisY.dx * t);
  point.y = y + (axisX.dy * s) + (axisY.dy * t);
  return point;
}

/* -------------------------------------------- */

/**
 * Sample a point from a rectangle boundary.
 * @param {Point} [out]  A point to write to.
 * @param {number} x     The x-coordinate of the rectangle corner.
 * @param {number} y     The y-coordinate of the rectangle corner.
 * @param {Ray} axisX    The rectangle width axis.
 * @param {Ray} axisY    The rectangle height axis.
 * @returns {Point}
 * @throws {Error} If the rectangle is empty.
 */
function sampleRectangleBoundary(out, x, y, axisX, axisY) {
  const width = axisX.distance;
  const height = axisY.distance;
  if ( (width <= 0) || (height <= 0) ) throw new Error("Cannot sample an empty shape.");
  const point = out ?? {x: 0, y: 0};
  let d = Math.random() * (2 * (width + height));
  if ( d < width ) {
    const t = d / width;
    point.x = x + (axisX.dx * t);
    point.y = y + (axisX.dy * t);
    return point;
  }

  d -= width;
  if ( d < height ) {
    const t = d / height;
    point.x = x + axisX.dx + (axisY.dx * t);
    point.y = y + axisX.dy + (axisY.dy * t);
    return point;
  }

  d -= height;
  if ( d < width ) {
    const t = d / width;
    point.x = x + axisX.dx + axisY.dx - (axisX.dx * t);
    point.y = y + axisX.dy + axisY.dy - (axisX.dy * t);
    return point;
  }

  d -= width;
  const t = d / height;
  point.x = x + axisY.dx - (axisY.dx * t);
  point.y = y + axisY.dy - (axisY.dy * t);
  return point;
}

/**
 * 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) {

  /* -------------------------------------------- */
  /*  Model Properties                            */
  /* -------------------------------------------- */

  /**
   * Is this ambient light source global in nature?
   * @type {boolean}
   */
  get isGlobal() {
    return !this.walls;
  }

  /**
   * The circle or cone shape of this AmbientLight document.
   * @type {CircleShapeData|ConeShapeData}
   */
  shape = this.shape; // Workaround for subclass property instantiation issue.

  /* -------------------------------------------- */
  /*  Model Methods                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    if ( this.parent ) {
      const d = this.parent.dimensions;
      this.x = Math.clamp(this.x, 0, d.width);
      this.y = Math.clamp(this.y, 0, d.height);
    }
    const {x, y, config: {dim, bright, angle}} = this;
    const grid = this.parent?.grid ?? foundry.documents.BaseScene.defaultGrid;
    const distancePixels = grid.size / grid.distance;
    const radius = Math.max(dim, bright) * distancePixels;
    this.shape = angle < 360 ? new ConeShapeData({x, y, radius, angle, rotation: this.rotation + 90}, {parent: this})
      : new CircleShapeData({x, y, radius}, {parent: this});
  }
}

/**
 * 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) {

  /**
   * The circle shape of this AmbientSound document.
   * @type {CircleShapeData}
   */
  shape = this.shape; // Workaround for subclass property instantiation issue.

  /* -------------------------------------------- */
  /*  Model Methods                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    if ( this.parent ) {
      const d = this.parent.dimensions;
      this.x = Math.clamp(this.x, 0, d.width);
      this.y = Math.clamp(this.y, 0, d.height);
    }
    const grid = this.parent?.grid ?? foundry.documents.BaseScene.defaultGrid;
    const distancePixels = grid.size / grid.distance;
    const {x, y} = this;
    const radius = this.radius * distancePixels;
    this.shape = new CircleShapeData({x, y, radius}, {parent: this});
  }
}

/**
 * @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",
    "elevation",
    "levels",
    "interface"
  ];

  /* -------------------------------------------- */

  /**
   * The rectangle shape of this Tile document.
   * @type {RectangleShapeData|EllipseShapeData|PolygonShapeData}
   * @internal
   */
  _shape = this._shape; // Workaround for subclass property instantiation issue.

  /* -------------------------------------------- */
  /*  Model Properties                            */
  /* -------------------------------------------- */

  /**
   * Is the current User the author of this drawing?
   * @type {boolean}
   */
  get isAuthor() {
    return game.user === this.author;
  }

  /* -------------------------------------------- */
  /*  Model Methods                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    if ( this.parent ) {
      const d = this.parent.dimensions;
      const {width, height} = this.shape;
      this.x = Math.clamp(this.x + (width / 2), 0, d.width) - (width / 2);
      this.y = Math.clamp(this.y + (height / 2), 0, d.height) - (height / 2);
    }
    this._shape = DrawingDocument.#createShape(this);
  }

  /* -------------------------------------------- */

  /**
   * Create the rectangle/ellipse/polygon shape.
   * @param {DrawingDocument} document
   * @returns {RectangleShapeData|EllipseShapeData|PolygonShapeData}
   */
  static #createShape(document) {
    const {x, y, shape, rotation} = document;
    const {type, width, height} = shape;
    switch ( type ) {
      case "r":
        return new RectangleShapeData({
          x: x + (width / 2),
          y: y + (height / 2),
          width, height,
          anchorX: 0.5, anchorY: 0.5,
          rotation
        }, {parent: document});
      case "e":
        return new EllipseShapeData({
          x: x + (width / 2),
          y: y + (height / 2),
          radiusX: width / 2,
          radiusY: height / 2,
          rotation
        }, {parent: document});
      case "p": {
        const polygon = new PolygonShapeData({
          points: shape.points.slice(),
          origin: {x: width / 2, y: height / 2}
        }, {parent: document});
        polygon.rotate(rotation);
        polygon.move({x: x + (width / 2), y: y + (height / 2)});
        return polygon;
      }
      case "c": throw new Error("The circle type is not supported");
    }
  }
}

/**
 * @import {Point, ElevatedPoint, ClipperPoint, DeepReadonly, TokenPanningOptions} from "@client/_types.mjs";
 * @import {RegionData, RegionSocketEvent, TokenCoordinates, TokenData} from "@common/documents/_types.mjs";
 * @import {RegionEvent, RegionMovementSegment, RegionSegmentizeMovementPathWaypoint} from "./_types.mjs";
 * @import Scene from "./scene.mjs";
 * @import {PointSourcePolygon} from "@client/canvas/geometry/_module.mjs";
 * @import {PointSourcePolygonConfig} from "@client/canvas/geometry/_types.mjs";
 * @import {DatabaseCreateOperation} from "@common/abstract/_types.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};

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Does this Region have a single shape that is not a hole?
   * @type {boolean}
   */
  get isSingleShape() {
    return (this.shapes.length === 1) && !this.shapes[0].hole;
  }

  /* -------------------------------------------- */

  /**
   * The polygons of this Region.
   *
   * The value of this property must not be mutated.
   *
   * Alias for `this.polygonTree.polygons`.
   * @type {ReadonlyArray<PIXI.Polygon>}
   */
  get polygons() {
    return this.polygonTree.polygons;
  }

  /* -------------------------------------------- */

  /**
   * The polygon tree of this Region.
   *
   * The value of this property must not be mutated.
   * @type {PolygonTree}
   */
  get polygonTree() {
    return this.#polygonTree ??= PolygonTree.fromClipperPolyTree(this.clipperPolyTree);
  }

  #polygonTree;

  /* -------------------------------------------- */

  /**
   * The Clipper paths of this Region.
   *
   * The value of this property must not be mutated.
   *
   * Alias for `this.polygonTree.clipperPaths`.
   * @type {DeepReadonly<ClipperPoint[][]>}
   */
  get clipperPaths() {
    return this.polygonTree.clipperPaths;
  }

  /* -------------------------------------------- */

  /**
   * The Clipper polygon tree of this Region.
   *
   * The value of this property must not be mutated.
   * @type {ClipperLib.PolyTree}
   */
  get clipperPolyTree() {
    return this.#clipperPolyTree ??= this._createClipperPolyTree(this.shapes, this._shapeConstraints);
  }

  #clipperPolyTree;

  /* -------------------------------------------- */

  /**
   * The triangulation of this Region.
   *
   * The value of this property must not be mutated.
   *
   * Alias for `this.polygonTree.triangulation`.
   * @type {Readonly<{vertices: Float32Array; indices: Uint16Array|Uint32Array}>}
   */
  get triangulation() {
    return this.polygonTree.triangulation;
  }

  /* -------------------------------------------- */

  /**
   * The bounds of this Region.
   *
   * The value of this property must not be mutated.
   *
   * Alias for `this.polygonTree.bounds`.
   * @type {PIXI.Rectangle}
   */
  get bounds() {
    return this.polygonTree.bounds;
  }

  /* -------------------------------------------- */

  /**
   * The area of this Region.
   *
   * Alias for `this.polygonTree.area`.
   * @type {number}
   */
  get area() {
    return this.polygonTree.area;
  }

  /* -------------------------------------------- */

  /**
   * The tokens inside this region.
   * @type {ReadonlySet<TokenDocument>}
   * @readonly
   */
  tokens = new Set();

  /* -------------------------------------------- */

  /**
   * This makes sure that the second preparation during world initialization knows to add the region
   * to the attachments of the attached token.
   * It abuses the subclass property instantiation issue.
   * @ignore
   */
  attachment = game._documentsReady ? this.attachment : {...this.attachment, token: undefined};

  /* -------------------------------------------- */
  /*  Document Preparation                        */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    this.elevation.bottom ??= -Infinity;
    this.elevation.top ??= Infinity;
    const addToAttachments = this.attachment.token === undefined;
    this.attachment.token = this.parent?.tokens.get(this._source.attachment.token) ?? null;
    if ( addToAttachments ) this.attachment.token?.attachments.regions.add(this);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  clone(data, context) {
    const clone = super.clone(data, context);
    if ( (clone.parent === this.parent) && foundry.utils.equals(clone._source.shapes, this._source.shapes)
      && foundry.utils.equals(clone._source._shapeConstraints, this._source._shapeConstraints)) {
      clone.#polygonTree = this.#polygonTree;
      clone.#clipperPolyTree = this.#clipperPolyTree;
    }
    return clone;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Clamp the given elevation (of a token with a depth) to the elevation range of this Region.
   *
   * The elevation is clamped such that the head of the token is in the range if possible, but
   * the feet are never outside of the range.
   * @param {number} elevation    The elevation (of the token)
   * @param {number} [depth=0]    The depth of the token
   * @returns {number}            The clamped elevation
   */
  clampElevation(elevation, depth=0) {

    // Same code as in Level#clampElevation
    const depthDistance = depth * (this.parent?.grid ?? foundry.documents.BaseScene.defaultGrid).distance;
    return Math.clamp(
      Math.clamp(
        elevation + depthDistance,
        this.elevation.bottom,
        this.elevation.top
      ) - depthDistance,
      this.elevation.bottom,
      this.elevation.top
    );
  }

  /* -------------------------------------------- */
  /*  Shape Methods                               */
  /* -------------------------------------------- */

  /**
   * Test whether the given point is inside this Region.
   * @param {ElevatedPoint} point    The point.
   * @returns {boolean}              Is the point inside this Region?
   */
  testPoint(point) {
    return this.#testElevation(point.elevation) && this.polygonTree.testPoint(point);
  }

  /* -------------------------------------------- */

  /**
   * Test whether the given elevation is within the elevation range of this Region.
   * @param {number} elevation    The elevation.
   * @returns {boolean}           Is the elevation within the elevation range of this Region?
   */
  #testElevation(elevation) {
    return RegionDocument._testElevation(this.elevation, elevation);
  }

  /* -------------------------------------------- */

  /**
   * Test whether the given elevation is within the elevation range of this Region.
   * @param {{bottom: number; top: number; topInclusive: boolean}} elevationRange  The elevation range.
   * @param {number} elevation    The elevation.
   * @returns {boolean}           Is the elevation within the elevation range of this Region?
   * @internal
   */
  static _testElevation({bottom, top, topInclusive}, elevation) {
    if ( elevation < bottom ) return false;
    if ( elevation === bottom ) return true; // Flat regions
    if ( topInclusive ) return elevation <= top;
    return elevation < top;
  }

  /* -------------------------------------------- */

  /**
   * Create the Clipper polygon tree for this Region.
   * @param {BaseShapeData[]} shapes
   * @param {object[]|null} shapeConstraints
   * @returns {ClipperLib.PolyTree}
   * @internal
   */
  _createClipperPolyTree(shapes, shapeConstraints) {
    const i0 = shapes.findIndex(s => !s.hole);
    if ( i0 < 0 ) return new ClipperLib.PolyTree();
    if ( i0 === shapes.length - 1 ) {
      const shape = shapes[i0];
      if ( shape.hole ) return new ClipperLib.PolyTree();
      if ( !this.restriction.enabled ) return shape.clipperPolyTree;
      return this.#constrainShape(shape, shapeConstraints?.[i0], {polyTree: true});
    }
    const clipper = new ClipperLib.Clipper();
    const batches = this.#buildClipperBatches(shapes, shapeConstraints);
    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.
   * @param {BaseShapeData[]} shapes
   * @param {object[]|null} shapeConstraints
   * @returns {{paths: ClipperLib.IntPoint[][]; fillType: ClipperLib.PolyFillType; clipType: ClipperLib.ClipType}[]}
   */
  #buildClipperBatches(shapes, shapeConstraints) {
    const batches = [];
    let i = 0;

    // Skip over holes at the beginning
    while ( i < shapes.length ) {
      if ( !shapes[i].hole ) break;
      i++;
    }

    // Iterate the shapes and batch paths of consecutive (non-)hole shapes
    while ( i < shapes.length ) {
      const paths = [];
      const isHole = shapes[i].hole;

      // Add paths of the current shape and following shapes until the next shape is (not) a hole
      do {
        const shape = shapes[i];
        let clipperPaths;
        if ( !this.restriction.enabled ) clipperPaths = shape.clipperPaths;
        else clipperPaths = this.#constrainShape(shape, shapeConstraints?.[i]);
        for ( const path of clipperPaths ) paths.push(path);
        i++;
      } while ( (i < shapes.length) && (shapes[i].hole === 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;
  }

  /* -------------------------------------------- */

  /**
   * Compute the restricted shape as ClipperPoint[] or PolyTree.
   * @param {BaseShapeData} shape                 The shape.
   * @param {number[]|undefined} constraint       The points of the shape constraint.
   * @param {object} [options]                    Additional options.
   * @param {boolean} [options.polyTree=false]    Return a PolyTree instead of ClipperPoint[]?
   * @returns {ClipperPoint[]|ClipperLib.PolyTree}
   */
  #constrainShape(shape, constraint, {polyTree=false}={}) {
    const solution = polyTree ? new ClipperLib.PolyTree() : [];
    if ( !constraint?.length ) return solution;
    const constraintPolygon = new PIXI.Polygon(constraint);
    const constraintPath = constraintPolygon.toClipperPoints({scalingFactor: CLIPPER_SCALING_FACTOR});
    const clipper = new ClipperLib.Clipper();
    clipper.AddPaths(shape.clipperPaths, ClipperLib.PolyType.ptSubject, true);
    clipper.AddPath(constraintPath, ClipperLib.PolyType.ptClip, true);
    clipper.Execute(ClipperLib.ClipType.ctIntersection, solution);
    return solution;
  }

  /* -------------------------------------------- */

  /**
   * 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 with respect to the
   *                                                `tolerance`, the moved object is considered to be inside the region.
   * @param {number} [tolerance=0]                  The tolerance of point containment (see `distance` parameter
   *                                                of {@link foundry.data.PolygonTree#testPoint}).
   * @returns {RegionMovementSegment[]}             The movement split into its segments.
   */
  segmentizeMovementPath(waypoints, samples, tolerance=0) {
    if ( samples.length === 0 ) return [];
    if ( waypoints.length === 2 ) return this.#segmentizeMovement(waypoints[0], waypoints[1], samples, tolerance);
    const segments = [];
    for ( let i = 1; i < waypoints.length; i++ ) {
      for ( const segment of this.#segmentizeMovement(waypoints[i - 1], waypoints[i], samples, tolerance) ) {
        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.
   * @param {number} tolerance                                The tolerance.
   * @returns {RegionMovementSegment[]}                       The movement split into its segments.
   */
  #segmentizeMovement(origin, destination, samples, tolerance) {
    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, tolerance);
    }

    // Movement with no elevation change
    else if ( originElevation === destinationElevation ) {
      segments = this.#getNoElevationChangeSegments(originX, originY, destinationX,
        destinationY, destinationElevation, samples, tolerance);
    }

    // Movement with elevation change
    else {
      segments = this.#getElevationChangeSegments(originX, originY, originElevation,
        destinationX, destinationY, destinationElevation, samples, tolerance);
    }

    // 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.
   * @param {number} tolerance                The tolerance.
   * @returns {{type: REGION_MOVEMENT_SEGMENTS; from: ElevatedPoint; to: ElevatedPoint}[]}
   */
  #getTeleportationSegments(originX, originY, originElevation, destinationX, destinationY, destinationElevation,
    samples, tolerance) {
    const positionChanged = (originX !== destinationX) || (originY !== destinationY);
    const elevationChanged = originElevation !== destinationElevation;
    if ( !(positionChanged || elevationChanged) ) return [];
    let originInside = this.#testElevation(originElevation);
    let destinationInside = this.#testElevation(destinationElevation);
    if ( positionChanged ) {
      originInside &&= this.#testSamples(originX, originY, samples, tolerance);
      destinationInside &&= this.#testSamples(destinationX, destinationY, samples, tolerance);
    } else if ( originInside || destinationInside ) {
      const inside = this.#testSamples(originX, originY, samples, tolerance);
      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.
   * @param {number} tolerance                The tolerance.
   * @returns {{type: REGION_MOVEMENT_SEGMENTS; from: ElevatedPoint; to: ElevatedPoint}[]}
   */
  #getNoElevationChangeSegments(originX, originY, destinationX, destinationY, elevation, samples, tolerance) {
    if ( !this.#testElevation(elevation) ) return [];
    return this.#getMovementSegments(originX, originY, elevation, destinationX, destinationY, elevation, samples,
      tolerance);
  }

  /* -------------------------------------------- */

  /**
   * 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.
   * @param {number} tolerance                The tolerance.
   * @returns {{type: REGION_MOVEMENT_SEGMENTS; from: ElevatedPoint; to: ElevatedPoint}[]}
   */
  #getElevationChangeSegments(originX, originY, originElevation, destinationX, destinationY, destinationElevation,
    samples, tolerance) {

    // Calculate the first and last elevation within the elevation range of this Region
    const {bottom, top, topInclusive} = this.elevation;
    const upwards = originElevation < destinationElevation;
    const e1 = upwards ? Math.max(originElevation, bottom) : Math.min(originElevation, top);
    const e2 = upwards ? Math.min(destinationElevation, top) : Math.max(destinationElevation, 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));

    // Adjust elevations based on top inclusion
    let e1Adjusted = e1;
    let e2Adjusted = e2;
    if ( !topInclusive && (bottom < top) ) {
      if ( upwards ) {
        if ( e2 === top ) {
          e2Adjusted = Math.max(Math.nextDown(e2), originElevation);
          if ( e1 === e2 ) e1Adjusted = e2Adjusted;
        }
      } else {
        if ( e1 === top ) {
          e1Adjusted = Math.max(Math.nextDown(e1), destinationElevation);
          if ( e1 === e2 ) e2Adjusted = e1Adjusted;
        }
      }
    }

    // Get movements segments within the elevation range of this Region
    const segments = this.#getMovementSegments(x1, y1, e1Adjusted, x2, y2, e2Adjusted, samples, tolerance);

    // Add segment if we enter vertically
    if ( (originElevation !== e1Adjusted) && this.#testElevation(e1Adjusted)
      && this.#testSamples(x1, y1, samples, tolerance) ) {
      let fromElevation;
      if ( upwards ) fromElevation = Math.max(Math.nextDown(e1), originElevation);
      else if ( topInclusive ) fromElevation = Math.min(Math.nextUp(e1), originElevation);
      else fromElevation = e1;
      segments.unshift({
        type: REGION_MOVEMENT_SEGMENTS.ENTER,
        from: {x: x1, y: y1, elevation: fromElevation},
        to: {x: x1, y: y1, elevation: e1Adjusted}
      });
    }

    // Add segment if we exit vertically
    if ( (destinationElevation !== e2Adjusted) && this.#testElevation(e2Adjusted)
      && this.#testSamples(x2, y2, samples, tolerance) ) {
      let toElevation;
      if ( !upwards ) toElevation = Math.max(Math.nextDown(e2), destinationElevation);
      else if ( topInclusive ) toElevation = Math.min(Math.nextUp(e2), destinationElevation);
      else toElevation = e2;
      segments.push({
        type: REGION_MOVEMENT_SEGMENTS.EXIT,
        from: {x: x2, y: y2, elevation: e2Adjusted},
        to: {x: x2, y: y2, elevation: toElevation}
      });
    }

    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.
   * @param {number} tolerance     The tolerance.
   * @returns {boolean}            Is one of the samples contained within this Region?
   */
  #testSamples(x, y, samples, tolerance) {
    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, tolerance) ) 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, which must be in the elevation range.
   * @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, which must be in the elevation range.
   * @param {Point[]} samples              The samples relative to the position.
   * @param {number} tolerance             The tolerance.
   * @returns {{type: REGION_MOVEMENT_SEGMENTS; from: ElevatedPoint; to: ElevatedPoint}[]}
   */
  #getMovementSegments(originX, originY, originElevation, destinationX, destinationY, destinationElevation, samples,
    tolerance) {
    const segments = [];
    if ( (originX === destinationX) && (originY === destinationY) ) {

      // Add move segment if inside and the elevation changed
      if ( (originElevation !== destinationElevation) && this.#testSamples(originX, originY, samples, tolerance) ) {
        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, tolerance) ) {
      return segments;
    }

    // Compute the intervals
    const intervals = this.#computeSegmentIntervals(originX, originY, destinationX, destinationY, samples, tolerance);

    // 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, tolerance, true);
      const [{x: x10, y: y10, inside: inside10}, {x: x11, y: y11, inside: inside11}] = this.#findBoundaryCrossing(
        startX, startY, endX, endY, destinationX, destinationY, samples, tolerance, 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, tolerance);
    const destinationInside = this.#testSamples(destinationX, destinationY, samples, tolerance);

    // 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, tolerance, 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, tolerance, 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, tolerance, 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, tolerance, 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.
   * @param {number} tolerance         The tolerance.
   * @returns {boolean}                Could the movement intersect?
   */
  #couldMovementIntersect(originX, originY, destinationX, destinationY, samples, tolerance) {
    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.
   * @param {number} tolerance                    The tolerance.
   * @returns {{start: number; end: number}[]}    The intervals where we have an intersection.
   */
  #computeSegmentIntervals(originX, originY, destinationX, destinationY, samples, tolerance) {
    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 {number} tolerance   The tolerance.
   * @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, tolerance, enter) {
    if ( tolerance !== 0 ) {
      return [
        this.#findNextInteriorExteriorPosition(currentX, currentY, startX, startY, samples, tolerance, !enter),
        this.#findNextInteriorExteriorPosition(currentX, currentY, endX, endY, samples, tolerance, 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, 0);
    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, 0) !== 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 {number} tolerance   The tolerance.
   * @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, tolerance, inside) {
    if ( this.#testSamplesInteriorExterior(x0, y0, samples, tolerance, 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, tolerance, inside) ) return {x: x0, y: y0, inside};
    }
    return {x: x1, y: y1, inside: this.#testSamples(x1, y1, samples, tolerance)};
  }

  /* -------------------------------------------- */

  /**
   * 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 {number} tolerance     The tolerance.
   * @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, tolerance, inside) {
    const point = RegionDocument.#SHARED_POINT;
    const radius = Math.abs(tolerance);
    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;
      const result = this.#polygonTree.testCircle(point, radius);
      if ( result === 0 ) return false;
      if ( result === 1 ) return inside;
    }
    return !inside;
  }

  /* -------------------------------------------- */

  /**
   * Update the shape constraints of this Region. If `save` is true, the shape constraints are updated
   * only if the current User is designated for it.
   * @param {object} [options]                Additional options
   * @param {boolean} [options.save=false]    Persist the shape constraints changes? Default: `false`.
   */
  updateShapeConstraints({save=false}={}) {
    if ( save && !this.persisted ) throw new Error("A nonpersisted Document cannot be updated.");
    if ( !this._source.restriction.enabled && !this._source._shapeConstraints ) return;
    if ( save ) this.parent._updateRegionShapeConstraints(this);
    else this.updateSource({_shapeConstraints: this._computeShapeConstraints()});
  }

  /* -------------------------------------------- */

  /**
   * Update the point sources of this Region document.
   * @param {object} [changes]     The changes that will be applied to this Region.
   * @returns {number[][]|null}    The computed shape constraint for each shape, if restricted/possible.
   * @internal
   */
  _computeShapeConstraints(changes={}) {
    const enabled = changes.restriction?.enabled !== undefined ? changes.restriction.enabled
      : this.restriction.enabled;
    if ( !enabled ) return null;

    // Compute the point sources if possible, i.e. the region is viewed
    const levels = changes.levels !== undefined ? changes.levels : this._source.levels;
    if ( levels.length !== 1 ) return null;
    const level = this.parent.levels.get(levels[0]);
    if ( !level?.isView ) return null;

    const type = changes.restriction?.type !== undefined ? changes.restriction.type : this.restriction.type;
    const priority = changes.restriction?.priority !== undefined ? changes.restriction.priority
      : this.restriction.priority;
    const shapes = changes.shapes !== undefined
      ? changes.shapes.map(s => s instanceof BaseShapeData ? s : new BaseShapeData.TYPES[s.type](s, {parent: this}))
      : this.shapes;

    const constraints = shapes.map(_ => undefined);
    const toCompute = {};
    for ( const [i, shape] of shapes.entries() ) {
      const x = Math.round(shape.origin.x);
      const y = Math.round(shape.origin.y);
      const key = `${x},${y}`;
      if ( toCompute[key] ) {
        toCompute[key].boundingBox.enlarge(shape.bounds);
        toCompute[key].indices.push(i);
      } else {
        toCompute[key] = {origin: {x, y}, boundingBox: shape.bounds.clone(), indices: [i]};
      }
    }
    for ( const {origin, boundingBox, indices} of Object.values(toCompute) ) {
      const config = {
        type,
        level,
        priority,
        edgeTypes: {wall: true, outerBounds: true},
        boundingBox: boundingBox.fit(level.parent.dimensions.rect).ceil(),
        useThreshold: true
      };
      let suppressed = false;
      if ( level.isView ) {
        if ( ["light", "sight"].includes(type) ) {
          const condition = darknessSource => priority <= darknessSource.priority;
          suppressed = canvas.effects.testInsideDarkness(origin, {condition});
          config.edgeTypes.source = true;
        } else if ( type === "darkness" ) {
          const condition = lightSource => priority < lightSource.priority;
          suppressed = canvas.effects.testInsideLight(origin, {condition});
          config.edgeTypes.source = true;
        }
      }
      if ( suppressed ) config.radius = 0;
      const polygon = this._computeShapeConstraint(origin, config);
      for ( const i of indices ) constraints[i] = polygon.points;
    }
    return constraints;
  }

  /* -------------------------------------------- */

  /**
   * Compute the shape constraint for the given origin and config.
   * @param {ElevatedPoint} origin               The origin of the constraint.
   * @param {PointSourcePolygonConfig} config    The config of the constraint.
   * @returns {PointSourcePolygon}               The shape constraint.
   * @protected
   */
  _computeShapeConstraint(origin, config) {
    const polygonBackend = CONFIG.Canvas.polygonBackends[config.type];
    return polygonBackend.create(origin, config);
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static async _preCreateOperation(documents, operation, user) {
    const allowed = await super._preCreateOperation(documents, operation, user);
    if ( allowed === false ) return false;

    // Compute point sources if possible
    for ( const document of documents ) {
      document.updateSource({_shapeConstraints: document._computeShapeConstraints()});
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static async _preUpdateOperation(documents, operation, user) {
    const allowed = await super._preUpdateOperation(documents, operation, user);
    if ( allowed === false ) return false;

    // Compute point sources if possible
    for ( let i = 0; i < documents.length; i++ ) {
      const document = documents[i];
      const changes = operation.updates[i];
      if ( ("shapes" in changes) || ("elevation" in changes) || (("restriction" in changes) && (("enabled" in changes.restriction)
          || (document.restriction.enabled && (("type" in changes.restriction) || ("priority" in changes.restriction))))) ) {
        changes._shapeConstraints = document._computeShapeConstraints(changes);
        operation._updateShapeConstraints ??= {};
        operation._updateShapeConstraints[document.id] = true;
      }
      if ( "token" in DataFieldOperator.get(changes.attachment ?? {}) ) {
        operation._priorAttachedToken = {};
        operation._priorAttachedToken[document.id] = document._source.attachment.token;
      }
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _updateCommit(copy, diff, options, state) {
    super._updateCommit(copy, diff, options, state);
    if ( ("shapes" in diff) || ("_shapeConstraints" in diff) ) this._clearPolygonTree();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    this.attachment.token?.attachments.regions.add(this);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Update attached token
    if ( "token" in DataFieldOperator.get(changed.attachment ?? {}) ) {
      const priorAttachedToken = options._priorAttachedToken?.[this.id];
      if ( priorAttachedToken ) this.parent.tokens.get(priorAttachedToken)?.attachments.regions.delete(this);
      this.attachment.token?.attachments.regions.add(this);
    }

    // Close all open shape configs for this region if the shapes are changed
    if ( "shapes" in changed ) {
      this.#regionShapes = undefined;
      for ( const app of foundry.applications.apps.ShapeConfig.instances() ) {
        if ( app.document === this ) app.close();
      }

      // Clear the shape clipboard if a shape of this region was cut
      const shapeClipboard = this.layer?._shapeClipboard;
      if ( shapeClipboard && (shapeClipboard.shape?.parent === this) && shapeClipboard.cut ) {
        shapeClipboard.shape = null;
      }
    }

    // If the user that updated the shapes could not compute the constraints (indicated by the null),
    // the current user may be able to correct it if they view the scene and are designated for the region
    if ( this.restriction.enabled && (DataFieldOperator.get(changed._shapeConstraints) === null) ) {
      this.updateShapeConstraints({save: true});
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    this.attachment.token?.attachments.regions.delete(this);
  }

  /* -------------------------------------------- */

  /**
   * Called when the scene's grid is changed.
   * @param {object} changed   The changes to the grid.
   * @internal
   */
  _onGridChange(changed) {
    if ( !(("type" in changed) || ("size" in changed)) ) return;

    // The geometry needs to be recomputed if one shape is grid-based
    const affectedByGrid = this.shapes.some(shape => shape.isAffectedByGrid);
    if ( affectedByGrid ) this._clearPolygonTree();
  }

  /* -------------------------------------------- */

  /**
   * Clear the polygon tree.
   * @internal
   */
  _clearPolygonTree() {
    this.#polygonTree = undefined;
    this.#clipperPolyTree = undefined;
    this._onPolygonTreeChange();
  }

  /* -------------------------------------------- */

  /**
   * Called when the polygon tree of the Region has changed.
   * @protected
   */
  _onPolygonTreeChange() {}

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _refreshViewedState() {
    const wasViewed = this.rendered;
    await super._refreshViewedState();
    const isViewed = this.rendered;
    if ( isViewed === wasViewed ) return;
    if ( isViewed ) {
      // noinspection ES6MissingAwait
      this._handleEvent({
        name: CONST.REGION_EVENTS.BEHAVIOR_VIEWED,
        data: {},
        region: this,
        user: game.user
      });
      /** @deprecated since v13 */
      // noinspection ES6MissingAwait
      this._handleEvent({name: "behaviorStatus", data: {viewed: true}, region: this, user: game.user});
    } else {
      // noinspection ES6MissingAwait
      this._handleEvent({
        name: CONST.REGION_EVENTS.BEHAVIOR_UNVIEWED,
        data: {},
        region: this,
        user: game.user
      });
      /** @deprecated since v13 */
      // noinspection ES6MissingAwait
      this._handleEvent({name: "behaviorStatus", data: {viewed: false}, region: this, user: game.user});
    }
  }

  /* -------------------------------------------- */
  /*  API Methods                                 */
  /* -------------------------------------------- */

  /**
   * Create an emanation Region for the Token and attach it to the Token.
   * @param {TokenDocument} token                    The Token to attach the emanation Region to
   * @param {number} range                           The range of the emanation in grid units
   * @param {Omit<RegionData, "shapes"|"elevation">} regionData   The Region data of the emanation
   * @param {options} [options]                      Additional options
   * @param {boolean} [options.excludeToken=false]   Exclude the Token's own shape from the area of the emanation?
   * @param {boolean} [options.gridBased=false]      Should the emanation conform to the grid's metric?
   * @param {Partial<Omit<DatabaseCreateOperation, "parent">>} [options.createOptions]  Optional creation options
   * @returns {Promise<RegionDocument|void>}         The created Region document unless the creation was prevented
   */
  static async createTokenEmanation(token, range, regionData, {excludeToken=false, gridBased=false,
    createOptions={}}={}) {
    if ( !token.persisted ) throw new Error("The Token must be persisted.");
    const {x, y, elevation, width, height, depth, shape, level} = token._source;
    const {distance, distancePixels} = token.parent.dimensions;
    const radius = range * distancePixels;
    return this.create({
      ...regionData,
      shapes: [{
        type: "emanation",
        base: {type: "token", x, y, width, height, shape, hole: excludeToken},
        radius,
        gridBased
      }],
      elevation: {bottom: elevation - range, top: elevation + (depth * distance) + range},
      attachment: {token: token.id},
      levels: [level]
    }, {...createOptions, parent: token.parent, pack: token.pack});
  }

  /* -------------------------------------------- */

  /**
   * 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.
   *
   * This function can work with ephemeral (non-persisted) Region documents.
   * @param {TokenDocument} token              An existing Token Document to teleport.
   * @param {object} [options]                 Additional options.
   * @param {"random"|"center"|"relative"} [options.placement="random"]  The placement. Default: `"random"`.
   * @param {boolean} [options.snap=true]      Attempt to teleport the tokens to a snapped position. Default: `true`.
   * @param {Point} [options.offset]           The relative offset position.
   * @param {boolean} [options.avoidOccupied=true]  Avoid occupied grid spaces when placing randomly with snapping.
   *                                                Default: `true`.
   * @param {boolean|TokenPanningOptions} [options.pan=true]
   *   Pan the canvas (with transition animation) to the destination if the token is controlled? Default: `true`.
   * @param {Partial<TokenData>} [options.updateData]   Additonal Token update data.
   * @returns {Promise<TokenDocument>}         The same Token Document if teleported within the same Scene,
   *                                           or a new Token Document if teleported to a different Scene
   * @throws {Error}                           If failed to teleport the Token document.
   * @example Teleport a single token with random rotation
   * ```js
   * await region.teleportToken(token, {
   *   updateData: {
   *     rotation: Math.random() * 360
   *   }
   * });
   * ```
   */
  async teleportToken(token, options) {
    if ( options?.updateData ) options = {...options, updateData: new Map([[token, options.updateData]])};
    const tokenMap = await this.teleportTokens([token], options);
    return tokenMap.get(token) ?? token;
  }

  /* ---------------------------------------- */

  /**
   * Teleport Tokens into this Region.
   *
   * The Tokens may be in the same Scene as this Region, or in a different Scene.
   * The current User must be an owner of the Token Documents in order to teleport them.
   * For teleportation to a different Scene the current User requires `TOKEN_CREATE` and
   * `TOKEN_DELETE` permissions. If a Token is teleported to different Scene, it is deleted
   * and a new Token Document in the other Scene is created.
   *
   * This function can work ephemeral (non-persisted) Region documents.
   * @param {Iterable<TokenDocument>} tokens   Existing Token Documents to teleport.
   * @param {object} [options]                 Additional options.
   * @param {"random"|"center"|"relative"} [options.placement="random"]  The placement. Default: `"random"`.
   * @param {boolean} [options.snap=true]      Attempt to teleport the tokens to a snapped position. Default: `true`.
   * @param {Point} [options.offset]           The relative offset position.
   * @param {boolean} [options.avoidOccupied=true]  Avoid occupied grid spaces when placing randomly with snapping.
   *                                                Default: `true`.
   * @param {string} [options.level]                The destination Level ID, which must be a Level this Region is in.
   *                                                Default: the Level of the Region if it is in only one Level.
   * @param {boolean|TokenPanningOptions} [options.pan=true]
   *   Pan the canvas (with transition animation) to the destination if the token is controlled? Default: `true`.
   * @param {Map<TokenDocument, Partial<TokenData>>} [options.updateData]  Additonal update data.
   * @returns {Promise<Map<TokenDocument, TokenDocument>>}  The mapping of deleted to created Token Documents.
   * @throws {Error}                           If the current User doesn't have the necessary permissions, Token
   *                                           Documents could not be created/updated/deleted or there's no valid
   *                                           placement.
   * @example Teleport multiple tokens with random rotation
   * ```js
   * const updateData = new Map();
   * for ( const token of tokens ) {
   *   updateData.set(token, {
   *     rotation: Math.random() * 360
   *   });
   * }
   * await region.teleportTokens(tokens, {updateData});
   * ```
   */
  async teleportTokens(tokens, {placement="random", snap=true, offset, avoidOccupied=true, level, pan, updateData}={}) {
    if ( this.inCompendium ) throw new Error("The Region document must not be in a compendium.");
    const destinationScene = this.parent;
    if ( !Array.isArray(tokens) ) tokens = [...tokens];
    if ( tokens.length === 0 ) return [];

    // Get the destination tokens
    const destinationTokens = tokens.map(token => {
      if ( token.parent === destinationScene ) return token;
      return token.clone({}, {parent: destinationScene});
    });

    // Get a destination for each token
    const destinations = this.#getTokenDestinations(destinationTokens, {placement, snap, offset, avoidOccupied, level});

    // Collect taken token IDs
    const takenIds = new Set(destinationScene.tokens.keys());
    for ( const token of tokens ) takenIds.add(token.id);

    // Build update, creation, and deletion data
    const toUpdate = [];
    const movement = {};
    const toCreate = [];
    const toDelete = new Map();
    const newUuids = new Map();
    for ( const [i, originToken] of tokens.entries() ) {
      if ( originToken.inCompendium ) throw new Error("The Token document must not be in a compendium.");
      if ( !originToken.isOwner ) throw new Error("You must be an owner of the Tokens in order to teleport them.");
      const originScene = originToken.parent;
      if ( (originScene !== destinationScene) && !(game.user.can("TOKEN_CREATE") && game.user.can("TOKEN_DELETE")) ) {
        throw new Error("You must have TOKEN_CREATE and TOKEN_DELETE permissions to teleport a Token to a different Scene.");
      }

      // Get destination and destination token
      const destinationToken = destinationTokens[i];
      const destination = destinations[i];

      // Get additional changes
      const changes = updateData?.get(originToken);

      // If the origin and destination scene are the same, update the token
      if ( originToken === destinationToken ) {
        toUpdate.push({...changes, _id: originToken.id});
        movement[originToken.id] = {waypoints: [{...destination, action: "displace"}]};
        continue;
      }

      // Otherwise create a new token and delete the old one
      const data = foundry.utils.mergeObject(destinationToken.toObject(), {...changes, ...destination});
      let destinationId = originToken.id;
      if ( !destinationId || destinationScene.tokens.has(destinationId) ) {
        do {
          destinationId = foundry.utils.randomID();
        } while ( takenIds.has(destinationId) );
      }
      data._id = destinationId;
      toCreate.push(data);

      const destinationUuid = foundry.utils.buildUuid({id: destinationId, documentName: "Token", parent: destinationScene});
      newUuids.set(originToken, destinationUuid);

      // Delete old token
      let replacements = toDelete.get(originScene);
      if ( !replacements ) toDelete.set(originScene, replacements = {});
      replacements[originToken.id] = destinationUuid;
    }

    const operations = [];
    if ( toUpdate.length ) {
      operations.push({
        action: "update",
        documentName: "Token",
        updates: toUpdate,
        parent: destinationScene,
        movement,
        animation: {linkToMovement: true},
        pan
      });
    }
    if ( toCreate.length ) {
      operations.push({
        action: "create",
        documentName: "Token",
        parent: destinationScene,
        data: toCreate,
        keepId: true
      });
    }
    for ( const [originScene, replacements] of toDelete.entries() ) {
      operations.push({
        action: "delete",
        documentName: "Token",
        ids: Object.keys(replacements),
        parent: originScene,
        replacements
      });
    }
    if ( operations.length ) await foundry.documents.modifyBatch(operations);

    // Return the mapping of deleted to created tokens
    const tokenMap = new Map();
    for ( const [originToken, destinationUuid] of newUuids.entries() ) {
      const destinationToken = foundry.utils.fromUuidSync(destinationUuid);
      if ( destinationToken ) tokenMap.set(originToken, destinationToken);
    }
    return tokenMap;
  }

  /* -------------------------------------------- */

  /**
   * Spawn Tokens into this Region.
   *
   * The current User must be an owner of the Token Documents and have the `TOKEN_CREATE` permission
   * in order to spawn them.
   *
   * This function can work ephemeral (non-persisted) Region documents.
   * @param {Iterable<Partial<TokenData>|TokenDocument>} tokenData       The data of tokens or Token documents to spawn.
   * @param {object} [options]                                           Additional options.
   * @param {"random"|"center"|"relative"} [options.placement="random"]  The placement. Default: `"random"`.
   * @param {boolean} [options.snap=true]      Attempt to spawn the tokens to a snapped position. Default: `true`.
   * @param {Point} [options.offset]           The relative offset position. Default: `true`.
   * @param {boolean} [options.avoidOccupied=true]  Avoid occupied grid spaces when placing randomly with snapping.
   *                                                Default: `true`.
   * @param {string} [options.level]                The destination Level ID, which must be a Level this Region is in.
   *                                                Default: the Level of the Region if it is in only one Level.
   * @param {boolean} [options.create=true]         Persist the spawned tokens to the database (default true) or
   *                                                otherwise return an array of ephemeral TokenDocument instances.
   * @param {Partial<Omit<DatabaseCreateOperation, "parent">>} [options.createOptions]  Additional create options.
   * @returns {Promise<TokenDocument[]>}       The array of Token Documents that where created, which might be
   *                                           less than request the creation was disallowed by a preCreate handler
   * @throws {Error}                           If the current User doesn't have the necessary permissions, the Token
   *                                           Document could not be created or there's no valid placement.
   * @example Spawn 10 tokens with random actor and random rotation in a placed circle with 30 grid units radius.
   * ```js
   * ui.notifications.info("Choose the placement for the spawn area.");
   * const spawnArea = await canvas.regions.placeRegion({
   *   name: "Spawn Area",
   *   shapes: [{
   *     type: "circle",
   *     x: 0,
   *     y: 0,
   *     radius: canvas.dimensions.distancePixels * 30
   *   }],
   *   restriction: {enabled: true},
   *   levels: [canvas.level.id]
   * }, {create: false});
   * if ( spawnArea ) {
   *   const {count: numTokensToSpawn=0} = await foundry.applications.api.DialogV2.input({
   *    window: {
   *       title: "How many tokens to you want to spawn?"
   *    },
   *    content: `<input type="number" name="count" min="0" step="1" value="10">`
   *   }) ?? {};
   *   const actors = game.actors.contents;
   *   const tokensToSpawn = [];
   *   for ( let i = 0; i < numTokensToSpawn; i++ ) {
   *     const actor = actors[Math.floor(Math.random() * actors.length)];
   *     const token = await actor.getTokenDocument({
   *       rotation: Math.random() * 360
   *     }, {parent: spawnArea.parent});
   *     tokensToSpawn.push(token);
   *   }
   *   const spawnedTokens = await spawnArea.spawnTokens(tokensToSpawn);
   * }
   * ```
   */
  async spawnTokens(tokenData, {placement="random", snap=true, offset, avoidOccupied=true, level, create=true,
    createOptions}={}) {
    if ( this.inCompendium ) throw new Error("The Region document must not be in a compendium.");
    if ( !game.user.can("TOKEN_CREATE") ) throw new Error("You must have the TOKEN_CREATE permission to spawn a Token.");
    const scene = this.parent;
    const cls = TokenDocument.implementation;

    // Convert token data to token documents
    const tokens = Array.from(tokenData, data => {
      if ( data instanceof TokenDocument ) {
        if ( data.parent === scene ) return data;
        data = data.toObject();
      }
      else data = foundry.utils.deepClone(data);
      return new cls(data, {parent: scene});
    });
    if ( tokens.length === 0 ) return [];

    // Get a destination for each token
    const destinations = this.#getTokenDestinations(tokens, {placement, snap, offset, avoidOccupied, level});

    // Build creation data
    const toCreate = [];
    for ( const [index, token] of tokens.entries() ) {
      if ( !token.isOwner ) throw new Error("You must be an owner of the Tokens in order to spawn them.");
      toCreate.push(Object.assign(token.toObject(), destinations[index]));
    }

    // Return ephemeral tokens that are not persisted
    if ( !create ) return toCreate.map(data => cls.fromSource(data, {parent: scene}));

    // Create the tokens
    return scene.createEmbeddedDocuments("Token", toCreate, createOptions);
  }

  /* ---------------------------------------- */

  /**
   * Initialize the placement context.
   *
   * The tokens are grouped by dimensions. For each group we compute the number of occupied grid spaces and
   * the pivot of the shape. For each token we calculate the destination elevation. We sort the groups by
   * number of occupied grid spaces in descending order if the `avoidOccupied` option is true: this is the
   * order in which the tokens are attempted to be placed increasing the chance for the best random placement.
   * @param {TokenDocument[]} tokens           The tokens to place
   * @param {object} options                   Additional options
   * @param {"random"|"center"|"relative"} options.placement  The placement
   * @param {boolean} options.snap             Attempt to teleport the token to a snapped position
   * @param {Point} [options.offset]           The relative offset position
   * @param {boolean} [options.avoidOccupied]  Avoid occupied grid spaces when placing randomly with snapping
   * @param {string} options.level             The destination level
   * @returns {object}
   */
  #initializeTokenPlacementContext(tokens, {placement, snap, offset, avoidOccupied, level}) {
    if ( this.area === 0 ) throw new Error("Region is empty.");
    const scene = this.parent;
    if ( !level ) {
      if ( (this.levels.size >= 2) || (!this.levels.size && (scene.levels.size !== 1)) ) throw new Error("Region is in multiple levels.");
      level = this._source.levels.at(0) ?? scene.levels.contents[0].id;
    }
    else if ( !this.includedInLevel(level) ) throw new Error("Region is not in the destination level.");
    const grid = scene.grid;
    const context = {tokens: [], groups: [], placement, snap, offset, avoidOccupied, level};
    const tokenData = new Map();
    for ( const token of tokens ) {

      // Clamp the elevation of the token the elevation range of the destination region:
      // try to get the head of the token in if possible, but more importantly the feet of the token.
      const elevation = this.clampElevation(token._source.elevation, token._source.depth);
      tokenData.set(token, {index: tokenData.size, elevation});
    }

    // Group by snapped elevation/dimensions/shape
    for ( const groupTokens of Object.values(Object.groupBy(tokens, token => {
      let {width, height, depth, shape} = token._source;
      if ( grid.isGridless ) {
        if ( (shape === TOKEN_SHAPES.ELLIPSE_1) || (shape === TOKEN_SHAPES.ELLIPSE_2) ) shape = TOKEN_SHAPES.ELLIPSE_1;
        else shape = TOKEN_SHAPES.RECTANGLE_1;
      }
      if ( grid.isSquare ) shape = TOKEN_SHAPES.RECTANGLE_1;
      const {elevation} = tokenData.get(token);
      const snappedElevation = token.getSnappedPosition({x: 0, y: 0, elevation, width, height, depth, shape}).elevation;
      return `${snappedElevation},${width},${height},${depth},${shape}`;
    })) ) {
      const representative = groupTokens[0];

      // Compute the pivot for later use
      const {width, height, depth, shape} = representative._source;
      const pivot = representative.getCenterPoint({x: 0, y: 0, elevation: 0, width, height, shape});

      // Calculate the priority (only relevant to sort by size in descending order)
      let priority;
      if ( avoidOccupied ) {
        priority = Math.ceil(Math.max(Math.round(width * 2) / 2, 0.5))
          * Math.ceil(Math.max(Math.round(height * 2) / 2, 0.5))
          * Math.ceil(Math.max(Math.round(depth * 2) / 2, 0.5));
      }

      const group = {tokens: [], pivot, width, height, depth, shape, priority};
      context.groups.push(group);

      // Populate tokens of this group
      for ( const token of groupTokens ) {
        group.tokens.push({...tokenData.get(token), document: token, group, placements: []});
      }
    }

    // Sort by size in descending order to improve probability of a placement with the lowest score
    if ( avoidOccupied ) context.groups.sort((a, b) => b.priority - a.priority);

    // Populate the tokens
    for ( const group of context.groups ) {
      for ( const token of group.tokens ) context.tokens.push(token);
    }

    return context;
  }

  /* ---------------------------------------- */

  /**
   * Compute the areas of the triangulation.
   *
   * Used to calculate the center (centroid) placement and for random unsnapped placement.
   * @param {object} context    The placement context
   * @returns {{triangles: number[]; total: number}}
   */
  #computeTokenPlacementArea(context) {
    if ( context.area ) return context.area;
    const triangleAreas = [];
    let totalArea = 0;
    const {vertices, indices} = this.triangulation;
    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;
      triangleAreas.push(area);
      totalArea += area;
    }
    return context.area = {triangles: triangleAreas, total: totalArea};
  }

  /* ---------------------------------------- */

  /**
   * Compute the center (centroid) placement.
   * @param {object} context    The placement context
   * @returns {Point}
   */
  #computeCenterTokenPlacement(context) {
    if ( context.center ) return context.center;
    const {triangles: triangleAreas, total: totalArea} = this.#computeTokenPlacementArea(context);
    const center = {x: 0.0, y: 0.0};
    const {vertices, indices} = this.triangulation;
    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 cx = (x0 + x1 + x2) / 3;
      const cy = (y0 + y1 + y2) / 3;
      const area = triangleAreas[k / 3];
      center.x += (area * cx);
      center.y += (area * cy);
    }
    center.x /= totalArea;
    center.y /= totalArea;
    return context.center = center;
  }

  /* ---------------------------------------- */

  /**
   * Compute the relative placement given the offset.
   * @param {object} context    The placement context
   * @returns {Point}
   */
  #computeRelativeTokenPlacement(context) {
    if ( context.relative ) return context.relative;
    if ( !context.offset ) throw new Error("Relative placement requires an offset");
    return context.relative = {
      x: Math.mix(this.bounds.left, this.bounds.right, context.offset.x),
      y: Math.mix(this.bounds.top, this.bounds.bottom, context.offset.y)
    };
  }

  /* ---------------------------------------- */

  /**
   * Compute all possible snapped placements.
   * @param {object} context    The placement context
   */
  #computeSnappedTokenPlacements(context) {
    const scene = this.parent;
    const grid = scene.grid;
    if ( grid.isGridless ) return;
    if ( context.containedSpaces ) return;
    const {level} = context;

    // Iterate over possibly contained grid space offsets
    const containedSpaces = context.containedSpaces = new Map();
    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++ ) {
        const offset = {i, j};

        // Drop the token with its center point on the grid space center and snap the token position
        const center = grid.getCenterPoint(offset);

        // The grid space center must be inside the region to be a valid drop target
        if ( !this.polygonTree.testPoint(center, 0.75) ) continue;

        // Record contained offset
        containedSpaces.set(`${i},${j}`, {offset, center});
      }
    }

    // Iterate of each group and calculate the possible placements for each token in the group
    for ( const group of context.groups ) {

      // The snapped position, center point, and occupied offset are the same for each token in the group
      const {tokens, pivot, width, height, depth, shape} = group;
      const {document: representative} = tokens[0];
      const data = {x: 0, y: 0, elevation: representative._source.elevation, width, height, depth, shape, level};

      // Compute candidate placements
      const candidates = [];
      for ( const {center} of containedSpaces.values() ) {
        data.x = center.x - pivot.x;
        data.y = center.y - pivot.y;
        let {x, y} = representative.getSnappedPosition(data);
        data.x = x = Math.round(x);
        data.y = y = Math.round(y);

        // Compute the occupied offsets of the token at this position
        const occupiedOffsets = representative.getOccupiedGridSpaceOffsets(data);

        // Calculate how many occupied grid spaces are outside or would-be occupied if it wasn't for a wall/surface
        let inside = 0;
        for ( const {i, j, k} of occupiedOffsets ) {
          if ( ((k + 1) * grid.distance > this.elevation.bottom) && (k * grid.distance <= this.elevation.top)
            && containedSpaces.has(`${i},${j}`) ) inside++;
        }
        const outside = representative.getMaxOccupiedGridSpaceCount(data) - inside;

        candidates.push({x, y, occupiedOffsets, outside});
      }

      // Sort candidates by number of outside occupied spaces in ascending order
      candidates.sort((a, b) => a.outside - b.outside);

      // Iterate over all candidates and potentially add them as a possible placement for the tokens
      for ( const {x, y, occupiedOffsets, outside} of candidates ) {
        data.x = x;
        data.y = y;

        // Get the offset keys of the grid spaces that are occupied by the token at this position
        const occupied = new Set();
        for ( const {i, j, k} of occupiedOffsets ) occupied.add(`${i},${j},${k}`);

        // Add possible placements for each token at this position
        for ( const {document, elevation, placements} of tokens ) {
          data.elevation = elevation;

          // The token itself must be inside the region
          if ( !document.testInsideRegion(this, data) ) continue;

          // The token's movement origin must be inside the region
          if ( !this.polygonTree.testPoint(document.getMovementOrigin(data), -0.75) ) continue;

          // Record the possible placement for the token
          placements.push({position: {x, y, elevation, level}, occupied, outside});
        }
      }
    }
  }

  /* ---------------------------------------- */

  /**
   * Compute the placement bounds.
   *
   * Any other token in the scene out these bounds can be ignored for calculating the placement score.
   * @param {TokenDocument[]} tokens
   * @returns {PIXI.Rectangle}
   */
  #computeTokenPlacementBounds(tokens) {
    const scene = this.parent;
    let paddingLeft = 0;
    let paddingTop = 0;
    let maxWidth = 0;
    let maxHeight = 0;
    for ( const token of tokens ) {
      const {x, y} = token._source;
      const center = token.getCenterPoint(token._source);
      paddingLeft = Math.max(paddingLeft, center.x - x);
      paddingTop = Math.max(paddingTop, center.y - y);
      const {width, height} = token.getSize(token._source);
      maxWidth = Math.max(maxWidth, width);
      maxHeight = Math.max(maxHeight, height);
    }
    const placementBounds = new PIXI.Rectangle(
      0, 0, scene.dimensions.width, scene.dimensions.height).fit(this.bounds);
    placementBounds.x -= paddingLeft;
    placementBounds.y -= paddingTop;
    placementBounds.width += maxWidth;
    placementBounds.height += maxHeight;
    return placementBounds;
  }

  /**
   * Compute initially occupied offsets.
   * @param {TokenDocument[]} tokens
   * @returns {Record<string, number[]>}
   */
  #computeInitiallyOccupiedOffsetsByTokens(tokens) {
    const initiallyOccupied = {};
    const scene = this.parent;
    const placementBounds = this.#computeTokenPlacementBounds(tokens);
    const tokensToPlace = new Set(tokens);
    const tokenBounds = new PIXI.Rectangle();
    const {bottom, top, topInclusive} = this.elevation;
    let index = 0;
    for ( const token of scene.tokens ) {

      // Ignore tokens that are about to be placed
      if ( tokensToPlace.has(token) ) continue;

      // Ignore tokens outside of the elevation range
      const {elevation: feet, depth} = token._source;
      if ( topInclusive ) {
        if ( feet > top ) continue;
      } else {
        if ( (feet !== bottom) && (feet >= top) ) continue;
      }
      const head = feet + (scene.grid.distance * depth);
      if ( depth === 0 ) {
        if ( head < bottom ) continue;
      } else {
        if ( head <= bottom ) continue;
      }

      // Ignore tokens that could never overlap with placed tokens
      const {x, y} = token._source;
      tokenBounds.x = x;
      tokenBounds.y = y;
      const {width, height} = token.getSize(token._source);
      tokenBounds.width = width;
      tokenBounds.height = height;
      if ( !placementBounds.intersects(tokenBounds) ) continue;

      // Record occupied offsets
      const tokenOffsets = token.getOccupiedGridSpaceOffsets(token._source);
      for ( const {i, j, k} of tokenOffsets ) {
        const indices = initiallyOccupied[`${i},${j},${k}`] ??= [];
        indices.push(index);
      }

      index++;
    }

    return initiallyOccupied;
  }

  /* ---------------------------------------- */

  /**
   * Get destinations for the Tokens within this Region that places the Token and its center point inside it.
   * @param {TokenDocument[]} tokens           The tokens that are placed
   * @param {object} options                   Additional options
   * @param {"random"|"center"|"relative"} options.placement The placement
   * @param {boolean} options.snap             Attempt to teleport the token to a snapped position
   * @param {Point} [options.offset]           The relative offset position
   * @param {boolean} [options.avoidOccupied]  Avoid occupied grid spaces when placing randomly with snapping
   * @param {string} [options.level]           The destination level
   * @returns {TokenCoordinates[]}  The destinations
   * @throws {Error}                           If unable to find a placement for any token
   */
  #getTokenDestinations(tokens, options) {
    if ( tokens.length === 0 ) return [];
    const context = this.#initializeTokenPlacementContext(tokens, options);

    // If not random placement with snapping that avoids occupied grid spaces
    const {placement, snap, avoidOccupied} = context;
    if ( (placement !== "random") || !snap || !avoidOccupied || this.parent.grid.isGridless ) {
      const destinations = new Array(tokens.length);
      for ( const token of context.tokens ) destinations[token.index] = this.#getTokenDestination(context, token);
      return destinations;
    }

    // Otherwise try to find the best random placement with the least overlap
    const initiallyOccupied = this.#computeInitiallyOccupiedOffsetsByTokens(tokens);
    let bestScore = Infinity;
    let bestDestinations;
    let attempts = 100;
    while ( attempts ) {
      const occupiedOffsets = context.occupiedOffsets = {};
      for ( const [key, indices] of Object.entries(initiallyOccupied) ) occupiedOffsets[key] = indices.slice();

      // Calculate the score as the sum of the number grid space overlaps and
      // the number of occupied grid spaces outside of the spawn area
      let score = 0;

      // Randomize the position of all tokens and count occupied grid spaces
      const destinations = new Array(tokens.length);
      for ( const [index, token] of context.tokens.entries() ) {
        const destination = this.#getBestRandomSnappedTokenDestination(context, token);
        destinations[token.index] = destination.position;
        for ( const key of destination.occupied ) {
          const indices = occupiedOffsets[key] ??= [];
          indices.push(~index); // Negative indices for tokens that are to be placed
        }
        score += destination.score;
      }

      // If this positioning is better than the previously best one, update the best score and positions
      if ( bestScore > score ) {
        bestScore = score;
        bestDestinations = destinations;

        // The perfect score is zero
        if ( bestScore === 0 ) break;
      }

      attempts--;
    }

    return bestDestinations;
  }

  /* ---------------------------------------- */

  /**
   * Get a destination for the Token within this Region that places the Token and its center point inside it.
   * @param {object} context                                      The placement context
   * @param {object} token                                        The token context
   * @returns {TokenCoordinates}  The destination
   */
  #getTokenDestination(context, token) {
    switch ( context.placement ) {
      case "center": return this.#getCenterTokenDestination(context, token);
      case "relative": return this.#getRelativeTokenDestination(context, token);
      case "random": return this.#getRandomTokenDestination(context, token);
      default: throw new Error("Invalid placement method.");
    }
  }

  /* -------------------------------------------- */

  /**
   * Try to place the token at the center of the region.
   * @param {object} context    The placement context
   * @param {object} token      The token context
   * @returns {TokenCoordinates}
   */
  #getCenterTokenDestination(context, token) {
    const center = this.#computeCenterTokenPlacement(context);
    return this.#tryPlaceTokenAt(context, token, center);
  }

  /* -------------------------------------------- */

  /**
   * Try to place the token at the relative position based of the provided offset.
   * @param {object} context    The placement context
   * @param {object} token      The token context
   * @returns {TokenCoordinates}
   */
  #getRelativeTokenDestination(context, token) {
    const relative = this.#computeRelativeTokenPlacement(context);
    return this.#tryPlaceTokenAt(context, token, relative);
  }

  /* -------------------------------------------- */

  /**
   * Pick a destination randomly if possible.
   * @param {object} context    The placement context
   * @param {object} token      The token context
   * @returns {TokenCoordinates}
   */
  #getRandomTokenDestination(context, token) {
    if ( context.snap && !this.parent.grid.isGridless ) return this.#getRandomSnappedTokenDestination(context, token);
    return this.#getRandomUnsnappedTokenDestination(context, token);
  }

  /* -------------------------------------------- */

  /**
   * Pick a unsnapped destination randomly if possible.
   * @param {object} context    The placement context
   * @param {object} token      The token context
   * @returns {TokenCoordinates}
   */
  #getRandomUnsnappedTokenDestination(context, token) {
    const {document, group: {pivot, width, height, depth, shape}, elevation} = token;
    const {vertices, indices} = this.triangulation;
    const {level} = context;
    const {triangles: triangleAreas, total: totalArea} = this.#computeTokenPlacementArea(context);

    // 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 < triangleAreas.length - 1; j++ ) {
        a -= triangleAreas[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, depth, shape, level};

      // The token itself must be inside the region
      if ( !document.testInsideRegion(this, data) ) continue;

      // The token's movement origin must be inside the region
      if ( !this.polygonTree.testPoint(document.getMovementOrigin(data), -0.75) ) continue;

      return {x, y, elevation, level};
    }

    throw new Error("Failed to find placement.");
  }

  /* -------------------------------------------- */

  /**
   * Pick a snapped destination randomly if possible.
   * @param {object} context    The placement context
   * @param {object} token      The token context
   * @returns {TokenCoordinates}
   */
  #getRandomSnappedTokenDestination(context, token) {
    this.#computeSnappedTokenPlacements(context);

    // Pick randomly from the placements
    const {placements} = token;
    if ( placements.length ) {

      // Choose a position with the least grid spaces outside of the region
      const minOutside = placements[0].outside;
      let k;
      if ( placements.at(-1).outside === minOutside ) k = placements.length;
      else {
        k = 1;
        while ( placements.at(k)?.outside === minOutside ) k++;
      }
      return placements[Math.floor(k * Math.random())].position;
    }

    // If there are no snapped placements, fallback to an unsnapped one
    return this.#getRandomUnsnappedTokenDestination(context, token);
  }

  /* -------------------------------------------- */

  /**
   * Pick a snapped destination randomly with the lowest score if possible.
   * @param {object} context    The placement context
   * @param {object} token      The token context
   * @returns {{position: TokenCoordinates; occupied: Set<string>; score: number}}
   */
  #getBestRandomSnappedTokenDestination(context, token) {
    this.#computeSnappedTokenPlacements(context);

    // If there are no snapped placements, fallback to an unsnapped one
    let {placements} = token;
    if ( !placements.length ) {
      const grid = this.parent.grid;
      const position = this.#getRandomUnsnappedTokenDestination(context, token);
      const {document, group: {width, height, depth, shape}} = token;
      const data = {...position, width, height, depth, shape};
      const occupiedOffsets = document.getOccupiedGridSpaceOffsets(data);
      const occupied = new Set();
      let inside = 0;
      for ( const {i, j, k} of occupiedOffsets ) {
        occupied.add(`${i},${j},${k}`);
        if ( ((k + 1) * grid.distance > this.elevation.bottom) && (k * grid.distance <= this.elevation.top)
          && context.containedSpaces.has(`${i},${j}`) ) inside++;
      }
      const outside = document.getMaxOccupiedGridSpaceCount(data) - inside;
      placements = [{position, occupied, outside}];
    }

    // Find the placements with the lowest score
    const candidates = [];
    let minScore = Infinity;
    const {occupiedOffsets} = context;
    for ( const placement of placements ) {
      const overlaps = {};
      for ( const key of placement.occupied ) {
        const indices = occupiedOffsets[key];
        if ( !indices ) continue;
        for ( const index of indices ) overlaps[index] = (overlaps[index] ?? 0) + 1;
      }
      let score = placement.outside * placement.outside; // Outside score
      for ( const count of Object.values(overlaps) ) score += (count * count); // Overlapping score
      if ( minScore > score ) {
        minScore = score;
        candidates.length = 0;
      }
      if ( score === minScore ) candidates.push(placement);
    }

    // Pick randomly from the placements with the lowest score
    const {position, occupied} = candidates[Math.floor(candidates.length * Math.random())];
    return {position, occupied, score: minScore};
  }

  /* ---------------------------------------- */

  /**
   * Try to place the token centered on the given point if possible
   * @param {object} context                                      The placement context
   * @param {object} token                                        The token context
   * @param {ElevatedPoint} point                                 The point to place the token at
   * @returns {TokenCoordinates}  The position of the token placement
   */
  #tryPlaceTokenAt(context, token, point) {
    if ( this.polygonTree.testPoint(point, 0.75) ) {
      const {level, snap} = context;
      const {document, group: {pivot, width, height, depth, shape}, elevation} = token;

      // Place the token at on the center
      let position = {x: point.x - pivot.x, y: point.y - pivot.y, elevation: 0};
      if ( snap ) position = document.getSnappedPosition({...position, width, height, shape});
      const x = Math.round(position.x);
      const y = Math.round(position.y);
      const data = {x, y, elevation, width, height, depth, shape, level};

      // Check if the token and its movement origin is inside the region in the snapped position
      if ( document.testInsideRegion(this, data)
        && this.polygonTree.testPoint(document.getMovementOrigin(data), -0.75) ) {
        return {x, y, elevation, level};
      }

      // If the snapped position isn't inside the region, try the unsnapped position
      if ( snap ) {
        const x = Math.round(point.x - pivot.x);
        const y = Math.round(point.y - pivot.y);
        const data = {x, y, elevation, width, height, depth, shape, level};
        if ( document.testInsideRegion(this, data)
          && this.polygonTree.testPoint(document.getMovementOrigin(data), -0.75) ) {
          return {x, y, elevation, level};
        }
      }
    }

    // Otherwise pick any snapped position
    return this.#getRandomSnappedTokenDestination(context, token);
  }

  /* -------------------------------------------- */
  /*  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 {{[tokenId: string]: string[]}} [options.attachments] The IDs of regions that are attached to
   *  the tokens. If this is set, the region were updated as as part of a token movement.
   * @param {boolean} [options.deleted=false]    Are the Region documents deleted?
   */
  static async #updateTokens(regions, {attachments, 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);
        const ignoredRegionIds = new Set(attachments?.[token.id]);
        let regionsChanged = false;
        for ( const region of regions ) {
          if ( ignoredRegionIds.has(region.id) ) continue;
          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});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static async _onCreateOperation(documents, operation, user) {
    if ( game.user.isActiveGM ) {
      RegionDocument.#updateTokens(documents);
    }
    for ( const region of documents ) {
      region.attachment.token?.attachments.regions.add(region);
      const status = {active: true};
      region._handleEvent({name: REGION_EVENTS.BEHAVIOR_ACTIVATED, data: {}, region, user});
      if ( region.viewed ) {
        status.viewed = true;
        region._handleEvent({name: REGION_EVENTS.BEHAVIOR_VIEWED, data: {}, region, user});
      }
      /** @deprecated since v13 */
      region._handleEvent({name: "behaviorStatus", data: status, region, user});
    }

    return super._onCreateOperation(documents, operation, user);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static async _onUpdateOperation(documents, operation, user) {
    await super._onUpdateOperation(documents, operation, user);

    const regionsHiddenChanged = new Set();
    const regionsBoundaryChanged = [];
    for ( let i = 0; i < documents.length; i++ ) {
      const document = documents[i];
      const changed = operation.updates[i];
      if ( "hidden" in changed ) regionsHiddenChanged.add(document);
      if ( ("shapes" in changed) || ("elevation" in changed) || ("levels" in changed) || ("_shapeConstraints" in changed)
         || (("restriction" in changed) && (("enabled" in changed.restriction) || (document.restriction.enabled
          && (("type" in changed.restriction) || ("priority" in changed.restriction))))) ) {
        regionsBoundaryChanged.push(document);
      }
    }

    // Update tokens
    if ( game.user.isActiveGM ) {
      RegionDocument.#updateTokens(regionsBoundaryChanged, {attachments: operation._attachedRegions});
    }

    // Trigger status and enter/exit events
    for ( const region of regionsHiddenChanged ) {

      // Region became hidden
      if ( region.hidden ) {
        for ( const behavior of region.behaviors ) {
          if ( behavior.disabled ) continue;

          // Trigger exit events
          for ( const token of region.tokens ) {
            const deleted = !region.parent.tokens.has(token.id);
            if ( deleted ) continue;
            behavior._handleRegionEvent({
              name: REGION_EVENTS.TOKEN_EXIT,
              data: {token, movement: null},
              region,
              user
            });
          }

          // Trigger status events
          const status = {active: false};
          if ( region.viewed ) {
            status.viewed = false;
            behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_UNVIEWED, data: {}, region, user});
          }
          behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_DEACTIVATED, data: {}, region, user});
          /** @deprecated since v13 */
          behavior._handleRegionEvent({name: "behaviorStatus", data: status, region, user});
        }
      }

      // Region became unhidden
      else {

        // Trigger status events
        const status = {active: true};
        region._handleEvent({name: REGION_EVENTS.BEHAVIOR_ACTIVATED, data: {}, region, user});
        if ( region.viewed ) {
          status.viewed = true;
          region._handleEvent({name: REGION_EVENTS.BEHAVIOR_VIEWED, data: {}, region, user});
        }
        /** @deprecated since v13 */
        region._handleEvent({name: "behaviorStatus", data: status, region, user});

        // Trigger enter events
        for ( const token of region.tokens ) {
          const deleted = !region.parent.tokens.has(token.id);
          if ( deleted ) continue;
          region._handleEvent({
            name: REGION_EVENTS.TOKEN_ENTER,
            data: {token, movement: null},
            region,
            user
          });
        }
      }

    }

    // Trigger boundary events
    for ( const region of regionsBoundaryChanged ) {

      // Don't trigger boundary events for regions that become (un)hidden
      if ( regionsHiddenChanged.has(region) ) continue;

      region._handleEvent({
        name: REGION_EVENTS.REGION_BOUNDARY,
        data: {},
        region,
        user
      });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static async _onDeleteOperation(documents, operation, user) {
    if ( game.user.isActiveGM ) {
      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.viewed ) {
        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 ) {
      event.region._handleEvent(event);
    }
    return super._onDeleteOperation(documents, operation, user);
  }

  /* -------------------------------------------- */

  /**
   * 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) {
    if ( this.hidden ) return;
    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") || this.hidden ) 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.viewed ) {
        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") || this.hidden ) 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 ) {
          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
      if ( disabled ) {
        if ( this.viewed ) {
          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.viewed ) {
          behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_VIEWED, data: {}, region: this, user});
        }
      }
      const status = {active: !disabled};
      if ( this.viewed ) 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") || this.hidden ) 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.viewed ) {
        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});
    }
  }

  /* -------------------------------------------- */

  /**
   * Present a Dialog form to confirm the removal of a shape.
   * @param {BaseShapeData|number} shapeOrIndex  The shape or shape index.
   * @param {object} [options]                   Additional options passed to `DialogV2.confirm`
   * @returns {Promise<boolean>}
   */
  removeShapeDialog(shapeOrIndex, options={}) {
    let index;
    if ( !(shapeOrIndex instanceof BaseShapeData ) ) index = shapeOrIndex;
    else {
      const shape = shapeOrIndex;
      if ( shape.parent !== this ) throw new Error("The shape does not belong to this Region.");
      index = shape._index;
    }
    let shapes = this.shapes;
    if ( (index < 0) || (index >= shapes.length) ) throw new Error("The shape index is out of bounds.");
    const shape = shapes[index];
    const title = `${_loc("SHAPE.ACTIONS.remove")}: ${_loc(`SHAPE.TYPES.${shape.type}.name`)}`;
    return foundry.applications.api.DialogV2.confirm(foundry.utils.mergeObject({
      window: {title},
      content: `<p>${_loc("COMMON.AreYouSure")}</p>`,
      yes: {
        callback: () => {
          // Test that there haven't been any changes to the shapes since the dialog was opened
          if ( this.shapes !== shapes ) return false;
          shapes = [...shapes];
          shapes.splice(index, 1);
          this.update({shapes});
          return true;
        }
      }
    }, options));
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  get regionShapes() {
    foundry.utils.logCompatibilityWarning("RegionDocument#regionShapes is deprecated. Use RegionDocument#shapes instead.",
      {since: 14, until: 16, once: true});
    return this._regionShapes;
  }

  /** @ignore */
  get _regionShapes() {
    return this.#regionShapes ??= this.shapes.map(shape => foundry.data.regionShapes.RegionShape._create(shape));
  }

  #regionShapes;
}

/**
 * @import Scene from "./scene.mjs";
 * @import {RegionData} from "./_types.mjs";
 */

/**
 * @deprecated since v14
 * @ignore
 */
class BaseMeasuredTemplate extends Document {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static metadata = mergeObject(super.metadata, {
    name: "MeasuredTemplate",
    collection: "templates",
    label: "Measured Template",
    labelPlural: "Measured Templates",
    isEmbedded: true,
    permissions: {
      create: this.#canCreate,
      delete: "OWNER"
    },
    schemaVersion: "13.341"
  }, {inplace: false});

  /* ---------------------------------------- */

  /** @override */
  static get TYPES() {
    return this.metadata.coreTypes;
  }

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      _id: new DocumentIdField(),
      author: new DocumentAuthorField(BaseUser),
      t: new StringField({required: true, choices: Object.values(MEASURED_TEMPLATE_TYPES),
        initial: "circle",
        validationError: "must be a value in CONST.MEASURED_TEMPLATE_TYPES",
        label: "DOCUMENT.FIELDS.type.label"
      }),
      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,
        label: "MEASUREMENT.Distance"}),
      direction: new AngleField({label: "MEASUREMENT.Direction"}),
      angle: new AngleField({normalize: false, label: "MEASUREMENT.Angle"}),
      width: new NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01}),
      borderColor: new ColorField({nullable: false, initial: "#000000", label: "Border Color"}),
      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", "DRAWING"];

  /* ---------------------------------------- */

  /**
   * 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("REGION_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(source, options) {
    /** @deprecated since 12 */
    this._addDataFieldMigration(source, "user", "author");
    return super.migrateData(source, options);
  }

  /* ---------------------------------------- */

  /** @inheritDoc */
  static shimData(data, options) {
    /** @deprecated since 12 */
    this._addDataFieldShim(data, "user", "author", {since: 12, until: 16});
    return super.shimData(data, options);
  }

  /* ---------------------------------------- */

  /**
   * @deprecated since 12
   * @ignore
   */
  get user() {
    this.constructor._logDataFieldMigration("user", "author", {since: 12, until: 16});
    return this.author;
  }
}

/* ---------------------------------------- */

/**
 * @deprecated since v14
 * @ignore
 */
class MeasuredTemplateDocument extends CanvasDocumentMixin(BaseMeasuredTemplate) {
  constructor(...args) {
    const msg = "MeasuredTemplateDocument is deprecated because it has been merged into the functionality of the Region document.";
    foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
    super(...args);
  }

  /* -------------------------------------------- */

  /**
   * @param {RegionDocument} region
   * @returns {MeasuredTemplateDocument}
   * @internal
   */
  static _fromRegion(region) {
    const shape = region.shapes.at(0);
    let grid = this.parent?.grid ?? BaseScene.defaultGrid;
    if ( !shape?.gridBased && !grid.isGridless ) grid = new GridlessGrid({size: grid.size, distance: grid.distance});
    const distancePixels = grid.size / grid.distance;
    let t = "circle";
    let x = 0;
    let y = 0;
    let distance = 0;
    let direction = 0;
    let angle = 0;
    let width = 0;
    switch ( shape?.type ) {
      case "circle": {
        t = "circle";
        x = shape.x;
        y = shape.y;
        distance = shape.radius / distancePixels;
        direction = 0;
        break;
      }
      case "cone": {
        t = "cone";
        x = shape.x;
        y = shape.y;
        distance = shape.radius / distancePixels;
        direction = shape.rotation;
        angle = shape.angle;
        break;
      }
      case "rectangle": {
        t = "rect";
        x = shape.x;
        y = shape.y;
        distance = grid.measurePath([{x: 0, y: 0}, {x: shape.width, y: shape.height}]).distance;
        let width = grid.measurePath([{x: 0, y: 0}, {x: shape.width, y: 0}]).distance;
        let height = grid.measurePath([{x: 0, y: 0}, {x: 0, y: shape.height}]).distance;
        const rotation = shape.rotation.toNearest(90, "floor");
        if ( rotation === 90 ) [width, height] = [-height, width];
        else if ( rotation === 180 ) [width, height] = [-width, -height];
        else if ( rotation === 270 ) [width, height] = [height, -width];
        direction = Math.toDegrees(Math.atan2(height, width));
        break;
      }
      case "line": {
        t = "ray";
        x = shape.x;
        y = shape.y;
        distance = shape.length / distancePixels;
        direction = shape.rotation;
        width = shape.width;
        break;
      }
    }
    return this.implementation.fromSource({
      _id: region._id, author: Object.keys(region.ownership).filter(k => k !== "default")[0],
      t, x, y, elevation: region.elevation.bottom, sort: 0,
      distance, direction, angle, width, borderColor: "#000000",
      fillColor: region.color, texture: null,
      hidden: region.visibility !== REGION_VISIBILITY.ALWAYS,
      flags: deepClone(region.flags)
    }, {parent: region.parent});
  }

  /* -------------------------------------------- */

  /**
   * @param {object} data
   * @param {Scene} scene
   * @returns {RegionData}
   */
  static #migrateData(data, scene) {
    data = BaseRegion._migrateMeasuredTemplateData(data, {
      grid: scene?.grid ?? BaseScene.defaultGrid,
      gridTemplates: game.settings.get("core", "gridTemplates"),
      coneTemplateType: game.settings.get("core", "coneTemplateType"),
      users: game.users.contents
    });
    return data;
  }

  /* -------------------------------------------- */
  /*  Database Operations                         */
  /* -------------------------------------------- */

  /** @override */
  static async createDocuments(data=[], operation={}) {
    const scene = await this.database._getParent(operation);
    data = data.map(d => {
      d = this.migrateData(d);
      d = this.cleanData(d);
      d = MeasuredTemplateDocument.#migrateData(d, scene);
      setProperty(d, "flags.core.MeasuredTemplate", true);
      return d;
    });
    const created = await RegionDocument.implementation.createDocuments(data, operation);
    return created.map(region => this._fromRegion(region));
  }

  /* -------------------------------------------- */

  /** @override */
  static async updateDocuments(updates=[], operation={}) {
    const scene = await this.database._getParent(operation);
    updates = updates.reduce((updates, data) => {
      const region = scene?.regions.get(data._id);
      if ( region?.getFlag("core", "MeasuredTemplate") ) return updates;
      data = this.migrateData(data);
      data = this.cleanData(data, {partial: true});
      data = mergeObject(this._fromRegion(region)._source, data, {inplace: false});
      data = MeasuredTemplateDocument.#migrateData(data, scene);
      updates.push(data);
      return updates;
    }, []);
    const updated = await RegionDocument.implementation.updateDocuments(updates, operation);
    return updated.map(region => this._fromRegion(region));
  }

  /* -------------------------------------------- */

  /** @override */
  static async deleteDocuments(ids, operation) {
    const scene = await this.database._getParent(operation);
    const collection = scene.templates;
    if ( operation.deleteAll ) ids = Array.from(collection.index?.keys() ?? collection.keys());
    else ids = ids.filter(id => collection.has(id));
    const deleted = await RegionDocument.implementation.deleteDocuments(ids, operation);
    return deleted.map(region => this._fromRegion(region));
  }
}

/**
 * @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 if ( parent.isView ) {
            const paletteData = canvas.notes.paletteCreateData;
            foundry.utils.mergeObject(noteData, paletteData);
            noteData.entryId = canvas.scene.journal?.id ?? null;
            noteData.text = fd.name;
            noteData.sort = Math.max(canvas.notes.getMaxSort() + 1, 0);
            return canvas.notes._createPreview(noteData, {top: event.clientY - 20, left: event.clientX + 40});
          } else {
            noteData.entryId = parent.journal?.id ?? null;
            noteData.text = fd.name;
            return this.implementation.create(noteData, createOptions);
          }
        }
      },
      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                                  */
  /* -------------------------------------------- */

  /**
   * A Note is never hidden.
   * @readonly
   */
  hidden = false;

  /**
   * 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";
  }

  /* -------------------------------------------- */

  /**
   * Is the current User the author of this note?
   * @type {boolean}
   */
  get isAuthor() {
    return game.user === this.author;
  }

  /* -------------------------------------------- */
  /*  Model Methods                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    if ( this.parent ) {
      const d = this.parent.dimensions;
      this.x = Math.clamp(this.x, 0, d.width);
      this.y = Math.clamp(this.y, 0, d.height);
    }
  }
}

/**
 * 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) {

  /**
   * The rectangle shape of this Tile document.
   * @type {RectangleShapeData}
   */
  shape = this.shape; // Workaround for subclass property instantiation issue.

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    /** @deprecated since v14 */
    Object.defineProperty(this.occlusion, "mode", {
      get() {
        foundry.utils.logCompatibilityWarning("TileDocument#occlusion.mode is deprecated in favor of TileDocument#occlusion.modes.",
          {since: 14, until: 16, once: true});
        return this.modes.first() ?? CONST.OCCLUSION_MODES.NONE;
      }
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    if ( this.parent ) {
      const d = this.parent.dimensions;
      this.x = Math.clamp(this.x, 0, d.width);
      this.y = Math.clamp(this.y, 0, d.height);
    }
    this.shape = new RectangleShapeData({
      x: this.x,
      y: this.y,
      width: this.width,
      height: this.height,
      anchorX: this.texture.anchorX,
      anchorY: this.texture.anchorY,
      rotation: this.rotation
    }, {parent: this});
  }
}

/**
 * @import {WallCategory} from "./_types.mjs";
 * @import {EdgeRestrictionType, EdgeSenseType} from "@common/constants.mjs";
 * @import {WallData} from "@common/documents/_types.mjs";
 * @import Level from "./level.mjs";
 */

/**
 * 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) {

  /**
   * A Wall cannot be hidden in the same sense as other canvas Documents, though Walls in general (excluding non-secret
   * doors) are not visible to players.
   * @readonly
   */
  hidden = false;

  /**
   * A Wall cannot be locked.
   * @readonly
   */
  locked = false;

  /**
   * The Edge instance which represents this Wall.
   * The Edge is re-created when data for the Wall changes.
   * @type {Edge|null}
   */
  get edge() {
    return this.#edge;
  }

  /** @type {Edge|null} */
  #edge = null;

  /**
   * The darkness edge sense type, which is the same value as {@link WallData#light}.
   * @type {EdgeSenseType}
   */
  get darkness() {
    return this.light;
  }

  /**
   * Whether this Document represents a door.
   * @type {boolean}
   */
  get isDoor() {
    return this.door > CONST.WALL_DOOR_TYPES.NONE;
  }

  /**
   * Whether this Document represents an open door.
   * @type {boolean}
   */
  get isOpen() {
    return this.isDoor && (this.ds === CONST.WALL_DOOR_STATES.OPEN);
  }

  /* -------------------------------------------- */

  /** @override */
  prepareBaseData() {
    this.threshold.darkness = this.threshold.light;
  }

  /* -------------------------------------------- */

  /**
   * Broadly classify a wall into one of several categories, based on its properties.
   * @returns {WallCategory}
   */
  getWallCategory() {
    const {NONE, LIMITED, PROXIMITY, DISTANCE} = CONST.EDGE_SENSE_TYPES;
    const {DOOR, SECRET} = CONST.WALL_DOOR_TYPES;
    if ( this.sight === NONE ) {
      if ( this.light + this.sound + this.move === NONE ) return "blank";
      return "invisible";
    }
    if ( this.sight === LIMITED ) return "terrain";
    if ( (this.sight === DISTANCE) || (this.sight === PROXIMITY) ) return "window";
    if ( this.move === NONE ) return "ethereal";
    if ( this.door === DOOR ) return "door";
    if ( this.door === SECRET ) return "secret";
    return "normal";
  }

  /* -------------------------------------------- */

  /**
   * Initialize the edge which represents this Wall document.
   * @param {object} [options]                                 Options which modify how the edge is initialized
   * @param {boolean} [options.deleted]                        Delete the edge of this Wall document?
   */
  initializeEdge({deleted=false}={}) {
    if ( !this.parent?.initializedEdges || (deleted && !this.#edge) ) return;
    const priorEdge = this.#edge;
    const newEdge = this.#edge = deleted ? null : this.#createEdge();
    const addedLevels = [];
    const removedLevels = [];
    const changedLevels = [];
    for ( const level of this.parent.levels ) {
      const hadPriorEdge = (priorEdge !== null) && level.edges.delete(priorEdge.id);
      if ( newEdge && this.includedInLevel(level) ) {
        level.edges.set(newEdge.id, newEdge);
        if ( hadPriorEdge ) changedLevels.push(level);
        else addedLevels.push(level);
      }
      else if ( hadPriorEdge ) removedLevels.push(level);
    }
    if ( addedLevels.length ) {
      const changedTypes = new Set();
      for ( const type of CONST.EDGE_RESTRICTION_TYPES ) {
        if ( newEdge[type] ) changedTypes.add(type);
      }
      for ( const level of addedLevels ) this._onEdgeChange(level, newEdge, null, changedTypes);
    }
    if ( removedLevels.length ) {
      const changedTypes = new Set();
      for ( const type of CONST.EDGE_RESTRICTION_TYPES ) {
        if ( priorEdge[type] ) changedTypes.add(type);
      }
      for ( const level of removedLevels ) this._onEdgeChange(level, null, priorEdge, changedTypes);
    }
    if ( changedLevels.length ) {
      const changedTypes = new Set();
      const coordinatesChanged = !newEdge.a.equals(priorEdge.a) || !newEdge.b.equals(priorEdge.b)
        || (newEdge.direction !== priorEdge.direction);
      const attenuationChanged = !newEdge.threshold?.attenuation !== !priorEdge.threshold?.attenuation;
      for ( const type of CONST.EDGE_RESTRICTION_TYPES ) {
        if ( (newEdge[type] !== priorEdge[type]) || (newEdge[type] && coordinatesChanged)
         || ((newEdge[type] >= CONST.EDGE_SENSE_TYPES.PROXIMITY) && (attenuationChanged
           || (newEdge.threshold?.[type] ?? 0) !== (priorEdge.threshold?.[type] ?? 0))) ) {
          changedTypes.add(type);
        }
      }
      for ( const level of changedLevels ) this._onEdgeChange(level, newEdge, priorEdge, changedTypes);
    }
  }

  /* -------------------------------------------- */

  /**
   * Create an Edge from the Wall placeable.
   * @returns {Edge}
   */
  #createEdge() {
    const {c, threshold, dir: direction} = this;
    let {light, darkness, sight, sound, move} = this;
    if ( this.isOpen ) light = darkness = sight = sound = move = CONST.EDGE_SENSE_TYPES.NONE;
    const dpx = this.parent.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,
      light,
      darkness,
      sight,
      sound,
      move,
      threshold: {
        light: threshold.light * dpx,
        darkness: threshold.darkness * dpx,
        sight: threshold.sight * dpx,
        sound: threshold.sound * dpx,
        attenuation: threshold.attenuation
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to the edge of this Wall document in a given level.
   * @param {Level} level                                    The level the edge is changed in
   * @param {Edge|null} newEdge                              The new edge in the level, unless removed from level
   * @param {Edge|null} priorEdge                            The prior edge in the level, unless added to level
   * @param {ReadonlySet<EdgeRestrictionType>} changedTypes  The affected edge restriction types
   * @protected
   */
  _onEdgeChange(level, newEdge, priorEdge, changedTypes) {
    if ( !changedTypes.size ) return;

    // Re-initialize vision sources and re-calculate planned movement paths
    if ( level.isView ) {
      if ( changedTypes.has("sight") ) canvas.perception.update({initializeVision: true});
      if ( changedTypes.has("move") ) canvas.tokens.recalculatePlannedMovementPaths();
    }

    // Refresh visibility and re-initialize light, darkness, and sound sources
    if ( level.isVisible ) {
      canvas.perception.update({
        refreshVision: changedTypes.size !== 0,
        initializeLighting: changedTypes.has("light") || changedTypes.has("darkness"),
        initializeSounds: changedTypes.has("sound")
      });
    }

    // Update region shape constraints
    level.updateRegionShapeConstraints(changedTypes);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    this.initializeEdge();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( !this.parent.initializedEdges ) return;
    const edgeChanged = ["c", "levels", "light", "move", "sight", "sound", "dir", "door", "ds", "threshold"].some(k => k in changed);
    if ( edgeChanged ) this.initializeEdge();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    this.initializeEdge({deleted: true});
  }
}

/** @module documents */


/**
 * Bundle multiple {@link Document}-modification operations into a single, batched request. The modifications
 * are made in sequence without a network delay between each. This can be useful when, for example, it is desirable that
 * there not be an unpredictable delay between operations due to latency. For certain operations there may also be a
 * need to ensure there will never be a mixed state: either all must succeed, or all must fail.
 *
 * The nature of batched modifications does have some limitations:
 * - Unlike with a sequence of unbatched operations, a batched operation is unable to reference the result of a prior
 *   operation in the same batch.
 * - A cancellation (via, for example, {@link Document#_preUpdate}) or exception thrown by a single operation will
 *   cancel the entire batch. No changes will be made.
 *
 * @example Modify an Actor and two TokenDocuments
 * Update an Actor's size category along with the dimensions of that Actor's related TokenDocuments across multiple
 * Scenes.
 * ```js
 * foundry.documents.modifyBatch([
 *   {
 *     action: "update",
 *     documentName: "Actor",
 *     updates: [{_id: "Ay52yVxCgusBct1b", "system.size": "big"}],
 *   },
 *   {
 *     action: "update",
 *     documentName: "Token",
 *     updates: [{_id: "30pnHJciHu4CvPnz", width: 2, height: 2}],
 *     parent: sceneA
 *   },
 *   {
 *     action: "update",
 *     documentName: "Token",
 *     updates: [{_id: "knSU5NQVXeIQQeHi", width: 2, height: 2}],
 *     parent: sceneB
 *   }
 * ]);
 * ```
 * @param {DatabaseWriteOperation[]} operations
 * @returns {Promise<Document[][]>}
 */
function modifyBatch(operations) {
  return CONFIG.DatabaseBackend.modifyDocumentBatch(operations);
}

var _module$_ = /*#__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,
  BaseLevel: BaseLevel,
  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,
  Level: Level$1,
  Macro: Macro$1,
  MeasuredTemplateDocument: MeasuredTemplateDocument,
  NoteDocument: NoteDocument,
  Playlist: Playlist$1,
  PlaylistSound: PlaylistSound$1,
  RegionBehavior: RegionBehavior$1,
  RegionDocument: RegionDocument,
  RollTable: RollTable$1,
  Scene: Scene$2,
  Setting: Setting$1,
  TableResult: TableResult$1,
  TileDocument: TileDocument,
  TokenDocument: TokenDocument,
  User: User$1,
  WallDocument: WallDocument,
  abstract: _module$14,
  collections: _module$12,
  modifyBatch: modifyBatch,
  types: _types$l
});

/**
 * @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
});

/**
 * @import {Point, ElevatedPoint, TokenMovementWaypoint, TokenCreateTerrainMovementPathOptions} from "../../_types.mjs";
 * @import {RegionDocument, RegionBehavior, Scene} from "../../documents/_module.mjs";
 * @import {RegionEvent} from "@client/documents/_types.mjs";
 * @import {PointSourcePolygon} from "@client/canvas/geometry/_module.mjs";
 * @import {WallRestrictionType} from "@common/constants.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"|"level"|"action"> & {preview: boolean}} segment
   *                                 The segment data of the token's movement
   * @param {Omit<TokenCreateTerrainMovementPathOptions, "preview">} [options]    Additional options
   * @returns {TerrainEffect[]}      The terrain effects that apply to this token's movement
   * @protected
   */
  _getTerrainEffects(token, segment, options) {
    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 {

    /**
     * The default uniform values for the shader.
     * A subclass of BaseShaderMixin must implement the defaultUniforms static getter.
     * @type {object}
     */
    static get defaultUniforms() { return {}; }

    /* -------------------------------------------- */

    /**
     * Handle creation of the vertex shader string for this class.
     * If this method is not provided, PIXI will assign a default vertex for this shader.
     * @param {object} [options]   Configuration options passed at creation time.
     * @returns {string}
     * @protected
     */
    static _createVertexShader(options) { return this._vertexShaderCompatibility(options); }

    /* -------------------------------------------- */

    /**
     * Handle creation of the fragment shader string for this class.
     * If this method is not provided, PIXI will assign a default fragment for this shader.
     * @param {object} [options]   Configuration options passed at creation time.
     * @returns {string}
     * @protected
     */
    static _createFragmentShader(options) { return this._fragmentShaderCompatibility(options); }

    /* -------------------------------------------- */

    /**
     * A factory method for creating the shader using its defined default values.
     * @param {object} [uniforms]   An object of uniform values which override the class {@link defaultUniforms}.
     * @param {object} [options]    Optional configuration parameters which may influence shader creation or initialization.
     * @returns {BaseShader}
     * @abstract
     */
    static create(_uniforms, _options) {}

    /* -------------------------------------------- */

    /**
     * A one time initialization performed on creation.
     * Subclasses may override to perform custom configuration of uniforms or state.
     * @param {object} [options]   Configuration options provided at creation time.
     * @protected
     */
    _configure(_options) {}

    /* -------------------------------------------- */

    /**
     * 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
    `;

    /* -------------------------------------------- */
    /*  Deprecations and Compatibility              */
    /* -------------------------------------------- */

    /** @ignore */
    static _fragmentShaderCompatibility(options) {
      if ( "fragmentShader" in this ) {
        const msg = `${this.name}.fragmentShader getter is deprecated in favor of _createFragmentShader.`;
        foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
        const v = this.fragmentShader;
        return (typeof v === "function") ? v.call(this, options) : v;
      }
      return undefined;
    }

    /** @ignore */
    static _vertexShaderCompatibility(options) {
      if ( "vertexShader" in this ) {
        const msg = `${this.name}.vertexShader getter is deprecated in favor of _createVertexShader.`;
        foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
        const v = this.vertexShader;
        return (typeof v === "function") ? v.call(this, options) : v;
      }
      return undefined;
    }
  }
  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;
  }

  /* -------------------------------------------- */

  /** @override */
  static create(uniforms, options) {
    const vert = this._vertexShaderCompatibility(options) ?? this._createVertexShader(options);
    const frag = this._fragmentShaderCompatibility(options) ?? this._createFragmentShader(options);
    const program = PIXI.Program.from(vert, frag);
    const u = foundry.utils.mergeObject(this.defaultUniforms, uniforms, {inplace: false, insertKeys: false});
    const shader = new this(program, u);
    shader._configure(options);
    return shader;
  }

  /* -------------------------------------------- */

  /**
   * Reset the shader uniforms back to their initial values.
   */
  reset() {
    for ( const [k, v] of Object.entries(this.initialUniforms) ) {
      this.uniforms[k] = foundry.utils.deepClone(v);
    }
  }

  /* ---------------------------------------- */

  /**
   * 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) {}
}

/**
 * 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 _createVertexShader() {
    return `
    precision ${PIXI.Program.defaultVertexPrecision} 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 _createFragmentShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} float;

    uniform vec4 tintAlpha;

    void main() {
      gl_FragColor = tintAlpha;
    }
    `;
  }

  /* ---------------------------------------- */

  /** @override */
  static get defaultUniforms() {
    return {
      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 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.animationState.triangulation;
    this.buffers[0].update(triangulation.vertices);
    this.indexBuffer.update(triangulation.indices);
    this.#invalidBuffers = false;
  }
}

/**
 * @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) {
    if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) {
      throw new Error("RegionMesh shader class must inherit from AbstractBaseShader.");
    }
    super();
    this.#region = region;
    this.geometry = region.geometry;
    this.#shader = shaderClass.create();
  }

  /* ---------------------------------------- */

  /**
   * Shared point instance.
   * @type {PIXI.Point}
   */
  static #SHARED_POINT = new PIXI.Point();

  /* ---------------------------------------- */

  /**
   * Shared triangle polygon instance.
   * @type {PIXI.Polygon}
   */
  static #SHARED_TRIANGLE = new PIXI.Polygon([0, 0, 0, 0, 0, 0]);

  /* ---------------------------------------- */

  /**
   * The Region of this RegionMesh.
   * @type {Region}
   */
  get region() {
    return this.#region;
  }

  #region;

  /* ---------------------------------------- */

  /**
   * The geometry of this RegionMesh.
   * @type {RegionGeometry | PIXI.Geometry}
   */
  get geometry() {
    return this.#geometry;
  }

  set geometry(value) {
    if ( (value instanceof RegionGeometry) && (value.region !== this.region) ) {
      throw new Error("The RegionGeometry doesn't belong to the Region of this RegionMesh");
    }
    if ( this.#geometry === value ) return;
    if ( this.#geometry ) {
      this.#geometry.refCount--;
      if ( this.#geometry.refCount === 0 ) this.#geometry.dispose();
    }
    this.#geometry = value;
    if ( this.#geometry ) this.#geometry.refCount++;
    this.#vertexDirty = -1;
  }

  #geometry;

  #vertexDirty = -1;

  #transformID = -1;

  /* ---------------------------------------- */

  /**
   * 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.#geometry;
    if ( geometry instanceof RegionGeometry ) geometry._updateBuffers();
    renderer.geometry.bind(geometry, this.#shader);

    // Draw the geometry
    renderer.geometry.draw(PIXI.DRAW_MODES.TRIANGLES);
  }

  /* ---------------------------------------- */

  /** @override */
  _calculateBounds() {
    const geometry = this.#geometry;
    if ( geometry instanceof RegionGeometry ) {
      const {left, top, right, bottom} = this.region.animationState.bounds;
      this._bounds.addFrame(this.transform, left, top, right, bottom);
      return;
    }
    const verticesBuffer = geometry.getBuffer("aVertexPosition");
    const vertices = verticesBuffer.data;
    const vertexDirtyId = verticesBuffer._updateID;
    if ( (vertexDirtyId === this.#vertexDirty) && (this.#transformID === this.transform._worldID) ) return;
    this.#transformID = this.transform._worldID;
    this._bounds.addVerticesMatrix(this.transform.worldTransform, vertices, 0, vertices.length);
  }

  /* ---------------------------------------- */

  /**
   * Tests if a point is inside this RegionMesh.
   * @param {Point} point
   * @returns {boolean}
   */
  containsPoint(point) {
    const geometry = this.#geometry;
    if ( geometry instanceof RegionGeometry ) {
      return this.region.animationState.polygonTree.testPoint(
        this.worldTransform.applyInverse(point, RegionMesh.#SHARED_POINT));
    }
    if ( !this.getBounds().contains(point.x, point.y) ) return false;
    point = this.worldTransform.applyInverse(point, RegionMesh.#SHARED_POINT);
    const triangle = RegionMesh.#SHARED_TRIANGLE;
    const points = triangle.points;
    const vertices = this.geometry.getBuffer("aVertexPosition").data;
    const indices = this.geometry.getIndex().data;
    for ( let i = 0; i < indices.length; i += 3 ) {
      const j0 = indices[i] * 2;
      const j1 = indices[i + 1] * 2;
      const j2 = indices[i + 2] * 2;
      points[0] = vertices[j0];
      points[1] = vertices[j0 + 1];
      points[2] = vertices[j1];
      points[3] = vertices[j1 + 1];
      points[4] = vertices[j2];
      points[5] = vertices[j2 + 1];
      if ( triangle.contains(point.x, point.y) ) return true;
    }
    return false;
  }

  /* ---------------------------------------- */

  /** @override */
  destroy(options) {
    super.destroy(options);
    this.#geometry.refCount--;
    if ( this.#geometry.refCount === 0 ) this.#geometry.dispose();
    this.#geometry = null;
    this.#shader = null;
    this.#state = null;
  }
}

/**
 * @import {RegionBehaviorViewedEvent, RegionBehaviorUnviewedEvent,
 *   RegionRegionBoundaryEvent, RegionRegionAnimationEvent} 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 ) {
      if ( dlMesh._blurFilter ) canvas.blurFilters.delete(dlMesh._blurFilter);
      dlMesh.destroy();
    }
    const ilMesh = canvas.visibility.vision.light.global.meshes.getChildByName(this.behavior.uuid);
    if ( ilMesh ) ilMesh.destroy();
    canvas.effects.illumination.invalidateDarknessLevelContainer(true);
    canvas.perception.update({refreshLighting: true, refreshVision: canvas.environment.globalLightSource.active});
  }

  /* ---------------------------------------- */

  /**
   * Called when the boundary has changed or the region was animated.
   * @param {RegionRegionBoundaryEvent|RegionRegionAnimationEvent} event
   * @this {AdjustDarknessLevelRegionBehaviorType}
   */
  static async #onRegionBoundaryOrAnimation(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.#onRegionBoundaryOrAnimation,
    [REGION_EVENTS.REGION_ANIMATION]: this.#onRegionBoundaryOrAnimation
  };

  /* ---------------------------------------- */

  /** @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);
    if ( dlMesh ) {
      dlMesh.shader.mode = this.mode;
      dlMesh.shader.modifier = this.modifier;
    }
    const ilMesh = canvas.visibility.vision.light.global.meshes.getChildByName(this.behavior.uuid);
    if ( ilMesh ) {
      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 {ActiveEffectData, RegionTokenEnterEvent, RegionTokenExitEvent} from "@client/documents/_types.mjs";
 * @import {ActiveEffect, Actor} from "@client/documents/_module.mjs";
 */

/**
 * The data model for a behavior that applies Active Effects to Tokens within the Region.
 *
 * This is a behavior that applies the configured Active Effects to the Token's Actor when the Token enters the region.
 * Once the Token exists the Region, these Active Effects are removed from the Token's Actor.
 *
 * For example, this behavior could apply a Slowed effect in a blizzard region, or grant extra cover and concealment
 * in areas of tall grass.
 *
 * @property {Set<ActiveEffect>} effects    The Active Effects that are applied to Token within the Region.
 */
class ApplyActiveEffectRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.applyActiveEffect", "BEHAVIOR.TYPES.base"];

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      effects: new SetField(new DocumentUUIDField({type: "ActiveEffect", nullable: false}))
    };
  }

  /* ---------------------------------------- */

  /**
   * Apply the Active Effects when the Token enters the Region.
   * @param {RegionTokenEnterEvent} event
   * @this {ApplyActiveEffectRegionBehaviorType}
   */
  static async #onTokenEnter(event) {
    if ( !event.user.isSelf ) return;
    const {token, movement} = event.data;
    const actor = token.actor;
    if ( !actor ) return;
    const resumeMovement = movement ? token.pauseMovement() : undefined;
    const effects = await Promise.all(this.effects.map(fromUuid));
    const toCreate = this.#getEffectsToCreate(actor, effects);
    if ( toCreate.length ) await actor.createEmbeddedDocuments("ActiveEffect", toCreate);
    await resumeMovement?.();
  }

  /* ---------------------------------------- */

  /**
   * Un-apply the Active Effects when the Token exists the Region.
   * @param {RegionTokenExitEvent} event
   * @this {ApplyActiveEffectRegionBehaviorType}
   */
  static async #onTokenExit(event) {
    if ( !event.user.isSelf ) return;
    const {token, movement} = event.data;
    const actor = token.actor;
    if ( !actor ) return;
    const toDelete = this.#getEffectsToDelete(actor);
    if ( !toDelete.length ) return;
    const resumeMovement = movement ? token.pauseMovement() : undefined;
    await actor.deleteEmbeddedDocuments("ActiveEffect", toDelete);
    await resumeMovement?.();
  }

  /* ---------------------------------------- */

  /** @override */
  static events = {
    [REGION_EVENTS.TOKEN_ENTER]: this.#onTokenEnter,
    [REGION_EVENTS.TOKEN_EXIT]: this.#onTokenExit
  };

  /* ---------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( !this.behavior.active || !("system" in changed) ) return;
    this.#recreateEffectsForAllTokens();
  }

  /* ---------------------------------------- */

  /**
   * Recreate the effects of tokens within the region.
   * @returns {Promise<void>}
   */
  async #recreateEffectsForAllTokens() {
    const effects = await Promise.all(this.effects.map(fromUuid));
    const operations = [];
    for ( const token of this.region.tokens ) {
      const actor = token.actor;
      if ( !actor ) continue;
      const toDelete = this.#getEffectsToDelete(actor);
      if ( toDelete.length ) {
        operations.push({
          action: "delete",
          documentName: "ActiveEffect",
          ids: toDelete,
          parent: actor
        });
      }
      const toCreate = this.#getEffectsToCreate(actor, effects);
      if ( toCreate.length ) {
        operations.push({
          action: "create",
          documentName: "ActiveEffect",
          data: toCreate,
          parent: actor
        });
      }
    }
    await modifyBatch(operations);
  }

  /* ---------------------------------------- */

  /**
   * Get Active Effects that should be created for the Actor.
   * @param {Actor} actor             The actor the Active Effects should be created for.
   * @param {ActiveEffect[]} effects  The effects the Active Effects are created from.
   * @returns {ActiveEffectData[]}    The data of Active Effects that should be created.
   */
  #getEffectsToCreate(actor, effects) {
    const toCreate = [];
    for ( const effect of effects ) {
      const data = effect.toObject();
      delete data._id;
      if ( effect.compendium ) {
        data._stats.duplicateSource = null;
        data._stats.compendiumSource = effect.uuid;
      } else {
        data._stats.duplicateSource = effect.uuid;
        data._stats.compendiumSource = null;
      }
      data._stats.exportSource = null;
      data.origin = this.behavior.uuid;
      toCreate.push(data);
    }
    return toCreate;
  }

  /* ---------------------------------------- */

  /**
   * Get Active Effects that should be deleted from the Actor.
   * @param {Actor} actor  The actor the Active Effects should be deleted from.
   * @returns {string[]}   The IDs of Active Effects that should be deleted.
   */
  #getEffectsToDelete(actor) {
    return actor.effects.reduce((ids, effect) => {
      if ( effect.origin === this.behavior.uuid ) ids.push(effect.id);
      return ids;
    }, []);
  }
}

/**
 * @import {ElevatedPoint} from "../../_types.mjs";
 * @import TokenDocument from "@client/documents/token.mjs";
 * @import {RegionTokenExitEvent, RegionTokenMoveInEvent, TokenPosition} from "@client/documents/_types.mjs";
 * @import {Level, User} from "@client/documents/_module.mjs";
 * @import RegionDocument from "@client/documents/region.mjs";
 */

/**
 * The data model for a behavior that prompts to change the level of Tokens that enter the Region.
 */
class ChangeLevelRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.changeLevel", "BEHAVIOR.TYPES.base"];

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      movementActions: new SetField(new StringField({required: true, blank: false, nullable: false,
        initial: undefined, choices: () => {
          const actions = {...CONFIG.Token.movement.actions};
          delete actions.displace;
          return actions;
        }}))
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeSource(data, options) {
    if ( Array.isArray(data.movementActions) ) {
      for ( let i = data.movementActions.length - 1; i >= 0; i-- ) {
        const action = data.movementActions[i];
        if ( (typeof action === "string") && !(action in CONFIG.Token.movement.actions) ) {
          data.movementActions.splice(i, 1);
        }
      }
    }
    return super._initializeSource(data, options);
  }

  /* ---------------------------------------- */

  /**
   * Prompt to change the level of the Token if it moves into the Region.
   * @param {RegionTokenMoveInEvent} event
   * @this {ChangeLevelRegionBehaviorType}
   */
  static async #onTokenMoveIn(event) {
    const user = event.user;
    if ( !user.isSelf ) return;

    // Displacement does not trigger level change
    const movement = event.data.movement;
    if ( movement.passed.waypoints.at(-1).action === "displace" ) return;

    // If the region doesn't span multiple levels, there's nothing to do
    const token = event.data.token;
    const originLevelId = token._source.level;
    const destinationLevels = ChangeLevelRegionBehaviorType.#getDestinationLevels(this.region, token);
    if ( !destinationLevels.length ) return;

    // If the user cannot use any of the allowed movement actions, there's nothing to do
    const selectableActions = ChangeLevelRegionBehaviorType.#getSelectableMovementActions(token, this.movementActions);
    if ( !selectableActions.size ) return;

    // Pause movement while the user decides whether to change the level
    const resumeMovement = token.pauseMovement();

    // 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 ) {
      await game.raceWithWindowHidden(token.object.movementAnimationPromise);
    }

    // Confirm the level change
    let currentAction = movement.passed.waypoints.at(-1).action;
    const {level: destinationLevelId, action: selectedAction} = await this.#confirmDialog(destinationLevels, token,
      currentAction, selectableActions) ?? {};
    if ( !destinationLevelId || (destinationLevelId === originLevelId) ) {
      resumeMovement?.();
      return;
    }
    const destinationLevel = token.parent.levels.get(destinationLevelId);
    if ( !destinationLevel ) return;

    // Move the token to the destination level unless the token has exited in the meantime
    if ( !token.regions.has(this.region) ) return;
    let snap;
    if ( token.movement.id === movement.id ) {
      snap = movement.pending.waypoints.at(0)?.snapped ?? movement.passed.waypoints.at(-1).snapped;
    } else {
      currentAction = token.movementAction;
      const snapped = token.getSnappedPosition(token._source);
      snap = (token._source.x === Math.round(snapped.x)) && (token._source.y === Math.round(snapped.y))
        && token._source.elevation.almostEqual(snapped.elevation);
    }
    await this.#moveToken(token, destinationLevel, currentAction, selectedAction, snap);

    // The view isn't automatically changed for GM users
    if ( game.user.isGM && token.parent.isView && (canvas.level.id === originLevelId) ) {
      await token.parent.view({level: destinationLevelId, controlledTokens: [token.id]});
    }
  }

  /* ---------------------------------------- */

  /**
   * Disable the confirmation dialog.
   * @param {RegionTokenExitEvent} event
   * @this {ChangeLevelRegionBehaviorType}
   */
  static async #onTokenExit(event) {
    const {token} = event.data;
    const dialog = foundry.applications.instances.get(this.#getConfirmDialogId(token));
    if ( dialog ) this.#disableConfirmDialog(dialog, token);
  }

  /* ---------------------------------------- */

  /**
   * Move the token to the given.
   * @param {TokenDocument} token
   * @param {Level} destinationLevel
   * @param {string} currentAction
   * @param {string} selectedAction
   * @param {boolean} snap
   */
  async #moveToken(token, destinationLevel, currentAction, selectedAction, snap) {

    // First move the token such that its origin is inside the region
    const waypoints = [];
    const closestPosition = this.#getClosestOriginContainingPosition(token);
    if ( closestPosition ) waypoints.push({...closestPosition, action: currentAction});

    // Then snap the token if the original movement was snapped
    if ( snap && !token.parent.grid.isGridless ) {
      const snappedPosition = this.#getSnappedPosition(token, waypoints.at(-1) ?? token._source);
      if ( snappedPosition ) waypoints.push({...snappedPosition, action: currentAction});
    }

    // Try to move the token into position before changing the level.
    // Note that we might move the token out of the region here if this movement is not fully contained
    // within the region and is constrained outside of the region. This is probably unlikely to occur in
    // practice though.
    if ( waypoints.length ) {
      const movementId = foundry.utils.randomID();
      const originLevelId = token._source.level;
      await token.move(waypoints, {id: movementId});
      if ( (token.movement.id !== movementId) || (token._source.level !== originLevelId) ) return;
    }

    // Move the token to the destination level. Ignore surfaces.
    await token.move({elevation: destinationLevel.elevation.base, level: destinationLevel.id, action: selectedAction},
      {animate: false, constrainOptions: {ignoreWalls: true}, split: !!waypoints.length});
  }

  /* ---------------------------------------- */

  /**
   * Find the closest position, in which the token's origin is contained within the region.
   * @param {TokenDocument} token
   * @returns {TokenPosition|void}
   */
  #getClosestOriginContainingPosition(token) {
    const {x, y, elevation, width, height, depth, shape, level} = token._source;
    const center = token.getMovementOrigin(token._source);
    const closest = this.region.polygonTree.findClosestPoint(center);
    const x0 = Math.round(closest.x - (center.x - x));
    const y0 = Math.round(closest.y - (center.y - y));
    const position = {x: x0, y: y0, elevation, width, height, depth, shape, level};
    for ( const dx of [0, 1, -1] ) {
      position.x = x0 + dx;
      for ( const dy of [0, 1, -1] ) {
        position.y = y0 + dy;
        if ( token.testInsideRegion(this.region, position)
          && this.region.polygonTree.testPoint(token.getMovementOrigin(position), -0.75) ) {
          return position;
        }
      }
    }
  }

  /* ---------------------------------------- */

  /**
   * Get the snapped position that is contained within the region.
   * @param {TokenDocument} token
   * @param {TokenPosition} position
   * @returns {TokenPosition|void}
   */
  #getSnappedPosition(token, position) {
    const {x, y, elevation, width, height, depth, shape, level} = position;
    const snappedPosition = {x, y, elevation, width, height, depth, shape, level};
    for ( const dx of [0, 1, -1] ) {
      for ( const dy of [0, 1, -1] ) {
        const snapped = token.getSnappedPosition({x: x + dx, y: y + dy, width, height, shape});
        snappedPosition.x = Math.round(snapped.x);
        snappedPosition.y = Math.round(snapped.y);
        if ( token.testInsideRegion(this.region, snappedPosition)
          && this.region.polygonTree.testPoint(token.getMovementOrigin(snappedPosition), -0.75) ) {
          return snappedPosition;
        }
      }
    }
  }

  /* ---------------------------------------- */

  /** @override */
  static events = {
    [REGION_EVENTS.TOKEN_MOVE_IN]: this.#onTokenMoveIn,
    [REGION_EVENTS.TOKEN_EXIT]: this.#onTokenExit
  };

  /* -------------------------------------------- */

  /**
   * Get the possible destination levels.
   * @param {RegionDocument} region      The region.
   * @param {TokenDocument} token        The token that was moved.
   * @returns {Level[]}
   */
  static #getDestinationLevels(region, token) {
    let levels;
    if ( !region.levels.size ) {
      levels = region.parent.levels.reverseSorted.filter(l => l.id !== token._source.level);
    } else {
      levels = region._source.levels.reduce((arr, id) => {
        const level = region.parent.levels.get(id);
        if ( level && (id !== token._source.level) ) arr.push(level);
        return arr;
      }, []);
      levels.sort((a, b) => b.index - a.index);
    }
    return levels;
  }

  /* -------------------------------------------- */

  /**
   * Get the selectable movement actions.
   * @param {TokenDocument} token         The token that was moved.
   * @param {Set<string>} allowedActions  The allowed movement actions to change the level.
   * @returns {Set<string>}               The selectable movement actions.
   */
  static #getSelectableMovementActions(token, allowedActions) {
    const selectableActions = new Set();
    for ( const action of (allowedActions.size ? allowedActions
      : foundry.utils.objectKeys(CONFIG.Token.movement.actions)) ) {
      const config = CONFIG.Token.movement.actions[action];
      if ( (action !== token.movementAction) && !config.canSelect(token) ) continue;
      selectableActions.add(action);
    }
    return selectableActions;
  }

  /* -------------------------------------------- */

  /**
   * Display a dialog to confirm the level change.
   * @param {Level[]} levels                The destination levels.
   * @param {TokenDocument} token           The token that was moved.
   * @param {string} currentAction          The current movement action of the token.
   * @param {Set<string>} selectableActions The selectable movement actions to change the level.
   * @returns {Promise<{level: string; action: string}|null>}     The result of the dialog.
   */
  async #confirmDialog(levels, token, currentAction, selectableActions) {
    const questionElement = window.document.createElement("p");
    questionElement.textContent = levels.length !== 1 ? _loc("BEHAVIOR.TYPES.changeLevel.ConfirmSelect", {token: token.name})
      : _loc("BEHAVIOR.TYPES.changeLevel.Confirm", {token: token.name, level: levels[0].name});
    const levelSelect = foundry.applications.fields.createFormGroup({
      label: _loc("BEHAVIOR.TYPES.changeLevel.Level"),
      input: foundry.applications.fields.createSelectInput({
        name: "level",
        options: levels.map(level => ({value: level.id, label: level.name})),
        disabled: levels.length <= 1
      }),
      classes: levels.length === 1 ? ["hidden"] : []
    });
    const actionSelect = foundry.applications.fields.createFormGroup({
      label: _loc("BEHAVIOR.TYPES.changeLevel.MovementAction"),
      input: foundry.applications.fields.createSelectInput({
        name: "action",
        options: selectableActions.reduce((options, action) => {
          options.push({value: action, label: _loc(CONFIG.Token.movement.actions[action].label)});
          return options;
        }, []),
        value: [currentAction, token.movementAction].find(action => selectableActions.has(action))
          ?? selectableActions.first(),
        disabled: selectableActions.size <= 1
      })
    });
    const content = window.document.createElement("div");
    content.append(questionElement, levelSelect, actionSelect);
    const getConfirmLabel = destinationLevel => {
      const originLevel = token.parent.levels.get(token._source.level);
      if ( !originLevel ) return "BEHAVIOR.TYPES.changeLevel.Move";
      if ( originLevel.elevation.base > destinationLevel.elevation.base ) return "BEHAVIOR.TYPES.changeLevel.MoveDown";
      if ( originLevel.elevation.base < destinationLevel.elevation.base ) return "BEHAVIOR.TYPES.changeLevel.MoveUp";
      return "BEHAVIOR.TYPES.changeLevel.Move";
    };
    return DialogV2.confirm({
      id: this.#getConfirmDialogId(token),
      window: {
        icon: CONFIG.RegionBehavior.typeIcons.changeLevel,
        title: CONFIG.RegionBehavior.typeLabels.changeLevel
      },
      content,
      yes: {
        label: getConfirmLabel(levels[0]),
        callback: (event, button) => ({
          level: button.form.elements.level.value,
          action: button.form.elements.action.value
        })
      },
      no: {label: "BEHAVIOR.TYPES.changeLevel.DoNotMove"},
      render: (event, dialog) => {
        dialog.element.querySelector('[name="level"]').addEventListener("change", e => {
          const span = dialog.element.querySelector('[data-action="yes"] > span');
          span.textContent = _loc(getConfirmLabel(token.parent.levels.get(e.target.value)));
        });
        if ( !token.regions.has(this.region) ) this.#disableConfirmDialog(dialog, token);
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Get the confirmation dialog ID for this token.
   * @param {TokenDocument} token
   * @returns {string}
   */
  #getConfirmDialogId(token) {
    return `dialog-${this.behavior.uuid}-${token.id}`;
  }

  /* -------------------------------------------- */

  /**
   * Disable the confirmation dialog.
   * @param {DialogV2} dialog
   * @param {TokenDocument} token
   */
  #disableConfirmDialog(dialog, token) {
    const tooltip = _loc("BEHAVIOR.TYPES.changeLevel.ConfirmDisabled", {token: token.name});
    const yesButtonOptions = dialog.options.buttons.yes;
    yesButtonOptions.disabled = true;
    yesButtonOptions.tooltip = tooltip;
    if ( dialog.rendered ) {
      const yesButton = dialog.element.querySelector('button[data-action="yes"]');
      yesButton.disabled = true;
      yesButton.dataset.tooltip = "";
      yesButton.ariaLabel = tooltip;
    }
    else dialog.render();
  }
}

/**
 * @import {RegionBehaviorViewedEvent, RegionBehaviorUnviewedEvent, RegionRegionBoundaryEvent,
 *   RegionBehaviorActivatedEvent, RegionBehaviorDeactivatedEvent} from "@client/documents/_types.mjs";
 */

/**
 * The data model for a behavior that defines surface(s) that can restrict light, movement, sight, and sound.
 *
 * @property {"bottom"|"top"|"both"} surface  Is the surface at the bottom or top of the elevation range?
 *                                            Or are there surfaces at both the bottom and the top?
 * @property {boolean} light                  Does the surface restrict light?
 * @property {boolean} move                   Does the surface restrict movement?
 * @property {boolean} sight                  Does the surface restrict sight?
 * @property {boolean} sound                  Does the surface restrict sound?
 * @property {boolean} occlusion              Does the surface cause occlusion?
 * @property {boolean} exposure               Does the surface cause exposure?
 * @property {boolean} culling                Does the surface cause culling?
 */
class DefineSurfaceRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.defineSurface", "BEHAVIOR.TYPES.base"];

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      placement: new StringField({required: true, blank: false, initial: "bottom", choices: {
        bottom: "BEHAVIOR.TYPES.defineSurface.PLACEMENTS.bottom.label",
        top: "BEHAVIOR.TYPES.defineSurface.PLACEMENTS.top.label",
        both: "BEHAVIOR.TYPES.defineSurface.PLACEMENTS.both.label"
      }}),
      light: new BooleanField({initial: true}),
      move: new BooleanField({initial: true}),
      sight: new BooleanField({initial: true}),
      sound: new BooleanField({initial: true}),
      occlusion: new BooleanField({initial: true}),
      exposure: new BooleanField(),
      culling: new BooleanField()
    };
  }

  /* ---------------------------------------- */

  /**
   * Called when the behavior is activated.
   * @param {RegionBehaviorActivatedEvent} event
   * @this {DefineSurfaceRegionBehaviorType}
   */
  static async #onBehaviorActivated(event) {
    this.scene._invalidateSurfaces();
  }

  /* ---------------------------------------- */

  /**
   * Called when the behavior is deactivated.
   * @param {RegionBehaviorDeactivatedEvent} event
   * @this {DefineSurfaceRegionBehaviorType}
   */
  static async #onBehaviorDeactivated(event) {
    this.scene._invalidateSurfaces();
  }

  /* ---------------------------------------- */

  /**
   * Called when the behavior is viewed.
   * @param {RegionBehaviorViewedEvent} event
   * @this {DefineSurfaceRegionBehaviorType}
   */
  static async #onBehaviorViewed(event) {
    this.#refreshAll();
  }

  /* ---------------------------------------- */

  /**
   * Called when the behavior is unviewed.
   * @param {RegionBehaviorUnviewedEvent} event
   * @this {DefineSurfaceRegionBehaviorType}
   */
  static async #onBehaviorUnviewed(event) {
    this.#refreshAll();
  }

  /* ---------------------------------------- */

  /**
   * Called when the boundary of an event has changed.
   * @param {RegionRegionBoundryEvent} event
   * @this {DefineSurfaceRegionBehaviorType}
   */
  static async #onRegionBoundary(event) {
    this.scene._invalidateSurfaces();
    if ( this.behavior.viewed ) this.#refreshAll();
  }

  /* ---------------------------------------- */

  /** @override */
  static events = {
    [REGION_EVENTS.BEHAVIOR_ACTIVATED]: this.#onBehaviorActivated,
    [REGION_EVENTS.BEHAVIOR_DEACTIVATED]: this.#onBehaviorDeactivated,
    [REGION_EVENTS.BEHAVIOR_VIEWED]: this.#onBehaviorViewed,
    [REGION_EVENTS.BEHAVIOR_UNVIEWED]: this.#onBehaviorUnviewed,
    [REGION_EVENTS.REGION_BOUNDARY]: this.#onRegionBoundary
  };

  /* ---------------------------------------- */

  /**
   * Refresh perception, planned movement paths, and culling as necessary.
   */
  #refreshAll() {
    if ( !canvas.ready ) return;
    canvas.perception.update({
      initializeLighting: this.light && this.exposure,
      initializeVision: this.sight && this.exposure,
      refreshLighting: this.light,
      refreshVision: this.light || this.sight || this.sound || this.move,
      refreshSounds: this.sound,
      refreshOccludedSurfaces: this.occlusion
    });
    if ( this.occlusion ) MouseInteractionManager.emulateMoveEvent();
    if ( this.move ) canvas.tokens.recalculatePlannedMovementPaths();
    if ( this.culling ) this.#refreshCulling();
  }

  /* ---------------------------------------- */

  /**
   * Refresh culling of tokens.
   */
  #refreshCulling() {
    if ( this.scene.tokenVision && (!game.user.isGM
      || canvas.effects.visionSources.some(s => s.active)) ) return;
    for ( const token of canvas.tokens.placeables ) {
      if ( !token.controlled ) token.renderFlags.set({refreshVisibility: true});
    }
  }

  /* ---------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( !this.behavior.active || !("system" in changed) ) return;
    this.scene._invalidateSurfaces();
    if ( !this.behavior.viewed ) return;
    const placementChanged = "placement" in changed.system;
    const lightChanged = (placementChanged && this.light) || ("light" in changed.system);
    const moveChanged = (placementChanged && this.move) || ("move" in changed.system);
    const sightChanged = (placementChanged && this.sight) || ("sight" in changed.system);
    const soundChanged = (placementChanged && this.sound) || ("sound" in changed.system);
    const exposureChanged = (placementChanged && this.exposure) || ("exposure" in changed.system);
    const occlusionChanged = (placementChanged && this.occlusion) || ("occlusion" in changed.system);
    const cullingChanged = (placementChanged && this.culling) || ("culling" in changed.system);
    canvas.perception.update({
      initializeLighting: (lightChanged && this.exposure) || (this.light && exposureChanged)
        || (lightChanged && exposureChanged),
      initializeVision: (sightChanged && this.exposure) || (this.sight && exposureChanged)
        || (sightChanged && exposureChanged),
      refreshLighting: lightChanged,
      refreshVision: lightChanged || sightChanged || soundChanged || moveChanged,
      refreshSounds: soundChanged,
      refreshOccludedSurfaces: occlusionChanged
    });
    if ( occlusionChanged ) MouseInteractionManager.emulateMoveEvent();
    if ( moveChanged ) canvas.tokens.recalculatePlannedMovementPaths();
    if ( cullingChanged ) this.#refreshCulling();
  }

  /* ---------------------------------------- */

  /**
   * Restricts darkness? Darkness is restricted if and only if light is restricted.
   * @type {boolean}
   */
  get darkness() {
    return this.light;
  }
}

/**
 * @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) && this.parent.includedInLevel(u.viewedLevel)))) ) {

        // 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
        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, config] of Object.entries(CONFIG.Token.movement.actions) ) {
      const {label, terrainAction, deriveTerrainDifficulty} = config;
      if ( (terrainAction !== undefined) || (deriveTerrainDifficulty !== undefined) ) 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, options) {
    const difficulty = this.difficulties[segment.action];
    if ( difficulty === 1 ) return [];
    return [{name: "difficulty", difficulty}];
  }
}

/**
 * @import {RegionTokenMoveInEvent} 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 {RegionTokenMoveInEvent} 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 ) {
      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 {ElevatedPoint} from "../../_types.mjs";
 * @import TokenDocument from "@client/documents/token.mjs";
 * @import {RegionTokenMoveInEvent} from "@client/documents/_types.mjs";
 * @import {User} from "@client/documents/_module.mjs";
 */

/**
 * The data model for a behavior that teleports Token that enter the Region to a preset destination Region.
 *
 * @property {Set<RegionDocument>} destination         The destination(s) Region the Token is teleported to.
 * @property {"random"|"center"|"relative"} placement  The placement of the Token within the destination Region.
 * @property {boolean} snap                            Snap the Token destination position?
 * @property {boolean} choice                          Show teleportation confirmation dialog?
 * @property {boolean} revealed                        Are the destinations revealed?
 * @property {{revealed: string|null; unrevealed: string|null}} dialog  Custom teleporation dialogs
 * @property {{type: string|null; duration: number}} transition         Transition options
 */
class TeleportTokenRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.teleportToken", "BEHAVIOR.TYPES.base"];

  /* ---------------------------------------- */

  /** @override */
  static defineSchema() {
    const transitionType = new StringField({required: true, nullable: true, blank: false, initial: null});
    transitionType.toInput = function(config={}) {
      config.blank ??= _loc("COMMON.None");
      config.choices ??= Object.entries(CONFIG.Canvas.sceneTransitions).reduce((types, [key, value]) => {
        types[key] = _loc(value.label ?? `SCENE.Transition.Types.${key}`);
        return types;
      }, {});
      return Object.getPrototypeOf(this).toInput.call(this, config);
    };
    return {
      destinations: new SetField(new DocumentUUIDField({type: "Region", nullable: false, relative: true})),
      placement: new StringField({required: true, initial: "random", choices: {
        random: "BEHAVIOR.TYPES.teleportToken.PLACEMENTS.random.label",
        center: "BEHAVIOR.TYPES.teleportToken.PLACEMENTS.center.label",
        relative: "BEHAVIOR.TYPES.teleportToken.PLACEMENTS.relative.label"
      }}),
      snap: new BooleanField({initial: true}),
      choice: new BooleanField(),
      revealed: new BooleanField(),
      dialog: new SchemaField({
        revealed: new StringField({required: true, nullable: true, blank: false}),
        unrevealed: new StringField({required: true, nullable: true, blank: false})
      }),
      transition: new SchemaField({
        type: transitionType,
        duration: new NumberField({required: true, nullable: false, integer: true,
          initial: 1500, min: 500, max: 10000, step: 100})
      })
    };
  }

  /* ---------------------------------------- */

  /**
   * Teleport the Token if it moves into the Region.
   * @param {RegionTokenMoveInEvent} event
   * @this {TeleportTokenRegionBehaviorType}
   */
  static async #onTokenMoveIn(event) {
    const movement = event.data.movement;

    // Displacement does not trigger teleportation
    if ( movement.passed.waypoints.at(-1).action === "displace" ) return;

    // Get destination regions
    const destinations = [];
    for ( const regionUuid of this.destinations ) {
      const destination = fromUuidSync(regionUuid, {relative: this.parent});
      if ( !(destination instanceof RegionDocument) ) {
        console.error(`${regionUuid} does not exist`);
        continue;
      }
      destinations.push(destination);
    }
    if ( destinations.length === 0 ) return;

    const token = event.data.token;
    const user = event.user;
    if ( user.isSelf ) {
      if ( this.choice ) token.pauseMovement(this.parent.uuid);
      else token.stopMovement();
    }
    if ( !this.#shouldTeleport(token, destinations, user) ) return;
    const originLevel = token._source.level;

    // 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 ) {
      await game.raceWithWindowHidden(token.object.movementAnimationPromise);
    }

    // Confirm the destination if choice
    let result;
    if ( this.choice ) {
      if ( user.isSelf ) result = await this.#confirmDialog(destinations, token);
      else result = await user.query("confirmTeleportToken", {behaviorUuid: this.parent.uuid, tokenUuid: token.uuid});
      if ( !result ) {
        token.resumeMovement(movement.id, this.parent.uuid);
        return;
      }
    }

    // Select the destination
    let destination;
    if ( typeof result === "string" ) destination = destinations.find(d => d.uuid === result);
    else destination = destinations[Math.floor(Math.random() * destinations.length)];
    if ( !destination ) {
      if ( this.choice ) token.resumeMovement(movement.id, this.parent.uuid);
      return;
    }
    if ( !token.regions.has(this.region) ) return;

    // Calculate of offset for relative placement
    const center = token.getCenterPoint(event.data.movement.destination);
    const offset = {
      x: (center.x - this.region.bounds.left) / this.region.bounds.width,
      y: (center.y - this.region.bounds.top) / this.region.bounds.height
    };

    // Teleport token to destination
    const panOptions = this.transition.type ? {transitionType: this.transition.type, duration: this.transition.duration,
      force: true} : {duration: 0, force: true};
    const destinationToken = await destination.teleportToken(token, {placement: this.placement, snap: this.snap, offset,
      pan: panOptions});
    const destinationLevel = destinationToken._source.level;
    if ( (token.parent === destination.parent) && (originLevel === destinationLevel) ) return;

    // Only control the teleported token in the destination scene and override transition animation
    const viewOptions = {level: destinationLevel, controlledTokens: [destinationToken.id], transition: this.transition};

    // Pull the user that moved the token and all active non-GM users that don't own a token in this scene (anymore)
    // and observe the teleported token
    if ( game.user.isGM ) {
      if ( token.parent !== destination.parent ) {
        destination.parent.pullUsers(this.#getUsersToPull(token, user, game.user), viewOptions);
      } else if ( !user.isSelf && !token.hidden && (token.parent.id === user.viewedScene)
        && (originLevel === user.viewedLevel) ) {
        destination.parent.pullUsers([user], viewOptions);
      }
    }

    // If this user moved the token, view the destination level
    if ( user.isSelf && token.parent.isView && (canvas.level.id === originLevel) ) {
      await destination.parent.view(viewOptions);
    }
  }

  /* ---------------------------------------- */

  /** @override */
  static events = {
    [REGION_EVENTS.TOKEN_MOVE_IN]: this.#onTokenMoveIn
  };

  /* -------------------------------------------- */

  /** @override */
  _handleRegionEvent(event) {
    if ( event.name !== REGION_EVENTS.TOKEN_EXIT ) return;
    const {token} = event.data;
    const dialog = foundry.applications.instances.get(this.#getConfirmDialogId(token));
    if ( dialog ) this.#disableConfirmDialog(dialog, token);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    if ( this.choice ) this.events.add(REGION_EVENTS.TOKEN_EXIT);
  }

  /* ---------------------------------------- */

  /**
   * Should the current user teleport the token?
   * @param {TokenDocument} token           The token that is teleported.
   * @param {RegionDocument[]} destinations The destination regions.
   * @param {User} user                     The user that moved the token.
   * @returns {boolean}                     Should the current user teleport the token?
   */
  #shouldTeleport(token, destinations, user) {
    const userCanTeleport = user.isGM || destinations.every(d => token.parent === d.parent)
      || (user.can("TOKEN_CREATE") && user.can("TOKEN_DELETE") && (!game.users.activeGM || !this.#getUsersToPull(token, user, user).length));
    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")));
  }

  /* -------------------------------------------- */

  /**
   * Get the Users that need to be pulled to the destination Scene.
   * @param {TokenDocument} token      The token that is teleported.
   * @param {User} user                The user that moved the token into the teleporter.
   * @param {User} designatedUser      The user designated to teleport the token.
   * @returns {User[]}
   */
  #getUsersToPull(token, user, designatedUser) {
    if ( token.hidden ) return [];
    const scene = token.parent;
    const toPull = [];
    for ( const u of game.users ) {

      // Don't pull inactive users, the designated user, and GMs
      if ( !u.active || (u === designatedUser) || u.isGM ) continue;

      // Don't pull users that don't view the level the token is in
      if ( (scene.id !== u.viewedScene) || (token._source.level !== u.viewedLevel) ) continue;

      // The user that moved the token is always pulled if they view the level the token is in
      if ( u !== user ) {

        // Don't pull users that don't observe the token
        if ( !token.testUserPermission(u, "OBSERVER") ) continue;

        // If the user still owns a nonhidden token with vision in the scene, don't pull them
        if ( scene.tokens.some(t => (t !== token) && !t.hidden && t.testUserPermission(u, "OWNER")
          && (t._source.level === token._source.level) && (!scene.tokenVision || t.sight.enabled)) ) continue;
      }

      toPull.push(u);
    }
    return toPull;
  }

  /* -------------------------------------------- */

  /**
   * The query handler for teleporation confirmation.
   * @type {(queryData: {behaviorUuid: string; tokenUuid: string}) => Promise<boolean>}
   * @internal
   */
  static _confirmQuery = async ({behaviorUuid, tokenUuid}) => {
    const behavior = await fromUuid(behaviorUuid);
    if ( !behavior || (behavior.type !== "teleportToken") || !behavior.system.destinations.size ) return false;
    const destinations = Array.from(behavior.system.destinations, fromUuidSync)
      .filter(d => d instanceof RegionDocument);
    if ( destinations.length === 0 ) return false;
    const token = await fromUuid(tokenUuid);
    if ( !token ) return false;
    return behavior.system.#confirmDialog(destinations, token);
  };

  /* -------------------------------------------- */

  /**
   * Display a dialog to confirm the teleportation?
   * @param {RegionDocument} destinations   The destination regions.
   * @param {TokenDocument} token           The token that is teleported.
   * @returns {Promise<boolean|string>}     The result of the dialog.
   */
  async #confirmDialog(destinations, token) {
    const config = {
      id: this.#getConfirmDialogId(token),
      window: {
        icon: CONFIG.RegionBehavior.typeIcons.teleportToken,
        title: CONFIG.RegionBehavior.typeLabels.teleportToken
      },
      yes: {label: "BEHAVIOR.TYPES.teleportToken.Teleport"},
      no: {label: "BEHAVIOR.TYPES.teleportToken.DoNotTeleport"},
      render: (event, dialog) => {
        if ( !token.regions.has(this.region) ) this.#disableConfirmDialog(dialog, token);
      }
    };
    if ( !this.revealed && !game.user.isGM ) {
      const text = this.dialog.unrevealed ? foundry.utils.escapeHTML(this.dialog.unrevealed) : "BEHAVIOR.TYPES.teleportToken.Confirm";
      const textReplacements = {token: foundry.utils.escapeHTML(token.name)};
      config.content = `<p>${_loc(text, textReplacements)}</p>`;
    } else if ( destinations.length === 1 ) {
      const destination = destinations[0];
      const text = this.dialog.revealed ? foundry.utils.escapeHTML(this.dialog.revealed) : "BEHAVIOR.TYPES.teleportToken.ConfirmRevealed";
      const textReplacements = {
        token: foundry.utils.escapeHTML(token.name),
        region: foundry.utils.escapeHTML(destination.name),
        scene: foundry.utils.escapeHTML(destination.parent.navName || destination.parent.name)
      };
      config.content = `<p>${_loc(text, textReplacements)}</p>`;
    } else {
      const scene = destinations[0].parent;
      const allInSameScene = destinations.every(d => d.parent === scene);
      const options = destinations.map(d => ({value: d.uuid, label: allInSameScene ? d.name : _loc("BEHAVIOR.TYPES.teleportToken.ConfirmSelectOption", {
        region: foundry.utils.escapeHTML(d.name),
        scene: foundry.utils.escapeHTML(d.parent.navName || d.parent.name)
      })}))
        .sort((a, b) => a.label.localeCompare(b.label));
      const text = this.dialog.revealed ? foundry.utils.escapeHTML(this.dialog.revealed) : `BEHAVIOR.TYPES.teleportToken.ConfirmSelect${allInSameScene ? "Same" : "Different"}Scene`;
      const textReplacements = {token: foundry.utils.escapeHTML(token.name)};
      if ( allInSameScene ) textReplacements.scene = foundry.utils.escapeHTML(scene.navName || scene.name);
      config.content = `
        <p>${_loc(text, textReplacements)}</p>
        <select name="destination">
          ${options.map(({value, label}) => `<option value="${value}">${label}</option>`).join("")}
        </select>
      `;
      config.yes.callback = (event, button) => button.form.elements.destination.value;
    }
    return DialogV2.confirm(config);
  }

  /* -------------------------------------------- */

  /**
   * Get the confirmation dialog ID for this token.
   * @param {TokenDocument} token
   * @returns {string}
   */
  #getConfirmDialogId(token) {
    return `dialog-${this.behavior.uuid}-${token.id}`;
  }

  /* -------------------------------------------- */

  /**
   * Disable the confirmation dialog.
   * @param {DialogV2} dialog
   * @param {TokenDocument} token
   */
  #disableConfirmDialog(dialog, token) {
    const tooltip = _loc("BEHAVIOR.TYPES.teleportToken.ConfirmDisabled", {token: token.name});
    const yesButtonOptions = dialog.options.buttons.yes;
    yesButtonOptions.disabled = true;
    yesButtonOptions.tooltip = tooltip;
    if ( dialog.rendered ) {
      const yesButton = dialog.element.querySelector('button[data-action="yes"]');
      yesButton.disabled = true;
      yesButton.dataset.tooltip = "";
      yesButton.ariaLabel = tooltip;
    }
    else dialog.render();
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(data, options) {
    /** @deprecated since v14 */
    if ( !("destinations" in data) ) {
      if ( data.destination === null ) data.destinations = [];
      else if ( typeof data.destination === "string" ) data.destinations = [data.destination];
      delete data.destination;
    }
    return super.migrateData(data, options);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  get destination() {
    foundry.utils.logCompatibilityWarning("You are accessing TeleportTokenRegionBehaviorType#destination which has been"
      + " migrated to TeleportTokenRegionBehaviorType#destinations", {since: 14, until: 16, once: true});
    return this.destinations.first() ?? null;
  }
}

/**
 * @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", nullable: false, relative: true})),
      disable: new SetField(new DocumentUUIDField({type: "RegionBehavior", nullable: false, relative: true}))
    };
  }

  /* -------------------------------------------- */

  /** @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, {relative: this.parent});
        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$Z = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AdjustDarknessLevelRegionBehaviorType: AdjustDarknessLevelRegionBehaviorType,
  ApplyActiveEffectRegionBehaviorType: ApplyActiveEffectRegionBehaviorType,
  ChangeLevelRegionBehaviorType: ChangeLevelRegionBehaviorType,
  DefineSurfaceRegionBehaviorType: DefineSurfaceRegionBehaviorType,
  DisplayScrollingTextRegionBehaviorType: DisplayScrollingTextRegionBehaviorType,
  ExecuteMacroRegionBehaviorType: ExecuteMacroRegionBehaviorType,
  ExecuteScriptRegionBehaviorType: ExecuteScriptRegionBehaviorType,
  ModifyMovementCostRegionBehaviorType: ModifyMovementCostRegionBehaviorType,
  PauseGameRegionBehaviorType: PauseGameRegionBehaviorType,
  RegionBehaviorType: RegionBehaviorType,
  SuppressWeatherRegionBehaviorType: SuppressWeatherRegionBehaviorType,
  TeleportTokenRegionBehaviorType: TeleportTokenRegionBehaviorType,
  ToggleBehaviorRegionBehaviorType: ToggleBehaviorRegionBehaviorType
});

/**
 * A shape of a {@link foundry.documents.RegionDocument}.
 * @template {BaseShapeData} [ShapeData=BaseShapeData]
 * @deprecated since v14
 * @ignore
 */
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) {
    foundry.utils.logCompatibilityWarning("RegionShape is deprecated. Use BaseShapeData subclasses instead.",
      {since: 14, until: 16, once: true});
    return this._create(data);
  }

  /** @ignore */
  static _create(data) {
    if ( !(data.type in foundry.data.BaseShapeData.TYPES) ) throw new Error("Invalid shape type");
    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: return new RegionShape(data);
    }
  }

  /* -------------------------------------------- */

  /**
   * 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.data.clipperPaths;
  }

  /* -------------------------------------------- */

  /**
   * The Clipper polygon tree of this shape.
   * @type {ClipperLib.PolyTree}
   */
  get clipperPolyTree() {
    return this.data.clipperPolyTree;
  }
}

/* -------------------------------------------- */

/**
 * A circle of a {@link foundry.documents.RegionDocument}.
 * @extends {RegionShape<CircleShapeData>}
 * @deprecated since v14
 * @ignore
 */
class RegionCircleShape extends RegionShape {}

/* -------------------------------------------- */

/**
 * An ellipse of a {@link foundry.documents.RegionDocument}.
 * @extends {RegionShape<EllipseShapeData>}
 * @deprecated since v14
 * @ignore
 */
class RegionEllipseShape extends RegionShape {}

/* -------------------------------------------- */

/**
 * A polygon of a {@link foundry.documents.RegionDocument}.
 * @extends {RegionShape<PolygonShapeData>}
 * @deprecated since v14
 * @ignore
 */
class RegionPolygonShape extends RegionShape {}

/* -------------------------------------------- */

/**
 * A rectangle of a {@link foundry.documents.RegionDocument}.
 * @extends {RegionShape<RectangleShapeData>}
 * @deprecated since v14
 * @ignore
 */
class RegionRectangleShape extends RegionShape {}

/**
 * @deprecated since v14
 * @ignore
 */
const RegionPolygonTree = PolygonTree;

/**
 * @deprecated since v14
 * @ignore
 */
const RegionPolygonTreeNode = PolygonTreeNode;

var _module$Y = /*#__PURE__*/Object.freeze({
  __proto__: null,
  RegionCircleShape: RegionCircleShape,
  RegionEllipseShape: RegionEllipseShape,
  RegionPolygonShape: RegionPolygonShape,
  RegionPolygonTree: RegionPolygonTree,
  RegionPolygonTreeNode: RegionPolygonTreeNode,
  RegionRectangleShape: RegionRectangleShape,
  RegionShape: RegionShape
});

/**
 * 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,
  DataModelSchemaField: DataModelSchemaField,
  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,
  GridOffsetField: GridOffsetField,
  GridOffsetsField: GridOffsetsField,
  HTMLField: HTMLField,
  HueField: HueField,
  IntegerSortField: IntegerSortField,
  JSONField: JSONField,
  JavaScriptField: JavaScriptField,
  NumberField: NumberField,
  ObjectField: ObjectField,
  SceneLevelsSetField: SceneLevelsSetField,
  SchemaField: SchemaField,
  SetField: SetField,
  ShaderField: ShaderField,
  ShapesField: ShapesField,
  StringField: StringField,
  TypeDataField: TypeDataField,
  TypedObjectField: TypedObjectField,
  TypedSchemaField: TypedSchemaField
});

/**
 * @import {TokenCreateTerrainMovementPathOptions, 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
   * @param {Omit<TokenCreateTerrainMovementPathOptions, "preview">} [options]  Additional options
   * @returns {BaseTerrainData<TerrainEffect>|null}                             The terrain data or null
   * @abstract
   */
  static resolveTerrainEffects(effects, options) {
    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, options) {
    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: _loc(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(documentClass, operation);
    return this._getDocuments(documentClass, operation, user);
  }

  /* -------------------------------------------- */

  /**
   * Validate and configure the parameters of the get operation.
   * @param {typeof Document} documentClass           The Document class definition
   * @param {DatabaseGetOperation} operation          The requested operation
   */
  async #configureGet(documentClass, operation) {
    operation.action = "get";
    await this.#configureOperation(documentClass, 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 Document.createDocuments} or {@link 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(documentClass, operation);
    return this._createDocuments(documentClass, operation, user);
  }

  /* -------------------------------------------- */

  /**
   * Validate and configure the parameters of the create operation.
   * @param {typeof Document} documentClass           The Document class definition
   * @param {DatabaseCreateOperation} operation       The requested operation
   */
  async #configureCreate(documentClass, 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(documentClass, 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 Document.updateDocuments} or {@link 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(documentClass, operation);
    return this._updateDocuments(documentClass, operation, user);
  }

  /* -------------------------------------------- */

  /**
   * Validate and configure the parameters of the update operation.
   * @param {typeof Document} documentClass           The Document class definition
   * @param {DatabaseUpdateOperation} operation       The requested operation
   */
  async #configureUpdate(documentClass, 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(documentClass, 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(documentClass, operation);
    return this._deleteDocuments(documentClass, operation, user);
  }

  /* -------------------------------------------- */

  /**
   * Validate and configure the parameters of the delete operation.
   * @param {typeof Document} documentClass           The Document class definition
   * @param {DatabaseDeleteOperation} operation       The requested operation
   */
  async #configureDelete(documentClass, 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(documentClass, 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 {typeof Document} documentClass           The Document class definition
   * @param {DatabaseOperation} operation           The requested operation
   * @returns {Promise<void>}
   */
  async #configureOperation(documentClass, operation) {
    this.#assertCompendiumUnlocked(operation);
    operation.documentName = documentClass.documentName;
    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
   * @param {boolean} [context.dryRun=false] Whether this operation is part of a dry run: abort true and level isn't
   *                                         "debug"
   * @protected
   */
  _logOperation(action, type, documents, {parent, pack, level="info", dryRun=false}={}) {
    if ( dryRun && (level !== "debug") ) return;
    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 = Error.isError(err) ? 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,
 *   DatabaseWriteOperation
 * } 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) {
    if ( operation.dryRun ) return [];
    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 || operation.dryRun ) return [];
    const request = ClientDatabaseBackend.#buildRequest(documentClass, "create", operation);
    const responses = await ClientDatabaseBackend.#dispatchRequest(request);
    const created = [];
    for ( const response of responses ) {
      const documents = await this.#handleResponse(response);
      if ( !response.sideEffect ) created.push(...documents);
    }
    return created;
  }

  /* -------------------------------------------- */

  /**
   * 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 createData of data ) {

      // Clean user-provided partial input data
      createData = documentClass.cleanData(createData, {
        copy: false,        // Clean user input in-place
        partial: true       // User input is partial
      }, {creation: true});

      // Create pending document
      let doc;
      try {
        doc = new documentClass(foundry.utils.deepClone(createData), {parent, pack, creation: true, clean: {
          migrate: false,   // Data was already migrated
          sanitize: false   // Data was already sanitized
        }});
      } catch(err) {
        Hooks$1.onError("ClientDatabaseBackend##preCreateDocumentArray", err, {id: createData._id, log: "error",
          notify: "error"});
        continue;
      }

      // Call per-document workflows
      let documentAllowed = await doc._preCreate(createData, options, user) ?? true;
      documentAllowed &&= (noHook || Hooks$1.call(`preCreate${type}`, doc, createData, options, user.id));
      if ( documentAllowed === false ) {
        console.debug(`${CONST.vtt} | ${type} creation prevented by _preCreate`);
        continue;
      }
      documents.push(doc);
      toCreate.push(createData);
    }
    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);

    // Clean user input data before dispatching downstream events
    for ( let i=0; i<result.length; i++ ) {
      result[i] = documentClass.cleanData(result[i], {
        copy: false,        // We don't need to copy data provided from the server
        migrate: false,     // Data was already migrated
        model: false,       // Model cleaning already happened
        sanitize: false     // Data was already sanitized
      }, {creation: true, documentId: result[i]._id});
    }

    // Unpack the operation and attach its result
    const {pack, parentUuid: _, syntheticActorUpdate, ...options} = operation;
    operation.data = result;

    // 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 => {
      // Create the document with a copy of user input data, but no further cleaning is necessary
      const doc = collection.createDocument(foundry.utils.deepClone(data), {parent, pack, clean: false});
      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 || operation.dryRun ) return [];
    const request = ClientDatabaseBackend.#buildRequest(documentClass, "update", operation);
    const responses = await ClientDatabaseBackend.#dispatchRequest(request);
    const updated = [];
    for ( const response of responses ) {
      const documents = await this.#handleResponse(response);
      if ( !response.sideEffect ) updated.push(...documents);
    }
    return updated;
  }

  /* -------------------------------------------- */

  /**
   * 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;
    // eslint-disable-next-line no-unused-vars
    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.");
      const doc = collection.get(update._id, {strict: true, invalid: true});

      // Clean the provided changes
      let changes = documentClass.cleanData(update, {
        migrate: true,        // Migrate user input
        sanitize: true,       // Sanitize user input
        partial: true         // User input is partial
      }, {creation: false, model: doc, source: doc._source});

      // 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, {
          clean: true,        // We need to clean again because data may have changed in preUpdate
          dryRun: true,       // Only get the diff, don't commit the change
          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 && foundry.utils.isEmpty(diff) ) continue;
      // Update the changes with the translation to ForcedReplacement.
      if ( options.diff || (options.recursive === false) ) {
        diff._id = doc.id;
        changes = diff;
      }
      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);

    // Clean user input data before dispatching downstream events
    for ( let i=0; i<result.length; i++ ) {
      const change = result[i];
      const doc = collection.get(change._id, {strict: false});
      result[i] = documentClass.cleanData(change, {
        copy: false,        // We don't need to copy data provided from the server
        migrate: false,     // Data was already migrated
        model: false,       // Model cleaning already happened
        partial: true,      // User input data is partial
        sanitize: false     // Data was already sanitized
      }, {creation: false, model: doc, source: doc._source});
    }

    // Unpack the operation and attach its result
    const {pack, parentUuid: _, syntheticActorUpdate, ...options} = operation;
    operation.updates = result;

    // 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, clean: {
        copy: false,        // We don't need to copy data provided from the server
        migrate: false,     // Data was already migrated
        sanitize: false     // Data was already sanitized
      }});
      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 || operation.dryRun ) return operation.ids;
    const request = ClientDatabaseBackend.#buildRequest(documentClass, "delete", operation);
    const responses = await ClientDatabaseBackend.#dispatchRequest(request);
    const deleted = [];
    for ( const response of responses ) {
      const documents = await this.#handleResponse(response);
      if ( !response.sideEffect ) deleted.push(...documents);
    }
    return deleted;
  }

  /* -------------------------------------------- */

  /**
   * 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) {
    // eslint-disable-next-line no-unused-vars
    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);
    // eslint-disable-next-line no-unused-vars
    const {deleteAll, pack, parentUuid, syntheticActorUpdate, ...options} = operation;
    const {documentName} = collection;
    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 = [];
    const invalidIds = [];
    for ( const id of result ) {
      const doc = collection.get(id, {strict: false});
      if ( !doc ) {
        if ( collection.invalidDocumentIds.has(id) ) {
          collection.invalidDocumentIds.delete(id);
          collection._source.findSplice(e => e._id === id);
          invalidIds.push(id);
        }
        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);

    // Handle deletion of invalid documents
    if ( invalidIds.length ) game.issues._onDeleteInvalid(documentName, invalidIds, {parent, pack});

    // 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;
  }

  /* -------------------------------------------- */
  /*  Batch Operations                            */
  /* -------------------------------------------- */

  /**
   * Bundle multiple Document-modification operations into a single, batched request.
   * @see {@link foundry.documents.modifyBatch}
   * @param {DatabaseWriteOperation[]} operations
   * @returns {Promise<ClientDocument[][]>}
   */
  async modifyDocumentBatch(operations) {

    // Sanity check
    if ( !operations.length ) return [];
    const isRealDryRun = operations.every(o => o.dryRun);
    if ( isRealDryRun !== operations.some(o => o.dryRun) ) {
      throw new Error("Either all or none of a batch request's operations must be dry runs.");
    }

    /** @type {DatabaseWriteOperation[]} */
    const requests = [];
    for ( const operation of operations ) {
      operation.dryRun = true;
      const documentClass = getDocumentClass$1(operation.documentName);
      switch ( operation.action ) {
        case "create": {
          await documentClass.createDocuments(operation.data, operation);
          if ( !operation.data.length || isRealDryRun ) continue;
          const request = ClientDatabaseBackend.#buildRequest(documentClass, operation.action, operation);
          requests.push(request);
          break;
        }
        case "update": {
          await documentClass.updateDocuments(operation.updates, operation);
          if ( !operation.updates.length || isRealDryRun ) continue;
          const request = ClientDatabaseBackend.#buildRequest(documentClass, operation.action, operation);
          requests.push(request);
          break;
        }
        case "delete": {
          await documentClass.deleteDocuments(operation.ids, operation);
          if ( !operation.ids.length || isRealDryRun ) continue;
          const request = ClientDatabaseBackend.#buildRequest(documentClass, operation.action, operation);
          requests.push(request);
          break;
        }
        default: {
          const message = operation.action
            ? `Invalid Document modification action "${operation.action}" provided`
            : "No action provided for constituent operation of batch modification";
          throw new Error(message);
        }
      }
      if ( !isRealDryRun ) delete operation.dryRun;
    }
    if ( !requests.length ) return [];
    const response = await SocketInterface.dispatch("modifyDocumentBatch", requests);
    const results = response.results.map(r => new foundry.abstract.DocumentSocketResponse(r));
    return this.#handleModifyDocumentBatch(results);
  }

  /* -------------------------------------------- */

  /**
   * Handle a SocketResponse from the server to a batch-operation request.
   * @param {DocumentSocketResponse[]} results
   * @returns {ClientDocument[][]}
   */
  async #handleModifyDocumentBatch(results) {
    const handled = [];
    for ( const result of results ) {
      if ( result.error ) {
        ui.notifications.error(result.error, {console: true});
        continue;
      }
      let documents;
      try {
        documents = await this.#handleResponse(result);
      }
      catch(error) {
        ui.notifications.error(error, {console: true});
        continue;
      }
      if ( !result.sideEffect ) handled.push(documents);
    }

    // Update user last activity
    const response = results[0];
    const user = game.users.get(response?.userId);
    if ( user && response.timestamp ) {
      user.lastActivityTime = response.timestamp;
      Object.defineProperty(user, "idle", {value: false});
    }
    return handled;
  }

  /* -------------------------------------------- */
  /*  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
   */
  _activateSocketListeners(socket) {
    socket.on("modifyDocument", response => {
      response = new foundry.abstract.DocumentSocketResponse(response);
      this.#onModifyDocument(response);
    });
    socket.on("modifyDocumentBatch", response => {
      const results = response.results.map(r => new foundry.abstract.DocumentSocketResponse(r));
      this.#handleModifyDocumentBatch(results);
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle document modification responses, delegating them to the correct action handler.
   * This is factored out because operations with side effects may include mixed actions which must be applied.
   * @param {DocumentSocketResponse} response  A document modification socket response.
   * @returns {Promise<ClientDocument[]>}
   */
  async #handleResponse(response) {
    switch ( response.action ) {
      case "create": return this.#handleCreateDocuments(response);
      case "update": return this.#handleUpdateDocuments(response);
      case "delete": return this.#handleDeleteDocuments(response);
      default: throw new Error(`Invalid Document modification action ${response.action} provided`);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle a socket response broadcast back from the server.
   * @param {DocumentSocketResponse} response  A document modification socket response
   * @returns {Promise<void>}
   */
  async #onModifyDocument(response) {
    await this.#handleResponse(response);

    // Update user last activity
    const user = game.users.get(response.userId);
    if ( user && response.timestamp ) {
      user.lastActivityTime = response.timestamp;
      Object.defineProperty(user, "idle", {value: false});
    }
  }

  /* -------------------------------------------- */
  /*  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, immediately returning null if a dry run.
   * @param {DocumentSocketRequest} request
   * @returns {DocumentSocketResponse|DocumentSocketResponse[]} A single response for "get" actions, otherwise
   *   an array of responses (primary response with side effects)
   */
  static async #dispatchRequest(request) {
    if ( request.operation.dryRun ) throw new Error("A dry-run request cannot be dispatched to the server.");
    const responseData = await SocketInterface.dispatch("modifyDocument", request);
    if ( request.action === "get" ) return new foundry.abstract.DocumentSocketResponse(responseData);
    if ( !responseData.results ) return [new foundry.abstract.DocumentSocketResponse(responseData)];
    return responseData.results.map(r => new foundry.abstract.DocumentSocketResponse(r));
  }

  /* -------------------------------------------- */

  /**
   * 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";
 * @import {SetElement} from "@common/_types.mjs";
 */

/**
 * Game Time Calendar configuration data model.
 * @extends {foundry.abstract.DataModel}
 * @mixes CalendarConfig
 * @template {TimeComponents} [Components=TimeComponents]
 */
class CalendarData extends DataModel {

  /**
   * {@link TimeComponents} units usable in {@link CalendarData.formatDuration}, sorted descendingly by order of
   * magnitude
   * @type {Set<"year"|"month"|"day"|"hour"|"minute"|"second">}
   * @internal
   */
  static _DURATION_FORMAT_UNITS = new Set(["year", "month", "day", "hour", "minute", "second"]);

  /* -------------------------------------------- */

  /** @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) => {
      const monthDays = month.leapDays ?? month.days ?? 0;
      return sum + monthDays;
    }, 0) : this.days.daysPerYear;
  }

  /* -------------------------------------------- */
  /*  Calendar Helper Methods                     */
  /* -------------------------------------------- */

  /**
   * Convert a set of {@link TimeComponents} into seconds.
   * @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;
  }

  /* -------------------------------------------- */

  /**
   * Convert a partial set {@link TimeComponents} into an integer of a provided unit.
   * @param {Partial<Components>} components             An amount of time expressed as components
   * @param {SetElement<typeof CalendarData._DURATION_FORMAT_UNITS>} unit The unit to convert into
   * @param {object} [options]
   * @param {"ceil"|"floor"|"round"|"trunc"} [options.roundFn="ceil"] The Math function to use in rounding a remainder
   * @returns {number}                              The cumulative time in the requested units
   */
  componentsToUnit(components, unit, {roundFn="ceil"}={}) {
    const seconds = this.componentsToTime(components);
    if ( unit === "second" ) return seconds;
    const {secondsPerMinute, minutesPerHour, hoursPerDay, daysPerYear} = this.days;
    let secondsPerUnit;
    switch ( unit ) {
      case "year":
        secondsPerUnit = daysPerYear * hoursPerDay * minutesPerHour * secondsPerMinute;
        break;
      case "day":
        secondsPerUnit = hoursPerDay * minutesPerHour * secondsPerMinute;
        break;
      case "hour":
        secondsPerUnit = minutesPerHour * secondsPerMinute;
        break;
      case "minute":
        secondsPerUnit = secondsPerMinute;
        break;
      default:
        throw new Error(`Unrecognized unit encountered while converting time components: "${unit}"`);
    }
    return Math[roundFn](seconds / secondsPerUnit);
  }

  /* -------------------------------------------- */

  /**
   * 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 ??= game.time.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=game.time.worldTime, 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 ( typeof formatter !== "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.ceil((year - leapStart) / leapInterval);
  }

  /* -------------------------------------------- */

  /**
   * 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, second: 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}". The empty string is returned if the
   * components are unabled to be formatted.
   * @type {TimeFormatter}
   */
  static formatDuration(_calendar, components, {formatter, maxTerms, ...options}={}) {
    let hasNegative = false; // Was a negative-value component found?
    const FORMAT_UNITS = Array.from(CalendarData._DURATION_FORMAT_UNITS);
    const durationEntries = iterateEntries(components).reduce((arr, [key, value]) => {
      if ( !CalendarData._DURATION_FORMAT_UNITS.has(key) ) return arr;
      value = Number(components[key]) || 0;
      const rounded = Math.round(value);
      if ( rounded < 0 ) hasNegative = true;
      if ( hasNegative || (rounded < 1) ) return arr;
      arr.push([`${key}s`, rounded]);
      return arr;
    }, []).sort((a, b) => FORMAT_UNITS.indexOf(a[0].slice(0, -1))
      - FORMAT_UNITS.indexOf(b[0].slice(0, -1))).slice(0, maxTerms);
    if ( durationEntries.some(e => !Number.isFinite(e[1])) ) return "∞";
    if ( !durationEntries.length ) durationEntries.push(["seconds", 0]);
    options.style ??= "long";
    formatter ??= new Intl.DurationFormat(game.i18n.lang, options);
    try {
      return formatter.format(Object.fromEntries(durationEntries));
    }
    catch(error) {
      const message = `Unable to format duration with components ${JSON.stringify(components)}`;
      if ( error instanceof RangeError ) {
        console.error(message);
        return "∞";
      }
      else throw new Error(message, {cause: error});
    }
  }

  /* -------------------------------------------- */

  /**
   * Format time components as "{years}, {days}, {hours}, {minutes}, {seconds} ago".
   * @type {TimeFormatter}
   */
  static formatAgo(calendar, components, options={}) {
    if ( typeof options.short === "boolean" ) {
      foundry.utils.logCompatibilityWarning("The short option in CalendarData.formatAgo is deprecated. Use the"
        + " Intl.DurationFormat style option instead.", {from: 14, until: 16, once: true});
      options.style ??= options.short ? "narrow" : "long";
    }
    options.style ??= "long";
    const since = calendar.constructor.formatDuration(calendar, components, options);
    return since ? _loc("TIME.Since", {since}) : _loc("TIME.Now");
  }

  /* -------------------------------------------- */

  /**
   * Allow the active world calendar instance to respond to changes in the world time.
   * This method is called and awaited before "updateWorldTime" hooks are dispatched.
   * @param {number} worldTime              The new world time, game.time.worldTime
   * @param {number} deltaTime              The relative change in the world time
   * @param {object} [options]              Options passed through the world time update operation
   * @param {string} userId                 The user who triggered the time change
   * @returns {Promise<void>}
   */
  async onUpdateWorldTime(worldTime, deltaTime, options, userId) {}
}

/* -------------------------------------------- */
/*  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$X = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActiveEffectTypeDataModel: ActiveEffectTypeDataModel,
  ActorDeltaField: ActorDeltaField,
  BaseShapeData: BaseShapeData,
  BaseTerrainData: BaseTerrainData,
  CalendarData: CalendarData,
  CircleShapeData: CircleShapeData,
  ClientDatabaseBackend: ClientDatabaseBackend,
  CombatConfiguration: CombatConfiguration,
  ConeShapeData: ConeShapeData,
  EllipseShapeData: EllipseShapeData,
  EmanationShapeData: EmanationShapeData,
  GridShapeData: GridShapeData,
  LightData: LightData,
  LineShapeData: LineShapeData,
  PolygonShapeData: PolygonShapeData,
  PolygonTree: PolygonTree,
  PolygonTreeNode: PolygonTreeNode,
  PrototypeToken: PrototypeToken,
  PrototypeTokenOverrides: PrototypeTokenOverrides,
  RectangleShapeData: RectangleShapeData,
  RingShapeData: RingShapeData,
  SIMPLIFIED_GREGORIAN_CALENDAR_CONFIG: SIMPLIFIED_GREGORIAN_CALENDAR_CONFIG,
  ShapeData: ShapeData,
  TerrainData: TerrainData,
  TextureData: TextureData,
  TokenShapeData: TokenShapeData,
  TombstoneData: TombstoneData,
  fields: fields,
  operators: operators,
  regionBehaviors: _module$Z,
  regionShapes: _module$Y,
  types: _types$h,
  validation: validationFailure,
  validators: validators
});

/**
 * @import {PackageCompatibilitySegment, 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 Array.isArray(value) ? 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 */
  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 */
  _cleanElement(value, options, _state) {
    const v = super._cleanElement(value, options, _state);
    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 */
  _validateModel(data, options) {
    super._validateModel(data, options);
    const packNames = new Set();
    const packPaths = new Set();
    for ( const pack of data ) {
      if ( packNames.has(pack.name) ) {
        throw new Error(`Duplicate Compendium name "${pack.name}" already declared by some other pack`);
      }
      packNames.add(pack.name);
      if ( pack.path ) {
        if ( packPaths.has(pack.path) ) {
          throw new Error(`Duplicate Compendium path "${pack.path}" already declared by some other pack`);
        }
        packPaths.add(pack.path);
      }
    }
  }
}

/* -------------------------------------------- */

/**
 * 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, compatibilityList} = 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 ?? [];

    /**
     * The package's full compatibility history from the package index, as an ordered array of non-overlapping segments.
     * Provided so that a client can determine which core version to downgrade or upgrade to when no released version
     * supports the current core.
     * @type {PackageCompatibilitySegment[]}
     */
    this.compatibilityList = compatibilityList ?? [];

    /**
     * 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";

  /**
   * A mapping of retired-field keys and their corresponding replacements
   * @type {Record<string, string>}
   */
  static #KNOWN_LEGACY_KEYS = {
    name: "id",
    author: "authors",
    minimumCoreVersion: "compatibility.minimum",
    compatibleCoreVersion: "compatibility.verified",
    maximumCoreVersion: "compatibility.maximum",
    systems: "relationships.systems"
  };

  /**
   * 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;
  }

  /**
   * An array of top-level manifest keys that were provided which are not supported by the schema.
   * @type {string[]}
   * @internal
   */
  _unknownKeys = this._unknownKeys;

  /**
   * 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_UNKNOWN);
  }

  /**
   * 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, validate: BasePackage.validateId}),
        module: new StringField({...optionalString, validate: BasePackage.validateId}),
        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, validate: BasePackage.validateId}),
        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"];

  /* -------------------------------------------- */

  /** @override */
  _initializeSource(data, {installed=true, ...options}={}) {
    if ( options.clean !== false ) {
      if ( typeof options.clean !== "object" ) options.clean = {};
      options.clean.prune = false;          // Don't prune keys until after we have identified unknown keys
      options.clean.expand = false;         // Don't expand data where "." characters in keys may be intentional
      options.clean.migrate = {installed};  // Migrate according to installed state
      options.clean.model = true;           // Joint model cleaning
      options.clean.sanitize = true;        // Package data should be sanitized
    }

    // Identify unknown keys in the manifest source data
    this._unknownKeys = [];
    for ( const k in data ) {
      const newKey = BasePackage.#KNOWN_LEGACY_KEYS[k];
      if ( !this.schema.has(k) && (!newKey || !foundry.utils.hasProperty(data, newKey)) ) {
        this._unknownKeys.push(k);
      }
    }

    return super._initializeSource(data, options);
  }

  /* -------------------------------------------- */

  /**
   * 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;

    // Require a certain minimum core version.
    if ( minimum && isNewerVersion(minimum, release.version) ) {
      const generation = Number(minimum.split(".").shift());
      const isStable = generation <= release.maxStableGeneration;
      const isUnstable = !isStable && (generation <= release.maxGeneration);
      return isStable ? codes.REQUIRES_CORE_UPGRADE_STABLE : (isUnstable ? codes.REQUIRES_CORE_UPGRADE_UNSTABLE
        : codes.REQUIRES_CORE_UPGRADE_UNKNOWN);
    }

    // Require a certain maximum core version.
    if ( maximum ) {
      const compatible = !isNewerVersion(release.version, maximum, { majorOnly: Number.isInteger(Number(maximum)) });
      if ( !compatible ) return codes.REQUIRES_CORE_DOWNGRADE;
    }

    // Require a certain compatible core version.
    if ( verified ) {
      const compatible = !isNewerVersion(release.version, verified, { majorOnly: Number.isInteger(Number(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;
  }

  /* -------------------------------------------- */

  /** @override */
  static _cleanData(data, _options, _state) {

    // Clean Langauges
    if ( "languages" in data ) {
      for ( const l of data.languages ) l.name = l.name ?? l.lang;
    }

    // Clean Compendium Packs
    if ( "packs" in data ) {

      // Identify whether this package depends on a single game system
      let systemId = undefined;
      if ( this.type === "system" ) systemId = (data.id ?? _state.source?.id);
      else if ( this.type === "world" ) systemId = (data.system ?? _state.source?.system);
      else {
        const relationships = "relationships" in data ? data.relationships : _state.source?.relationships;
        if ( relationships.systems?.length === 1 ) systemId = relationships.systems[0].id;
      }

      // Auto-configure some pack fields
      for ( const pack of data.packs ) {
        if ( !pack.system && systemId ) pack.system = systemId; // System dependency
        if ( typeof pack.ownership === "string" ) pack.ownership = {PLAYER: pack.ownership};
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * 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, options) {
    this._migrateStyles(data);
    return super.migrateData(data, options);
  }

  /* -------------------------------------------- */

  /**
   * Migrate to v13-schema styles array from string array
   * @param {PackageManifestData} data
   * @internal
   * @deprecated since v13
   */
  static _migrateStyles(data) {
    if ( !Array.isArray(data.styles) ) return;
    data.styles = data.styles.map(src => {
      if ( foundry.utils.isPlainObject(src) ) return src;
      return { src };
    });
  }

  /* -------------------------------------------- */

  /**
   * 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 ( !isPlainObject(subtypes) ) 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 ( !isPlainObject(config) ) {
      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() {
    return Object.assign(super.defineSchema(), {
      type: new StringField({required: true, choices: ["module"], initial: "module"}),
      coreTranslation: new BooleanField(),
      library: new BooleanField(),
      documentTypes: new AdditionalTypesField(),
      quickstart: new SchemaField({
        adventures: new TypedObjectField(new SchemaField({
          uuid: new StringField({ required: true, blank: false })
        }), { expandKeys: false, validateKey: BasePackage.validateId }),
        postImport: new BooleanField(),
        world: new SchemaField({
          background: new FilePathField({ categories: ["IMAGE"], required: false }),
          cover: new FilePathField({ categories: ["IMAGE"], required: false }),
          description: new HTMLField({ required: false })
        })
      }, { required: false, initial: undefined })
    });
  }

  /** @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:
        case codes.REQUIRES_CORE_UPGRADE_UNKNOWN: {
          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",
            [codes.REQUIRES_CORE_UPGRADE_UNKNOWN]: "SETUP.RequireCoreUnknown"
          };
          return {
            type: "error",
            tooltip: _loc(labels[availability]),
            label: version,
            icon: "fa-solid fa-file-slash"
          };
        }
        case codes.MISSING_SYSTEM:
          return {
            type: "error",
            tooltip: _loc("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: _loc("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: _loc("SETUP.CompatibilityRiskWithVersion", {version: compatibility.verified}),
            label: version,
            icon: "fa-solid fa-code-branch"
          };

        // Safe
        case codes.VERIFIED:
          return {
            type: "success",
            tooltip: _loc("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 _loc(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 _loc(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(), {
      type: new StringField({required: true, choices: ["system"], initial: "system"}),
      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;
}

/**
 * @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;
  }
}

/**
 * @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(), {
      type: new StringField({required: true, choices: ["world"], initial: "world"}),
      system: new StringField({required: true, blank: false, validate: BasePackage.validateId}),
      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, options) {
    super.migrateData(data, options);

    // 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: _loc("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 = [...deps, ...system.relationships.requires.values()];
    return super._formatBadDependenciesTooltip(availability, data, deps);
  }
}

/**
 * @typedef PackageCompatibilityBadge
 * @property {"success"|"warning"|"neutral"|"error"} type  A CSS class applied to the badge
 * @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$W = /*#__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 {string|null} [parentCollection] The name of the parent Document's collection that would contain this one
 *
 * @property {boolean} [creation]             In a ServerDocument context, is this document being created?
 * @property {number} [modifiedTime]          In a ServerDocument context, the timestamp of creation
 * @property {User} [user]                    In a ServerDocument context, the User performing the construction
 */

/**
 * @typedef {DataModelConstructionContext & _DocumentConstructionContext} DocumentConstructionContext
 */

/**
 * @typedef DataModelValidationOptions
 * @property {boolean} [fields=true]      Validate each field. Ignored if changes are passed.
 * @property {boolean} [joint=true]       Perform joint validation on the full data model.
 *                                        Ignored if changes are passed.
 * @property {object} [changes]           A specific set of proposed changes to validate, rather than the full
 *                                        source data of the model. This type of validation is redirected to
 *                                        DataModel#updateSource as a dry-run.
 * @property {Omit<DataModelCleaningOptions, "partial">|boolean} [clean]  Configuration of data cleaning
 *                                        steps applied to user input data. This can be explicitly passed as `false`
 *                                        which requires the provided data to already be clean.
 * @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.
 * @property {boolean} [dropInvalidElements=false] For Array-like fields, control whether an invalid element is
 *                                        allowed to be removed from the data in lieu of fallback replacement.
 * @property {DataModel} [model]          The data model instance being validated. This is automatically populated
 *                                        during the model validation flow.
 * @property {boolean} [strict=true]      Throw an error if validation fails.
 */

/**
 * @typedef _DataModelConstructionOptions
 * @property {DataModel|null} [parent]    A parent DataModel instance to which this DataModel belongs
 * @property {DataModelSchemaField} [schema]  If we are constructing an embedded model, it may be provided by the
 *                                        constructing DataModelSchemaField with an instance-specific schema to use
 * @property {Omit<DataModelCleaningOptions, "partial">|boolean} [clean]  Configuration of data cleaning
 *                                        steps applied to user input data. This can be explicitly passed as `false`
 *                                        to skip preprocessing when using data that is known to already be fully
 *                                        cleaned.
 */

/**
 * @typedef {_DataModelConstructionOptions &
 *           Pick<DataModelValidationOptions, "dropInvalidEmbedded"|"fallback"|"strict">} DataModelConstructionContext
 */

/**
 * @typedef DataModelUpdateOptions
 * @property {Omit<DataModelCleaningOptions, "partial">|boolean} [clean]  Configuration of data cleaning
 *                                        steps applied to user input data. This can be explicitly passed as `false`
 *                                        to skip preprocessing when using data that is known to already be fully
 *                                        cleaned.
 * @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. This is automatically re-interpreted as every key of the
 *                                        update being defined as a ForcedReplacement operator.
 * @property {boolean} [restoreDelta]     An advanced option used specifically and internally by the ActorDelta model
 * @property {User} [user]                Used only by the server to understand update context
 */


/**
 * @typedef DataModelUpdateState
 * @property {DataModel} model
 * @property {object} source
 * @property {object} diff
 * @property {DataModelValidationFailure} failure
 */

/**
 * @typedef {"get"|"create"|"update"|"delete"} DatabaseAction
 */

/**
 * @typedef {Exclude<DatabaseAction, "get">} DatabaseWriteAction
 */

/**
 * @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 {string} documentName              The Document name
 * @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
 * @property {object} [queryOptions]            Additional options passed to the ServerDocument#find API
 */

/**
 * @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 {string} documentName              The Document name
 * @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]           Skip dispatch of preCreate hooks for this operation
 * @property {boolean} [render=true]            Re-render Applications whose display depends on the created Documents
 * @property {boolean} [controlObject=false]    Control the object of any 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 {boolean} [dryRun]                 Is the operation a dry run? If so, an empty result array is returned
 *                                              before the Documents are created.
 * @property {Record<string, object>} [_createData] Used internally by server-side backend
 * @property {(string|object)[]} [_result]      Used internally by the server-side backend
 * @property {Record<string, string>} [extractedImages] Base64 images extracted during server-side processing
 */

/**
 * @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 {string} documentName              The Document name
 * @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]           Skip dispatch of preUpdate hooks for 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
 * @property {boolean} [dryRun]                 Is the operation a dry run? If so, an empty result array is returned
 *                                              before the Documents are updated.
 * @property {Record<string, string>} [extractedImages] Base64 images extracted during server-side processing
 */

/**
 * @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} documentName              The Document name
 * @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]           Skip dispatch of preDelete hooks for 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 {boolean} [dryRun]                 Is the operation a dry run? If so, an empty result array is returned
 *                                              before the Documents are deleted.
 * @property {(string|object)[]} [_result]      An alias for 'ids' used internally by the server-side backend
 */

/**
 * @typedef {DatabaseGetOperation|DatabaseCreateOperation|DatabaseUpdateOperation
 *   |DatabaseDeleteOperation} DatabaseOperation
 */

/**
 * @typedef {Exclude<DatabaseOperation, DatabaseGetOperation>} DatabaseWriteOperation
 */

/**
 * @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`.
 * @property {boolean} [discardInvalidEmbedded=false]  Discard invalid embedded documents from the source.
 *                                                     Not to be confused with `dropInvalidEmbedded`, which
 *                                                     does not discard the invalid embedded documents but
 *                                                     just prevents the document from being considered invalid
 *                                                     and drops invalid embedded documents during preparation.
 *                                                     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} [baseTypeAllowed] If the Document class has type data, can users normally create instances of the
 *                                       "base" type?
 * @property {boolean} indexed
 * @property {string[]} compendiumIndexFields
 * @property {string[]} preserveOnImport
 * @property {string} [schemaVersion]
 */

/**
 * @callback RecursiveFieldClearCallback
 * @param {object} data       The (partial) Document data.
 * @param {string} fieldName  The name of the field to clear.
 * @returns {void}
 */

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;
    }

    if ( this.error && !Error.isError(this.error) ) {
      const {message, stack} = request.error;
      this.error = new Error(message);
      if ( stack ) this.error.stack = stack;
    }
  }

  /**
   * The type of Document being transacted.
   * @type {string}
   */
  type;

  /**
   * The database action that was performed.
   * @type {DatabaseAction}
   */
  action;

  /**
   * The time the response was sent in milliseconds.
   * @type {number}
   */
  timestamp;

  /**
   * 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;

  /**
   * Is this a side effect?
   * @type {boolean}
   */
  sideEffect = false;

  /**
   * An error that occurred. Present if unsuccessful
   * @type {Error}
   */
  error;
}

/** @module abstract */

var _module$V = /*#__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
});

/**
 * 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 {string} [tempDir]                 A custom path where downloaded package archives are temporarily stored
 *                                              during extraction.
 * @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 {
      adminUsername: new StringField({required: true, blank: false, nullable: true, initial: null}),
      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(),
      tempDir: 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: true, initial: 30000,
        validate: ServerSettings.#validatePort}),
      unixSocket: new StringField({required: true, nullable: true, blank: false,
        validate: ServerSettings.#validateUDS}),
      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, _options) {

    // 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 {string} port     The requested port number
   * @throws                  An error if the port number is invalid
   */
  static #validatePort(port) {
    if ( ((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");
    }
  }

  /* ----------------------------------------- */

  /**
   * Validate a path to a Unix domain socket.
   * @param {string} path     The requested path
   * @throws                  An error if the port number is invalid
   */
  static #validateUDS(path) {
    if ( (path.length < 2) || !path.startsWith("/") ) {
      throw new Error("A path to a Unix domain socket must be absolute.");
    }
  }
}

/* ----------------------------------------- */

/**
 * 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
});

/**
 * @import {Schema} from "prosemirror-model";
 * @import {Plugin} from "prosemirror-state";
 */

/**
 * @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}
     */
    this.schema = schema;
    Object.defineProperty(this, "schema", {configurable: false, enumerable: false, writable: false});
  }

  /* -------------------------------------------- */

  /**
   * 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 unique key for this plugin that can be used to identify a plugin instance in any given editor.
   * @returns {PluginKey}
   */
  static get key() {
    if ( Object.hasOwn(this, "key") ) return this.key;
    const key = new PluginKey(this.name);
    Object.defineProperty(this, "key", { value: key, writable: false });
    return key;
  }
}

/**
 * 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 behavior.
   * @param {Function} [options.onSave]          A function to call when Ctrl+S is pressed.
   * @param {boolean} [options.joinDoubleBreak]  If a double line-break is detected, join it into a paragraph instead.
   */
  constructor(schema, {onSave, joinDoubleBreak=true}={}) {
    super(schema);

    /**
     * A function to call when Ctrl+S is pressed.
     * @type {Function}
     */
    Object.defineProperty(this, "onSave", {value: onSave, writable: false});
    this.#joinDoubleBreak = joinDoubleBreak;
  }

  /* -------------------------------------------- */

  /**
   * If a double line-break is detected, join it into a paragraph instead.
   * @type {boolean}
   */
  #joinDoubleBreak;

  /* -------------------------------------------- */

  /** @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;
    const { paragraph } = this.schema.nodes;

    // Exit a code block if we're in one, then create a line-break.
    const cmd = chainCommands(exitCode, (state, dispatch) => {
      const { $cursor } = state.selection;
      const { nodeBefore } = $cursor ?? {};
      if ( this.#joinDoubleBreak && ($cursor.parent.type === paragraph) && (nodeBefore?.type === br) ) {
        let pos = $cursor.pos - nodeBefore.nodeSize;
        const tr = state.tr.delete(pos, $cursor.pos);
        pos = tr.mapping.map(pos);
        dispatch(tr.split(pos).scrollIntoView());
      }
      else 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 ( typeof node.split === "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 {string} [options.menu]                    An associated menu that this item collapses under.
   * @param {function(MouseEvent)} [options.onAction]  A callback to fire when a menu item is clicked.
   * @param {number} [options.weight]                  The relative importance of an entry. Entries with lower weight
   *                                                   collapse first.
   */
  constructor(title, items, {cssClass, icon, menu, onAction, weight}={}) {
    /**
     * 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;
    this.#menu = menu;
    this.#weight = weight;
  }

  /* -------------------------------------------- */

  /**
   * The menu CSS class name.
   * @type {string}
   */
  #cssClass;

  /* -------------------------------------------- */

  /**
   * The icon to use instead of a text label, if any.
   * @type {string}
   */
  #icon;

  /* -------------------------------------------- */

  /**
   * An associated menu that this item collapses under.
   * @type {string}
   */
  get menu() {
    return this.#menu;
  }

  #menu;

  /* -------------------------------------------- */

  /**
   * The callback to fire when a menu item is clicked.
   * @type {function(MouseEvent)}
   */
  #onAction;

  /* -------------------------------------------- */

  /**
   * The relative importance of this entry.
   * @type {number}
   */
  get weight() {
    return this.#weight;
  }

  #weight;

  /* -------------------------------------------- */

  /**
   * 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 = _loc(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) {
    const document = event.target.ownerDocument;
    foundry.applications.detached.querySelector("#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 } = event.target.ownerDocument.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 ?? ""}">${_loc(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, ProseMirrorMenuResizeState} 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;
    this.#renderTarget = view.dom.ownerDocument.createElement("template");

    /**
     * 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;

  /* -------------------------------------------- */

  /**
   * Stored resize state.
   * @type {ProseMirrorMenuResizeState}
   */
  #resizeState = {
    hiddenWidth: 0,
    menus: {},
    observer: new ResizeObserver(this._onResize.bind(this)),
    sizes: []
  };

  /* -------------------------------------------- */

  /**
   * 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({
      key: this.key,
      view: editorView => {
        return new this(schema, editorView, options).render();
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Render the menu's HTML.
   * @returns {ProseMirrorMenu}
   */
  render() {
    const document = this.view.dom.ownerDocument;
    const scopes = this.constructor._MENU_ITEM_SCOPES;
    const scopeKey = this.editingSource ? "HTML" : "TEXT";

    // Dropdown Menus
    const dropdowns = this.dropdowns.map(d => {
      const { menu, weight } = d;
      const dataset = [menu ? `data-menu="${menu}"` : "", weight ? `data-weight="${weight}"` : ""].filterJoin(" ");
      return `<li class="text" ${dataset}>${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");
      Object.assign(button.dataset, { action: item.action, tooltip: item.title });
      if ( item.weight ) button.dataset.weight = item.weight;
      if ( item.menu ) button.dataset.menu = item.menu;
      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 || _loc("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>
    `;
    const menu = this.#renderTarget.content.getElementById(this.id);
    this.#resizeState.hiddenWidth = 0;
    this.#resizeState.observer.disconnect();
    document.getElementById(this.id).replaceWith(menu);
    this.#recordSizes(menu);
    if ( !this.view.editable ) menu.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(menu);
    this.#resizeState.observer.observe(menu);
    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();
  }

  /* -------------------------------------------- */

  /**
   * Instantiate the ProseMirrorDropDown instances and configure them with the defined menu items.
   * @protected
   */
  _createDropDowns() {
    const dropdowns = Object.values(this._getDropDownMenus()).map(({title, entries, ...options}) => {
      return new ProseMirrorDropDown(title, entries, { onAction: this._onAction.bind(this), ...options });
    });

    /**
     * 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() {
    // Formatting.
    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,
              cmd: () => this._toggleTextBlock(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")
            }]
          }
        ]
      }
    };

    // Headings.
    const headings = Array.fromRange(6, 1).map(level => ({
      action: `h${level}`,
      title: _loc("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
    });

    // Inserts.
    if ( CONFIG.TextEditor.inserts.length ) {
      const cmd = this._placeInsert;
      menus.format.entries.push({
        action: "custom-inserts",
        title: "EDITOR.Custom",
        children: CONFIG.TextEditor.inserts.map(function transformConfig({ action, title, inline, html, children }) {
          if ( children?.length ) return { action, title, children: children.map(transformConfig) };
          return {
            action, title,
            cmd: (state, dispatch, view) => cmd(state, dispatch, view, html, { inline })
          };
        })
      });
    }

    // Tables.
    const tables = [{
      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-before",
      title: "EDITOR.TableAddColumnBefore",
      group: 2,
      cmd: addColumnBefore
    }, {
      action: "add-col-after",
      title: "EDITOR.TableAddColumnAfter",
      group: 2,
      cmd: addColumnAfter
    }, {
      action: "delete-col",
      title: "EDITOR.TableDeleteColumn",
      group: 2,
      cmd: deleteColumn
    }, {
      action: "add-row-before",
      title: "EDITOR.TableAddRowBefore",
      group: 3,
      cmd: addRowBefore
    }, {
      action: "add-row-after",
      title: "EDITOR.TableAddRowAfter",
      group: 3,
      cmd: addRowAfter
    }, {
      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
    }];
    menus.format.entries.push({
      action: "tables",
      title: "EDITOR.Table",
      children: tables
    });
    menus.table = {
      title: "EDITOR.Table",
      cssClass: "tables",
      icon: '<i class="fa-solid fa-table fa-fw"></i>',
      weight: 100,
      menu: "tables",
      entries: tables
    };

    // Fonts.
    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: () => this._toggleMark(this.schema.marks.font, {family})
    }));
    menus.format.entries.push({
      action: "fonts",
      title: "EDITOR.Font.Title",
      children: fonts
    });
    menus.fonts = {
      title: "EDITOR.Font.Title",
      cssClass: "fonts",
      icon: '<i class="fa-solid fa-font fa-fw"></i>',
      weight: 200,
      menu: "fonts",
      entries: fonts
    };

    // Sizes.
    const sizes = [{
      action: "font-size-small",
      title: "EDITOR.Font.Size.Small",
      group: 1,
      cmd: () => this._toggleMark(this.schema.marks.size, { size: "small" })
    }, {
      action: "font-size-normal",
      title: "EDITOR.Font.Size.Normal",
      group: 1,
      cmd: () => this._clearMark(this.schema.marks.size)
    }, {
      action: "font-size-large",
      title: "EDITOR.Font.Size.Large",
      group: 1,
      cmd: () => this._toggleMark(this.schema.marks.size, { size: "large" })
    }, {
      action: "font-size-huge",
      title: "EDITOR.Font.Size.Huge",
      group: 1,
      cmd: () => this._toggleMark(this.schema.marks.size, { size: "x-large" })
    }, {
      action: "font-size-custom",
      title: "EDITOR.Custom",
      group: 2,
      cmd: this._fontSizePrompt.bind(this)
    }];
    menus.format.entries.push({
      action: "font-size",
      title: "EDITOR.Font.Size.Title",
      children: sizes
    });
    menus.sizes = {
      title: "EDITOR.Font.Size.Title",
      cssClass: "sizes",
      icon: '<i class="fa-solid fa-text-size fa-fw"></i>',
      weight: 200,
      menu: "font-size",
      entries: sizes
    };

    // Menu items.
    const items = this._getMenuItems();
    const byMenu = Object.groupBy(items, ({ menu }) => menu || "");
    menus.format.entries.push({
      action: "font-format",
      title: "EDITOR.Format",
      children: byMenu["font-format"]
    });
    menus.format.entries.push({
      action: "lists",
      title: "EDITOR.Lists",
      children: byMenu.lists
    });
    menus.format.entries.push({
      action: "insert",
      title: "EDITOR.Insert",
      children: byMenu.insert
    });

    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: "font-color",
        title: "EDITOR.Font.Color.Title",
        icon: '<i class="fa-solid fa-palette fa-fw"></i>',
        weight: 200,
        menu: "font-format",
        scope: scopes.TEXT,
        cmd: this._fontColorPrompt.bind(this)
      },
      {
        action: "bullet-list",
        title: "EDITOR.BulletList",
        icon: '<i class="fa-solid fa-list-ul fa-fw"></i>',
        weight: 300,
        menu: "lists",
        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>',
        weight: 300,
        menu: "lists",
        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>',
        weight: 400,
        menu: "insert",
        scope: scopes.TEXT,
        cmd: this.#insertHorizontalRule.bind(this)
      },
      {
        action: "image",
        title: "EDITOR.InsertImage",
        icon: '<i class="fa-solid fa-image fa-fw"></i>',
        weight: 400,
        menu: "insert",
        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>',
        weight: 400,
        menu: "insert",
        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.equals(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.
    foundry.applications.detached.querySelector("#prosemirror-dropdown")?.remove();
    this.view.focus();
  }

  /* -------------------------------------------- */

  /**
   * Handle collapsing various menu items to accommodate the menu's available width.
   * @param {ResizeObserverEntry[]} entries
   * @protected
   */
  _onResize([entry]) {
    // TODO: We could eliminate all getBoundingClientRect calls here by caching them on render and then invalidating
    //       them whenever the parent window moves.
    const { x } = entry.target.getBoundingClientRect();
    const { children } = entry.target;
    let last;
    let i = 1;
    do { last = children[children.length - i++]; } while ( last && (getComputedStyle(last).display === "none") );
    if ( !last ) return;
    const contentWidth = last.getBoundingClientRect().right - x;
    const [size] = entry.borderBoxSize;
    const { inlineSize } = size;
    const totalWidth = contentWidth + this.#resizeState.hiddenWidth;
    let hiddenWidth = 0;
    for ( const { elements, menu: menuKey, width } of this.#resizeState.sizes ) {
      const menu = this.#resizeState.menus[menuKey];
      const fits = (totalWidth - hiddenWidth) <= inlineSize;
      if ( !fits ) hiddenWidth += width;
      menu.hidden = fits;
      for ( const el of elements ) el.hidden = !fits;
    }
    this.#resizeState.hiddenWidth = hiddenWidth;
  }

  /* -------------------------------------------- */

  /**
   * Cache sizing information after rendering for fast lookup during resize handling.
   * @param {HTMLElement} menu  The rendered menu.
   */
  #recordSizes(menu) {
    this.#resizeState.menus = Array.from(menu.querySelectorAll("li[data-action]")).reduce((obj, li) => {
      obj[li.dataset.action] = li;
      return obj;
    }, {});
    this.#resizeState.sizes = Array.from(menu.querySelectorAll("[data-weight]")).map(element => {
      const { menu, weight } = element.dataset;
      return { element, menu, size: element.getBoundingClientRect(), weight: Number(weight) };
    }).sort((a, b) => a.weight - b.weight).reduce((acc, entry, i, arr) => {
      const { element, menu, size, weight } = entry;
      if ( !i || (arr[i-1].menu !== menu) ) acc.push({ menu, weight, elements: [], width: 0 });
      const obj = acc.at(-1);
      obj.elements.push(element);
      obj.width += size.width;
      return acc;
    }, []);
  }

  /* -------------------------------------------- */

  /**
   * Wrap the editor view element and inject our template ready to be rendered into.
   * @protected
   */
  _wrapEditor() {
    const document = this.view.dom.ownerDocument;
    const menuWrapper = document.createElement("div");
    menuWrapper.classList.add("menu-container");
    const editorWrapper = document.createElement("div");
    editorWrapper.classList.add("editor-container");
    const template = document.createElement("template");
    template.setAttribute("id", this.id);
    menuWrapper.append(template);
    this.view.dom.before(menuWrapper);
    this.view.dom.replaceWith(editorWrapper);
    editorWrapper.append(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.
   * @param {Document} document  The document to bind to.
   */
  static activateListeners(document, { _deprecated=false }={}) {
    if ( !_deprecated && (foundry.utils.getDefiningClass(this, "eventListeners") !== ProseMirrorMenu) ) {
      foundry.utils.logCompatibilityWarning("ProseMirrorMenu.eventListeners is deprecated. "
        + "Please use ProseMirrorMenu.activateListeners instead.", { since: 14, until: 16 });
      this.eventListeners();
      return;
    }
    document ??= window.document;
    document.addEventListener("pointerdown", event => {
      if ( !event.target.closest("#prosemirror-dropdown") ) {
        foundry.applications.detached.querySelector("#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 > .source-editor");
    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.focus();
    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") ) this._handleSave();
  }

  /* -------------------------------------------- */
  /*  Editor Functions                            */
  /* -------------------------------------------- */

  /**
   * Clear a specific mark from the selection.
   * @param {MarkType} markType  The mark to remove.
   * @protected
   */
  _clearMark(markType) {
    const { state } = this.view;
    const { from, to } = state.selection;
    this.view.dispatch(state.tr.removeMark(from, to, markType));
  }

  /* -------------------------------------------- */

  /**
   * Display a prompt for font color.
   * @protected
   */
  async _fontColorPrompt() {
    const { state } = this.view;
    const { empty, from, to, $cursor, $from } = state.selection;
    const colorMark = this.schema.marks.color;
    const marks = empty ? $cursor.marks() : $from.marks();
    let value = "";
    for ( const mark of marks ) {
      if ( mark.type === colorMark ) {
        value = mark.attrs.color;
        break;
      }
    }
    if ( value.startsWith("rgb") ) {
      const [, r, g, b] = value.match(/rgb\((\d+), (\d+), (\d+)\)/);
      value = foundry.utils.Color.fromRGBvalues(r / 255, g / 255, b / 255).css;
    }
    const field = new foundry.data.fields.ColorField({ label: "EDITOR.Font.Color.Label" }, { name: "color" });
    const dialog = await this._showDialog("font-color", "templates/journal/font-color.hbs", {
      data: { field, value }
    });
    const form = dialog.querySelector("form");
    form.elements.save.addEventListener("click", () => {
      const color = form.elements.color.value;
      if ( color ) {
        this.view.dispatch(state.tr.removeMark(from, to, colorMark).addMark(from, to, colorMark.create({ color })));
      }
      else this._clearMark(colorMark);
    });
  }

  /* -------------------------------------------- */

  /**
   * Display a prompt for a custom font size.
   * @protected
   */
  async _fontSizePrompt() {
    const field = new foundry.data.fields.StringField({ label: "EDITOR.Font.Size.Label" }, { name: "size" });
    const dialog = await this._showDialog("font-size-custom", "templates/journal/font-size-custom.hbs", {
      data: { field }
    });
    const form = dialog.querySelector("form");
    const span = dialog.querySelector("[hidden]");
    const onSubmit = event => {
      event.preventDefault();
      const input = form.elements.size;
      let size = input.value;
      // Assume unit-less values are specified in points.
      if ( Number.isNumeric(size) ) size = `${size}pt`;
      // Validate font size.
      span.style.fontSize = size;
      if ( span.style.fontSize !== size ) {
        event.stopImmediatePropagation();
        input.setCustomValidity(_loc("EDITOR.Font.Size.Invalid"));
        input.addEventListener("input", () => input.setCustomValidity(""), { once: true });
        form.reportValidity();
        return;
      }
      if ( size ) this._toggleMark(this.schema.marks.size, { size });
      else this._clearMark(this.schema.marks.size);
    };
    form.addEventListener("submit", onSubmit, { capture: true });
    form.elements.save.addEventListener("click", onSubmit, { capture: true });
  }

  /* -------------------------------------------- */

  /**
   * Display the insert image prompt.
   * @protected
   */
  async _insertImagePrompt() {
    const state = this.view.state;
    const { $from, empty } = state.selection;
    const { figcaption, figure, image } = this.schema.nodes;
    const data = { src: "", alt: "", width: "", height: "" };
    let selected;
    if ( !empty ) {
      selected = state.doc.nodeAt($from.pos);
      Object.assign(data, selected?.attrs ?? {});
    }
    const { FilePathField, NumberField, StringField } = foundry.data.fields;
    const context = {
      alt: {
        field: new StringField({ label: "EDITOR.ImageAlt" }, { name: "alt" }),
        value: data.alt
      },
      height: {
        field: new NumberField({ min: 0, integer: true }, { name: "height" }),
        value: data.height
      },
      src: {
        field: new FilePathField({
          required: true, categories: ["IMAGE"], base64: true, label: "EDITOR.ImageSource"
        }, { name: "src" }),
        value: data.src
      },
      width: {
        field: new NumberField({ min: 0, integer: true }, { name: "width" }),
        value: data.width
      }
    };
    if ( selected?.type !== this.schema.nodes.image ) context.caption = {
      field: new StringField({ label: "EDITOR.ImageCaption" }, { name: "caption" }),
      value: data.caption
    };
    const dialog = await this._showDialog("image", "templates/journal/insert-image.hbs", { data: context });
    const form = dialog.querySelector("form");
    const src = form.elements.src;
    form.elements.save.addEventListener("click", () => {
      if ( !src.value ) return;
      let node;
      const imageNode = node = image.create({
        src: src.value,
        alt: form.elements.alt.value,
        width: form.elements.width.value,
        height: form.elements.height.value
      });
      if ( form.elements.caption?.value ) {
        node = figure.create(null, [
          imageNode,
          figcaption.create(null, this.schema.text(form.elements.caption.value))
        ]);
      }
      this.view.dispatch(this.view.state.tr.replaceSelectionWith(node).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={}}={}) {
    const document = this.view.dom.ownerDocument;
    const button = this.getActionElement(action);
    button.classList.add("active");
    let { top, bottom, left } = 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");
    const size = form.getBoundingClientRect();
    const { clientWidth, clientHeight } = document.documentElement;
    left = Math.min(left + 15 - (size.width / 2), clientWidth - size.width);
    top = bottom + size.height > clientHeight ? top - size.height : bottom;
    left = Math.clamp(left, 0, clientWidth);
    top = Math.clamp(top, 0, clientHeight);
    Object.assign(form.style, { top: `${top}px`, left: `${left}px` });
    dialog.style.zIndex = ++foundry.applications.api.ApplicationV2._maxZ;
    const onSubmit = event => {
      event.preventDefault();
      button.classList.remove("active");
      dialog.remove();
      this.view.focus();
    };
    form.addEventListener("submit", onSubmit);
    form.elements.save?.addEventListener("click", onSubmit);
    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);
  }

  /* -------------------------------------------- */

  /**
   * Place the given insert into the ProseMirror Document.
   * @param {EditorState} state  The editor state.
   * @param {Function} dispatch  Dispatch changes to the editor state.
   * @param {EditorView} view    The editor view.
   * @param {string} html        The insert's markup.
   * @param {object} [options]
   * @param {boolean} [options.inline]  Whether the insert contains inline content, otherwise block content is assumed.
   * @protected
   */
  _placeInsert(state, dispatch, view, html, { inline }={}) {
    const { $cursor, $from, $to } = state.selection;
    let selection = "";
    if ( $from && $to ) selection = state.doc.textBetween($from.pos, $to.pos);

    // For inline inserts, eagerly replace the selection as <selection> cannot contain inline content.
    // TODO: Preserve marks in the selection.
    if ( inline ) html = html.replace(/<selection>(.*?)<\/selection>/, function(match, alt) {
      return selection || alt || "";
    });

    // Parse the markup as a ProseMirror Document.
    const parsed = ProseMirror.dom.parseString(html);
    let doc = parsed?.content?.content?.[0] ?? parsed;

    // Find the slot where the current selection should be placed, if any.
    let slot;
    doc.nodesBetween(0, doc.nodeSize - 2, (node, pos) => {
      if ( node.type === state.schema.nodes.selection ) slot = { node, pos };
    });

    if ( Number.isFinite(slot?.pos) ) {
      const slice = $cursor
        // If there is no selection, place the contents of the selection slot at the cursor position.
        ? slot.node.slice(0, slot.node.nodeSize - 2)
        // Otherwise replace the selection slot with the current selection contents.
        : state.doc.slice($from.start($from.depth), $to.end($to.depth));
      doc = new Transform(doc).replaceRange(slot.pos, slot.node.nodeSize + slot.pos, slice).doc;
    }

    // Place the insert into the editor.
    let tr = state.tr;
    if ( $cursor ) tr = tr.insert($cursor.pos, inline ? doc.content.content : doc);
    else if ( inline ) tr = tr.replaceSelection(doc.slice(0));
    else tr = tr.replaceSelectionWith(doc);

    // Place the cursor at the end of the newly-inserted content.
    let lastText;
    const suffix = html.includes(">") ? html.length - html.lastIndexOf(">") - 1 : 0;
    tr.doc.nodesBetween($from.pos, doc.nodeSize + $from.pos - suffix - 2, (node, pos) => {
      if ( node.type === state.schema.nodes.text ) lastText = { node, pos };
    });
    tr = tr.setSelection(TextSelection.create(tr.doc, lastText.pos + lastText.node.nodeSize));

    // Dispatch the transaction.
    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 a mark in the selection. Unlike the built-in toggleMark command, this will check for exact attribute
   * matches to determine toggling behavior.
   * @param {MarkType} markType  The mark to apply.
   * @param {Attrs|null} attrs   The mark's attributes.
   * @protected
   */
  _toggleMark(markType, attrs=null) {
    const { state } = this.view;
    const { from, to } = state.selection;
    const mark = markType.create(attrs);
    const hasMark = state.doc.rangeHasMark(from, to, mark);
    let tr = state.tr.removeMark(from, to, markType);
    if ( !hasMark ) tr = tr.addMark(from, to, mark);
    this.view.dispatch(tr);
  }

  /* -------------------------------------------- */

  /**
   * 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));
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Get the visible element from its action.
   * @param {string} action  The action.
   * @returns {HTMLElement}
   * @protected
   */
  getActionElement(action) {
    const document = this.view.dom.ownerDocument;
    const query = `[data-action="${action}"]`;

    // Case 1 - The button is inside an active drop-down.
    let button = document.getElementById("prosemirror-dropdown")?.querySelector(query);
    if ( button ) return button;

    const editor = this.view.dom.closest(".editor");
    // Case 2 - The button is inside an inactive drop-down.
    button ??= editor.querySelector(`li:not([hidden]) > ul > li${query}`);

    // Case 3 - The button is on the menu bar.
    button ??= editor.querySelector(`button${query}`);
    return button;
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14 until v16
   * @ignore
   */
  static eventListeners() {
    foundry.utils.logCompatibilityWarning("ProseMirrorMenu.eventListeners is deprecated. "
      + "Please use ProseMirrorMenu.activateListeners instead.", { since: 14, until: 16 });
    this.activateListeners(document, { _deprecated: true });
  }
}

/**
 * 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 Object.entries(attrs).every(([k, v]) => foundry.utils.equals(nodeAttrs[k], v));
      return true;
    }
  }
  return false;
};

/* -------------------------------------------- */

/**
 * Determine if this position is in the first node of the Document.
 * @this {ResolvedPos}
 * @returns {boolean}
 */
function isFirstNode() {
  let { depth } = this;
  while ( depth && !this.index(depth--) );
  return !depth;
}

/* -------------------------------------------- */

/**
 * Determine if this position is in the last node of the Document.
 * @this {ResolvedPos}
 * @returns {boolean}
 */
function isLastNode() {
  let { depth } = this;
  while ( depth-- ) {
    const parent = this.node(depth);
    const index = this.index(depth);
    if ( index < parent.childCount - 1 ) return false;
  }
  return true;
}

/* -------------------------------------------- */

Object.defineProperties(ResolvedPos.prototype, {
  isFirstNode: { get: isFirstNode },
  isLastNode: { get: isLastNode }
});

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"]
};

/* -------------------------------------------- */

const icon = {
  inline: true,
  group: "inline",
  parseDOM: [{
    tag: "i",
    getAttrs: el => {
      if ( !el.className || !el.matches(":empty") ) return false;
    }
  }],
  toDOM: () => ["i"]
};

const ol = {
  content: "list_item+",
  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+",
  group: "block",
  parseDOM: [{tag: "ul"}],
  toDOM: () => ["ul", 0]
};

/* -------------------------------------------- */

const li = {
  content: "paragraph block*",
  defining: true,
  parseDOM: [{tag: "li"}],
  toDOM: () => ["li", 0]
};

const builtInTableNodes = tableNodes({
  tableGroup: "block",
  cellContent: "block+"
});

const selection = {
  content: "block*",
  group: "block",
  defining: true,
  parseDOM: [{ tag: "selection" }],
  toDOM: () => ["selection", 0]
};

// These nodes are supported for HTML preservation purposes, but do not have robust editing support for now.

const dl = {
  content: "(dl_group|dt|dd)*",
  group: "block",
  defining: true,
  parseDOM: [{tag: "dl"}],
  toDOM: () => ["dl", 0]
};

/* -------------------------------------------- */

const dl_group = {
  content: "(dt|dd)*",
  group: "block",
  defining: true,
  parseDOM: [{
    tag: "div",
    getAttrs: el => {
      if ( !el.querySelector(":scope > dt") && !el.querySelector(":scope > dd") ) return false;
    }
  }],
  toDOM: () => ["div", 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: "image? 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.parse(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",
      getAttrs: el => {
        if ( el.className && el.matches(":empty") ) return false;
      }
    },
    {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 cite = {
  parseDOM: [{tag: "cite"}],
  toDOM: () => ["cite", 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 => {
      const { color, fontFamily, fontSize } = el.style;
      // If only one of these styles is set, we can handle this element with a specific mark. Otherwise, we handle it
      // with a generic mark that preserves the styles.
      if ( [color, fontFamily, fontSize].filter(_ => _).length === 1 ) return false;
      return {};
    }
  }],
  toDOM: () => ["span", 0]
};

/* -------------------------------------------- */

const font = {
  attrs: {
    family: {}
  },
  managed: {
    styles: ["font-family"]
  },
  parseDOM: [{
    tag: '*[style*="font-family:"]',
    getAttrs: el => ({ family: el.style.fontFamily.replace(/["']/g, "") })
  }],
  toDOM: node => ["span", {style: `font-family: '${node.attrs.family}'`}]
};

/* -------------------------------------------- */

const size = {
  attrs: {
    size: {}
  },
  managed: {
    styles: ["font-size"]
  },
  parseDOM: [{
    tag: '*[style*="font-size:"]',
    getAttrs: el => ({ size: el.style.fontSize })
  }],
  toDOM: node => ["span", {style: `font-size: ${node.attrs.size}`}]
};

/* -------------------------------------------- */

const color = {
  attrs: {
    color: {}
  },
  managed: {
    styles: ["color"]
  },
  parseDOM: [{
    tag: '*[style*="color:"]',
    getAttrs: el => ({ color: el.style.color })
  }],
  toDOM: node => ["span", {style: `color: ${node.attrs.color}`}]
};

/**
 * 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;
  }
}

/* -------------------------------------------- */

/**
 * 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(" ");
}

/**
 * @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.classes ??= { default: "" };
    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, {
          classes: this._captureClasses(el, spec.managed),
          _preserve: this._captureAttributes(el, spec.managed)
        });
        return attrs;
      };
    });
    const toDOM = spec.toDOM;
    spec.toDOM = node => {
      const domSpec = toDOM(node);
      const attrs = domSpec[1];
      const classes = node.attrs.classes ?? "";
      const preserved = node.attrs._preserve ?? {};
      if ( preserved.style ) preserved.style = preserved.style.replaceAll('"', "'");
      if ( isPlainObject(attrs) ) {
        domSpec[1] = mergeObject(preserved, attrs, { inplace: false });
        if ( ("style" in preserved) && ("style" in attrs) ) domSpec[1].style = mergeStyle(preserved.style, attrs.style);
        if ( classes ) domSpec[1].class = "class" in attrs ? mergeClass(classes, attrs.class) : classes;
      } else {
        const attrs = { ...preserved };
        if ( classes ) attrs.class = classes;
        domSpec.splice(1, 0, attrs);
      }
      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 {Record<string, string>}
   * @internal
   */
  _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.
      if ( attr.name === "class" ) return obj; // Classes are handled separately.
      // 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 === "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("; ");
        if ( !obj.style ) delete obj.style;
        return obj;
      }
      obj[attr.name] = attr.value;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Capture all classes present on an HTML element.
   * @param {HTMLElement} el                 The element.
   * @param {ManagedAttributesSpec} managed  An object containing the classes that are managed by the ProseMirror node
   *                                         and should not be preserved.
   * @returns {string}
   * @internal
   */
  _captureClasses(el, managed={}) {
    const { classes=[] } = managed;
    return classesFromString(el.className).filter(cls => !classes.includes(cls)).join(" ");
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }
}

/**
 * A class responsible for encapsulating logic around image-link nodes in the ProseMirror schema.
 * @extends {SchemaDefinition}
 */
class ImageLinkNode extends SchemaDefinition {
  /** @override */
  static tag = "a";

  /* -------------------------------------------- */

  /**
   * Cached attribute capture instance.
   * @type {AttributeCapture}
   */
  static #capture = new AttributeCapture();

  /* -------------------------------------------- */

  /** @override */
  static get attrs() {
    const attrs = mergeObject(ImageNode.attrs, LinkMark.attrs);
    attrs._img = { default: null };
    return 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;
    attrs._img = ImageLinkNode.#capture._captureAttributes(el.children[0], {
      styles: ["float"], classes: ["centered"]
    });
    return attrs;
  }

  /* -------------------------------------------- */

  /** @override */
  static toDOM(node) {
    const spec = LinkMark.toDOM(node);
    const img = ImageNode.toDOM(node);
    const [, attrs] = img;
    mergeObject(attrs, node.attrs._img);
    spec.push(img);
    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;
  }
}

/**
 * ProseMirror implementation of the HTML disclosure widget.
 */
class DisclosureWidget {
  /**
   * @param {Node} node            The node this view represents.
   * @param {EditorView} view      The parent EditorView.
   * @param {() => number} getPos  A function that returns the node's current position.
   */
  constructor(node, view, getPos) {
    this.#node = node;
    this.#view = view;
    this.dom = this.contentDOM = view.dom.ownerDocument.createElement("details");
    this.getPos = getPos;
    if ( node.attrs.open ) this.dom.open = true;
    this.dom.addEventListener("click", this.#onClick.bind(this));
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The DOM node to render the document node's children into.
   * @type {HTMLDetailsElement}
   */
  contentDOM;

  /* -------------------------------------------- */

  /**
   * The outer DOM node that represents the document node.
   * @type {HTMLDetailsElement}
   */
  dom;

  /* -------------------------------------------- */

  /**
   * A function that returns the node's current position.
   * @type {() => number}
   */
  getPos;

  /* -------------------------------------------- */

  /**
   * The ProseMirror document node represented by this view.
   * @type {Node}
   */
  #node;

  /* -------------------------------------------- */

  /**
   * The EditorView this NodeView belongs to.
   * @type {EditorView}
   */
  #view;

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * When the EditorView updates itself, determine if this view can update to the given node.
   * @param {Node} node  The node.
   * @returns {boolean}
   */
  update(node) {
    return node.type === this.#view.state.schema.nodes.details;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle interaction with the disclosure widget while inside an editor view.
   * @param {PointerEvent} event  The triggering event.
   */
  #onClick(event) {
    if ( !event.target.closest("summary") && (event.target.tagName !== "DETAILS") ) return;
    const attrs = { ...this.#node.attrs };
    if ( attrs.open ) delete attrs.open;
    else attrs.open = true;
    const tr = this.#view.state.tr.setNodeMarkup(this.getPos(), null, attrs);
    this.#view.dispatch(tr);
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Static instantiator function for the NodeView that can be passed to a new EditorView.
   * @param {Node} node            The node this view represents.
   * @param {EditorView} view      The parent EditorView.
   * @param {() => number} getPos  A function that returns the node's current position.
   * @returns {DisclosureWidget}
   */
  static view(node, view, getPos) {
    return new DisclosureWidget(node, view, getPos);
  }

  /* -------------------------------------------- */
  /*  Schema                                      */
  /* -------------------------------------------- */

  /**
   * Return the specs for the disclosure widget nodes.
   * @type {Record<string, NodeSpec>}
   */
  static get nodes() {
    return {
      details: {
        group: "block",
        content: "summary? block*",
        managed: {
          attributes: ["open"]
        },
        attrs: {
          open: { default: false }
        },
        parseDOM: [{
          tag: "details",
          getAttrs: el => ({ open: el.hasAttribute("open") })
        }],
        toDOM: node => node.attrs.open ? ["details", { open: "" }, 0] : ["details", 0]
      },
      summary: {
        content: "inline*",
        parseDOM: [{ tag: "summary" }],
        toDOM: () => ["summary", 0]
      }
    };
  }
}

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, icon,
  hard_break: br,

  // Lists.
  ordered_list: ol, bullet_list: ul, list_item: li,

  // Disclosure
  ...DisclosureWidget.nodes,

  // Tables
  ...builtInTableNodes,

  // Inserts
  selection,

  // Misc.
  dl, dl_group, 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, size, color, cite, 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", "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').
    const [tagName, ...tail] = spec;
    const attrs = isPlainObject(tail[0]) ? 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);
  }
}

/**
 * @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 {Fragment} 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({
      key: this.key,
      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({
      key: this.key,
      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({
      key: this.key,
      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({
      key: this.key,
      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({
      key: this.key,
      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({
      key: this.key,
      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);
      }
    });
  }
}

/**
 * A plugin for the chat message editor which handles interactivity.
 * @extends {ProseMirrorPlugin}
 */
class ChatInputPlugin extends ProseMirrorPlugin {
  /**
   * @param {Schema} schema  The ProseMirror schema to build the plugin against.
   * @param {ChatLog} chat   The ChatLog instance this plugin belongs to.
   */
  constructor(schema, chat) {
    super(schema);
    this.#chat = chat;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The ChatLog instance this plugin belongs to.
   * @type {ChatLog}
   */
  get chat() {
    return this.#chat;
  }

  #chat;

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Build the plugin.
   * @param {Schema} schema           The ProseMirror schema to build the plugin against.
   * @param {object} [options]
   * @param {ChatLog} [options.chat]  The ChatLog instance this plugin belongs to.
   * @returns {Plugin}
   */
  static build(schema, { chat }={}) {
    const plugin = new this(schema, chat);
    return new Plugin({
      appendTransaction: plugin._inspectTransactions.bind(plugin),
      instance: plugin,
      key: this.key,
      props: {
        handleKeyDown: plugin._onKeyDown.bind(plugin)
      }
    });
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Inspect transactions and update pending state if they involve insertions or deletions.
   * @param {Transaction[]} transactions  The transactions.
   * @param {EditorState} oldState        The editor state before.
   * @param {EditorState} newState        The editor state after.
   * @protected
   */
  _inspectTransactions(transactions, oldState, newState) {
    let modifiedPending = false;
    for ( const transaction of transactions ) {
      const { noHistory } = transaction.getMeta(this.constructor.key) ?? {};
      if ( noHistory ) return;
      if ( transaction.steps.some(s => s instanceof ReplaceStep) ) {
        modifiedPending = true;
        break;
      }
    }
    if ( modifiedPending ) this.chat.history.pending = serializeHTMLString(newState.doc.content);
  }

  /* -------------------------------------------- */

  /**
   * Handle keydown events.
   * @param {EditorView} view      The editor view.
   * @param {KeyboardEvent} event  The keyboard event.
   * @returns {boolean|void}
   * @protected
   */
  _onKeyDown(view, event) {
    if ( event.isComposing ) return; // Ignore IME composition.

    const inputOptions = { recordPending: true };
    if ( Hooks.call("chatInput", event, inputOptions) === false ) {
      if ( event.defaultPrevented || (event.key === "Enter") ) return true;
      if ( !event.key.startsWith("Arrow") && !inputOptions.recordPending ) return true;
      return;
    }

    if ( event.shiftKey || !["ArrowDown", "ArrowUp", "Enter"].includes(event.key) ) return;

    switch ( event.key ) {
      case "ArrowUp": case "ArrowDown":
        return this.#recallMessage(view, event);

      case "Enter":
        this.sendMessage(view);
        return true;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle navigating the chat history.
   * @param {EditorView} view      The editor view.
   * @param {KeyboardEvent} event  The keyboard event.
   * @returns {boolean|void}
   */
  #recallMessage(view, event) {
    let { index } = this.chat.history;
    const { pending, queue } = this.chat.history;
    if ( pending || !queue.length ) return;
    const dir = event.key === "ArrowUp" ? 1 : -1;
    index = Math.clamp(index + dir, -1, queue.length - 1);
    this.chat.history.index = index;
    this.setMessage(view, queue[index] || "", { noHistory: true });
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Record a message in the history.
   * @param {string} message  The message.
   */
  #recordMessage(message) {
    const { queue } = this.chat.history;
    if ( queue.length >= this.chat.constructor.MAX_MESSAGE_HISTORY ) queue.pop();
    queue.unshift(message);
    this.chat.history.index = -1;
  }

  /* -------------------------------------------- */

  /**
   * Handle sending a chat message.
   * @param {EditorView} view  The editor view.
   */
  async sendMessage(view) {
    const message = serializeHTMLString(view.state.doc.content);
    if ( !message ) return;
    try {
      await this.chat.processMessage(message);
      this.#recordMessage(message);
      this.setMessage(view, "");
    } catch(e) {
      Hooks.onError("ChatLog#processMessage", e, { notify: "error", log: "error", message });
    }
  }

  /* -------------------------------------------- */

  /**
   * Set the contents of the chat input to the given value.
   * @param {EditorView} view  The editor view.
   * @param {string} message   The message to set.
   * @param {object} meta      Any metadata to append to the transaction.
   */
  setMessage(view, message, meta) {
    const { state } = view;
    let tr = state.tr.replaceWith(0, state.doc.content.size, parseHTMLString(message).content);
    if ( meta ) tr = tr.setMeta(this.constructor.key, meta);
    view.dispatch(tr);
  }
}

/**
 * A ProseMirror menu implementation specialized for the chat editor.
 * @extends {ProseMirrorMenu}
 */
class ChatMenuPlugin extends ProseMirrorMenu {
  /** @override */
  static key = new PluginKey(this.name);

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  render() {
    super.render();
    this.view.dom.closest(".editor").querySelector("menu .format [data-action=insert]").hidden = true;
    return this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getMenuItems() {
    const items = super._getMenuItems();
    for ( const item of items ) {
      switch ( item.action ) {
        // Always show these items.
        case "horizontal-rule": case "image": case "link":
          delete item.weight;
          break;

        case "source-code":
          item.cmd = this._editSource.bind(this);
          break;
      }
    }
    return items;
  }

  /* -------------------------------------------- */

  /**
   * Spawn a dialog for editing the message's source HTML.
   * @protected
   */
  _editSource() {
    const dialog = new foundry.applications.api.DialogV2({
      content: '<code-mirror language="html"></code-mirror>',
      modal: true,
      position: {
        width: 800,
        height: 600
      },
      window: {
        contentClasses: ["flexcol"],
        icon: "fa-solid fa-code",
        resizable: true
      },
      buttons: [{
        action: "send",
        label: "CHAT.SendMessage",
        icon: "fa-solid fa-arrow-down-to-bracket",
        type: "button",
        callback: this.#sendMessage.bind(this)
      }, {
        action: "save",
        label: "EDITOR.Save",
        icon: "fa-regular fa-floppy-disk",
        type: "submit",
        default: true,
        callback: this.#saveMarkup.bind(this)
      }]
    });
    dialog.addEventListener("render", event => {
      const { element } = event.target;
      element.querySelector(".dialog-form").classList.add("flexcol", "flex1");
      element.querySelector(".dialog-content").classList.add("flex1");
      element.querySelector("code-mirror").value = serializeHTMLString(this.view.state.doc.content, { spaces: 4 });
    });
    dialog.addEventListener("close", () => this.view.focus());
    dialog.render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle saving the edited source markup.
   * @param {SubmitEvent} event         The dialog submission event.
   * @param {HTMLButtonElement} button  The button that was clicked.
   * @param {DialogV2} dialog           The dialog instance.
   */
  #saveMarkup(event, button, dialog) {
    const editor = dialog.element.querySelector("code-mirror");
    this.#getChatInput().setMessage(this.view, editor.value);
  }

  /* -------------------------------------------- */

  /**
   * Handle sending a chat message directly from the markup editor.
   * @param {SubmitEvent} event         The dialog submission event.
   * @param {HTMLButtonElement} button  The button that was clicked.
   * @param {DialogV2} dialog           The dialog content.
   * @returns {Promise<void>}
   */
  #sendMessage(event, button, dialog) {
    this.#saveMarkup(event, button, dialog);
    return this.#getChatInput().sendMessage(this.view);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve the chat input plugin instance from the view state.
   * @returns {ChatInputPlugin}
   */
  #getChatInput() {
    return ChatInputPlugin.key.get(this.view.state).spec.instance;
  }
}

var chat$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ChatInputPlugin: ChatInputPlugin,
  ChatMenuPlugin: ChatMenuPlugin
});

/**
 * @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 {number} [weight]           The relative importance of an entry. Lower-weight entries are collapsed under
 *                                       menus before higher-weight entries when the viewport is constrained. Entries
 *                                       with no weight are never collapsed.
 * @property {string} [menu]             An associated menu item that this entry collapses under. When this entry is
 *                                       visible, its associated menu item is hidden.
 * @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.
 */

/**
 * @typedef ProseMirrorMenuResizeState
 * @property {number} hiddenWidth                 The widths of menu items that are currently hidden.
 * @property {Record<string, HTMLElement>} menus  Cached references to rendered drop-down menu entries.
 * @property {ResizeObserver} observer            The resize observer.
 * @property {{ elements: HTMLElement[], menu: string, weight: number, width: number }[]} sizes  Cached references to
 *                                                rendered menu items and their sizes, ordered by weight and grouped by
 *                                                menu.
 */

/**
 * @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$e = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/** @module prosemirror */


const dom = {
  parser: DOMParser$1.fromSchema(schema),
  serializer: DOMSerializer.fromSchema(schema),
  parseString: parseHTMLString,
  serializeString: serializeHTMLString
};

const nodeViews = {
  details: DisclosureWidget.view
};

const plugins = {
  ProseMirrorPlugin, ProseMirrorContentLinkPlugin, ProseMirrorHighlightMatchesPlugin,
  ProseMirrorDirtyPlugin, ProseMirrorImagePlugin, ProseMirrorClickHandler, ProseMirrorPasteTransformer,
  ProseMirrorInputRules, ProseMirrorKeyMaps, ProseMirrorMenu, ProseMirrorDropDown,
  chat: chat$1, dropCursor, gapCursor, history: history$1, keymap
};

var _module$U = /*#__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,
  defaultSchema: schema,
  dom: dom,
  input: index$2,
  keymap: keymap,
  list: index$3,
  nodeViews: nodeViews,
  plugins: plugins,
  state: index$4,
  tables: index$5,
  transform: index$6,
  types: _types$e
});

/**
 * @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$d = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * @import HexagonalGrid from "./hexagonal.mjs";
 * @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) {
    /**
     * 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);
    if ( !Number.isInteger(this.cube.q) || !Number.isInteger(this.cube.r) || !Number.isInteger(this.cube.s)
      || (this.cube.q + this.cube.r + this.cube.s) !== 0 ) {
      throw new Error(`Invalid GridHex coordinates: q=${this.cube.q} r=${this.cube.r} s=${this.cube.s}`);
    }

    /**
     * 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.
   * At least two of (dq, dr, ds) must be passed, an undefined third parameter may be inferred.
   * If all three are passed, they must sum to zero to represent a valid cube shift.
   * @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;
    dq ??= -(dr + ds);
    dr ??= -(dq + ds);
    ds ??= -(dq + dr);
    if ( (dq + dr + ds) !== 0 ) throw new Error("Invalid cube shift: dq + dr + ds must equal zero");
    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$T = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BaseGrid: BaseGrid,
  GridHex: GridHex,
  GridlessGrid: GridlessGrid,
  HexagonalGrid: HexagonalGrid,
  SquareGrid: SquareGrid,
  types: _types$d
});

/* -------------------------------------------- */
/*  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: {}}});

/* -------------------------------------------- */

/**
 * A mapping of template paths to IDs they may be registered under.
 * @type {Record<string, Set<string>>}
 */
Object.defineProperties(Handlebars, { templateIds: { 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 ) {
        const err = new Error(resp.error);
        if ( resp.path !== undefined ) err.path = resp.path;
        if ( resp.stack ) err.stack = resp.stack;
        return reject(new Error(`Failed to load template "${path}":\n${err.message}`, {cause: err}));
      }
      const compiled = Handlebars.compile(resp.html, {preventIndent: true});
      Handlebars.registerPartial(id, compiled);
      Handlebars.templateIds[path] ??= new Set();
      Handlebars.templateIds[path].add(id);
      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) {
  const promises = foundry.utils.isPlainObject(paths)
    ? Object.entries(paths).map(([k, p]) => getTemplate(p, k))
    : 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,
    concat,
    editor,
    formInput,
    formGroup,
    formField: formGroup, // Alias
    filePicker,
    ifThen,
    localize,
    numberFormat,
    numberInput,
    object,
    radioBoxes,
    rangePicker,
    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 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="prosemirror"]  The editor engine to use, see
 *   {@link foundry.applications.ux.TextEditor.create}. Default: `"prosemirror"`.
 * @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="prosemirror", 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 = foundry.utils.isEmpty(options.hash) ? undefined : options.hash;
  return _loc(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 = _loc(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, valueAttr, labelAttr, inverted, groups} = options.hash;

  // Normalize
  if ( (selected === undefined) || (selected === null) ) selected = [];
  else if ( selected instanceof Set ) selected = Array.from(selected);
  else if ( !Array.isArray(selected) ) selected = [selected];

  // Prepare the choices as an array of objects
  const selectOptions = [];
  if ( Array.isArray(choices) ) {
    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, stacked, units, hidden, widget, ...inputConfig} = options.hash;
  const groupConfig = {label, hint, rootId: inputConfig.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: 16, 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 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,
  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,
  selectOptions: selectOptions
});

/**
 * @import { DetachedWindowOptions } from "../_types.mjs";
 */

/**
 * @typedef DetachedWindowDescriptor
 * @property {WindowProxy} window                       The window instance.
 * @property {Map<string, ApplicationV2>} applications  A mapping of applications rendered in the window.
 */

/**
 * A singleton class that provides an API for spawning and managing detached windows.
 */
class DetachedWindowManager {
  constructor() {
    if ( globalThis.foundry?.applications.detached ) {
      throw new Error("You may not re-construct the singleton DetachedWindowManager.");
    }
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The currently-focused window.
   * @type {WindowProxy|null}
   */
  get focused() {
    if ( document.hasFocus() ) return document.defaultView;
    for ( const { window: win } of this.#windows.values() ) {
      if ( win.document.hasFocus() ) return win;
    }
    return null;
  }

  /* -------------------------------------------- */

  /**
   * A registry of detached window instances.
   * @type {Map<string, DetachedWindowDescriptor>}
   */
  get windows() {
    return this.#windows;
  }

  #windows = new Map();

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Check if a detached window contains any applications, and closes it if there are none.
   * @param {WindowProxy} win  The window.
   */
  checkEmpty(win) {
    if ( win?.id && !this.#windows.get(win.id)?.applications?.size ) win.close();
  }

  /* -------------------------------------------- */

  /**
   * Open a window detached from the main application window.
   * @param {DetachedWindowOptions} [options]
   * @returns {Promise<WindowProxy>}
   */
  openWindow({ id, position={}, timeout=10_000, source=window }={}) {
    id ??= foundry.utils.randomID();
    const { top=0, left=0, width=1024, height=1024 } = position;
    const attrs = ["popup", Object.entries({ top, left, width, height }).map(([k, v]) => {
      return `${k}=${Math.max(v, 0)}`;
    })].join(",");
    // If the source is a known detached window, dispatch a custom event so that window.open() is called from within
    // that window's execution context.
    let win;
    if ( this.#windows.has(source.id) ) {
      const event = new CustomEvent("foundry:openChildWindow", { detail: { id, attrs } });
      source.document.dispatchEvent(event);
      win = event.result ?? null;
    }
    else win = source.open("detached/index.html", id, attrs);
    const { promise, resolve, reject } = Promise.withResolvers();
    if ( !win || win.closed ) {
      focus();
      ui.notifications.error("ERROR.DETACHED.PopupBlocked", { localize: true, console: false });
      reject(new Error(`Failed to load detached window '${id}'.`));
      return promise;
    }
    const wait = setTimeout(() => {
      ui.notifications.error("ERROR.DETACHED.TimedOut", { localize: true, console: false });
      reject(new Error(`Attempting to load detached window '${id}' timed out.`));
      win?.close();
    }, timeout);
    win.id = id;
    win.addEventListener("DOMContentLoaded", async () => {
      clearTimeout(wait);
      try {
        await this.#applyHarness(win);
      } catch(err) {
        return reject(err);
      }
      this.#windows.set(id, { window: win, applications: new Map() });
      Hooks.callAll("openDetachedWindow", id, win);
      resolve(win);
    }, { once: true, passive: true });
    return promise;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Adopt nodes into a new host window and append them to the given target.
   * @param {HTMLElement} target  The target.
   * @param {...Node} nodes       The nodes.
   */
  adoptNodes(target, ...nodes) {
    const document = target.ownerDocument;
    for ( const node of nodes ) target.append(document.adoptNode(node));
  }

  /* -------------------------------------------- */

  /**
   * Copy attributes between two elements.
   * @param {HTMLElement} from                  The element to copy from.
   * @param {HTMLElement} to                    The element to copy to.
   * @param {object} [options]
   * @param {Iterable<string>} [options.attrs]  Copy only the specified attributes.
   */
  copyAttributes(from, to, { attrs }={}) {
    for ( const attr of attrs ?? from.attributes ) {
      const name = typeof attr === "string" ? attr : attr.name;
      to.setAttribute(name, from.getAttribute(name));
    }
  }

  /* -------------------------------------------- */

  /**
   * Import nodes into a new host window and append them to the given target.
   * @param {HTMLElement} target  The target.
   * @param {...Node} nodes       The nodes.
   */
  importNodes(target, ...nodes) {
    const document = target.ownerDocument;
    for ( const node of nodes ) target.append(document.importNode(node, true));
  }

  /* -------------------------------------------- */

  /**
   * Performs a DOM query across all detached windows and the main workspace, and return the first match.
   * @param {string} selector  The query selector.
   * @returns {HTMLElement|null}
   */
  querySelector(selector) {
    const match = document.querySelector(selector);
    if ( match ) return match;
    for ( const { window: win } of this.windows.values() ) {
      const match = win.document.querySelector(selector);
      if ( match ) return match;
    }
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Perform a DOM query across all detached windows and the main workspace, and return all matches.
   * @param {string} selector  The query selector.
   * @returns {HTMLElement[]}
   */
  querySelectorAll(selector) {
    const matches = [...document.querySelectorAll(selector)];
    for ( const { window: win } of this.windows.values() ) {
      matches.push(...win.document.querySelectorAll(selector));
    }
    return matches;
  }

  /* -------------------------------------------- */
  /*  Private API                                 */
  /* -------------------------------------------- */

  /**
   * Apply scaffolding to allow Applications rendered in the given window to operate correctly.
   * @param {WindowProxy} win  The detached window.
   * @returns {Promise<void>}
   */
  async #applyHarness(win) {
    const parent = globalThis.document;
    const { document } = win;

    // Set the route prefix.
    const base = document.createElement("base");
    base.href = foundry.utils.getRoute("/");
    document.head.append(base);

    // Add icon.
    const icon = document.createElement("link");
    Object.assign(icon, { rel: "icon", href: "icons/vtt.png" });
    document.head.append(icon);

    // Sync DOM.
    this.copyAttributes(parent.documentElement, document.documentElement);
    this.copyAttributes(parent.body, document.body);
    document.body.classList.add("detached");

    // Import styles.
    this.importNodes(document.head, ...parent.head.querySelectorAll('link[rel="stylesheet"], style'));

    // Apply harness.
    const harness = document.createElement("script");
    const harnessLoaded = Promise.withResolvers();
    harness.onload = harnessLoaded.resolve;
    harness.onerror = () => harnessLoaded.reject(new Error("Failed to load detached window harness."));
    Object.assign(harness, { src: "scripts/detached/harness.mjs", type: "module" });
    document.head.append(harness);
    await harnessLoaded.promise;

    // Bind global listeners.
    game.activateListeners(document);
    game.keyboard._activateListeners(document);
    foundry.applications.ux.ContextMenu.implementation.activateListeners(document);
    foundry.applications.ux.Autocomplete.implementation.activateListeners(document);
    foundry.prosemirror.ProseMirrorMenu.activateListeners(document);

    // Re-register custom elements.
    Object.values(foundry.applications.elements).forEach(Element => {
      if ( Element.tagName ) win.customElements.define(Element.tagName, Element);
    });

    // Load fonts.
    await foundry.applications.settings.menus.FontConfig._loadFonts({ document });
  }

  /* -------------------------------------------- */

  /**
   * Handle tear-down when a detached window is closed.
   * @param {WindowProxy} win  The window instance.
   * @internal
   */
  _onWindowClosed(win) {
    setTimeout(() => {
      if ( win.closed ) {
        const { applications } = this.#windows.get(win.id) ?? {};
        this.#windows.delete(win.id);
        applications?.values().forEach(app => app.close());
        Hooks.callAll("closeDetachedWindow", win.id, win);
      }
    }, 1000);
  }
}

/**
 * @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 {boolean} nameplates.playerName  Whether to show player names on nameplates.
 * @property {boolean} 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 ? _loc("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$S = /*#__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");
    const {
      _ALLOWED_ASSISTANT_KEYS: ALLOWED_ASSISTANT_KEYS,
      _GAMEMASTER_ONLY_KEYS: GAMEMASTER_ONLY_KEYS
    } = foundry.documents.BaseSetting;
    for ( const menu of game.settings.menus.values() ) {
      const restricted = menu.restricted;
      if ( restricted && !canConfigure && !(game.user.isGM && ALLOWED_ASSISTANT_KEYS.includes(menu.key)) ) continue;
      if ( GAMEMASTER_ONLY_KEYS.includes(menu.key) && !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 ) continue;
      const restricted = setting.scope === CONST.SETTING_SCOPES.WORLD;
      if ( restricted && !canConfigure && !(game.user.isGM && ALLOWED_ASSISTANT_KEYS.includes(setting.id)) ) continue;
      if ( GAMEMASTER_ONLY_KEYS.includes(setting.id) && !game.user.hasRole("GAMEMASTER") ) 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;
        data.input = setting.input;
      }
      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"],
          texture: ["TEXTURE"],
          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 ||= _loc(setting.name ?? "");
      data.field.hint ||= _loc(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: _loc("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: _loc("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>${_loc("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-user-lock"
    },
    position: {width: 420},
    form: {
      handler: DocumentOwnershipConfig.#onSubmitForm,
      closeOnSubmit: true
    },
    sheetConfig: false,
    ownershipConfig: 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 _loc("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: _loc(`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: _loc(`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: _loc(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: _replace(ownership)};
      });
      await cls.updateDocuments(updates, {noHook: true});
    }
    // Update a single Document
    else {
      const ownership = _replace(ownershipLevels);
      await document.update({ownership}, {noHook: true});
    }

  }
}

/**
 * @import {ApplicationConfiguration} from "../_types.mjs";
 * @import {RollParseNode, StringParseNode} from "../../dice/_types.mjs";
 */

/**
 * @typedef FormulaEditorConfiguration
 * @property {string} context  The formula editing context.
 * @property {string} formula  The formula being edited.
 */

/**
 * An application that provides improved formula editing capabilities.
 * @extends {ApplicationV2<ApplicationConfiguration & FormulaEditorConfiguration>}
 */
class FormulaEditor extends ApplicationV2 {
  constructor(options) {
    super(options);
    this.#context = CONFIG.formulaEditor.contexts[this.options.context] ?? {};
    const inverted = {};
    for ( const [k, v] of Object.entries(this.labels) ) {
      inverted[v.normalize("NFC").toLocaleLowerCase(game.i18n.lang)] = k;
    }
    this.#invertedLabels = Object.entries(inverted).sort(([a], [b]) => b.length - a.length);
    this.#tree = this.#buildWordTree(inverted);
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    id: "formula-editor",
    tag: "dialog",
    classes: ["dialog", "formula-editor"],
    window: {
      icon: "fa-solid fa-lambda",
      contentClasses: ["flexcol"],
      resizable: true,
      minimizable: false
    },
    position: {
      width: 400,
      height: 200
    },
    context: "default",
    formula: ""
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The regular expression to match formula data identifiers.
   * @type {RegExp}
   */
  static IDENTIFIER = /^@[\w.-]+$/;

  /**
   * The auto-complete menu.
   * @type {Autocomplete}
   */
  #autocomplete = new foundry.applications.ux.Autocomplete({ onSelect: this.#onAutocomplete.bind(this) });

  /**
   * Get the configured context for this formula editor.
   * @type {FormulaEditorContext}
   */
  get context() {
    return this.#context;
  }

  #context;

  /**
   * The active ProseMirror editor instance.
   * @type {EditorView}
   */
  #editor;

  /**
   * The current formula value.
   * @type {string}
   */
  get formula() {
    return this.constructor.toFormula(this.#editor.state);
  }

  /**
   * The inverted context object entries sorted by descending label length.
   * @type {Array<[string, string]>}
   */
  #invertedLabels;

  /**
   * Get the labels mapping for this context.
   * @type {Record<string, string>}
   */
  get labels() {
    return this.#context.labels ?? {};
  }

  /**
   * A word tree for prefix lookup.
   * @type {WordTree}
   */
  #tree;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _canAttach() {
    return false;
  }

  /* -------------------------------------------- */

  /** @override */
  _canDetach() {
    return false;
  }

  /* -------------------------------------------- */

  /** @override */
  _canRender(options) {
    if ( this.rendered ) return false;
  }

  /* -------------------------------------------- */

  /** @override */
  async _onFirstRender(context, options) {
    this.element.showModal();
  }

  /* -------------------------------------------- */

  /** @override */
  _replaceHTML(result, content, options) {
    const { EditorView } = foundry.prosemirror;
    const { EditorState, Plugin, TextSelection } = foundry.prosemirror.state;
    content.replaceChildren(result);
    const state = EditorState.create({ doc: this.constructor.schema.nodes.doc.create() });
    let tr = state.tr;
    if ( this.options.formula ) {
      tr = state.tr.insert(0, state.schema.text(this.options.formula));
      tr = this._replaceTerms(tr, state.schema);
    }
    tr = tr.setSelection(TextSelection.create(tr.doc, tr.doc.content.size)); // Set cursor to the end.
    const { doc, selection } = tr;
    this.#editor = new EditorView({ mount: result }, {
      state: EditorState.create({
        doc, selection,
        plugins: [new Plugin({ appendTransaction: this.#onTransaction.bind(this) })]
      })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderFrame(options) {
    const frame = await super._renderFrame(options);
    this.window.close.classList.remove("fa-xmark");
    this.window.close.classList.add("fa-floppy-disk");
    return frame;
  }

  /* -------------------------------------------- */

  /** @override */
  async _renderHTML(context, options) {
    const html = this.element.ownerDocument.createElement("div");
    html.autofocus = true;
    return html;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle selecting an auto-complete option.
   * @param {string} identifier        The selected entry's identifier.
   * @param {string} label             The selected entry's label.
   * @param {object} [options]
   * @param {string} [options.prefix]  The originally matched prefix.
   */
  #onAutocomplete(identifier, label, { prefix }={}) {
    const { state } = this.#editor;
    const { schema, selection } = state;
    const { data } = schema.marks;
    const { $cursor } = selection;
    if ( !$cursor || !prefix ) return;

    // Verify the text before the cursor matches the prefix.
    const end = $cursor.parentOffset;
    const textBefore = $cursor.parent.textBetween(0, end);
    const start = end - prefix.length;
    if ( start < 0 ) return;
    if ( textBefore.substring(start, end).toLocaleLowerCase(game.i18n.lang) !== prefix ) return;

    // Get the absolute position of the start of the prefix.
    const pos = $cursor.pos - prefix.length;

    // Fill in the rest of the autocomplete and wrap it in a data mark.
    let tr = state.tr
      .delete(pos, $cursor.pos)
      .insert(pos, schema.text(label, [data.create({ value: identifier })]));
    tr = tr.setSelection(foundry.prosemirror.state.Selection.near(tr.doc.resolve(pos + label.length)));
    this.#editor.dispatch(tr.setMeta("autocomplete", true));
  }

  /* -------------------------------------------- */

  /**
   * Handle state changes when editing the formula.
   * @param {Transaction[]} transactions  The editor transactions.
   * @param {EditorState} oldState        The editor's state before transactions were applied.
   * @param {EditorState} newState        The editor's state after transactions were applied.
   * @returns {Transaction|null|void}
   */
  #onTransaction(transactions, oldState, newState) {
    if ( transactions.some(tr => tr.getMeta("autocomplete")) ) return;
    let tr = newState.tr;
    const { schema } = newState;
    const { data } = schema.marks;

    // Mark formula data.
    tr = tr.removeMark(0, newState.doc.content.size);
    const formula = tr.doc.textContent.normalize("NFC").toLocaleLowerCase(game.i18n.lang);
    for ( const [label, identifier] of this.#invertedLabels ) {
      let pos = formula.indexOf(label);
      while ( pos > -1 ) {
        const end = pos + label.length;
        if ( !tr.doc.rangeHasMark(pos, end, data) ) {
          tr = tr.addMark(pos, end, data.create({ value: identifier }));
        }
        pos = formula.indexOf(label, pos + 1);
      }
    }

    // Handle auto-complete.
    const { $cursor } = newState.selection;
    if ( $cursor?.pos ) {
      let prefix;
      let pos = 0;
      for ( const node of newState.doc.content.content ) {
        const to = pos + node.nodeSize;
        if ( ($cursor.pos > pos) && ($cursor.pos <= to) ) {
          [prefix] = node.text.substring(0, $cursor.pos - pos).match(/[\p{L}\p{N}_ ]+$/u) ?? [];
          break;
        }
        pos = to;
      }
      prefix = prefix?.toLocaleLowerCase(game.i18n.lang).trim();
      const matches = prefix ? this.#tree.lookup(prefix.stripDiacritics()) : [];
      if ( matches.length ) {
        const coords = this.#editor.coordsAtPos($cursor.pos - 1);
        const entries = matches.map(identifier => ({ identifier, label: this.labels[identifier] }));
        this.#autocomplete.activate(this.element, entries, {
          prefix,
          position: { top: coords.bottom, left: coords.left }
        });
      }
      else this.#autocomplete.dismiss();
    }
    else this.#autocomplete.dismiss();

    return tr;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Build a word tree from the provided context.
   * @param {Record<string, string>} context  A mapping of human-readable terms to formula data paths.
   * @returns {WordTree}
   */
  #buildWordTree(context) {
    const tree = new WordTree();
    for ( const label in context ) tree.addLeaf(label.stripDiacritics(), context[label]);
    return tree;
  }

  /* -------------------------------------------- */

  /**
   * Parse the given ProseMirror document state and replace data terms with human-readable labels.
   * @param {Transaction} tr  The state transaction.
   * @param {Schema} schema   The ProseMirror document schema.
   * @returns {Transaction}
   * @protected
   */
  _replaceTerms(tr, schema) {
    let ast;
    const formula = tr.doc.textContent;
    try {
      ast = foundry.dice.RollGrammar.parse(formula);
    } catch {
      return tr;
    }
    const { data } = schema.marks;
    for ( const term of this.constructor._collectStringTerms(ast).reverse() ) {
      if ( (term.class !== "StringTerm") || !this.constructor.IDENTIFIER.test(term.term) ) continue;
      const identifier = term.term.slice(1);
      const label = this.labels[identifier.toLowerCase()];
      if ( !label ) continue;
      const { start, end } = term.offset;
      tr = tr.replaceWith(start, end, schema.text(label, [data.create({ value: identifier })]));
    }
    return tr;
  }

  /* -------------------------------------------- */
  /*  Parsing                                     */
  /* -------------------------------------------- */

  /**
   * Collect every string term in a roll parse tree, in the order that they appear in the formula.
   * @param {RollParseNode} node         The node to search.
   * @param {StringParseNode[]} [terms]  The terms collected so far.
   * @returns {StringParseNode[]}
   * @protected
   */
  static _collectStringTerms(node, terms=[]) {
    if ( !node?.class ) return terms;
    switch ( node.class ) {
      case "StringTerm":
        terms.push(node);
        break;
      case "Node":
        for ( const operand of node.operands ) this._collectStringTerms(operand, terms);
        break;
      case "FunctionTerm":
      case "PoolTerm":
        for ( const term of node.terms ) this._collectStringTerms(term, terms);
        break;
      case "ParentheticalTerm":
        this._collectStringTerms(node.term, terms);
        break;
      case "DiceTerm":
        this._collectStringTerms(node.number, terms);
        this._collectStringTerms(node.faces, terms);
        break;
    }
    return terms;
  }

  /* -------------------------------------------- */

  /**
   * The ProseMirror schema.
   * @type {Schema}
   */
  static get schema() {
    return new foundry.prosemirror.Schema({
      nodes: {
        doc: { content: "text*" },
        text: { inline: true }
      },
      marks: {
        data: {
          attrs: {
            value: { default: "" }
          },
          parseDOM: [{ tag: "data", getAttrs: el => ({ value: el.value }) }],
          toDOM: node => ["data", { value: node.attrs.value }]
        }
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Convert a ProseMirror document back into a formula string.
   * @param {EditorState} state  The ProseMirror document state.
   * @returns {string}
   */
  static toFormula(state) {
    let formula = "";
    if ( !state?.doc?.content?.size ) return formula;
    state.doc.forEach(node => {
      if ( !node.isText ) return;
      const data = node.marks.find(m => m.type === state.schema.marks.data);
      formula += data ? `@${data.attrs.value}` : node.text;
    });
    return formula;
  }
}

/**
 * @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: 600},
    form: {
      closeOnSubmit: true
    },
    actions: {
      addLevel: SceneConfig.#onAddLevel,
      capturePosition: SceneConfig.#onCapturePosition,
      editLevel: SceneConfig.#onEditLevel,
      toggleLinkDimensions: SceneConfig.#onToggleLinkDimensions,
      openGridConfig: SceneConfig.#onOpenGridConfig,
      removeLevel: SceneConfig.#onRemoveLevel,
      resetEnvironment: SceneConfig.#onResetEnvironment,
      transitionPlay: SceneConfig.#onTransitionPlay
    }
  };

  /** @override */
  static PARTS = {
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    basics: {template: "templates/scene/config/basics.hbs", scrollable: [""]},
    grid: {template: "templates/scene/config/grid.hbs"},
    levels: {template: "templates/scene/config/levels.hbs", scrollable: [".levels"]},
    visibility: {template: "templates/scene/config/visibility.hbs", scrollable: [""]},
    environment: {template: "templates/scene/config/environment.hbs", scrollable: [""]},
    misc: {template: "templates/scene/config/misc.hbs", scrollable: [""]},
    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: "levels", icon: "fa-solid fa-layer-group"},
        {id: "visibility", icon: "fa-solid fa-eye"},
        {id: "environment", icon: "fa-solid fa-sun-cloud"},
        {id: "misc", icon: "fa-solid fa-shapes"}
      ],
      initial: "basics",
      labelPrefix: "SCENE.TABS.SHEET"
    }
  };

  /* -------------------------------------------- */

  /**
   * 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
  };

  /* -------------------------------------------- */

  /**
   * The level to draw delegated fields from. This will be the viewed level if this scene is the viewed scene, otherwise
   * it will be the scene's active level.
   * @type {Level}
   */
  get defaultLevel() {
    if ( this.document.isView && canvas.level ) return canvas.level;
    return this.document.initialLevel;
  }

  /* -------------------------------------------- */

  /**
   * 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] = _loc(labels[key]);
      return types;
    }, {});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( !options.renderContext || !["createlevels", "updatelevels", "deletelevels"].includes(options.renderContext) ) return;
    options.parts = ["levels"];
    const defaultId = this.defaultLevel.id;
    const defaultChange = options.renderData?.some(l => defaultId === (typeof l === "string" ? l : l._id));
    if ( defaultChange ) options.parts.push("basics", "visibility");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.tabs = this._prepareTabs("sheet");
    context.tabClasses = "top-tabs";
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const defaultLevel = context.defaultLevel = this.defaultLevel?.toObject() ?? {};
    context.levelLabel = this.document.isView && canvas.level ? "SCENE.CurrentLevel" : "SCENE.InitialLevel";
    const levelTooltip = this.document.levels?.size > 1
      ? _loc("SCENE.InheritedField", { level: defaultLevel.name })
      : null;
    context.gridTypes = this.constructor._getGridTypes();
    context.environmentFields = context.fields.environment.fields;
    switch ( partId ) {
      case "basics":
        context.ownerships = [
          {value: 0, label: _loc("SCENE.AccessibilityGM")},
          {value: 2, label: _loc("SCENE.AccessibilityAll")}
        ];
        context.background = {
          color: {
            field: new foundry.data.fields.ColorField({
              nullable: false, label: "SCENE.FIELDS.backgroundColor.label"
            }, { name: "backgroundColor" }),
            tooltip: levelTooltip,
            value: defaultLevel.background?.color
          },
          src: {
            field: new foundry.data.fields.FilePathField({
              required: true, categories: ["TEXTURE"], virtual: true, label: "SCENE.FIELDS.background.src.label",
              hint: "SCENE.FIELDS.background.src.hint"
            }, { name: "background.src" }),
            tooltip: levelTooltip,
            value: defaultLevel.background?.src
          }
        };
        context.weatherTypes = Object.entries(CONFIG.weatherEffects).reduce((types, [key, value]) => {
          types[key] = _loc(value.label);
          return types;
        }, {});
        break;
      case "grid":
        context.pixelsLabel = _loc("MEASUREMENT.Pixels");
        context.minGrid = CONST.GRID_MIN_SIZE;
        context.gridStyles = Object.entries(CONFIG.Canvas.gridStyles).reduce((styles, [key, value]) => {
          styles[key] = _loc(value.label);
          return styles;
        }, {});
        break;
      case "levels":
        context.initialLevel = this.document.initialLevel.id;
        context.levels = this.document.levels.reverseSorted.map(l => {
          const { id, name, elevation } = l;
          const { top, bottom } = elevation;
          return {
            id, name,
            label: l.name,
            value: id,
            viewed: this.document.isView && canvas.level === l,
            meta: Number.isFinite(top) || Number.isFinite(bottom)
              ? `[${Number.isFinite(bottom) ? bottom.toNearest(.01).toLocaleString(game.i18n.lang) : "-∞"}, `
                + `${Number.isFinite(top) ? top.toNearest(.01).toLocaleString(game.i18n.lang) : "+∞"}]`
              : ""
          };
        });
      case "visibility":
        context.globalLight = context.source.environment.globalLight;
        context.fogFields = context.fields.fog.fields;
        context.fogExplorationModes = this.constructor._getFogExplorationModes();
        context.fog = {
          overlay: {
            field: new foundry.data.fields.FilePathField({
              categories: ["TEXTURE"], virtual: true, label: "SCENE.FIELDS.fog.overlay.label",
              hint: "SCENE.FIELDS.fog.overlay.hint"
            }, { name: "fog.overlay" }),
            tooltip: levelTooltip,
            value: defaultLevel.fog?.src
          }
        };
        break;
      case "environment":
        context.baseFields = context.fields.environment.fields.base.fields;
        context.darkFields = context.fields.environment.fields.dark.fields;
        context.baseHueDisabled = (context.source.environment.base.intensity === 0);
        context.darkHueDisabled = (context.source.environment.dark.intensity === 0);
        break;
      case "misc": {
        context.pages = this.#documentsToOptions(context.document.journal?.pages, {sortBy: "sort"});
        context.sounds = this.#documentsToOptions(context.document.playlist?.sounds, {sortBy: "name"});
        context.transitionTypes = Object.entries(CONFIG.Canvas.sceneTransitions).reduce((types, [key, value]) => {
          types[key] = _loc(value.label ?? `SCENE.Transition.Types.${key}`);
          return types;
        }, {});
        break;
      }
      case "footer":
        context.buttons = this.#prepareButtons();
    }
    if ( partId in context.tabs ) context.tab = context.tabs[partId];
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Get an enumeration of the available fog exploration modes for this Scene.
   * @returns {Record<FOG_EXPLORATION_MODES, string>}
   * @internal
   */
  static _getFogExplorationModes() {
    const prefix = "SCENE.FIELDS.fog.MODES";
    return Object.entries(CONST.FOG_EXPLORATION_MODES).reduce((modes, [key, value]) => {
      const k = `${prefix}.${key}`;
      modes[value] = game.i18n.has(k) ? game.i18n.localize(k) : key;
      return modes;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Prepare buttons that are presented for this application view.
   * @returns {FormFooterButton[]}
   */
  #prepareButtons() {
    return [
      {
        type: "button",
        icon: "fa-solid fa-arrow-rotate-left",
        label: "SCENE.Environment.Reset",
        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);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    const level = {};
    for ( const [sceneKey, levelKey] of foundry.documents.BaseScene._LEVELS_PROPERTY_MAP ) {
      const value = foundry.utils.getProperty(submitData, sceneKey);
      if ( value !== undefined ) foundry.utils.setProperty(level, levelKey, value);
    }
    if ( !foundry.utils.isEmpty(level) ) {
      level._id = this.defaultLevel?.id ?? this.document.constructor.metadata.defaultLevelId;
      submitData.levels = [level];
    }

    // Handle transition "None" special case
    if ( submitData.transition.type === "" ) {
      submitData.transition.type = null;
    }

    return submitData;
  }

  /* -------------------------------------------- */

  /** @override */
  async _processSubmitData(event, form, submitData, options) {
    const scene = this.document;
    const current = scene._source;
    const initialLevelId = submitData.initialLevel ?? current.initialLevel;
    const initialLevel = (initialLevelId ? scene.levels.get(initialLevelId) : null) ?? scene.firstLevel;
    const backgrounds = new Map();
    for ( const changed of (submitData.levels ?? []) ) {
      if ( changed.background?.src ) backgrounds.set(changed._id, changed.background.src);
    }
    const getLevelBackground = level => backgrounds.get(level?.id) ?? level?.background?.src;

    // Ensure submitted background textures are loaded before accessing them from cache or generating thumbnails.
    if ( backgrounds.size && !game.settings.get("core", "noCanvas") ) {
      await foundry.canvas.TextureLoader.loader.load(Array.from(backgrounds.values()), {
        localize: true,
        message: "SCENE.DeterminingDimensions"
      });
    }

    // Auto-set scene dimensions to the max of all submitted background texture dimensions if the scene has default
    // dimensions, or if the dimensions have been explicitly cleared.
    const hasDefaultDims = scene.levels.every(l => !l.background?.src)
      && (current.width === 4000) && (current.height === 3000);
    const clearedDims = (submitData.width === null) || (submitData.height === null);
    if ( backgrounds.size && (clearedDims || hasDefaultDims) ) {
      let width = 0;
      let height = 0;
      for ( const src of backgrounds.values() ) {
        const texture = foundry.canvas.getTexture(src);
        if ( texture ) {
          width = Math.max(width, texture.width);
          height = Math.max(height, texture.height);
        }
      }
      if ( width && height ) {
        submitData.width = width;
        submitData.height = height;
      }
    }

    // Generate a thumbnail if the scene doesn't already have one and has a background image on any level.
    if ( !current.thumb ) {
      // Find the level whose background to use: initial level > viewed level > any level.
      const srcLevel = getLevelBackground(initialLevel)
        ? initialLevel
        : scene.isView && getLevelBackground(canvas.level)
          ? canvas.level
          : scene.levels.find(getLevelBackground);

      if ( srcLevel ) {
        if ( game.settings.get("core", "noCanvas") ) {
          ui.notifications.warn("SCENE.GenerateThumbNoCanvas", { localize: true });
        } else {
          try {
            const clone = scene.clone();
            for ( const changed of (submitData.levels ?? []) ) clone.levels.get(changed._id)?.updateSource(changed);
            if ( submitData.width ) clone.updateSource({ width: submitData.width, height: submitData.height });
            const { thumb } = await clone.createThumbnail({ level: srcLevel.id });
            submitData.thumb = thumb;
          } catch(error) {
            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);
    if ( "grid.size" in changes ) {
      const confirm = await foundry.applications.api.DialogV2.confirm({
        window: {title: "SCENE.DimensionChangeTitle"},
        content: `<p>${_loc("SCENE.DimensionChangeWarning")}</p>`
      });
      if ( !confirm ) return {};
    }

    // Handle nonuniform canvas size changes
    let autoReposition = false;
    const hasContent = scene.levels.some(l => l.background?.src || l.foreground?.src);
    if ( hasContent && ["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: _loc("SCENE.DistortedDimensionsWarning")
        });
        if ( !confirm ) autoReposition = false;
      }
    }

    // Remove unneeded fields and perform the update
    delete submitData.environment.darknessLock;
    const updated = await scene.update(submitData, {...options, autoReposition});
    return updated ? {updated} : {};
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Retrieve the scene level from a DOM event.
   * @param {Event} event  The event.
   * @returns {Level}
   */
  #getEventLevel(event) {
    const { levelId } = event.target.closest("[data-level-id]")?.dataset ?? {};
    return this.document.levels.get(levelId);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    this._createContextMenu(this._getLevelContextOptions, ".levels .level", {
      hookName: "getLevelContextOptions", parentClassHooks: false, fixed: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.#onChangeTab();
    this.#updateTransitionControls();
    if ( options.parts.includes("grid") ) {
      const {width, height} = this.document._source;
      this.#lastDimensionValues = {width, height};
    }
    new DragDrop.implementation({
      dragSelector: ".draggable",
      callbacks: {
        dragstart: this._onDragStart.bind(this),
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);
  }

  /* -------------------------------------------- */

  /**
   * Get the set of ContextMenu options for levels in the scene config.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getLevelContextOptions() {
    const getLevel = li => this.document.levels.get(li.dataset.levelId);
    return [
      {
        label: "SCENE.View",
        icon: "fa-solid fa-eye",
        visible: li => !getLevel(li)?.isView,
        onClick: (event, li) => this.document.view({ level: li.dataset.levelId })
      },
      {
        label: "SIDEBAR.Edit",
        icon: "fa-solid fa-edit",
        visible: this.isEditable,
        onClick: (event, li) => getLevel(li)?.sheet.render({ force: true })
      },
      {
        label: "SIDEBAR.Duplicate",
        icon: "fa-regular fa-copy",
        visible: this.isEditable,
        onClick: (event, li) => {
          const level = getLevel(li);
          return level?.clone({ name: game.i18n.format("DOCUMENT.CopyOf", { name: level.name }) }, { save: true });
        }
      },
      {
        label: "SIDEBAR.Delete",
        icon: "fa-solid fa-trash",
        visible: this.isEditable,
        onClick: (event, li) => getLevel(li)?.deleteDialog()
      }
    ];
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a new level.
   * @this {SceneConfig}
   */
  static #onAddLevel() {
    foundry.documents.Level.createDialog({}, { parent: this.document, renderSheet: true });
  }

  /* -------------------------------------------- */

  /**
   * 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 !== "lighting");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    const formElements = this.form.elements;
    const name = event.target.name || event.target.dataset.name;

    // Keep form values with the same name in-sync across the form.
    for ( const el of this.form.querySelectorAll(`[name="${name}"], [data-name="${name}"]`) ) {
      if ( el === event.target ) continue;
      if ( el.type === "radio" ) continue; // Skip actual radio inputs.
      if ( el.type === "checkbox" ) el.checked = event.target.checked;
      else if ( el.hasAttribute("value") || foundry.utils.isElementInstanceOf(el, HTMLSelectElement) ) {
        // Special handling for AbstractFormInputElements. We need to prevent the value assignment from triggering
        // another change event, otherwise we end up in an infinite loop. Normal inputs do not fire a change event on
        // programmatic updates.
        if ( foundry.utils.isElementInstanceOf(el, foundry.applications.elements.AbstractFormInputElement) ) {
          el._setValue(event.target.value);
          el._refresh();
        }
        else el.value = event.target.value;
      }
    }

    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"]: {
        this.#onChangeIntensity();
        break;
      }
      case formElements["transition.type"]:
        this.#updateTransitionControls();
        break;
      case formElements["environment.darknessLock"]:
        this.#onChangeDarknessLock(event.target.checked);
        break;
      case formElements["environment.cycle"]:
        this.#onChangeBlend();
        break;
    }
    this._previewScene(event.target.name || event.target.dataset.name);
  }

  /* -------------------------------------------- */

  /**
   * Handle enabling or disabling darkness ambience based on whether blending is enabled.
   */
  #onChangeBlend() {
    const blend = this.form.elements["environment.cycle"];
    for ( const input of blend.closest("fieldset").querySelectorAll("input") ) {
      if ( input !== blend ) input.disabled = !blend.checked;
    }
    this.#onChangeIntensity();
  }

  /* -------------------------------------------- */

  /**
   * Handle hue slider interactivity based on intensity.
   */
  #onChangeIntensity() {
    ["environment.base.intensity", "environment.dark.intensity"].forEach(name => {
      const intensity = this.form.elements[name];
      const slider = intensity.closest("fieldset").querySelector("hue-slider input");
      slider.disabled = intensity.disabled || (intensity.value === 0);
    });
  }

  /* -------------------------------------------- */

  /**
   * 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.toNearest(.001);
    ui.notifications.info("SCENE.CaptureInitialViewPosition", {localize: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle editing a level.
   * @this {SceneConfig}
   * @param {PointerEvent} event        The triggering event.
   * @param {HTMLButtonElement} target  The action target.
   */
  static #onEditLevel(event, target) {
    this.#getEventLevel(event)?.sheet.render({ force: 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();
  }

  /* -------------------------------------------- */

  /**
   * Handle removing a level.
   * @this {SceneConfig}
   * @param {PointerEvent} event        The triggering event.
   * @param {HTMLButtonElement} target  The action target.
   */
  static #onRemoveLevel(event, target) {
    this.#getEventLevel(event)?.deleteDialog();
  }

  /* -------------------------------------------- */

  /**
   * Enable or disable transition duration and activeOnly depending on the selected type.
   * When no transition type is selected ("None"), those controls are disabled.
   * @private
   */
  #updateTransitionControls() {
    const form = this.form;
    if ( !form ) return;

    const typeInput = form.elements["transition.type"];
    const durationInput = form.elements["transition.duration"];
    const activeOnlyInput = form.elements["transition.activeOnly"];
    if ( !typeInput || !durationInput || !activeOnlyInput ) return;

    const disabled = typeInput.value === "";
    durationInput.disabled = disabled;
    activeOnlyInput.disabled = disabled;
  }

  /* -------------------------------------------- */

  /**
   * Preview the configured scene transition on the current canvas.
   * @this {SceneConfig}
   * @type {ApplicationClickAction}
   */
  static async #onTransitionPlay() {
    if ( !canvas.ready ) return;

    const formData = new FormDataExtended(this.form);
    const submitData = foundry.utils.expandObject(formData.object);

    const formTransition = submitData.transition ?? {};
    const current = this.document._source.transition ?? {};

    const type = formTransition.type ?? current.type;
    if ( !type ) return; // "None" selected, nothing to preview

    // Resolve duration
    const def = CONFIG.Canvas.sceneTransitions[type];
    const duration = formTransition.duration ?? current.duration ?? def.defaultDuration
      ?? canvas.transition.defaultDuration ?? 1000;

    // Launch the preview
    await canvas.transition.run({
      fromBlack: true,
      duration,
      transitionType: type
    });
  }

  /* -------------------------------------------- */

  /**
   * 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 = _loc(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(_loc("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$2.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;
    elements["environment.globalLight.enabled"].checked = def.globalLight.enabled;
    elements["environment.globalLight.darkness.max"].value = def.globalLight.darkness.max;
    elements["environment.darknessLevel"].value = def.darknessLevel;
    elements["environment.darknessLock"].checked = def.darknessLock;
    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();
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /**
   * Handle drag start.
   * @param {DragEvent} event  The initial event.
   * @protected
   */
  _onDragStart(event) {
    const dragData = this.#getEventLevel(event)?.toDragData();
    if ( dragData ) event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping some data onto the sheet.
   * @param {DragEvent} event  The triggering event.
   * @protected
   */
  async _onDrop(event) {
    const data = TextEditor$1.implementation.getDragEventData(event);
    if ( data.type !== "Level" ) return;
    const level = await foundry.documents.Level.fromDropData(data);
    if ( level?.parent === this.document ) return this._onSortLevel(event, level);
  }

  /* -------------------------------------------- */

  /**
   * Handle re-ordering levels via scene config.
   * @param {DragEvent} event  The triggering event.
   * @param {Level} level      The scene level.
   * @returns {Promise<Level[]>|void}
   * @protected
   */
  _onSortLevel(event, level) {
    const levels = this.document.levels;
    const dropTarget = event.target.closest("[data-level-id]");
    if ( !dropTarget ) return;
    const target = levels.get(dropTarget.dataset.levelId);
    if ( level.id === target.id ) return;
    const siblings = levels.filter(l => l !== level);
    const updates = foundry.utils.performIntegerSort(level, { target, siblings });
    return this.document.updateEmbeddedDocuments("Level", updates.map(u => ({ _id: u.target._id, ...u.update })));
  }
}

/**
 * @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,
    ownershipConfig: 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 _loc("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.#getTexture(this.document) ) {
        ui.notifications.warn("WARNING.GridConfigNoBG", {localize: true});
      }
      this.#previewScene = this.document.clone({}, {keepId: true});
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const scene = this.#previewScene;
    const src = this.#getTexture(scene);
    const bg = foundry.canvas.getTexture(src);
    return Object.assign(context, {
      scene, src,
      gridTypes: SceneConfig._getGridTypes(),
      scale: src ? this.document.width / bg.width : 1,
      pixelsLabel: _loc("MEASUREMENT.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 === "shiftX" ) {
        event.preventDefault();
        event.stopImmediatePropagation();
        this.#shiftBackground({deltaX: normalizedDelta});
      }
      // Increase/Decrease the background y offset
      else if ( activeElement.name === "shiftY" ) {
        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({}, {keepId: true});
    await this.render();
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    const bg = foundry.canvas.getTexture(this.#getTexture(this.#previewScene));
    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",
      "shiftX",
      "shiftY",
      "grid.size",
      "grid.type"
    ];
    const hasChanges = fieldNames.some(k => k in changes);
    const confirmed = hasChanges && await DialogV2.confirm({
      window: {title: "SCENE.DimensionChangeTitle"},
      content: `<p>${_loc("SCENE.DimensionChangeWarning")}</p>`
    });
    if ( confirmed ) return super._processSubmitData(event, form, submitData, options);
    return {};
  }

  /* -------------------------------------------- */
  /*  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";
    const src = this.#getTexture(scene);
    if ( src ) {
      try {
        this.#background.texture = await foundry.canvas.loadTexture(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();
  }

  /* -------------------------------------------- */

  /**
   * Get the background texture source path for a scene. Uses the current canvas level if the scene is the viewed
   * scene, otherwise falls back to the scene's initial level.
   * @param {Scene} scene
   * @returns {string}
   */
  #getTexture(scene) {
    const level = canvas.scene?.id === scene?.id ? (canvas.level ?? scene?.initialLevel) : scene?.initialLevel;
    return level?.background?.src;
  }

  /* -------------------------------------------- */

  /**
   * 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.#getTexture(this.#previewScene) ) 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.#getTexture(this.#previewScene) ) return;
    const oxInput = this.form.elements.shiftX;
    oxInput.value = (Number(oxInput.value) || 0) + deltaX;
    const oyInput = this.form.elements.shiftY;
    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 || _loc("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 = _loc(perm.label);
      perm.hint = _loc(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: p.requiredRoles.includes(role) ? "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();
  }
}

/**
 * @import {BaseShapeData} from "@common/data/_module.mjs";
 * @import {FormInputConfig} from "@common/data/_types.mjs";
 * @import {DataField} from "@client/data/fields.mjs";
 */

/**
 * The configuration to edit a shape of a Document.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class ShapeConfig extends HandlebarsApplicationMixin(ApplicationV2) {

  /**
   * @param {ApplicationConfiguration & {shape: BaseShapeData}} options
   */
  constructor(options) {
    if ( !(options.shape.parent instanceof Document) ) throw new Error("The shape must have a parent Document");
    super(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "{id}",
    classes: ["shape-config"],
    tag: "form",
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-shapes"
    },
    position: {width: 480},
    form: {
      handler: this.#onSubmitForm,
      closeOnSubmit: true
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    form: {
      templates: ["templates/apps/shape-config/parts/origin.hbs"]
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * The shape that is configured by this application.
   * @type {BaseShapeData}
   */
  get shape() {
    return this.options.shape;
  }

  /* -------------------------------------------- */

  /**
   * The Document whose shape is configured by this application.
   * @type {Document}
   */
  get document() {
    return this.shape.parent;
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return `${_loc("SHAPE.ACTIONS.edit")}: ${_loc(`SHAPE.TYPES.${this.shape.type}.name`)}`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options = super._initializeApplicationOptions(options);
    const suffix = options.shape.parent.uuid ?? foundry.utils.randomID();
    options.uniqueId = `${this.constructor.name}-${suffix.replaceAll(".", "-")}`;
    if ( options.shape._index !== undefined ) options.uniqueId += `-${options.shape._index}`;
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    parts.form.template = `templates/apps/shape-config/${this.shape.type}.hbs`;
    if ( this.shape.type === "emanation" ) parts.form.templates.push(`templates/apps/shape-config/${this.shape.base.type}.hbs`);
    return parts;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.rootId = this.id;
    ShapeConfig._prepareShapeContext(context, this.shape, this.shape.constructor.schema.fields);
    context.buttons = [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "Update"}];
    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeForm(formConfig, event) {
    ShapeConfig._onChangeDimension(event.target, this.shape.grid);
  }

  /* -------------------------------------------- */

  /**
   * Submit a Document update 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} shapeData                    Processed and validated form data to be used for a document update
   * @param {Partial<DatabaseUpdateOperation>} [updateOptions] Additional options altering the request
   * @returns {Promise<void>}
   * @protected
   */
  async _processSubmitData(event, form, shapeData, updateOptions) {
    if ( this.document.schema.has("shapes") ) {
      const shapes = this.document.toObject().shapes;
      shapes[this.shape._index] = shapeData;
      await this.document.update({shapes}, updateOptions);
    } else if ( this.document.schema.has("shape") ) {
      await this.document.update({shape: shapeData}, updateOptions);
    } else {
      throw new Error(`${this.constructor.name} must implement the _processSubmitData method.`);
    }
  }

  /* -------------------------------------------- */

  /**
   * Process form submission for the config.
   * @this {ShapeConfig}                          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 #onSubmitForm(event, form, formData, options={}) {
    const {updateData, ...updateOptions} = options;
    const shapeData = ShapeConfig._processShapeData(this.shape, foundry.utils.expandObject(formData.object));
    if ( updateData ) foundry.utils.mergeObject(shapeData, updateData);

    // Apply the change
    await this._processSubmitData(event, form, shapeData, updateOptions);
  }

  /* -------------------------------------------- */

  /**
   * Process the shape form data.
   * @param {BaseShapeData} shape
   * @param {object} shapeData
   * @returns {object}
   * @internal
   */
  static _processShapeData(shape, shapeData) {

    // Base shapes of emanation need the same processing
    if ( (shape.type === "emanation") && ("base" in shapeData) ) {

      // The fieldPath doesn't work as the name and therefore must be converted
      const baseData = Object.values(shapeData.base)[0];
      shapeData.base = this._processShapeData(shape.base, baseData);
    }

    // Handle optional origin
    if ( shapeData.origin && !(Number.isFinite(shapeData.origin.x) && Number.isFinite(shapeData.origin.y)) ) {
      shapeData.origin = null;
    }

    return foundry.utils.mergeObject(shape.toObject(), shapeData);
  }

  /* -------------------------------------------- */

  /**
   * Create the dimension field.
   * @param {DataField} field
   * @param {FormInputConfig} inputConfig
   * @returns {HTMLElement[]}
   */
  static #dimensionField(field, inputConfig) {
    const grid = inputConfig.grid;
    const distancePixels = grid.size / grid.distance;
    const pixelsInput = foundry.applications.fields.createNumberInput({
      name: field.fieldPath, id: `${inputConfig.rootId}-${field.fieldPath}-pixels`,
      min: 0, step: "any", dataset: {units: "pixels"}, value: inputConfig.value
    });
    const pixelsLabel = document.createElement("label");
    pixelsLabel.setAttribute("for", pixelsInput.id);
    pixelsLabel.textContent = _loc("MEASUREMENT.Pixels");
    const linkIcon = document.createElement("i");
    linkIcon.classList.add("fa-solid", "fa-fw", "fa-link-simple");
    const gridInput = foundry.applications.fields.createNumberInput({
      id: `${inputConfig.rootId}-${field.fieldPath}-grid`, min: 0, step: "any", dataset: {units: "grid"},
      value: inputConfig.value / distancePixels
    });
    const gridLabel = document.createElement("label");
    gridLabel.setAttribute("for", gridInput.id);
    gridLabel.textContent = _loc("MEASUREMENT.GridUnits");
    if ( grid.units ) gridLabel.textContent += ` (${grid.units})`;
    return [pixelsLabel, pixelsInput, linkIcon, gridInput, gridLabel];
  }

  /* -------------------------------------------- */

  /**
   * Prepare the shape context.
   * @param {object} context
   * @param {BaseShapeData} shape
   * @param {DataField} fields
   * @internal
   */
  static _prepareShapeContext(context, shape, fields) {
    context.shape = shape;
    context.fields = fields;
    context.source = shape._source;
    context.gridUnits = shape.grid.units || _loc("MEASUREMENT.GridUnits");
    context.dimensionInput = ShapeConfig.#dimensionField;

    // Prepare base shape context for emanations
    if ( shape.type === "emanation" ) {
      context.baseContext = {
        rootId: context.rootId,
        shape: shape.base,
        fields: fields.base.types[shape.base.type].fields,
        source: shape.base._source,
        gridUnits: context.gridUnits,
        dimensionInput: context.dimensionInput
      };
    }

    // Prepare token shapes if necessary
    if ( (shape.type === "token") || ((shape.type === "emanation") && (shape.base.type === "token")) ) {
      let tokenShapes;
      if ( shape.grid.isGridless ) {
        tokenShapes = {
          [CONST.TOKEN_SHAPES.ELLIPSE_1]: _loc("TOKEN.SHAPES.ELLIPSE.label"),
          [CONST.TOKEN_SHAPES.RECTANGLE_1]: _loc("TOKEN.SHAPES.RECTANGLE.label")
        };
      } else if ( shape.grid.isSquare ) {
        tokenShapes = {
          [CONST.TOKEN_SHAPES.RECTANGLE_1]: _loc("TOKEN.SHAPES.RECTANGLE.label")
        };
      } else {
        tokenShapes = Object.entries(CONST.TOKEN_SHAPES)
          .reduce((shapes, [key, value]) => {
            shapes[value] = _loc(`TOKEN.SHAPES.${key}.label`);
            return shapes;
          }, {});
      }
      if ( shape.type === "emanation" ) context.baseContext.tokenShapes = tokenShapes;
      else context.tokenShapes = tokenShapes;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to dimension input.
   * @param {HTMLInputElement} input
   * @param {BaseGrid} grid
   * @internal
   */
  static _onChangeDimension(input, grid) {
    const distancePixels = grid.size / grid.distance;
    if ( input.dataset.units === "pixels" ) {
      const pixels = input.value;
      const gridUnits = pixels / distancePixels;
      input.parentNode.querySelector('input[data-units="grid"]').value = gridUnits;
    } else if ( input.dataset.units === "grid" ) {
      const gridUnits = input.value;
      const pixels = gridUnits * distancePixels;
      input.parentNode.querySelector('input[data-units="pixels"]').value = pixels;
    }
  }
}

var _module$R = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CombatTrackerConfig: CombatTrackerConfig,
  CompendiumArtConfig: CompendiumArtConfig,
  DocumentOwnershipConfig: DocumentOwnershipConfig,
  DocumentSheetConfig: DocumentSheetConfig,
  FilePicker: FilePicker,
  FormulaEditor: FormulaEditor,
  GridConfig: GridConfig,
  ImagePopout: ImagePopout,
  PermissionConfig: PermissionConfig,
  ShapeConfig: ShapeConfig,
  av: _module$S
});

/**
 * @import DiceTerm from "@client/dice/terms/dice.mjs";
 */

/**
 * @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: _loc(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, id, method, icon, exploded, rerolled, isNew,
          value: value ?? "",
          minValue: denomination === "c" ? 0 : 1,
          maxValue: denomination === "c" ? 1 : faces,
          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: _loc(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 = _loc("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.default || 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$Q = /*#__PURE__*/Object.freeze({
  __proto__: null,
  RollResolver: RollResolver
});

/**
 * @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 textContent 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.textContent || "";
      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.textContent = "";
    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(_loc("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") || _loc(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 = "";
    button.ariaLabel = _loc(this.constructor.labels.add);
    this.#button = button;

    // Group the input and add button into a single framed control, kept separate from the tags list.
    const group = document.createElement("div");
    group.classList.add("input-group");
    group.append(this.#input, this.#button);
    return [this.#tags, group];
  }

  /* -------------------------------------------- */

  /** @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 = _loc(this.labels.remove);
      const a = `<a class="remove ${this.icons.remove}" data-tooltip 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 = _loc("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";
 */

/**
 * 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 = {};

  /**
   * Option values which were originally marked as disabled.
   * @type {Set<string>}
   * @protected
   */
  _disabledOptions = new Set();

  /** @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.textContent;
      if ( option.disabled ) this._disabledOptions.add(option.value);
      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`);
      }
      if ( this._disabledOptions.has(value) ) return;
      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._disabledOptions.has(value) ) { // Disabled values are locked to their current state.
      this._value.delete(value);
      this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
      this._refresh();
    }
  }

  /* -------------------------------------------- */

  /**
   * Toggle the disabled state of a specific option.
   * @param {string} value          The option value to modify
   * @param {boolean} [disabled=true]  Whether the option should be disabled
   */
  disableOption(value, disabled=true) {
    if ( !(value in this._choices) ) {
      throw new Error(`"${value}" is not an option allowed by this multi-select element`);
    }
    if ( disabled ) this._disabledOptions.add(value);
    else this._disabledOptions.delete(value);
    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._disabledOptions.has(id));
    });
    this.#tags.replaceChildren(...tags);

    // Disable selected options and originally disabled options
    for ( const option of this.#select.querySelectorAll("option") ) {
      option.disabled = this._disabledOptions.has(option.value) || 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, type: "multi-select"});
  }
}

/* -------------------------------------------- */

/**
 * 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 ( foundry.utils.isElementInstanceOf(option, "optgroup") ) 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.textContent = optgroup.label;
    group.append(legend);

    // Add child options
    for ( const option of optgroup.children ) {
      if ( foundry.utils.isElementInstanceOf(option, "option") ) 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._disabledOptions.has(option.value) || this.disabled;
    label.append(checkbox, option.textContent);
    this.#checkboxes.push(checkbox);
    return label;
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    for ( const checkbox of this.#checkboxes ) {
      checkbox.checked = this._value.has(checkbox.value);
      checkbox.disabled = this._disabledOptions.has(checkbox.value) || this.disabled;
    }
  }

  /* -------------------------------------------- */

  /** @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 || this._disabledOptions.has(checkbox.value);
    }
  }

  /* -------------------------------------------- */

  /**
   * Create a HTMLMultiCheckboxElement using provided configuration data.
   * @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
   * @returns {HTMLMultiCheckboxElement}
   */
  static create(config) {
    return foundry.applications.fields.createMultiSelectInput({...config, type: "checkboxes"});
  }
}

/**
 * @import {FormInputConfig} from "../../../common/data/_types.mjs";
 * @import {AutocompleteEntry} from "../ux/_types.mjs";
 */

/**
 * Provide a multi-select workflow as a text input which offers autocompletion over the available options. Chosen
 * options are displayed as a list of tags which may be individually removed.
 *
 * @extends {AbstractMultiSelectElement}
 *
 * @example Autocomplete Tags HTML Markup
 * ```html
 * <autocomplete-tags name="select-many-things">
 *   <option value="foo">Foo</option>
 *   <option value="bar">Bar</option>
 *   <option value="baz">Baz</option>
 * </autocomplete-tags>
 * ```
 */
class HTMLAutocompleteTagsElement extends AbstractMultiSelectElement {

  /** @override */
  static tagName = "autocomplete-tags";

  /**
   * The autocomplete widget which offers matching options.
   * @type {Autocomplete}
   */
  #autocomplete;

  /**
   * A text input used to search the available options.
   * @type {HTMLInputElement}
   */
  #input;

  /**
   * The available options in their declared order, with labels normalized for prefix matching.
   * @type {(AutocompleteEntry & {search: string})[]}
   */
  #options = [];

  /**
   * A display element which lists the chosen options.
   * @type {HTMLDivElement}
   */
  #tags;

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    this.#autocomplete = new Autocomplete.implementation({onSelect: this.#onSelectOption.bind(this)});
    const refresh = this.#refreshMenu.bind(this);
    this.#input.addEventListener("click", refresh);
    this.#input.addEventListener("focus", refresh);
    this.#input.addEventListener("input", refresh);
    this.#tags.addEventListener("click", this.#onClickTag.bind(this));
    this.ownerDocument.body.addEventListener("click", this.#onClickAway.bind(this), {signal: this.abortSignal});
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {
    this.#tags = document.createElement("div");
    this.#tags.className = "tags input-element-tags";
    const input = this.#input = this._primaryInput = document.createElement("input");
    input.type = "text";
    input.placeholder = this.getAttribute("placeholder") ?? "";
    return [this.#tags, input];
  }

  /* -------------------------------------------- */

  /** @override */
  _disconnect() {
    this.#dismissMenu();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _initialize() {
    super._initialize();
    for ( const [value, label] of Object.entries(this._choices) ) {
      this.#options.push({label, identifier: value, search: HTMLAutocompleteTagsElement.#normalize(label)});
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    const tags = Array.from(this._value).map(id => {
      return HTMLStringTagsElement.renderTag(id, this._choices[id], this.editable && !this._disabledOptions.has(id));
    });
    this.#tags.replaceChildren(...tags);
  }

  /* -------------------------------------------- */

  /** @override */
  _toggleDisabled(disabled) {
    this.#input.disabled = disabled;
    if ( disabled ) this.#dismissMenu();
  }

  /* -------------------------------------------- */

  /**
   * Dismiss the autocomplete menu if this element is the one currently using it.
   */
  #dismissMenu() {
    if ( ui.autocomplete === this.#autocomplete ) this.#autocomplete.dismiss();
  }

  /* -------------------------------------------- */

  /**
   * Offer the options whose labels begin with the current search text, omitting those which are already chosen or are
   * disabled. The full list is offered when no search text has been entered.
   */
  #refreshMenu() {
    if ( !this.editable ) return this.#dismissMenu();
    const prefix = HTMLAutocompleteTagsElement.#normalize(this.#input.value);
    const entries = this.#options.filter(o => {
      return o.search.startsWith(prefix) && !this._value.has(o.identifier) && !this._disabledOptions.has(o.identifier);
    });
    if ( entries.length ) this.#autocomplete.activate(this.#input, entries);
    else this.#dismissMenu();
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Dismiss the menu when a click occurs outside this element's input or the menu itself.
   * @param {PointerEvent} event  The originating click event
   */
  #onClickAway(event) {
    if ( (event.target === this.#input) || this.#autocomplete.element?.contains(event.target) ) return;
    this.#dismissMenu();
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on a chosen tag, 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);
  }

  /* -------------------------------------------- */

  /**
   * Add an option to the chosen set when it is committed in the autocomplete menu.
   * @param {string} identifier  The identifier of the committed entry
   */
  #onSelectOption(identifier) {
    this.select(identifier);
    this.#input.value = "";
  }

  /* -------------------------------------------- */

  /**
   * Create a HTMLAutocompleteTagsElement using provided configuration data.
   * @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
   * @returns {HTMLAutocompleteTagsElement}
   */
  static create(config) {
    return foundry.applications.fields.createMultiSelectInput({...config, type: "autocomplete"});
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Standardize a label or a search query for prefix matching.
   * @param {string} text  The text to normalize
   * @returns {string}
   */
  static #normalize(text) {
    return text.trim().stripDiacritics().toLocaleLowerCase(game.i18n.lang);
  }
}

/**
 * @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.textContent);
    this.textContent = "";
  }

  /**
   * @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)});
  }

  /* -------------------------------------------- */

  /** @override */
  focus() {
    this.#view.focus();
  }

  /* -------------------------------------------- */

  /**
   * 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(),
      root: this.ownerDocument,
      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 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 textContent 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.textContent || "";
      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.textContent = "";
    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";
    const typeLabel = this.type ? getDocumentClass$1(this.type).metadata.label : "DOCUMENT.Document";
    this.#input.placeholder = this.getAttribute("placeholder")
      || _loc("HTMLDocumentTagsElement.PLACEHOLDER", { type: _loc(typeLabel) });

    // 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);

    // Group the input and add button into a single framed control, kept separate from the tags list.
    const group = document.createElement("div");
    group.classList.add("input-group");
    group.append(this.#input, this.#button);
    return [this.#tags, group];
  }

  /* -------------------------------------------- */

  /** @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 = _loc("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();
    event.stopImmediatePropagation();
    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 AdoptableHTMLElement {

  /**
   * 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 AdoptableHTMLElement {

  /**
   * 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 ( typeof this.#enricher?.onRender !== "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.textContent || "";
    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._setValue(this.input.value);
      this._refresh();
    });
    this.input.addEventListener("change", this.#onChangeInput.bind(this));
    this.button?.addEventListener("click", this.#onClickButton.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to one of the inputs of the file picker element.
   * @param {InputEvent} event     The originating input change event
   */
  #onChangeInput(event) {
    event.stopPropagation();
    this.value = event.currentTarget.value;
  }

  /* -------------------------------------------- */

  /**
   * Handle clicks on the button element to render the FilePicker UI.
   * @param {PointerEvent} event      The initiating click event
   */
  async #onClickButton(event) {
    event.preventDefault();
    this.picker = new FilePicker.implementation({
      type: this.type,
      current: this.value,
      allowUpload: !this.noupload,
      callback: src => this.value = src
    });
    await this.picker.browse(undefined, { render: false });
    return this.picker.render({ force: true, window: { windowId: this.ownerDocument.defaultView.id } });
  }

  /* -------------------------------------------- */

  /**
   * 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 form input element custom tailored to formula expressions.
 * @extends {AbstractFormInputElement}
 * @fires {Event} edit  Fired when the formula editor has been requested.
 *
 * @example Using the custom element in markup
 * ```html
 * <formula-input context="default">8 + @prof + @abilities.cha.mod</formula-input>
 * ```
 *
 * @example Creating the element programmatically
 * ```js
 * HTMLFormulaInputElement.create({ value: "8 + @prof + @abilities.cha.mod" });
 * ```
 */
class HTMLFormulaInputElement extends AbstractFormInputElement {
  /** @override */
  static tagName = "formula-input";

  /**
   * A button to open the formula editor interface.
   * @type {HTMLButtonElement}
   */
  button;

  /**
   * The formula input.
   * @type {HTMLInputElement}
   */
  input;

  /**
   * A reference to the formula editor application instance spawned by this element.
   * @type {FormulaEditor}
   */
  editor;

  /* -------------------------------------------- */

  /**
   * An identifier to distinguish contexts a formula might be in, which may control which auto-complete suggestions are
   * available, or other behavior. Context configuration is available in CONFIG.formulaEditor.
   * @type {string}
   */
  get context() {
    return this.getAttribute("context");
  }

  set context(value) {
    this.setAttribute("context", value);
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    this.input.addEventListener("input", () => {
      this._setValue(this.input.value);
      this._refresh();
    });
    this.input.addEventListener("change", this.#onChangeInput.bind(this));
    this.button.addEventListener("click", this.#onClickButton.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to one of the inputs of the formula input element.
   * @param {InputEvent} event     The originating input change event
   */
  #onChangeInput(event) {
    event.stopPropagation();
    this.value = event.currentTarget.value;
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {
    this._value ??= this.getAttribute("value") || this.textContent || "";
    this.removeAttribute("value");
    this.input = this._primaryInput = this.ownerDocument.createElement("input");
    Object.assign(this.input, { placeholder: this.getAttribute("placeholder") ?? "", type: "text" });
    this.button = this.ownerDocument.createElement("button");
    Object.assign(this.button, { className: "fa-solid fa-lambda fa-fw icon", type: "button" });
    return [this.input, this.button];
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    this.input.value = this._value;
  }

  /* -------------------------------------------- */

  /** @override */
  _toggleDisabled(disabled) {
    this.input.disabled = disabled;
    this.button.disabled = disabled;
  }

  /* -------------------------------------------- */

  /**
   * Handle spawning the formula editor interface.
   * @param {PointerEvent} event  The triggering event.
   */
  #onClickButton(event) {
    event.preventDefault();
    const edit = new Event("edit", { bubbles: true, cancelable: true });
    this.dispatchEvent(edit);
    if ( edit.defaultPrevented ) return;
    this.editor = new FormulaEditor({ context: this.context, formula: this._value ?? "" });
    this.editor.addEventListener("close", () => {
      this.value = this.editor.formula;
      this.editor = undefined;
    }, { once: true });
    this.editor.render({ force: true, window: { windowId: this.ownerDocument.defaultView.id } });
  }

  /* -------------------------------------------- */

  /**
   * @typedef FormulaInputConfig
   * @property {string} [context="default"]  The formula editor context.
   */

  /**
   * Create an HTMLFormulaInputElement using provided configuration data.
   * @param {FormInputConfig<string> & FormulaInputConfig} config  The configuration.
   * @returns {HTMLFormulaInputElement}
   */
  static create(config) {
    const input = new this();
    const { context="default", name, value } = config;
    Object.assign(input, { context, name });
    input.setAttribute("value", value);
    foundry.applications.fields.setInputAttributes(input, config);
    return input;
  }
}

/**
 * A custom HTMLElement used to render a tagged set of 2D grid offsets.
 */
class HTMLGridOffset2DTagsElement extends HTMLStringTagsElement {

  /** @override */
  static tagName = "grid-offset-2d-tags";

  /** @override */
  static icons = {
    add: "fa-solid fa-square-plus",
    remove: "fa-solid fa-square-minus"
  };

  /** @override */
  static labels = {
    add: "ELEMENTS.TAGS.AddGridOffset",
    remove: "ELEMENTS.TAGS.RemoveGridOffset",
    placeholder: "i.j"
  };

  /* -------------------------------------------- */

  /** @override */
  _validateTag(tag) {
    if ( !/^(?:-?[1-9]\d*|0)\.(?:-?[1-9]\d*|0)$/.test(tag) ) throw new Error(_loc("ELEMENTS.TAGS.ErrorInvalidGridOffset2D"));
  }
}

/* -------------------------------------------- */

/**
 * A custom HTMLElement used to render a tagged set of 3D grid offsets.
 */
class HTMLGridOffset3DTagsElement extends HTMLStringTagsElement {

  /** @override */
  static tagName = "grid-offset-3d-tags";

  /** @override */
  static icons = {
    add: "fa-solid fa-square-plus",
    remove: "fa-solid fa-square-minus"
  };

  /** @override */
  static labels = {
    add: "ELEMENTS.TAGS.AddGridOffset",
    remove: "ELEMENTS.TAGS.RemoveGridOffset",
    placeholder: "i.j.k"
  };

  /* -------------------------------------------- */

  /** @override */
  _validateTag(tag) {
    if ( !/^(?:-?[1-9]\d*|0)\.(?:-?[1-9]\d*|0)\.(?:-?[1-9]\d*|0)$/.test(tag) ) throw new Error(_loc("ELEMENTS.TAGS.ErrorInvalidGridOffset3D"));
  }
}

/**
 * @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];
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    if ( !this.#input ) return;
    this.#input.style.setProperty("--color-thumb", Color.fromHSL([this._value, 1, 0.5]).css);
    this.#input.value = this._value * 360;
  }

  /* -------------------------------------------- */

  /**
   * 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";
 */

/**
 * @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("input", () => {
      this._setValue(this.#colorString.value);
      this._refresh();
    });
    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 value as the range slider is actively changed.
   * @param {InputEvent} event     The originating input event
   */
  #onDragSlider(event) {
    event.preventDefault();
    this._setValue(this.#rangeInput.valueAsNumber);
    this._refresh();
  }

  /* -------------------------------------------- */

  /**
   * 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 });
    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 {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() {
    super.disconnectedCallback();
    if ( this.#active ) {
      this.save();
      if ( !this.#toggled ) this.#editor?.destroy();
      this.#active = false;
      this.#editor = undefined;
    }
  }

  /* -------------------------------------------- */

  /** @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-edit"></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 if ( !this.open ) this.open = true;
    else if ( !this.#active ) this.#activateEditor();
  }

  /* -------------------------------------------- */

  /** @override */
  _getValue() {
    if ( this.#active ) {
      return this.querySelector(":scope > .source-editor")?.value
        ?? 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,
      relativeLinks: this.hasAttribute("relative") && !!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, {
        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) && (event.button === 0);
    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 } = this;
    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                                  */
  /* -------------------------------------------- */

  /** @override */
  focus() {
    if ( !this.#editor?.view ) return super.focus();
    this.#editor.view.focus();
  }

  /* -------------------------------------------- */

  /**
   * 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("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(HTMLAutocompleteTagsElement.tagName, HTMLAutocompleteTagsElement);
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(HTMLFormulaInputElement.tagName, HTMLFormulaInputElement);
window.customElements.define(HTMLGridOffset2DTagsElement.tagName, HTMLGridOffset2DTagsElement);
window.customElements.define(HTMLGridOffset3DTagsElement.tagName, HTMLGridOffset3DTagsElement);
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$P = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbstractFormInputElement: AbstractFormInputElement,
  AbstractMultiSelectElement: AbstractMultiSelectElement,
  AdoptableHTMLElement: AdoptableHTMLElement,
  HTMLAutocompleteTagsElement: HTMLAutocompleteTagsElement,
  HTMLCodeMirrorElement: HTMLCodeMirrorElement,
  HTMLColorPickerElement: HTMLColorPickerElement,
  HTMLDocumentEmbedElement: HTMLDocumentEmbedElement,
  HTMLDocumentTagsElement: HTMLDocumentTagsElement,
  HTMLEnrichedContentElement: HTMLEnrichedContentElement,
  HTMLFilePickerElement: HTMLFilePickerElement,
  HTMLFormulaInputElement: HTMLFormulaInputElement,
  HTMLGridOffset2DTagsElement: HTMLGridOffset2DTagsElement,
  HTMLGridOffset3DTagsElement: HTMLGridOffset3DTagsElement,
  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);
    if ( typeof context.elevation === "number" ) context.elevation -= canvas.level.elevation.base;
    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  _updatePosition(position) {
    const s = canvas.dimensions.uiScale;
    const {x: left, y: top, width, height} = this.#object.bounds;
    Object.assign(position, {left, top, width: width / s, height: height / s});
    position.scale = s;
    return position;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _postRender(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);

    return super._postRender(context, options);
  }

  /* -------------------------------------------- */

  /** @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) {

    // Special submission process for elevation changes
    if ( (event.type === "change") && (event.target.name === "elevation") ) {
      return this._onSubmitElevation(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);
  }

  /* -------------------------------------------- */

  /**
   * Special submission process for elevation changes.
   * @param {SubmitEvent} event
   * @param {HTMLFormElement} form
   * @param {FormDataExtended} formData
   * @returns {Promise<void>}
   * @protected
   */
  async _onSubmitElevation(event, form, formData) {
    const elevation = this._parseAttributeInput("elevation", this.document._source.elevation
      - canvas.level.elevation.base, event.target.value).value + canvas.level.elevation.base;
    await this.document.update({elevation});
  }

  /* -------------------------------------------- */

  /**
   * 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 sentToFront = this.layer._sendToBackOrBringToFront(true);
    if ( !sentToFront ) this.layer._sendToBackOrBringToFront(false);
  }

  /* -------------------------------------------- */

  /**
   * 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: _loc(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";
 * @import {Actor} from "../../documents/_module.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,
      level: TokenHUD.#onChangeLevel
    }
  };

  /** @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");
    const levels = this._getLevelChoices();
    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],
      levels,
      canChangeLevel: Object.keys(levels).length > 1
    });
  }

  /* -------------------------------------------- */

  /**
   * Get the valid status effect choices.
   * @returns {{[id: string]: {
   *   id: string;
   *   _id: string;
   *   title: string;
   *   src: string;
   *   order: number;
   *   isActive: boolean;
   *   isOverlay: boolean;
   *   cssClass: string;
   * }}}
   * @protected
   */
  _getStatusEffectChoices() {

    // Include all HUD-enabled status effects
    const choiceList = [];
    for ( const status of foundry.utils.iterateValues(CONFIG.statusEffects) ) {
      if ( (status.hud === false) || (status.hud?.actorTypes?.includes(this.document.actor?.type) === false) ) {
        continue;
      }
      choiceList.push({
        _id: status._id,
        id: status.id,
        title: _loc(status.name ?? ""),
        src: status.img,
        order: status.order ?? 0,
        isActive: false,
        isOverlay: false
      });
    }
    const choices = choiceList.sort((a, b) => (a.order - b.order) || a.title.localeCompare(b.title, game.i18n.lang))
      .reduce((obj, choice) => {
        obj[choice.id] = choice;
        return obj;
      }, {});

    // 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: `${_loc("COMMON.Default")} (${_loc(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: _loc(config.label),
        icon: config.icon,
        img: config.img,
        isActive,
        cssClass: isActive ? "active" : ""
      };
    }
    return choices;
  }

  /* -------------------------------------------- */

  /**
   * Get the level choices for this Token.
   * @returns {{[id: string]: {
   *   id: string;
   *   name: string;
   *   cssClass: string;
   * }}}
   * @protected
   */
  _getLevelChoices() {
    if ( !game.user.isGM ) return {};
    const choices = {};
    for ( const level of this.document.parent.levels.reverseSorted ) {
      const isActive = level.id === this.document._source.level;
      choices[level.id] = {
        id: level.id,
        name: level.name,
        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);
    }

    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);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onSubmitElevation(event, form, formData) {
    const origin = this.document._source;
    const elevation = this._parseAttributeInput("elevation", origin.elevation - canvas.level.elevation.base,
      event.target.value).value + canvas.level.elevation.base;
    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;
    const unconstrainedMovement = game.user.isGM && game.settings.get("core", "unconstrainedMovement");
    const constrainOptions = {ignoreWalls: unconstrainedMovement, ignoreCost: unconstrainedMovement};
    await this.document.move(destination, {method: "hud", constrainOptions, animate: false});
  }

  /* -------------------------------------------- */

  /**
   * 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);
  }

  /* -------------------------------------------- */

  /**
   * Handle changing level of controlled Tokens.
   * @this {TokenHUD}
   * @param {PointerEvent} event
   * @param {HTMLButtonElement} target
   * @returns {Promise<void>}
   */
  static async #onChangeLevel(event, target) {
    const level = target.dataset.levelId;
    await canvas.scene.moveTokens(
      this.layer.controlled.reduce((instructions, token) => {
        if ( token.document._source.level !== level ) instructions[token.id] = {destination: {level}};
        return instructions;
      }, {}),
      {method: "hud", pan: {duration: 0}, animate: false}
    );
  }

  /* -------------------------------------------- */
  /*  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$O = /*#__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();
    }
    const submitLabel = this.options.enabling ? "MODMANAGE.Activate" : "MODMANAGE.Deactivate";
    return Object.assign(context, {
      required, optional, subtypes,
      checkbox: new BooleanField(),
      enabling: this.options.enabling,
      buttons: [
        { type: "submit", icon: "fa-solid fa-check", label: submitLabel},
        { type: "button", icon: "fa-solid fa-xmark", label: "COMMON.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, {
      tabClasses: "top-tabs",
      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]: _loc("WEBRTC.ModeDisabled"),
          [AVSettings.AV_MODES.AUDIO]: _loc("WEBRTC.ModeAudioOnly"),
          [AVSettings.AV_MODES.VIDEO]: _loc("WEBRTC.ModeVideoOnly"),
          [AVSettings.AV_MODES.AUDIO_VIDEO]: _loc("WEBRTC.ModeAudioVideo")
        };
        partContext.voiceModes = Object.values(AVSettings.VOICE_MODES).reduce((modes, mode) => {
          modes[mode] = _loc(`WEBRTC.VoiceMode${mode.titleCase()}`);
          return modes;
        }, {});
        partContext.dockPositions = Object.values(AVSettings.DOCK_POSITIONS).reduce((positions, position) => {
          positions[position] = _loc(`WEBRTC.DockPosition${position.titleCase()}`);
          return positions;
        }, {});
        partContext.nameplates = {
          [AVSettings.NAMEPLATE_MODES.OFF]: _loc("WEBRTC.NameplatesOff"),
          [AVSettings.NAMEPLATE_MODES.PLAYER_ONLY]: _loc("WEBRTC.NameplatesPlayer"),
          [AVSettings.NAMEPLATE_MODES.CHAR_ONLY]: _loc("WEBRTC.NameplatesCharacter"),
          [AVSettings.NAMEPLATE_MODES.BOTH]: _loc("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: _loc("WEBRTC.TURNServerProvisioned"),
          custom: _loc("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: _loc("WEBRTC.DefaultSource")});
    }
    if ( disabledLabel ) {
      options.unshift({value: "disabled", label: _loc(disabledLabel)});
    }
    if ( unavailableDevice ) {
      options.push({value: unavailableDevice, label: _loc("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 = Cls.metadata.labelPlural;
      schema[documentName] = new TypedObjectField(new StringField({required: true, nullable: true,
        blank: false, initial: null}), {label, expandKeys: false});
      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.DefaultSheetsL",
      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 ? _loc(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: 15, 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.Label",
      label: "DICE.CONFIG.Label",
      hint: "DICE.CONFIG.Hint",
      icon: "fa-solid fa-dice",
      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: Object.entries(methods).map(([value, {label}]) => ({value, label: _loc(label)})),
      defaultMethod: config.default || "",
      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: _loc("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: _loc("FONTS.Styles.Normal")},
      {value: "italic", label: _loc("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.
   * @param {object} [options]
   * @param {Document} [options.document]       The host Document to load fonts for.
   * @returns {Promise<boolean>}                Returns true if the font was successfully loaded.
   */
  static async loadFont(family, definition, { document }={}) {
    document ??= window.document;
    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 {object} [options]
   * @param {number} [options.timeout=4500] The maximum time to spend loading fonts before proceeding.
   * @param {Document} [options.document]   The host Document to load fonts for.
   * @returns {Promise<void>}
   * @internal
   */
  static async _loadFonts({ document, timeout=4500 }={}) {
    document ??= window.document;
    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, { document }));
      }
    }

    // Font Awesome icons aren't registered with the software: preload them separately
    for ( const font of document.fonts ) {
      if ( font.family.startsWith("Font Awesome") && ( font.status === "unloaded" ) ) promises.push(font.load());
    }

    const wait = new Promise(resolve => setTimeout(resolve, timeout));
    const ready = Promise.all(promises).then(() => document.fonts.ready);

    return Promise.race([ready, wait]).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() || _loc("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(_loc("FONTS.WarnNoFamily"));
      return;
    }

    // Disallow empty file path for file type font
    if ( (type === FontConfig.FONT_TYPES.FILE) && !src ) {
      ui.notifications.warn(_loc("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.Label",
      hint: "SETTINGS.PrototypeTokenOverrides.Hint",
      scope: "world",
      type: PrototypeTokenOverrides,
      onChange: PrototypeTokenOverrides.applyAll.bind(PrototypeTokenOverrides)
    });
    game.settings.registerMenu("core", PrototypeTokenOverrides.SETTING, {
      name: "SETTINGS.PrototypeTokenOverrides.Label",
      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: _loc(`${tabLabelPrefix}.Basics`), icon: "fa-solid fa-gear"},
      marker: {id: "marker", label: _loc(`${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(),
      tabClasses: "vertical",
      rootId: this.id,
      buttons: this.#prepareButtons(),
      booleanOptions: {true: _loc("COMMON.Yes"), false: _loc("COMMON.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.cleanData(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 = _loc("COMMON.AreYouSure");
    const warning = _loc("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 {boolean} chatBackground
 * @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"],
      icon: "fa-solid fa-table-layout"
    },
    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"
      }})
    }),
    chatBackground: new BooleanField({required: true}),
    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: "SETTINGS.UI.ACTIONS.Reset", icon: "fa-solid fa-arrow-rotate-left", action: "reset"},
        {type: "submit", label: "SETTINGS.Save", 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$N = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AVConfig: AVConfig,
  DefaultSheetsConfig: DefaultSheetsConfig,
  DiceConfig: DiceConfig,
  FontConfig: FontConfig,
  PrototypeOverridesConfig: PrototypeOverridesConfig,
  UIConfig: UIConfig
});

var _module$M = /*#__PURE__*/Object.freeze({
  __proto__: null,
  DependencyResolution: DependencyResolution,
  SettingsConfig: SettingsConfig,
  menus: _module$N
});

/**
 * @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 _loc("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: _loc("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) {
    const updated = await this.document.update({ categories: this.#prepareCategories() }, options);
    return updated ? {updated} : {};
  }

  /* -------------------------------------------- */

  /**
   * 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 _JournalSheetRenderOptions
 * {@ignore}
 * @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 {DocumentSheetRenderOptions & _JournalSheetRenderOptions} JournalSheetRenderOptions
 * {@interface}
 */

/**
 * @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 isEditable = !(this.document.pack && game.packs.get(this.document.pack).locked);
    const getPage = li => this.entry.pages.get(li.dataset.pageId);
    return [{
      label: "SIDEBAR.Edit",
      icon: "fa-solid fa-edit",
      visible: li => isEditable && getPage(li)?.canUserModify(game.user, "update"),
      onClick: (event, li) => this._onEditPage(null, li)
    }, {
      label: "OWNERSHIP.Configure",
      icon: "fa-solid fa-user-lock",
      visible: game.user.isGM,
      onClick: (event, li) => {
        const {top, right} = li.getBoundingClientRect();
        this.renderChild(new DocumentOwnershipConfig({
          document: getPage(li),
          position: {top, left: right}
        }));
      }
    }, {
      label: "JOURNAL.ActionShow",
      icon: "fa-solid fa-eye",
      visible: li => getPage(li)?.isOwner,
      onClick: (event, li) => Journal.showDialog(getPage(li), { parent: this })
    }, {
      label: "SIDEBAR.JumpPin",
      icon: "fa-solid fa-crosshairs",
      visible: li => !!getPage(li)?.sceneNote,
      onClick: (event, li) => canvas.notes.panToNote(getPage(li).sceneNote)
    }, {
      label: "SIDEBAR.Delete",
      icon: "fa-solid fa-trash",
      visible: li => isEditable && getPage(li)?.canUserModify(game.user, "delete"),
      onClick: (event, li) => {
        const { top, right } = li.getBoundingClientRect();
        return getPage(li).deleteDialog({
          position: { top, left: right },
          renderOptions: { window: { windowId: this.window.windowId } }
        });
      }
    }, {
      label: "SIDEBAR.Duplicate",
      icon: "fa-regular fa-copy",
      visible: li => isEditable && getPage(li)?.canUserModify(game.user, "create"),
      onClick: (event, li) => {
        const page = getPage(li);
        return page?.clone({name: _loc("DOCUMENT.CopyOf", {name: page.name})}, {save: true, addSource: true});
      }
    }];
  }

  /* -------------------------------------------- */

  /** @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.isMultiple || !options.pageChanged) ) {
      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 = _loc("SIDEBAR.Search", {types: _loc("DOCUMENT.JournalEntryPages")});
    if ( this.isEditable ) {
      context.lockMode = this.locked
        ? { icon: "fa-solid fa-lock", label: "JOURNAL.LockModeLocked" }
        : { icon: "fa-solid fa-lock-open", 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: _loc("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" ) 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-edit" data-tooltip="JOURNAL.EditPage" data-action="editPage"
                  aria-label="${_loc("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 >= Object.keys(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() {
    this.renderChild(new JournalEntryCategoryConfig({ document: this.entry }));
  }

  /* -------------------------------------------- */

  /**
   * 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 sheet = this.entry.pages.get(pageId)?.sheet;
    if ( !sheet ) return;
    if ( sheet.isV2 ) return this.renderChild(sheet);
    return 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, { parent: this });
  }

  /* -------------------------------------------- */

  /**
   * 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 position = {width: 320};
    const sheetBounds = this.element?.getBoundingClientRect();
    if ( sheetBounds ) {
      position.top = sheetBounds.bottom - 200;
      position.left = sheetBounds.left + 10;
    }
    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.FIELDS.category.label"}, {name: "category"}),
          name: new StringField({required: true, blank: false, label: "DOCUMENT.FIELDS.name.label"}, {name: "name"}),
          type: new StringField({required: true, blank: false, label: "DOCUMENT.FIELDS.type.label"}, {name: "type"})
        }
      },
      position,
      renderOptions: {
        window: {
          windowId: this.window.windowId
        }
      }
    }, { window: { windowId: this.window.windowId } });
  }

  /* -------------------------------------------- */

  /**
   * 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 = _loc(button.dataset.tooltip);
    button.classList.toggle("fa-caret-left", !this.sidebarExpanded);
    button.classList.toggle("fa-caret-right", this.sidebarExpanded);
    game.tooltip.deactivate();
  }

  /* -------------------------------------------- */

  /**
   * Iterate over the JournalEntryPage documents which are currently visible in the sheet.
   * @yields {JournalEntryPage}
   */
  * viewedPageDocuments() {
    for ( const article of this.pagesInView ) {
      const page = this.document.pages.get(article.dataset.pageId);
      if ( page ) yield page;
    }
  }

  /* -------------------------------------------- */
  /*  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 ? (pageId in this._pages ? [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 */
  async _insertElement(element, options) {
    if ( this.options.window.frame ) return super._insertElement(element, options);
  }

  /* -------------------------------------------- */

  /** @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] = _loc("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: _loc("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 abstract class that provides code-mirror-specific methods for editing journal page content.
 * @extends JournalEntryPageTextSheet
 */
class JournalEntryPageCodeMirrorSheet extends JournalEntryPageTextSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      contentClasses: ["codemirror", "flexcol"]
    }
  };

  /** @inheritDoc */
  static VIEW_PARTS = {
    content: {
      root: true,
      template: "templates/journal/pages/text/view.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Store the dirty flag for this editor.
   * @type {boolean}
   */
  #isDirty = false;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContentContext(context, options) {
    await super._prepareContentContext(context, options);
    if ( this.isView ) context.text.enriched = await TextEditor$1.implementation.enrichHTML(context.text.content, {
      relativeTo: this.page,
      secrets: this.page.isOwner
    });
  }

  /* -------------------------------------------- */

  /** @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 code-mirror 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 code-mirror 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.element.querySelector("code-mirror");
    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;
  }
}

/**
 * An Application responsible for displaying a single text-type JournalEntryPage Document, and editing it with an HTML
 * editor.
 * @extends JournalEntryPageCodeMirrorSheet
 */
class JournalEntryPageHTMLSheet extends JournalEntryPageCodeMirrorSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      icon: "fa-brands fa-html5"
    }
  };

  /** @inheritDoc */
  static EDIT_PARTS = {
    header: super.EDIT_PARTS.header,
    content: {
      classes: ["flex1", "flexcol"],
      template: "templates/journal/pages/html/edit.hbs"
    },
    footer: super.EDIT_PARTS.footer
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContentContext(context, options) {
    await super._prepareContentContext(context, options);
    if ( !this.isView ) context.text.formatted = this.constructor.formatHTML(context.text.content);
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareSubmitData(event, form, formData, updateData) {
    const submitData = super._prepareSubmitData(event, form, formData, updateData);
    const content = foundry.utils.getProperty(submitData, "text.content");
    if ( content ) foundry.utils.setProperty(submitData, "text.content", content.replace(/>\n+\s*</g, "><"));
    return submitData;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Auto-format an HTML string.
   * @param {string} html                       The HTML string.
   * @param {object} [options]
   * @param {string|number} [options.spaces=4]  The number of spaces to indent by, or a string to use as indentation.
   * @returns {string}
   */
  static formatHTML(html, { spaces=4 }={}) {
    if ( !html || !html.trim() ) return "";
    try {
      return foundry.utils.StringNode.fromString(html).toString(spaces);
    } catch(e) {
      console.warn("Failed to format HTML: ", e);
    }
    return "";
  }
}

/**
 * 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 JournalEntryPageCodeMirrorSheet
 */
class JournalEntryPageMarkdownSheet extends JournalEntryPageCodeMirrorSheet {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      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
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  static format = JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContentContext(context, options) {
    await super._prepareContentContext(context, options);
    if ( !this.isView ) context.markdownFormat = JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @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.parse(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;
    if ( options.window && ("detached" in options.window) ) 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",
      default: true
    }],
    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 _loc("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),
      isGM: game.user.isGM,
      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: _replace(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$L = /*#__PURE__*/Object.freeze({
  __proto__: null,
  JournalEntryCategoryConfig: JournalEntryCategoryConfig,
  JournalEntryPageCodeMirrorSheet: JournalEntryPageCodeMirrorSheet,
  JournalEntryPageHTMLSheet: JournalEntryPageHTMLSheet,
  JournalEntryPageHandlebarsSheet: JournalEntryPageHandlebarsSheet,
  JournalEntryPageImageSheet: JournalEntryPageImageSheet,
  JournalEntryPageMarkdownSheet: JournalEntryPageMarkdownSheet,
  JournalEntryPagePDFSheet: JournalEntryPagePDFSheet,
  JournalEntryPageProseMirrorSheet: JournalEntryPageProseMirrorSheet,
  JournalEntryPageSheet: JournalEntryPageSheet,
  JournalEntryPageTextSheet: JournalEntryPageTextSheet,
  JournalEntryPageVideoSheet: JournalEntryPageVideoSheet,
  JournalEntrySheet: JournalEntrySheet,
  ShowToPlayersDialog: ShowToPlayersDialog
});

/**
 * @import ApplicationV2 from "../../api/application.mjs";
 * @import {Constructor} from "@common/_types.mjs";
 * @import {PlaceablePaletteConfiguration, PlaceablePaletteRenderOptions} from "../../_types.mjs";
 */

/**
 * Augment a placeable config so that it can be used to bulk edit and set default creation data.
 * @param {Constructor<ApplicationV2>} BaseConfig
 */
function PlaceablePaletteMixin(BaseConfig) {
  /**
   * The mixed application class augmented with placeable palette functionality.
   * @extends {ApplicationV2<PlaceablePaletteConfiguration, PlaceablePaletteRenderOptions>}
   * @template {typeof Document} T
   */
  class PlaceablePalette extends BaseConfig {
    constructor(options, ...args) {
      const documentClass = getDocumentClass(new.target.documentName);
      const createData = new.target.createData ?? {};
      let { initialData={} } = new.target.DEFAULT_OPTIONS;
      initialData = foundry.utils.mergeObject(initialData, options.initialData ?? {}, { inplace: false });
      foundry.utils.mergeObject(initialData, createData);
      options.document ??= new documentClass(initialData, { parent: canvas.scene });
      super(options, ...args);
    }

    /* -------------------------------------------- */

    /** @override */
    static DEFAULT_OPTIONS = {
      classes: ["placeable-palette", "faded-ui"],
      initialData: {},
      position: {
        scale: .8,
        width: 375
      },
      form: {
        closeOnSubmit: false,
        submitOnChange: true
      },
      actions: {
        closeDetails: PlaceablePalette.#onCloseDetails,
        closePalette: PlaceablePalette.#onClosePalette,
        commit: PlaceablePalette.#onCommit,
        reset: PlaceablePalette.#onReset
      },
      preview: false
    };

    /* -------------------------------------------- */
    /*  Properties                                  */
    /* -------------------------------------------- */

    /**
     * The all controlled documents for this palette's placeables layer.
     * @type {InstanceType<T>[]}
     */
    get controlled() {
      return this.layer?.controlled.map(p => p.document) ?? [];
    }

    /* -------------------------------------------- */

    /**
     * The initial creation data for a new document.
     * @type {object}
     */
    get createData() {
      return game.settings.get("core", this.constructor.SETTING_KEY);
    }

    /* -------------------------------------------- */

    /**
     * The initial creation data for a new document, reading from the live palette instance if one is open, otherwise
     * falling back to the stored settings, with level and elevation synced to the currently viewed level.
     * @type {object}
     */
    static get createData() {
      if ( ui.placeablesPalette ) return ui.placeablesPalette.createData;
      const data = game.settings.get("core", this.SETTING_KEY);
      if ( !canvas.level ) return data;
      return foundry.utils.mergeObject(data, this._getDefaultLevelData(), { inplace: false });
    }

    /* -------------------------------------------- */

    /**
     * Get the default level and elevation data for a given level, used when syncing to the currently viewed level.
     * @returns {object}
     * @protected
     */
    static _getDefaultLevelData() {
      return {
        levels: [canvas.level.id],
        elevation: canvas.level.elevation.base
      };
    }

    /* -------------------------------------------- */

    /**
     * If defined, switch to this tool after committing settings so the user can immediately draw with them.
     * @type {string}
     */
    static COMMIT_TOOL;

    /* -------------------------------------------- */

    /**
     * The fields the user has modified from their default values.
     * @type {Set<string>}
     * @internal
     */
    _dirtyFields = new Set();

    /* -------------------------------------------- */

    /**
     * The fields that have differing values across the set of controlled documents.
     * @type {Set<string>}
     * @internal
     */
    _multiFields = new Set();

    /* -------------------------------------------- */

    /**
     * The class of the document that backs this form.
     * @type {T}
     */
    get documentClass() {
      return getDocumentClass(this.documentName);
    }

    /* -------------------------------------------- */

    /**
     * The placeable document.
     * @returns {string}
     */
    get documentName() {
      return this.constructor.documentName;
    }

    /* -------------------------------------------- */

    /**
     * Whether the palette is editing multiple placeables.
     * @type {boolean}
     */
    get isSelect() {
      return game.activeTool === "select";
    }

    /* -------------------------------------------- */

    /**
     * The canvas layer for this palette's placeable.
     * @type {PlaceablesLayer}
     */
    get layer() {
      return canvas.getLayerByEmbeddedName(this.documentName);
    }

    /* -------------------------------------------- */

    /**
     * The schema of the user's stored palette values.
     * @returns {SchemaField}
     */
    static get schema() {
      const schema = getDocumentClass(this.documentName).defineSchema();
      delete schema._id;

      // Remove internal fields.
      for ( const fieldName in schema ) {
        if ( fieldName.startsWith("_") ) delete schema[fieldName];
      }

      return new foundry.data.fields.SchemaField(schema);
    }

    /* -------------------------------------------- */

    /** @override */
    get title() {
      const label = _loc(this.documentClass.metadata.label);
      if ( this.isSelect ) {
        const count = this.layer?.controlled.length;
        if ( count ) return _loc("PLACEABLE_PALETTE.Selected", { document: label, count });
      }
      return _loc("PLACEABLE_PALETTE.Title", { document: label });
    }

    /* -------------------------------------------- */

    /** @override */
    get isEditable() {
      if ( this.isSelect && !this.layer?.controlled.length ) return false;
      return super.isEditable;
    }

    /* -------------------------------------------- */
    /*  Rendering                                   */
    /* -------------------------------------------- */

    /**
     * Configure an appropriate preset to apply.
     * @param {PlaceablePaletteRenderOptions} [options]  Render options.
     * @returns {object|void}                            The preset to apply, if any.
     */
    #applyPreset(options={}) {
      if ( !this.rendered || !options.preset ) return;
      if ( !options.preservePlacement ) return options.preset;
      const formData = new FormDataExtended(this.form).object;
      return this._applyPreset(formData, options);
    }

    /* -------------------------------------------- */

    /**
     * Configure an appropriate preset to apply.
     * @param {object} formData                          The palette data.
     * @param {PlaceablePaletteRenderOptions} [options]  Render options.
     * @protected
     */
    _applyPreset(formData, { preset={} }={}) {
      preset = foundry.utils.deepClone(preset);
      preset.levels = formData.levels;
      if ( formData.elevation != null ) preset.elevation = formData.elevation;
      return preset;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _configureRenderOptions(options) {
      super._configureRenderOptions(options);
      options.window ??= {};
      options.window.title = this.title;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _prepareContext(options) {
      const context = await super._prepareContext(options);
      context.rootId = this.id;
      context.isSelect = this.isSelect;
      context.buttons = [{
        type: "reset", action: "reset", icon: "fa-solid fa-arrow-rotate-left", label: "SETTINGS.UI.ACTIONS.Reset"
      }];
      if ( this.isSelect && this.layer?.controlled.length ) context.buttons.push({
        type: "button", action: "commit", icon: "fa-solid fa-palette", label: "PLACEABLE_PALETTE.CloneSettings"
      });
      if ( this.layer?.controlled.length ) {
        const disabled = this.layer.controlled.some(o => o.document.locked);
        context.buttons.push({
          type: "submit", icon: "fa-solid fa-check", label: "PLACEABLE_PALETTE.Apply",
          hidden: !this._dirtyFields.size, disabled, tooltip: disabled ? "PLACEABLE_PALETTE.CannotApplyToLocked" : ""
        });
      }
      return context;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async render(options, _options) {
      const preset = this.#applyPreset(options);
      this._dirtyFields = new Set();
      if ( this.isSelect ) {
        const { controlled } = this;
        if ( preset ) {
          this._multiFields = new Set();
          this.document.updateSource(preset);
          this._dirtyFields = new Set(Object.keys(foundry.utils.flattenObject(preset)));
        } else {
          this._multiFields = this._determineMultiFields(controlled);
          if ( controlled.length ) {
            const startValues = controlled[0].toObject();
            this.constructor.schema.clean(startValues);
            this.document.updateSource(startValues);
          }
          else this.document.updateSource(this.constructor.createData);
        }
      } else if ( preset ) {
        this.document.updateSource(preset);
        game.settings.set("core", this.constructor.SETTING_KEY, foundry.utils.mergeObject(this.createData, preset));
      }
      else this.document.updateSource(this.constructor.createData);
      this._preview = this.document;
      await super.render(options, _options);
    }

    /* -------------------------------------------- */
    /*  Lifecycle                                   */
    /* -------------------------------------------- */

    /** @inheritDoc */
    _onClose(options) {
      super._onClose(options);
      ui.placeablesPalette = null;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _onFirstRender(context, options) {
      await super._onFirstRender(context, options);
      ui.placeablesPalette = this;
      if ( !this.isSelect ) this.submit();
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _onRender(context, options) {
      await super._onRender(context, options);
      this.#renderInlineHints();
      if ( !this.isSelect || !this._multiFields.size ) return;
      // Apply placeholders to fields with divergent values.
      for ( const fieldPath of this._multiFields ) {
        const el = this.form.elements[fieldPath];
        if ( el ) this._setPlaceholder(el);
      }
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _processFormData(event, form, formData) {
      const submitData = super._processFormData(event, form, formData);
      if ( !this.isSelect ) return submitData;
      const dirty = {};
      for ( const field of this._dirtyFields ) {
        const value = foundry.utils.getProperty(submitData, field);
        if ( value !== undefined ) foundry.utils.setProperty(dirty, field, value);
      }
      return dirty;
    }

    /* -------------------------------------------- */

    /** @override */
    async _processSubmitData(event, form, submitData, options) {
      if ( (event.type === "submit") && event.isTrusted ) {
        if ( !foundry.utils.isEmpty(submitData) ) {
          await canvas.scene.updateEmbeddedDocuments(this.documentName, this.controlled.reduce((updates, d) => {
            if ( !d.locked ) updates.push({ _id: d.id, ...submitData });
            return updates;
          }, []), options);
        }
        this._dirtyFields = new Set();
        return {};
      }

      if ( this.document.locked ) return {};
      this.document.updateSource(submitData, options);
      await game.settings.set("core", this.constructor.SETTING_KEY, this.document.toObject());
      ui.controls.render({ parts: ["tools"] });
      await this.render();
      return {};
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _renderFrame(options) {
      const frame = await super._renderFrame(options);
      this.window.close.dataset.action = "closePalette";
      return frame;
    }

    /* -------------------------------------------- */
    /*  Event Listeners & Handlers                  */
    /* -------------------------------------------- */

    /** @inheritDoc */
    _onChangeForm(formConfig, event) {
      if ( this.isSelect ) {
        const name = event.target.name;
        if ( name ) {
          this._dirtyFields.add(name);
          PlaceablePalette.#clearPlaceholder(event.target);
          this.element.querySelector("button[type=submit]")?.removeAttribute("hidden");
        }
        // Allow the normal config to perform any onchange logic.
        return super._onChangeForm({ ...formConfig, submitOnChange: false }, event);
      }
      return super._onChangeForm(formConfig, event);
    }

    /* -------------------------------------------- */

    /**
     * Handle collapsing a details disclosure.
     * @this {PlaceablePalette}
     * @param {PointerEvent} event  The triggering event.
     * @param {HTMLElement} target  The action target.
     */
    static #onCloseDetails(event, target) {
      const details = target.closest("details");
      if ( details ) details.open = false;
    }

    /* -------------------------------------------- */

    /**
     * Handle closing the palette via the close button.
     * @this {PlaceablePalette}
     */
    static #onClosePalette() {
      return ui.controls.activate({ toggles: { togglePalette: false } });
    }

    /* -------------------------------------------- */

    /**
     * Handle committing the common values of the current selection as the palette defaults.
     * @this {PlaceablePalette}
     */
    static async #onCommit() {
      const { controlled, _dirtyFields, _multiFields } = this;
      if ( !controlled.length ) return;

      // Build the set of common values: all fields from the first document that don't differ across selection.
      const baseData = foundry.utils.flattenObject(controlled[0].toObject());
      const formData = new FormDataExtended(this.form).object;
      const toCommit = {};
      for ( let [path, value] of Object.entries(baseData) ) {
        if ( _dirtyFields.has(path) ) value = formData[path];
        else if ( _multiFields.has(path) ) continue;
        toCommit[path] = value;
      }

      // Merge committed values on top of current settings.
      const current = game.settings.get("core", this.constructor.SETTING_KEY) ?? {};
      const updated = foundry.utils.mergeObject(current, foundry.utils.expandObject(toCommit), { inplace: false });
      await game.settings.set("core", this.constructor.SETTING_KEY, updated);
      if ( this.constructor.COMMIT_TOOL ) ui.controls.activate({ tool: this.constructor.COMMIT_TOOL });
    }

    /* -------------------------------------------- */

    /**
     * Handle resetting to default values.
     * @this {PlaceablePalette}
     */
    static #onReset() {
      if ( this.isSelect ) return this.render();
      const preset = this.constructor.schema.clean(this.options.initialData);
      foundry.utils.mergeObject(preset, this.constructor._getDefaultLevelData());
      return this.render({ preset });
    }

    /* -------------------------------------------- */
    /*  Helpers                                     */
    /* -------------------------------------------- */

    /**
     * Clear a multi-value placeholder from the given element.
     * @param {HTMLElement} element
     */
    static #clearPlaceholder(element) {
      const { isElementInstanceOf } = foundry.utils;
      element.classList.remove("multiple-values");
      if ( isElementInstanceOf(element, HTMLSelectElement) ) {
        element.querySelector("option[data-multi-placeholder]")?.remove();
      } else if ( isElementInstanceOf(element, HTMLMultiSelectElement) ) {
        const blank = element.querySelector("option[data-multi-placeholder]");
        if ( blank ) {
          blank.textContent = "";
          delete blank.dataset.multiPlaceholder;
        }
      } else if ( foundry.utils.isElementInstanceOf(element, HTMLMultiCheckboxElement) ) {
        for ( const cb of element.querySelectorAll("input[type=checkbox]") ) cb.indeterminate = false;
      } else if ( element.type === "checkbox" ) {
        element.indeterminate = false;
      }
      if ( !element._originalPlaceholder ) return;
      if ( isElementInstanceOf(element, HTMLFilePickerElement)
        || isElementInstanceOf(element, HTMLColorPickerElement) ) {
        const input = element.querySelector(":scope > input");
        input.placeholder = element._originalPlaceholder;
      } else if ( isElementInstanceOf(element, HTMLInputElement) ) {
        element.placeholder = element._originalPlaceholder || "";
      }
    }

    /* -------------------------------------------- */

    /**
     * Determine which fields have values that are not the same across all selected documents.
     * @param {InstanceType<T>[]} docs
     * @returns {Set<string>}
     * @protected
     */
    _determineMultiFields(docs) {
      const multiple = new Set();
      if ( docs.length < 2 ) return multiple;
      const fields = foundry.utils.flattenObject(docs[0]._source);
      for ( const [key, value] of Object.entries(fields) ) {
        const field = docs[0].getFieldForProperty(key);
        let cast;
        if ( field instanceof foundry.data.fields.SetField ) cast = v => Array.isArray(v) ? new Set(v) : v;
        else cast = v => v;
        fields[key] = {value: cast(value), cast};
      }
      for ( let i = 1; i < docs.length; i++ ) {
        const d = docs[i];
        for ( const [key, {value: v0, cast}] of Object.entries(fields) ) {
          const v1 = cast(foundry.utils.getProperty(d._source, key));
          if ( !foundry.utils.equals(v0, v1) ) multiple.add(key);
        }
      }
      return multiple;
    }

    /* -------------------------------------------- */

    /**
     * Determine whether the given preset creation data matches the currently stored palette settings.
     * @param {object} createData  The preset creation data to compare against.
     * @returns {boolean}
     */
    static isActivePreset(createData) {
      // Get the raw setting value prior to SchemaField initialization.
      const settings = JSON.parse(game.settings.storage.get("client").getItem(`core.${this.SETTING_KEY}`) ?? "{}");
      if ( foundry.utils.isEmpty(settings) ) return false;
      return foundry.utils.isEmpty(foundry.utils.diffObject(settings, createData));
    }

    /* -------------------------------------------- */

    /**
     * Render hints as inline as info bubbles.
     */
    #renderInlineHints() {
      for ( const hint of this.element.querySelectorAll("p.hint") ) {
        const group = hint.closest(".form-group");
        const label = group?.querySelector("label");
        if ( !label ) continue;
        label.classList.add("info");
        label.dataset.tooltipText = hint.textContent;
        label.insertAdjacentElement("beforeend", createFontAwesomeIcon("circle-info"));
        hint.hidden = true;
      }
    }

    /* -------------------------------------------- */

    /**
     * Set a multi-value placeholder on the given element.
     * @param {HTMLElement} element
     * @protected
     */
    _setPlaceholder(element) {
      const document = this.element.ownerDocument;
      const multiLabel = _loc("PLACEABLE_PALETTE.MultipleValues");
      element.classList.add("multiple-values");
      if ( foundry.utils.isElementInstanceOf(element, HTMLSelectElement) ) {
        const placeholder = document.createElement("option");
        Object.assign(placeholder, { textContent: multiLabel, disabled: true, selected: true });
        placeholder.dataset.multiPlaceholder = "";
        element.insertAdjacentElement("afterbegin", placeholder);
        element.selectedIndex = 0;
      } else if ( foundry.utils.isElementInstanceOf(element, HTMLMultiSelectElement) ) {
        element._value.clear();
        element._refresh();
        const blank = element.querySelector('select > option[value=""]');
        blank.textContent = multiLabel;
        blank.dataset.multiPlaceholder = "";
      } else if ( foundry.utils.isElementInstanceOf(element, HTMLFilePickerElement)
        || foundry.utils.isElementInstanceOf(element, HTMLColorPickerElement) ) {
        const input = element.querySelector(":scope > input");
        element._setValue("");
        element._refresh();
        element._originalPlaceholder = input.placeholder;
        input.placeholder = multiLabel;
      } else if ( foundry.utils.isElementInstanceOf(element, HTMLMultiCheckboxElement) ) {
        for ( const cb of element.querySelectorAll("input[type=checkbox]") ) {
          cb.indeterminate = true;
          cb.checked = false;
        }
      } else if ( element.type === "checkbox" ) {
        element.indeterminate = true;
        element.checked = false;
      } else if ( foundry.utils.isElementInstanceOf(element, HTMLInputElement) ) {
        element.value = "";
        element._originalPlaceholder = element.placeholder;
        element.placeholder = multiLabel;
      }
    }
  }
  return PlaceablePalette;
}

/**
 * @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 get FRAGMENT_UNIFORMS() {
    return `
  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.Program.defaultVertexPrecision} float rotation;
  uniform ${PIXI.Program.defaultVertexPrecision} float angle;
  uniform ${PIXI.Program.defaultVertexPrecision} float radius;
  uniform ${PIXI.Program.defaultVertexPrecision} float depthElevation;
  uniform ${PIXI.Program.defaultVertexPrecision} vec2 resolution;
  uniform ${PIXI.Program.defaultVertexPrecision} vec2 screenDimensions;
  uniform ${PIXI.Program.defaultVertexPrecision} vec3 origin;
  uniform ${PIXI.Program.defaultVertexPrecision} vec3 dimensions;
  uniform ${PIXI.Program.defaultVertexPrecision} mat3 translationMatrix;
  uniform ${PIXI.Program.defaultVertexPrecision} 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);
  `;

  /** @override */
  static _createVertexShader() {
    return `
    ${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 - (0.5 / 255.0), 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.COLORATION.Legacy"
    },
    LUMINANCE: {
      id: 1,
      label: "LIGHT.COLORATION.AdaptiveLuminance",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      finalColor *= reflection;`
    },
    INTERNAL_HALO: {
      id: 2,
      label: "LIGHT.COLORATION.InternalHalo",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      finalColor = switchColor(finalColor, finalColor * reflection, dist);`
    },
    EXTERNAL_HALO: {
      id: 3,
      label: "LIGHT.COLORATION.ExternalHalo",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      finalColor = switchColor(finalColor * reflection, finalColor, dist);`
    },
    COLOR_BURN: {
      id: 4,
      label: "LIGHT.COLORATION.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.COLORATION.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.COLORATION.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.COLORATION.LowAbsorption",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      reflection *= smoothstep(0.35, 0.75, reflection);
      finalColor *= reflection;`
    },
    HIGH_ABSORPTION: {
      id: 8,
      label: "LIGHT.COLORATION.HighAbsorption",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      reflection *= smoothstep(0.55, 0.85, reflection);
      finalColor *= reflection;`
    },
    INVERT_ABSORPTION: {
      id: 9,
      label: "LIGHT.COLORATION.InvertAbsorption",
      coloration: `
      float r = reversePerceivedBrightness(baseColor);
      finalColor *= (r * r * r * r * r);`
    },
    NATURAL_LIGHT: {
      id: 10,
      label: "LIGHT.COLORATION.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);`
    },
    NATURAL_ATTENUATION: {
      id: 100,
      label: "LIGHT.COLORATION.NaturalAttenuation",
      coloration: `
        float reflection = perceivedBrightness(baseColor);
        finalColor *= reflection;
        float k = 1.0 + attenuation * 13.33;
        float s = 0.05;
        float fall = mix(exp(-k * (dist - s)), 1.0, step(dist, s));
        depth *= max(0.095, fall);
      `,
      illumination: `
        float k = 1.0 + attenuation * 13.33;
        float s = 0.05;
        float fall = mix(exp(-k * (dist - s)), 1.0, step(dist, s));
        depth *= max(0.095, fall);
      `,
      background: `
        float ambientColorIntensity = perceivedBrightness(computedBackgroundColor);
        vec3 mutedColor = mix(finalColor,
                              finalColor * mix(color, computedBackgroundColor, ambientColorIntensity),
                              backgroundAlpha);
        finalColor = mix( finalColor,
                          mutedColor,
                          computedDarknessLevel);`
    },
    ADAPTIVE_ATTENUATION: {
      id: 101,
      label: "LIGHT.COLORATION.AdaptiveAttenuation",
      coloration: `
        float reflection = perceivedBrightness(baseColor);
        finalColor *= reflection;
        float k = 1.0 + attenuation * 13.33;
        float s = 0.05;
        float fall = mix(exp(-k * (dist - s)), 1.0, step(dist, s));
        depth *= max(0.095, fall);
      `,
      illumination: `
        float k = 1.0 + attenuation * 13.33;
        float s = 0.05;
        float fall = mix(exp(-k * (dist - s)), 1.0, step(dist, s));
        depth *= max(0.095, fall);
      `
    }
  };
}

/**
 * The Application responsible for configuring a single AmbientLight document within a parent Scene.
 */
class AmbientLightConfig extends PlaceableConfig {

  /** @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"
    }
  };

  /* -------------------------------------------- */
  /*  Application Rendering                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.#toggleReset();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const {document} = context;
    const isDarkness = document.config.negative;
    return Object.assign(context, {
      tabClasses: "top-tabs",
      light: document,
      colorationTechniques: AdaptiveLightingShader.SHADER_TECHNIQUES,
      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.${this.document.collection?.has(this.document.id) ? "UPDATE" : "CREATE"}`
        }
      ]
    });
  }

  /* -------------------------------------------- */

  /**
   * Toggle visibility of the reset button which is only visible on the advanced tab.
   */
  #toggleReset() {
    if ( !("advanced" in this.constructor.PARTS) ) return;
    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);

    // Special handling for darkness state change
    if ( (event.target.name === "config.negative") && ("advanced" in this.constructor.PARTS) ) {
      this.render({parts: ["animation", "advanced"]});
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _previewChanges(changes) {
    super._previewChanges(changes);
    if ( !this._preview.rendered ) return;
    const redraw = this._preview.object.controls.shape.type !== this._preview.shape.type;
    this._preview.object.renderFlags.set({redraw});
    this._preview.object.initializeLightSource();
  }

  /* -------------------------------------------- */
  /*  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();
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  get preview() {
    foundry.utils.logCompatibilityWarning("The AmbientLightConfig#preview has been deprecated in favor of AmbientLightConfig#_preview",
      {since: 14, until: 16, once: true});
    return this._preview;
  }
}

/**
 * A dialog that provides bulk operation or default values for newly-created ambient lights.
 * @extends {AmbientLightConfig}
 * @mixes PlaceablePaletteMixin
 */
class AmbientLightPalette extends PlaceablePaletteMixin(AmbientLightConfig) {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "ambient-light-palette"
  };

  /* -------------------------------------------- */

  /**
   * The setting key where default data is saved.
   * @type {string}
   */
  static SETTING_KEY = "ambientLightPalette";

  /* -------------------------------------------- */

  /**
   * The placeable document.
   * @type {string}
   */
  static documentName = "AmbientLight";

  /* -------------------------------------------- */

  /** @override */
  static TABS = {};

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    body: { template: "templates/scene/palette/ambient-light/body.hbs" },
    footer: { template: "templates/generic/form-footer.hbs" }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  static get schema() {
    const schema = super.schema;
    schema.removeFields(["x", "y", "name", "locked", "flags", "rotation", "config"]);

    // Replace config with a SchemaField that excludes dim and bright
    const { dim: _d, bright: _b, ...config} = LightData.defineSchema();
    schema.extendFields({ config: new foundry.data.fields.SchemaField(config) });

    return schema;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    // Restore fields excluded from the palette schema for select mode display
    if ( this.isSelect ) {
      const source = this.controlled.at(0)?._source;
      this.document.updateSource({
        rotation: source?.rotation ?? 0,
        config: { dim: source?.config.dim ?? 0, bright: source?.config.bright ?? 0 }
      });
    }
    const context = await super._prepareContext(options);
    context.isDarkness = context.document.config.negative;
    context.colorationTechniques = AdaptiveLightingShader.SHADER_TECHNIQUES;
    context.lightAnimations = context.isDarkness ? CONFIG.Canvas.darknessAnimations : CONFIG.Canvas.lightAnimations;
    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  async _preparePartContext(partId, context) {
    context.partId = `${this.id}-${partId}`;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    if ( (event.target.name === "config.negative") && this.isSelect ) {
      this.element.querySelector('[data-sync="details-advanced"]').hidden = event.target.checked;
    }
    return super._onChangeForm(formConfig, event);
  }
}

/**
 * The Application responsible for configuring a single AmbientSound document within a parent Scene.
 */
class AmbientSoundConfig extends PlaceableConfig {

  /** @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 _loc("AMBIENT_SOUND.ACTIONS.CREATE");
    return super.title;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    return Object.assign(context, {
      soundEffects: CONFIG.soundEffects,
      buttons: [{
        type: "submit",
        icon: "fa-solid fa-floppy-disk",
        label: `AMBIENT_SOUND.ACTIONS.${this.document.collection?.has(this.document.id) ? "UPDATE" : "CREATE"}`
      }]
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    this._toggleDisabledFields();
    return super._onRender(context, options);
  }

  /* -------------------------------------------- */
  /*  Real-Time Preview                           */
  /* -------------------------------------------- */


  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    this._toggleDisabledFields();
    super._onChangeForm(formConfig, event);
  }

  /* -------------------------------------------- */

  /**
   * Special logic to toggle the disabled state of form fields depending on the values of other fields.
   * @protected
   */
  _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;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _previewChanges(changes) {
    super._previewChanges(changes);
    if ( !this._preview.rendered ) return;
    this._preview.object.initializeSoundSource();
  }
}

/**
 * A dialog that provides bulk operation or default values for newly-created ambient sounds.
 * @extends {AmbientSoundConfig}
 * @mixes PlaceablePaletteMixin
 */
class AmbientSoundPalette extends PlaceablePaletteMixin(AmbientSoundConfig) {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "ambient-sound-palette"
  };

  /* -------------------------------------------- */

  /**
   * The setting key where default data is saved.
   * @type {string}
   */
  static SETTING_KEY = "ambientSoundPalette";

  /* -------------------------------------------- */

  /**
   * The placeable document.
   * @type {string}
   */
  static documentName = "AmbientSound";

  /* -------------------------------------------- */

  /** @override */
  static TABS = {};

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    body: { template: "templates/scene/palette/ambient-sound/body.hbs" },
    footer: { template: "templates/generic/form-footer.hbs" }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get createData() {
    if ( !this.rendered ) return super.createData;
    const formData = new FormDataExtended(this.form);
    return foundry.utils.expandObject(formData.object);
  }

  /* -------------------------------------------- */

  /** @override */
  static get schema() {
    const schema = super.schema;
    schema.removeFields(["x", "y", "radius", "name", "path", "locked"]);
    return schema;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    // Restore fields excluded from the palette schema for select mode display
    if ( this.isSelect ) this.document.updateSource({ radius: this.controlled.at(0)?._source.radius ?? 0 });
    return super._prepareContext(options);
  }

  /* -------------------------------------------- */

  /** @override */
  async _preparePartContext(partId, context) {
    context.partId = `${this.id}-${partId}`;
    return context;
  }
}

/**
 * @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$c = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * @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);
  }
}

/**
 * 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;
    }
  };
}

/**
 * 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 {Level} from "@client/documents/_module.mjs";
 */

/**
 * 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 transcoder for PIXI.Assets.
   * @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";

    return PIXI.TranscoderWorker.loadTranscoder(transcoderJSPath, transcoderWASMPath);
  }

  /* -------------------------------------------- */

  /**
   * @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 or Level.
   * @param {Scene|Level} sceneOrLevel                    The Scene or Level 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(sceneOrLevel, {expireCache=true, additionalSources=[], maxConcurrent}={}) {
    let scene;
    let level;
    if ( sceneOrLevel instanceof Scene$2 ) scene = sceneOrLevel;
    else {
      level = sceneOrLevel;
      scene = level.parent;
    }
    const originalScene = scene;

    // Simulate view of the (initial) level if the scene/level is not viewed
    const manager = scene.isView ? canvas.manager : foundry.canvas.Canvas.getSceneManager(scene);
    if ( !scene.isView || (level && !level.isView) ) {
      scene = scene.clone();
      scene._view = level?.id ?? foundry.canvas.Canvas._determineInitialLevel(scene, manager);
    }
    level = scene.levels.get(scene._view);
    const originalLevel = originalScene.levels.get(level.id);

    // Level textures
    let toLoad = [];
    for ( const lt of scene._configureLevelTextures() ) toLoad.push(lt.src);  // All visible levels
    if ( level?.fog?.src ) toLoad.push(level.fog.src);                        // Viewed level only

    // Tiles
    toLoad = toLoad.concat(scene.tiles.reduce((arr, t) => {
      if ( t.texture.src && t.viewed ) arr.push(t.texture.src);
      return arr;
    }, []));

    // Tokens
    toLoad.push(CONFIG.Token.ring.spritesheet);
    toLoad = toLoad.concat(scene.tokens.reduce((arr, t) => {
      if ( !t.viewed ) return arr;
      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 && wall.viewed ) toLoad.push(wall.animation.texture);
    }

    // Drawing Textures
    for ( const drawing of scene.drawings ) {
      if ( drawing.texture && drawing.viewed ) toLoad.push(drawing.texture);
    }

    // Note Icons
    for ( const note of scene.notes ) {
      if ( note.texture.src && note.viewed ) toLoad.push(note.texture.src);
    }

    // Control Icons
    toLoad = toLoad.concat(Object.values(CONFIG.controlIcons));

    // Status Effect textures
    toLoad = toLoad.concat(Object.values(CONFIG.statusEffects).map(e => e.img));

    // Configured scene textures
    const sceneTextures = originalLevel.isView ? canvas.sceneTextures : {};
    manager?._loadTextures(sceneTextures, additionalSources, originalLevel);
    for ( const t of Object.values(sceneTextures) ) {
      if ( typeof t === "string" ) toLoad.push(t);
    }

    // Additional requested sources
    toLoad.push(...additionalSources);

    // Load files
    const showName = scene.active || scene.isView || (scene.navigation && scene.visible) || game.user.isGM;
    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 || s.startsWith("#") ) continue;
      toLoad.add(s);
    }
    if ( !toLoad.size ) return;

    // Create progress tracking
    console.groupCollapsed(`${CONST.vtt} | Loading ${toLoad.size} Assets`);
    let progress;
    if ( displayProgress && toLoad.size ) {
      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;
  }

  /* -------------------------------------------- */

  /**
   * 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`
      );
    }
  }

  /* -------------------------------------------- */
  /*  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 v14
   * @ignore
   */
  static hasTextExtension(src) {
    foundry.utils.logCompatibilityWarning("TextureLoader.hasTextExtension has been deprecated without replacement.",
      {since: 14, until: 16, once: true});
    return HAS_TEXT_REGEXP.test(src);
  }

  /* --------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  static async fetchResource(src, {bustCache=false}={}) {
    foundry.utils.logCompatibilityWarning("TextureLoader.fetchResource is deprecated. "
      + "Please use foundry.utils.fetchResource instead.", {since: 14, until: 16, once: true});
    return foundry.utils.fetchResource(src, {bustCache});
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  static getCacheBustURL(src) {
    foundry.utils.logCompatibilityWarning("TextureLoader.getCacheBustURL is deprecated. "
      + "Please use foundry.utils.getCacheBustURL instead.", {since: 14, until: 16, once: true});
    return foundry.utils.getCacheBustURL(src);
  }
}

/**
 * A global reference to the singleton texture loader
 * @type {TextureLoader}
 */
TextureLoader.loader = new TextureLoader();

/* -------------------------------------------- */

/**
 * 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");

/* -------------------------------------------- */
/*  Deprecations                                */
/* -------------------------------------------- */

/**
 * @deprecated since v14
 * @ignore
 */
async function srcExists(src) {
  foundry.utils.logCompatibilityWarning("foundry.canvas.srcExists is deprecated. "
    + "Please use foundry.utils.srcExists instead.", {since: 14, until: 16, once: true});
  return foundry.utils.srcExists(src);
}

/**
 * A generic helper for drawing a standard Control Icon.
 */
class ControlIcon extends RenderFlagsMixin(PIXI.Container) {
  /**
   * @param {object} [options]
   * @param {PIXI.Texture|string} [options.texture]   The (URL of the) icon texture
   * @param {number} [options.size]                   The size of the icon
   * @param {PIXI.ColorSource} [options.tint]         The icon tint
   * @param {PIXI.ColorSource} [options.borderColor]  The border color
   * @param {number} [options.elevation]              The elevation
   */
  constructor({texture=PIXI.Texture.EMPTY, size=40, borderColor=CONFIG.Canvas.dispositionColors.CONTROLLED,
    tint=0xFFFFFF, elevation=0}={}) {
    if ( tint === null ) {
      foundry.utils.logCompatibilityWarning("Passing null for tint to the ControlIcon constructor is deprecated. Pass 0xFFFFFF or undefined instead.",
        {since: 14, until: 16, once: true});
      tint = 0xFFFFFF;
    }
    super();
    this.#texture = texture;
    this.#loadedTexture = typeof texture === "string" ? getTexture(texture) : texture;
    this.#size = size;
    this.#elevation = elevation;
    this.bg = this.addChild(new PIXI.Graphics());
    this.bg.eventMode = "none";
    this.bg.tint = 0x000000;
    this.bg.alpha = 0.4;
    this.icon = this.addChild(new PIXI.Sprite());
    this.icon.tint = tint;
    this.icon.eventMode = "none";
    this.border = this.addChild(new PIXI.Graphics());
    this.border.eventMode = "none";
    this.border.tint = borderColor;
    this.tooltip = this.addChild(new PreciseText("", CONFIG.canvasTextStyle));
    this.tooltip.eventMode = "none";
    this.hitArea = new PIXI.Rectangle(0, 0, 0, 0);
    this.interactiveChildren = false;
    this.eventMode = "static";
    this.cursor = "pointer";
  }

  /* -------------------------------------------- */

  /** @override */
  static RENDER_FLAG_PRIORITY = "INTERFACE";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {}
  };

  /* -------------------------------------------- */

  /**
   * The (URL of the) icon texture used by this control icon.
   * @type {PIXI.Texture|string}
   */
  get texture() {
    return this.#texture;
  }

  set texture(value) {
    if ( value === this.#texture ) return;
    this.#texture = value;
    this.#loadedTexture = typeof value === "string" ? getTexture(value) : value;
    this.renderFlags.set({redraw: !this.#loadedTexture, refresh: true});
  }

  #texture;

  /** @type {PIXI.Texture|null} */
  #loadedTexture;

  /* -------------------------------------------- */

  /**
   * The size of the control icon.
   * @type {number}
   */
  get size() {
    return this.#size;
  }

  set size(value) {
    if ( value === this.#size ) return;
    this.#size = value;
    this.refresh();
  }

  #size;

  /* -------------------------------------------- */

  /**
   * The elevation of the control icon, which is displayed in its tooltip text.
   * @type {number}
   */
  get elevation() {
    return this.#elevation;
  }

  set elevation(value) {
    if ( (typeof value !== "number") || !Number.isFinite(value) ) {
      throw new Error("ControlIcon#elevation must be a finite numeric value.");
    }
    if ( value === this.#elevation ) return;
    this.#elevation = value;
    this.refresh();
  }

  #elevation = 0;

  /* -------------------------------------------- */

  /**
   * The background of this control icon.
   * @type {PIXI.Graphics}
   */
  bg;

  /* -------------------------------------------- */

  /**
   * The border of this control icon.
   * @type {PIXI.Graphics}
   */
  border;

  /* -------------------------------------------- */

  /**
   * The icon of this control icon.
   * @type {PIXI.Sprite}
   */
  icon;

  /* -------------------------------------------- */

  /**
   * The tooltip of this control icon.
   * @type {PreciseText}
   */
  tooltip;

  /* -------------------------------------------- */

  /**
   * An internal reference to a Promise in-progress to draw this control icon.
   * @type {Promise<this>}
   */
  #drawing = Promise.resolve(this);

  /* -------------------------------------------- */

  /**
   * Has this control icons been drawn and is there no drawing in progress?
   * @type {boolean}
   */
  #drawn = false;

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @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 this control icon are drawn
    if ( !this.#drawn ) return;

    if ( flags.refresh ) this._refresh();
  }

  /* -------------------------------------------- */

  /**
   * Draw the visualization of this control icon.
   * @returns {Promise<this>}
   */
  async draw() {
    return this.#drawing = this.#drawing.finally(async () => {
      this.#drawn = false;
      this._clear();
      await this._draw();
      this.refresh();
      this.#drawn = true;
    });
  }

  /* -------------------------------------------- */

  /**
   * Draw this control icon.
   * @returns {Promise<void>}
   * @protected
   */
  async _draw() {
    this.#loadedTexture = typeof this.#texture === "string" ? await loadTexture(this.#texture) : this.#texture;
  }

  /* -------------------------------------------- */

  /**
   * Clear this control icon.
   * @protected
   */
  _clear() {
    this.bg.clear();
    this.icon.texture = PIXI.Texture.EMPTY;
    this.icon.width = this.icon.height = 0;
    this.border.clear();
    this.tooltip.text = "";
    this.hitArea.width = this.hitArea.height = 0;
    MouseInteractionManager.emulateMoveEvent();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the visualization of this control icon.
   * @protected
   */
  _refresh() {
    const s = canvas.dimensions.uiScale;
    const thickness = 2 * s;
    const radius = 5 * s;
    const rect = new PIXI.RoundedRectangle(0, 0, this.size, this.size, radius);

    // Pivot
    this.pivot.set(this.size / 2, this.size / 2);

    // Background
    this.bg.clear();
    this.bg.beginFill(0xFFFFFF);
    this.bg.drawShape(rect);
    this.bg.endFill();

    // Icon
    this.icon.texture = this.#loadedTexture;
    this.icon.width = this.icon.height = this.size;

    // Border
    this.border.clear();
    this.border.lineStyle({width: thickness, color: 0x000000, alignment: 0.75});
    this.border.drawShape(rect);
    this.border.lineStyle({width: thickness / 2, color: 0xFFFFFF, alignment: 1});
    this.border.drawShape(rect);

    // Tooltip
    const elevation = this.elevation.toNearest(0.01);
    this.tooltip.text = `${elevation.signedString()} ${canvas.grid.units}`.trim();
    this.tooltip.visible = elevation !== 0;
    this.tooltip.position.set(this.size / 2, -12 * s);
    this.tooltip.anchor.set(0.5, 1);
    this.tooltip.scale.set(s, s);

    // Hit area
    this.hitArea.width = this.hitArea.height = this.size;
    MouseInteractionManager.emulateMoveEvent();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the visualization of this control icon.
   * @overload
   */
  /**
   * @overload
   * @param {object} options
   * @deprecated since v14
   * @ignore
   */
  refresh(options) {
    /** @deprecated since v14*/
    if ( options ) {
      foundry.utils.logCompatibilityWarning("ControlIcon#refresh(options) has been deprecated. "
        + "Set ControlIcon#visible, ControlIcon#icon#tint, and ControlIcon#border#tint instead.", {since: 14, until: 16, once: true});
      const {visible, iconColor, borderColor, borderVisible} = options;
      if ( iconColor !== undefined ) this.icon.tint = iconColor ?? 0xFFFFFF;
      if ( borderColor !== undefined ) this.border.tint = borderColor;
      if ( borderVisible !== undefined ) this.border.visible = borderVisible;
      if ( visible !== undefined && (this.visible !== visible) ) {
        this.visible = visible;
        MouseInteractionManager.emulateMoveEvent();
      }
      return this;
    }
    this.renderFlags.set({refresh: true});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  destroy(options) {
    this.renderFlags.clear();
    MouseInteractionManager.emulateMoveEvent();
    super.destroy(options);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  get rect() {
    foundry.utils.logCompatibilityWarning("ControlIcon#rect has been deprecated in favor of ControlIcon#size.",
      {since: 14, until: 16, once: true});
    const s = canvas.dimensions.uiScale;
    return [-2 * s, -2 * s, this.size + (4 * s), this.size + (4 * s)];
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  get tintColor() {
    foundry.utils.logCompatibilityWarning("ControlIcon#tintColor has been deprecated in favor of ControlIcon#icon.tint.",
      {since: 14, until: 16, once: true});
    return this.icon.tint;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  get borderColor() {
    foundry.utils.logCompatibilityWarning("ControlIcon#borderColor has been deprecated in favor of ControlIcon#border.tint.",
      {since: 14, until: 16, once: true});
    return this.border.tint;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  get iconSrc() {
    foundry.utils.logCompatibilityWarning("ControlIcon#iconSrc has been deprecated in favor of ControlIcon#texture.",
      {since: 14, until: 16, once: true});
    return this.texture;
  }

  set iconSrc(value) {
    foundry.utils.logCompatibilityWarning("ControlIcon#iconSrc has been deprecated in favor of ControlIcon#texture.",
      {since: 14, until: 16, once: true});
    this.texture = value;
  }
}

/**
 * @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);
    canvas.perception.update({refreshVision: true});
  }

  /* -------------------------------------------- */

  /**
   * 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 canvas.visibility.testVisibility(points, {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});
  }
}

/**
 * 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 _createVertexShader() {
    return `
    precision ${PIXI.Program.defaultVertexPrecision} 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 _createFragmentShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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 get batchVertexShader() {
    return `#version 300 es
    precision ${PIXI.Program.defaultVertexPrecision} 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 get batchFragmentShader() {
    return `#version 300 es
    precision ${PIXI.Program.defaultFragmentPrecision} 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;
    }
    `;
  }

  /** @override */
  static get defaultUniforms() {
    return {
      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]);

  /**
   * Last renderer pass in which this mesh was visited (entered culling/render).
   * @type {number}
   */
  #seenFrameId = -1;

  /**
   * Last renderer pass in which this mesh actually drew its own content.
   * @type {number}
   * @protected
   */
  #renderedFrameId = -1;

  /**
   * 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();

  /* ---------------------------------------- */

  /**
   * True if this mesh drew its own content during the current renderer pass.
   * @type {boolean}
   */
  get renderedThisPass() {
    return this.#renderedFrameId === game.canvas._frameId;
  }

  /**
   * True if this mesh was visited by the renderer during the current pass.
   * @type {boolean}
   */
  get visitedThisPass() {
    return this.#seenFrameId === game.canvas._frameId;
  }

  /**
   * True if the mesh was culld (visited but not drawn) during the current pass.
   * @type {boolean}
   */
  get culledThisPass() {
    return this.visitedThisPass && !this.renderedThisPass;
  }

  /* ---------------------------------------- */

  /**
   * 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 */
  _renderWithCulling(renderer) {
    this.#seenFrameId = game.canvas._frameId;
    this.#renderedFrameId = -1;
    return super._renderWithCulling(renderer);
  }

  /* ---------------------------------------- */

  /** @override */
  _render(renderer) {
    const pluginName = this.pluginName;
    if ( pluginName ) this.#renderBatched(renderer, pluginName);
    else this.#renderDirect(renderer, this._shader);
    this.#renderedFrameId = game.canvas._frameId;
  }

  /* ---------------------------------------- */

  /**
   * 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 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;
}

/**
 * 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) {
  constructor(...args) {
    super(...args);
    this.sortableChildren = true;
    this.on("added", this._onAdded);
    this.on("removed", this._onRemoved);
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * 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;
    if ( this.parent instanceof PrimaryCanvasContainer ) {
      if ( value !== this.parent.elevation ) return;
    }
    else if ( this.parent ) this.parent.sortDirty = true;
    this.#elevation = value;
    this._onElevationChange();
  }

  #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("PrimaryCanvasContainer#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("PrimaryCanvasContainer#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("PrimaryCanvasContainer#zIndex must be a numeric value.");
    }
    if ( value === this._zIndex ) return;
    this._zIndex = value;
    if ( this.parent ) this.parent.sortDirty = true;
  }

  /* -------------------------------------------- */

  /**
   * Is this container in the primary group?
   * @type {boolean}
   */
  get inPrimary() {
    return this._inPrimary;
  }

  /**
   * @type {boolean}
   * @protected
   */
  _inPrimary = false;

  /* -------------------------------------------- */

  /**
   * To know if this container has at least one children that should render its depth.
   * @returns {boolean}
   */
  get shouldRenderDepth() {
    return this.#shouldRenderDepth;
  }

  #shouldRenderDepth = false;

  /* -------------------------------------------- */
  /*  Events                                      */
  /* -------------------------------------------- */

  /**
   * Event fired when this container is added to a parent.
   * @param {PIXI.Container} parent   The new parent container.
   * @protected
   */
  _onAdded(parent) {
    if ( parent === canvas.primary ) {
      this._onAddedPrimary();
      return;
    }
    if ( parent instanceof PrimaryCanvasContainer ) {
      if ( parent.inPrimary ) this._onAddedPrimary();
      this.elevation = parent.elevation;
      return;
    }
    throw new Error("PrimaryCanvasContainer instances may only be direct children of the PrimaryCanvasGroup or a PrimaryCanvasContainer");
  }

  /* -------------------------------------------- */

  /**
   * Called when the container is now in the primary group.
   * @protected
   */
  _onAddedPrimary() {
    this._inPrimary = true;
    for ( const child of this.children ) child._onAddedPrimary?.();
  }

  /* -------------------------------------------- */

  /**
   * Event fired when this container is removed from its parent.
   * @param {PIXI.Container} parent   Parent from which the container is removed.
   * @protected
   */
  _onRemoved(parent) {
    if ( this._inPrimary ) this._onRemovedPrimary();
  }

  /* -------------------------------------------- */

  /**
   * Called when the container is no longer in the primary group.
   * @protected
   */
  _onRemovedPrimary() {
    this._inPrimary = false;
    for ( const child of this.children ) child._onRemovedPrimary?.();
  }

  /* -------------------------------------------- */

  /**
   * Called when the elevation was changed.
   * @protected
   */
  _onElevationChange() {
    for ( const child of this.children ) {
      if ( "elevation" in child ) child.elevation = this.#elevation;
    }
  }

  /* -------------------------------------------- */
  /*  Depth Rendering                             */
  /* -------------------------------------------- */

  /**
   * Does this object render to the depth buffer?
   * @returns {boolean}
   * @protected
   */
  _shouldRenderDepth() {
    return this.children.some(c => c.shouldRenderDepth);
  }

  /* -------------------------------------------- */

  /**
   * Render the depth of this object.
   * @param {PIXI.Renderer} renderer
   */
  renderDepthData(renderer) {
    if ( !this.#shouldRenderDepth || !this.visible || !this.renderable ) return;
    for ( const c of this.children ) c.renderDepthData?.(renderer);
  }

  /* -------------------------------------------- */
  /*  Canvas Transform & Sorting                  */
  /* -------------------------------------------- */

  /** @override */
  updateCanvasTransform() {
    if ( this.sortableChildren && this.sortDirty ) this.sortChildren();
    super.updateCanvasTransform();
    this.#shouldRenderDepth = this._shouldRenderDepth();
  }

  /* -------------------------------------------- */

  /** @override */
  sortChildren() {
    const children = this.children;
    for ( let i = 0, n = children.length; i < n; i++ ) children[i]._lastSortedIndex = i;
    children.sort(PrimaryCanvasContainer.#compareObjects);
    this.sortDirty = false;
  }

  /* -------------------------------------------- */

  /**
   * The sorting function used to order objects inside the Primary Canvas 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.sortLayer || 0) - (b.sortLayer || 0))
      || ((a.sort || 0) - (b.sort || 0))
      || (a.zIndex - b.zIndex)
      || (a._lastSortedIndex - b._lastSortedIndex);
  }
}

/**
 * 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;
      if ( this.parent instanceof PrimaryCanvasContainer ) {
        if ( value !== this.parent.elevation ) return;
      } else if ( this.parent ) {
        this.parent.sortDirty = true;
      }
      this.#elevation = value;
      this._onElevationChange();
    }

    #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;
    }

    /* -------------------------------------------- */

    /**
     * Is this object in the primary group?
     * @type {boolean}
     */
    get inPrimary() {
      return this._primaryIndex >= 0;
    }

    /**
     * The index of this object in {@link canvas.primary.objects}.
     * @type {number}
     * @private
     */
    _primaryIndex = -1;

    /* -------------------------------------------- */
    /*  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 ) {
        this._onAddedPrimary();
        return;
      }
      if ( parent instanceof PrimaryCanvasContainer ) {
        if ( parent.inPrimary ) this._onAddedPrimary();
        this.elevation = parent.elevation;
        return;
      }
      throw new Error("PrimaryCanvasObject instances may only be direct children of the PrimaryCanvasGroup or a PrimaryCanvasContainer");
    }

    /* -------------------------------------------- */

    /**
     * Called when the PCO is now in the primary group.
     * @protected
     */
    _onAddedPrimary() {
      this._primaryIndex = canvas.primary.objects.length;
      canvas.primary.objects.push(this);
      if ( this.#shouldRenderDepth ) canvas.masks.depth._elevationDirty = true;
    }

    /* -------------------------------------------- */

    /**
     * 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) {
      if ( this.inPrimary ) this._onRemovedPrimary();
    }

    /* -------------------------------------------- */

    /**
     * Called when the PCO is no longer in the primary group.
     * @protected
     */
    _onRemovedPrimary() {
      const lastObject = canvas.primary.objects.at(-1);
      lastObject._primaryIndex = this._primaryIndex;
      canvas.primary.objects[this._primaryIndex] = lastObject;
      canvas.primary.objects.length--;
      this._primaryIndex = -1;
      if ( this.#shouldRenderDepth ) canvas.masks.depth._elevationDirty = true;
      this.#updateQuadtree(true);
    }

    /* -------------------------------------------- */

    /**
     * Called when the elevation was changed.
     * @protected
     */
    _onElevationChange() {
      if ( this.#shouldRenderDepth && this.inPrimary ) canvas.masks.depth._elevationDirty = 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) {}
  };
}

/**
 * @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
   * const attributes = [
   *   {
   *     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 = delta => {
      CanvasAnimation.#animateFrame(delta / PIXI.Ticker.targetFPMS, 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 (a union of {@link CONST.OCCLUSION_MODES})
     * @type {number}
     */
    occlusionMode = CONST.OCCLUSION_MODES.NONE;

    /**
     * Do surfaces at the same elevation as this object apply occlusion to this object?
     * This property only applies to SURFACE occlusion.
     * @type {boolean}
     * @internal
     */
    _occludedBySameElevationSurfaces = true;

    /**
     * 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
     * @property {number} surface         The amount of SURFACE occlusion
     */

    /**
     * The amount of rendered FADE, RADIAL, VISION, and SURFACE occlusion.
     * @type {OcclusionState}
     * @internal
     */
    _occlusionState = {
      fade: 0.0,
      radial: 0.0,
      vision: 0.0,
      surface: 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() {
      const M = CONST.OCCLUSION_MODES;
      return !!(this.occlusionMode & M.FADE) || (!!(this.occlusionMode & M.VISION) && !canvas.masks.occlusion.vision);
    }

    /* -------------------------------------------- */

    /** @override */
    get elevation() {
      return super.elevation;
    }

    /** @override */
    set elevation(value) {
      super.elevation = value;
      this.#elevationNextDown = undefined;
    }

    /**
     * The elevation minus eplison used for {@link _occludedBySameElevationSurfaces}.
     * @type {number|undefined}
     */
    #elevationNextDown;

    /* -------------------------------------------- */

    /**
     * The occlusion elevation of this object.
     * @type {number}
     * @internal
     */
    get _occlusionElevation() {
      let elevation;
      if ( this._occludedBySameElevationSurfaces ) elevation = this.elevation;
      else elevation = this.#elevationNextDown ??= Math.nextDown(this.elevation);
      return canvas.masks.occlusion.mapElevation(elevation);
    }

    /* -------------------------------------------- */

    /**
     * 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 */
    updateTransform() {
      super.updateTransform();
      this.#updateHoverFadeState();
      this.#updateOcclusionState();
    }

    /* -------------------------------------------- */
    /*  Methods                                     */
    /* -------------------------------------------- */

    /**
     * Update the occlusion state.
     */
    #updateOcclusionState() {
      const state = this._occlusionState;
      state.fade = 0;
      state.radial = 0;
      state.vision = 0;
      state.surface = 0;
      const M = CONST.OCCLUSION_MODES;
      if ( (this.occlusionMode & M.FADE) && this.occluded ) state.fade = 1;
      if ( this.occlusionMode & M.SURFACE ) state.surface = 1;
      if ( this.occlusionMode & M.RADIAL ) state.radial = 1;
      if ( this.occlusionMode & M.VISION ) {
        if ( canvas.masks.occlusion.vision ) state.vision = 1;
        else if ( this.occluded ) state.fade = 1;
      }
      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.
     * @param {Token} token       The Token to test
     * @returns {boolean}         Is the Token occluded by the PCO?
     */
    testOcclusion(token) {
      if ( token.document.elevation >= this.elevation ) return false;
      if ( !token.bounds.intersects(this.canvasBounds) ) return false;
      for ( const point of token.document.getOcclusionTestPoints() ) {
        if ( this.containsCanvasPoint(point) ) return true;
      }
      return false;
    }
  }
  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 = `
    vec4 occluded = step(texture(occlusionTexture, vScreenCoord), vec4(occlusionElevation - (0.5 / 255.0)));
    float occlusion = max(occluded.r * fadeOcclusion, max(occluded.g * radialOcclusion, max(occluded.b * visionOcclusion, occluded.a * surfaceOcclusion)));
    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_BYTE},
    {id: "aOcclusionElevation", size: 1, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE},
    {id: "aOcclusionAlphas", size: 2, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE},
    {id: "aOcclusionStates", 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 get defaultUniforms() {
    return {
      screenDimensions: [1, 1],
      sampler: null,
      tintAlpha: [1, 1, 1, 1],
      occlusionTexture: null,
      unoccludedAlpha: 1,
      occludedAlpha: 0,
      occlusionElevation: 0,
      fadeOcclusion: 0,
      radialOcclusion: 0,
      visionOcclusion: 0,
      surfaceOcclusion: 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, 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 = (element.occlusionElevation * 255) | 0;
    const fadeOcclusion = (element.fadeOcclusion * 255) | 0;
    const radialOcclusion = (element.radialOcclusion * 255) | 0;
    const visionOcclusion = (element.visionOcclusion * 255) | 0;
    const surfaceOcclusion = (element.surfaceOcclusion * 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 <<= 2;
      uint8View[k++] = textureId;
      uint8View[k++] = occlusionElevation;
      uint8View[k++] = unoccludedAlpha;
      uint8View[k++] = occludedAlpha;
      uint8View[k++] = fadeOcclusion;
      uint8View[k++] = radialOcclusion;
      uint8View[k++] = visionOcclusion;
      uint8View[k++] = surfaceOcclusion;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  static get batchVertexShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_VERTEX}

      precision ${PIXI.Program.defaultVertexPrecision} 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 float aOcclusionElevation;
      in vec2 aOcclusionAlphas;
      in vec4 aOcclusionStates;

      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;
      flat out float vSurfaceOcclusion;

      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 = aOcclusionElevation;
        vFadeOcclusion = aOcclusionStates.x;
        vRadialOcclusion = aOcclusionStates.y;
        vVisionOcclusion = aOcclusionStates.z;
        vSurfaceOcclusion = aOcclusionStates.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.Program.defaultFragmentPrecision} 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;
      flat in float vSurfaceOcclusion;

      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;
        float surfaceOcclusion = vSurfaceOcclusion;
        ${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 _createVertexShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_VERTEX}

      precision ${PIXI.Program.defaultVertexPrecision} 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 _createFragmentShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_FRAGMENT}

      precision ${PIXI.Program.defaultFragmentPrecision} 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;
      uniform float surfaceOcclusion;

      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 = mesh._occlusionElevation;
    uniforms.unoccludedAlpha = mesh.unoccludedAlpha;
    uniforms.occludedAlpha = mesh.occludedAlpha;
    const occlusionState = mesh._occlusionState;
    uniforms.fadeOcclusion = occlusionState.fade;
    uniforms.radialOcclusion = occlusionState.radial;
    uniforms.visionOcclusion = occlusionState.vision;
    uniforms.surfaceOcclusion = occlusionState.surface;
  }
}

/**
 * @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: "aOcclusionElevation", size: 1, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE},
    {id: "aOcclusionStates", size: 4, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE},
    {id: "aRestrictionState", size: 1, normalized: false, type: PIXI.TYPES.FLOAT}
  ];

  /* ---------------------------------------- */

  /** @override */
  static batchVertexSize = 7;

  /* -------------------------------------------- */

  /** @override */
  static reservedTextureUnits = 1; // We need a texture unit for the occlusion texture

  /* -------------------------------------------- */

  /** @override */
  static get defaultUniforms() {
    return {
      screenDimensions: [1, 1],
      sampler: null,
      occlusionTexture: null,
      textureAlphaThreshold: 0,
      depthElevation: 0,
      occlusionElevation: 0,
      fadeOcclusion: 0,
      radialOcclusion: 0,
      visionOcclusion: 0,
      surfaceOcclusion: 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 = (element.depthElevation * 255) | 0;
    const occlusionElevation = (element.occlusionElevation * 255) | 0;
    const fadeOcclusion = (element.fadeOcclusion * 255) | 0;
    const radialOcclusion = (element.radialOcclusion * 255) | 0;
    const visionOcclusion = (element.visionOcclusion * 255) | 0;
    const surfaceOcclusion = (element.surfaceOcclusion * 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++] = occlusionElevation;
      uint8View[k++] = fadeOcclusion;
      uint8View[k++] = radialOcclusion;
      uint8View[k++] = visionOcclusion;
      uint8View[k++] = surfaceOcclusion;
      k >>= 2;
      float32View[k++] = restrictionState;
    }
  }

  /* ---------------------------------------- */

  /** @override */
  static get batchVertexShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_VERTEX}

      precision ${PIXI.Program.defaultVertexPrecision} float;

      in vec2 aVertexPosition;
      in vec2 aTextureCoord;

      uniform vec2 screenDimensions;

      ${this._batchVertexShader}

      in float aTextureId;
      in float aTextureAlphaThreshold;
      in float aDepthElevation;
      in float aOcclusionElevation;
      in vec4 aOcclusionStates;
      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 float vSurfaceOcclusion;
      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 = aOcclusionElevation;
        vFadeOcclusion = aOcclusionStates.x;
        vRadialOcclusion = aOcclusionStates.y;
        vVisionOcclusion = aOcclusionStates.z;
        vSurfaceOcclusion = aOcclusionStates.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.Program.defaultFragmentPrecision} 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 float vSurfaceOcclusion;
      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;
        float surfaceOcclusion = vSurfaceOcclusion;
        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 _createVertexShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_VERTEX}

      precision ${PIXI.Program.defaultVertexPrecision} 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 _createFragmentShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_FRAGMENT}

      precision ${PIXI.Program.defaultFragmentPrecision} 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 float surfaceOcclusion;
      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 = mesh._occlusionElevation;
    uniforms.depthElevation = mesh._depthElevation;
    const occlusionState = mesh._occlusionState;
    uniforms.fadeOcclusion = occlusionState.fade;
    uniforms.radialOcclusion = occlusionState.radial;
    uniforms.visionOcclusion = occlusionState.vision;
    uniforms.surfaceOcclusion = occlusionState.surface;
    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);
    vec4 weight = step(texture(occlusionTexture, vOcclusionCoord), vec4(occlusionElevation - (0.5 / 255.0)));
    float occlusion = step(0.5, max(max(max(weight.r * fadeOcclusion, weight.g * radialOcclusion), weight.b * visionOcclusion), weight.a * surfaceOcclusion));
    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;

  /* -------------------------------------------- */

  /**
   * The depth elevation of this sprite.
   * @type {number}
   * @internal
   */
  get _depthElevation() {
    return canvas.masks.depth.mapElevation(this.elevation);
  }

  /* -------------------------------------------- */
  /*  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.depthElevation = this._depthElevation;
    batchData.occlusionElevation = this._occlusionElevation;
    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.surfaceOcclusion = occlusionState.surface;
    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?.valid ) 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;
  }
}

/**
 * @import Level from "@client/documents/level.mjs";
 * @import CanvasLayer from "../layers/base/canvas-layer.mjs";
 * @import {CanvasTearDownOptions} from "@client/_types.mjs";
 */

/**
 * 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]    Options which configure how the group is drawn.
     *                              Forwarded to {@link foundry.canvas.layers.CanvasLayer#draw}.
     * @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, options);
        this.#drawn = true;
        MouseInteractionManager.emulateMoveEvent();
      });
    }

    /**
     * Draw the canvas group and all its component layers.
     * @param {object} options      Options which configure how the group is drawn.
     *                              Forwarded to {@link foundry.canvas.layers.CanvasLayer#draw}.
     * @returns {Promise<this>}     A Promise which resolves once the group is fully drawn.
     * @protected
     */
    async _draw(options) {
      // Draw CanvasLayer instances
      for ( const layer of Object.values(this.layers) ) {
        this.addChild(layer);
        await layer.draw(options);
      }
    }

    /* -------------------------------------------- */
    /*  Tear-Down                                   */
    /* -------------------------------------------- */

    /**
     * Remove and destroy all layers from the base canvas.
     * @param {CanvasTearDownOptions} [options]  Options which configure how the group is deconstructed.
     *                                           Forwarded to {@link foundry.canvas.layers.CanvasLayer#tearDown}
     * @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, options);
      MouseInteractionManager.emulateMoveEvent();
      return this;
    }

    /**
     * Remove and destroy all layers from the base canvas.
     * @param {CanvasTearDownOptions} options  Options which configure how the group is deconstructed.
     *                                         Forwarded to {@link foundry.canvas.layers.CanvasLayer#tearDown}
     * @protected
     */
    async _tearDown(options) {
      // Remove layers
      for ( const layer of Object.values(this.layers).reverse() ) {
        await layer.tearDown(options);
        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});
      }
    }
  };
}

/**
 * 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) {

  /** @override */
  static create(uniforms, options) {
    const vert = this._vertexShaderCompatibility(options) ?? this._createVertexShader(options);
    const frag = this._fragmentShaderCompatibility(options) ?? this._createFragmentShader(options);
    const u = foundry.utils.mergeObject(this.defaultUniforms, uniforms, {inplace: false});
    const filter = new this(vert, frag, u);
    filter._configure(options);
    return filter;
  }
}

/**
 * This class defines an interface for masked custom filters
 */
class AbstractBaseMaskFilter extends AbstractBaseFilter {

  /** @override */
  static _createVertexShader() {
    return `
    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 _createFragmentShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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 get defaultUniforms() {
    return {
      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 = {

    // Light and Darkness Sources
    initializeLighting: {propagate: ["initializeLightSources"]},
    initializeLightSources: {propagate: ["refreshLighting", "refreshVision"]},
    refreshLighting: {propagate: ["refreshLightSources"]},
    refreshLightSources: {},

    // Vision
    initializeVisionModes: {propagate: ["refreshVisionSources", "refreshLighting", "refreshPrimary"]},
    initializeVision: {propagate: ["initializeVisionModes", "refreshVision"]},
    refreshVision: {propagate: ["refreshVisionSources", "refreshOcclusionMask"]},
    refreshVisionSources: {},

    // Primary Canvas Group
    refreshPrimary: {},
    refreshOcclusion: {propagate: ["refreshOcclusionStates", "refreshOcclusionMask"]},
    refreshOcclusionStates: {propagate: ["refreshOccludedSurfaces"]},
    refreshOcclusionMask: {},
    refreshOccludedSurfaces: {propagate: ["refreshOcclusionMask"]},

    // Sound
    initializeSounds: {propagate: ["refreshSounds"]},
    refreshSounds: {},
    soundFadeDuration: {},

    /** @deprecated since v13 */
    initializeDarknessSources: {
      propagate: ["initializeLightSources"],
      deprecated: {
        message: "The initializeDarknessSources flag is now obsolete. initializeLightSources flag must be used instead.",
        since: 13, until: 15
      }
    },

    /** @deprecated since v14 */
    refreshEdges: {
      deprecated: {
        message: "The refreshEdges flag is now obsolete and no longer requires to be set.",
        since: 14, until: 16
      }
    }
  };

  static #deprecatedFlags = ["refreshTiles", "identifyInteriorWalls"];

  /** @override */
  static RENDER_FLAG_PRIORITY = "PERCEPTION";

  /* -------------------------------------------- */

  /** @override */
  applyRenderFlags() {
    if ( !this.renderFlags.size ) return;
    const flags = this.renderFlags.clear();

    // Update the set of occludable tokens
    if ( flags.refreshOcclusion ) canvas.masks.occlusion._updateOccludableTokens();

    // Update the set of occluded surfaces
    if ( flags.refreshOccludedSurfaces ) canvas.masks.occlusion._updateOccludedSurfaces(flags);

    if ( flags.initializeLightSources ) {
      // Initialize sources with edges
      canvas.effects.initializePriorityLightSources();

      // Initialize positive light sources
      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 occlusion mask and states based on token positions and vision
    if ( flags.refreshOcclusionMask ) canvas.masks.occlusion._updateOcclusionMask();
    if ( flags.refreshOcclusionStates ) canvas.masks.occlusion._updateOccludedObjects();

    // 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({
      initializeLighting: true,
      initializeVision: true,
      initializeSounds: true,
      refreshOcclusion: true
    });
  }
}

/**
 * @import {CanvasTearDownOptions} from "@client/_types.mjs";
 */

/**
 * 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; zIndex: number}}
   */
  static get layerOptions() {
    return {
      name: "",
      baseClass: CanvasLayer,
      zIndex: 0
    };
  }

  /* -------------------------------------------- */

  /**
   * 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, options);
      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
   * @protected
   */
  async _draw(options) {
    this.zIndex = this.getZIndex();
  }

  /* -------------------------------------------- */

  /**
   * Deconstruct data used in the current layer in preparation to re-draw the canvas
   * @param {CanvasTearDownOptions} [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, options);
    this.renderable = true;
    MouseInteractionManager.emulateMoveEvent();
    return this;
  }

  /**
   * The inner _tearDown method which may be customized by each CanvasLayer subclass.
   * @param {CanvasTearDownOptions} options  Options which configure how the layer is deconstructed
   * @protected
   */
  async _tearDown(options) {
    this.removeChildren().forEach(c => c.destroy({children: true}));
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }
}

/**
 * @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);
  }
}

/**
 * @import {SceneControl, SceneControlTool} from "@client/applications/ui/scene-controls.mjs";
 */

/**
 * A subclass of CanvasLayer which provides support for user interaction with its contained objects.
 * @category Canvas
 */
class InteractionLayer extends CanvasLayer {

  /**
   * The shared palette tool.
   * @type {Partial<SceneControlTool>}
   */
  static get TOGGLE_PALETTE() {
    return {
      name: "togglePalette",
      title: "CONTROLS.Palette",
      icon: "fa-solid fa-palette",
      active: !!ui.controls?.paletteOpen,
      toggle: true,
      onChange: (event, toggled) => ui.controls.togglePlaceablePalette(toggled)
    };
  }

  /**
   * Is this layer currently active
   * @type {boolean}
   */
  get active() {
    return this.#active;
  }

  /** @ignore */
  #active = false;

  /** @override */
  eventMode = "passive";

  /** @inheritDoc */
  static get layerOptions() {
    return Object.assign(super.layerOptions, {
      baseClass: InteractionLayer
    });
  }

  /* -------------------------------------------- */
  /*  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;
    const controlOptions = { toggles: { togglePalette: ui.controls.paletteOpen } };
    if ( (control !== ui.controls.control?.name) || (tool && (tool !== ui.controls.tool?.name)) ) {
      Object.assign(controlOptions, { control, tool });
      if ( wasActive ) ui.controls.activate(controlOptions);
    }

    if ( wasActive ) return this;
    ui.controls.activate(controlOptions);

    // 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() {}

  /* -------------------------------------------- */

  /**
   * 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 Quadtree from "../../geometry/quadtree.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 {

  /**
   * 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,
      keyboardMovableObjects: false,
      confirmDeleteKey: false,
      confirmBeforeCreation: false,
      controlObjectAfterCreation: true,
      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;

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * The default creation data sourced from this layer's palette, if it has one.
   * @type {object}
   */
  get paletteCreateData() {
    return this.constructor.paletteClass?.createData ?? {};
  }

  /* -------------------------------------------- */

  /**
   * 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 && placeable.interactive ) 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;

  /* -------------------------------------------- */

  /**
   * A throttled function that rotates many placeables.
   * @type {(options: object) => Promise<PlaceableObject[]>}
   * @internal
   */
  _throttleRotateMany = foundry.utils.throttle(options => this.rotateMany(options), 100);

  /* -------------------------------------------- */

  /**
   * 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|void}        Returns undefined if the layer does not have sortable object.
   *                                Returns false if the controlled objects are already at the front/back.
   * @internal
   */
  _sendToBackOrBringToFront(front) {
    const collection = this.documentCollection;
    const documentClass = collection?.documentClass;
    if ( !documentClass?.schema.has("sort") ) return;
    if ( !this.controlled.length ) return false;

    // 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 false;
    if ( toUpdate.every(front ? d => d.sort > target : d => d.sort < target) ) return false;
    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});
  }

  /* -------------------------------------------- */

  /**
   * Iterate over all documents which are viewed in the current Level.
   * @yields {CanvasDocument}
   */
  * viewedDocuments() {
    for ( const doc of this.documentCollection ) {
      if ( doc.viewed ) yield doc;
    }
  }

  /* -------------------------------------------- */

  /** @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 promises = [];
    for ( const doc of this.viewedDocuments() ) {
      const obj = doc._object = this.createObject(doc);
      this.objects.addChild(obj);
      promises.push(obj.draw());
    }

    // Wait for all objects to draw
    await Promise.allSettled(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});
    ui.placeables.render({ force: 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 [];
    if ( !this.active ) return [];
    options = {...options, releaseOthers: false, renderSidebar: false};
    let controlledChanged = false;
    for ( const placeable of this.controllableObjects() ) {
      if ( placeable.controlled ) continue;
      const controlled = placeable.control(options);
      controlledChanged ||= controlled;
    }
    if ( controlledChanged ) {
      ui.placeables.render();
      if ( game.activeTool === "select" ) ui.placeablesPalette?.render();
    }
    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={}) {
    if ( !this.options.controllableObjects ) return 0;
    options = {...options, releaseOthers: false, renderSidebar: false};
    let released = 0;
    for ( const placeable of this.#controlledObjects.values() ) {
      placeable.release(options);
      released++;
    }
    if ( released ) {
      ui.placeables.render();
      if ( game.activeTool === "select" ) ui.placeablesPalette?.render();
    }
    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 ( Array.isArray(ids) ) 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: _loc(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: _loc(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: _loc(getDocumentClass$1(type).metadata.label)}});
    return created;
  }

  /* -------------------------------------------- */

  /**
   * A helper method to prompt for deletion of all (owned) 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;
    const typeLabel = _loc(getDocumentClass$1(type).metadata.label);
    const collection = canvas.scene.getEmbeddedCollection(type);
    let content = `<p>${_loc(game.user.isGM ? "CONTROLS.ClearAllHint" : "CONTROLS.ClearAllOwnedHint", {type: typeLabel})}</p>`;
    const hasLocked = collection.some(d => d.locked && d.isOwner && d.viewed);
    if ( hasLocked ) {
      content += `
        <div class="form-group slim">
          <label>${_loc("CONTROLS.DeleteLockedObjects")}</label>
          <div class="form-fields">
            <input type="checkbox" name="deleteLocked">
          </div>
        </div>
      `;
    }
    return DialogV2.confirm({
      window: {title: game.user.isGM ? "CONTROLS.ClearAll" : "CONTROLS.ClearAllOwned"},
      content,
      yes: {
        callback: async (_event, button, _dialog) => {
          const toDelete = [];
          const deleteOptions = {};
          const deleteLocked = !hasLocked || button.form.elements.deleteLocked.checked;
          if ( game.user.isGM && deleteLocked && (canvas.scene.levels.size <= 1) ) deleteOptions.deleteAll = true;
          else {
            for ( const document of collection ) {
              if ( document.isOwner && document.viewed && (deleteLocked || !document.locked) ) {
                toDelete.push(document.id);
              }
            }
          }
          const deleted = await canvas.scene.deleteEmbeddedDocuments(type, toDelete, deleteOptions);
          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 = _loc(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 [];
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return [];
    }
    if ( cut && objects.some(o => o.document.locked) ) {
      ui.notifications.warn("CONTROLS.ObjectIsLocked",
        {format: {type: _loc(this.documentCollection.documentClass.metadata.label)}});
      return [];
    }
    const sourceScene = objects[0].document.parent;
    const targetScene = canvas.scene;

    // 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 = [];
    const discardInvalidEmbedded = !cut || (sourceScene !== targetScene);
    for ( const object of objects ) {
      const d = object._pasteObject(offset, {hidden, snap, cut});
      data.push(object.document.clone(d, {parent: targetScene, discardInvalidEmbedded}).toObject());
    }

    // Call hooks
    const allowed = Hooks$1.call(`paste${this.constructor.documentName}`, objects, data, {cut});
    if ( !allowed ) return [];

    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: _loc(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, renderSidebar: false });
    });

    // 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, renderSidebar: false }));

    const controlChanged = (releaseOthers && (toRelease.size > 0)) || (toControl.size > 0);
    if ( controlChanged ) {
      ui.placeables.render();
      if ( game.activeTool === "select" ) ui.placeablesPalette?.render();
    }

    // Return a boolean for whether the control set was changed
    return controlChanged;
  }

  /* -------------------------------------------- */

  /**
   * 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 = typeof transformation === "function";
    if ( !hasTransformer && !foundry.utils.isPlainObject(transformation) ) {
      throw new Error("You must provide a data object or transformation function");
    }
    const hasCondition = typeof condition === "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);
    object._previewType = "creation";
    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 && !event.shiftKey ) {
      this.releaseAll();
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _canDragLeftStart(user, event) {
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return false;
    }

    // Prevent dragging another object if currently being previewed
    if ( this.preview.children.length ) {
      ui.notifications.warn("CONTROLS.ObjectConfigured", {localize: true});
      return false;
    }

    // A drawing tool needs to be active
    if ( !this._isCreationToolActive() ) return false;

    return true;
  }

  /* -------------------------------------------- */

  /**
   * Is a creation tool active?
   * @returns {boolean}
   * @protected
   */
  _isCreationToolActive() {
    return this.active && !!ui.controls.tool?.creation;
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftStart(event) {
    const interaction = event.interactionData;
    interaction.cancelOnPause = true;
    const documentClass = getDocumentClass$1(this.constructor.documentName);
    const previewData = this._createDragPreviewData(event);
    const document = new documentClass(previewData, {parent: canvas.scene});
    const placeableObjectClass = getPlaceableObjectClass(this.constructor.documentName);
    interaction.preview = new placeableObjectClass(document);
    interaction.preview._previewType = "creation";
    document._object = interaction.preview;
    interaction.preview.draw();
    this.preview.addChild(interaction.preview);
  }

  /* -------------------------------------------- */

  /**
   * Create the preview document data from the drag start event.
   * @param {PIXI.FederatedEvent} event    The pointer event
   * @returns {object}                     The initial document data
   * @protected
   */
  _createDragPreviewData(event) {
    const data = foundry.utils.deepClone(this.paletteCreateData);
    const documentClass = getDocumentClass$1(this.constructor.documentName);
    if ( documentClass.schema.has("name") ) data.name ??= documentClass.defaultName({parent: canvas.scene});
    return data;
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftDrop(event) {
    const preview = event.interactionData.preview;
    if ( !preview || preview._destroyed ) return;
    this._commitDragLeftDrop(event);
  }

  /* -------------------------------------------- */

  /**
   * Commit the drag-left drop.
   * @param {PIXI.FederatedEvent} event    The pointer event.
   * @returns {Promise<void>}
   * @protected
   */
  async _commitDragLeftDrop(event) {
    event.interactionData.clearPreviewContainer = false;
    const preview = event.interactionData.preview;
    const evaluateOption = value => {
      if ( value === true ) return true;
      if ( value === false ) return false;
      if ( Array.isArray(value) ) return value.includes(game.activeTool);
      return value();
    };
    const confirm = evaluateOption(this.options.confirmBeforeCreation);
    if ( confirm ) {
      await preview.sheet.render({force: true, preview: true});
      return;
    }
    try {
      const controlObject = this.options.controllableObjects && evaluateOption(this.options.controlObjectAfterCreation);
      const documentClass = getDocumentClass$1(this.constructor.documentName);
      await documentClass.create(preview.document, {parent: canvas.scene, controlObject});
    } finally {
      this.clearPreviewContainer();
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftCancel(event) {
    if ( event.interactionData.clearPreviewContainer !== false ) {
      this.clearPreviewContainer();
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickRight(event) {
    const preview = event.interactionData.preview;
    if ( preview && !preview._destroyed ) return canvas.mouseInteractionManager._dragRight = false;
    if ( !event.target.hasActiveHUD ) this.hud?.close();
  }

  /* -------------------------------------------- */

  /** @override */
  _onMouseWheel(event) {
    if ( !this.options.rotatableObjects ) return;
    const hasTarget = this.options.controllableObjects ? this.controlled.length : !!this.hover;
    if ( !hasTarget ) return;

    // 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._throttleRotateMany({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: _loc(getDocumentClass$1(this.constructor.documentName).metadata.label)}});
    }
    if ( game.activeTool === "select" ) ui.placeablesPalette?.render();
  }

  /* -------------------------------------------- */

  /**
   * 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: {
        icon: "fa-solid fa-trash",
        title: _loc("DOCUMENT.Delete", {
          type: _loc(getDocumentClass$1(this.constructor.documentName).metadata.label)
        })
      },
      content: `<p>${_loc("COMMON.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 v14
   * @ignore
   */
  static get CREATION_STATES() {
    foundry.utils.logCompatibilityWarning("PlaceablesLayer.CREATION_STATES has been deprecated without replacement.",
      {since: 14, until: 16, once: true});
    return {
      NONE: 0,
      POTENTIAL: 1,
      CONFIRMED: 2,
      COMPLETED: 3
    };
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  getDocuments() {
    foundry.utils.logCompatibilityWarning(`${this.name}#getDocuments is deprecated in favor of the
     ${this.name}#viewedDocuments generator which is aware of Scene Levels.`, {since: 14, until: 16});
    return Array.from(this.viewedDocuments());
  }
}

/**
 * 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
];

/**
 * 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;
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultUniforms() {
    return {
      tintAlpha: [1, 1, 1, 1],
      opaque: 0,
      backgroundColor: [0, 0, 0, 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()
    };
  }

  /* -------------------------------------------- */

  /** @override */
  static _createVertexShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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;
    }
    `;
  }

  /* -------------------------------------------- */

  /** @override */
  static _createFragmentShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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;
    uniform float opaque;
    uniform vec4 backgroundColor;

    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();
      }

      result *= tintAlpha;
      if ( opaque > 0.5 ) {
        vec4 bg = backgroundColor;
        result.rgb = result.rgb + (bg.rgb * bg.a) * (1.0 - result.a);
        result.a = 1.0;
      }
      gl_FragColor = result;
    }
    `;
  }

  /* -------------------------------------------- */

  /** @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);
  }
}

/**
 * Persistent overlay dedicated to scene transitions.
 */
class TransitionContainer extends UnboundContainer {
  constructor() {
    super();
    this.visible = false;
    this.zIndex = +Infinity;
    this.eventMode = "none";
  }

  /**
   * CanvasAnimation symbol used for scene transition animations.
   * @type {symbol}
   */
  static #ANIMATION_SYMBOL = Symbol("TRANSITION_ANIMATION");

  /**
   * Desired transition type to use for transitions when no explicit type is provided.
   * @type {string}
   */
  defaultTransitionType = CONFIG.Canvas.sceneTransitions.fade.id;

  /**
   * Default transition duration used by {@link TransitionContainer#_play}
   * when no explicit duration is provided (milliseconds).
   * @type {number}
   */
  defaultDuration = 1000;

  /**
   * Flag indicating whether this container is reserved for an external workflow.
   * When true, core scene transitions should not use this container.
   * @type {boolean}
   */
  isLocked = false;

  /**
   * Render texture containing the source (current) scene frame.
   * Used as the "from" texture in the transition.
   * @type {PIXI.RenderTexture|null}
   */
  #fromRT = null;

  /**
   * Render texture containing the destination (next) scene frame.
   * Used as the "to" texture in the transition.
   * @type {PIXI.RenderTexture|null}
   */
  #toRT = null;

  /**
   * Sprite used to display the captured scene texture full-screen.
   * This sprite is the visual surface for the transition filter.
   * @type {PIXI.Sprite|null}
   */
  #sprite = null;

  /**
   * Active transition filter blending from the source to the destination texture.
   * Created lazily by {@link TransitionContainer#_captureNextScene}.
   * @type {TextureTransitionFilter|null}
   */
  #filter = null;

  /**
   * Handling of the concurrency for transition.
   * @type {Semaphore}
   */
  #queue = new foundry.utils.Semaphore();

  /**
   * Binded run function
   * @type {(function({operation?: Function, nextScene?: globalThis.Scene, activate?: boolean, clearColor?: number[], fromBlack?: boolean, duration?: number, transitionType?: string, easing?: Function}=): Promise<void>)|*}
   */
  #runBound = this.#run.bind(this);

  /* ----------------------------------------- */
  /*  Getters                                  */
  /* ----------------------------------------- */

  /**
   * Flag indicating whether a transition animation is currently running.
   * Prevents overlapping calls to {@link TransitionContainer#_play}.
   * @type {boolean}
   */
  get isRunning() {
    return this.#running;
  }
  #running = false;

  /**
   * Promise that resolves when the current transition finishes or is cancelled.
   * Reused to return the same promise on repeated {@link TransitionContainer#_play} calls.
   * @type {Promise<void>|null}
   */
  get promise() {
    return this.#promise;
  }
  #promise = null;

  /* ----------------------------------------- */
  /*  Capture                                  */
  /* ----------------------------------------- */

  /**
   * Snapshot the current framebuffer into a render texture.
   * The overlay container hides itself during capture so it is not included.
   * @param {number[]} [clearColor=[0,0,0,0]] RGBA clear color in the 0-1 range.
   * @returns {PIXI.RenderTexture} The newly created render texture.
   */
  static #capture(clearColor=[0,0,0,0]) {
    const renderer = canvas.app.renderer;
    const {width, height} = renderer.screen;
    const resolution = renderer.resolution;
    const multisample = renderer.multisample ?? PIXI.MSAA_QUALITY.NONE;

    // Hide the transition overlay so we do not capture a previous sprite.
    const overlay = canvas.transition;
    const wasVisible = overlay.visible;
    if ( wasVisible ) overlay.visible = false;

    const rt = foundry.canvas.Canvas.getRenderTexture({
      clearColor,
      textureConfiguration: {width, height, resolution, multisample}
    });

    const rootVisible = canvas.root.visible;
    canvas.root.visible = true;
    renderer.render(canvas.stage, {renderTexture: rt});
    canvas.root.visible = rootVisible;

    if ( wasVisible ) overlay.visible = true;
    return rt;
  }

  /* ----------------------------------------- */
  /*  Public API                               */
  /* ----------------------------------------- */

  /**
   * Run a full transition around a given canvas operation.
   * Encapsulates captureCurrentScene => operation/scene switch => captureNextScene => play.
   *
   * If both {@link options.operation} and {@link options.nextScene} are provided, the operation runs first,
   * then the Scene is switched.
   *
   * If neither {@link options.operation} nor {@link options.nextScene} are provided, a simple demo transition
   * is performed from a black frame to the currently rendered Scene.
   *
   * @example Transition around a camera pan
   * ```js
   * await canvas.transition.run({
   *   operation: async () => {
   *     await canvas.animatePan({
   *       x: 2000,
   *       y: 1500,
   *       scale: 1.25,
   *       duration: 0
   *     });
   *   },
   *   duration: 800,
   *   transitionType: "dots"
   * });
   * ```
   *
   * @example Switch to another Scene with a transition
   * ```js
   * const scene = game.scenes.get("ABC123");
   * await canvas.transition.run({
   *   nextScene: scene,
   *   activate: true,
   *   duration: 1200,
   *   transitionType: "fade"
   * });
   * ```
   *
   * @example Demo the current Scene from black
   * ```js
   * await canvas.transition.run({
   *   fromBlack: true,
   *   duration: 800,
   *   transitionType: "swirl"
   * });
   * ```
   * @param {Function} [operation]              Async function performing canvas changes.
   * @param {Scene}    [nextScene]              Scene document to view or activate as the "next" scene.
   * @param {boolean}  [activate=false]         When true, call {@link Scene#activate} instead of {@link Scene#view}.
   * @param {number[]} [clearColor=[0,0,0,1]]   RGBA clear color in the 0-1 range.
   * @param {boolean}  [fromBlack=false]        When true, starts from a black frame instead of capturing.
   * @param {number}   [duration]               Duration in milliseconds. Defaults to {@link defaultDuration}.
   * @param {string}   [transitionType]         Transition type id to use for this run.
   * @param {Function} [easing=t=>t]            Easing function mapping [0,1] to [0,1].
   * @returns {Promise<void>} Promise that resolves when the transition completes.
   */
  async run({operation, nextScene, activate=false, clearColor=[0,0,0,1], fromBlack=false, duration, transitionType, easing=t=>t}={}) {
    return this.#queue.add(this.#runBound, {operation, nextScene, activate, clearColor, fromBlack, duration, transitionType, easing});
  }

  /* -------------------------------------------- */

  /**
   * Cancel any currently running transition and await its termination.
   * @returns {Promise<void>}
   */
  async cancel() {
    if ( !this.#running ) return;
    const p = this.#promise;
    this._reset();
    if ( p ) await p.catch(() => {});
  }

  /* -------------------------------------------- */

  /**
   * Internal implementation of {@link TransitionContainer#run}.
   * @param {Function} [operation]              Async function performing canvas changes.
   * @param {Scene}    [nextScene]              Scene document to view or activate as the "next" scene.
   * @param {boolean}  [activate=false]         When true, call {@link Scene#activate} instead of {@link Scene#view}.
   * @param {number[]} [clearColor=[0,0,0,1]]   RGBA clear color in the 0-1 range.
   * @param {boolean}  [fromBlack=false]        When true, starts from a black frame instead of capturing.
   * @param {number}   [duration]               Duration in milliseconds. Defaults to {@link defaultDuration}.
   * @param {string}   [transitionType]         Transition type id to use for this run.
   * @param {Function} [easing=t=>t]            Easing function mapping [0,1] to [0,1].
   * @returns {Promise<void>} Promise that resolves when the transition completes.
   */
  async #run({operation, nextScene, activate=false, clearColor=[0,0,0,1], fromBlack=false, duration, transitionType, easing=t=>t}={}) {
    const hasOperation = typeof operation === "function";
    const hasNextScene = nextScene instanceof Scene;

    // If nothing to do between the two captures, treat this as a "demo" run
    const effectiveDuration = duration ?? this.defaultDuration;
    const effectiveType = transitionType ?? this.defaultTransitionType;

    // Capture current scene (or black) before doing any work
    this._captureCurrentScene({clearColor, black: fromBlack || (!hasOperation && !hasNextScene)});

    // Only lock while we perform operations that may trigger Canvas#draw
    const previousLock = this.isLocked;
    this.isLocked = true;
    try {
      // Custom operation
      if ( hasOperation ) {
        await operation();
      }

      // Next scene switch
      if ( hasNextScene ) {
        if ( activate ) await nextScene.activate();
        else await nextScene.view();
      }
    }
    finally {
      this.isLocked = previousLock;
    }

    // Capture next scene (which is: either modified current, or nextScene, or just the same scene)
    const wasVisible = canvas.root.visible;
    canvas.root.visible = false;
    try {
      await this._captureNextScene({clearColor, transitionType: effectiveType});
      await this._play({duration: effectiveDuration, easing});
    }
    catch(err) {
      // If anything goes wrong, do not block the scene load
      console.error(`Scene transition failed (type="${effectiveType}"). Continuing without transition.`, err);
      this._reset();
    }
    finally {
      canvas.root.visible = wasVisible;
    }
  }

  /* ----------------------------------------- */
  /*  Internals                                */
  /* ----------------------------------------- */

  /**
   * Capture the currently displayed scene into a render texture and show it.
   * If `black` is true, uses a solid black frame instead of capturing the scene.
   * @param {object}   [options]                       Capture options.
   * @param {number[]} [options.clearColor=[0,0,0,1]]  RGBA clear color in the 0-1 range.
   * @param {boolean}  [options.black=false]           When true, uses a black frame instead of capturing.
   * @returns {PIXI.RenderTexture|null} The render texture of the current scene, or null if black.
   * @internal
   */
  _captureCurrentScene({clearColor=[0, 0, 0, 1], black=false}={}) {
    this._reset();

    // Solid black frame instead of capturing.
    if ( black ) {
      const texture = PIXI.Texture.WHITE;
      this.#sprite = this.#createSprite(texture, 1, 0x000000);
      this.addChild(this.#sprite);
      this.visible = true;
      return null;
    }

    this.#fromRT = TransitionContainer.#capture(clearColor);
    const texture = this.#fromRT;
    this.#sprite = this.#createSprite(texture, 1);
    this.addChild(this.#sprite);
    this.visible = true;
    return this.#fromRT;
  }

  /* -------------------------------------------- */

  /**
   * Capture the next rendered frame of the new scene into a render texture.
   * Internally waits for the next `postrender` so all canvas groups and caches are fully updated before capturing.
   * The filter class and filter type are resolved from {@link CONFIG.Canvas.sceneTransitions}.
   *
   * @param {object}   [options]                       Capture options.
   * @param {number[]} [options.clearColor=[0,0,0,1]]  RGBA clear color in the 0-1 range.
   * @param {string}   [options.transitionType]        Transition type id to use for this capture.
   *                                                   Defaults to {@link TransitionContainer#defaultTransitionType}.
   * @returns {Promise<PIXI.RenderTexture>}            Promise resolving to the captured render texture.
   * @internal
   */
  async _captureNextScene({clearColor=[0, 0, 0, 1], transitionType}={}) {
    const {filterClass, filterType} = this.#resolveTransition(transitionType);

    return new Promise(resolve => {
      const renderer = canvas.app.renderer;

      const after = () => {
        // Ignore render operations that are not for the canvas stage
        const last = renderer.lastObjectRendered;
        if ( last !== canvas.stage ) return;

        renderer.off("postrender", after);

        this.#toRT = TransitionContainer.#capture(clearColor);

        // If the sprite was reset in the meantime, we cannot attach a filter.
        if ( !this.#sprite ) {
          resolve(this.#toRT);
          return;
        }

        // Ensure the filter instance matches the configured filter class.
        if ( this.#filter && !(this.#filter instanceof filterClass) ) {
          this.#filter.destroy?.(true);
          this.#filter = null;
        }
        if ( !this.#filter ) this.#filter = filterClass.create();

        this.#filter.type = filterType;
        this.#filter.targetTexture = this.#toRT;
        this.#filter.uniforms.progress = 0;
        this.#filter.uniforms.opaque = 1;
        this.#filter.uniforms.backgroundColor = clearColor;

        this.#sprite.filters = [this.#filter];
        this.visible = true;

        resolve(this.#toRT);
      };

      renderer.on("postrender", after);
    });
  }

  /* -------------------------------------------- */

  /**
   * Run the transition animation from the captured "from" texture to the "to" texture.
   * If a transition is already running, returns the existing promise.
   * If no filter has been prepared, resolves immediately.
   * @param {object}   [opts]                               Animation options.
   * @param {number}   [opts.duration=this.defaultDuration] Duration in milliseconds.
   * @param {Function} [opts.easing=t=>t]                   Easing function mapping [0,1] to [0,1].
   * @returns {Promise<void>} Promise that resolves when the transition completes.
   */
  _play({duration=this.defaultDuration, easing=t=>t}={}) {
    const existing = CanvasAnimation.getAnimation(this.constructor.#ANIMATION_SYMBOL);
    if ( !existing && this.#running ) {
      this.#running = false;
      this.#promise = null;
    }

    if ( this.#running ) return this.#promise ?? Promise.resolve();
    if ( !this.#filter ) return Promise.resolve();

    this.#running = true;
    this.visible = true;

    const easingFn = (typeof easing === "function") ? easing : (t => t);

    const uniforms = this.#filter.uniforms;
    uniforms.progress = 0;

    const attributes = [{parent: uniforms, attribute: "progress", to: 1}];
    const animationOptions = {
      name: this.constructor.#ANIMATION_SYMBOL,
      duration,
      easing: easingFn
    };
    return this.#promise = CanvasAnimation.animate(attributes, animationOptions).finally(() => this.#cleanup());
  }

  /* -------------------------------------------- */

  /**
   * Create a full-screen sprite for the given texture.
   * @param {PIXI.Texture|PIXI.RenderTexture} texture Texture to display.
   * @param {number} [alpha=1]                        Sprite alpha value.
   * @param {number} [tint=0xFFFFFF]                  Sprite tint color.
   * @returns {PIXI.Sprite} Configured full-screen sprite.
   */
  #createSprite(texture, alpha=1, tint=0xFFFFFF) {
    const {width, height} = canvas.app.renderer.screen;
    return Object.assign(new PIXI.Sprite(texture), {width, height, alpha, tint});
  }

  /* -------------------------------------------- */

  /**
   * Resolve a transition definition from CONFIG with a safe fallback.
   * If the requested type does not exist (per example a module removed), fall back to fade.
   * @param {string|null|undefined} transitionType
   * @returns {{filterClass: typeof TextureTransitionFilter, filterType: string, id: string}}
   */
  #resolveTransition(transitionType) {
    const requested = transitionType ?? this.defaultTransitionType;

    // Prefer requested if present
    const def = CONFIG.Canvas.sceneTransitions?.[requested];
    if ( def ) {
      return {
        id: requested,
        filterClass: def.filterClass ?? TextureTransitionFilter,
        filterType: def.filterType ?? requested
      };
    }

    // Fallback
    const fallbackId =
      (CONFIG.Canvas.sceneTransitions?.[this.defaultTransitionType] ? this.defaultTransitionType : null)
      ?? (CONFIG.Canvas.sceneTransitions?.fade?.id ? CONFIG.Canvas.sceneTransitions.fade.id : null)
      ?? (Object.keys(CONFIG.Canvas.sceneTransitions ?? {})[0] ?? "fade");
    const fb = CONFIG.Canvas.sceneTransitions?.[fallbackId];
    console.warn(`Transition type "${requested}" was not found in CONFIG.Canvas.sceneTransitions. Falling back to "${fallbackId}".`);

    return {
      id: fallbackId,
      filterClass: fb?.filterClass ?? TextureTransitionFilter,
      filterType: fb?.filterType ?? fallbackId
    };
  }

  /* -------------------------------------------- */

  /**
   * Internal cleanup and processing when the animation finishes or is terminated.
   * Hides the overlay, removes filters, destroys textures, invalidate masks, and clears flags.
   */
  #cleanup() {
    canvas.hidden.invalidateMasks();

    if ( this.#sprite ) {
      if ( this.#toRT ) this.#sprite.texture = this.#toRT;
      this.#sprite.filters = null;
    }

    this.visible = false;
    this.removeChildren();

    this.#sprite = null;
    this.#filter = null;

    this.#fromRT?.destroy(true);
    this.#toRT?.destroy(true);
    this.#fromRT = this.#toRT = null;

    this.#running = false;
    this.#promise = null;
  }

  /* -------------------------------------------- */

  /**
   * Reset the internal state of the transition container.
   */
  _reset() {
    CanvasAnimation.terminateAnimation(this.constructor.#ANIMATION_SYMBOL);
    this.#cleanup();
  }
}

/**
 * @import {CanvasTearDownOptions} from "../_types.mjs";
 */


/**
 * 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}"`);
 *   }
 *
 *   _getAvailableLevels(defaultLevels) {
 *     // Return a custom subset of levels for this Scene
 *     return new Set([this.scene.levels.get(someLevelId)]);
 *   }
 *
 *   async _onDraw() {
 *     console.log(`Drawing managed Scene "${this.scene.name}"`);
 *   }
 *
 *   async _onReady() {
 *     console.log(`Readying managed Scene "${this.scene.name}"`);
 *   }
 *
 *   async _onTearDown({nextScene}) {
 *     console.log(`Deconstructing "${this.scene.name}", next up: ${nextScene?.name ?? "blank canvas"}`);
 *   }
 *
 *   _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                    */
  /* -------------------------------------------- */

  /**
   * Configure which level of the Scene should be initially viewed for a managed Scene.
   * This initial level could be user-specific.
   * This method may be called when the Scene is not viewed.
   * @returns {string|void}
   * @protected
   */
  _determineInitialLevel() {}

  /* -------------------------------------------- */

  /**
   * Configure which levels of the Scene are available to the current user.
   * This method may be called when the Scene is not viewed.
   * @param {Set<Level>} defaultLevels  The levels that are available to the current user by default in ascending order.
   * @returns {Set<Level>|void}         Return a Set of Level documents to override the default token-ownership logic,
   *                                    or return nothing to fall back to the default behavior. The returned Levels must
   *                                    be sorted in ascending order.
   * @protected
   */
  _getAvailableLevels(defaultLevels) {}

  /* -------------------------------------------- */

  /**
   * Additional behaviors to perform when the Canvas is first initialized for the Scene.
   * @returns {Promise<void>}
   * @protected
   */
  async _onInit() {}

  /* -------------------------------------------- */

  /**
   * Load additional texture resources for the Scene/Level.
   * This method may be called when the Scene is not viewed.
   * @param {Record<string, string|PIXI.Texture|PIXI.Spritesheet>} textures
   *                                      Destination record to register textures into.
   * @param {string[]} additionalSources  Additional sources to load.
   * @param {Level} level                 The Level to load textures for.
   * @protected
   */
  _loadTextures(textures, additionalSources, level) {}

  /* -------------------------------------------- */

  /**
   * Additional behaviors to perform after core groups and layers are drawn to the canvas.
   * @returns {Promise<void>}
   * @protected
   */
  async _onDraw() {}

  /**
   * Additional behaviors to perform after the Canvas is fully initialized for the Scene.
   * @returns {Promise<void>}
   * @protected
   */
  async _onReady() {}

  /**
   * Additional behaviors to perform when the Scene is deactivated.
   * @param {CanvasTearDownOptions} options  Options which configure how the canvas is deconstructed.
   * @returns {Promise<void>}
   * @protected
   */
  async _onTearDown(options) {}

  /* -------------------------------------------- */
  /*  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)
   * @protected
   */
  _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.
   * @protected
   */
  _deactivateHooks() {
    for ( const [hookName, hookId] of Object.entries(this.#hooks) ) Hooks$1.off(hookName, hookId);
  }
}

/**
 * @import {ActivityData, CanvasAnimationPanOptions, CanvasDimensions, CanvasPerformanceSettings,
 *   CanvasSupportedComponents, CanvasTearDownOptions, CanvasViewPosition, Point} from "@client/_types.mjs";
 * @import CanvasEdges from "./geometry/edges/edges.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 BaseGrid from "@common/grid/base.mjs";
 * @import {Color} from "@common/utils/_module.mjs";
 * @import {SceneViewOptions} from "@client/documents/_types.mjs";
 * @import Level from "@client/documents/level.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, "perception", {value: new PerceptionManager(), writable: false});
  }

  /**
   * Mouse move handler priorities.
   * @enum number
   */
  static MOUSE_MOVE_HANDLER_PRIORITIES = Object.freeze({
    HIGH: 75,
    MEDIUM: 50,
    LOW: 25
  });

  /**
   * Frame ID. Each frame has its own unique value, incremented each frame.
   * @type {number}
   * @internal
   */
  _frameId = 0;

  /**
   * A set of blur filter instances which are modified by the zoom level and the "soft shadows" setting
   * @type {Set<PIXI.Filter>}
   * @readonly
   */
  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}
   * @readonly
   */
  supported;

  /**
   * Is the photosensitive mode enabled?
   * @type {boolean}
   */
  photosensitiveMode;

  /**
   * The renderer screen dimensions.
   * @type {number[]}
   * @readonly
   */
  screenDimensions = [0, 0];

  /**
   * The framenbuffer snapshot.
   * @type {FramebufferSnapshot}
   * @readonly
   */
  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;

  /**
   * The view options.
   * @type {SceneViewOptions}
   * @internal
   */
  _viewOptions = {};

  /* -------------------------------------------- */

  /**
   * A throttled function that handles mouse moves.
   * @type {() => void}
   */
  #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 root container
   * @type {PIXI.Container}
   */
  root;

  /**
   * 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;

  /**
   * 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 transition container specialized in smooth scene transitions
   * @type {TransitionContainer}
   */
  transition;

  /**
   * 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();

  /**
   * Is the mouse position visible?
   * @type {boolean}
   */
  get mousePositionVisible() {
    return this._mousePositionVisible;
  }

  /**
   * @type {boolean}
   * @internal
   */
  _mousePositionVisible = false;

  /**
   * Is the mouse position explored?
   * @type {boolean}
   */
  get mousePositionExplored() {
    return this.#mousePositionExplored;
  }

  #mousePositionExplored = false;

  /**
   * Called when the fog is explored.
   * @type {() => void}
   */
  #onFogExplored = () => {
    const priorMousePositionExplored = this.#mousePositionExplored;
    this.#mousePositionExplored = this.fog.isPointExplored(this.mousePosition);
    if ( this.#mousePositionExplored !== priorMousePositionExplored ) {
      MouseInteractionManager.emulateMoveEvent();
    }
  };

  /**
   * 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;

  /**
   * Track the last active Scene ID.
   * @type {string|null}
   */
  #lastActiveSceneId = null;

  /* -------------------------------------------- */

  /**
   * 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;
  }

  #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;
  }

  #scene = null;

  /* -------------------------------------------- */

  /**
   * A reference to the currently displayed Level document, or null if the Canvas is currently blank.
   * @type {Level|null}
   */
  get level() {
    return this.#level;
  }

  #level = null;

  /* -------------------------------------------- */

  /**
   * A reference to the edges of the currently displayed Level document.
   * @type {CanvasEdges|null}
   */
  get edges() {
    return this.level?.edges ?? 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;
  }

  #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 {InteractionLayer}
   */
  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                              */
  /* -------------------------------------------- */

  /**
   * Increment the per-frame counter Id.
   */
  #onPrerender() {
    this._frameId++;
  }

  /* -------------------------------------------- */

  /**
   * Initialize the canvas frame counter.
   */
  #initializeFrameCounter() {
    this.app.renderer.off("prerender", this.#onPrerender, this);
    this.app.renderer.on("prerender", this.#onPrerender, this);
  }

  /* -------------------------------------------- */

  /**
   * Remove the frame counter listener.
   */
  #teardownFrameCounter() {
    this.app.renderer.off("prerender", this.#onPrerender, this);
  }

  /* -------------------------------------------- */

  /**
   * 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(), writable: false});

    // Cache photosensitive mode
    Object.defineProperty(this, "photosensitiveMode", {
      value: game.settings.get("core", "photosensitiveMode"),
      writable: false
    });

    // Create groups
    this.root = new PIXI.Container();
    this.stage.addChild(this.root);
    this.#createGroups("stage", this.root);

    // Add the transition container
    this.transition = new TransitionContainer();
    this.stage.addChild(this.transition);

    // 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(_loc("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.removeEventListener("explored", this.#onFogExplored);
      this.fog.destroy();
      this.fog = undefined;
    }
    this.fog ??= new CONFIG.Canvas.fogManager();
    this.fog.addEventListener("explored", this.#onFogExplored);
  }

  /* -------------------------------------------- */

  /**
   * 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),
      lightSoftEdges: true
    };

    // Medium settings
    if ( mode >= modes.MED ) {
      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;
    PIXI.Program.defaultVertexPrecision = PIXI.PRECISION.HIGH;
    PIXI.Program.defaultFragmentPrecision = PIXI.PRECISION.HIGH;
    this.app.ticker.maxFPS = PIXI.Ticker.shared.maxFPS = PIXI.Ticker.system.maxFPS = settings.fps;
    this.app.ticker.minFPS = PIXI.Ticker.shared.minFPS = PIXI.Ticker.system.minFPS = 0;
    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} [nextScene]         A specific Scene document to render on the Canvas
   * @returns {Promise<void>}
   */
  async #draw(nextScene) {

    // 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 ( nextScene === undefined ) nextScene = game.scenes.current;
    if ( !((nextScene instanceof Scene$2) || (nextScene === null)) ) {
      throw new Error("You must provide a Scene Document to draw the Canvas.");
    }

    // Snapshot of the current scene
    await this.transition.cancel();
    const clearColor = [...this.#level?.background?.color.rgb ?? [0, 0, 0], 1];
    this.transition._captureCurrentScene({clearColor, black: !this.#ready});

    // Assign status flags
    const wasReady = this.#ready;
    this.#ready = false;
    this.root.visible = false;
    this.loading = true;

    // Reset mouse position visible/explored state
    this._mousePositionVisible = false;
    this.#mousePositionExplored = false;

    // Determine next viewed level
    let nextManager = null;
    let nextLevel = null;
    if ( nextScene ) {
      nextManager = Canvas.getSceneManager(nextScene);
      nextLevel = nextScene.levels.get(this._viewOptions.level
        ?? Canvas._determineInitialLevel(nextScene, nextManager));
    }

    // Tear down any existing scene
    if ( wasReady ) {
      try {
        await this.tearDown({nextScene, nextLevel});
      } catch(err) {
        err.message = `Encountered an error while tearing down the previous scene: ${err.message}`;
        logger.error(err);
      }
    }

    // Remember viewed level of current scene and unview it
    if ( this.#scene ) {
      this.#scene._viewPosition.level = this.#scene._view;
      this.#scene._view = null;
    }

    // Draw a blank canvas if no scene/level is viewed
    if ( !nextLevel ) {
      this.#scene = this.#level = null;
      return this.#drawBlank();
    }

    // Configure a SceneManager instance
    this.#manager = nextManager;

    // Configure the viewed level
    const priorScene = this.#scene;
    this.#scene = nextScene;
    this.#scene._view = nextLevel.id;
    this.#level = nextLevel;

    // Initialize edges
    this.#scene.initializeEdges();

    // Configure Scene dimensions
    this.#dimensions = this.#getDimensions();
    canvas.app.view.style.display = "block";
    document.documentElement.style.setProperty("--gridSize", `${this.dimensions.size}px`);

    // 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.eventMode = "static";
    this.stage.filterArea = canvas.app.screen;
    this.root.hitArea = {contains: () => true};
    this.root.eventMode = "auto";
    this.root.sortableChildren = true;

    // 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.masks.canvas;
    /** @deprecated since v14*/
    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();
    await this.#callManagerEvent("_onReady");
    Hooks$1.call("canvasReady", this);

    // Handle Region BEHAVIOR_VIEWED events
    await this.#handleRegionBehaviorViewedEvents(true);

    // Animate the scene transition
    this.#animateTransition(priorScene).finally(() => this.root.visible = true);
    if ( this.scene.active ) this.#lastActiveSceneId = this.scene.id;

    // Record that loading was complete and return
    this.loading = false;

    // Update mouse position visible/explored state
    this._mousePositionVisible = this.visibility.testVisibility(this.mousePosition, {tolerance: 0});
    this.#mousePositionExplored = this.fog.isPointExplored(this.mousePosition);

    // Update Region shape constraints
    this.#scene.updateRegionShapeConstraints();
    MouseInteractionManager.emulateMoveEvent();
  }

  /* -------------------------------------------- */

  /**
   * Draw and animate the scene transition.
   * @param {Scene|null} priorScene   The prior scene, if any.
   * @returns {Promise<void>}
   */
  async #animateTransition(priorScene) {
    // Handle transition
    const explicitTransition = this._viewOptions.transition;
    const transition = explicitTransition ?? this.scene.transition;
    const transitionType = transition.type ?? null;
    let shouldPlayTransition = !!transitionType && (!!explicitTransition || (this.#scene !== priorScene))
      && !this.transition.isLocked && (transitionType in CONFIG.Canvas.sceneTransitions);

    // If the scene is configured as "active only", only animate when the Scene is activated or
    // when it is active after joining the game
    if ( shouldPlayTransition && transition.activeOnly ) {
      const isActive = this.scene.active === true;
      const becameActive = isActive && (this.scene.id !== this.#lastActiveSceneId);
      shouldPlayTransition = becameActive;
    }

    if ( shouldPlayTransition ) {

      // We need an immediate update to snapshot the scene in its right state
      this.perception.applyRenderFlags();

      // We are launching another "delayed" update to keep a consistent behavior
      this.perception.initialize();

      // Process the scene capture
      const def = CONFIG.Canvas.sceneTransitions[transitionType];
      const duration = transition.duration ?? def.defaultDuration ?? canvas.transition.defaultDuration;
      await this.transition._captureNextScene({clearColor: [0, 0, 0, 1], transitionType});
      await this.transition._play({duration});
    }
    else this.transition._reset();
  }

  /* -------------------------------------------- */

  /**
   * When re-drawing the canvas, first tear down or discontinue some existing processes
   * @param {CanvasTearDownOptions} [options]   Options which configure how the canvas is deconstructed.
   * @returns {Promise<void>}
   */
  async tearDown(options={}) {
    this.stage.filters = null;
    this.sceneTextures = {};
    this.blurOptions = undefined;

    // Track current data which should be restored on draw
    this.#reload = {
      scene: this.#scene.id,
      level: this.#level.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 animejs engine
    foundry.canvas.vfx.deactivate();

    // 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();
    }

    // Handle Region BEHAVIOR_UNVIEWED events
    await this.#handleRegionBehaviorViewedEvents(false);

    // Call tear-down hooks
    await this.#callManagerEvent("_deactivateHooks");
    await this.#callManagerEvent("_onTearDown", options);
    Hooks$1.callAll("canvasTearDown", this, options);

    // Tear down groups
    for ( const name of Object.keys(CONFIG.Canvas.groups).reverse() ) {
      const group = this[name];
      await group.tearDown(options);
    }

    // Tear down every layer
    await this.effects.tearDown();
    for ( const layer of this.layers.reverse() ) {
      await layer.tearDown(options);
    }

    // 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();

    // Stop frame counting
    this.#teardownFrameCounter();
  }

  /* -------------------------------------------- */

  /**
   * Handle Region BEHAVIOR_(UN)VIEWED events that are triggered when the Scene is (un)viewed.
   * @param {boolean} viewed    Is the scene viewed or not?
   */
  async #handleRegionBehaviorViewedEvents(viewed) {
    const eventName = viewed ? CONST.REGION_EVENTS.BEHAVIOR_VIEWED : CONST.REGION_EVENTS.BEHAVIOR_UNVIEWED;
    const promises = [];
    for ( const region of this.scene.regions ) {
      if ( !region.includedInLevel(this.#level.id) ) continue;
      promises.push(region._handleEvent({name: eventName, data: {}, region, user: game.user}));
      /** @deprecated since v13 */
      promises.push(region._handleEvent({name: "behaviorStatus", data: {viewed}, region, user: game.user}));
    }
    const results = await Promise.allSettled(promises);
    for ( const result of results ) {
      if ( result.status === "rejected" ) console.error(result.reason);
    }
  }

  /* -------------------------------------------- */

  /**
   * Get a SceneManager instance used for this Scene, if any.
   * The SceneManager is created by this function if the entry in {@link CONFIG.Canvas.managedScenes} is a class.
   * @param {Scene} scene
   * @returns {SceneManager|null}
   */
  static getSceneManager(scene) {
    const manager = CONFIG.Canvas.managedScenes[scene.id];
    if ( manager instanceof SceneManager ) return manager;
    return manager ? new manager(scene) : null;
  }

  /* -------------------------------------------- */

  /**
   * Determine an initial level based on what tokens the user has visibility of.
   * This choice may be delegated to a SceneManager, if present.
   * @param {Scene} scene                The Scene that is about to be drawn next.
   * @param {SceneManager|null} manager  The SceneManager for this scene.
   * @returns {string}                   The Level ID.
   * @internal
   */
  static _determineInitialLevel(scene, manager) {

    // Delegate this choice to a SceneManager, if present
    const managerLevel = manager?._determineInitialLevel();
    if ( scene.levels.has(managerLevel) ) return managerLevel;

    // Restore the last-viewed level when switching back to a previously-visited scene.
    const availableLevels = scene._getAvailableLevels({ manager });
    const lastLevel = scene.levels.get(scene._viewPosition.level);
    if ( lastLevel && availableLevels.has(lastLevel) ) return lastLevel.id;

    // Prefer the initial level configured for the Scene
    const initialLevel = scene.initialLevel;
    if ( game.user.isGM ) return initialLevel.id;

    // Otherwise find the level closest to the active one.
    if ( availableLevels.has(initialLevel) ) return initialLevel.id;
    return availableLevels.reduce((min, level) => {
      const distance = Math.abs(initialLevel.index - level.index);
      return distance > min.distance ? min : { level, distance };
    }, { level: initialLevel, distance: Infinity }).level.id;
  }

  /* -------------------------------------------- */

  /**
   * 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});
    ui.placeables.render({tabs: true});
    this.loading = this.#ready = false;
    this.#manager = null;
    this.#dimensions = null;
    MouseInteractionManager.emulateMoveEvent();
    this.#initializeUserActivity();
  }

  /* -------------------------------------------- */

  /**
   * 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();
    await this.#initializeTokenControl();
    this._onResize();
    this.#reload = {};

    // Initialize perception
    this.perception.initialize();

    // Initialize frame counter
    this.#initializeFrameCounter();

    // Initialize user activity
    this.#initializeUserActivity();

    // Re-render specific UI elements
    await ui.nav.render({ scrollToActiveLevel: true }); // 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();

    // Activate animejs engine
    // TODO only do this conditionally?
    foundry.canvas.vfx.activate();

    // Call PCO sorting
    canvas.primary.sortChildren();
  }

  /* -------------------------------------------- */

  /**
   * Broadcast user presence in the Scene and request user activity data.
   */
  #initializeUserActivity() {
    const sceneId = game.user.viewedScene = this.#scene?.id ?? null;
    const levelId = game.user.viewedLevel = this.#level?.id ?? null;
    game.user.broadcastActivity({sceneId, levelId, cursor: null, ruler: [], plannedMovements: null, targets: []});
    game.socket.emit("getUserActivity");
  }

  /* -------------------------------------------- */

  /**
   * 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.
   */
  async #initializeTokenControl() {
    let panToken = null;

    // Initial tokens based on reload data
    const isReload = (this.#reload.scene === this.#scene.id) && (this.#reload.level === this.#level.id);
    let controlledTokens = [];
    if ( isReload ) {
      controlledTokens = this.#reload.controlledTokens.map(id => canvas.tokens.get(id));
    }

    // Initialize tokens based on view options
    else if ( this._viewOptions.controlledTokens ) {
      controlledTokens = this._viewOptions.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: only control tokens that are in the viewed level
    for ( const token of controlledTokens ) {
      if ( token?.document._source.level !== this.level.id ) continue;
      const controlled = token.control({releaseOthers: false});
      if ( controlled && !panToken ) panToken = token;
    }

    // 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});
      canvas.tokens.releaseAll();
    }

    // Reinitialize Token targets
    if ( isReload ) game.user._onUpdateTokenTargets(this.#reload.targetedTokens);

    // Pan camera to controlled token
    if ( panToken && !isReload ) await panToken.panCanvas({duration: 0});
  }

  /* -------------------------------------------- */

  /**
   * Safely call a function of the SceneManager instance, catching and logging any errors.
   * @param {string} fnName       The name of the manager function to invoke
   * @param {...*} args           Arguments to forward to the manager function
   * @returns {Promise<void>}
   */
  async #callManagerEvent(fnName, ...args) {
    if ( !this.#manager ) return;
    const fn = this.#manager[fnName];
    try {
      if ( typeof fn !== "function" ) {
        console.error(`Invalid SceneManager function name "${fnName}"`);
        return;
      }
      await fn.call(this.#manager, ...args);
    } 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,
      /** @deprecated since v14 */
      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,
      /** @deprecated since v14 */
      templates: this.templates,
      tiles: this.tiles,
      tokens: this.tokens,
      walls: this.walls
    }[collectionName];
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Infer the Level for a given elevation. The inferred Level is always a visible one.
   * Returns the viewed Level if there's no Level with the given elevation in range,
   * or null if there's no viewed Level.
   * @param {number} elevation               The elevation
   * @param {object} [options]               Additional options
   * @param {Set<string>} [options.levels]   Restrict to these Levels (empty means all Levels)
   * @returns {Level|null}
   */
  inferLevelFromElevation(elevation, {levels}={}) {
    if ( !this.level ) return null;
    const candidates = [];
    for ( const id of levels?.size ? levels : this.scene.levels.keys() ) {
      const level = this.scene.levels.get(id);
      if ( !level?.isVisible ) continue;
      const {bottom, top} = level.elevation;
      if ( (elevation < bottom) || (elevation > top) ) continue;
      candidates.push({level, x: (bottom < elevation) && (elevation < top)
        ? 0 : (bottom === elevation) ? 1 : 2}); // Interior: 0; Bottom: 1; Top: 2
    }
    if ( !candidates.length ) return this.level;
    let m = candidates[0];
    for ( let i = 1; i < candidates.length; i++ ) {
      const c = candidates[i];
      if ( ((c.x - m.x) || (m.level.isView - c.level.isView)) < 0 ) m = c;
    }
    return m.level;
  }

  /* -------------------------------------------- */

  /**
   * 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<Omit<CanvasViewPosition, "level">>} [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, level: this.scene._viewPosition.level };

    // 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();
  }

  /* -------------------------------------------- */

  /**
   * 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 {Omit<CanvasViewPosition, "level"> & CanvasAnimationPanOptions} view  The desired view and animation
   *                                                                              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 {Omit<CanvasViewPosition, "level">} 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) {
    origin = {x: origin.x, y: origin.y}; // Strip to bare Point
    if ( !this.dimensions.rect.contains(origin.x, origin.y) ) return false; // Disallow pinging outside of canvas bounds

    // 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<Omit<CanvasViewPosition, "level">>} position  The unconstrained camera position.
   * @returns {Omit<CanvasViewPosition, "level">}                  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;

    if ( this.#ready && hasMouseMoved ) {
      this._mousePositionVisible = this.visibility.testVisibility(currentPos, {tolerance: 0});
      this.#mousePositionExplored = this.fog.isPointExplored(currentPos);
    }

    // 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
   */
  #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
   */
  #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
   * @public
   */
  _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 "ActiveEffect":
        return canvas.tokens._onDropActiveEffect(event, data);
      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,
      INTERFACE: p.HIGH - 3,
      PRIMARY: p.NORMAL + 3,
      PERCEPTION: p.NORMAL + 2
    });

    // Create pending queues
    Object.defineProperty(this, "pendingRenderFlags", {
      value: {
        OBJECTS: new Set(),
        INTERFACE: 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);

    // Apply elements RenderFlags
    this.#tickerFunctions.INTERFACE = this.#applyRenderFlags.bind(this, this.pendingRenderFlags.INTERFACE);
    this.app.ticker.add(this.#tickerFunctions.INTERFACE, undefined, p.INTERFACE);

    // 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;
  }
};

/**
 * @import {Drawing, Tile, Token} from "../placeables/_module.mjs";
 * @import Level from "@client/documents/level.mjs";
 * @import {LevelTexture} from "@common/documents/_types.mjs";
 * @import Collection from "@common/utils/collection.mjs";
 */

/**
 * 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) {
  /**
   * @param {SpriteMesh} [sprite]
   */
  constructor(sprite) {
    sprite ||= new SpriteMesh(undefined, BaseSamplerShader);
    super(sprite);
    this.eventMode = "none";
    this.#createAmbienceFilter();
    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;

  /**
   * All PCOs that are in this primary group in no particular order.
   * @type {ReadonlyArray<PrimaryCanvasObject>}
   */
  objects = [];

  /**
   * Track the set of HTMLVideoElements which are currently playing as part of this group.
   * @type {Set<PrimarySpriteMesh>}
   */
  videoMeshes = new Set();

  /**
   * 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;

  /**
   * The level texture meshes, sorted in ascending order.
   * @type {PrimarySpriteMesh[]}
   */
  levelTextures = [];

  /**
   * 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 {PrimaryCanvasObject[]}
   */
  #hoveredObjects = [];

  /* -------------------------------------------- */
  /*  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.#drawLevelTextures();
    this.#drawPadding();
    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 full-canvas textures configured by Level documents.
   */
  #drawLevelTextures() {
    for ( const lt of canvas.scene._configureLevelTextures() ) {
      const mesh = this.#drawLevelTexture(lt);
      this.addChild(mesh);
      this.levelTextures.push(mesh);
      if ( lt.level.isView ) Object.defineProperty(this, lt.name, {value: mesh, configurable: true});
    }
    this.levelTextures.sort(PrimaryCanvasGroup._compareObjects);
  }

  /* -------------------------------------------- */

  /**
   * Draw a single full-canvas texture configured by a Level.
   * @param {LevelTexture & {level: Level; name: string; elevation: number; sort: number; zIndex: number;
   *    isBackground: boolean; isUpper: boolean}} lt
   * @returns {PrimarySpriteMesh}
   */
  #drawLevelTexture(lt) {

    // Create Mesh
    const name = `Level.${lt.level.index}.${lt.name}`;
    const texture = getTexture(lt.src) ?? PIXI.Texture.EMPTY;
    const mesh = new PrimarySpriteMesh({name, texture, object: this});

    // Configure properties
    const config = lt.level.textures;
    const r = canvas.dimensions.sceneRect;
    mesh.anchor.set(config.anchorX, config.anchorY);
    mesh.position.set(r.center.x + config.offsetX, r.center.y + config.offsetY);
    mesh.angle = config.rotation;
    mesh.elevation = lt.elevation;
    mesh.sort = lt.sort;
    mesh.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.SCENE;
    mesh.resize(r.width, r.height, config);
    mesh.zIndex = lt.zIndex;
    mesh.textureAlphaThreshold = lt.alphaThreshold;
    if ( lt.isBackground ) {
      mesh.restrictsLight = true;
      mesh.restrictsWeather = true;
    } else {
      mesh._occludedBySameElevationSurfaces = false;
    }
    mesh.occlusionMode = CONST.OCCLUSION_MODES.SURFACE;
    mesh.hoverFade = false;
    mesh.visible = mesh.texture !== PIXI.Texture.EMPTY;
    if ( lt.tint ) mesh.tint = lt.tint;

    // 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")});
    }
    return mesh;
  }

  /* -------------------------------------------- */

  /**
   * 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();

    // Clear level textures
    this.levelTextures.length = 0;
  }

  /* -------------------------------------------- */
  /*  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) {
    const name = tile.objectId;
    const 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);
  }

  /* -------------------------------------------- */
  /*  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 ) {
        // TODO: Ideally we would always break here, but we can only do that if we consider occlusion
        // in #updateHoveredObjects
        if ( !object.occlusionMode ) break;
        continue;
      }
      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.
   * @param {PIXI.Point} mousePosition
   */
  #updateHoveredObjects(mousePosition) {
    this.#hoveredObjects.length = 0;

    // Only visible or explored mouse positions can trigger hovering
    if ( !(canvas.mousePositionVisible || canvas.mousePositionExplored) ) return;

    // Discard hit objects at or below the mouse position
    const mouseThreshold = {
      elevation: canvas.level.elevation.base,
      sortLayer: Infinity,
      sort: Infinity,
      zIndex: Infinity,
      _lastSortedIndex: Infinity
    };
    const hoveredPlaceable = canvas.activeLayer?.hover;
    if ( hoveredPlaceable ) {
      const {Drawing, Tile, Token, Region} = foundry.canvas.placeables;
      if ( (hoveredPlaceable instanceof Token) || (hoveredPlaceable instanceof Tile) ) {
        const mesh = hoveredPlaceable.mesh;
        if ( mesh ) {
          mouseThreshold.elevation = mesh.elevation;
          mouseThreshold.sortLayer = mesh.sortLayer;
          mouseThreshold.sort = mesh.sort;
          mouseThreshold.zIndex = mesh.zIndex;
        }
      } else if ( hoveredPlaceable instanceof Drawing ) {
        const shape = hoveredPlaceable.shape;
        if ( shape ) {
          mouseThreshold.elevation = shape.elevation;
          mouseThreshold.sortLayer = shape.sortLayer;
          mouseThreshold.sort = shape.sort;
          mouseThreshold.zIndex = shape.zIndex;
        }
      } else if ( hoveredPlaceable instanceof Region ) {
        mouseThreshold.elevation = hoveredPlaceable.document.elevation.top;
      } else if ( hoveredPlaceable.document.schema.has("elevation") ) {
        mouseThreshold.elevation = hoveredPlaceable.document.elevation;
      }
    }

    // Get all PCOs that contain the mouse position
    const collisionTest = ({t}) => t.visible && t.renderable
      && t._hoverFadeState && t.containsCanvasPoint(mousePosition)
      && (PrimaryCanvasGroup._compareObjects(t, mouseThreshold) > 0);
    for ( const object of canvas.primary.quadtree.getObjects(
      new PIXI.Rectangle(mousePosition.x, mousePosition.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));
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  get hoverFadeElevation() {
    foundry.utils.logCompatibilityWarning("PrimaryCanvasGroup#hoverFadeElevation has been deprecated. It no longer has any effect.",
      {since: "14.361", until: 16, once: true});
    return canvas.level?.elevation.base ?? 0;
  }

  /**
   * @deprecated since v14
   * @ignore
   */
  set hoverFadeElevation(_value) {
    foundry.utils.logCompatibilityWarning("PrimaryCanvasGroup#hoverFadeElevation has been deprecated. It no longer has any effect.",
      {since: "14.361", until: 16, once: true});
  }
}

/**
 * @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 ( typeof initialize === "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.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.SCENE;
    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 {DoorStateSnapshot}
   */
  #getClosedPosition({flip}={}) {
    const ray = this.object.toRay();
    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.scene._source.grid.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
    const elevation = canvas.primary.foreground.elevation;
    const sort = canvas.primary.foreground.sort - 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 ( typeof preAnimate === "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 ( typeof postAnimate === "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 _createVertexShader() {
    return `
    #version 300 es

    ${this.GLSL1_COMPATIBILITY_VERTEX}

    precision ${PIXI.Program.defaultVertexPrecision} 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 _createFragmentShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_FRAGMENT}

      precision ${PIXI.Program.defaultFragmentPrecision} 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 get defaultUniforms() {
    return {
      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);
  }
}

/**
 * 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 {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;
  }
}

/**
 * @import {BaseShapeData} from "@common/data/_module.mjs";
 * @import Document from "@common/abstract/document.mjs";
 * @import {PlaceableObject} from "@client/canvas/placeables/_module.mjs";
 * @import {PlaceablesLayer} from "@client/canvas/layers/_module.mjs";
 * @import {Point, DeepReadonly} from "@common/_types.mjs";
 */

/**
 * Controls for a shape.
 * @template {Document} DocumentClass
 * @template {PlaceableObject} ObjectClass
 * @template {PlaceablesLayer} LayerClass
 * @template {BaseShapeData} [ShapeClass=BaseShapeData]
 */
class ShapeControls extends RenderFlagsMixin(PIXI.Container) {
  /**
   * @param {ShapeClass} shape    The shape.
   */
  constructor(shape) {
    super();
    this.#shape = shape;
    this.#border = this.addChild(new PIXI.Graphics());
    this.#border.eventMode = "none";
    this.#handles = this.addChild(new PIXI.Container());
    this.#handles.eventMode = "passive";
  }

  /* -------------------------------------------- */

  /** @override */
  static RENDER_FLAG_PRIORITY = "INTERFACE";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {}
  };

  /* -------------------------------------------- */

  /**
   * The shape.
   * @type {ShapeClass}
   */
  get shape() {
    return this.#shape;
  }

  #shape;

  /* -------------------------------------------- */

  /**
   * The Document of this shape.
   * @type {DocumentClass}
   */
  get document() {
    return this.shape.parent;
  }

  /* -------------------------------------------- */

  /**
   * The PlaceableObject of this shape.
   * @type {ObjectClass}
   */
  get object() {
    return this.document.object;
  }

  /* -------------------------------------------- */

  /**
   * The PlaceableLayer of this shape.
   * @type {LayerClass}
   */
  get layer() {
    return this.object.layer;
  }

  /* -------------------------------------------- */

  /**
   * The border of the shape.
   * @type {PIXI.Graphics}
   */
  get border() {
    return this.#border;
  }

  #border;

  /* -------------------------------------------- */

  /**
   * The handles of the shape.
   * @type {PIXI.Container<ShapeControlsHandle>}
   */
  get handles() {
    return this.#handles;
  }

  #handles;

  /* -------------------------------------------- */

  /**
   * The handle offset delta.
   * @type {number}
   */
  #handleOffsetDelta;

  /* -------------------------------------------- */

  /**
   * An internal reference to a Promise in-progress to draw these controls.
   * @type {Promise<this>}
   */
  #drawing = Promise.resolve(this);

  /* -------------------------------------------- */

  /**
   * Have these controls been drawn and is there no drawing in progress?
   * @type {boolean}
   */
  #drawn = false;

  /* -------------------------------------------- */

  /**
   * The tint applied to these controls.
   * @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.#border.tint = this.#tintColor.value;
    for ( const handle of this.#handles.children ) handle.tint = this.#tintColor.value;
  }

  /* ---------------------------------------- */

  /**
   * The tint applied to the controls.
   * @type {PIXI.Color}
   */
  #tintColor = new PIXI.Color(0xFFFFFF);

  /* -------------------------------------------- */

  /**
   * Are the controls editable?
   * @type {boolean}
   * @defaultValue true
   */
  editable = true;

  /* -------------------------------------------- */

  /**
   * Is the border dashed?
   * @type {boolean}
   * @defaultValue false
   */
  get dashed() {
    return this.#dashed;
  }

  set dashed(value) {
    if ( this.#dashed === value ) return;
    this.#dashed = value;
    this.refresh();
  }

  #dashed = false;

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @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 these controls are drawn
    if ( !this.#drawn ) return;

    if ( flags.refresh ) this._refresh();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the visualization of these controls.
   * @protected
   */
  _refresh() {

    // Refresh border
    foundry.canvas.borders.drawBorder(this.#border, this._drawShape.bind(this), {dashed: this.#dashed});

    // Refresh the position and rotation of each handle
    const offsets = {};
    for ( const handle of this.#handles.children ) {
      const {position, rotation, visible} = this.#shape.controlHandles[handle.name];
      const positionKey = `${position.x},${position.y}`;
      const offset = offsets[positionKey] = (offsets[positionKey] ?? -1) + 1;
      handle.position = position;
      handle.pivot.set(-offset * this.#handleOffsetDelta, 0);
      handle.rotation = rotation;
      handle.visible = visible;
    }
    MouseInteractionManager.emulateMoveEvent();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the visualization of these controls.
   */
  refresh() {
    this.renderFlags.set({refresh: true});
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Draw the shape.
   * @param {PIXI.Graphics} graphics
   * @protected
   */
  _drawShape(graphics) {
    this.#shape.drawShape(graphics);
  }

  /* -------------------------------------------- */

  /**
   * Draw the visualization of these controls.
   * @returns {Promise<this>}
   */
  async draw() {
    return this.#drawing = this.#drawing.finally(async () => {
      this.#drawn = false;
      this._clear();
      await this._draw();
      this.refresh();
      this.#drawn = true;
    });
  }

  /* -------------------------------------------- */

  /**
   * Draw these controls.
   * @returns {Promise<void>}
   * @protected
   */
  async _draw() {
    const thickness = CONFIG.Canvas.objectBorderThickness * canvas.dimensions.uiScale;
    const handleSize = thickness * 4;
    const outlineThickness = thickness / 4;

    // Create and draw handles
    this.#handleOffsetDelta = handleSize + (3 * outlineThickness);
    const handles = [];
    const handleStyle = {size: handleSize, outlineThickness};
    for ( const action of Object.keys(this.#shape.controlHandles) ) {
      const handle = new ShapeControlsHandle(this, action);
      handle.tint = this.tint;
      handles.push(handle);
    }
    await Promise.all(handles.map(handle => handle.draw(handleStyle)));
    for ( const handle of handles ) this.#handles.addChild(handle);
  }

  /* -------------------------------------------- */

  /**
   * Clear these controls.
   * @protected
   */
  _clear() {
    this.#border.clear();
    this.#handles.removeChildren().forEach(c => c.destroy({children: true}));
    MouseInteractionManager.emulateMoveEvent();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  destroy(options) {
    this.renderFlags.clear();
    MouseInteractionManager.emulateMoveEvent();
    super.destroy(options);
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /**
   * Can the handle be dragged?
   * @param {PIXI.FederatedEvent} event      The pointer event
   * @param {{notify: boolean}} [options]    Options, used internally
   * @returns {boolean}
   * @protected
   */
  _canDragStart(event, options) {
    return this.editable;
  }

  /* -------------------------------------------- */

  /**
   * Handle the drag start event of a handle.
   * @param {PIXI.FederatedEvent} event    The pointer event.
   * @protected
   */
  _onDragStart(event) {
    event.interactionData.shape = this.shape.clone();
    event.interactionData.preview = ShapeControls._createDragPreview(this.object);
    event.interactionData.unlinked = false;
    event.interactionData.cancelOnPause = true;
  }

  /* -------------------------------------------- */

  /**
   * Create and draw the drag preview for a placeable object.
   * @param {ObjectClass} object    The original placeable object
   * @returns {ObjectClass}         The preview of the placeable object.
   * @internal
   */
  static _createDragPreview(object) {
    const preview = object.clone();
    preview._previewType = "controls";
    preview._onDragStart();
    object.layer.preview.addChild(preview);
    preview.draw();
    return preview;
  }

  /* -------------------------------------------- */

  /**
   * Handle the drag move event of a handle.
   * @param {PIXI.FederatedEvent} event    The pointer event.
   * @protected
   */
  _onDragMove(event) {
    canvas._onDragCanvasPan(event);
    const {handle, destination, shape, unlinked: wasUnlinked} = event.interactionData;
    const isUnlinked = event.altKey;
    if ( wasUnlinked !== isUnlinked ) {
      event.interactionData.unlinked = isUnlinked;
      if ( wasUnlinked ) shape.updateSource(this.shape.toObject());
    }
    shape.moveControlHandle(handle.name, destination, {snap: !event.shiftKey, unlinked: isUnlinked});
    this._updateDragPreview(event);
  }

  /* -------------------------------------------- */

  /**
   * Update the drag preview. Called when the shape has changed.
   * @param {PIXI.FederatedEvent} event    The pointer event.
   * @protected
   */
  _updateDragPreview(event) {
    const {shape, preview} = event.interactionData;
    if ( this.document.schema.has("shapes") ) {
      const shapes = this.document.toObject().shapes;
      shapes[this.shape._index] = shape.toObject();
      preview.document.updateSource({shapes});
      preview.renderFlags.set({refreshShapes: true});
    } else if ( this.document.schema.has("shape") ) {
      preview.document.updateSource({shape: shape.toObject()});
      preview.renderFlags.set({refreshShape: true});
    } else {
      throw new Error(`${this.constructor.name} must implement the _updateDragPreview method.`);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragDrop(event) {
    event.interactionData.clearPreviewContainer = false;
    let result = this._prepareDragDropUpdate(event);
    if ( !Array.isArray(result[0]) ) result = [result];
    const [data, options] = result;
    this.#commitDragDropUpdate(data, options);
  }

  /* -------------------------------------------- */

  /**
   * Prepare the database update that should occur as the result of a drop operation.
   * @param {PIXI.FederatedEvent} event                    The pointer event.
   * @returns {object|[data: object, options?: object]}    The update data and options (optional)
   * @protected
   */
  _prepareDragDropUpdate(event) {
    const {preview} = event.interactionData;
    if ( this.document.schema.has("shapes") ) {
      return {shapes: preview.document.toObject().shapes};
    } else if ( this.document.schema.has("shape") ) {
      return {shape: preview.document.toObject().shape};
    } else {
      throw new Error(`${this.constructor.name} must implement the _prepareDragDropUpdate method.`);
    }
  }

  /* -------------------------------------------- */

  /**
   * Perform the database update using the result of a drop operation.
   * @param {object} data         The update data.
   * @param {object} [options]    Additional update options.
   * @returns {Promise<void>}
   */
  async #commitDragDropUpdate(data, options) {
    await this.document.update(data, options);
    this.layer.clearPreviewContainer();
  }

  /* -------------------------------------------- */

  /**
   * Handle the drag cancel event of a handle.
   * @param {PIXI.FederatedEvent} event    The pointer event.
   * @protected
   */
  _onDragCancel(event) {
    if ( event.interactionData.clearPreviewContainer !== false ) {
      this.layer.clearPreviewContainer();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle the double left-click event of a handle.
   * @param {PIXI.FederatedEvent} event    The pointer event.
   * @protected
   */
  _onClick2(event) {
    if ( this.document.schema.has("shapes") || this.document.schema.has("shape") ) {
      new foundry.applications.apps.ShapeConfig({shape: this.shape}).render({force: true});
    } else {
      this.document.sheet.render({force: true});
    }
  }
}

/* -------------------------------------------- */

/**
 * A handle of a shape controls element.
 */
class ShapeControlsHandle extends PIXI.smooth.SmoothGraphics {
  /**
   * @param {ShapeControls} controls    The controls this handle belongs to.
   * @param {string} name               The name of this handle.
   */
  constructor(controls, name) {
    super();
    this.#controls = controls;
    this.name = name;
    this.cursor = "grab";
    this.eventMode = "static";
    this.on("pointerover", this.#onHoverIn.bind(this));
    this.on("pointerout", this.#onHoverOut.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * The controls that this handle belongs to.
   * @type {ShapeControls}
   */
  get controls() {
    return this.#controls;
  }

  #controls;

  /* -------------------------------------------- */

  /**
   * Is hovered?
   * @type {boolean}
   */
  get hovered() {
    return this.#hovered;
  }

  #hovered = false;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Draw the handle.
   * @param {{size: number; outlineThickness: number}} style    The style.
   * @returns {Promise<void>}
   */
  async draw({size, offset, outlineThickness}) {
    this.clear();
    this.beginFill(0xFFFFFF);
    this.lineStyle(outlineThickness, 0x000000);
    const radius = (size + outlineThickness) / 2;
    switch ( this.name ) {
      case "translate": this.drawCircle(0, 0, radius); break;
      case "scale":
      case "scaleX":
      case "scaleY":
      case "inset":
      case "offset":
      case "sweep": this.drawRect(-radius, -radius, radius * 2, radius * 2); break;
      case "rotate": this.drawPolygon(radius, 0, -radius, radius, -radius, -radius); break;
    }
    this.endFill();
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /**
   * Handle the handle being hovered in.
   * @param {PIXI.FederatedEvent} event    The pointer event.
   */
  #onHoverIn(event) {
    this.#hovered = true;
    this.#controls.object._hoveredHandle = this;
  }

  /* -------------------------------------------- */

  /**
   * Handle the handle being hovered out.
   * @param {PIXI.FederatedEvent} event    The pointer event.
   */
  #onHoverOut(event) {
    this.#hovered = false;
    if ( this.#controls.object._hoveredHandle === this ) {
      this.#controls.object._hoveredHandle = null;
    }
  }
}

/**
 * An interface for defining particle-based weather effects
 * @mixes FullCanvasObjectMixin
 * @deprecated since v14
 * @ignore
 */
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();
    foundry.utils.logCompatibilityWarning("The foundry.canvas.containers.ParticleEffect class is deprecated in favor"
      + " of the newer foundry.canvas.animation.ParticleGenerator API. Support for this class will be removed in"
      + " Version 16.", {since: 14, until: 16, once: true});
    /**
     * 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}
 * @deprecated since v14
 * @ignore
 */
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$K = /*#__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,
  ShapeControls: ShapeControls,
  ShapeControlsHandle: ShapeControlsHandle,
  SpriteMesh: SpriteMesh,
  UnboundContainer: UnboundContainer,
  types: _types$c
});

/**
 * @import PlaceableObject from "../placeable-object.mjs";
 * @import {BaseShapeData} from "@common/data/_module.mjs";
 */

/**
 * A mixin for UX shared between PlaceableObjects that have shapes.
 * @param {typeof PlaceableObject} Base
 */
function ShapeObjectMixin(Base) {
  return class ShapeObject extends Base {
    /**
     * The measurement lines.
     * @type {PIXI.Graphics}
     * @protected
     */
    _measurementLines;

    /* -------------------------------------------- */

    /**
     * The measurement labels.
     * @type {PIXI.Container}
     * @protected
     */
    _measurementLabels;

    /* -------------------------------------------- */

    /**
     * The solid measurement line style.
     * @type {PIXI.ILineStyleOptions}
     * @protected
     */
    _measurementSolidLineStyle;

    /* -------------------------------------------- */

    /**
     * The dashed measurement line style.
     * @type {PIXI.ILineStyleOptions}
     * @protected
     */
    _measurementDashLineStyle;

    /* -------------------------------------------- */

    /**
     * The controls handle that is currently hovered, if any.
     * @type {ShapeControlsHandle|null}
     */
    get hoveredHandle() {
      return this._hoveredHandle;
    }

    /**
     * @type {ShapeControlsHandle|null}
     * @internal
     */
    _hoveredHandle = null;

    /* -------------------------------------------- */

    /** @override */
    get bounds() {
      if ( !this.document.shapes ) return this.document.shape.bounds.clone(); // PlaceableObject#bounds always returns a new instance
      let bounds;
      const shapes = this.document.shapes;
      if ( shapes.length === 0 ) bounds = new PIXI.Rectangle();
      else {
        bounds = shapes[0].bounds.clone();
        for ( let i = 1; i < shapes.length; i++ ) {
          bounds.enlarge(shapes[i].bounds);
        }
      }
      return bounds;
    }

    /* -------------------------------------------- */

    /** @override */
    get center() {
      if ( !this.document.shapes ) {
        const {x, y} = this.document.shape.center;
        return new PIXI.Point(x, y);
      }
      let x = 0;
      let y = 0;
      let totalArea = 0;
      for ( const shape of this.document.shapes ) {
        x += (shape.center.x * shape.area);
        y += (shape.center.y * shape.area);
        totalArea += shape.area;
      }
      if ( totalArea > 0 ) {
        x /= totalArea;
        y /= totalArea;
      }
      return new PIXI.Point(x, y);
    }

    /* -------------------------------------------- */

    /** @override */
    _getTargetAlpha() {
      return 1;
    }

    /* -------------------------------------------- */

    /** @override */
    _overlapsSelection(rectangle) {
      if ( !rectangle.intersects(this.bounds) ) return false;
      const x0 = Math.round(rectangle.left * CLIPPER_SCALING_FACTOR);
      const y0 = Math.round(rectangle.top * CLIPPER_SCALING_FACTOR);
      const x1 = Math.round(rectangle.right * CLIPPER_SCALING_FACTOR);
      const y1 = Math.round(rectangle.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)
      ];
      let shapePaths;
      if ( this.document.shapes ) {
        shapePaths = [];
        for ( const shape of this.document.shapes ) shapePaths.push(...shape.clipperPaths);
      }
      else shapePaths = this.document.shape.clipperPaths;
      const clipper = new ClipperLib.Clipper();
      const solution = [];
      clipper.Clear();
      clipper.AddPath(rectanglePath, ClipperLib.PolyType.ptSubject, true);
      clipper.AddPaths(shapePaths, ClipperLib.PolyType.ptClip, true);
      clipper.Execute(ClipperLib.ClipType.ctIntersection, solution);
      return solution.length !== 0;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onClickLeft(event) {
      const allowed = super._onClickLeft(event);
      if ( allowed === false ) return false;
      if ( event.target instanceof ShapeControlsHandle ) {
        event.interactionData.handle = event.target;
        event.stopPropagation();
      }
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onClickLeft2(event) {
      if ( event.target instanceof ShapeControlsHandle ) {
        event.interactionData.handle = event.target;
        event.stopPropagation();
        return event.interactionData.handle.controls._onClick2(event);
      }
      return super._onClickLeft2(event);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _canDragLeftStart(user, event, options) {
      const allowed = super._canDragLeftStart(user, event, options);
      if ( !allowed ) return false;
      const handle = event.interactionData.handle;
      if ( handle ) return handle.controls._canDragStart(event, options);
      return true;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onDragLeftStart(event) {
      const handle = event.interactionData.handle;
      if ( handle ) return handle.controls._onDragStart(event);
      return super._onDragLeftStart(event);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onDragLeftMove(event) {
      const handle = event.interactionData.handle;
      if ( handle ) return handle.controls._onDragMove(event);
      canvas._onDragCanvasPan(event);
      const {destination, shape, offset} = event.interactionData;
      shape.move({x: destination.x - offset.x, y: destination.y - offset.y}, {snap: !event.shiftKey});
      this._updateDragPreviews(event);
    }

    /* -------------------------------------------- */

    /**
     * Update the drag previews. Called when the shape has changed.
     * @param {PIXI.FederatedEvent} event    The pointer event
     * @protected
     */
    _updateDragPreviews(event) {
      const {clones, origin, shape, offset} = event.interactionData;
      const dx = shape.origin.x - (origin.x - offset.x);
      const dy = shape.origin.y - (origin.y - offset.y);
      if ( this.document.schema.has("shapes") ) {
        for ( const clone of clones ) {
          const shapes = clone._original.document.shapes.map(shape => {
            shape = shape.clone();
            shape.move({x: shape.origin.x + dx, y: shape.origin.y + dy});
            return shape.toObject();
          });
          clone.document.updateSource({shapes});
          clone.renderFlags.set({refreshShapes: true});
        }
      } else if ( this.document.schema.has("shape") ) {
        for ( const clone of clones ) {
          const shape = clone._original.document.shape.clone();
          shape.move({x: shape.origin.x + dx, y: shape.origin.y + dy});
          clone.document.updateSource({shape: shape.toObject()});
          clone.renderFlags.set({refreshShape: true});
        }
      } else if ( this.document.schema.has("x") && this.document.schema.has("y") ) {
        for ( const clone of clones ) {
          const {x, y} = clone._original.document;
          clone.document.updateSource({x: x + dx, y: y + dy});
          clone.renderFlags.set({refreshPosition: true});
        }
      } else {
        throw new Error(`${this.constructor.name} must implement the _updateDragPreviews method.`);
      }
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onDragLeftDrop(event) {
      const handle = event.interactionData.handle;
      if ( handle ) return handle.controls._onDragDrop(event);
      return super._onDragLeftDrop(event);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onDragLeftCancel(event) {
      const handle = event.interactionData.handle;
      if ( handle ) return handle.controls._onDragCancel(event);
      return super._onDragLeftCancel(event);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _initializeDragLeft(event) {
      const interaction = event.interactionData;
      if ( interaction.handle ) return;
      super._initializeDragLeft(event);
      const {origin} = interaction;
      interaction.shape = this._initializeDragShape(event);
      const {x, y} = interaction.shape.origin;
      interaction.offset = {x: origin.x - x, y: origin.y - y};
    }

    /* -------------------------------------------- */

    /**
     * Initialize the shape for dragging.
     * @param {PIXI.FederatedEvent} event    The pointer event
     * @returns {BaseShapeData}              The shape that is dragged
     */
    _initializeDragShape(event) {
      if ( !this.document.shapes ) return this.document.shape.clone();
      const {origin} = event.interactionData;
      let shape;
      for ( let i = this.document.shapes.length - 1; i >= 0; i-- ) {
        shape = this.document.shapes[i];
        if ( (i === 0) || shape.testPoint(origin) ) break;
      }
      return shape.clone();
    }

    /* -------------------------------------------- */

    /** @override */
    _prepareDragLeftDropUpdates(event) {
      const {clones} = event.interactionData;
      const updates = [];
      if ( this.document.shapes ) {
        for ( const clone of clones ) updates.push({_id: clone._original.id, shapes: clone.document.toObject().shapes});
      } else if ( this.document.schema.has("shape") ) {
        for ( const clone of clones ) updates.push({_id: clone._original.id, shape: clone.document.toObject().shape});
      } else {
        for ( const clone of clones ) {
          const {x, y} = clone.document;
          updates.push({_id: clone._original.id, x, y});
        }
      }
      return updates;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _finalizeDragLeft(event) {
      if ( event.interactionData.handle ) return;
      super._finalizeDragLeft(event);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _draw(options) {
      this._measurementLines = this.addChild(new PIXI.Graphics());
      this._measurementLines.eventMode = "none";
      this._measurementLabels = this.addChild(new PIXI.Container());
      this._measurementLabels.eventMode = "none";
      const thickness = CONFIG.Canvas.objectBorderThickness * canvas.dimensions.uiScale;
      const dash = thickness * 2;
      const gap = dash * (5 / 8);
      this._measurementSolidLineStyle = {
        width: thickness / 2,
        color: 0x000000
      };
      this._measurementDashLineStyle = {
        width: thickness / 2,
        color: 0x000000,
        shader: new PIXI.smooth.DashLineShader({dash, gap})
      };
    }

    /* -------------------------------------------- */

    /**
     * Define a PIXI TextStyle object which is used for the measurement labels.
     * @returns {PIXI.TextStyle}
     * @protected
     */
    _getMeasurementTextStyle() {
      return CONFIG.canvasTextStyle;
    }

    /* -------------------------------------------- */

    /**
     * Get the shape that should be measured.
     * @returns {BaseShapeData[]}
     * @protected
     */
    _getMeasuredShapes() {
      return [];
    }

    /* -------------------------------------------- */

    /**
     * Format a distance that is displayed in a measurement label.
     * @param {number} distance     The distance
     * @returns {string}            The distance label
     * @protected
     */
    _formatMeasuredDistance(distance) {
      const formattedDistance = distance.toNearest(0.01).toLocaleString(game.i18n.lang);
      const units = canvas.grid.units;
      return units ? `${formattedDistance} ${units}` : formattedDistance;
    }

    /* -------------------------------------------- */

    /**
     * Refresh the measurements.
     * @protected
     */
    _refreshMeasurements() {
      this._measurementLines.clear();

      // Get the shapes to draw measurements for
      const shapes = this._getMeasuredShapes();
      if ( !shapes.length ) {
        this._measurementLabels.removeChildren().forEach(c => c.destroy({children: true}));
        return;
      }

      // Draw references lines
      this._measurementLines.lineStyle(this._measurementDashLineStyle);
      for ( const shape of shapes ) shape.drawReferenceLines(this._measurementLines);

      const uiScale = canvas.dimensions.uiScale;
      const thickness = CONFIG.Canvas.objectBorderThickness * uiScale;
      const originRadius = thickness * 2;

      // Collect measurement segments and draw shape outlines and origin
      const measuredSegments = [];
      this._measurementLines.lineStyle(this._measurementSolidLineStyle);
      for ( const shape of shapes ) {

        // Draw shape outline
        shape.drawShape(this._measurementLines);

        // Draw shape origin
        this._measurementLines.drawCircle(shape.origin.x, shape.origin.y, originRadius);

        // Get measurements
        measuredSegments.push(...shape.measuredSegments);
      }

      let usedLabels = 0;
      const createLabel = (text, rotation, winding, anchorX) => {
        let label = this._measurementLabels.children.at(usedLabels++);
        if ( label ) {
          label.text = text;
          label.style = textStyle;
        }
        else label = this._measurementLabels.addChild(new PreciseText(text, textStyle));
        label.scale.set(uiScale, uiScale);
        const flip = !rotation.between(-(Math.PI / 2) - 1e-9, (Math.PI / 2) - 1e-9);
        label.anchor.x = anchorX;
        label.anchor.y = (1 - winding) / 2;
        if ( flip ) {
          label.anchor.x = 1 - label.anchor.x;
          label.anchor.y = 1 - label.anchor.y;
        }
        label.pivot.y = Math.sign(winding) * (flip ? 1 : -1) * thickness;
        label.rotation = rotation + (flip ? Math.PI : 0);
        return label;
      };

      // Create measurement labels and draw measurement lines
      this._measurementLines.lineStyle(this._measurementDashLineStyle);
      const textStyle = this._getMeasurementTextStyle();
      for ( let i = 0; i < measuredSegments.length; i++ ) {
        const {ray, winding, distance, angle} = measuredSegments[i];
        if ( distance >= (0.5 * canvas.dimensions.distance) - 1e-9 ) {

          // Create distance label
          const distanceText = this._formatMeasuredDistance(distance);
          const distanceLabel = createLabel(distanceText, ray.angle, winding, 0.5);
          distanceLabel.position = ray.project(0.5);
          const s = Math.clamp(ray.distance / (2 * canvas.dimensions.size), 0.25, 1);
          distanceLabel.scale.x *= s;
          distanceLabel.scale.y *= s;

          // Create angle label
          if ( (angle !== undefined) && (distance >= (2 * canvas.dimensions.distance) - 1e-9) ) {
            const formattedDistance = angle.toNearest(0.01).toLocaleString(game.i18n.lang);
            const distanceText = `${formattedDistance}°`;
            const angleLabel = createLabel(distanceText, ray.angle, winding, 0);
            angleLabel.position = ray.project(2 * originRadius / ray.distance);
            const s = Math.clamp((ray.distance - (1.5 * canvas.dimensions.size))
              / (2 * canvas.dimensions.size), 0.25, 1);
            angleLabel.scale.x *= s;
            angleLabel.scale.y *= s;
          }
        }

        // Draw measurement line
        if ( winding === 0 ) {
          this._measurementLines.moveTo(ray.A.x, ray.A.y);
          this._measurementLines.lineTo(ray.B.x, ray.B.y);
        }
      }

      // Destroy excess measurement labels
      if ( usedLabels < this._measurementLabels.children.length ) {
        this._measurementLabels.removeChildren(usedLabels).forEach(c => c.destroy({children: true}));
      }
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onUpdate(changed, options, userId) {
      super._onUpdate(changed, options, userId);
      if ( this._hasShapeChanged(changed) ) this.#cancelInteractionWorkflows();
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onDelete(options, userId) {
      this.#cancelInteractionWorkflows();
      super._onDelete(options, userId);
    }

    /* -------------------------------------------- */

    /**
     * Cancel all interaction workflows related to this object.
     */
    #cancelInteractionWorkflows() {

      // Cancel draw workflow if any
      if ( this.mouseInteractionManager?.state > MouseInteractionManager.INTERACTION_STATES.HOVER ) {
        this.mouseInteractionManager.interactionData.cancelled = true;
        this.mouseInteractionManager.cancel();
      }

      // Cancel shape creation workflow if any
      if ( (this._preview?._previewType === "creation")
        && (canvas.mouseInteractionManager.state > MouseInteractionManager.INTERACTION_STATES.HOVER) ) {
        canvas.mouseInteractionManager.interactionData.cancelled = true;
        canvas.mouseInteractionManager.cancel();
      }
    }

    /* -------------------------------------------- */

    /**
     * Has the shape or a shape changed?
     * @param {object} changed  The changes of the update operation
     * @returns {boolean}       True if the shape or a shape has changed
     * @internal
     */
    _hasShapeChanged(changed) {
      if ( this.document.schema.has("shapes") ) return "shapes" in changed;
      if ( this.document.schema.has("shape") ) return "shape" in changed;
      throw new Error(`${this.constructor.name} must implement the _hasShapeChanged method.`);
    }
  };
}

/**
 * @import DrawingDocument from "@client/documents/drawing.mjs";
 * @import Drawing from "@client/canvas/placeables/drawing.mjs";
 * @import DrawingsLayer from "@client/canvas/layers/drawings.mjs";
 * @import {RectangleShapeData, EllipseShapeData, PolygonShapeData} from "@common/data/_module.mjs";
 */

/**
 * Controls for a Drawing shape.
 * @extends {ShapeControls<DrawingDocument, Drawing, DrawingsLayer,
 *   RectangleShapeData|EllipseShapeData|PolygonShapeData>}
 */
class DrawingShapeControls extends ShapeControls {

  /** @override */
  _drawShape(graphics) {
    if ( this.shape.type !== "polygon" ) {
      this.shape.drawShape(graphics);
      return;
    }
    const isClosed = this.document.fillType || (this.shape.points.slice(0, 2).equals(this.shape.points.slice(-2)));
    if ( isClosed ) graphics.drawPolygon(this.shape.points);
    else graphics.drawPath(this.shape.points);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragStart(event) {
    super._onDragStart(event);

    // Handle transformation of other controlled drawings
    if ( event.interactionData.handle.name === "translate" ) {
      const others = event.interactionData.others = [];
      for ( const object of this.layer.controlled ) {
        if ( object !== this.object ) others.push(ShapeControls._createDragPreview(object));
      }
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragMove(event) {
    const {handle, destination, shape, preview} = event.interactionData;
    if ( (shape.type !== "polygon") || (handle.name !== "rotate") ) return super._onDragMove(event);
    const snap = !event.shiftKey;
    canvas._onDragCanvasPan(event);
    const {x: x0, y: y0} = this.shape.origin;
    const [x1, y1] = this.shape.points;
    let rotation = this.document.rotation + (Math.toDegrees(Math.atan2(destination.y - y0, destination.x - x0))
      - Math.toDegrees(Math.atan2(y1 - y0, x1 - x0)));
    if ( snap ) rotation = shape._getSnappedRotation(rotation);
    else rotation = Math.round(rotation);
    preview.document.updateSource({rotation});
    preview.renderFlags.set({refreshRotation: true});
  }

  /* -------------------------------------------- */

  /** @override */
  _updateDragPreview(event) {
    this.layer._updateDragPreview(event);

    // Handle translation of other controlled drawings
    if ( !event.interactionData.others?.length ) return;
    const {preview} = event.interactionData;
    const dx = preview.document.x - this.document.x;
    const dy = preview.document.y - this.document.y;
    for ( const preview of event.interactionData.others ) {
      const {x, y} = preview._original.document._source;
      preview.document.updateSource({x: x + dx, y: y + dy});
      preview.renderFlags.set({refreshPosition: true});
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareDragDropUpdate(event) {
    const {x, y, shape: {width, height, points}, rotation} = event.interactionData.preview.document._source;
    return {x, y, shape: {width, height, points}, rotation};
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragDrop(event) {

    // Handle translation of multiple controlled drawings
    if ( event.interactionData.others?.length ) {
      event.interactionData.clearPreviewContainer = false;
      const {x, y} = event.interactionData.preview.document._source;
      const updates = [{_id: this.document.id, x, y}];
      for ( const preview of event.interactionData.others ) {
        const {x, y} = preview.document._source;
        updates.push({_id: preview._original.document.id, x, y});
      }
      this.document.parent.updateEmbeddedDocuments("Drawing", updates).finally(() => {
        this.layer.clearPreviewContainer();
      });
      return;
    }

    return super._onDragDrop(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _onClick2(event) {
    this.document.sheet.render({force: true});
  }
}

/**
 * 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 ShapeObjectMixin(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;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static embeddedName = "Drawing";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshTransform", "refreshText", "refreshElevation"], alias: true},
    refreshState: {propagate: ["refreshVisibility"]},
    refreshVisibility: {},
    refreshTransform: {propagate: ["refreshPosition", "refreshRotation", "refreshSize"], alias: true},
    refreshPosition: {},
    refreshRotation: {},
    refreshSize: {propagate: ["refreshPosition", "refreshShape", "refreshText"]},
    refreshShape: {},
    refreshText: {},
    refreshElevation: {},
    /** @deprecated since v14 */
    refreshFrame: {deprecated: {since: 14, until: 16}, 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;
  }

  /* -------------------------------------------- */

  /** @override */
  get bounds() {
    return this.document._shape.bounds.clone(); // PlaceableObject#bounds always returns a new instance
  }

  /* -------------------------------------------- */

  /** @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 shape controls of this Drawing.
   * @type {PIXI.Container}
   */
  controls;

  /* -------------------------------------------- */


  /**
   * 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() {
    if ( this.shape ) this.shape.visible = false;
    this.text?.destroy({children: true});
    this.text = null;
    super._clear();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._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;

    // Draw hitbox
    this.frame = this.addChild(this.#drawFrame());

    // Draw controls
    this.controls = this.addChild(new DrawingShapeControls(this.document._shape.clone()));
    await this.controls.draw();

    // Drawing text
    this.text = this.hasText ? this.shape.addChild(this.#drawText()) : null;

    // Set cursor
    this.cursor = "pointer";
  }

  /* -------------------------------------------- */

  /**
   * 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 hitbox
   * @returns {PIXI.Container}
   */
  #drawFrame() {
    const point = new PIXI.Point();
    const frame = new PIXI.Container();
    frame.eventMode = "auto";
    frame.hitArea = {
      contains: (x, y) => {
        const {_shape: shape, fillType, fillAlpha} = this.document;
        const isOpen = (shape.type === "polygon") && !(fillType && fillAlpha);
        return !isOpen && shape.testPoint(point.set(x, y));
      }
    };
    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                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyRenderFlags(flags) {
    super._applyRenderFlags(flags);
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshRotation ) this._refreshRotation();
    if ( flags.refreshShape ) this._refreshShape();
    if ( flags.refreshText ) this._refreshText();
    if ( flags.refreshElevation ) this._refreshElevation();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the position.
   * @protected
   */
  _refreshPosition() {
    const {x, y, shape: {width, height}} = this.document;
    if ( this.controls.shape.type === "polygon" ) this.controls.shape.updateSource(this.document._shape.toObject());
    else this.controls.shape.updateSource({x: x + (width / 2), y: y + (height / 2)});
    this.controls.refresh();
    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} = this.document;
    if ( this.controls.shape.type === "polygon" ) this.controls.shape.updateSource(this.document._shape.toObject());
    else this.controls.shape.updateSource({rotation});
    this.controls.refresh();
    this.shape.angle = rotation;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshVisibility() {
    super._refreshVisibility();
    this.shape.visible = this.visible;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshState() {
    super._refreshState();
    const {hidden, locked, sort} = this.document;
    const colors = CONFIG.Canvas.dispositionColors;
    this.controls.tint = this.controlled || this.isPreview ? colors.CONTROLLED
      : (locked ? colors.HOSTILE : colors.INACTIVE);
    this.controls.visible = this.controlled || this.hover || this.layer.highlightObjects || ["dragging", "wheel", "controls"].includes(this.previewType);
    this.controls.handles.visible = this.layer.active && (game.activeTool !== "text") && this.controlled && !locked;
    this.controls.editable = this.layer.active && !!ui.controls.tool?.interaction
      && !!ui.controls.tool?.control && !locked;
    this.controls.dashed = hidden;
    this.shape.zIndex = this.zIndex;
    this.shape.sort = sort;
    this.shape.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.DRAWINGS;
    const isDragging = ["dragging", "creation"].includes(this.previewType);
    this.shape.alpha = this.alpha * (isDragging ? 0.8 : 1) * (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.controls.shape.updateSource(this.document._shape.toObject());
    this.controls.refresh();
    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 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                               */
  /* -------------------------------------------- */

  /** @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 ) {
        if ( this._preview?.previewType !== "config" ) 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 ( !rectangle.overlaps(this.bounds) ) return false;
    const x0 = Math.round(rectangle.left * CLIPPER_SCALING_FACTOR);
    const y0 = Math.round(rectangle.top * CLIPPER_SCALING_FACTOR);
    const x1 = Math.round(rectangle.right * CLIPPER_SCALING_FACTOR);
    const y1 = Math.round(rectangle.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 {_shape: shape, fillType, fillAlpha} = this.document;
    const isOpen = (shape.type === "polygon") && !(fillType && fillAlpha);
    const clipper = new ClipperLib.Clipper();
    const solution = isOpen ? new ClipperLib.PolyTree() : [];
    clipper.Clear();
    clipper.AddPaths(shape.clipperPaths, ClipperLib.PolyType.ptSubject, !isOpen);
    clipper.AddPath(rectanglePath, ClipperLib.PolyType.ptClip, true);
    clipper.Execute(ClipperLib.ClipType.ctIntersection, solution);
    return (Array.isArray(solution) ? solution.length : solution.ChildCount()) !== 0;
  }

  /* -------------------------------------------- */

  /**
   * 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()) ?? null;
    }
  }

  /* -------------------------------------------- */

  /**
   * 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))
        || (this.controls.shape.type !== this.document._shape.type),
      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);
  }

  /* -------------------------------------------- */

  /** @override */
  _hasShapeChanged(changed) {
    return ["x", "y", "rotation", "shape"].some(k => k in changed);
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @override */
  _initializeDragShape(event) {
    return this.document._shape.clone();
  }

  /* -------------------------------------------- */

  /** @override */
  _updateDragPreviews(event) {
    const {clones, origin, shape, offset} = event.interactionData;
    const dx = shape.origin.x - (origin.x - offset.x);
    const dy = shape.origin.y - (origin.y - offset.y);
    for ( const clone of clones ) {
      const {x, y} = clone._original.document;
      clone.document.updateSource({x: x + dx, y: y + dy});
      clone.renderFlags.set({refreshPosition: true});
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareDragLeftDropUpdates(event) {
    const {clones} = event.interactionData;
    const updates = [];
    for ( const clone of clones ) {
      const {x, y} = clone.document;
      updates.push({_id: clone._original.id, x, y});
    }
    return updates;
  }

  /* -------------------------------------------- */
  /*  Resize Handling                             */
  /* -------------------------------------------- */

  /**
   * 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";
 * @import DrawingDocument from "@client/documents/drawing.mjs";
 */

/**
 * The Application responsible for configuring a single Drawing document within a parent Scene.
 */
class DrawingConfig extends PlaceableConfig {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["drawing-config"],
    canCreate: true,
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-pencil"
    },
    position: {width: 480},
    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 */
  _previewChanges(changes) {
    super._previewChanges(changes);
    if ( !this._preview.rendered ) return;
    const redraw = (this._preview.object.controls.shape.type !== this._preview._shape.type)
      || (this._preview.object.hasText === !this._preview.object.text);
    this._preview.object.renderFlags.set({redraw});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    return Object.assign(context, {
      tabClasses: "top-tabs",
      userColor: game.user.color,
      units: {
        degrees: _loc("MEASUREMENT.Degrees"),
        pixels: _loc("MEASUREMENT.Pixels")
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Configure footer buttons for this sheet.
   * @returns {FormFooterButton[]}
   */
  #prepareButtons() {
    const submitLabel = this.document.id ? "DRAWING.ACTIONS.submitUpdate" : "DRAWING.ACTIONS.submitCreate";
    return [{type: "submit", icon: "fa-solid fa-floppy-disk", label: submitLabel}];
  }

  /* -------------------------------------------- */

  /** @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 "position":
        partContext.drawingRoles = {false: _loc("DRAWING.Object"), true: _loc("DRAWING.Information")};
        break;
      case "lines":
        partContext.scaledBezierFactor = context.source.bezierFactor * 2;
        break;
      case "fill":
        partContext.fillDisabled = context.source.fillType === CONST.DRAWING_FILL_TYPES.NONE;
        partContext.fillTypes = Object.entries(CONST.DRAWING_FILL_TYPES).map(([k, v]) => ({
          value: v,
          label: _loc(`DRAWING.FILL_TYPES.${k.toLowerCase()}`)
        }));
        break;
      case "text":
        partContext.fontFamilies = FontConfig.getAvailableFontChoices();
        break;
      case "footer":
        partContext.buttons = this.#prepareButtons();
    }
    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;
      }
    }
    super._onChangeForm(formConfig, event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);

    // Un-scale the bezier factor
    if ( "bezierFactor" in submitData ) submitData.bezierFactor /= 2;

    return submitData;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _processSubmitData(event, form, submitData, options) {

    // 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);
  }
}

/**
 * A dialog that provides bulk operation or default values for newly-created drawings.
 * @extends {DrawingConfig}
 * @mixes PlaceablePaletteMixin
 */
class DrawingPalette extends PlaceablePaletteMixin(DrawingConfig) {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "drawing-palette",
    initialData: { shape: { type: "p", width: 1, height: 1, points: [0, 0, 1, 0] } }
  };

  /* -------------------------------------------- */

  /**
   * The setting key where default data is saved.
   * @type {string}
   */
  static SETTING_KEY = "drawingPalette";

  /* -------------------------------------------- */

  /**
   * The placeable document.
   * @type {string}
   */
  static documentName = "Drawing";

  /* -------------------------------------------- */

  /** @override */
  static TABS = {};

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    body: { template: "templates/scene/palette/drawing/body.hbs" },
    footer: { template: "templates/generic/form-footer.hbs" }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  static get schema() {
    const fullSchema = DrawingDocument.defineSchema();
    const allowed = new Set(DrawingDocument.defaultDrawingFields);
    const fields = {};
    for ( const key of allowed ) fields[key] = fullSchema[key];
    fields.bezierFactor.initial = 0.5;
    return new foundry.data.fields.SchemaField(fields);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.isFreehand = game.activeTool === "freehand";
    context.scaledBezierFactor = context.source.bezierFactor * 2;
    context.fillDisabled = context.source.fillType === CONST.DRAWING_FILL_TYPES.NONE;
    context.fillTypes = Object.entries(CONST.DRAWING_FILL_TYPES).map(([k, v]) => ({
      value: v, label: _loc(`DRAWING.FILL_TYPES.${k.toLowerCase()}`)
    }));
    context.fontFamilies = FontConfig.getAvailableFontChoices();
    context.drawingRoles = { false: _loc("DRAWING.Object"), true: _loc("DRAWING.Information") };
    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  async _preparePartContext(partId, context, options) {
    context.partId = `${this.id}-${partId}`;
    return context;
  }

  /* -------------------------------------------- */
  /*  Deprecations & Compatibility                */
  /* -------------------------------------------- */

  /**
   * Migrate existing default drawing settings into the palette.
   * @internal
   */
  static _migrateDefaultDrawingConfig() {
    const storage = game.settings.storage.get("client");
    const oldData = storage?.getItem("core.defaultDrawingConfig");
    if ( !oldData ) return;
    try {
      const parsed = JSON.parse(oldData);
      if ( !foundry.utils.isEmpty(parsed) ) {
        const newKey = `core.${this.SETTING_KEY}`;
        if ( !storage.getItem(newKey) ) storage.setItem(newKey, JSON.stringify(parsed));
      }
      storage.removeItem("core.defaultDrawingConfig");
    } catch(e) {
      console.warn("Failed to migrate defaultDrawingConfig to drawingPalette", e);
    }
  }
}

/**
 * The Application responsible for configuring a single Note document within a parent Scene.
 */
class NoteConfig extends PlaceableConfig {
  /** @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 _loc("NOTE.Create");
    const textLabel = this.document.text;
    return textLabel ? `${_loc("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 icon = this.#prepareIcons(source.texture.src);
    const textAnchors = Object.entries(CONST.TEXT_ANCHOR_POINTS).reduce((anchors, e) => {
      anchors[e[1]] = _loc(`JOURNAL.Anchor${e[0].titleCase()}`);
      return anchors;
    }, {});
    const isCreated = !!this.document.collection?.has(this.document.id);
    const submitText = isCreated ? "NOTE.Update" : "NOTE.Create";
    return Object.assign(context, {
      author: document.author?.name || "",
      entries: game.journal
        .filter(e => e.visible)
        .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,
      icon,
      fontFamilies: FontConfig.getAvailableFontChoices(),
      textAnchors,
      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.visible ) obj[page.id] = page.name;
      return obj;
    }, {}) ?? {};
  }

  /* -------------------------------------------- */

  /**
   * Localize and sort available icon options and determine whether a custom icon is in use.
   * @returns {{selected: string; custom: string, field: StringField}}
   */
  #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;
      }, {"": _loc("NOTE.Custom")});
    const currentIcon = this.document._source.texture.src;
    const usingCustom = (currentIcon === "") || !(currentIcon in icons);
    return {
      selected: usingCustom ? "" : currentIcon,
      custom: usingCustom ? currentIcon : "",
      field: new foundry.data.fields.StringField({
        required: false, blank: true, choices: icons, label: "NOTE.FIELDS.texture.src.label"
      }, { name: "icon.selected" })
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _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.elements.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.form.elements["icon.custom"].closest(".form-group").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 ? `${_loc("DOCUMENT.Note")}: ${newValue}` : this.title;
        this.element.querySelector(":scope > header > h1").innerText = windowTitle;
      }
    }
    super._onChangeForm(formConfig, event);
  }

  /* -------------------------------------------- */

  /** @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 */
  _previewChanges(changes) {
    const priorTextureSrc = this._preview.texture.src;
    super._previewChanges(changes);
    if ( !this._preview.rendered ) return;
    const redraw = this._preview.texture.src !== priorTextureSrc;
    this._preview.object.renderFlags.set({redraw});
  }
}

/**
 * A dialog that provides bulk operation or default values for newly-created notes.
 * @extends {NoteConfig}
 * @mixes PlaceablePaletteMixin
 */
class NotePalette extends PlaceablePaletteMixin(NoteConfig) {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "note-palette"
  };

  /* -------------------------------------------- */

  /**
   * The setting key where default data is saved.
   * @type {string}
   */
  static SETTING_KEY = "notePalette";

  /* -------------------------------------------- */

  /**
   * The placeable document.
   * @type {string}
   */
  static documentName = "Note";

  /* -------------------------------------------- */

  /** @override */
  static TABS = {};

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    body: { template: "templates/scene/palette/note/body.hbs" },
    footer: { template: "templates/generic/form-footer.hbs" }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  static get schema() {
    const schema = super.schema;
    for ( const f of ["x", "y", "sort", "author", "entryId", "pageId"] ) {
      delete schema.fields[f];
    }
    return schema;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _preparePartContext(partId, context, options) {
    context.partId = `${this.id}-${partId}`;
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    if ( this.isSelect ) {
      const name = event.target.name;
      if ( name.startsWith("icon.") ) this._dirtyFields.add("texture.src");
    }
    return super._onChangeForm(formConfig, event);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _determineMultiFields(docs) {
    const multiple = super._determineMultiFields(docs);
    if ( multiple.has("texture.src") ) {
      multiple.add("icon.selected");
      multiple.add("icon.custom");
    }
    return multiple;
  }
}

/**
 * @import {ApplicationClickAction, FormFooterButton} from "../_types.mjs";
 */

/**
 * The Application responsible for configuring a single Region document within a parent Scene.
 */
class RegionConfig extends PlaceableConfig {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["region-config"],
    viewPermission: DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-regular fa-game-board"
    },
    position: {width: 500},
    form: {
      closeOnSubmit: true
    },
    actions: {
      shapeCreateFromWalls: RegionConfig.#onShapeCreateFromWalls,
      shapeToggleHole: RegionConfig.#onShapeToggleHole,
      shapeMoveUp: RegionConfig.#onShapeMoveUp,
      shapeMoveDown: RegionConfig.#onShapeMoveDown,
      shapeEdit: RegionConfig.#onShapeEdit,
      shapeRemove: RegionConfig.#onShapeRemove,
      shapeRemoveAll: RegionConfig.#onShapeRemoveAll,
      behaviorCreate: RegionConfig.#onBehaviorAdd,
      behaviorDelete: RegionConfig.#onBehaviorDelete,
      behaviorEdit: RegionConfig.#onBehaviorEdit,
      behaviorToggle: RegionConfig.#onBehaviorToggle
    }
  };

  /** @override */
  static PARTS = {
    tabs: {
      template: "templates/generic/tab-navigation.hbs"
    },
    appearance: {
      template: "templates/scene/parts/region-appearance.hbs"
    },
    shapes: {
      template: "templates/scene/parts/region-shapes.hbs",
      templates: [
        "templates/apps/shape-config/parts/origin.hbs",
        "templates/apps/shape-config/circle.hbs",
        "templates/apps/shape-config/cone.hbs",
        "templates/apps/shape-config/ellipse.hbs",
        "templates/apps/shape-config/line.hbs",
        "templates/apps/shape-config/polygon.hbs",
        "templates/apps/shape-config/rectangle.hbs",
        "templates/apps/shape-config/emanation.hbs",
        "templates/apps/shape-config/ring.hbs",
        "templates/apps/shape-config/token.hbs",
        "templates/apps/shape-config/grid.hbs"
      ],
      scrollable: [".scrollable"]
    },
    placement: {
      template: "templates/scene/parts/region-placement.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: "appearance", icon: "fa-solid fa-paint-roller"},
        {id: "shapes", icon: "fa-solid fa-shapes"},
        {id: "placement", icon: "fa-solid fa-location-dot"},
        {id: "behaviors", icon: "fa-solid fa-child-reaching"}
      ],
      initial: "appearance",
      labelPrefix: "REGION.TABS"
    }
  };

  /* -------------------------------------------- */
  /*  Context Preparation                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    switch ( options.renderContext ) {
      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 document = context.document;
    switch ( partId ) {
      case "tabs":
        context.tabClasses = "top-tabs";
        break;
      case "footer":
        context.buttons = this.#getFooterButtons();
        break;
      case "behaviors":
        context.behaviors = this.document.behaviors.filter(b => b.visible).map(b => ({
          id: b.id,
          name: b.name,
          typeLabel: _loc(CONFIG.RegionBehavior.typeLabels[b.type]),
          typeIcon: CONFIG.RegionBehavior.typeIcons[b.type] || "fa-regular fa-notdef",
          disabled: b.disabled,
          canUpdate: b.canUserModify(game.user, "update"),
          canDelete: b.canUserModify(game.user, "delete")
        })).sort((a, b) => (a.disabled - b.disabled) || a.name.localeCompare(b.name, game.i18n.lang));
        context.canCreateBehavior = RegionBehavior$1.canUserCreate(game.user);
        break;
      case "appearance":
        context.visibilities = Object.entries(CONST.REGION_VISIBILITY).reduce((arr, [key, value]) => {
          arr.push({value, label: _loc(`REGION.VISIBILITY.${key}.label`)});
          return arr;
        }, []);
        break;
      case "shapes":
        if ( document.isSingleShape ) {
          const shape = document.shapes[0];
          const fields = {...shape.schema.fields};
          delete fields.hole;
          context.shapeContext = {rootId: this.id};
          ShapeConfig._prepareShapeContext(context.shapeContext, shape, fields);
        }
        break;
      case "placement":
        context.attachableTokens = (document.parent?.tokens ?? []).reduce((arr, t) => {
          if ( t.isOwner ) arr.push({ value: t.id, label: t.name });
          return arr;
        }, []).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
        context.restrictionTypes = CONST.EDGE_RESTRICTION_TYPES.reduce((arr, value) => {
          arr.push({value, label: _loc(`REGION.RESTRICTION_TYPES.${value}.label`)});
          return arr;
        }, []).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
        break;
    }
    if ( partId in context.tabs ) context.tab = context.tabs[partId];
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _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);
    if ( this.document.testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER) ) {
      this.element.querySelectorAll(".region-shape").forEach(e => {
        e.addEventListener("mouseover", this.#onShapeHoverIn.bind(this));
        e.addEventListener("mouseout", this.#onShapeHoverOut.bind(this));
      });
    }
    this._updateLevelsSelectElement();
    this._updateRestrictionEnabledElement();
    this.document.object?.renderFlags.set({refreshState: true});
    return super._onRender(context, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    ShapeConfig._onChangeDimension(event.target, this.document.parent.grid);
    super._onChangeForm(formConfig, event);
    if ( event.target.name === "levels" ) this._updateRestrictionEnabledElement();
    else if ( ["attachment.token", "restriction.enabled"].includes(event.target.name) ) this._updateLevelsSelectElement(event);
  }

  /* -------------------------------------------- */

  /**
   * Update the Levels select element.
   * @param {Event} [event]    An input change event within the form
   * @protected
   */
  _updateLevelsSelectElement(event) {
    const attachedToken = this.document.parent.tokens.get(this.element.querySelector('[name="attachment.token"]')?.value);
    const unattachedToken = (event?.target.name === "attachment.token") && !event.target.value;
    const restrictionEnabled = this.element.querySelector('[name="restriction.enabled"]').checked;
    const oldLevelsInput = this.element.querySelector('[name="levels"]');
    if ( !oldLevelsInput ) return;
    const newLevelsInput = this.document.schema.fields.levels.toInput({
      value: attachedToken ? attachedToken._source.level : (unattachedToken && !restrictionEnabled
        ? this.document._source.levels : oldLevelsInput.value),
      rootId: this.id,
      input: restrictionEnabled || attachedToken
        ? (field, config) => foundry.applications.fields.createSelectInput(config)
        : (field, config) => foundry.applications.fields.createMultiSelectInput(config),
      options: (this.document.parent?.levels ?? []).reduce((arr, l) => {
        if ( l.visible ) arr.push({ value: l.id, label: l.name });
        return arr;
      }, []),
      disabled: !!attachedToken,
      dataset: restrictionEnabled || attachedToken ? {tooltip: ""} : {},
      aria: attachedToken ? {label: _loc("REGION.TOOLTIPS.CannotChangeLevelsIfAttached")}
        : (restrictionEnabled ? {label: _loc("REGION.TOOLTIPS.CannotSelectMultipleLevelsIfRestricted")} : {})
    });
    oldLevelsInput.replaceWith(newLevelsInput);
    if ( !foundry.utils.equals(oldLevelsInput.value, newLevelsInput.value) ) this._updateRestrictionEnabledElement();
  }

  /* -------------------------------------------- */

  /**
   * Update the Is Restricted select element.
   * @param {Event} [event]    An input change event within the form
   * @protected
   */
  _updateRestrictionEnabledElement(event) {
    let levels = this.element.querySelector('[name="levels"]');
    if ( !levels ) return;
    levels = levels.value;
    if ( !Array.isArray(levels) ) levels = [levels];
    const restrictionEnabledInput = this.element.querySelector('[name="restriction.enabled"]');
    const canChangeRestricted = restrictionEnabledInput.checked || (levels.length === 1)
      || ((levels.length === 0) && (this.document.parent.levels.size === 1));
    if ( canChangeRestricted ) {
      restrictionEnabledInput.disabled = false;
      delete restrictionEnabledInput.dataset.tooltip;
      restrictionEnabledInput.removeAttribute("aria-label");
    } else {
      restrictionEnabledInput.disabled = true;
      restrictionEnabledInput.dataset.tooltip = "";
      restrictionEnabledInput.ariaLabel = _loc("REGION.TOOLTIPS.CannotEnableRestriction");
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const data = super._processFormData(event, form, formData);

    // Single shape
    if ( "shapes" in data ) {

      // The fieldPath doesn't work as the name and therefore must be converted
      const shapeData = Object.values(data.shapes.element)[0];
      data.shapes = [ShapeConfig._processShapeData(this.document.shapes[0], shapeData)];
    }

    // FormData doesn't include disabled fields
    if ( form.elements.levels ) data.levels = form.elements.levels.value;
    const restrictionEnabledInput = form.elements["restriction.enabled"];
    if ( restrictionEnabledInput ) foundry.utils.setProperty(data, "restriction.enabled", restrictionEnabledInput.checked);

    return data;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _previewChanges(changes) {
    const priorShapeTypes = this._preview.shapes.map(s => `${s.type}.${s.base?.type ?? ""}`);
    super._previewChanges(changes);
    if ( !this._preview.rendered ) return;
    canvas.regions._highlightShape(null);
    const redrawShapeControls = !foundry.utils.equals(this._preview.shapes.map(
      s => `${s.type}.${s.base?.type ?? ""}`), priorShapeTypes);
    if ( redrawShapeControls ) this._preview.object._redrawShapeControls();
  }

  /* -------------------------------------------- */

  /**
   * 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._preview?.rendered ) return;
    const index = this.#getControlShapeIndex(event);
    canvas.regions._highlightShape(this._preview.shapes[index]);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-unhover events for shape.
   * @param {MouseEvent} event
   */
  #onShapeHoverOut(event) {
    event.preventDefault();
    if ( !this._preview ) 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("SHAPE.NOTIFICATIONS.NoControlledWalls", {localize: true});
      }
      else {
        canvas.walls.activate({tool: "select"});
        ui.notifications.info("SHAPE.NOTIFICATIONS.ControlWalls", {localize: true});
      }
      return;
    }

    // Create the shape
    const polygons = canvas.walls.identifyInteriorArea(canvas.walls.controlled);
    if ( polygons.length === 0 ) {
      ui.notifications.error("SHAPE.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 edit a shape.
   * @param {PointerEvent} event
   * @this {RegionConfig}
   */
  static #onShapeEdit(event) {
    const shapeIndex = this.#getControlShapeIndex(event);
    const shape = this.document.shapes[shapeIndex];
    const config = new ShapeConfig({shape});
    const existingConfig = foundry.applications.instances.get(config.id);
    if ( existingConfig?.rendered ) existingConfig.bringToFront();
    else (config ?? existingConfig).render({force: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to remove a shape.
   * @param {PointerEvent} event
   * @this {RegionConfig}
   */
  static async #onShapeRemove(event) {
    const index = this.#getControlShapeIndex(event);
    return this.document.removeShapeDialog(index);
  }

  /* -------------------------------------------- */

  /**
   * Handle button clicks to remove all shapes.
   * @param {PointerEvent} event
   * @this {RegionConfig}
   */
  static async #onShapeRemoveAll(event) {
    const confirmed = await foundry.applications.api.DialogV2.confirm({
      window: {title: "SHAPE.ACTIONS.removeAll"},
      content: `<p>${_loc("COMMON.AreYouSure")}</p>`
    });
    if ( confirmed ) await this.document.update({shapes: []});
  }

  /* -------------------------------------------- */

  /**
   * 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 #onBehaviorAdd() {
    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);
  }
}

/**
 * A dialog that provides bulk operation or default values for newly-created regions.
 * @extends {RegionConfig}
 * @mixes PlaceablePaletteMixin
 */
class RegionPalette extends PlaceablePaletteMixin(RegionConfig) {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "region-palette",
    initialData: {
      name: "regionPalette",
      ownership: { default: DOCUMENT_OWNERSHIP_LEVELS.OWNER }
    }
  };

  /* -------------------------------------------- */

  /**
   * The setting key where default data is saved.
   * @type {string}
   */
  static SETTING_KEY = "regionPalette";

  /* -------------------------------------------- */

  /**
   * The placeable document.
   * @type {string}
   */
  static documentName = "Region";

  /* -------------------------------------------- */

  /** @override */
  static TABS = {};

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    body: { template: "templates/scene/palette/region/body.hbs" },
    footer: { template: "templates/generic/form-footer.hbs" }
  };

  /* -------------------------------------------- */

  /** @override */
  static _getDefaultLevelData() {
    return {
      levels: [canvas.level.id],
      elevation: {
        bottom: Number.isFinite(canvas.level.elevation.bottom) ? canvas.level.elevation.bottom : null,
        top: Number.isFinite(canvas.level.elevation.top) ? canvas.level.elevation.top : null
      }
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  static get schema() {
    const schema = super.schema;
    for ( const f of ["name", "shapes", "behaviors", "locked", "ownership", "flags"] ) {
      delete schema.fields[f];
    }
    // The palette's color field can be null to indicate a random color should be used.
    schema.fields.color = new foundry.data.fields.ColorField({ nullable: true, initial: null });
    return schema;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyPreset(formData, options={}) {
    const preset = super._applyPreset(formData, options);
    preset.elevation ??= {};
    foundry.utils.mergeObject(preset.elevation, formData.elevation);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.visibilities = Object.entries(CONST.REGION_VISIBILITY).reduce((arr, [key, value]) => {
      arr.push({value, label: _loc(`REGION.VISIBILITY.${key}.label`)});
      return arr;
    }, []);
    context.restrictionTypes = CONST.EDGE_RESTRICTION_TYPES.reduce((arr, value) => {
      arr.push({value, label: _loc(`REGION.RESTRICTION_TYPES.${value}.label`)});
      return arr;
    }, []).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
    context.color = context.source.color;
    if ( (ui.controls.tool?.creation || (this.isSelect && !this.layer?.controlled.length))
      && !game.settings.get("core", this.constructor.SETTING_KEY).color ) {
      context.color = null;
    }
    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  async _preparePartContext(partId, context, options) {
    context.partId = `${this.id}-${partId}`;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _processSubmitData(event, form, submitData, options) {
    const rawColor = form.elements.color.value || null;
    if ( (event.type === "submit") && event.isTrusted ) {
      // Blank color means no change rather than random color in the context of bulk application.
      if ( !rawColor ) delete submitData.color;
      return super._processSubmitData(event, form, submitData, options);
    }

    this.document.updateSource(submitData, options);
    const settings = this.document.toObject();
    if ( rawColor === null ) settings.color = null;
    await game.settings.set("core", this.constructor.SETTING_KEY, settings);
    ui.controls.render({ parts: ["tools"] });
    await this.render();
    return {};
  }
}

/**
 * @import TileDocument from "@client/documents/tile.mjs";
 */

/**
 * The Application responsible for configuring a single Tile document within a parent Scene.
 */
class TileConfig extends PlaceableConfig {
  /** @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 _loc("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 submitText = `TILE.ACTIONS.${this.document.collection?.has(this.document.id) ? "UPDATE" : "CREATE"}`;
    return Object.assign(context, {
      tabClasses: "top-tabs",
      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(context.source.texture.src);
    }
    const tab = partContext.tabs[partId];
    if ( tab ) partContext.tab = tab;
    return partContext;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);

    // Show/hide video options
    const videoOptions = this.element.querySelector("fieldset[data-video-options]");
    if ( !videoOptions ) return;
    const formData = new FormDataExtended(this.form);
    const hasVideo = foundry.helpers.media.VideoHelper.hasVideoExtension(formData.object["texture.src"]);
    videoOptions.hidden = !hasVideo;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _previewChanges(changes) {
    const priorTextureSrc = this._preview.texture.src;
    super._previewChanges(changes);
    if ( !this._preview.rendered ) return;
    const redraw = this._preview.texture.src !== priorTextureSrc;
    this._preview.object.renderFlags.set({redraw});
  }
}

/**
 * A dialog that provides bulk operation or default values for newly-created tiles.
 * @extends {TileConfig}
 * @mixes PlaceablePaletteMixin
 */
class TilePalette extends PlaceablePaletteMixin(TileConfig) {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "tile-palette",
    initialData: { width: 100, height: 100 }
  };

  /* -------------------------------------------- */

  /**
   * The setting key where default data is saved.
   * @type {string}
   */
  static SETTING_KEY = "tilePalette";

  /* -------------------------------------------- */

  /**
   * The placeable document.
   * @type {string}
   */
  static documentName = "Tile";

  /* -------------------------------------------- */

  /** @override */
  static TABS = {};

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    body: { template: "templates/scene/palette/tile/body.hbs" },
    footer: { template: "templates/generic/form-footer.hbs" }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get createData() {
    if ( !this.rendered ) return super.createData;
    // Special case handling for the tile layer which doesn't typically 'draw' tiles, but instead places them from the
    // tile browser while the select tool is active. Changes made to the palette with the select tool do not usually
    // update draw state, so we retrieve them directly from the form.
    const formData = new FormDataExtended(this.form);
    return foundry.utils.expandObject(formData.object);
  }

  /* -------------------------------------------- */

  /** @override */
  static get schema() {
    const schema = super.schema;
    ["x", "y", "width", "height", "sort", "rotation", "name", "hidden", "locked"].forEach(f => {
      delete schema.fields[f];
    });
    ["src", "fit", "alphaThreshold"].forEach(f => delete schema.fields.texture.fields[f]);
    return schema;
  }

  /* -------------------------------------------- */

  /**
   * Store ephemeral palette state.
   * @type {{elevation: {dynamic: boolean, original: number|null}}}
   */
  #state = {
    elevation: {
      dynamic: false,
      original: null
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.elevation = context.source.elevation;
    context.isForeground = context.elevation >= canvas.level.elevation.top;
    if ( context.isForeground ) {
      if ( context.elevation < canvas.level.elevation.top ) {
        Object.assign(this.#state.elevation, { dynamic: true, original: context.elevation });
        context.elevation = canvas.level.elevation.top;
      }
    } else if ( this.#state.elevation.dynamic ) {
      context.elevation = this.#state.elevation.original;
      Object.assign(this.#state.elevation, { dynamic: false, original: null });
    }
    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  async _preparePartContext(partId, context) {
    context.partId = `${this.id}-${partId}`;
    return context;
  }
}

/**
 * The Application responsible for configuring a single Wall document within a parent Scene.
 */
class WallConfig extends PlaceableConfig {
  /** @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 = [EDGE_SENSE_TYPES.PROXIMITY, EDGE_SENSE_TYPES.DISTANCE];

  /**
   * 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 */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const {document, source, fields} = context;
    const c = source.c;
    const coordinates = _loc("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: _loc("WALL.ANIMATION_DIRECTIONS.REVERSE")},
      {value: 1, label: _loc("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",
      gridUnits: document.parent.grid.units || _loc("MEASUREMENT.GridUnits"),
      doorSounds: CONFIG.Wall.doorSounds,
      buttons: [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "WALL.Submit"}]
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _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();
    }
    return super._onChangeForm(formConfig, event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.#toggleThresholdInputVisibility();
  }

  /* -------------------------------------------- */

  /**
   * 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(".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() {
    let anyProximityType = false;
    for ( const sense of ["light", "sight", "sound"] ) {
      const type = Number(this.form[sense].value);
      const input = this.form[`threshold.${sense}`];
      const isProximityType = WallConfig.#PROXIMITY_SENSE_TYPES.includes(type);
      anyProximityType ||= isProximityType;
      input.disabled = input.hidden = !isProximityType;
    }
    this.form.elements["threshold.attenuation"].closest(".form-group").hidden = !anyProximityType;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareSubmitData(event, form, formData, updateData) {
    const submitData = super._prepareSubmitData(event, form, formData, updateData);
    for ( const sense of ["light", "sight", "sound"] ) {
      if ( (sense in submitData) && !WallConfig.#PROXIMITY_SENSE_TYPES.includes(submitData[sense]) ) {
        (submitData.threshold ??= {})[sense] = null;
      }
    }
    if ( submitData.door === CONST.WALL_DOOR_TYPES.NONE ) submitData.animation = null; // Purge animation data
    return submitData;
  }

}

/**
 * A dialog that provides bulk operation or default values for newly-created walls.
 * @extends {WallConfig}
 * @mixes PlaceablePaletteMixin
 */
class WallPalette extends PlaceablePaletteMixin(WallConfig) {

  /** @override */
  static DEFAULT_OPTIONS = {
    id: "wall-palette",
    initialData: { c: [0, 0, 0, 0] }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    body: { template: "templates/scene/palette/wall/body.hbs" },
    footer: { template: "templates/generic/form-footer.hbs" }
  };

  /* -------------------------------------------- */

  /**
   * The setting key where default data is saved.
   * @type {string}
   */
  static SETTING_KEY = "wallPalette";

  /* -------------------------------------------- */

  /** @override */
  static COMMIT_TOOL = "wall";

  /* -------------------------------------------- */

  /**
   * The placeable document.
   * @type {string}
   */
  static documentName = "Wall";

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static get schema() {
    const schema = super.schema;
    delete schema.fields.c;
    return schema;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    delete context.coordinates;
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle clicking a preset button in the scene controls.
   * @param {PointerEvent} event
   */
  static onClickPreset(event) {
    const { tool } = event.target.closest("[data-tool]")?.dataset ?? {};
    const preset = foundry.utils.deepClone(ui.controls.tools[tool].createData);
    game.settings.set("core", WallPalette.SETTING_KEY, preset);
    ui.controls.render({ parts: ["tools"] });
    ui.placeablesPalette?.render({ preset, preservePlacement: true });
  }
}

var _module$J = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AmbientLightPalette: AmbientLightPalette,
  AmbientSoundPalette: AmbientSoundPalette,
  DrawingPalette: DrawingPalette,
  NotePalette: NotePalette,
  PlaceablePaletteMixin: PlaceablePaletteMixin,
  RegionPalette: RegionPalette,
  TilePalette: TilePalette,
  WallPalette: WallPalette
});

/**
 * @import {ApplicationClickAction} from "../_types.mjs";
 * @import {DataSchema} from "@common/abstract/_types.mjs";
 * @import {EffectChangeData, EffectStartData} from "@common/documents/_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",
      templates: ["templates/sheets/active-effect/change.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"
    }
  };

  /* ----------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("plugins", event => {
      const plugins = event.detail;
      plugins.menu = ProseMirrorMenu.build(foundry.prosemirror.defaultSchema, {destroyOnSave: false});
    });
  }

  /* ----------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context) {
    const partContext = await super._preparePartContext(partId, context);
    if ( partId in partContext.tabs ) partContext.tab = partContext.tabs[partId];
    const effect = this.document;
    switch ( partId ) {
      case "details":
        partContext.isActorEffect = effect.parent?.documentName === "Actor";
        partContext.isItemEffect = effect.parent?.documentName === "Item";
        partContext.statuses = Object.values(CONFIG.statusEffects)
          .map(s => ({value: s.id, label: _loc(s.name)}));
        partContext.showIconOptions = Object.entries(CONST.ACTIVE_EFFECT_SHOW_ICON).map(([k, value]) => ({
          value, label: _loc(`EFFECT.SHOW_ICON.${k.toLowerCase()}`)
        })).reverse();
        break;
      case "duration": {
        partContext.start = await this._prepareStartContext();
        partContext.hasDuration = typeof context.source.duration.value === "number";
        const groups = {
          time: _loc("EFFECT.DURATION.UNITS.GROUPS.time"),
          combat: _loc("EFFECT.DURATION.UNITS.GROUPS.combat")
        };
        partContext.durationUnits = ACTIVE_EFFECT_DURATION_UNITS.map(
          value => ({
            value,
            label: _loc(`EFFECT.DURATION.UNITS.${value}`),
            group: ACTIVE_EFFECT_TIME_DURATION_UNITS.includes(value) ? groups.time : groups.combat
          })
        );
        partContext.expiryEvents = Object.entries(ActiveEffect$1.EXPIRY_EVENTS)
          .map(([value, label]) => ({value, label: _loc(label)}))
          .sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang))
          .reduce((events, {value, label}) => {
            events[value] = label;
            return events;
          }, {});
        break;
      }
      case "changes": {
        const fields = effect.system.schema.fields.changes.element.fields;
        const changeTypes = Object.entries(ActiveEffect$1.CHANGE_TYPES)
          .map(([type, {label}]) => ({type, label: _loc(label)}))
          .sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang))
          .reduce((types, {type, label}) => {
            types[type] = label;
            return types;
          }, {});
        partContext.changes = await Promise.all(foundry.utils.deepClone(context.source.changes).map((change, index) => {
          const defaultPriority = ActiveEffect$1.CHANGE_TYPES[change.type]?.defaultPriority ?? 0;
          return this._renderChange({change, index, fields, defaultPriority, changeTypes});
        }));
        break;
      }
      case "footer":
        partContext.buttons = [{type: "submit", icon: "fa-solid fa-floppy-disk", label: "EFFECT.Submit"}];
        break;
      case "tabs":
        partContext.tabClasses = "top-tabs";
        break;
    }
    return partContext;
  }

  /* ----------------------------------------- */

  /**
   * Prepare render context for a single change object.
   * @param {object} context                   Data for rendering the change row
   * @param {EffectChangeData} context.change  A copy of the change from the Effect's source array
   * @param {number} context.index             The change object's index in the array
   * @param {DataSchema} context.fields        The defined fields of the change data
   * @param {number} context.defaultPriority   The change type's default priority
   * @param {Record<string, string>} context.changeTypes All change types and their localized labels
   * @returns {Promise<string>}
   * @protected
   */
  async _renderChange(context) {
    const {change, index} = context;
    if ( ("value" in change) && (typeof change.value !== "string") ) change.value = JSON.stringify(change.value);
    Object.assign(
      change,
      ["key", "type", "value", "phase", "priority"].reduce((paths, fieldName) => {
        if ( fieldName in change ) paths[`${fieldName}Path`] = `system.changes.${index}.${fieldName}`;
        return paths;
      }, {}));
    const changeType = ActiveEffect$1.CHANGE_TYPES[change.type];
    context.changeType = changeType;
    return changeType?.render?.(context) ?? renderTemplate("templates/sheets/active-effect/change.hbs", context);
  }

  /* ----------------------------------------- */

  /**
   * Prepare display context for {@link EffectStartData}.
   * @returns {object}
   * @protected
   */
  async _prepareStartContext() {
    const effect = this.document;
    const start = effect._source.start;
    if ( !start ) return null;
    const time = game.time.calendar.format(game.time.worldTime - start.time, "ago");
    const combat = effect.start.combat ?? null;
    const combatant = combat?.combatants.get(start.combatant) ?? null;
    const combatantName = combatant?.visible ? combatant.name : "???";
    const combatantInitiative = combatant?.visible === false
      ? "???"
      : typeof combatant?.initiative === "number"
        ? combatant.initiative
        : _loc("EFFECT.START.NoInitiative");
    return {...start, time, combat, combatant, combatantName, combatantInitiative};
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    if ( submitData.duration?.expiry === "" ) submitData.duration.expiry = null;
    if (!foundry.utils.isPlainObject(submitData.system?.changes) ) return submitData;
    for ( const [index, change] of Object.values(submitData.system.changes).entries() ) {
      if ( !ActiveEffect$1.CHANGE_TYPES[change.type] ) { // Ignore submit data of unregistered change types
        submitData.system.changes[index] = foundry.utils.deepClone(this.document._source.system.changes[index]);
        continue;
      }
      this._processChangeSubmission(change, index);
    }
    return submitData;
  }

  /* -------------------------------------------- */

  /**
   * Process submission data for a single change object.
   * @param {EffectChangeData} change The submitted change object with the value deserialized
   * @param {number} index            The object's index in the submitted array
   * @protected
   */
  _processChangeSubmission(change, index) {
    try {
      if ( typeof change.value === "string" ) change.value &&= JSON.parse(change.value);
    }
    catch{}
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);

    // Update the priority placeholder to match the mode selection
    if ( foundry.utils.isElementInstanceOf(event.target, "select") && event.target.name.endsWith(".type") ) {
      const typeSelect = event.target;
      const selector = `input[name="${typeSelect.name.replace(/\.type$/, ".priority")}"]`;
      const priorityInput = typeSelect.closest("li").querySelector(selector);
      priorityInput.placeholder = ActiveEffect$1.CHANGE_TYPES[typeSelect.value]?.defaultPriority ?? "";
    }
  }

  /**
   * 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.system?.changes ?? {});
    changes.push(this.document.system.schema.fields.changes.element.getInitialValue());
    return this.submit({updateData: {system: {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.system.changes);
    const row = event.target.closest("li");
    const index = Number(row.dataset.index) || 0;
    changes.splice(index, 1);
    return this.submit({updateData: {system: {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;
  }

  /**
   * Return a cached copy of a DragDrop instance, creating one on first access.
   * @type {DragDrop}
   * @protected
   */
  get _dragDrop() {
    return this.#dragDrop ??= 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)
      }
    });
  }

  /** @type {DragDrop|null} */
  #dragDrop = null;

  /* -------------------------------------------- */

  /** @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);
    this._dragDrop.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;
    const src = this.token?.texture.src ?? prototypeToken.texture.src;
    new ImagePopout({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 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 this.isEditable;
  }

  /* -------------------------------------------- */

  /**
   * An event that occurs when a drag workflow begins for a draggable Item or ActiveEffect 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;

    // If a Document reference is being dragged, assume it is either an Item or ActiveEffect
    const actor = this.document;
    const {effectId, itemId} = target.dataset;
    const item = actor.items.get(itemId);
    const effects = itemId ? item?.effects : actor.effects;
    const document = effectId ? effects?.get(effectId) : item;

    // Set data transfer
    const dragData = document?.toDragData();
    if ( dragData ) 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) {
    const actor = this.document;
    if ( !actor.isOwner || !this.isEditable ) return null;
    if ( [effect.parent, effect.parent?.parent].includes(actor) ) return null;
    const result = await ActiveEffect$1.implementation.create(effect.toObject(), {parent: actor});
    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 data = item.inCompendium ? game.items.fromCompendium(item, { clearFolder: true, keepId }) : item.toObject();
    const result = await Item$1.implementation.create(data, {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: {
      closeOnSubmit: true
    }
  };

  /** @override */
  static PARTS = {
    body: {template: "templates/adventure/importer.hbs"},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /* -------------------------------------------- */

  /**
   * The fully-loaded adventure document, once available.
   * @type {Adventure|null}
   */
  #adventure = null;

  /* -------------------------------------------- */

  /**
   * Whether the full adventure data is still loading.
   * @type {boolean}
   */
  #loading = false;

  /* -------------------------------------------- */

  /**
   * A convenience alias for AdventureImporter#document
   * @type {Adventure}
   */
  get adventure() {
    return this.#adventure ?? 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 } = this;
    const description = await TextEditor$1.implementation.enrichHTML(adventure.description, {
      secrets: adventure.isOwner
    });
    return Object.assign(context, {
      adventure,
      description,
      loading: !this.#adventure,
      contents: this.#adventure ? this._getContentList() : [],
      imported: !!game.settings.get("core", "adventureImports")?.[adventure.uuid],
      optionsSchema: this._prepareImportOptionsSchema(options),
      buttons: [{
        type: "submit",
        icon: this.#adventure ? "fa-solid fa-download" : "fa-solid fa-spinner fa-spin fa-spin-pulse",
        label: "ADVENTURE.ImportSubmit",
        disabled: !this.#adventure
      }]
    });
  }

  /* -------------------------------------------- */

  /**
   * 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: _loc(count > 1 ? cls.metadata.labelPlural : cls.metadata.label),
        count, field
      });
      return arr;
    }, []);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    if ( !this.#adventure && !this.#loading ) this.#loadDocument();
  }

  /* -------------------------------------------- */

  /**
   * Load the full adventure document from its compendium pack.
   */
  async #loadDocument() {
    const { compendium } = this.document;
    if ( !compendium ) return;

    // Check if the full document is already cached in the compendium.
    let doc = compendium.get(this.document.id);
    if ( !(doc instanceof foundry.abstract.Document) ) {
      // Otherwise load asynchronously.
      this.#loading = true;
      doc = await compendium.getDocument(this.document.id);
      this.#loading = false;
      // Hydrate the shallow document as well in case downstream is referencing that.
      this.document.updateSource(doc.toObject());
    }

    this.#adventure = doc;
    doc._sheet = this;
    this.render();
  }

  /* -------------------------------------------- */
  /*  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) {
    for ( const button of this.element.querySelectorAll("button[type=submit]") ) button.disabled = true;
    await this.adventure.import(submitData);
    return {};
  }
}

/**
 * @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] = _loc(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"}];
        break;
      case "tabs":
        partContext.tabClasses = "top-tabs";
        break;
    }
    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 = _loc("COMMON.AreYouSure");
    const warning = _loc("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];
    else if ( partId === "tabs" ) partContext.tabClasses = "top-tabs";
    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"
    },
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /* -------------------------------------------- */

  /** @override */
  get title() {
    const combatant = this.document;
    const key = combatant.collection?.has(combatant.id) ? "COMBATANT.UpdateNamed" : "COMBATANT.ACTIONS.Create";
    return _loc(key, {name: combatant.name});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const combatant = context.document;
    const submitLabel = combatant.collection?.has(combatant.id)
      ? "COMBATANT.ACTIONS.Update"
      : "COMBATANT.ACTIONS.Create";
    context.buttons = [{type: "submit", icon: "fa-solid fa-floppy-disk", label: submitLabel}];
    return context;
  }
}

/**
 * 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,
    canImport: false,
    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 = `${_loc("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 ) {
      const updated = await folder.update(submitData, options);
      return updated ? {updated} : {};
    }
    let result;
    try {
      result = await super._processSubmitData(event, form, submitData, options);
    } catch(err) {
      this.options.resolve?.(null);
      throw err;
    }
    this.options.resolve?.(this.document);
    return result;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    this.options.resolve?.(null);
  }
}

/**
 * @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;
  }

  /**
   * Return a cached copy of a DragDrop instance, creating one on first access.
   * @type {DragDrop}
   * @protected
   */
  get _dragDrop() {
    return this.#dragDrop ??= 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)
      }
    });
  }

  /** @type {DragDrop|null} */
  #dragDrop = null;

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this._dragDrop.bind(this.element);
  }

  /* -------------------------------------------- */
  /*  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 this.isEditable;
  }

  /* -------------------------------------------- */

  /**
   * An event that occurs when a drag workflow begins for a draggable ActiveEffect 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;
    const item = this.document;
    const effectId = target.dataset.effectId;
    const effect = item.effects.get(effectId);
    const dragData = effect?.toDragData();
    if ( dragData ) 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 allowed = Hooks.call("dropItemSheetData", this.document, 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 ItemSheet
   * @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) {
    return document.documentName === "ActiveEffect"
      ? (await this._onDropActiveEffect(event, document)) ?? null
      : null;
  }

  /* -------------------------------------------- */

  /**
   * Handle a dropped Active Effect on the Item Sheet.
   * The default implementation creates an Active Effect embedded document on the Item.
   * @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) {
    const item = this.document;
    if ( !this.isEditable || !item.isOwner || (item === effect.parent) ) return null;
    const result = await ActiveEffect$1.implementation.create(effect.toObject(), {parent: item});
    return result ?? null;
  }
}

/**
 * The Application responsible for configuring a single Level document.
 * @extends {DocumentSheetV2}
 * @mixes HandlebarsApplication
 */
class LevelConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["level-config"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-layer-group"
    },
    position: { width: 560 },
    form: { closeOnSubmit: true }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    body: { template: "templates/scene/level/body.hbs", scrollable: [""] },
    footer: { template: "templates/generic/form-footer.hbs" }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.gridUnits = this.document.parent?.grid.units || _loc("MEASUREMENT.GridUnits");
    context.buttons = [{ type: "submit", icon: "fa-solid fa-floppy-disk", label: "SETTINGS.Save" }];
    context.otherLevels = this.document.parent?.levels.reverseSorted
      .filter(l => l !== this.document)
      .map(l => {
        const { id, name } = l;
        return { label: name, value: id };
      });
    context.textureFitModes = CONST.TEXTURE_DATA_FIT_MODES.reduce((obj, fit) => {
      obj[fit] = _loc(`TEXTURE_DATA.FIT.${fit}`);
      return obj;
    }, {});
    return context;
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareSubmitData(event, form, formData, updateData) {
    const submitData = super._prepareSubmitData(event, form, formData, updateData);
    submitData.background ||= null;
    submitData.foreground ||= null;
    submitData.fog ||= null;
    return submitData;
  }
}

/**
 * @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" : "html";
    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" : "html";

    // 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);
  }
}

/**
 * @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] = _loc(`PLAYLIST.Mode${key.titleCase()}`);
        return modes;
      }, {}),
      sortModes: Object.entries(CONST.PLAYLIST_SORT_MODES).reduce((sortings, [key, value]) => {
        sortings[value] = _loc(`PLAYLIST.Sort${key.titleCase()}`);
        return sortings;
      }, {}),
      channels: Object.entries(CONST.AUDIO_CHANNELS).reduce((channels, [value, locPath]) => {
        channels[value] = _loc(locPath);
        return channels;
      }, {}),
      milliseconds: _loc("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);
    const isCreated = !!this.document.collection?.has(this.document.id);
    const submitText = isCreated ? "PLAYLIST_SOUND.Update" : "PLAYLIST_SOUND.Create";
    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] = _loc(value);
        return channels;
      }, {}),
      defaultChannel: _loc("PLAYLIST.DefaultChannel"),
      milliseconds: _loc("TIME.Millisecond.abbr"),
      buttons: [{type: "submit", icon: "fa-solid fa-floppy-disk", label: submitText}]
    });
  }

  /* -------------------------------------------- */

  /** @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"],
    viewPermission: DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
    window: {
      contentClasses: ["standard-form"],
      icon: undefined // Defined in constructor
    },
    position: {width: 640},
    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(),
      hint: CONFIG.RegionBehavior.typeHints[context.document.type],
      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 {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: _loc(`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 "tabs":
        context.tabClasses = "top-tabs";
        break;
      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 = results.size ? context.document.formula : "1d20";
        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";
 */

/**
 * @deprecated since v14
 * @ignore
 */
class MeasuredTemplateConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {
  constructor(...args) {
    const msg = "MeasuredTemplateConfig is deprecated because the MeasuredTemplate document has been merged into the functionality of the Region document.";
    foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
    super(...args);
  }

  /* -------------------------------------------- */

  /** @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 = {circle: "Circle", cone: "Cone", rect: "Rectangle", ray: "Ray"};
    const units = {
      degrees: _loc("MEASUREMENT.Degrees"),
      gridUnits: document.parent.grid.units || _loc("MEASUREMENT.GridUnits"),
      pixels: _loc("MEASUREMENT.Pixels")
    };
    const submitText = document.collection?.has(document.id) ? "REGION.ACTIONS.update" : "REGION.ACTIONS.create";
    return Object.assign(context, {
      templateTypes,
      units,
      userColor: game.user.color,
      buttons: [{type: "submit", icon: "fa-solid fa-floppy-disk", label: submitText}]
    });
  }
}

/**
 * @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: {
        overrideDetectionMode: TokenApplication.#onOverrideDetectionMode,
        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] = _loc(`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] = _loc(`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] = _loc(`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] = _loc(`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;
      }, {});
    }

    /* -------------------------------------------- */

    /**
     * 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: _loc(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 _prepareContext(options) {
      const context = await super._prepareContext(options);
      return Object.assign(context, {
        tabClasses: "top-tabs",
        fields: this._fields,
        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._source.movementAction;
      const defaultAction = this.isPrototype ? undefined
        : (!currentAction ? this.token.movementAction : this.token._inferMovementAction());
      const defaultLabel = _loc("COMMON.Default");
      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)),
        defaultMovementActionLabel: defaultAction
          ? `${defaultLabel} (${_loc(CONFIG.Token.movement.actions[defaultAction].label)})`
          : defaultLabel,
        movementActions: Object.entries(CONFIG.Token.movement.actions).reduce(
          (choices, [action, {label, canSelect}]) => {
            if ( canSelect(this.token) || (action === currentAction) ) choices[action] = _loc(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] = _loc(`TEXTURE_DATA.FIT.${fit}`);
          return obj;
        }, {}),
        ringEffectsInput: this.#ringEffectsInput.bind(this),
        usingSubject: source.ring.enabled && source.ring.subject.texture
      };
    }

    /* -------------------------------------------- */

    /**
     * Prepare data to be displayed in the Vision tab.
     * @returns {Promise<object>}
     * @protected
     */
    async _prepareVisionTab() {
      const token = this.token;
      const visionModes = Object.values(CONFIG.Canvas.visionModes)
        .filter(f => f.tokenConfig)
        .reduce((modes, mode) => {
          modes[mode.id] = mode.label;
          return modes;
        }, {});
      const detectionModes = [];
      for ( let {id, label, tokenConfig} of Object.values(CONFIG.Canvas.detectionModes) ) {
        if ( !tokenConfig ) continue;
        label = _loc(label);
        const source = id in token._source.detectionModes;
        let mode = source ? token._source.detectionModes[id] : token.detectionModes[id];
        if ( !mode ) mode = {range: 0, enabled: false};
        let {range, ...rest} = mode;
        if ( range === Infinity ) range = null;
        detectionModes.push({id, range, label, source, ...rest});
      }
      detectionModes.sort((mode1, mode2) => mode1.label.localeCompare(mode2.label, game.i18n.lang));
      return {
        sightFields: this._fields.sight.fields,
        visionModes,
        detectionModes
      };
    }

    /* -------------------------------------------- */

    /**
     * Prepare data to be displayed in the Vision tab.
     * @returns {Promise<object>}
     * @protected
     */
    async _prepareLightTab() {
      return {
        lightFields: this._fields.light.fields,
        lightAnimations: this.token.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"}];
    }

    /* -------------------------------------------- */
    /*  Form Submission                             */
    /* -------------------------------------------- */

    /** @inheritDoc */
    _processFormData(event, form, formData) {
      const submitData = super._processFormData(event, form, formData);
      this._processChanges(submitData);
      return submitData;
    }

    /* -------------------------------------------- */
    /*  Event Listeners and Handlers                */
    /* -------------------------------------------- */

    /** @inheritDoc */
    _onChangeForm(formConfig, event) {
      super._onChangeForm(formConfig, event);
      const target = event.target;

      // Pre-populate vision mode defaults
      if ( 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 ( target?.name === "alternateImages" ) {
        event.target.form.elements["texture.src"].value = event.target.value;
      }

      // Show or hide the warning that the base texture is overridden by the ring subject texture.
      else if ( (target?.name === "ring.subject.texture") || (target?.name === "ring.enabled") ) {
        const usingSubject = this.form.elements["ring.enabled"]?.checked
          && this.form.elements["ring.subject.texture"]?.value;
        this.form.querySelector(".texture-src-warning")?.toggleAttribute("hidden", !usingSubject);
      }
    }

    /* -------------------------------------------- */

    /**
     * Add a new detection mode to the Token preview.
     * @this {TokenApplication}
     * @type {ApplicationClickAction}
     */
    static async #onOverrideDetectionMode(_event, target) {
      const id = target.closest("[data-id]").dataset.id;
      const formData = new FormDataExtended(this.form);
      const modes = this._processFormData(event, this.form, formData).detectionModes;
      modes[id] = {range: null, enabled: true};
      this._previewChanges({detectionModes: modes});
      await this.render({parts: ["vision"]});
    }

    /* -------------------------------------------- */

    /**
     * Remove a detection mode from the Token preview.
     * @this {TokenApplication}
     * @type {ApplicationClickAction}
     */
    static async #onRemoveDetectionMode(_event, target) {
      const id = target.closest("[data-id]").dataset.id;
      const formData = new FormDataExtended(this.form);
      const modes = this._processFormData(event, this.form, formData).detectionModes;
      modes[id] = _del;
      this._previewChanges({detectionModes: modes});
      await this.render({parts: ["vision"]});
    }

    /* -------------------------------------------- */
    /*  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;
      }

      // Process detection modes
      submitData.detectionModes ??= {};
      for ( const [id, {tokenConfig}] of Object.entries(CONFIG.Canvas.detectionModes) ) {
        if ( tokenConfig ) submitData.detectionModes[id] ??= _del;
      }
    }
  }
  return TokenApplication;
}

/**
 * @import {ApplicationClickAction, ApplicationFormSubmission} from "../../_types.mjs";
 * @import DocumentSheetV2 from "../../api/document-sheet.mjs";
 * @import Actor from "@client/documents/actor.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,
      cycleImage: PrototypeTokenConfig.#onCycleImage,
      openOverridesConfig: PrototypeTokenConfig.#onOpenOverridesConfig
    },
    form: {
      handler: PrototypeTokenConfig.#onSubmit
    }
  };

  /* -------------------------------------------- */

  /**
   * The prototype token being edited
   * @type {PrototypeToken}
   */
  #prototype;

  /**
   * The index of the currently-previewed wildcard image.
   * @type {number}
   */
  #previewImageIndex = 0;

  /**
   * Debounced render of the appearance tab, used for live texture path updates.
   * @type {function}
   */
  #renderAppearance = foundry.utils.debounce(() => this.render({ parts: ["appearance"] }), 300);

  /** @override */
  isPrototype = true;

  /** @override */
  get title() {
    return `${_loc("TOKEN.TitlePrototype")}: ${this.actor.name}`;
  }

  /** @override */
  get token() {
    return this._preview ?? this.#prototype;
  }

  /**
   * The preview.
   * @type {PrototypeToken|null}
   * @protected
   */
  _preview = null;

  /**
   * @type {Actor}
   * @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(_loc("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;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    if ( options.isFirstRender ) this._preview = this.#prototype.clone();
    const context = await super._prepareContext(options);
    const document = this._preview;
    return Object.assign(context, {
      document,
      model: document,
      source: document._source,
      rootId: this.id,
      gridUnits: _loc("MEASUREMENT.GridUnits")
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareAppearanceTab() {
    const context = await super._prepareAppearanceTab();
    const source = this.token._source;
    if ( source.randomImg ) {
      const images = await PrototypeTokenConfig.#getWildcardImages(source.texture.src);
      if ( images.length ) {
        this.#previewImageIndex = Math.clamp(this.#previewImageIndex, 0, images.length - 1);
        const src = images[this.#previewImageIndex];
        context.imagePreview = {
          src,
          cls: "token-image-preview wildcard",
          isVideo: foundry.helpers.media.VideoHelper.hasVideoExtension(src),
          hasPrev: this.#previewImageIndex > 0,
          hasNext: this.#previewImageIndex < (images.length - 1),
          current: this.#previewImageIndex + 1,
          total: images.length
        };
      }
    } else if ( source.texture.src ) {
      const { src } = source.texture;
      context.imagePreview = {
        src, cls: "token-image-preview", isVideo: foundry.helpers.media.VideoHelper.hasVideoExtension(src)
      };
    }
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    const name = event.target.name || event.target.closest("[name]")?.name;
    const formData = new FormDataExtended(this.form);
    const submitData = this._processFormData(event, this.form, formData);
    this._previewChanges(submitData);

    // Re-render the appearance tab when wildcard mode is toggled.
    if ( name === "randomImg" ) this.render({ parts: ["appearance"] });

    // Debounced re-render when the image path changes to update the preview thumbnail.
    else if ( name === "texture.src" ) this.#renderAppearance();
  }

  /* -------------------------------------------- */

  /** @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;
    game.settings.get("core", "prototypeTokenOverrides", {document: true}).apps[this.id] = this;
  }

  /* -------------------------------------------- */

  /**
   * Create a warning button informing the user why a field is disabled.
   * @returns {HTMLButtonElement}
   */
  #createOverridesWarning() {
    if ( this.#overridesWarning ) return this.#overridesWarning.cloneNode();
    const button = document.createElement("button");
    button.type = "button";
    button.className = "overrides-warning inline-control icon fa-solid fa-warning";
    button.dataset.action = "openOverridesConfig";
    button.dataset.tooltip = "";
    button.ariaLabel = _loc("TOKEN.PROTOTYPE.OverridesWarning");
    return (this.#overridesWarning = button);
  }

  /**
   * @type {HTMLButtonElement}
   */
  #overridesWarning;

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    // Disable fields with PrototypeToken overrides applied
    let overrides = {};
    PrototypeTokenOverrides.applyOverrides(overrides, this.actor.type);
    if ( foundry.utils.isEmpty(overrides) ) return;
    overrides = foundry.utils.flattenObject(overrides);
    for ( const [path, value] of Object.entries(overrides) ) {
      if ( value === undefined ) continue;
      const input = this.form.elements[path];
      input.disabled = true;
      const formGroup = input.closest(".form-group");
      formGroup.classList.add("overridden");
      const label = formGroup.querySelector(":scope > label, :scope > span.label");
      label.append(this.#createOverridesWarning());
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    this._preview = null;
  }

  /* -------------------------------------------- */

  /**
   * Preview changes.
   * @param {object} changes  The changes to preview.
   * @protected
   */
  _previewChanges(changes) {
    this._preview.updateSource(changes);
  }

  /* -------------------------------------------- */
  /*  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}});
  }

  /* -------------------------------------------- */

  /**
   * Cycle through available wildcard images for the token image preview.
   * @this {PrototypeTokenConfig}
   * @type {ApplicationClickAction}
   */
  static async #onCycleImage(event, target) {
    const images = await PrototypeTokenConfig.#getWildcardImages(this.token._source.texture.src);
    this.#previewImageIndex = Math.clamp(this.#previewImageIndex + Number(target.dataset.delta), 0, images.length - 1);
    await this.render({ parts: ["appearance"] });
  }

  /* -------------------------------------------- */

  /**
   * Open the {@link PrototypeOverridesConfig} application to allow the user to review what overrides are causing some
   * fields in this sheet to be disabled.
   * @this {PrototypeTokenConfig}
   * @type {ApplicationClickAction}
   */
  static async #onOpenOverridesConfig() {
    const existing = foundry.applications.instances.get("prototype-token-overrides");
    if ( existing?.rendered ) existing.bringToFront();
    else new PrototypeOverridesConfig().render({force: true});
  }

  /* -------------------------------------------- */
  /*  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) {
    const submitData = foundry.utils.expandObject(formData.object);
    this._processChanges(submitData);
    return submitData;
  }

  /* -------------------------------------------- */

  /**
   * Process form submission for the sheet
   * @this {PrototypeTokenConfig}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmit(event, form, formData) {
    const submitData = this._processFormData(event, form, formData);
    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.actor.apps[this.id];
    delete game.settings.get("core", "prototypeTokenOverrides", {document: true}).apps[this.id];
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Browse for images matching a wildcard texture path.
   * @param {string} src    The wildcard-eligible texture path.
   * @returns {Promise<string[]>}
   */
  static async #getWildcardImages(src) {
    if ( !src ) return [];
    const source = CONFIG.ux.FilePicker.matchS3URL(src) ? "s3" : "data";
    try {
      const result = await CONFIG.ux.FilePicker.browse(source, src, { wildcard: true });
      return result.files ?? [];
    } catch {
      return [];
    }
  }
}

/**
 * The Application responsible for configuring a single token document within a parent Scene
 * @extends PlaceableConfig
 * @mixes TokenApplication
 */
class TokenConfig extends TokenApplicationMixin(PlaceableConfig) {

  /** @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");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareAppearanceTab(options) {
    const context = await super._prepareAppearanceTab(options);
    const gridType = this.token.parent?.grid.type;
    const S = CONST.TOKEN_SHAPES;
    if ( gridType === CONST.GRID_TYPES.SQUARE ) {
      if ( this.token.shape === CONST.TOKEN_SHAPES.RECTANGLE_1 ) {
        context.shapes = {
          [CONST.TOKEN_SHAPES.RECTANGLE_1]: _loc("TOKEN.SHAPES.RECTANGLE.label")
        };
      }
    }
    else if ( gridType === CONST.GRID_TYPES.GRIDLESS ) {
      if ( [S.ELLIPSE_1, S.RECTANGLE_1].includes(this.token.shape) ) {
        context.shapes = {
          [S.ELLIPSE_1]: _loc("TOKEN.SHAPES.ELLIPSE.label"),
          [S.RECTANGLE_1]: _loc("TOKEN.SHAPES.RECTANGLE.label")
        };
      }
    }
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _previewChanges(changes) {
    const priorTextureSrc = this.document.texture.src;
    const priorRingEnabled = this.document.ring.enabled;
    const priorRingColors = this.document.ring.colors;
    const priorRingEffects = this.document.ring.effects;
    changes = {...changes};
    delete changes.actorId;
    delete changes.actorLink;
    super._previewChanges(changes);
    if ( !this._preview.rendered ) return;
    const redraw = (this._preview.texture.src !== priorTextureSrc)
      || (this._preview.ring.enabled !== priorRingEnabled) || (priorRingEnabled
        && ((this._preview.ring.colors !== priorRingColors)
        || !foundry.utils.equals(this._preview.ring.effects, priorRingEffects)));
    this._preview.object.renderFlags.set({redraw: redraw});
    this._preview.object.initializeSources();
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    this.#toggleTurnMarkerDisabledFields();
    return super._onRender(context, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    if ( ["bar1", "bar2"].includes(event.target.name) ) this._onChangeBar(event);

    // Turn Marker Disabled/Enabled
    if ( event.target.name === "turnMarker.mode" ) this.#toggleTurnMarkerDisabledFields();

    super._onChangeForm(formConfig, event);

    // 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 : "";
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @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];
      }
    }
    const result = await super._processSubmitData(event, form, submitData, options);
    if ( foundry.utils.isEmpty(waypoint) ) return result;
    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}});
    return result;
  }
}

/**
 * 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 `${_loc("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 = _loc("OWNERSHIP.OWNER");
    const observerGroup = _loc("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"});
  }
}

/**
 * @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;
    context.tabClasses = "top-tabs";
    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: _loc(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
          });
          doc.constructor._clearFieldsRecursively(data, ["_stats.duplicateSource", "_stats.exportSource"]);
        }

        // 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 ) {
      ui.notifications.error("ADVENTURE.ExportPackNoSystem", {localize: true, permanent: true});
      return {};
    }

    // 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);
    const result = 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}});
    return result;
  }

  /* -------------------------------------------- */

  /** @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,
    Level: LevelConfig,
    /**
     * @deprecated since v14
     * @ignore
     */
    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];
    if ( !cfg ) throw new Error(`Missing required CONFIG.${documentName} entry.`);
    cfg.sheetClasses = {};
    const defaultSheet = defaultSheets[documentName];
    if ( !defaultSheet ) continue;
    DocumentSheetConfig.registerSheet(cfg.documentClass, "core", defaultSheet, {
      makeDefault: true,
      label: () => _loc("SHEETS.DefaultDocumentSheet", {
        document: _loc(`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", JournalEntryPageImageSheet, {
    types: ["image"],
    makeDefault: true,
    label: () => {
      return _loc("JOURNALENTRYPAGE.DefaultPageSheet", {
        page: _loc("TYPES.JournalEntryPage.image")
      });
    }
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage$1, "core", JournalEntryPageVideoSheet, {
    types: ["video"],
    makeDefault: true,
    label: () => {
      return _loc("JOURNALENTRYPAGE.DefaultPageSheet", {
        page: _loc("TYPES.JournalEntryPage.video")
      });
    }
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage$1, "core", JournalEntryPagePDFSheet, {
    types: ["pdf"],
    makeDefault: true,
    label: () => {
      return _loc("JOURNALENTRYPAGE.DefaultPageSheet", {
        page: _loc("TYPES.JournalEntryPage.pdf")
      });
    }
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage$1, "core", JournalEntryPageProseMirrorSheet, {
    types: ["text"],
    makeDefault: true,
    label: () => {
      return _loc("JOURNALENTRYPAGE.DefaultPageSheet", {
        page: _loc("TYPES.JournalEntryPage.text")
      });
    }
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage$1, "core", JournalEntryPageMarkdownSheet, {
    types: ["text"],
    label: () => _loc("EDITOR.Markdown")
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage$1, "core", JournalEntryPageHTMLSheet, {
    types: ["text"],
    label: () => _loc("EDITOR.HTML")
  });
}

var _module$I = /*#__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,
  LevelConfig: LevelConfig,
  MacroConfig: MacroConfig,
  MeasuredTemplateConfig: MeasuredTemplateConfig,
  NoteConfig: NoteConfig,
  PlaceableConfig: PlaceableConfig,
  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$L,
  palette: _module$J
});

/**
 * @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 = {
    id: "{id}",
    tag: "section",
    classes: ["tab", "sidebar-tab"],
    window: {
      frame: false,
      positioned: false
    }
  };

  /** @override */
  static emittedEvents = Object.freeze([...super.emittedEvents, "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) {
    const applicationOptions = super._initializeApplicationOptions(options);
    applicationOptions.uniqueId = this.tabName;
    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,
        controls: []
      }
    }, { 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 {
 *   ApplicationClosingOptions, 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,
      messageMode: this.#onChangeMessageMode
    }
  };

  /** @override */
  static tabName = "chat";

  /** @override */
  static PARTS = {
    log: {
      template: "templates/sidebar/tabs/chat/log.hbs",
      templates: ["templates/sidebar/tabs/chat/notifications.hbs"]
    },
    input: {
      template: "templates/sidebar/tabs/chat/input.hbs"
    }
  };

  /**
   * @callback ChatCommandCallback
   * Called in the context of a {@link ChatLog} instance.
   * @param {string} command                                      The matched command name.
   * @param {RegExpMatchArray|RegExpMatchArray[]|string[]} match  The regex match result.
   * @param {object} chatData                                     Chat message data.
   * @param {object} createOptions                                Options passed to ChatMessage.create.
   * @returns {Promise<false|void>}                               Return false to prevent message creation.
   */

  /**
   * @typedef ChatCommandPattern
   * @property {RegExp} rgx               The regular expression pattern used to match this command.
   * @property {ChatCommandCallback} fn   The processing function invoked when this command is matched.
   * @property {keyof CONFIG.ChatMessage.modes} [mode]  A chat message mode to enforce for this command. Otherwise,
   *                                      the default message mode is applied.
   * @property {boolean} [isRoll=false]   Is this command related to rolling dice?
   * @property {boolean} [isMultiline=false] Can this command be processed over multiple lines?
   */

  /**
   * A registry of chat commands with associated regular expression patterns and processing functions.
   * @type {Object<string, ChatCommandPattern>}
   */
  static CHAT_COMMANDS = (() => {
    const dice = "([^#]+)(?:#(.*))?";       // Dice expression with appended flavor text
    const any = "([^]*)";                   // Any character, including new lines
    const diceCommand = async function(command, match, chatData, createOptions) {
      await this.#processDiceCommand(command, match, chatData, createOptions);
    };
    const chatCommand = function(command, match, chatData, createOptions) {
      this.#processChatCommand(command, match, chatData, createOptions);
    };
    const whisperCommand = function(command, match, chatData) {
      this.#processWhisperCommand(command, match, chatData);
    };
    const macroCommand = function(command, match) {
      this.#processMacroCommand(match);
      return false; // Prevent message creation
    };
    const diceCmd = {fn: diceCommand, isRoll: true, isMultiline: true};
    return {
      roll: {rgx: new RegExp(`^(\\/r(?:oll)? )${dice}$`, "i"), ...diceCmd},
      gmroll: {rgx: new RegExp(`^(\\/gmr(?:oll)? )${dice}$`, "i"), mode: "gm", ...diceCmd},
      blindroll: {rgx: new RegExp(`^(\\/b(?:lind)?r(?:oll)? )${dice}$`, "i"), mode: "blind", ...diceCmd},
      selfroll: {rgx: new RegExp(`^(\\/s(?:elf)?r(?:oll)? )${dice}$`, "i"), mode: "self", ...diceCmd},
      publicroll: {rgx: new RegExp(`^(\\/p(?:ublic)?r(?:oll)? )${dice}$`, "i"), mode: "public", ...diceCmd},
      ooc: {rgx: new RegExp(`^(/ooc )${any}`, "i"), fn: chatCommand},
      ic: {rgx: new RegExp(`^(/ic )${any}`, "i"), mode: "ic", fn: chatCommand},
      emote: {rgx: new RegExp(`^(/(?:em(?:ote)?|me) )${any}`, "i"), mode: "ic", fn: chatCommand},
      gm: {rgx: new RegExp(`^(/gm )${any}`, "i"), fn: whisperCommand},
      whisper: {rgx: new RegExp(/^(\/w(?:hisper)?\s)(\[[^\]]+]|\S+)\s*([^]*)/, "i"), fn: whisperCommand},
      reply: {rgx: new RegExp(`^(/reply )${any}`, "i"), fn: whisperCommand},
      players: {rgx: new RegExp(`^(/players )${any}`, "i"), fn: whisperCommand},
      macro: {rgx: new RegExp(`^(\\/m(?:acro)? )${any}`, "i"), fn: macroCommand}
    };
  })();

  /**
   * Patterns registered by legacy code via writes to {@link ChatLog.MESSAGE_PATTERNS}.
   * @type {Record<string, RegExp>}
   */
  static #legacyPatterns = {};

  /**
   * The maximum number of messages to retain in the history in a given session.
   * @type {number}
   */
  static MAX_MESSAGE_HISTORY = 16;

  /**
   * The number of milliseconds to keep a chat card notification until it is automatically dismissed.
   * @type {number}
   */
  static NOTIFY_DURATION = 5000;

  /**
   * The notification ticker frequency.
   * @type {number}
   */
  static NOTIFY_TICKER = 500;

  /**
   * The number of milliseconds to wait before unpausing the notification queue.
   * @type {number}
   */
  static NOTIFY_UNPAUSE = 2000;

  /**
   * The number of milliseconds to display the chat notification pip.
   * @type {number}
   */
  static PIP_DURATION = 3000;

  /**
   * A template element used to convert messages to plain text.
   * @type {HTMLTemplateElement}
   */
  static #PLAIN_TEXT;

  /**
   * How often, in milliseconds, to update timestamps.
   * @type {number}
   */
  static UPDATE_TIMESTAMP_FREQUENCY = 1000 * 15;

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A reference to the Messages collection that the chat log displays.
   * @type {Messages}
   */
  get collection() {
    return game.messages;
  }

  /**
   * Message history management.
   * @type {{ queue: string[], index: number, pending: string }}
   */
  get history() {
    return this.#history;
  }

  #history = {
    index: -1,
    pending: "",
    queue: []
  };

  /**
   * The chat input element.
   * @type {HTMLProseMirrorElement}
   */
  #inputElement;

  /**
   * A flag for whether the chat log is currently scrolled to the bottom.
   * @type {boolean}
   */
  get isAtBottom() {
    return this.#isAtBottom;
  }

  #isAtBottom = true;

  /**
   * The jump to bottom button.
   * @type {HTMLButtonElement}
   */
  #jumpToBottomElement;

  /**
   * Track the ID of the oldest message displayed in the log.
   * @type {string|null}
   */
  #lastId;

  /**
   * Store the last whisper this user received for replying.
   * @type {ChatMessage}
   */
  #lastWhisper;

  /**
   * The chat notifications container.
   * @type {HTMLDivElement}
   */
  #notificationsElement;

  /**
   * The debounced function for returning the chat notification queue to an inactive state.
   * @type {function(): void}
   */
  #notifyAlertDebounce =
    foundry.utils.debounce(this.#onNotifyInactive.bind(this), this.constructor.NOTIFY_DURATION);

  /**
   * The debounced function for unpausing the notification queue.
   * @type {function(): void}
   */
  #notifyUnpauseDebounce =
    foundry.utils.debounce(this.#onUnpauseNotifications.bind(this), this.constructor.NOTIFY_UNPAUSE);

  /**
   * Debounced function for setting the overflowing state of the chat scrollback.
   * @type {function(): void}
   */
  #overflowingDebounce = foundry.utils.debounce(this.#setOverflowing.bind(this), 100);

  /**
   * The debounced function for hiding the notification pip.
   * @type {function(): void}
   */
  #pipHideDebounce = foundry.utils.debounce(this.#onHidePip.bind(this), this.constructor.PIP_DURATION);

  /**
   * Chat controls containing message-modes buttons and log actions
   * @type {HTMLDivElement}
   */
  #chatControls;

  /**
   * A semaphore to queue rendering of Chat Messages.
   * @type {Semaphore}
   */
  #renderingQueue = new foundry.utils.Semaphore(1);

  /**
   * Whether batch rendering is currently in-progress.
   * @type {boolean}
   */
  #renderingBatch = false;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    // If the log has already been rendered once, prevent it from being re-rendered.
    if ( this.rendered ) options.parts = options.parts.filter(p => (p !== "log") && (p !== "input"));
  }

  /* -------------------------------------------- */

  /**
   * Render a batch of additional messages, prepending them to the top of the log.
   * @param {number} size  The batch size.
   * @returns {Promise<void>}
   */
  async #doRenderBatch(size) {
    if ( !this.rendered ) {
      this.#renderingBatch = false;
      return;
    }

    const messages = game.messages.contents;
    const log = this.element.querySelector(".chat-log");

    // Get the index of the last rendered chat message
    let lastIdx = messages.findIndex(m => m.id === this.#lastId);
    lastIdx = lastIdx > -1 ? lastIdx : messages.length;
    if ( !lastIdx ) {
      this.#renderingBatch = false;
      return;
    }

    // Get the next batch to render
    const targetIdx = Math.max(lastIdx - size, 0);
    const elements = [];
    for ( let i = targetIdx; i < lastIdx; i++ ) {
      const message = messages[i];
      if ( !message.visible ) continue;
      message.logged = true;
      try {
        elements.push(await this.constructor.renderMessage(message));
      } catch(err) {
        Hooks$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 [{
      label: "CHAT.PopoutMessage",
      icon: "fa-solid fa-up-right-from-square fa-rotate-180",
      visible: li => {
        const message = game.messages.get(li.dataset.messageId);
        return message.getFlag("core", "canPopout") === true;
      },
      onClick: (event, li) => {
        const message = game.messages.get(li.dataset.messageId);
        new CONFIG.ChatMessage.popoutClass({message}).render({force: true});
      }
    }, {
      label: "CHAT.RevealMessage",
      icon: "fa-solid fa-eye",
      visible: li => {
        const message = game.messages.get(li.dataset.messageId);
        const isLimited = message.whisper.length || message.blind;
        return isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
      },
      onClick: (event, li) => {
        const message = game.messages.get(li.dataset.messageId);
        return message.update({whisper: [], blind: false});
      }
    }, {
      label: "CHAT.ConcealMessage",
      icon: "fa-solid fa-eye-slash",
      visible: li => {
        const message = game.messages.get(li.dataset.messageId);
        const isLimited = message.whisper.length || message.blind;
        return !isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
      },
      onClick: (event, li) => {
        const message = game.messages.get(li.dataset.messageId);
        return message.update({whisper: ChatMessage$1.getWhisperRecipients("gm").map(u => u.id), blind: false});
      }
    }, {
      label: "SIDEBAR.Delete",
      icon: "fa-solid fa-trash",
      visible: li => {
        const message = game.messages.get(li.dataset.messageId);
        return message.canUserModify(game.user, "delete");
      },
      onClick: (event, li) => {
        const message = game.messages.get(li.dataset.messageId);
        return message ? message.delete() : this.deleteMessage(li.dataset.messageId);
      }
    }];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    if ( this.isPopout ) ui.chat._toggleNotifications(options);
    else {
      const { chatBackground } = game.settings.get("core", "uiConfig");
      this.element.classList.toggle("with-background", chatBackground);
      setInterval(this.updateTimestamps.bind(this), this.constructor.UPDATE_TIMESTAMP_FREQUENCY);
      await this.#renderNotifications();
    }
    await this.renderBatch(CONFIG.ChatMessage.batchSize);
    /** @fires {hookEvents:getChatMessageContextOptions} */
    this._createContextMenu(this._getEntryContextOptions, ".message[data-message-id]", {
      hookName: "getChatMessageContextOptions",
      parentClassHooks: false,
      fixed: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this._toggleNotifications(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _postRender(context, options) {
    await super._postRender(context, options);
    if ( options.isFirstRender ) await this.scrollBottom({waitImages: true});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "input": await this._prepareInputContext(context, options); break;
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the chat panel's message input component.
   * @param {ApplicationRenderContext} context
   * @param {ApplicationRenderOptions} options
   * @protected
   */
  async _prepareInputContext(context, options) {
    context.isAtBottom = this.isAtBottom;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderHTML(context, options) {
    const parts = await super._renderHTML(context, options);
    if ( parts.input ) this.#jumpToBottomElement = parts.input.querySelector(".jump-to-bottom");
    return parts;
  }


  /* -------------------------------------------- */

  /**
   * Render chat notifications framework.
   * @returns {Promise<void>}
   */
  async #renderNotifications() {
    const right = document.getElementById("ui-right-column-1") ?? document.body;
    const mode = game.settings.get("core", "messageMode");
    const messageModes = Object.entries(CONFIG.ChatMessage.modes).map(([action, {label, icon}]) => {
      return {icon, label, action, active: action === mode};
    });

    const html = await renderTemplate("templates/sidebar/tabs/chat/notifications.hbs", {user: game.user, messageModes});
    [this.#notificationsElement, this.#inputElement, this.#chatControls] = foundry.utils.parseHTML(html);
    this.#notificationsElement.addEventListener("click", this._onClickNotification.bind(this));
    this.#inputElement.addEventListener("plugins", this._onConfigurePlugins.bind(this));

    right.append(this.#notificationsElement);
    this.#notificationsElement.append(this.#inputElement, this.#chatControls);
    window.addEventListener("blur", this.#onBlurWindow.bind(this));
    setInterval(this.#checkNotificationLifeSpan.bind(this), this.constructor.NOTIFY_TICKER);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preSyncPartState(partId, newElement, priorElement, state) {
    super._preSyncPartState(partId, newElement, priorElement, state);
    switch ( partId ) {
      case "input": this._preSyncInputState(newElement, priorElement, state); break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare data used to synchronize the state of the chat input.
   * @param {HTMLElement} newElement    The newly-rendered element.
   * @param {HTMLElement} priorElement  The existing element.
   * @param {object} state              A state object which is used to synchronize after replacement.
   * @protected
   */
  _preSyncInputState(newElement, priorElement, state) {
    const textarea = priorElement.querySelector(".chat-input");
    state.message = textarea?.value;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _syncPartState(partId, newElement, priorElement, state) {
    super._syncPartState(partId, newElement, priorElement, state);
    switch ( partId ) {
      case "input": this._syncInputState(newElement, priorElement, state); break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Synchronize the state of the chat input.
   * @param {HTMLElement} newElement    The newly-rendered element.
   * @param {HTMLElement} priorElement  The element being replaced.
   * @param {object} state              The state object used to synchronize the pre- and post-render states.
   * @protected
   */
  _syncInputState(newElement, priorElement, state) {
    const textarea = newElement.querySelector(".chat-input");
    if ( textarea ) textarea.value = state.message;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachPartListeners(partId, element, options) {
    super._attachPartListeners(partId, element, options);
    switch ( partId ) {
      case "log": this._attachLogListeners(element, options); break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Attach listeners to the chat log.
   * @param {HTMLElement} element  The log element.
   * @param {ApplicationRenderOptions} options
   * @protected
   */
  _attachLogListeners(element, options) {
    element.addEventListener("scroll", this.#onScrollLog.bind(this), {passive: true});
    element.addEventListener("change", this.#onChangeLog.bind(this));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onActivate() {
    super._onActivate();
    this._toggleNotifications();
  }

  /* -------------------------------------------- */

  /**
   * Handle unpausing the notification queue when the window loses focus.
   */
  #onBlurWindow() {
    this.#notificationsElement.querySelectorAll(".message.hovered").forEach(el => el.classList.remove("hovered"));
    this.#notifyUnpauseDebounce();
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the message mode.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onChangeMessageMode(event) {
    const mode = event.target.dataset.mode;
    game.settings.set("core", "messageMode", mode);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking a chat card notification.
   * Treat action button clicks within the Notifications UI as action clicks on the ChatLog instance itself.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onClickNotification(event) {
    const target = event.target.closest("[data-action]");
    if ( !target ) return;
    const {action} = target.dataset;
    let handler = this.options.actions[action];
    let buttons = [0];
    if ( typeof handler === "object" ) {
      buttons = handler.buttons;
      handler = handler.handler;
    }
    if ( buttons.includes(event.button) ) handler?.call(this, event, target);
    else this._onClickAction(event, target);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    this.#lastId = null;
  }

  /* -------------------------------------------- */

  /**
   * Configure chat-specific plugins.
   * @param {ProseMirrorPluginsEvent} event  The plugin configuration event.
   * @protected
   */
  _onConfigurePlugins(event) {
    const {defaultSchema, plugins} = foundry.prosemirror;
    if ( event.target.closest("#chat-message") ) {
      Object.assign(event.plugins, {
        chatInput: plugins.chat.ChatInputPlugin.build(defaultSchema, {chat: this}),
        keyMaps: plugins.ProseMirrorKeyMaps.build(defaultSchema, {joinDoubleBreak: true}),
        menu: plugins.chat.ChatMenuPlugin.build(defaultSchema)
      });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDeactivate() {
    super._onDeactivate();
    this._toggleNotifications();
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting a single message from the log.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static async #onDeleteMessage(event) {
    event.preventDefault();
    const {messageId} = event.target.closest("[data-message-id]")?.dataset ?? {};
    const message = game.messages.get(messageId);
    if ( message ) await message.delete();
    else await this.deleteMessage(messageId);
  }

  /* -------------------------------------------- */

  /**
   * Handle dismissing a chat card notification.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static async #onDismissNotification(_event, target) {
    const message = target.closest(".chat-message");
    await this.#dismissNotification(message);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the expanded state of a roll breakdown.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onExpandRoll(event, target) {
    event.preventDefault();
    target.classList.toggle("expanded");
  }

  /* -------------------------------------------- */

  /**
   * Handle exporting the chat log.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onExportLog() {
    game.messages.export();
  }

  /* -------------------------------------------- */

  /**
   * Handle flushing the chat log.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onFlushLog() {
    game.messages.flush();
  }

  /* -------------------------------------------- */

  /**
   * Handle hiding the chat tab notification pip.
   */
  #onHidePip() {
    document.querySelector('#sidebar .tabs [data-tab="chat"] + .notification-pip')?.classList.remove("active");
  }

  /* -------------------------------------------- */

  /**
   * Handle pausing the chat notification log when interacting with a chat notification card.
   * @param {PointerEvent} event  The triggering event.
   */
  #onHoverNotification(event) {
    event.currentTarget.classList.toggle("hovered", event.type === "pointerenter");
    if ( event.type === "pointerleave" ) this.#notifyUnpauseDebounce();
  }

  /* -------------------------------------------- */

  /**
   * Handle jumping to the bottom of the chat log.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onJumpToBottom() {
    this.scrollBottom();
  }

  /* -------------------------------------------- */

  /**
   * Return the chat notification queue to the inactive state.
   */
  #onNotifyInactive() {
    this.#notificationsElement.classList.remove("active");
  }

  /* -------------------------------------------- */

  /**
   * Handle scroll events within the chat log.
   * @param {UIEvent} [event]  A triggering scroll event.
   */
  #onScrollLog(event) {
    if ( !this.rendered ) return;
    const log = event?.currentTarget ?? this.element.querySelector(".chat-scroll");
    const pct = log.scrollTop / (log.scrollHeight - log.clientHeight);
    this.#isAtBottom = (pct > 0.99) || Number.isNaN(pct);
    this.#jumpToBottomElement.toggleAttribute("hidden", this.#isAtBottom);
    log.classList.toggle("scrolled", !this.#isAtBottom);
    const top = log.querySelector("li.message");
    if ( pct < 0.01 ) return this.renderBatch(CONFIG.ChatMessage.batchSize).then(() => {
      // Retain the scroll position at the top-most element before the extra messages were prepended to the log.
      if ( top ) log.scrollTop = top.offsetTop;
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to an input element within the log.
   * @param {Event} event
   */
  #onChangeLog(event) {
    if ( !foundry.utils.isElementInstanceOf(event.target, HTMLSecretBlockElement) ) return;
    const messageId = event.target.closest("[data-message-id]")?.dataset.messageId;
    if ( !messageId ) return;
    const message = game.messages.get(messageId);
    if ( !message ) return;
    const modified = event.target.toggleRevealed(message.content);
    message.update({content: modified});
  }

  /* -------------------------------------------- */

  /**
   * Handle unpausing the notification queue and scrolling back down to the bottom.
   */
  #onUnpauseNotifications() {
    if ( this.#notificationsElement.querySelector(".message.hovered") ) return;

    // Now the queue is unpaused, clear any messages that have already expired.
    this.#checkNotificationLifeSpan();

    // Scroll to the bottom.
    this.#notificationsElement.querySelector(":scope > .overflow").scrollTop = 0;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preClose(options) {
    if ( this.isPopout ) this._toggleNotifications({...options, closing: true});
  }

  /* -------------------------------------------- */
  /*  Message Input                               */
  /* -------------------------------------------- */

  /**
   * Parse a chat string to identify the chat command (if any) which was used.
   * @param {string} message  The message to parse.
   * @returns {[string, string[]|RegExpMatchArray|RegExpMatchArray[], ChatCommandCallback]}  The identified command,
   *                          regex match, and associated handler function.
   */
  static parse(message) {
    const template = ChatLog.#PLAIN_TEXT ??= document.createElement("template");
    template.innerHTML = message;
    template.content.querySelectorAll("br").forEach(br => br.replaceWith("\n"));
    const text = template.content.textContent;
    const html = message.replace(/^<p>|<\/p>$/gi, "");

    // Check registered CHAT_COMMANDS first
    for ( const [rule, {rgx, fn, isMultiline, isRoll}] of Object.entries(this.CHAT_COMMANDS) ) {

      // For multi-line matches, the first line must match
      if ( isMultiline ) {
        const lines = (isRoll ? text : html).split(/<br\b[^>]*>|\n/i);
        if ( rgx.test(lines[0]) ) return [rule, lines.map(l => l.match(rgx)), fn];
      }

      // For single-line matches, match directly
      else {
        const match = (isRoll ? text : html).match(rgx);
        if ( match ) return [rule, match, fn];
      }
    }

    /** @deprecated since v14 */
    for ( const [rule, rgx] of Object.entries(ChatLog.#legacyPatterns) ) {
      if ( this.MULTILINE_COMMANDS.has(rule) ) {
        foundry.utils.logCompatibilityWarning("Assigning a CHAT_COMMANDS entry to ChatLog.MULTILINE_COMMANDS is"
          + " deprecated in favor of directly configuring `isMultiline: true` in the CHAT_COMMANDS record.",
        {since: 14, until: 16, once: true});
        const lines = html.split(/<br\b[^>]*>|\n/i);
        if ( rgx.test(lines[0]) ) return [rule, lines.map(l => l.match(rgx))];
      } else {
        const match = html.match(rgx);
        if ( match ) return [rule, match];
      }
    }

    // Any remaining unrecognized slash command is invalid.
    const invalidMatch = html.match(/^(\/\S+)/);
    if ( invalidMatch ) return ["invalid", invalidMatch];

    return ["none", [message, "", message]];
  }

  /* -------------------------------------------- */

  /**
   * Prepare the data object of chat message data depending on the type of message being posted.
   * @param {string} message                      The original string of the message content
   * @param {object} [options]                    Additional options
   * @param {ChatSpeakerData} [options.speaker]   The speaker data
   * @returns {Promise<ChatMessage|void>}         The created ChatMessage Document, or void if we were executing a
   *                                              macro instead.
   * @throws {Error}                              If an invalid command is found.
   */
  async processMessage(message, {speaker}={}) {
    message = message.trim();
    if ( !message ) return;
    const cls = ChatMessage$1.implementation;
    speaker ??= cls.getSpeaker();
    const createOptions = {};

    // 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
    let [command, match, fn] = this.constructor.parse(message);

    // Special handlers for no command
    if ( command === "invalid" ) throw new Error(_loc("CHAT.InvalidCommand", {command: match[1]}));
    if ( command === "none" ) {
      const mode = game.settings.get("core", "messageMode");
      createOptions.messageMode = mode;
      command = mode === "ic" ? "ic" : "ooc";
      fn = this.constructor.CHAT_COMMANDS[command].fn;
    }

    // Process message data based on the identified command type
    const result = await fn?.call(this, command, match, chatData, createOptions);
    if ( result === false ) return;

    // Create the message using provided data and options
    return cls.create(chatData, createOptions);
  }

  /* -------------------------------------------- */

  /**
   * Process non-whispered messages.
   * @param {string} command          The chat command type
   * @param {RegExpMatchArray} match  The matched RegExp expressions
   * @param {Object} chatData         The initial chat data
   * @param {Object} createOptions    Options used to create the message
   * @throws {Error}                  If attempting to emote or chat in-character without a speaker.
   */
  #processChatCommand(command, match, chatData, createOptions) {
    if ( ["ic", "emote"].includes(command) && !(chatData.speaker.actor || chatData.speaker.token) ) {
      throw new Error("You cannot chat in-character without an identified speaker");
    }
    chatData.content = match[2].replace(/\n/g, "<br>");

    // Augment chat data
    if ( command === "ic" ) createOptions.messageMode = "ic";
    else if ( command === "emote" ) {
      chatData.style = CONST.CHAT_MESSAGE_STYLES.EMOTE;
      chatData.content = `${chatData.speaker.alias} ${chatData.content}`;
      createOptions.chatBubble = true;
    }
    else {
      chatData.style = CONST.CHAT_MESSAGE_STYLES.OOC;
      delete chatData.speaker;
    }
  }

  /* -------------------------------------------- */

  /**
   * Process dice roll commands.
   * @param {string} command              The chat command type
   * @param {RegExpMatchArray[]} matches  Multi-line matched roll expressions
   * @param {Object} chatData             The initial chat data
   * @param {Object} createOptions        Options used to create the message
   */
  async #processDiceCommand(command, matches, chatData, createOptions) {
    const actor = ChatMessage$1.getSpeakerActor(chatData.speaker) || game.user.character;
    const rollData = actor ? actor.getRollData() : {};
    const rolls = [];
    const messageMode = this.constructor.CHAT_COMMANDS[command]?.mode || game.settings.get("core", "messageMode");
    for ( const match of matches ) {
      if ( !match ) continue;
      const [formula, flavor] = match.slice(2, 4);
      if ( flavor && !chatData.flavor ) chatData.flavor = flavor;
      const roll = Roll.create(formula, rollData);
      await roll.evaluate({allowInteractive: messageMode !== "blind"});
      rolls.push(roll);
    }
    chatData.rolls = rolls;
    chatData.sound = CONFIG.sounds.dice;
    chatData.content = rolls.reduce((t, r) => t + r.total, 0);
    createOptions.messageMode = messageMode;
  }

  /* -------------------------------------------- */

  /**
   * Process messages which execute a macro.
   * @param {RegExpMatchArray} match  The RegExp matches.
   * @throws {Error}                  If the macro could not be found.
   */
  #processMacroCommand(match) {

    // Parse the macro command with the form /macro {macroName} [param1=val1] [param2=val2] ...
    let [macroName, ...params] = match[2].split(" ");
    let expandName = true;
    const scope = {};
    let k = undefined;
    for ( const p of params ) {
      const kv = p.split("=");
      if ( kv.length === 2 ) {
        k = kv[0];
        scope[k] = kv[1];
        expandName = false;
      }
      else if ( expandName ) macroName += ` ${p}`; // Macro names may contain spaces
      else if ( k ) scope[k] += ` ${p}`;  // Expand prior argument value
    }
    macroName = macroName.trimEnd(); // Eliminate trailing spaces

    // Get the target macro by number or by name
    let macro;
    if ( Number.isNumeric(macroName) ) {
      const macroID = game.user.hotbar[macroName];
      macro = game.macros.get(macroID);
    }
    if ( !macro ) macro = game.macros.getName(macroName);
    if ( !macro ) throw new Error(`Requested Macro "${macroName}" was not found as a named macro or hotbar position`);

    // Execute the Macro with provided scope
    macro.execute(scope);
  }

  /* -------------------------------------------- */

  /**
   * Process whispered messages.
   * @param {string} command          The chat command type
   * @param {RegExpMatchArray} match  The matched RegExp expressions
   * @param {Object} chatData         The initial chat data
   * @throws {Error}                  If this user does not have permission to whisper, or there are no valid whisper
   *                                  recipients.
   */
  #processWhisperCommand(command, match, chatData) {
    delete chatData.speaker;

    // Determine the recipient users
    let users = [];
    let message = "";
    switch ( command ) {
      case "whisper": {
        message = match[3];
        const names = match[2].replace(/[[\]]/g, "").split(",").map(n => n.trim());
        users = names.reduce((arr, n) => arr.concat(ChatMessage$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(_loc("ERROR.NoTargetUsersForWhisper"));
    if ( users.some(u => !u.isGM) && !game.user.can("MESSAGE_WHISPER") ) {
      throw new Error(_loc("ERROR.CantWhisper"));
    }

    // Update chat data
    chatData.whisper = users.map(u => u.id);
    chatData.content = message;
    chatData.sound = CONFIG.sounds.notification;
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Delete a single message from the chat log.
   * @param {string} messageId                   The ID of the ChatMessage Document to remove from the log.
   * @param {object} [options]
   * @param {boolean} [options.deleteAll=false]  Delete all messages from the log.
   * @returns {Promise<void>}
   */
  deleteMessage(messageId, options={}) {
    return this.#renderingQueue.add(this.#deleteMessage.bind(this), messageId, options);
  }

  /* -------------------------------------------- */

  /**
   * Trigger a notification that alerts the user visually and audibly of new chat activity.
   * @param {ChatMessage} message             The created or updated message.
   * @param {object} [options]
   * @param {HTMLElement} [options.existing]  The existing rendered chat card, if it exists.
   * @param {boolean} [options.newMessage]    Whether this is a new message.
   */
  notify(message, {existing, newMessage}={}) {
    if ( !this.rendered ) return;

    // Post a chat card notification if the setting is enabled and this is a new message.
    if ( newMessage && this._shouldShowNotifications() ) this.#postNotification(message, {existing});

    // Otherwise show a notification pip on the chat tab.
    else {
      document.querySelector('#sidebar .tabs [data-tab="chat"] + .notification-pip')?.classList.add("active");
      this.#pipHideDebounce();
    }

    // Play a notification sound effect.
    if ( message.sound ) game.audio.play(message.sound, {context: game.audio.interface});
  }

  /* -------------------------------------------- */

  /**
   * Post a single chat message to the log.
   * @param {ChatMessage} message             The chat message.
   * @param {object} [options]
   * @param {string} [options.before]         An existing message ID to prepend the posted message to, by default the
   *                                          new message is appended to the end of the log.
   * @param {boolean} [options.notify=false]  Trigger a notification which shows the log as having a new unread message.
   * @param {boolean} [options.scroll]        Pass true to always scroll-to-bottom, or false to suppress this. Omitting
   *                                          this option preserves the default conditional scroll-to-bottom.
   * @returns {Promise<void>}                 A Promise which resolves once the message has been posted.
   */
  async postOne(message, options={}) {
    if ( !message.visible ) return;
    return this.#renderingQueue.add(this.#postOne.bind(this), message, options);
  }

  /* -------------------------------------------- */

  /**
   * Render a batch of additional messages, prepending them to the top of the log.
   * @param {number} size  The batch size.
   * @returns {Promise<void>}
   */
  async renderBatch(size) {
    if ( this.#renderingBatch ) return;
    this.#renderingBatch = true;
    return this.#renderingQueue.add(this.#doRenderBatch.bind(this), size);
  }

  /* -------------------------------------------- */

  /**
   * Re-render a message in the chat log, keeping its ephemeral state synchronized.
   * @param {ChatMessage} message   The ChatMessage Document.
   * @param {HTMLElement} existing  The existing rendered element.
   * @param {object} [options]      Options forwarded to {@link foundry.documents.ChatMessage#renderHTML}.
   */
  async #rerenderMessage(message, existing, options={}) {
    const expanded = Array.from(existing.querySelectorAll('[data-action="expandRoll"]')).map(el => {
      return el.classList.contains("expanded");
    });
    const replacement = await this.constructor.renderMessage(message, options);
    const rolls = replacement.querySelectorAll('[data-action="expandRoll"]');
    for ( let i = 0; i < rolls.length; i++ ) rolls[i].classList.toggle("expanded", expanded[i] ?? false);
    replacement.hidden = existing.hidden;
    replacement.style.opacity = existing.style.opacity;
    existing.replaceWith(replacement);
    if ( "_lifeSpan" in existing ) {
      replacement._lifeSpan = existing._lifeSpan;
      replacement.addEventListener("pointerenter", this.#onHoverNotification.bind(this));
      replacement.addEventListener("pointerleave", this.#onHoverNotification.bind(this));
    }
  }

  /* -------------------------------------------- */

  /**
   * Scroll the chat log to the bottom.
   * @param {object} [options]
   * @param {boolean} [options.popout=false]                 If a popout exists, scroll it to the bottom too.
   * @param {boolean} [options.waitImages=false]             Wait for any images embedded in the chat log to load first
   *                                                         before scrolling.
   * @param {ScrollIntoViewOptions} [options.scrollOptions]  Options to configure scrolling behavior.
   */
  async scrollBottom({popout=false, waitImages=false, scrollOptions={}}={}) {
    if ( !this.rendered ) return;
    const scroll = this.element.querySelector(".chat-scroll");
    if ( waitImages ) await this.constructor.waitForImages(scroll);
    scroll.scrollTop = 0x7fffffbf;
    if ( popout ) this.popout?.scrollBottom({waitImages, scrollOptions});
  }

  /* -------------------------------------------- */

  /**
   * Update the contents of a previously-posted message.
   * @param {ChatMessage} message  The ChatMessage instance to update.
   * @param {object} options
   * @param {boolean} [options.notify=false]  Trigger a notification which shows the log as having a new unread message.
   * @returns {Promise<void>}
   */
  async updateMessage(message, options={}) {
    return this.#renderingQueue.add(this.#updateMessage.bind(this), message, options);
  }

  /* -------------------------------------------- */

  /**
   * Update displayed timestamps for every displayed message in the chat log.
   * Timestamps are displayed in a humanized "time-since" format.
   */
  updateTimestamps() {
    for ( const li of document.querySelectorAll(".chat-message[data-message-id]") ) {
      const message = game.messages.get(li.dataset.messageId);
      if ( !message?.timestamp ) return;
      const stamp = li.querySelector(".message-timestamp");
      if ( stamp ) stamp.textContent = foundry.utils.timeSince(message.timestamp);
    }
  }

  /* -------------------------------------------- */
  /*  Private API                                 */
  /* -------------------------------------------- */

  /**
   * Check for notifications that should be automatically dismissed.
   */
  #checkNotificationLifeSpan() {
    let isPaused = false;
    const {NOTIFY_DURATION, NOTIFY_TICKER} = this.constructor;
    for ( const element of this.#notificationsElement.querySelectorAll(".message") ) {
      element._lifeSpan += NOTIFY_TICKER;
      if ( element.classList.contains("hovered") ) isPaused = true;
      if ( !isPaused && (element._lifeSpan >= NOTIFY_DURATION) ) this.#dismissNotification(element);
    }
  }

  /* -------------------------------------------- */

  /**
   * Delete a single message from the chat log.
   * @param {string} messageId                   The ID of the ChatMessage Document to remove from the log.
   * @param {object} [options]
   * @param {boolean} [options.deleteAll=false]  Delete all messages from the log.
   */
  #deleteMessage(messageId, {deleteAll=false}={}) {
    if ( !this.rendered ) return;

    // Get the chat message being removed from the log
    const message = game.messages.get(messageId);
    if ( message ) message.logged = false;

    // Get the message's element
    const li = this.element.querySelector(`.message[data-message-id="${messageId}"]`);
    if ( !li ) return;

    // Update the last index
    if ( deleteAll ) this.#lastId = null;
    else if ( messageId === this.#lastId ) this.#lastId = li.nextElementSibling?.dataset.messageId ?? null;

    // Remove the deleted message
    li.classList.add("deleting");
    li.animate(
      {height: [`${li.getBoundingClientRect().height}px`, "0"]},
      {duration: 100, easing: "ease"}
    ).finished.then(() => {
      li.remove();
      this.#onScrollLog();
      // Re-fit only once the removal animation is complete, so we have an accurate size.
      if ( this.isPopout ) this._refit();
    });

    if ( !this.isPopout ) {
      this.#notificationsElement.querySelector(`.message[data-message-id="${messageId}"]`)?.remove();
    }

    // Delete from the popout tab
    this.popout?.deleteMessage(messageId, {deleteAll});
    if ( !this.isPopout ) this.#overflowingDebounce();
  }

  /* -------------------------------------------- */

  /**
   * Dismiss a notification from the ChatLog.
   * @param {HTMLElement} element
   * @returns {Promise<void>}
   */
  async #dismissNotification(element) {
    element.classList.add("deleting");
    await element.animate({
      opacity: [1, 0],
      transform: ["translateY(0)", "translateY(-35px)"]
    }, {duration: 250, easing: "ease", fill: "forwards"}).finished;
    await element.animate({
      height: [`${element.getBoundingClientRect().height}px`, "0"]
    }, {duration: 100, easing: "ease"}).finished;
    element.remove();
  }

  /* -------------------------------------------- */

  /**
   * Post a chat card to the notification queue.
   * @param {ChatMessage} message             The message to render.
   * @param {object} [options]
   * @param {HTMLElement} [options.existing]  The corresponding element in the chat log, if it exists.
   * @returns {Promise<void>}
   */
  async #postNotification(message, {existing}={}) {
    const log = this.#notificationsElement.querySelector(".chat-log");
    const paused = log.querySelector(".message.hovered");
    this.#notificationsElement.classList.add("active");
    this.#notifyAlertDebounce();

    const dummy = document.createElement("li");
    dummy.classList.add("chat-message", "message");
    dummy.dataset.messageId = message.id;
    dummy.hidden = true;
    log.append(dummy);

    let element = await this.constructor.renderMessage(message, {canDelete: false, canClose: true});
    element.addEventListener("pointerenter", this.#onHoverNotification.bind(this));
    element.addEventListener("pointerleave", this.#onHoverNotification.bind(this));
    element.hidden = true;
    dummy.replaceWith(element);

    // Insert spacer element to animate gap for card to be inserted.
    const {height=130} = existing?.getBoundingClientRect() ?? {};
    const spacer = document.createElement("li");
    spacer.style.height = "0";
    spacer.classList.add("spacer");
    log.append(spacer);
    if ( !paused ) spacer.scrollIntoView();
    await spacer.animate(
      {height: ["0", `${height}px`]},
      {duration: 100, easing: "ease", fill: "forwards"}
    ).finished;

    // Insert the new card.
    spacer.remove();
    element = this.#notificationsElement.querySelector(`.message[data-message-id="${message.id}"]`);
    element.hidden = false;
    element.animate({
      opacity: [0, 1],
      transform: ["translateY(-35px)", "translateY(0)"]
    }, {duration: 250, easing: "ease", fill: "forwards"});
    element._lifeSpan = 0;
  }

  /* -------------------------------------------- */

  /**
   * Post a single chat message to the log.
   * @param {ChatMessage} message             The chat message.
   * @param {object} [options]
   * @param {string} [options.before]         An existing message ID to prepend the posted message to, by default the
   *                                          new message is appended to the end of the log.
   * @param {boolean} [options.notify=false]  Trigger a notification which shows the log as having a new unread message.
   * @param {boolean} [options.scroll]        Pass true to always scroll-to-bottom, or false to suppress this. Omitting
   *                                          this option preserves the default conditional scroll-to-bottom.
   * @returns {Promise<void>}                 A Promise which resolves once the message has been posted.
   */
  async #postOne(message, {before, notify=false, scroll}={}) {
    if ( !this.rendered || !message.visible ) return;
    message.logged = true;

    // Track internal flags
    if ( !this.#lastId ) this.#lastId = message.id; // Ensure that new messages don't result in batched scrolling.
    if ( (message.whisper || []).includes(game.user.id) && !message.isRoll ) this.#lastWhisper = message;

    // Render the message to the log
    const log = this.element.querySelector(".chat-log");
    const html = await this.constructor.renderMessage(message);

    // Due to latency, a newer message might have been posted to the log. If so, place this message behind it.
    if ( !before ) {
      let newerMessage;
      for ( let i = log.children.length; i--; ) {
        const msg = game.messages.get(log.children[i].dataset.messageId);
        if ( !msg ) continue;
        if ( msg.timestamp <= message.timestamp ) break;
        newerMessage = msg;
      }
      before = newerMessage?.id;
    }

    // Append the message after some other one
    const existing = before ? this.element.querySelector(`.message[data-message-id="${before}"]`) : null;
    if ( existing ) existing.insertAdjacentElement("beforebegin", html);

    // Otherwise, append the message to the bottom of the log
    else log.append(html);

    if ( scroll || ((scroll !== false) && !existing && (this.isAtBottom || (message.author.id === game.user.id))) ) {
      this.scrollBottom({waitImages: true});
    }

    // Append to notifications.
    if ( notify ) this.notify(message, {existing: html, newMessage: true});

    // Update popout tab
    if ( this.popout?.rendered ) await this.popout.postOne(message, {before, scroll, notify: false});
    if ( this.isPopout ) this._refit();
    else this.#overflowingDebounce();
  }

  /* -------------------------------------------- */

  /**
   * Update the contents of a previously-posted message.
   * @param {ChatMessage} message  The ChatMessage instance to update.
   * @param {object} options
   * @param {boolean} [options.notify=false]  Trigger a notification which shows the log as having a new unread message.
   * @returns {Promise<void>}
   */
  async #updateMessage(message, {notify=false}={}) {
    if ( !message.visible ) return this.#deleteMessage(message.id);
    const li = this.element.querySelector(`.message[data-message-id="${message.id}"]`);
    if ( li ) await this.#rerenderMessage(message, li);
    // A previously invisible message has become visible to this user.
    else {
      const messages = game.messages.contents;
      const messageIndex = messages.findIndex(m => m === message);
      let nextMessage;
      for ( let i = messageIndex + 1; i < messages.length; i++ ) {
        if ( messages[i].visible ) {
          nextMessage = messages[i];
          break;
        }
      }
      await this.#postOne(message, {before: nextMessage?.id, notify: false});
    }

    if ( !this.isPopout ) {
      const existing = this.#notificationsElement.querySelector(`.message[data-message-id="${message.id}"]`);
      if ( existing ) await this.#rerenderMessage(message, existing, {canDelete: false, canClose: true});
    }

    if ( notify ) this.notify(message);

    // Update the popout tab
    if ( this.popout?.rendered ) await this.popout.updateMessage(message, {notify: false});
    if ( this.isPopout ) this._refit();
    else this.#overflowingDebounce();
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Handles chat message rendering during the ChatMessage#getHTML deprecation period. After that period ends, calls to
   * this method can be replaced by ChatMessage#renderHTML.
   * @param {ChatMessage} message  The chat message to render.
   * @param {object} [options]     Options forwarded to the render function.
   * @returns {Promise<HTMLElement>}
   * @throws {Error}               If the message's render methods do not return a usable result.
   */
  static async renderMessage(message, options) {
    const hasGetHTML = foundry.utils.getDefiningClass(message, "getHTML") !== ChatMessage$1;
    const hasRenderHTML = foundry.utils.getDefiningClass(message, "renderHTML") !== ChatMessage$1;
    /** @deprecated since v13 */
    if ( hasGetHTML && !hasRenderHTML ) {
      const html = await message.getHTML(options);
      if ( foundry.utils.getType(html) === "HTMLElement" ) return html;
      if ( foundry.utils.getType(html[0]) === "HTMLElement" ) return html[0];
      throw new Error(`Unable to render ChatMessage [${message.id}] as it did not return an HTMLElement or jQuery.`);
    }
    return message.renderHTML(options);
  }

  /* -------------------------------------------- */

  /**
   * Update the overflowing state of the chat scroll container.
   */
  #setOverflowing() {
    const scroll = this.element.querySelector(".chat-scroll");
    scroll.classList.toggle("overflowed", scroll.scrollHeight > scroll.offsetHeight);
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the notifications pane should be visible.
   * @param {object} [options]
   * @param {boolean} [options.closing=false]  Whether the chat popout is closing.
   * @returns {boolean}
   * @protected
   */
  _shouldShowNotifications({closing=false}={}) {
    const {chatNotifications, uiScale} = game.settings.get("core", "uiConfig");

    // Case 1 - Chat notifications disabled.
    if ( (chatNotifications === "pip") || this.options.stream ) return false;

    // Case 2 - Chat tab visible in sidebar.
    if ( ui.sidebar.expanded && ui.chat.active ) return false;

    // Case 3 - Chat popout visible.
    if ( ui.chat.popout?.rendered && (!this.isPopout || !closing) ) return false;

    // Case 4 - Not enough viewport width available.
    const cameraDock = ui.webrtc.isVertical && !ui.webrtc.hidden;
    const viewportWidth = window.innerWidth / uiScale;
    const spaceRequired = 1024 + (ui.sidebar.expanded * 300) + (cameraDock * 264);
    return viewportWidth >= spaceRequired;
  }

  /* -------------------------------------------- */

  /**
   * Update notification display, based on interface state.
   * If the chat log is popped-out, embed chat input into it. Otherwise,
   * if the sidebar is expanded, and the chat log is the active tab, embed chat input into it. Otherwise,
   * embed chat input into the notifications area.
   * If the sidebar is expanded, and the chat log is the active tab, do not display notifications.
   * If the chat log is popped out, do not display notifications.
   * @param {(ApplicationRenderOptions|ApplicationClosingOptions) & { closing?: boolean }} [options]  Options which were
   *                                           passed to the render or close operation that triggered this method.
   * @param {boolean} [options.closing=false]  Whether this method has been triggered by the chat popout closing.
   * @fires {hookEvents:renderChatInput}
   * @internal
   */
  _toggleNotifications(options={}) {
    const {closing=false} = options;
    if ( ui.chat.popout?.rendered && !this.isPopout ) return;
    const notifications = ui.chat.#notificationsElement;
    const inputElement = ui.chat.#inputElement;
    const chatControls = ui.chat.#chatControls;
    const modeButtons = chatControls.querySelector("#message-modes");
    const log = notifications.querySelector(".chat-log");
    const embedInput = !this._shouldShowNotifications({closing});
    log.hidden = embedInput;
    modeButtons.classList.toggle("vertical", !embedInput);
    const previousParent = inputElement.parentElement;
    if ( game.user.isGM ) chatControls.querySelector(".control-buttons").hidden = !embedInput;
    if ( embedInput ) {
      const target = ui.chat.popout?.rendered && !closing ? ui.chat.popout : ui.chat;
      target.element.querySelector(".chat-form").append(chatControls, inputElement);
      inputElement.focus();
    }
    else if ( !notifications.contains(inputElement) ) notifications.append(inputElement, chatControls);
    Hooks$1.callAll("renderChatInput", this, {
      "#chat-message": inputElement,
      "#chat-controls": chatControls,
      "#chat-notifications": notifications,
      "#message-modes": modeButtons,
      "#roll-privacy": modeButtons /** @deprecated since v14 */
    }, {previousParent}, options);
    this.#offsetHotbar(!embedInput);
    if ( this.#isAtBottom ) this.scrollBottom();
  }

  /* -------------------------------------------- */

  /**
   * Handle updating the chat message mode display.
   * @internal
   */
  _updateMessageMode() {
    if ( this.isPopout ) return;
    const mode = game.settings.get("core", "messageMode");
    for ( const button of document.getElementById("message-modes").querySelectorAll("button") ) {
      button.ariaPressed = `${mode === button.dataset.mode}`;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle offsetting the hotbar based on the chat notification position.
   * @param {boolean} notifications  Whether the chat notifications are active.
   */
  #offsetHotbar(notifications) {
    if ( this.options.stream ) return;
    const hotbar = document.getElementById("hotbar");
    const currentOffset = Number(hotbar.style.getPropertyValue("--offset").replace(/px$/, "")) || 0;
    hotbar.style.transition = "none";
    hotbar.classList.remove("offset");
    hotbar.style.removeProperty("--offset");
    hotbar.style.transition = "";
    if ( !notifications ) return;
    const {uiScale} = game.settings.get("core", "uiConfig");
    // Calculate an offset to keep the hotbar visually centered.
    const bb = hotbar.getBoundingClientRect();
    // Hard-code this number for now since the sidebar is mid-animation so we can't get an accurate width.
    const rightWidth = (380 + (300 * ui.sidebar.expanded)) / uiScale;
    const offset = currentOffset + (window.innerWidth / uiScale) - bb.right - rightWidth;
    if ( offset > 0 ) return; // Don't move closer to the sidebar.
    hotbar.classList.add("offset");
    hotbar.style.setProperty("--offset", `${offset}px`);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * An enumeration of regular expression patterns used to match chat messages.
   * @enum {RegExp}
   * @deprecated since v14
   * @ignore
   */
  static MESSAGE_PATTERNS = (() => {
    const target = Object.fromEntries(Object.entries(this.CHAT_COMMANDS).map(([k, v]) => [k, v.rgx]));
    return new Proxy(target, {
      get(target, key, receiver) {
        foundry.utils.logCompatibilityWarning(
          "ChatLog.MESSAGE_PATTERNS is deprecated. Use ChatLog.CHAT_COMMANDS instead.",
          {since: 14, until: 16, once: true}
        );
        return Reflect.get(target, key, receiver);
      },
      set(target, key, value, receiver) {
        foundry.utils.logCompatibilityWarning(
          "Writing to ChatLog.MESSAGE_PATTERNS is deprecated. Register commands via ChatLog.CHAT_COMMANDS instead.",
          {since: 14, until: 16, once: true}
        );
        Reflect.set(target, key, value, receiver);
        ChatLog.#legacyPatterns[key] = value;
        return true;
      }
    });
  })();

  /**
   * The set of commands that can be processed over multiple lines.
   * @type {Set<string>}
   * @deprecated since v14
   * @ignore
   */
  static MULTILINE_COMMANDS = new Set();

}

/**
 * @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 ( !foundry.utils.isElementInstanceOf(event.target, 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", "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 _loc("SIDEBAR.DirectoryTitle", {type: _loc(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 [{
      label: "SIDEBAR.Edit",
      icon: "fa-solid fa-edit",
      visible: li => {
        const sheet = getDocument(li).sheet;
        return sheet.isVisible ?? sheet._canUserView(game.user);
      },
      onClick: (event, li) => getDocument(li).sheet.render(true)
    }, {
      label: "OWNERSHIP.Configure",
      icon: "fa-solid fa-user-lock",
      visible: game.user.isGM,
      onClick: (event, li) => new DocumentOwnershipConfig({
        document: getDocument(li),
        position: {
          top: Math.min(li.offsetTop, window.innerHeight - 350),
          left: window.innerWidth - 720
        }
      }).render({force: true})
    }, {
      label: "SIDEBAR.Export",
      icon: "fa-solid fa-file-export",
      visible: li => getDocument(li).isOwner,
      onClick: (event, li) => getDocument(li).exportToJSON()
    }, {
      label: "SIDEBAR.Import",
      icon: "fa-solid fa-file-import",
      visible: li => getDocument(li).isOwner,
      onClick: (event, li) => getDocument(li).importFromJSONDialog()
    }, {
      label: "FOLDER.Clear",
      icon: "fa-solid fa-folder",
      visible: header => {
        const li = header.closest(".directory-item");
        return game.user.isGM && !!getDocument(li).folder;
      },
      onClick: (event, li) => getDocument(li).update({ folder: null })
    }, {
      label: "SIDEBAR.Delete",
      icon: "fa-solid fa-trash",
      visible: li => getDocument(li).canUserModify(game.user, "delete"),
      onClick: (event, li) => getDocument(li).deleteDialog({
        position: {
          top: Math.min(li.offsetTop, window.innerHeight - 350),
          left: window.innerWidth - 770,
          width: 450
        }
      })
    }, {
      label: "SIDEBAR.Duplicate",
      icon: "fa-regular fa-copy",
      visible: li => getDocument(li).isOwner && this.documentClass.canUserCreate(game.user),
      onClick: (event, li) => {
        const original = getDocument(li);
        return original.clone(this._prepareDuplicateData(original), {save: true, addSource: true,
          discardInvalidEmbedded: 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: _loc("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 locPath = `SUPPORT.UnavailableDocuments.${game.i18n.pluralRules.select(unavailable)}`;
      const docLabel = _loc(this.documentClass.metadata.label);
      const label = _loc(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 = _loc(this.documentClass.metadata.labelPlural);
    context.searchMode.placeholder = _loc("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: this.#searchFilter.query, 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={}) {
    if ( !foundry.utils.isEmpty(updates) ) entry = entry.clone(updates, {keepId: true});
    return this.collection.importDocument(entry, {dialog: true});
  }

  /* -------------------------------------------- */

  /**
   * 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 (entry.collection === this.collection) && 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) && (entry.collection === this.collection) ) 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 [{
      label: "FOLDER.Edit",
      icon: "fa-solid fa-edit",
      visible: game.user.isGM,
      onClick: async (event, 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}
        });
      }
    }, {
      label: "OWNERSHIP.Configure",
      icon: "fa-solid fa-user-lock",
      visible: game.user.isGM,
      onClick: async (event, 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});
      }
    }, {
      label: "FOLDER.CreateTable",
      icon: CONFIG.RollTable.sidebarIcon,
      visible: header => {
        const li = header.closest(".directory-item");
        const folder = fromUuidSync(li.dataset.uuid);
        return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type);
      },
      onClick: async (event, header) => {
        const li = header.closest(".directory-item");
        const folder = await fromUuid(li.dataset.uuid);
        const title = _loc("FOLDER.CreateTableConfirm.Title", { folder: folder.name });
        return DialogV2.confirm({
          window: { title },
          position: {
            top: Math.min(li.offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 740,
            width: 420
          },
          content: `<p>${_loc("FOLDER.CreateTableConfirm.Question")}</p>`,
          yes: { callback: () => RollTable$1.implementation.fromFolder(folder), default: true }
        });
      }
    }, {
      label: "FOLDER.Export",
      icon: "fa-solid fa-book-atlas",
      visible: header => {
        const li = header.closest(".directory-item");
        const folder = fromUuidSync(li.dataset.uuid);
        return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type);
      },
      onClick: async (event, 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
          }
        });
      }
    }, {
      label: "FOLDER.Remove",
      icon: "fa-solid fa-trash",
      visible: game.user.isGM,
      onClick: async (event, header) => {
        const li = header.closest(".directory-item");
        const folder = await fromUuid(li.dataset.uuid);
        const question = _loc("COMMON.AreYouSure");
        const warning = _loc("FOLDER.RemoveWarning");
        const title = _loc("FOLDER.RemoveName", { name: folder.name });
        return folder.deleteDialog({
          content: `<p><strong>${question}</strong> ${warning}</p>`,
          window: { title, icon: "fa-solid fa-trash" },
          position: {
            top: Math.min(li.offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 770,
            width: 450
          }
        });
      }
    }, {
      label: "FOLDER.Delete",
      icon: "fa-solid fa-dumpster",
      visible: game.user.isGM,
      onClick: async (event, header) => {
        const li = header.closest(".directory-item");
        const folder = await fromUuid(li.dataset.uuid);
        const question = _loc("COMMON.AreYouSure");
        const warning = _loc("FOLDER.DeleteWarning");
        const title = _loc("FOLDER.DeleteName", {name: folder.name});
        return folder.deleteDialog({
          window: { title, icon: "fa-solid fa-dumpster" },
          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 })
          }
        });
      }
    }];
  }

  /* -------------------------------------------- */

  /**
   * 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 = _loc(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 = {
    id: "Compendium-{id}",
    classes: ["compendium-directory", "sidebar-popout"],
    window: {
      frame: true,
      positioned: true
    },
    position: {
      top: 70,
      left: 120,
      width: 350,
      height: window.innerHeight - 100
    },
    actions: {
      copyId: Compendium.#onCopyId
    }
  };

  /** @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 _loc(this.collection.title);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    const initialized = super._initializeApplicationOptions(options);
    initialized.uniqueId = initialized.collection.collection.replaceAll(".", "_");
    return initialized;
  }

  /* -------------------------------------------- */

  /** @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 [{
      label: "COMPENDIUM.ImportEntry",
      icon: "fa-solid fa-download",
      visible: () => CONST.WORLD_DOCUMENT_TYPES.includes(this.documentName)
        && this.documentClass.canUserCreate(game.user),
      onClick: async (event, li) => {
        const collection = game.collections.get(this.documentName);
        const options = {clearOwnership: false, dialog: true, renderSheet: true};
        return collection.importFromCompendium(this.collection, li.dataset.entryId, {}, options);
      }
    }, {
      label: "ADVENTURE.ExportEdit",
      icon: "fa-solid fa-edit",
      visible: () => isAdventure && game.user.isGM && !this.collection.locked,
      onClick: async (event, li) => {
        const document = await this.collection.getDocument(li.dataset.entryId);
        return new CONFIG.Adventure.exporterClass({
          document: document.clone({}, {keepId: true})
        }).render({force: true});
      }
    }, {
      label: "SCENE.GenerateThumb",
      icon: "fa-solid fa-image",
      visible: () => !this.collection.locked && isScene,
      onClick: async (event, 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);
        }
      }
    }, {
      label: "COMPENDIUM.DeleteEntry",
      icon: "fa-solid fa-trash",
      visible: () => game.user.isGM && !this.collection.locked,
      onClick: async (event, li) => {
        const document = await this.collection.getDocument(li.dataset.entryId);
        return document?.deleteDialog();
      }
    }];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getFolderContextOptions() {
    return super._getFolderContextOptions().filter(({ label }) => {
      return (label !== "OWNERSHIP.Configure") && (label !== "FOLDER.Export");
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getFrameButtons(options) {
    const buttons = super._getFrameButtons(options);
    if ( this.collection.collection ) buttons.push({
      icon: "fa-solid fa-passport",
      label: "COMPENDIUM.CopyId",
      action: "copyId"
    });
    return buttons;
  }

  /* -------------------------------------------- */

  /** @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 */
  async _onClickEntry(event, target, options={}) {
    if ( this.documentName !== "Adventure" ) return super._onClickEntry(event, target, options);

    event.preventDefault();
    const { entryId } = target.closest("[data-entry-id]").dataset;

    // If full document is already cached, open its sheet as normal.
    const cached = this.collection.get(entryId);
    if ( cached instanceof foundry.abstract.Document ) return cached.sheet.render(true);

    // Otherwise create a shallow Adventure from index data.
    let adventure = Adventure$1.implementation.fromIndex(entryId, this.collection);

    // V1 sheets need the full Document.
    if ( adventure.sheet instanceof foundry.appv1.sheets.AdventureImporter ) {
      adventure = await this.collection.getDocument(entryId);
    }

    adventure.sheet.render(true);
  }

  /* -------------------------------------------- */

  /** @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);
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to copy the ID of this compendium to clipboard.
   * @param {PointerEvent} event
   * @this {Compendium}
   */
  static #onCopyId(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 = this.collection.collection;
    game.clipboard.copyPlainText(id);
    ui.notifications.info("COMPENDIUM.IdCopiedClipboard", {format: {id}});
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @override */
  _canDragDrop(selector) {
    return this.collection.testUserPermission(game.user, DOCUMENT_OWNERSHIP_LEVELS.OWNER);
  }

  /* -------------------------------------------- */

  /** @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};

    // Handle remote connection test
    if ( addresses.remoteIsAccessible === null ) {
      addresses.remoteClass = "unknown-connection";
      addresses.remoteTitle = _loc("INVITATIONS.UnknownConnection");
      addresses.failedCheck = true;
    }
    else if ( addresses.remoteIsAccessible ) {
      addresses.remoteClass = "connection";
      addresses.remoteTitle = _loc("INVITATIONS.OpenConnection");
      addresses.canConnect = true;
    }
    else if ( addresses.remoteIsAccessible === false ) {
      addresses.remoteClass = "no-connection";
      addresses.remoteTitle = _loc("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 = target.closest(".form-fields").querySelector("input");
    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 = target.closest(".form-fields").querySelector("input");
    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.
   * @param {Document} [document]  The document to bind listeners to.
   * @internal
   */
  _activateListeners(document=window.document) {
    const win = document.defaultView;
    KeyboardManager.#universalMode = game.settings.get("core", "universalKeybindings");
    win.addEventListener("keydown", event => this.#handleKeyboardEvent(event, false));
    win.addEventListener("keyup", event => this.#handleKeyboardEvent(event, true));
    win.addEventListener("visibilitychange", this.#reset.bind(this));
    win.addEventListener("compositionend", this.#onCompositionEnd.bind(this));
    win.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();

  /* -------------------------------------------- */

  /**
   * The set of panning keys which were recently pressed
   * @type {Set<string>}
   */
  panKeys = 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: "=",
      IntlBackslash: "\\",
      IntlRo: "/",
      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 doc = foundry.applications.detached.focused?.document ?? document;
    const focused = doc.activeElement;
    if ( foundry.utils.getType(focused) !== "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 ${_loc(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();
    this.panKeys = 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 movement operation was performed
   * @type {number}
   */
  #moveTime = 0;

  /* -------------------------------------------- */

  /**
   * 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(_loc("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 ( !Array.isArray(values) ) throw new Error(_loc("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(_loc("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(_loc("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", "moveUp", {
      name: "KEYBINDINGS.MoveUp",
      editable: [
        {key: "KeyW"}
      ],
      onUp: context => this.#onMove(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP]),
      onDown: context => this.#onMove(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "moveLeft", {
      name: "KEYBINDINGS.MoveLeft",
      editable: [
        {key: "KeyA"}
      ],
      onUp: context => this.#onMove(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      onDown: context => this.#onMove(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "moveDown", {
      name: "KEYBINDINGS.MoveDown",
      editable: [
        {key: "KeyS"}
      ],
      onUp: context => this.#onMove(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN]),
      onDown: context => this.#onMove(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "moveRight", {
      name: "KEYBINDINGS.MoveRight",
      editable: [
        {key: "KeyD"}
      ],
      onUp: context => this.#onMove(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      onDown: context => this.#onMove(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "moveUpLeft", {
      name: "KEYBINDINGS.MoveUpLeft",
      onUp: context => this.#onMove(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      onDown: context => this.#onMove(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "moveUpRight", {
      name: "KEYBINDINGS.MoveUpRight",
      onUp: context => this.#onMove(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      onDown: context => this.#onMove(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "moveDownLeft", {
      name: "KEYBINDINGS.MoveDownLeft",
      onUp: context => this.#onMove(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      onDown: context => this.#onMove(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "moveDownRight", {
      name: "KEYBINDINGS.MoveDownRight",
      onUp: context => this.#onMove(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      onDown: context => this.#onMove(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "ascend", {
      name: "KEYBINDINGS.Ascend",
      editable: [{key: "KeyE"}],
      onUp: context => this.#onMove(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.ASCEND]),
      onDown: context => this.#onMove(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.ASCEND]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "descend", {
      name: "KEYBINDINGS.Descend",
      editable: [{key: "KeyQ"}],
      onUp: context => this.#onMove(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DESCEND]),
      onDown: context => this.#onMove(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DESCEND]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panUp", {
      name: "KEYBINDINGS.PanUp",
      editable: [
        {key: "ArrowUp"},
        {key: "Numpad8"}
      ],
      onUp: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP]),
      onDown: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panLeft", {
      name: "KEYBINDINGS.PanLeft",
      editable: [
        {key: "ArrowLeft"},
        {key: "Numpad4"}
      ],
      onUp: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      onDown: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panDown", {
      name: "KEYBINDINGS.PanDown",
      editable: [
        {key: "ArrowDown"},
        {key: "Numpad2"}
      ],
      onUp: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN]),
      onDown: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panRight", {
      name: "KEYBINDINGS.PanRight",
      editable: [
        {key: "ArrowRight"},
        {key: "Numpad6"}
      ],
      onUp: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      onDown: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panUpLeft", {
      name: "KEYBINDINGS.PanUpLeft",
      editable: [
        {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: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panUpRight", {
      name: "KEYBINDINGS.PanUpRight",
      editable: [
        {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: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panDownLeft", {
      name: "KEYBINDINGS.PanDownLeft",
      editable: [
        {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: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panDownRight", {
      name: "KEYBINDINGS.PanDownRight",
      editable: [
        {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: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "zoomIn", {
      name: "KEYBINDINGS.ZoomIn",
      editable: [
        {key: "PageUp"},
        {key: "NumpadAdd"}
      ],
      onUp: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.ASCEND]),
      onDown: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.ASCEND]),
      reservedModifiers: [SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "zoomOut", {
      name: "KEYBINDINGS.ZoomOut",
      editable: [
        {key: "PageDown"},
        {key: "NumpadSubtract"}
      ],
      onUp: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DESCEND]),
      onDown: context => this.#onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DESCEND]),
      reservedModifiers: [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: _loc("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: _loc("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 ) 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) !== undefined;
  }

  /* -------------------------------------------- */

  /**
   * 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) !== undefined;
  }

  /* -------------------------------------------- */

  /**
   * 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) {
    let moveKeys = game.keyboard.moveKeys;
    if ( !moveKeys.size ) return;

    // Get the directions of movement
    const grid = canvas.grid;
    const diagonals = (grid.type !== CONST.GRID_TYPES.SQUARE) || (grid.diagonals !== CONST.GRID_DIAGONALS.ILLEGAL);
    if ( !diagonals ) moveKeys = new Set(Array.from(moveKeys).slice(-1));

    // Define movement offsets and get moved directions
    let dx = 0;
    let dy = 0;
    let dz = 0;
    if ( moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT) ) dx -= 1;
    if ( moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT) ) dx += 1;
    if ( moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.UP) ) dy -= 1;
    if ( moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN) ) dy += 1;
    if ( moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DESCEND) ) dz -= 1;
    if ( moveKeys.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.
   * @param {KeyboardEventContext} context        The context data of the event
   */
  #handleCanvasPan(context) {
    const panKeys = game.keyboard.panKeys;
    if ( !panKeys.size ) return;

    // Determine movement offsets
    let dx = 0;
    let dy = 0;
    let dz = 0;
    if ( panKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT) ) dx -= 1;
    if ( panKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.UP) ) dy -= 1;
    if ( panKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DESCEND) ) dz -= 1;
    if ( panKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT) ) dx += 1;
    if ( panKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN) ) dy += 1;
    if ( panKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.ASCEND) ) dz += 1;

    // Pan by the grid size
    const s = canvas.dimensions.size;
    const m = context.isShift ? 0.5 : 1;
    return canvas.animatePan({
      x: canvas.stage.pivot.x + (dx * s * m),
      y: canvas.stage.pivot.y + (dy * s * m),
      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 Move action
   * @param {KeyboardEventContext} context          The context data of the event
   * @param {string[]} movementDirections           The Directions being moved in
   */
  #onMove(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
    const moveKeys = game.keyboard.moveKeys;
    if ( context.up ) {
      for ( const d of movementDirections ) 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 ) moveKeys.add(d);

    // Delay 50ms before shifting tokens in order to capture diagonal movements
    const layer = canvas.activeLayer;
    if ( layer.options.keyboardMovableObjects ) {
      if ( delta < 100 ) return true; // Throttle keyboard movement once per 100ms
      setTimeout(() => this.#handleMovement(context, layer), 50);
    }
    this.#moveTime = now;
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Handle Pan action
   * @param {KeyboardEventContext} context         The context data of the event
   * @param {string[]} panningDirections           The Directions being panned in
   */
  #onPan(context, panningDirections) {

    // Case 1: Check for Tour
    if ( foundry.nue.Tour.tourInProgress && !context.repeat && !context.up ) {
      foundry.nue.Tour.onMovementAction(panningDirections);
      return true;
    }

    // Case 2: Check for Canvas
    if ( !canvas.ready ) return false;

    // Remove Keys on Up
    const panKeys = game.keyboard.panKeys;
    if ( context.up ) {
      for ( const d of panningDirections ) panKeys.delete(d);
      return true;
    }

    // Track the panning set
    for ( const d of panningDirections ) panKeys.add(d);

    this.#handleCanvasPan(context);
    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;
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  get moveKeys() {
    foundry.utils.logCompatibilityWarning("ClientKeybindings#moveKeys is deprecated in favor of game.keyboard.moveKeys.",
      {since: 14, until: 16});
    return game.keyboard.moveKeys;
  }
}

/**
 * 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) {
    const win = foundry.applications.detached.focused ?? window;
    const { document, navigator } = win;
    // 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 await 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.");
  }

  /* -------------------------------------------- */

  /**
   * 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;
    if ( isCtrl || isShift ) {

      // Case 1 - active ruler
      const ruler = canvas.controls.ruler;
      if ( ruler.active ) return ruler._onMouseWheel(event);

      // Case 2 - active layer
      return canvas.activeLayer._onMouseWheel(event);
    }

    // Case 3 - 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 _loc(this.config.title);
  }

  /**
   * The human-readable description of the tour.
   * @type {string}
   */
  get description() {
    return _loc(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: _loc("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: _loc(step.title),
      content: _loc(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 = Tour.highlightElement(this.targetElement, {
      padding: step.selector ? Tour.HIGHLIGHT_PADDING : 0
    });

    // 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();
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Create and append a tour-fadeout highlight element positioned over the given target element.
   * @param {HTMLElement} element                        The element to highlight.
   * @param {object} [options]
   * @param {number} [options.padding]                   Padding (px) around the element. Defaults to
   *                                                     Tour.HIGHLIGHT_PADDING.
   * @param {boolean} [options.preventInteraction=true]  Whether user interaction should be prevented while the
   *                                                     highlight is in effect.
   * @returns {HTMLElement}                              The created fadeout element, already appended to document.body.
   */
  static highlightElement(element, { padding=Tour.HIGHLIGHT_PADDING, preventInteraction=true }={}) {
    const rect = element.getBoundingClientRect();
    const highlight = element.ownerDocument.createElement("div");
    highlight.classList.add("tour-fadeout");
    Object.assign(highlight.style, {
      width: `${rect.width + padding}px`,
      height: `${rect.height + padding}px`,
      top: `${rect.top - (padding / 2)}px`,
      left: `${rect.left - (padding / 2)}px`,
      pointerEvents: preventInteraction ? "all" : "none"
    });
    element.ownerDocument.body.appendChild(highlight);
    return highlight;
  }
}

/**
 * @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 in the given document by listening for hover events on HTML elements which have a
   * data-tooltip defined.
   * @param {Document} document  The document to bind to.
   */
  activateListeners(document, { _deprecated=false }={}) {
    if ( !_deprecated && (foundry.utils.getDefiningClass(this, "activateEventListeners") !== TooltipManager) ) {
      foundry.utils.logCompatibilityWarning("TooltipManager#activateEventListeners is deprecated. "
        + "Please use TooltipManager#activateListeners instead.", { since: 14, until: 16 });
      this.activateEventListeners();
      return;
    }
    document ??= window.document;
    document.body.addEventListener("pointerenter", this.#onActivate.bind(this), { capture: true, passive: true });
    document.body.addEventListener("pointerleave", this.#onDeactivate.bind(this), { capture: true, passive: 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
    if ( this.#active ) {
      this.activate(element);
      return;
    }

    // Clear any existing deactivation workflow
    this.#clearDeactivation();

    // Delay activation to determine user intent
    this.#pending = element;
    this.#activationTimeout = window.setTimeout(() => {
      this.#activationTimeout = null;
      if ( this.#pending ) this.activate(this.#pending);
    }, this.constructor.TOOLTIP_ACTIVATION_MS);
  }

  /* -------------------------------------------- */

  /**
   * Handle hover events which deactivate a tooltipped element.
   * @param {PointerEvent} event    The initiating pointerleave event
   */
  #onDeactivate(event) {
    if ( event.target !== (this.element ?? this.#pending) ) return;
    const parent = event.target.parentElement.closest("[data-tooltip]");
    if ( parent ) this.activate(parent);
    else this.#startDeactivation();
  }

  /* -------------------------------------------- */

  /**
   * Start the deactivation process.
   */
  #startDeactivation() {
    if ( this.#deactivationTimeout ) return;

    // Clear any existing activation workflow
    this.clearPending();

    // Delay deactivation to confirm whether some new element is now pending
    this.#deactivationTimeout = window.setTimeout(() => {
      this.#deactivationTimeout = null;
      if ( !this.#pending ) this.deactivate();
    }, this.constructor.TOOLTIP_ACTIVATION_MS);
  }

  /* -------------------------------------------- */

  /**
   * Clear any existing deactivation workflow.
   */
  #clearDeactivation() {
    window.clearTimeout(this.#deactivationTimeout);
    this.#deactivationTimeout = null;
  }

  /* -------------------------------------------- */

  /**
   * Activate the tooltip for a hovered HTML element which defines a tooltip localization key.
   * @param {HTMLElement} element      The HTML element being hovered.
   * @param {object} [options={}]      Additional options which can override tooltip behavior.
   * @param {string} [options.text]    Explicit tooltip text to display. If this is not provided the tooltip text is
   *                                   acquired from the 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 ( !element.ownerDocument.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 || element.ariaLabel;
        // Localized message should be safe
        if ( game.i18n.has(text) ) this.tooltip.innerHTML = _loc(text);
        else this.tooltip.innerHTML = foundry.utils.cleanHTML(text);
      }
    }

    // Activate display of the tooltip
    foundry.applications.detached.adoptNodes(element.ownerDocument.body, this.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");
    this.tooltip.ownerDocument.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;
    const { innerHeight } = this.tooltip.ownerDocument.defaultView;
    return dirs[pos.y + this.tooltip.offsetHeight > 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 { innerHeight, innerWidth } = this.tooltip.ownerDocument.defaultView;
    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 = 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 = 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;
    const { innerHeight, innerWidth } = this.tooltip.ownerDocument.defaultView;

    // Left or Right
    const maxW = 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 = 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;
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14 until v16
   * @ignore
   */
  activateEventListeners() {
    foundry.utils.logCompatibilityWarning("TooltipManager#activateEventListeners is deprecated. "
      + "Please use TooltipManager#activateListeners instead.", { since: 14, until: 16 });
    this.activateListeners(document, { _deprecated: true });
  }
}

/**
 * @typedef {keyof typeof TooltipManager.TOOLTIP_DIRECTIONS} TooltipDirection
 */

var _module$H = /*#__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: _loc(action.name),
        hint: [
          reservedModifiers ? _loc("KEYBINDINGS.ReservedModifiers", {modifiers: reservedModifiers}) : "",
          action.restricted ? _loc("KEYBINDINGS.Restricted") : "",
          action.hint ? foundry.utils.escapeHTML(_loc(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 => _loc(c.name))
          );
          return {
            id: `${actionId}.binding.${index}`,
            display: ControlsConfig.humanizeBinding(binding),
            editable: !uneditable,
            conflicts: conflicts ? _loc("KEYBINDINGS.Conflict", {conflicts}) : null
          };
        })
      };
      const category = this.#categorizeEntry(action.namespace);
      categories[category.id] ??= {id: category.id, label: _loc(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 => _loc(`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 = _loc("COMMON.AreYouSure");
    const warning = _loc("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 => _loc(c.name)));
      input.dataset.tooltip = "";
      input.ariaLabel = _loc("KEYBINDINGS.Conflict", {conflicts: listString});
    }
    else {
      icon.className = "fa-regular fa-keyboard";
      delete input.dataset.tooltip;
      input.ariaLabel = _loc("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,
      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 = _loc("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 = _loc("SETUP.CompatibilityRequireUpdate", {version: required.compatibility.minimum});
        continue;
      }
      if ( c.maximum && foundry.utils.isNewerVersion(dependencyVersion, c.maximum) ) {
        hasDependencyIssue = true;
        required.class = "error";
        required.tooltip = _loc("SETUP.CompatibilityRequireDowngrade", {version: required.compatibility.maximum});
        continue;
      }
      if ( c.verified && !foundry.utils.isNewerVersion(dependencyVersion, c.verified) ) {
        required.class = "warning";
        required.tooltip = _loc("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 = _loc("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 = _loc("MODMANAGE.SystemCompatibilityIssueMinimum", {minimum, version});
    }
    if ( maximum && foundry.utils.isNewerVersion(version, maximum, { majorOnly: Number.isInteger(Number(maximum)) }) ) {
      module.enableable = false;
      module.tooltip = _loc("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 = _loc(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 = _loc(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 = `${_loc(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 = _loc("MODMANAGE.RequiredModule");

      // String formatting labels
      const authorsLabel = _loc(module.authors.size > 1 ? "MODMANAGE.Authors" : "MODMANAGE.Author");
      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: _loc(`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 = "";
    button.ariaLabel = _loc(`APPLICATION.ACTIONS.${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 = _loc("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,
      copyUuid: SupportDetails.#onCopyUuid,
      deleteDocument: SupportDetails.#onDeleteDocument
    }
  };

  /** @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);
    if ( partId === "tabs" ) partContext.tabClasses = "top-tabs";
    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: _loc(message, params)
        }));
        break;
      case "tabs":
        partContext.tabClasses = "top-tabs";
        break;
    }
    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});
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to copy the UUID of this document to clipboard.
   * @param {PointerEvent} event
   * @this {SupportDetails}
   */
  static #onCopyUuid(event) {
    event.preventDefault();
    event.stopPropagation();
    if ( event.detail > 1 ) return; // Ignore repeated clicks
    const {uuid} = event.target.closest("[data-uuid]").dataset;
    const documentName = foundry.utils.parseUuid(uuid).type;
    const documentClass = foundry.utils.getDocumentClass(documentName);
    const label = _loc(documentClass.metadata.label);
    game.clipboard.copyPlainText(uuid);
    ui.notifications.info("DOCUMENT.IdCopiedClipboard", {format: {label, type: "UUID", id: uuid}});
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to delete a document.
   * @param {PointerEvent} event
   * @this {SupportDetails}
   */
  static async #onDeleteDocument(event) {
    event.preventDefault();
    event.stopPropagation();
    if ( event.detail > 1 ) return; // Ignore repeated clicks
    const {uuid} = event.target.closest("[data-uuid]").dataset;
    const document = await foundry.utils.fromUuid(uuid, {invalid: true});
    await document?.deleteDialog({}, {noHook: 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(_loc("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(_loc("SUPPORT.CompendiumData"))}\n\n`;
      fullReport += packSizes.map(([collectionName, size]) => {
        const pack = game.packs.get(collectionName);
        const type = _loc(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(_loc("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 = _loc(cls.metadata.label);
      const labelPlural = _loc(cls.metadata.labelPlural);
      context.push({
        label: labelPlural,
        documents: Object.entries(documents).map(([uuid, {name, error}]) => {
          return {name: name ?? label, uuid, validationError: error.asHTML()};
        })
      });
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Marshal package-related warnings and errors and format it for display.
   * @returns {object[]}
   * @protected
   */
  _getModuleIssues() {
    const errors = {label: _loc("SUPPORT.Errors"), issues: []};
    const warnings = {label: _loc("SUPPORT.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(", ") || _loc("COMMON.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: _loc(performanceModes[game.settings.get("core", "performanceMode")]),
      screen: _loc("SUPPORT.Dimensions", window.screen),
      viewport: _loc("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} ${_loc("COMMON.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 _loc("WORLD.Title.Update", {world: this.world.title});
  }

  /* -------------------------------------------- */

  /** @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} = this;
    const themes = Object.entries(CONST.WORLD_JOIN_THEMES).reduce((themes, [key, label]) => {
      themes[key] = _loc(label);
      return themes;
    }, {});
    const context = {
      source: world._source,
      fields: world.schema.fields,
      rootId: this.id,
      themes,
      worldId: world._source.id,
      worldTitle: world._source.title,
      worldKbUrl: WorldConfig.#WORLD_KB_URL,
      nextSession: this.#formatNextSession(),
      inWorld: !!game.world,
      buttons: [{
        type: "submit",
        cssClass: "submit",
        icon: "fa-solid fa-floppy-disk",
        label: "WORLD.SubmitUpdate"
      }]
    };
    context.showEditFields = !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 editing an existing world.
   * @param {object} worldData  The updated world data.
   * @returns {Promise<void>}
   */
  async #onEditWorld(worldData) {
    try {
      worldData.action = "editWorld";
      const response = await this.#submitWorldData(worldData);
      this.world.updateSource(response);
    } catch {}
  }

  /* -------------------------------------------- */

  /**
   * Handle form submission.
   * @this {WorldConfig}
   * @type {ApplicationFormSubmission}
   */
  static async #onSubmit(event, form, formData) {
    const submitData = foundry.utils.expandObject(formData.object);

    // Augment submission actions
    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;
    return this.#processSubmitData(submitData);
  }

  /* -------------------------------------------- */

  /**
   * Create or update the World.
   * @param {object} submitData
   */
  async #processSubmitData(submitData) {
    const submit = this.form.querySelector("button.submit i");
    submit.parentElement.disabled = true;
    submit.classList.remove("fa-floppy-disk");
    submit.classList.add("fa-spinner", "fa-spin-pulse");

    // Validate the submission data
    try {
      this.world.validate({changes: submitData, clean: true});
    } catch(error) {
      ui.notifications.error(error.message.replace("\n", ". "));
      submit.parentElement.disabled = false;
      submit.classList.remove("fa-spinner", "fa-spin-pulse");
      submit.classList.add("fa-floppy-disk");
      throw error;
    }

    await this.#onEditWorld(submitData);

    submit.parentElement.disabled = false;
    submit.classList.remove("fa-spinner", "fa-spin-pulse");
    submit.classList.add("fa-floppy-disk");
    if ( game.view === "setup" ) ui.setupPackages.render();
  }

  /* -------------------------------------------- */

  /**
   * Submit world data to the /setup endpoint.
   * @param {object} worldData  The data to submit.
   * @returns {Promise<object>}
   * @throws {Error}            If the request was unsuccessful.
   */
  async #submitWorldData(worldData) {
    let response;
    try {
      response = await foundry.utils.fetchJsonWithTimeout(foundry.utils.getRoute("setup"), {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify(worldData)
      });
      if ( response.error ) throw response.error;
    } catch(e) {
      ui.notifications.error(e);
    }
    return response;
  }
}

/**
 * @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: "TOURS.Title",
      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 ? _loc("KEYBINDINGS.Restricted") : "",
          tour.description
        ].filterJoin("<br>")
      };
      switch ( tour.status ) {
        case Tour.STATUS.UNSTARTED: {
          data.status = _loc("TOURS.NotStarted");
          data.canPlay = tour.canStart;
          data.startOrResume = _loc("TOURS.Start");
          break;
        }
        case Tour.STATUS.IN_PROGRESS: {
          data.status = _loc("TOURS.InProgress", {
            current: (tour.stepIndex + 1) || 0,
            total: tour.steps.length ?? 0
          });
          data.canPlay = tour.canStart;
          data.canReset = true;
          data.startOrResume = _loc(`TOURS.${tour.config.canBeResumed ? "Resume" : "Restart"}`);
          break;
        }
        case Tour.STATUS.COMPLETED: {
          data.status = _loc("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: _loc("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: _loc("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>${_loc("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$G = /*#__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 {ApplicationConfiguration} from "../../_types.mjs";
 * @import {HandlebarsRenderOptions} from "../../api/handlebars-applications.mjs";
 * @import PlaceableTab from "../tabs/placeable-tab.mjs";
 */

/**
 * A dialog application for configuring advanced placeable filters.
 * @extends {ApplicationV2<ApplicationConfiguration, HandlebarsRenderOptions>}
 * @mixes HandlebarsApplication
 */
class PlaceableFilter extends HandlebarsApplicationMixin(ApplicationV2) {
  /**
   * @param {PlaceableTab} tab                             The tab this dialog belongs to.
   * @param {Partial<ApplicationConfiguration>} [options]  Application options.
   */
  constructor(tab, options={}) {
    super(options);
    this.#tab = tab;
    this.#applyFilters = foundry.utils.debounce(() => this.#tab._applyFilters(), 200);
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    tag: "form",
    classes: ["placeable-filter", "standard-form"],
    window: {
      title: "SIDEBAR.PLACEABLES.Filter.Title",
      icon: "fa-solid fa-filter",
      minimizable: false,
      resizable: false
    },
    position: {
      scale: .8,
      width: 375
    },
    actions: {
      clear: PlaceableFilter.#onClear
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    body: {
      root: true,
      classes: ["standard-form"],
      template: "templates/sidebar/filters/body.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A debounced function to apply filters on the parent tab.
   * @type {Function}
   */
  #applyFilters;

  /* -------------------------------------------- */

  /**
   * The tab this dialog belongs to.
   * @type {PlaceableTab}
   */
  get tab() {
    return this.#tab;
  }

  #tab;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _canDetach() {
    return false;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const { NumberField } = foundry.data.fields;
    const { elevation } = this.#tab._filterState;
    context.elevation = {
      bottom: {
        field: new NumberField({
          label: "SIDEBAR.PLACEABLES.Filter.Bottom", integer: true, placeholder: "\uf068\u221E"
        }, { name: "elevation.bottom" }),
        value: Number.isFinite(elevation.bottom) ? elevation.bottom : null
      },
      top: {
        field: new NumberField({
          label: "SIDEBAR.PLACEABLES.Filter.Top", integer: true, placeholder: "\u002b\u221E"
        }, { name: "elevation.top" }),
        value: Number.isFinite(elevation.top) ? elevation.top : null
      }
    };
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("input", this.#onFilterInput.bind(this), { passive: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle clearing all filters.
   * @this {PlaceableFilter}
   */
  static #onClear() {
    this.#tab._clearFilters();
    this.#tab._applyFilters();
    return this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle filtering.
   * @param {Event} event  The triggering event.
   */
  #onFilterInput(event) {
    const { target } = event;
    switch ( target.name ) {
      case "elevation.bottom":
        this.#tab._filterState.elevation.bottom = Number.isNaN(target.valueAsNumber) ? -Infinity : target.valueAsNumber;
        break;
      case "elevation.top":
        this.#tab._filterState.elevation.top = Number.isNaN(target.valueAsNumber) ? Infinity : target.valueAsNumber;
        break;
      default: return;
    }
    this.#applyFilters();
  }
}

/**
 * @import {ContextMenuCallback} from "../../ux/context-menu.mjs";
 */

/**
 * An advanced filter dialog for the AmbientLight placeable tab.
 * @extends {PlaceableFilter}
 */
class AmbientLightFilter extends PlaceableFilter {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "ambient-light-filter"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    body: {
      root: true,
      classes: ["standard-form"],
      template: "templates/sidebar/filters/ambient-light.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const { StringField, BooleanField } = foundry.data.fields;
    const { animationType, color, negative, walls, vision } = this.tab._filterState;

    // Build animation type choices from the registered animation types.
    const animationChoices = {
      "": "LIGHT.FIELDS.animation.type.any",
      none: "LIGHT.FIELDS.animation.type.none"
    };
    for ( const [k, v] of foundry.utils.objectEntries(CONFIG.Canvas.lightAnimations) ) animationChoices[k] = v.label;
    context.animationType = {
      field: new StringField({
        choices: animationChoices, label: "LIGHT.FIELDS.animation.type.label"
      }, { name: "animation.type" }),
      value: animationType ?? ""
    };
    context.color = { value: color };
    context.negative = {
      field: new BooleanField({ label: "LIGHT.FIELDS.negative.label" }, { name: "negative" }),
      value: negative
    };
    context.walls = {
      field: new BooleanField({ label: "AMBIENT_LIGHT.FIELDS.walls.label" }, { name: "walls" }),
      value: walls
    };
    context.vision = {
      field: new BooleanField({ label: "AMBIENT_LIGHT.FIELDS.vision.label" }, { name: "vision" }),
      value: vision
    };
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    new foundry.applications.ux.ContextMenu.implementation(
      this.element, "[data-action='selectColor']", [], {
        eventName: "click",
        fixed: true,
        relative: "target",
        jQuery: false,
        onOpen: () => { ui.context.menuItems = this.#buildColorMenuItems(); }
      }
    );
  }

  /* -------------------------------------------- */

  /**
   * Build the menu items for the color filter context menu.
   * @returns {ContextMenuEntry[]}
   */
  #buildColorMenuItems() {
    const state = this.tab._filterState;

    // Collect unique colours in use across all scene lights, sorted alphabetically.
    const colors = new Set();
    for ( const light of canvas.scene?.lights ?? [] ) {
      if ( light.config.color ) colors.add(light.config.color.css);
    }

    /**
     * Generate color filter callback.
     * @param {string|null} color  The filtered color.
     * @returns {ContextMenuCallback}
     */
    const onFilterColor = color => () => {
      state.color = color;
      this.tab._applyFilters();
      this.render();
    };

    return [
      {
        label: "LIGHT.FIELDS.animation.type.any",
        onClick: onFilterColor(null)
      },
      ...Array.from(colors).sort().map(color => ({
        label: color,
        icon: `<i class="color-swatch" style="background: ${color};"></i>`,
        onClick: onFilterColor(color)
      }))
    ];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    const { target } = event;
    const state = this.tab._filterState;
    switch ( target.name ) {
      case "animation.type":
        state.animationType = target.value || null;
        break;
      case "negative": state.negative = target.checked; break;
      case "walls": state.walls = target.checked; break;
      case "vision": state.vision = target.checked; break;
      default: return;
    }
    this.tab._applyFilters();
  }
}

/**
 * An advanced filter dialog for the Regions placeable tab.
 * @extends {PlaceableFilter}
 */
class RegionFilter extends PlaceableFilter {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "region-filter"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    body: {
      root: true,
      classes: ["standard-form"],
      template: "templates/sidebar/filters/region.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const { StringField } = foundry.data.fields;
    const choices = Object.fromEntries(
      Object.entries(CONFIG.RegionBehavior.typeLabels)
        .filter(([k]) => k !== CONST.BASE_DOCUMENT_TYPE)
    );
    const { behaviorType } = this.tab._filterState;
    context.behaviorType = {
      field: new StringField({ choices, label: "DOCUMENT.RegionBehavior" }, { name: "behaviorType" }),
      value: behaviorType
    };
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    const { target } = event;
    if ( target.name === "behaviorType" ) {
      this.tab._filterState.behaviorType = target.value;
      this.tab._applyFilters();
    }
  }
}

/**
 * An advanced filter dialog for the Tiles placeable tab.
 * @extends {PlaceableFilter}
 */
class TileFilter extends PlaceableFilter {
  static DEFAULT_OPTIONS = {
    id: "tile-filter"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    body: {
      root: true,
      classes: ["standard-form"],
      template: "templates/sidebar/filters/tile.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const { SetField, NumberField, BooleanField } = foundry.data.fields;

    // Include NONE to allow filtering for unoccluded tiles.
    const { OCCLUSION_MODES } = CONST;
    const allOcclusionModes = Object.entries(OCCLUSION_MODES).reduce((modes, [k, v]) => {
      modes[v] = `TILE.FIELDS.OCCLUSION_MODES.${k}.label`;
      return modes;
    }, {});
    const { occlusionModes, restrictions } = this.tab._filterState;
    context.occlusion = {
      field: new SetField(
        new NumberField({ choices: allOcclusionModes }),
        { label: "TILE.FIELDS.occlusion.modes.label" },
        { name: "occlusion.modes" }),
      value: occlusionModes
    };
    context.restrictions = {
      light: {
        field: new BooleanField({ label: "TILE.FIELDS.restrictions.light.label" }, { name: "restrictions.light" }),
        value: restrictions.light
      },
      weather: {
        field: new BooleanField({ label: "TILE.FIELDS.restrictions.weather.label" }, { name: "restrictions.weather" }),
        value: restrictions.weather
      }
    };
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    const { target } = event;
    const { restrictions } = this.tab._filterState;
    switch ( target.name ) {
      case "occlusion.modes":
        this.tab._filterState.occlusionModes = new Set(target.value.map(Number));
        break;
      case "restrictions.light": restrictions.light = target.checked; break;
      case "restrictions.weather": restrictions.weather = target.checked; break;
      default: return;
    }
    this.tab._applyFilters();
  }
}

var _module$F = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AmbientLightFilter: AmbientLightFilter,
  PlaceableFilter: PlaceableFilter,
  RegionFilter: RegionFilter,
  TileFilter: TileFilter
});

/**
 * @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();
    const configureOwnershipIndex = options.findIndex(o => o.label === "OWNERSHIP.Configure");
    options.splice(configureOwnershipIndex + 1, 0, {
      label: "SIDEBAR.CharArt",
      icon: "fa-solid fa-image",
      visible: li => {
        const actor = this.collection.get(li.dataset.entryId);
        const { img } = actor.constructor.getDefaultArtwork(actor._source);
        return actor.img !== img;
      },
      onClick: (event, 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 });
      }
    }, {
      label: "SIDEBAR.TokenArt",
      icon: "fa-solid fa-image",
      visible: 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);
      },
      onClick: (event, 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 });
      }
    });
    return 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?.img && 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 {
 *   ApplicationConfiguration, ApplicationRenderContext, ApplicationRenderOptions, FormFooterButton
 * } from "../../_types.mjs";
 * @import {HandlebarsRenderOptions} from "../../api/handlebars-applications.mjs";
 * @import Document from "@common/abstract/document.mjs";
 */

/**
 * @typedef _PlaceableTabConfiguration
 * @property {string} collectionName         The name of the Scene collection represented in this tab.
 * @property {PlaceableDirectory} directory  The parent directory this tab is part of.
 */

/**
 * @typedef {ApplicationConfiguration & _PlaceableTabConfiguration} PlaceableTabConfiguration
 */

/**
 * @typedef {ApplicationRenderOptions & HandlebarsRenderOptions} PlaceableTabRenderOptions
 */

/**
 * @typedef PlaceableTabEntryContext
 * @property {Partial<FormFooterButton>[]} buttons
 * @property {string} id
 * @property {string} label
 */

/**
 * An application responsible for rendering a view of all placeables of a given type on the viewed Scene.
 * @extends {ApplicationV2<ApplicationRenderContext, PlaceableTabConfiguration>}
 * @mixes HandlebarsApplication
 */
class PlaceableTab extends HandlebarsApplicationMixin(ApplicationV2) {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "{id}",
    tag: "section",
    classes: ["placeable-tab", "flexcol"],
    window: {
      frame: false,
      positioned: false
    },
    actions: {
      createEntry: PlaceableTab.#onCreateEntry,
      filterViewed: PlaceableTab.#onFilterViewed,
      openFilter: { handler: PlaceableTab.#onOpenFilter, buttons: [0, 2] }
    }
  };

  /**
   * The filter application class used by this tab.
   * @type {typeof PlaceableFilter}
   */
  static FILTER_CLASS = PlaceableFilter;

  /**
   * The template of the directory.
   * @type {string}
   */
  static DIRECTORY_PARTIAL = "templates/sidebar/tabs/placeable/placeables.hbs";

  /**
   * The template of a single directory entry.
   * @type {string}
   */
  static ENTRY_PARTIAL = "templates/sidebar/tabs/placeable/placeable.hbs";

  /** @override */
  static PARTS = {
    search: { template: "templates/sidebar/tabs/placeable/search.hbs" },
    directory: {
      template: "templates/sidebar/tabs/placeable/directory.hbs",
      scrollable: [""]
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The Scene's collection whose contents are shown in this tab.
   * @type {string}
   */
  get collectionName() {
    return this.options.collectionName;
  }

  /* -------------------------------------------- */

  /**
   * The Document class of the entries shown in this tab.
   * @type {typeof Document}
   */
  get documentClass() {
    return foundry.documents.Scene.hierarchy[this.collectionName].model;
  }

  /* -------------------------------------------- */

  /**
   * The canvas layer whose contents are shown in this tab.
   * @returns {PlaceablesLayer}
   */
  get layer() {
    return canvas?.getCollectionLayer(this.collectionName);
  }

  /* -------------------------------------------- */

  /**
   * The schema of the entries shown in this tab.
   * @returns {DataModelSchemaField}
   */
  get schema() {
    return this.documentClass.schema;
  }


  /* -------------------------------------------- */

  /**
   * Track whether the tab is restricted to objects that are currently viewed.
   * @type {boolean}
   */
  static #filterViewed = true;

  /* -------------------------------------------- */

  /**
   * Current filter state.
   * @type {{
   *   dialog: PlaceableFilter|null,
   *   elevation: { bottom, top: number },
   *   levels: Set<string>,
   *   visible: Set<string>,
   *   [p: string]: any
   * }}
   * @protected
   */
  _filterState = {
    dialog: null,
    elevation: { bottom: -Infinity, top: Infinity },
    levels: new Set(),
    visible: new Set()
  };

  /* -------------------------------------------- */

  /**
   * The filter helper.
   * @type {SearchFilter}
   * @protected
   */
  _searchFilter = new SearchFilter({
    inputSelector: "input[type=search]",
    contentSelector: ".placeable-directory",
    callback: this._onSearchFilter.bind(this)
  });

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Highlight a hovered entry in the sidebar.
   * @param {PlaceableObject} object  The object being hovered on canvas.
   * @param {boolean} hover           The hover state.
   */
  hoverEntry(object, hover) {
    if ( !this.rendered ) return;
    this.element.querySelector(`[data-entry-id="${object.id}"]`)?.classList.toggle("hovered", hover);
  }

  /* -------------------------------------------- */
  /**
   * Determine if a placeable is visible in the sidebar.
   * @param {PlaceableObject} object  The placeable.
   * @returns {boolean}
   */
  isEntryVisible(object) {
    if ( !this.rendered ) return true;
    return this._filterState.visible.has(object.document);
  }

  /* -------------------------------------------- */

  /**
   * Test whether this tab currently has any active filter other than "Filter Viewed".
   * @returns {boolean}
   * @protected
   */
  _isFiltered() {
    return !!this._searchFilter.query || !!this._filterState.levels.size || this._hasAdvancedFilters();
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    const applicationOptions = super._initializeApplicationOptions(options);
    if ( options.collectionName ) {
      applicationOptions.uniqueId = `${options.collectionName}-tab${options.directory?.isPopout ? "-popout" : ""}`;
      applicationOptions.classes.push(`${options.collectionName}-tab`);
    }
    return applicationOptions;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    parts.directory.templates.push(this.constructor.DIRECTORY_PARTIAL, this.constructor.ENTRY_PARTIAL);
    return parts;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve an entry's label for use in the directory.
   * @param {Document} entry  The directory entry.
   * @returns {string}
   * @protected
   */
  _getEntryLabel(entry) {
    return entry.name || entry.id;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    this._createContextMenu(this._getEntryContextOptions, ".placeable-entry", {
      fixed: true, parentClassHooks: false,
      hookName: `get${this.documentClass.documentName}PlaceableContextOptions`
    });
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the directory.
   * @param {ApplicationRenderContext} context   Render context.
   * @param {PlaceableTabRenderOptions} options  Render options.
   * @returns {Promise}
   * @protected
   */
  async _prepareDirectoryContext(context, options) {
    const controlled = Object.fromEntries(this.layer.controlled.map(o => [o.id, o]));
    context.directoryPartial = this.constructor.DIRECTORY_PARTIAL;
    context.entryPartial = this.constructor.ENTRY_PARTIAL;
    const promises = [];
    for ( const entry of canvas.scene?.[this.collectionName] ?? [] ) {
      if ( !entry.visible ) continue;
      const { id } = entry;
      const css = id in controlled ? "active" : "";
      const ctx = { css, id, label: this._getEntryLabel(entry) };
      promises.push(this._prepareEntry(entry, ctx));
    }
    context.entries = await Promise.all(promises);
    context.entries.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Entry-specific preparation.
   * @param {Document} entry                    The Document instance.
   * @param {PlaceableTabEntryContext} context  Render context.
   * @returns {Promise<object>}
   * @protected
   */
  async _prepareEntry(entry, context) {
    context.hidden = entry.hidden;
    context.locked = entry.locked;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "directory": return this._prepareDirectoryContext(context, options);
      case "search": return this._prepareSearchContext(context, options);
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for the search part.
   * @param {ApplicationRenderContext} context   Render context.
   * @param {PlaceableTabRenderOptions} options  Render options.
   * @returns {Promise}
   * @protected
   */
  async _prepareSearchContext(context, options) {
    const hasLevels = (this.schema.has("levels") || this.schema.has("level")) && (canvas.scene?.levels.size > 1);
    const canCreate = this.collectionName === "regions";
    const { label, labelPlural } = this.documentClass.metadata;
    context.labels = {
      create: _loc("SIDEBAR.Create", { type: _loc(label) }),
      placeholder: _loc("SIDEBAR.PLACEABLES.Search.placeholder", { name: _loc(labelPlural) })
    };
    const { elevation, levels } = this._filterState;
    const state = foundry.utils.mergeObject(this._filterState, {
      filteredByLevel: !!levels.size
    }, { inplace: false });
    const filters = [
      {
        action: "filterViewed",
        active: PlaceableTab.#filterViewed,
        cssClass: "fa-solid fa-image",
        tooltip: "SIDEBAR.PLACEABLES.Filter.Viewed"
      },
      {
        action: "filterByLevel",
        active: state.filteredByLevel,
        cssClass: "fa-solid fa-layer-group",
        tooltip: "SIDEBAR.PLACEABLES.Filter.Level"
      },
      {
        action: "openFilter",
        active: Number.isFinite(elevation.bottom) || Number.isFinite(elevation.top),
        cssClass: "fa-solid fa-filter",
        tooltip: "SIDEBAR.PLACEABLES.Filter.Tooltip"
      }
    ];
    Object.assign(context, { canCreate, filters, hasLevels, state });
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("click", this._onClickEntry.bind(this), { passive: true });
    this.element.addEventListener("dblclick", event => this._onConfigureEntry(event, event.target), { passive: true });
    new FilterMenu.implementation(this.element, "search [data-action=filterByLevel]", {
      menuItems: this.#getLevelFilterOptions.bind(this)
    });
  }

  /* -------------------------------------------- */

  /**
   * Get context menu entries for the entries in this tab.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getEntryContextOptions() {
    return [{
      label: "SIDEBAR.Edit",
      icon: "fa-solid fa-edit",
      onClick: (event, li) => this._onConfigureEntry(null, li)
    }, {
      label: "SIDEBAR.PLACEABLES.ACTIONS.Lock",
      icon: "fa-solid fa-lock",
      visible: li => {
        if ( !this.schema.has("locked") ) return false;
        const doc = this._getPlaceableFromElement(li);
        return !doc.locked && doc.canUserModify(game.user, "update", {locked: true});
      },
      onClick: (event, li) => this._onToggleLocked(null, li)
    }, {
      label: "SIDEBAR.PLACEABLES.ACTIONS.Unlock",
      icon: "fa-solid fa-lock-open",
      visible: li => {
        if ( !this.schema.has("locked") ) return false;
        const doc = this._getPlaceableFromElement(li);
        return doc.locked && doc.canUserModify(game.user, "update", {locked: false});
      },
      onClick: (event, li) => this._onToggleLocked(null, li)
    }, {
      label: "SIDEBAR.PLACEABLES.ACTIONS.Hide",
      icon: "fa-solid fa-eye-slash",
      visible: li => {
        if ( !this.schema.has("hidden") ) return false;
        const doc = this._getPlaceableFromElement(li);
        return !doc.hidden && doc.canUserModify(game.user, "update", {hidden: true});
      },
      onClick: (event, li) => this._onToggleHidden(null, li)
    }, {
      label: "SIDEBAR.PLACEABLES.ACTIONS.Show",
      icon: "fa-solid fa-eye",
      visible: li => {
        if ( !this.schema.has("hidden") ) return false;
        const doc = this._getPlaceableFromElement(li);
        return doc.hidden && doc.canUserModify(game.user, "update", {hidden: false});
      },
      onClick: (event, li) => this._onToggleHidden(null, li)
    }, {
      label: "SIDEBAR.Delete",
      icon: "fa-solid fa-trash",
      visible: li => {
        const doc = this._getPlaceableFromElement(li);
        if ( !doc.canUserModify(game.user, "delete") ) return false;
        return !doc.locked;
      },
      onClick: (event, li) => this._onDeleteEntry(null, li)
    }, {
      label: "SIDEBAR.Duplicate",
      icon: "fa-regular fa-copy",
      visible: li => this._getPlaceableFromElement(li).isOwner && this.documentClass.canUserCreate(game.user),
      onClick: (event, li) => this._onDuplicateEntry(null, li)
    }];
  }

  /* -------------------------------------------- */

  /**
   * Get options for filtering by level.
   * @returns {ContextMenuEntry[]}
   */
  #getLevelFilterOptions() {
    const entries = Array.from(canvas.scene.availableLevels.map(({ id, name }) => {
      return {
        label: name,
        icon: this._filterState.levels.has(id) ? "fa-solid fa-check" : "fa-solid",
        onClick: () => this._onFilterByLevel(id)
      };
    }));
    entries.push({
      label: _loc("SIDEBAR.PLACEABLES.ACTIONS.Clear"),
      icon: "fa-solid fa-xmark",
      onClick: () => {
        this._onFilterByLevel(null);
        ui.context.close();
      },
      group: "state"
    });
    return entries;
  }

  /* -------------------------------------------- */

  /**
   * Handle selecting an entry.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onClickEntry(event) {
    if ( event.target.closest("button") ) return;
    if ( !ui.controls.tool?.control ) ui.controls.activate({ tool: "select" });
    const doc = this._getPlaceableFromElement(event.target);
    if ( !doc?.rendered ) return;
    if ( doc.object.controlled && event.shiftKey ) doc.object.release();
    else doc.object.control({ force: true, releaseOthers: !event.shiftKey });
  }

  /* -------------------------------------------- */

  /**
   * Handle configuring an entry.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @protected
   */
  _onConfigureEntry(event, target) {
    if ( event?.target.closest("button") ) return;
    return this._getPlaceableFromElement(target)?.sheet.render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle creating an entry.
   * @this {PlaceableTab}
   * @param {...any} args
   * @returns {Promise<Document|void>}
   */
  static #onCreateEntry(...args) {
    return this._onCreateEntry(...args);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling viewed filter.
   * @this {PlaceableTab}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onFilterViewed(event, target) {
    const viewed = target.ariaPressed !== "true";
    PlaceableTab.#filterViewed = viewed;
    target.ariaPressed = viewed;
    this._applyFilters();
  }

  /* -------------------------------------------- */

  /**
   * Handle opening or closing the advanced filter dialog.
   * @this {PlaceableTab}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onOpenFilter(event, target) {
    if ( event.button === 2 ) {
      this._clearFilters();
      this._applyFilters();
      return;
    }
    const { dialog } = this._filterState;
    if ( dialog?.rendered ) return dialog.close();
    const { left, bottom } = target.getBoundingClientRect();
    this._filterState.dialog = new this.constructor.FILTER_CLASS(this);
    return this._filterState.dialog.render({ force: true, position: { top: bottom, left } });
  }

  /* -------------------------------------------- */

  /**
   * Handle creating an entry.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @protected
   */
  async _onCreateEntry(event, target) {
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", { localize: true });
      return;
    }

    const createData = {};
    if ( this.schema.has("name") ) {
      createData.name = this.documentClass.implementation.defaultName({ parent: canvas.scene });
    }
    return this.documentClass.implementation.create(createData, { parent: canvas.scene });
  }

  /* -------------------------------------------- */

  /**
   * Clear state when deactivating the directory of this tab.
   * @internal
   */
  _onDeactivateDirectory() {
    this._filterState.dialog?.close();
  }

  /* -------------------------------------------- */

  /**
   * Clear state when deactivating this tab.
   * @internal
   */
  _onDeactivateTab() {
    this._filterState.elevation = { bottom: -Infinity, top: Infinity };
    this._filterState.visible.clear();
    this._filterState.dialog?.close();
    // Preserve filtered levels when moving between tabs.
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting an entry.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @returns {Promise<void>}
   * @protected
   */
  async _onDeleteEntry(event, target) {
    const doc = this._getPlaceableFromElement(target);
    if ( !doc ) return;
    if ( canvas.ready && doc.rendered && doc.object.controlled && (this.layer.controlled.length > 1) ) {
      const toDelete = this.layer.controlled.map(o => o.id);
      const question = _loc("COMMON.AreYouSure");
      const warning = _loc("SIDEBAR.DeleteManyWarning", {
        type: _loc(this.documentClass.metadata.labelPlural),
        count: this.layer.controlled.length
      });
      const confirmed = await DialogV2.confirm({
        window: {
          icon: "fa-solid fa-trash",
          title: _loc("DOCUMENT.Delete", { type: _loc(this.documentClass.metadata.label) })
        },
        content: `<p><strong>${question}</strong> ${warning}</p>`
      });
      if ( confirmed ) await canvas.scene.deleteEmbeddedDocuments(this.documentClass.documentName, toDelete);
    }
    else await doc.deleteDialog();
  }

  /* -------------------------------------------- */

  /**
   * Handle duplicating an entry.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @returns {Promise<Document>}
   * @protected
   */
  async _onDuplicateEntry(event, target) {
    const doc = this._getPlaceableFromElement(target);
    if ( !doc ) return;
    const cloneOptions = { addSource: true, discardInvalidEmbedded: true };
    const createOptions = { controlObject: true };
    if ( canvas.ready && doc.rendered && doc.object.controlled ) {
      const toCreate = this.layer.controlled.map(o => {
        const original = o.document;
        const clone = original.clone(this._prepareDuplicateData(original), cloneOptions);
        return clone.toObject();
      });
      await canvas.scene.createEmbeddedDocuments(this.documentClass.documentName, toCreate, createOptions);
    }
    else await doc.clone(this._prepareDuplicateData(doc), { ...cloneOptions, ...createOptions, save: 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) {
    const data = {};
    if ( document._source.name ) data.name = _loc("DOCUMENT.CopyOf", {name: document._source.name});
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Handle dragging an entry.
   * @param {DragEvent} event  The triggering event.
   * @protected
   */
  _onDragStart(event) {
    const doc = this._getPlaceableFromElement(event.currentTarget);
    if ( doc ) event.dataTransfer.setData("text/plain", JSON.stringify(doc.toDragData()));
  }

  /* -------------------------------------------- */

  /**
   * Handle adjusting the levels filter.
   * @param {string|null} id  The ID of the level to add/remove, or null to clear the filter entirely and show all
   *                          levels.
   * @protected
   */
  _onFilterByLevel(id) {
    const { levels } = this._filterState;
    if ( id === null ) levels.clear();
    else if ( levels.has(id) ) levels.delete(id);
    else levels.add(id);
    this.element.querySelector("search [data-action=filterByLevel]").ariaPressed = String(!!levels.size);
    this._applyFilters();
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering over a directory entry.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onHoverInEntry(event) {
    if ( !event.target.matches("[data-entry-id]") || !canvas.ready ) return;
    event.target.classList.add("hovered");
    this._getPlaceableFromElement(event.target)?.object?._onHoverIn(event, {
      hoverOutOthers: true, updateLegend: false
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle no longer hovering over a directory entry.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onHoverOutEntry(event) {
    if ( !event.target.matches("[data-entry-id]") || !canvas.ready ) return;
    event.target.classList.remove("hovered");
    this._getPlaceableFromElement(event.target)?.object?._onHoverOut(event, { updateLegend: false });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    new DragDrop.implementation({
      dragSelector: "[data-entry-id]",
      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("[data-entry-id]") ) {
      li.addEventListener("pointerenter", this._onHoverInEntry.bind(this), { passive: true });
      li.addEventListener("pointerleave", this._onHoverOutEntry.bind(this), { passive: true });
    }
    this._applyFilters();
  }

  /* -------------------------------------------- */

  /**
   * Handle post-filter operations.
   * @param {KeyboardEvent} event  The triggering event.
   * @param {string} query         The raw search query.
   * @param {RegExp} rgx           The regular expression to test against.
   * @param {HTMLElement} html     The element that should be filtered.
   * @protected
   */
  _onSearchFilter(event, query, rgx, html) {
    if ( !this.rendered ) return;
    this._applyFilters();
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling an entry's hidden state.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @returns {Promise<void>}
   * @protected
   */
  async _onToggleHidden(event, target) {
    const doc = this._getPlaceableFromElement(target);
    if ( !doc ) return;
    const { hidden } = doc;
    if ( canvas.ready && doc.rendered && doc.object.controlled ) {
      const toUpdate = this.layer.controlled.map(o => ({ _id: o.id, hidden: !hidden }));
      await canvas.scene.updateEmbeddedDocuments(this.documentClass.documentName, toUpdate);
    }
    else await doc.update({ hidden: !hidden });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling an entry's locked state.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @returns {Promise<void>}
   * @protected
   */
  async _onToggleLocked(event, target) {
    const doc = this._getPlaceableFromElement(target);
    if ( !doc ) return;
    const { locked } = doc;
    if ( canvas.ready && doc.rendered && doc.object.controlled ) {
      const toUpdate = this.layer.controlled.map(o => ({ _id: o.id, locked: !locked }));
      await canvas.scene.updateEmbeddedDocuments(this.documentClass.documentName, toUpdate);
    }
    else await doc.update({ locked: !locked });
  }

  /* -------------------------------------------- */
  /*  Filtering                                   */
  /* -------------------------------------------- */

  /**
   * Filter out elements from the sidebar based on user input.
   * @protected
   */
  _applyFilters() {
    if ( !this.layer || !canvas.ready ) return; // Still drawing.
    let releasedObject = false;
    const viewed = PlaceableTab.#filterViewed;
    const { elevation, levels, visible } = this._filterState;
    visible.clear();
    const { cleanQuery } = this._searchFilter.constructor;
    const elevationField = this.schema.getField("elevation");
    const hasElevationRange = elevationField instanceof foundry.data.fields.SchemaField;
    const hasElevation = hasElevationRange || (elevationField instanceof foundry.data.fields.NumberField);
    for ( const li of this.element.querySelectorAll("[data-entry-id]") ) {
      const entry = this._getPlaceableFromElement(li);
      const matchesName = this._searchFilter.rgx?.test(cleanQuery(this._getEntryLabel(entry))) !== false;
      let matchesElevation = true;
      if ( hasElevationRange ) {
        const {bottom, top, topInclusive=true} = entry.elevation;
        const a = Math.max(bottom, elevation.bottom);
        const b = Math.min(top, elevation.top);
        if ( topInclusive || (bottom === top) || (top > b) ) matchesElevation = a <= b;
        else matchesElevation = a < b;
      } else if ( hasElevation ) {
        matchesElevation = (entry.elevation >= elevation.bottom) && (entry.elevation <= elevation.top);
      }
      const matchesLevel = !levels.size || levels.some(level => entry.locatedInLevel(level));
      let hidden = !matchesName || !matchesElevation || !matchesLevel || (viewed && !entry.viewed);
      if ( !hidden ) hidden = !this._matchesFilter(entry);
      if ( hidden ) {
        if ( entry.rendered && entry.object.controlled ) {
          entry.object.release({ renderSidebar: false });
          releasedObject = true;
        }
        li.classList.remove("active");
      }
      else visible.add(entry);
      li.hidden = hidden;
    }
    // Recompute grouped category counts from visible entries.
    for ( const group of this.element.querySelectorAll(".placeable-group") ) {
      const meta = group.querySelector(".group-header .meta");
      if ( meta ) {
        const count = group.querySelectorAll("[data-entry-id]").values().reduce((n, li) => n + (li.hidden ? 0 : 1), 0);
        meta.textContent = `(${count})`;
      }
    }
    this.layer.setAllRenderFlags({ refreshState: true });
    const filter = this.element.querySelector("search [data-action=openFilter]");
    if ( filter ) filter.ariaPressed = String(this._hasAdvancedFilters());
    ui.placeables?._updateFilterPip();
    if ( releasedObject && (game.activeTool === "select") ) ui.placeablesPalette?.render();
  }

  /* -------------------------------------------- */

  /**
   * Test whether a placeable entry matches any additional tab-specific filter criteria.
   * Only called for entries that already pass the common name, elevation, and level filters.
   * @param {Document} entry  The placeable document to test.
   * @returns {boolean}       True if the entry should remain visible.
   * @protected
   */
  _matchesFilter(entry) {
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Test whether any advanced filter criteria are currently active.
   * @returns {boolean}
   * @protected
   */
  _hasAdvancedFilters() {
    const { elevation } = this._filterState;
    return Number.isFinite(elevation.bottom) || Number.isFinite(elevation.top);
  }

  /* -------------------------------------------- */

  /**
   * Clear all advanced filter state.
   * @protected
   */
  _clearFilters() {
    this._filterState.elevation = { bottom: -Infinity, top: Infinity };
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve the Document instance represented by the given entry's element.
   * @param {HTMLElement} element
   * @returns {Document}
   * @protected
   */
  _getPlaceableFromElement(element) {
    const { entryId } = element.closest("[data-entry-id]")?.dataset ?? {};
    return canvas.scene.getEmbeddedCollection(this.collectionName).get(entryId);
  }
}

/**
 * The AmbientLight-specific placeables tab.
 * @extends {PlaceableTab}
 */
class AmbientLightTab extends PlaceableTab {
  constructor(...args) {
    super(...args);
    Object.assign(this._filterState, {
      animationType: null,
      color: null,
      negative: false,
      walls: false,
      vision: false
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static FILTER_CLASS = AmbientLightFilter;

  /* -------------------------------------------- */
  /*  Filtering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyFilters() {
    super._applyFilters();
    if ( !canvas.activeLayer || !canvas.scene ) return;
    this.layer.placeables.forEach(p => p.initializeLightSource());
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _clearFilters() {
    super._clearFilters();
    Object.assign(this._filterState, {
      animationType: null, color: null, negative: false, walls: false, vision: false
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _matchesFilter(entry) {
    const { animationType, color, negative, walls, vision } = this._filterState;
    if ( animationType !== null ) {
      const type = entry.config.animation.type;
      const matchesAnimation = animationType === "none" ? type === null : type === animationType;
      if ( !matchesAnimation ) return false;
    }
    if ( (color !== null) && (entry.config.color?.css !== color) ) return false;
    if ( negative && !entry.config.negative ) return false;
    if ( walls && !entry.walls ) return false;
    if ( vision && !entry.vision ) return false;
    return true;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _hasAdvancedFilters() {
    const { animationType, color, negative, walls, vision } = this._filterState;
    return super._hasAdvancedFilters() || (animationType !== null) || (color !== null) || negative || walls || vision;
  }
}

/**
 * The AmbientSound-specific placeables tab.
 * @extends {PlaceableTab}
 */
class AmbientSoundTab extends PlaceableTab {
  /** @inheritDoc */
  _applyFilters() {
    super._applyFilters();
    if ( !canvas.activeLayer || !canvas.scene ) return;
    this.layer.placeables.forEach(p => p.initializeSoundSource());
  }
}

/**
 * @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.label === "SIDEBAR.Duplicate");
    duplicate.visible = 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,
      editName: CombatTracker.#onEditName,
      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;
    let data = {};
    if ( Array.isArray(renderData) ) 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((c, i) => ({ id: c.id, name: c.name, 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
    });
  }

  /* -------------------------------------------- */

  /**
   * 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 = [];
    const SHOW_ICON = CONST.ACTIVE_EFFECT_SHOW_ICON;
    for ( const effect of combatant.actor?.appliedEffects ?? [] ) {
      if ( effect.statuses.has(CONFIG.specialStatusEffects.DEFEATED) ) turn.isDefeated = true;
      else if ( (effect.showIcon === SHOW_ICON.ALWAYS)
        || ((effect.showIcon === SHOW_ICON.CONDITIONAL) && effect.isTemporary) ) {
        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 ( foundry.utils.isElementInstanceOf(event.target, "input") ) 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 [{
      label: "COMBATANT.ACTIONS.Update",
      icon: "fa-solid fa-edit",
      visible: () => game.user.isGM,
      onClick: (event, li) => getCombatant(li)?.sheet.render({
        force: true,
        position: {
          top: Math.min(li.offsetTop, window.innerHeight - 350),
          left: window.innerWidth - 720
        }
      })
    }, {
      label: "COMBATANT.ACTIONS.Clear",
      icon: "fa-solid fa-arrow-rotate-left",
      visible: li => game.user.isGM && Number.isFinite(getCombatant(li)?.initiative),
      onClick: (event, li) => getCombatant(li)?.update({ initiative: null })
    }, {
      label: "COMBATANT.ACTIONS.Reroll",
      icon: "fa-solid fa-dice-d20",
      visible: () => game.user.isGM,
      onClick: (event, li) => {
        const combatant = getCombatant(li);
        if ( combatant ) return this.viewed.rollInitiative([combatant.id]);
      }
    }, {
      label: "COMBATANT.ACTIONS.ClearMovementHistory",
      icon: "fa-solid fa-shoe-prints",
      visible: li => game.user.isGM && (getCombatant(li)?.token?.movementHistory.length > 0),
      onClick: async (event, li) => {
        const combatant = getCombatant(li);
        if ( !combatant ) return;
        await combatant.clearMovementHistory();
        ui.notifications.info("COMBATANT.MovementHistoryCleared", {format: {name: combatant.token.name}});
      }
    }, {
      label: "COMBATANT.ACTIONS.Remove",
      icon: "fa-solid fa-trash",
      visible: () => game.user.isGM,
      onClick: (event, li) => getCombatant(li)?.delete()
    }];
  }

  /* -------------------------------------------- */

  /**
   * Get context menu entries for Combat in the tracker.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getCombatContextOptions() {
    return [{
      label: "COMBAT.ACTIONS.EditName",
      icon: "fa-solid fa-tag",
      visible: () => game.user.isGM && !!this.viewed,
      onClick: () => CombatTracker.#onEditName.call(this)
    }, {
      label: "COMBAT.InitiativeReset",
      icon: "fa-solid fa-arrow-rotate-left",
      visible: () => game.user.isGM && (this.viewed?.turns.length > 0),
      onClick: () => this.viewed.resetAll()
    }, {
      label: "COMBAT.ClearMovementHistories",
      icon: "fa-solid fa-shoe-prints",
      visible: () => game.user.isGM && (this.viewed?.combatants.size > 0),
      onClick: () => this.viewed.clearMovementHistories()
    }, {
      label: "COMBAT.ACTIONS.LinkToScene",
      icon: '<i class="fa-solid fa-link"></i>',
      visible: () => game.user.isGM && !this.scene,
      onClick: () => this.viewed.toggleSceneLink()
    }, {
      label: "COMBAT.ACTIONS.UnlinkFromScene",
      icon: '<i class="fa-solid fa-unlink"></i>',
      visible: () => game.user.isGM && !!this.scene,
      onClick: () => this.viewed.toggleSceneLink()
    }, {
      label: "COMBAT.End",
      icon: "fa-solid fa-xmark",
      visible: () => game.user.isGM && !!this.viewed,
      onClick: () => 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 });
  }

  /* -------------------------------------------- */

  /**
   * Open the dialog used to edit the name of the currently viewed Combat encounter.
   * @this {CombatTracker}
   * @returns {Promise<void>}
   */
  static async #onEditName() {
    const combat = this.viewed;
    if ( !combat || !game.user.isGM ) return null;
    const field = combat.schema.fields.name;
    const inputHTML = field.toFormGroup({}, {name: "name", value: combat.name, autofocus: true}).outerHTML;
    const formData = await foundry.applications.api.DialogV2.input({
      window: {icon: "fa-solid fa-tag", title: "COMBAT.ACTIONS.EditNameTitle"},
      position: {width: 480},
      content: inputHTML
    });
    await combat.update({name: formData.name || ""});
  }

  /* -------------------------------------------- */

  /**
   * 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 ( foundry.utils.isElementInstanceOf(event.target, "input")
      || foundry.utils.isElementInstanceOf(event.target, "button") ) 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("COMBATANT.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("COMBATANT.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 {ApplicationClickAction, ApplicationConfiguration} from "../../_types.mjs";
 * @import {HandlebarsRenderOptions} from "../../api/handlebars-application.mjs";
 * @import Document from "@common/abstract/document.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,
      openDocumentSheet: CompendiumDirectory.#onOpenDocumentSheet
    }
  };

  /** @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",
        "templates/sidebar/partials/document-match.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;
  }

  /**
   * @type {Set<string>}
   */
  #activeFilters = new Set();

  /**
   * The list-item element for a document search match.
   * @type {HTMLElement}
   */
  #documentMatchEl;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Get context menu entries for entries in this directory.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getEntryContextOptions() {
    if ( !game.user.isGM ) return [];
    return [{
      label: "OWNERSHIP.Configure",
      icon: "fa-solid fa-user-lock",
      onClick: (event, li) => game.packs.get(li.dataset.pack)?.configureOwnershipDialog()
    }, {
      label: "COMPENDIUM.ToggleLocked.Lock",
      icon: "fa-solid fa-lock",
      visible: li => game.packs.get(li.dataset.pack)?.locked === false,
      onClick: (event, li) => this._onToggleLock(li)
    }, {
      label: "COMPENDIUM.ToggleLocked.Unlock",
      icon: "fa-solid fa-lock-open",
      visible: li => game.packs.get(li.dataset.pack)?.locked === true,
      onClick: (event, li) => this._onToggleLock(li)
    }, {
      label: "COMPENDIUM.ImportAll.Option",
      icon: "fa-solid fa-download",
      visible: li => game.packs.get(li.dataset.pack)?.documentName !== "Adventure",
      onClick: (event, li) => game.packs.get(li.dataset.pack)?.importDialog({
        position: {
          top: Math.min(li.offsetTop, window.innerHeight - 350),
          left: window.innerWidth - 740,
          width: 420
        }
      })
    }, {
      label: "FOLDER.Clear",
      icon: "fa-solid fa-folder",
      visible: li => !!game.packs.get(li.dataset.pack)?.folder,
      onClick: (event, li) => game.packs.get(li.dataset.pack)?.setFolder(null)
    }, {
      label: "COMPENDIUM.Delete.Option",
      icon: "fa-solid fa-trash",
      visible: li => game.packs.get(li.dataset.pack)?.metadata.packageType === "world",
      onClick: (event, li) => this._onDeleteCompendium(li)
    }, {
      label: "COMPENDIUM.Duplicate.Option",
      icon: "fa-solid fa-copy",
      onClick: (event, li) => this._onDuplicateCompendium(li)
    }];
  }

  /* -------------------------------------------- */

  /**
   * Get options for filtering the directory by document type.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getFilterContextOptions() {
    const entries = CONST.COMPENDIUM_DOCUMENT_TYPES.map(t => ({
      label: _loc(getDocumentClass$1(t).metadata.label),
      icon: CONFIG[t]?.sidebarIcon,
      classes: `document-type${this.#activeFilters.has(t) ? " active" : ""}`,
      onClick: event => this._onToggleCompendiumFilterType(event, t)
    }));
    entries.push({
      label: _loc("COMPENDIUM.ClearFilters"),
      icon: "fa-solid fa-xmark",
      group: "state",
      onClick: event => this._onToggleCompendiumFilterType(event, null)
    });
    return entries;
  }

  /* -------------------------------------------- */

  /**
   * Get context menu entries for folders in this directory.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getFolderContextOptions() {
    return DocumentDirectory._getFolderContextOptions().filter(({ label }) => {
      return ["Edit", "Remove"].includes(label.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 FilterMenu.implementation(this.element, "button.filter", {
      closeOnSelect: true,
      menuItems: this._getFilterContextOptions.bind(this)
    });

    // Store the document-match template
    this.#documentMatchEl = parseHTML$1(await renderTemplate("templates/sidebar/partials/document-match.hbs"));
  }

  /* -------------------------------------------- */

  /** @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);
      const directoryList = this.element.querySelector("ol.directory-list");
      directoryList.querySelectorAll(".directory-item.folder").forEach(folder => {
        folder.addEventListener("dragenter", this._onDragHighlight.bind(this));
        folder.addEventListener("dragleave", this._onDragHighlight.bind(this));
      });
      directoryList.addEventListener("dragstart", this._onDragDocumentStart.bind(this));
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    Object.assign(context, {
      folderIcon: CONFIG.Folder.sidebarIcon,
      label: _loc("PACKAGE.TagCompendium"),
      labelPlural: _loc("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 = _loc("SIDEBAR.Search", {types: _loc("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: _loc(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 a User click to open a Document match from an index lookup.
   * @type {ApplicationClickAction}
   * @this {CompendiumDirectory}
   */
  static async #onOpenDocumentSheet(event) {
    const uuid = event.target.closest("[data-uuid]").dataset.uuid;
    const document = await fromUuid(uuid);
    await document?.sheet.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: _loc(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 = _loc(count ? "DOCUMENT.NewCount" : "DOCUMENT.New", {
        count: count + 1,
        type: _loc("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 = _loc("COMMON.AreYouSure");
    const warning = _loc("COMPENDIUM.Delete.Warning");
    const result = await DialogV2.confirm({
      window: {
        title: _loc("COMPENDIUM.Delete.Title", {compendium: pack.title}),
        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: _loc("DOCUMENT.CopyOf", {name: pack.title}),
        name: "label"
      }).outerHTML,
      window: {
        title: _loc("COMPENDIUM.Duplicate.Title", {compendium: pack.title}),
        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: "COMMON.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 = _loc("COMMON.Warning");
    const warning = _loc("COMPENDIUM.ToggleLocked.Warning");
    const result = skipConfirmation ? "unlock" : await DialogV2.wait({
      window: {
        title: _loc("COMPENDIUM.ToggleLocked.Title", {compendium: pack.title}),
        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: "COMMON.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 = 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 folderIds = new Set();
    const packs = new Set();
    const documents = new Set();
    const autoExpandIds = new Set();
    const options = {};

    // Match entries and folders.
    const queryLengthSufficient = query.length >= CONFIG.i18n.searchMinimumCharacterLength;
    if ( queryLengthSufficient ) {
      // First match folders.
      this._matchSearchFolders(rgx, folderIds, autoExpandIds, options);

      // Next match compendiums.
      this._matchSearchCompendiums(rgx, packs, folderIds, autoExpandIds, options);

      // Finally match Document index entries from the indexes.
      this._matchSearchDocuments(query, documents);
    }

    // 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 = !queryLengthSufficient || match ? "flex" : "none";
        if ( autoExpandIds.has(folderId) ) {
          if ( queryLengthSufficient && match ) el.classList.add("expanded");
        }
        else el.classList.toggle("expanded", uuid in game.folders._expanded);
      }
      else if ( !queryLengthSufficient ) el.style.display = "flex";
      else this._onMatchSearchEntry(query, packs, el, options);
    }
    this._onMatchSearchDocuments(documents, html);
  }

  /* -------------------------------------------- */

  /**
   * 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
   */
  _matchSearchCompendiums(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 });
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Identify Document matches from DocumentIndex lookups and add to the provided Set.
   * @param {string} query            The user's search query
   * @param {Set<Document>} documents The Document Set
   * @protected
   */
  _matchSearchDocuments(query, documents) {
    const ownership = CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED;
    const documentTypes = Array.from(this.#activeFilters);
    const lookupOptions = {documentTypes, limit: 25, ownership, filterEntries: e => !!e.pack};
    const results = Object.values(game.documentIndex.lookup(query, lookupOptions)).flat();
    for ( const result of results ) documents.add(result.entry);
  }

  /* -------------------------------------------- */

  /**
   * Render Document-search matches for display.
   * @param {Set<WordTreeEntry["entry"]>} indexEntries
   * @param {HTMLElement} listEl
   * @protected
   */
  _onMatchSearchDocuments(indexEntries, listEl) {
    for ( const li of listEl.querySelectorAll("li[data-document-match]") ) li.remove();
    for ( const entry of indexEntries ) {
      const collection = foundry.utils.parseUuid(entry.uuid).collection;
      const li = this.#documentMatchEl.cloneNode(true);
      li.dataset.uuid = entry.uuid;
      li.dataset.type = collection.documentName;

      // Entry image
      const imgEl = li.querySelector("img");
      const imgPath = entry.img ?? collection.documentClass.getDefaultArtwork?.(entry)?.img ?? "icons/svg/book.svg";
      Object.assign(imgEl, {alt: entry.name, src: imgPath});

      // Entry name
      const nameAnchor = li.querySelector("a[data-name]");
      nameAnchor.textContent = entry.name;

      // Compendium pack
      const packAnchor = li.querySelector("a[data-pack]");
      packAnchor.dataset.pack = collection.metadata.id;
      packAnchor.append(" ", collection.title);

      // Providing package
      const sourceEl = li.querySelector("[data-source]");
      const {packageType, packageName} = collection.metadata;
      if ( packageType !== "system" ) {
        const packageTitle = game.modules.get(packageName)?.title ?? _loc("PACKAGE.Type.world");
        const icon = createFontAwesomeIcon(packageType === "module" ? "fa-plug" : "fa-globe");
        sourceEl.append(icon, " ", packageTitle);
      }
      else sourceEl.hidden = true;

      listEl.append(li);
    }
  }

  /* -------------------------------------------- */
  /*  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));
  }


  /* -------------------------------------------- */

  /**
   * Handle dragging a Document search result.
   * @param {DragEvent} event
   * @protected
   */
  _onDragDocumentStart(event) {
    const row = event.target.closest("li[data-document-match]");
    if ( !row ) return;
    event.stopImmediatePropagation();
    const {uuid, type} = row.dataset;
    const dragData = JSON.stringify({uuid, type});
    event.dataTransfer.setData("text/plain", 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);
  }
}

/**
 * The Drawing-specific placeables tab.
 * @extends {PlaceableTab}
 */
class DrawingTab extends PlaceableTab {
  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      selectGroup: DrawingTab.#onSelectGroup
    }
  };

  /** @override */
  static DIRECTORY_PARTIAL = "templates/sidebar/tabs/placeable/drawings.hbs";

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _getEntryLabel(entry) {
    return entry.name || entry.text || entry.id;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareDirectoryContext(context, options) {
    context = await super._prepareDirectoryContext(context, options);
    const noAuthor = [];
    const groups = {};
    for ( const entry of context.entries ) {
      const { author } = entry;
      if ( author ) {
        const { color, id, name: label } = author;
        const group = groups[id] ??= { color, id, label, entries: [] };
        group.entries.push(entry);
      }
      else noAuthor.push(entry);
    }
    context.groups = Object.values(groups);
    if ( noAuthor.length ) context.groups.unshift({ label: _loc("SIDEBAR.PLACEABLES.Categories.NoAuthor"), entries: noAuthor });
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEntry(entry, context) {
    context = await super._prepareEntry(entry, context);
    context.author = entry.author;
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle selecting a group of objects.
   * @this {DrawingTab}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onSelectGroup(event, target) {
    const { authorId } = target.closest("[data-author-id]").dataset;
    const [release, control] = this.layer.placeables.partition(d => {
      return (!authorId && !d.document.author) || (d.document.author.id === authorId);
    });
    if ( !event.shiftKey ) release.forEach(d => d.release({ renderSidebar: false }));
    control.forEach(d => d.control({ renderSidebar: false, releaseOthers: false }));
    ui.placeables.render();
    if ( game.activeTool === "select" ) ui.placeablesPalette?.render();
  }
}

/**
 * @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();
    const configureOwnershipIndex = options.findIndex(o => o.label === "OWNERSHIP.Configure");
    options.splice(configureOwnershipIndex + 1, 0, {
      label: "ITEM.ViewArt",
      icon: "fa-solid fa-image",
      visible: li => {
        const item = game.items.get(li.dataset.entryId);
        const { img } = item.constructor.getDefaultArtwork(item._source);
        return item.img !== img;
      },
      onClick: (event, 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 });
      }
    });
    return 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() {
    const options = super._getEntryContextOptions();
    const configureOwnershipIndex = options.findIndex(o => o.label === "OWNERSHIP.Configure");
    options.splice(configureOwnershipIndex + 1, 0, {
      label: "SIDEBAR.JumpPin",
      icon: "fa-solid fa-crosshairs",
      visible: li => !!this.collection.get(li.dataset.entryId)?.sceneNote,
      onClick: (event, li) => this.collection.get(li.dataset.entryId)?.panToNote()
    });
    return options;
  }
}

/**
 * @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";
}

/**
 * The Note-specific placeables tab.
 * @extends {PlaceableTab}
 */
class NoteTab extends PlaceableTab {
  /** @override */
  static ENTRY_PARTIAL = "templates/sidebar/tabs/placeable/note.hbs";

  /* -------------------------------------------- */

  /** @override */
  _getEntryLabel(entry) {
    const journal = entry.page ?? entry.entry;
    return entry.text || journal?.name || entry.id;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEntry(entry, context) {
    context = await super._prepareEntry(entry, context);
    if ( game.user.isGM ) {
      const journal = entry.page ?? entry.entry;
      const playerAuthor = (entry._source.author === null) || !(entry.author?.isGM ?? true);
      context.isVisible = journal
        ? game.users.some(u => !u.isGM && journal.testUserPermission(u, "LIMITED"))
        : playerAuthor;
    }
    return context;
  }
}

/**
 * @import {ApplicationRenderContext, ApplicationRenderOptions} from "../../_types.mjs";
 * @import {HandlebarsRenderOptions} from "../../api/handlebars-applications.mjs";
 * @import PlaceableTab from "./placeable-tab.mjs";
 */

/**
 * @typedef {ApplicationRenderOptions & HandlebarsRenderOptions} PlaceableDirectoryRenderOptions
 */

/**
 * The sidebar placeables tab.
 * @extends {AbstractSidebarTab}
 * @mixes HandlebarsApplication
 */
class PlaceableDirectory extends HandlebarsApplicationMixin(AbstractSidebarTab) {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["flexcol"],
    window: {
      title: "SIDEBAR.TabPlaceables"
    }
  };

  /** @override */
  static PARTS = {
    tabs: { template: "templates/sidebar/tabs/placeable/tabs.hbs" },
    tab: { template: "templates/sidebar/tabs/placeable/tab.hbs" }
  };

  /** @override */
  static TABS = {
    sheet: {
      tabs: [],
      initial: ""
    }
  };

  /** @override */
  static tabName = "placeables";

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The currently rendered tab.
   * @returns {PlaceableTab}
   */
  get tab() {
    return this.#tab;
  }

  #tab;

  /* -------------------------------------------- */

  /**
   * Tab application cache.
   * @type {Record<string, PlaceableTab>}
   */
  #tabs = {};

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _canRender(options) {
    if ( game.settings.get("core", "noCanvas") ) return false;
    const { renderContext } = options;
    if ( !renderContext ) return true;
    const layerName = this.tabGroups.sheet;
    if ( !layerName ) return true;
    const layerClass = CONFIG.Canvas.layers[layerName].layerClass;
    const collection = foundry.utils.getDocumentClass(layerClass.documentName).metadata.collection;
    return [`create${collection}`, `update${collection}`, `delete${collection}`].includes(renderContext);
  }

  /* -------------------------------------------- */

  /** @override */
  async close(options) {
    if ( options?.renderContext !== "deleteScene" ) return super.close(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    if ( !options.isFirstRender && !options.tabs ) delete parts.tab;
    return parts;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getTabsConfig(group) {
    const config = foundry.utils.deepClone(super._getTabsConfig(group));
    if ( group === "sheet" ) config.tabs = Object.values(CONFIG.Canvas.layers).reduce((tabs, config) => {
      const { layerClass } = config;
      const { documentClass, sidebar } = CONFIG[layerClass.documentName] ?? {};
      if ( !sidebar ) return tabs;
      const control = layerClass.prepareSceneControls();
      const { name: id, icon, visible } = control;
      if ( visible === false ) return tabs;
      const { labelPlural: label } = documentClass.metadata;
      tabs.push({ id, icon, label, disabled: !canvas.scene, order: sidebar?.order ?? 1000 });
      return tabs;
    }, []).sort((a, b) => a.order - b.order);
    return config;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    await this._renderTab(context, options);
    if ( canvas.scene ) canvas.scene.apps[this.id] = this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    await canvas.initializing;
    const activeLayer = canvas.activeLayer?.options.name;
    const layerClass = CONFIG.Canvas.layers[activeLayer]?.layerClass;
    if ( CONFIG[layerClass?.documentName]?.sidebar ) this.tabGroups.sheet = activeLayer;
    this.tabGroups.sheet ??= "tokens";
    return super._prepareContext(options);
  }

  /* -------------------------------------------- */

  /**
   * Render the directory for a given placeable type.
   * @param {ApplicationRenderContext} context          Render context.
   * @param {PlaceableDirectoryRenderOptions} options   Render options.
   * @returns {Promise<void>}
   * @protected
   */
  async _renderTab(context, options) {
    this.#tab?._onDeactivateTab();
    const tab = this.tabGroups.sheet;
    let app = this.#tabs[tab];
    if ( !app ) {
      const { layerClass } = CONFIG.Canvas.layers[tab] ?? {};
      const { sidebar } = CONFIG[layerClass?.documentName] ?? {};
      if ( !sidebar ) return;
      const collectionName = foundry.documents.Scene.getCollectionName(layerClass.documentName);
      app = new sidebar.applicationClass({ collectionName, directory: this });
      this.#tabs[tab] = app;
    }
    this.#tab = app;
    await app.render({ force: true });
    app.element.dataset.applicationPart = "tab";
    const prior = this.element.querySelector("[data-application-part=tab]");
    if ( app.element !== prior ) prior.replaceWith(app.element);
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  changeTab(tab, group, options) {
    super.changeTab(tab, group, options);
    ui.controls.activate({ control: tab });
  }

  /* -------------------------------------------- */

  /** @override */
  _onActivate() {
    this._updateFilterPip();
  }

  /* -------------------------------------------- */

  /** @override */
  _onDeactivate() {
    for ( const tab of Object.values(this.#tabs) ) tab._onDeactivateDirectory();
    this._updateFilterPip();
  }

  /* -------------------------------------------- */

  /**
   * Update the notification pip on the placeables sidebar tab button.
   * The pip is shown when the placeables tab is not active and the current sub-tab has an active filter.
   */
  _updateFilterPip() {
    const pip = document.querySelector("#sidebar .tabs [data-tab=placeables] + .notification-pip");
    pip?.classList.toggle("active", !this.active && !!this.#tab?._isFiltered());
  }

  /* -------------------------------------------- */
  /*  Delegation                                  */
  /* -------------------------------------------- */

  /**
   * Highlight a hovered entry in the sidebar.
   * @param {PlaceableObject} object  The object being hovered on canvas.
   * @param {boolean} hover           The hover state.
   */
  hoverEntry(object, hover) {
    return this.#tab?.hoverEntry(object, hover);
  }

  /* -------------------------------------------- */

  /**
   * Determine if a placeable is visible in the sidebar.
   * @param {PlaceableObject} object  The placeable.
   * @returns {boolean}
   */
  isEntryVisible(object) {
    if ( !this.#tab || !this.rendered ) return true;
    return this.#tab.isEntryVisible(object);
  }
}

/**
 * @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.#onSoundAdd,
      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() {
    const options = super._getEntryContextOptions();
    const configureOwnershipIndex = options.findIndex(o => o.label === "OWNERSHIP.Configure");
    options.splice(configureOwnershipIndex + 1, 0, {
      label: "PLAYLIST.Preload",
      icon: "fa-solid fa-download",
      onClick: (event, header) => {
        const { entryId } = header.closest("[data-entry-id]")?.dataset ?? {};
        const playlist = game.playlists.get(entryId);
        if ( !playlist ) return;
        for ( const sound of playlist.sounds ) {
          if ( sound.path ) game.audio.preload(sound.path);
        }
      }
    }, {
      label: "PLAYLIST.BulkImport.Title",
      icon: "fa-solid fa-files",
      onClick: async (event, header) => {
        const { entryId } = header.closest("[data-entry-id]")?.dataset ?? {};
        const playlist = game.playlists.get(entryId);
        if ( await playlist?.bulkImportDialog() ) this._expanded.add(playlist.id);
      }
    });
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Context menu options for individual PlaylistSounds.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getSoundContextOptions() {
    return [{
      label: "PLAYLIST.SoundEdit",
      icon: "fa-solid fa-edit",
      onClick: (event, 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);
      }
    }, {
      label: "PLAYLIST.SoundPreload",
      icon: "fa-solid fa-download",
      onClick: (event, li) => {
        const {playlistId, soundId} = li.dataset;
        const sound = game.playlists.get(playlistId)?.sounds.get(soundId);
        if ( sound?.path ) game.audio.preload(sound.path);
      }
    }, {
      label: "PLAYLIST.SoundDelete",
      icon: "fa-solid fa-trash",
      onClick: (event, 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: _loc("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: _loc("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: _loc("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: _loc("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().reduce((sounds, sound) => {
      if ( document.sounds.invalidDocumentIds.has(sound._id) ) return sounds;
      sound.playing = false;
      sounds.push(sound);
      return sounds;
    }, []);
    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 #onSoundAdd(event, target) {
    const playlist = game.playlists.get(target.closest("[data-entry-id]")?.dataset.entryId);
    if ( !playlist ) return;
    const sound = new PlaylistSound$1.implementation({ name: _loc("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"]})
    });
  }
}

/**
 * The Region-specific placeables tab.
 * @extends {PlaceableTab}
 */
class RegionTab extends PlaceableTab {
  constructor(...args) {
    super(...args);
    Object.assign(this._filterState, { behaviorType: "" });
  }

  /* -------------------------------------------- */

  /** @override */
  static ENTRY_PARTIAL = "templates/sidebar/tabs/placeable/region.hbs";

  /* -------------------------------------------- */

  /** @override */
  static FILTER_CLASS = RegionFilter;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEntry(entry, context) {
    context = await super._prepareEntry(entry, context);
    const { ALWAYS, OBSERVER } = CONST.REGION_VISIBILITY;
    const { color, elevation, shapes, visibility, hidden } = entry;
    if ( game.user.isGM ) {
      const hasPlayerObserver = game.users.some(u => !u.isGM && entry.testUserPermission(u, "OBSERVER"));
      context.isVisible = !hidden && ((visibility === ALWAYS) || ((visibility === OBSERVER) && hasPlayerObserver));
    }
    const hasElevation = Number.isFinite(elevation.bottom) || Number.isFinite(elevation.top);
    foundry.utils.mergeObject(context, { color, elevation, empty: !shapes.length });
    context.elevation.bottom = Number.isFinite(elevation.bottom)
      ? elevation.bottom.toNearest(.01).toLocaleString(game.i18n.lang)
      : "−∞";
    context.elevation.top = Number.isFinite(elevation.top)
      ? elevation.top.toNearest(.01).toLocaleString(game.i18n.lang)
      : "+∞";
    if ( !hasElevation ) context.elevation.label = "";
    else if ( elevation.topInclusive || (elevation.bottom === elevation.top) ) {
      context.elevation.label = `[${context.elevation.bottom}, ${context.elevation.top}]`;
    } else {
      context.elevation.label = `[${context.elevation.bottom}, ${context.elevation.top})`;
    }
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _getEntryContextOptions() {
    const options = super._getEntryContextOptions();
    const editIndex = options.findIndex(o => o.label === "SIDEBAR.Edit");
    options.splice(editIndex + 1, 0, {
      label: "OWNERSHIP.Configure",
      icon: "fa-solid fa-user-lock",
      visible: () => game.user.isGM,
      onClick: (event, li) => this.#onConfigureOwnership(li)
    });
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Handle configuring the region's ownership.
   * @param {HTMLElement} li  The context menu target.
   */
  #onConfigureOwnership(li) {
    const { top, left, width } = li.getBoundingClientRect();
    const doc = this._getPlaceableFromElement(li);
    const { width: sheetWidth } = foundry.applications.apps.DocumentOwnershipConfig.DEFAULT_OPTIONS.position;
    this.renderChild(new foundry.applications.apps.DocumentOwnershipConfig({
      document: doc,
      position: {
        top: top + 40,
        left: left + ((width - sheetWidth) / 2)
      }
    }));
  }

  /* -------------------------------------------- */
  /*  Filtering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _clearFilters() {
    super._clearFilters();
    this._filterState.behaviorType = "";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _hasAdvancedFilters() {
    return super._hasAdvancedFilters() || (this._filterState.behaviorType.length > 0);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _matchesFilter(entry) {
    const { behaviorType } = this._filterState;
    if ( behaviorType.length && !entry.behaviors.some(b => b.type === behaviorType) ) return false;
    return true;
  }
}

/**
 * @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 [
      {
        label: "TABLE.ACTIONS.DrawResult",
        icon: "fa-solid fa-dice-d20",
        onClick: (event, li) => {
          const table = this.collection.get(li.dataset.entryId);
          table.draw({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 = [
      {
        label: "SCENE.View",
        icon: "fa-solid fa-eye",
        visible: li => !canvas.ready || (li.dataset.entryId !== canvas.scene.id),
        onClick: (event, li) => game.scenes.get(li.dataset.entryId)?.view()
      },
      {
        label: "SCENE.Activate",
        icon: "fa-solid fa-bullseye",
        visible: li => game.user.isGM && !game.scenes.get(li.dataset.entryId)?.active,
        onClick: (event, li) => game.scenes.get(li.dataset.entryId)?.activate()
      },
      ...super._getEntryContextOptions()
    ];
    const configureOwnershipIndex = options.findIndex(o => o.label === "OWNERSHIP.Configure");
    options.splice(configureOwnershipIndex, 1, {
      label: "SCENE.Notes",
      icon: "fa-solid fa-scroll",
      visible: li => game.scenes.get(li.dataset.entryId)?.journal,
      onClick: (event, li) => {
        const scene = game.scenes.get(li.dataset.entryId);
        scene?.journal?.sheet?.render(true, { pageId: scene.journalEntryPage });
      }
    }, {
      label: "SCENE.Preload",
      icon: "fa-solid fa-download",
      visible: li => game.user.isGM,
      onClick: (event, li) => {
        const sceneId = li.dataset.entryId;
        game.scenes.preload(sceneId, {broadcast: true});
      }
    }, {
      label: "SCENE.ToggleNav",
      icon: "fa-solid fa-compass",
      visible: li => game.user.isGM && !game.scenes.get(li.dataset.entryId)?.active,
      onClick: (event, li) => {
        const scene = game.scenes.get(li.dataset.entryId);
        scene?.update({ navigation: !scene.navigation });
      }
    }, {
      label: "SCENE.GenerateThumb",
      icon: "fa-solid fa-image",
      visible: li => {
        if ( game.settings.get("core", "noCanvas") ) return false;
        const scene = game.scenes.get(li.dataset.entryId);
        if ( scene.isView ) return !!canvas.level?.background?.src;
        return !!scene.initialLevel.background?.src;
      },
      onClick: async (event, li) => {
        const scene = game.scenes.get(li.dataset.entryId);
        try {
          const { thumb } = await scene?.createThumbnail({ level: scene._view }) ?? {};
          if ( thumb ) await scene.update({ thumb }, { diff: false });
          ui.notifications.info("SCENE.GenerateThumbSuccess", { format: { name: scene.name } });
        } catch(err) {
          ui.notifications.error(err.message);
        }
      }
    });
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getFolderContextOptions() {
    return super._getFolderContextOptions().filter(o => o.label !== "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
      ? _loc("SETUP.UpdateAvailable", {
        type: _loc("SETUP.Software"),
        channel: game.data.coreUpdate.channel,
        version: game.data.coreUpdate.version
      })
      : null;
    const systemUpdate = game.user.isGM && game.data.systemUpdate.hasUpdate
      ? _loc("SETUP.UpdateAvailable", {
        type: _loc("PACKAGE.Type.system"),
        channel: game.data.system.title,
        version: game.data.systemUpdate.version
      })
      : null;
    const issues = Object.values(game.issues.validationFailures).reduce((count, failures) => {
      return count + Object.keys(failures).length;
    }, 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});
  }
}

/**
 * The Tile-specific placeables tab.
 * @extends {PlaceableTab}
 */
class TileTab extends PlaceableTab {
  constructor(...args) {
    super(...args);
    Object.assign(this._filterState, {
      occlusionModes: new Set(),
      restrictions: { light: false, weather: false }
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static FILTER_CLASS = TileFilter;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _getEntryLabel(entry) {
    return entry.name
      || (entry.texture.src ? foundry.audio.AudioHelper.getDefaultSoundName(entry.texture.src) : entry.id);
  }

  /* -------------------------------------------- */
  /*  Filtering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _clearFilters() {
    super._clearFilters();
    this._filterState.occlusionModes = new Set();
    this._filterState.restrictions = { light: false, weather: false };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _matchesFilter(entry) {
    const { occlusionModes, restrictions } = this._filterState;
    if ( occlusionModes.size ) {
      const hasMode = occlusionModes.some(m => {
        return m === CONST.OCCLUSION_MODES.NONE ? entry.occlusion.modes.size === 0 : entry.occlusion.modes.has(m);
      });
      if ( !hasMode ) return false;
    }
    if ( restrictions.light && !entry.restrictions.light ) return false;
    if ( restrictions.weather && !entry.restrictions.weather ) return false;
    return true;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _hasAdvancedFilters() {
    const { occlusionModes, restrictions } = this._filterState;
    return super._hasAdvancedFilters() || (occlusionModes.size > 0) || restrictions.light || restrictions.weather;
  }
}

/**
 * The Token-specific placeables tab.
 * @extends {PlaceableTab}
 */
class TokenTab extends PlaceableTab {
  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      selectGroup: TokenTab.#onSelectGroup
    }
  };

  /** @override */
  static DIRECTORY_PARTIAL = "templates/sidebar/tabs/placeable/tokens.hbs";

  /** @override */
  static ENTRY_PARTIAL = "templates/sidebar/tabs/placeable/token.hbs";

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareDirectoryContext(context, options) {
    context = await super._prepareDirectoryContext(context, options);
    if ( (canvas.scene?.levels.size ?? 0) <= 1 ) return context;
    const noLevel = [];
    const groups = {};
    for ( const entry of context.entries ) {
      const level = canvas.scene.levels.get(entry.levelId);
      if ( level ) {
        const group = groups[level.id] ??= { id: level.id, label: level.name, entries: [] };
        group.entries.push(entry);
      }
      else noLevel.push(entry);
    }
    context.groups = canvas.scene.levels.reverseSorted.reduce((arr, l) => {
      const group = groups[l.id];
      if ( group?.entries.length ) arr.push(group);
      return arr;
    }, []);
    if ( noLevel.length ) context.groups.push({
      label: _loc("SIDEBAR.PLACEABLES.Categories.NoLevel"), entries: noLevel
    });
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEntry(entry, context) {
    context = await super._prepareEntry(entry, context);
    context.levelId = entry.level;
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _getEntryContextOptions() {
    const options = super._getEntryContextOptions();
    const deleteIndex = options.findIndex(o => o.label === "SIDEBAR.Delete");
    options.splice(deleteIndex, 0, {
      label: "SIDEBAR.PLACEABLES.ACTIONS.ChangeLevel",
      icon: "fa-solid fa-layer-group",
      visible: li => {
        if ( !game.user.isGM ) return false;
        const token = this._getPlaceableFromElement(li);
        return !token.locked && !!canvas.level && (token._source.level !== canvas.level.id);
      },
      onClick: (event, li) => this.#onChangeLevel(li)
    });
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Handle moving the token to the viewed level.
   * @param {HTMLElement} li  The context menu target.
   * @returns {Promise<void>}
   */
  async #onChangeLevel(li) {
    if ( !canvas.level ) return;
    const token = this._getPlaceableFromElement(li);
    if ( !token ) return;
    const destination = {level: canvas.level.id};
    const options = {pan: {duration: 0}, animate: false};
    if ( canvas.ready && token.rendered && token.object.controlled ) {
      const instructions = this.layer.controlled.reduce((instructions, token) => {
        instructions[token.id] = {destination};
        return instructions;
      }, {});
      await canvas.scene.moveTokens(instructions, options);
    }
    else await token.move(destination, options);
  }

  /* -------------------------------------------- */

  /**
   * Handle selecting a group of tokens.
   * @this {TokenTab}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onSelectGroup(event, target) {
    if ( !ui.controls.tool?.control ) ui.controls.activate({ tool: "select" });
    const { levelId } = target.closest("[data-level-id]").dataset;
    const [release, control] = this.layer.placeables.partition(t => t.document._source.level === levelId);
    if ( !event.shiftKey ) release.forEach(t => t.release({ renderSidebar: false }));
    control.forEach(t => t.control({ renderSidebar: false, releaseOthers: false }));
    ui.placeables.render();
    if ( game.activeTool === "select" ) ui.placeablesPalette?.render();
  }
}

/**
 * The Wall-specific placeables tab.
 * @extends {PlaceableTab}
 */
class WallTab extends PlaceableTab {
  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      selectGroup: WallTab.#onSelectGroup
    }
  };

  /** @override */
  static DIRECTORY_PARTIAL = "templates/sidebar/tabs/placeable/walls.hbs";

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareDirectoryContext(context, options) {
    context = await super._prepareDirectoryContext(context, options);
    const { CATEGORY_COLORS } = getDocumentClass("Wall");
    const groups = {};
    for ( const entry of context.entries ) {
      const { category } = entry;
      const color = CATEGORY_COLORS[category];
      const group = groups[category] ??= {
        category, color,
        label: _loc(`SIDEBAR.PLACEABLES.WALLS.${category}`), entries: []
      };
      group.entries.push(entry);
    }
    context.groups = ["secret", "door", "window", "invisible", "ethereal", "terrain", "normal", "blank"].reduce((arr, cat) => {
      const group = groups[cat];
      if ( group?.entries.length ) arr.push(group);
      return arr;
    }, []);
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareEntry(entry, context) {
    context = await super._prepareEntry(entry, context);
    context.category = entry.getWallCategory();
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareSearchContext(context, options) {
    context = await super._prepareSearchContext(context, options);
    context.filters.findSplice(f => f.action === "openFilter", {
      action: "filterByCategory", active: !!this._filterState.categories?.size,
      cssClass: "fa-solid fa-shapes", tooltip: "SIDEBAR.PLACEABLES.WALLS.filterByCategory"
    });
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    new FilterMenu.implementation(this.element, "search [data-action=filterByCategory]", {
      menuItems: this.#getCategoryFilterOptions.bind(this)
    });
  }

  /* -------------------------------------------- */

  /**
   * Get options for filtering by wall category.
   * @returns {ContextMenuEntry[]}
   */
  #getCategoryFilterOptions() {
    const { CATEGORY_COLORS } = getDocumentClass("Wall");
    const entries = Object.keys(CATEGORY_COLORS).map(cat => ({
      label: _loc(`SIDEBAR.PLACEABLES.WALLS.${cat}`),
      icon: this._filterState.categories?.has(cat) ? "fa-solid fa-check" : "fa-solid",
      onClick: () => this._onFilterByCategory(cat)
    }));
    entries.push({
      label: _loc("SIDEBAR.PLACEABLES.ACTIONS.Clear"),
      icon: "fa-solid fa-xmark",
      onClick: () => {
        this._onFilterByCategory(null);
        ui.context.close();
      },
      group: "state"
    });
    return entries;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _matchesFilter(entry) {
    const { categories=new Set() } = this._filterState;
    return !categories.size || categories.has(entry.getWallCategory());
  }

  /* -------------------------------------------- */

  /**
   * Handle adjusting the category filter.
   * @param {string} cat  The wall category to filter by.
   * @protected
   */
  _onFilterByCategory(cat) {
    const categories = this._filterState.categories ??= new Set();
    if ( cat === null ) categories.clear();
    else if ( categories.has(cat) ) categories.delete(cat);
    else categories.add(cat);
    this.element.querySelector("search [data-action=filterByCategory]").ariaPressed = String(!!categories.size);
    this._applyFilters();
  }

  /* -------------------------------------------- */

  /**
   * Handle selecting a group of objects.
   * @this {WallTab}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onSelectGroup(event, target) {
    const { category } = target.closest("[data-category]").dataset;
    const [release, control] = this.layer.placeables.partition(w => w.document.getWallCategory() === category);
    if ( !event.shiftKey ) release.forEach(w => w.release({ renderSidebar: false }));
    control.forEach(w => w.control({ renderSidebar: false, releaseOthers: false }));
    ui.placeables.render();
    if ( game.activeTool === "select" ) ui.placeablesPalette?.render();
  }
}

var _module$E = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActorDirectory: ActorDirectory,
  AmbientLightTab: AmbientLightTab,
  AmbientSoundTab: AmbientSoundTab,
  CardsDirectory: CardsDirectory,
  ChatLog: ChatLog,
  CombatTracker: CombatTracker,
  CompendiumDirectory: CompendiumDirectory,
  DrawingTab: DrawingTab,
  ItemDirectory: ItemDirectory,
  JournalDirectory: JournalDirectory,
  MacroDirectory: MacroDirectory,
  NoteTab: NoteTab,
  PlaceableDirectory: PlaceableDirectory,
  PlaceableTab: PlaceableTab,
  PlaylistDirectory: PlaylistDirectory,
  RegionTab: RegionTab,
  RollTableDirectory: RollTableDirectory,
  SceneDirectory: SceneDirectory,
  Settings: Settings,
  TileTab: TileTab,
  TokenTab: TokenTab,
  WallTab: WallTab
});

/**
 * @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
    },
    placeables: {
      tooltip: "SIDEBAR.TabPlaceables",
      icon: "fa-solid fa-puzzle-piece"
    },
    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];
      const visible = tab && (tab._canRender({ isFirstRender: true }) !== false);
      const button = this.element.querySelector(`nav.tabs [data-tab="${id}"]`);
      if ( button ) button.closest("li").hidden = !visible;
      if ( !visible ) continue;
      promises.push(tab.render({ force: 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]");
    if ( !target.matches("#sidebar-tabs [data-tab]") ) return;
    const { tab } = event.target.closest("[data-tab]")?.dataset ?? {};
    const app = ui[tab];
    if ( app && (event.button === 2) ) app.renderPopout();
    else {
      super._onClickTab(event);
      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 ? "APPLICATION.ACTIONS.Collapse" : "APPLICATION.ACTIONS.Expand";
    expander.ariaLabel = _loc(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$D = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbstractSidebarTab: AbstractSidebarTab,
  DocumentDirectory: DocumentDirectory,
  Sidebar: Sidebar,
  apps: _module$G,
  filters: _module$F,
  tabs: _module$E
});

/**
 * @import {DocumentOwnershipLevel} from "@common/constants.mjs"
 * @import FormDataExtended from "./ux/form-data-extended.mjs"
 * @import {ContextMenuEntry} from "./ux/context-menu.mjs";
 */

/**
 * @typedef ApplicationConfiguration
 * @property {string} id                        An HTML element identifier used for this Application instance
 * @property {string} uniqueId                  A 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} action                      The action name triggered by clicking the control button
 * @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.
 */

/**
 * @typedef {ContextMenuEntry & _ApplicationHeaderControlsEntry} ApplicationHeaderControlsEntry
 */

/**
 * @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?
 * @property {string} [windowId]                The ID of an existing detached window to render into.
 * @property {boolean} [detached]               Whether the application should render inside the main application, or
 *                                              in a separate, detached window. Pass false to attach to the main
 *                                              application, or true to detach into a new window.
 */

/**
 * @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]
 */

/**
 * @typedef DetachedWindowOptions
 * @property {string} [id]               A unique identifier for the detached browser window.
 * @property {number} [timeout=10000]    The time to wait, in milliseconds, for the window to open before considering
 *                                       the operation as failed.
 * @property {Omit<ApplicationPosition, "scale"|"zIndex">} [position]  Window position.
 * @property {WindowProxy} [source]      The window from which to call open(). Defaults to the main workspace window.
 *                                       Must be the window in which the triggering user gesture occurred, otherwise
 *                                       the browser may block the popup.
 */

/**
 * @typedef _PlaceablePaletteConfiguration
 * @property {object} initialData  Initial data to populate the dialog with, overriding the user's previous choices, if
 *                                 any.
 */

/**
 * @typedef {ApplicationConfiguration & _PlaceablePaletteConfiguration} PlaceablePaletteConfiguration
 */

/**
 * @typedef _PlaceablePaletteRenderOptions
 * @property {object} [preset]              Reset the palette's stored data to the given preset.
 * @property {boolean} [preservePlacement]  Do not reset level or elevation when applying a preset.
 */

/**
 * @typedef {ApplicationRenderOptions & _PlaceablePaletteRenderOptions} PlaceablePaletteRenderOptions
 */

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: _loc("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.ACTIONS.Unmute"},
      inactive: {icon: "fa-volume", tooltip: "HOTBAR.ACTIONS.Mute"}
    },
    lock: {
      get state() {
        return game.settings.get("core", "hotbarLock");
      },
      active: {icon: "fa-lock", tooltip: "HOTBAR.ACTIONS.Unlock"},
      inactive: {icon: "fa-lock-open", tooltip: "HOTBAR.ACTIONS.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 ?? _loc("HOTBAR.Empty")
      });
    });
  }

  /* -------------------------------------------- */

  /** @override */
  async _onFirstRender(_context, _options) {
    game.macros.apps.push(this);
    this.element.setAttribute("aria-roledescription", _loc("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 [
      {
        label: "MACRO.Edit",
        icon: "fa-solid fa-edit",
        visible: li => {
          const macro = this.#getMacroForSlot(li);
          return macro?.isOwner ?? false;
        },
        onClick: (event, li) => {
          const macro = this.#getMacroForSlot(li);
          const hotbarSlot = li.dataset.slot;
          macro.sheet.render({force: true, hotbarSlot});
        }
      },
      {
        label: "MACRO.Remove",
        icon: "fa-solid fa-xmark",
        onClick: (event, li) => game.user.assignHotbarMacro(null, Number(li.dataset.slot))
      },
      {
        label: "MACRO.Delete",
        icon: "fa-solid fa-trash",
        visible: li => {
          const macro = this.#getMacroForSlot(li);
          return macro?.isOwner ?? false;
        },
        onClick: (event, 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", _loc(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: _loc("DOCUMENT.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.ACTIONS.Clear",
        icon: "fa-solid fa-trash"
      },
      content: _loc("HOTBAR.ClearConfirm"),
      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: `${_loc("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 || `${_loc(doc.constructor.metadata.label)} ${doc.id}`;
    return Macro$1.implementation.create({
      name: _loc("HOTBAR.ToggleSheet", {document: 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} enabled
   * @property {function(event):void} onClick
   */

  /**
   * Configuration of Main Menu items.
   * @type {Record<string, MainMenuItem>}
   */
  static ITEMS = {
    reload: {
      label: "MENU.Reload",
      icon: "fa-solid fa-fw fa-arrow-rotate-right",
      enabled: true,
      onClick: () => window.location.reload()
    },
    logout: {
      label: "MENU.Logout",
      icon: "fa-solid fa-fw fa-user",
      enabled: true,
      onClick: () => game.logOut()
    },
    players: {
      label: "MENU.Players",
      icon: "fa-solid fa-fw fa-users",
      enabled: () => game.user.isGM && !game.data.demoMode,
      onClick: () => window.location.href = "./players"
    },
    world: {
      label: "GAME.ReturnSetup",
      icon: "fa-solid fa-fw fa-globe",
      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) || ((typeof v.enabled === "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 ( typeof item.onClick === "function" ) item.onClick.call(this, event);
  }
}

/**
 * @import {Notification, NotificationOptions} from "./_types.mjs";
 */

/**
 * 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                                  */
  /* -------------------------------------------- */

  /**
   * 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;
      }
    }
    else if ( localize ) {
      if ( game.i18n.has(message) ) clean = false; // Localized message should be safe
    }
    if ( localize || format ) message = _loc(message, format);
    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 = _loc(message, format);
      } else if ( localize ) {
        message = _loc(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"
    }
  };

  /* -------------------------------------------- */

  /**
   * 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(`[${_loc("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);
    /** @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
    for ( const li of this.element.querySelectorAll(".players-list > .player") ) {
      const user = game.users.get(li.dataset.userId, {strict: true});
      this._onChangeIdleStatus(user, li);
    }
  }

  /* -------------------------------------------- */

  /**
   * Show in the interface whether a given User is idle.
   * @param {User} user
   * @param {HTMLLIElement} [row] The User's row in the application DOM
   * @internal
   */
  _onChangeIdleStatus(user, row) {
    if ( !this.element ) return; // Guards against this being called externally in-between renders.
    row ??= this.element.querySelector(`li.player[data-user-id="${user.id}"]`);
    const idle = user.active && user.idle;
    row?.classList.toggle("idle", idle);
  }

  /* -------------------------------------------- */

  /**
   * 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 [
      {
        label: "PLAYERS.ConfigTitle",
        icon: "fa-solid fa-user",
        visible: li => game.user.isGM || (li.dataset.userId === game.user.id),
        onClick: (event, li) => {
          const user = game.users.get(li.dataset.userId);
          user?.sheet.render({force: true});
        }
      },
      {
        label: "PLAYERS.ViewAvatar",
        icon: "fa-solid fa-image",
        visible: li => {
          const user = game.users.get(li.dataset.userId);
          return user.avatar !== CONST.DEFAULT_TOKEN;
        },
        onClick: (event, 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});
        }
      },
      {
        label: "PLAYERS.PullToScene",
        icon: "fa-solid fa-diamond-turn-right",
        visible: li => {
          const user = game.users.get(li.dataset.userId);
          return user.active && game.user.isGM && !user.isSelf;
        },
        onClick: (event, li) => canvas.scene.pullUsers([li.dataset.userId])
      },
      {
        label: "PLAYERS.Kick",
        icon: "fa-solid fa-door-open",
        visible: li => {
          const user = game.users.get(li.dataset.userId);
          return game.user.isGM && user.active && !user.isSelf;
        },
        onClick: (event, li) => {
          const user = game.users.get(li.dataset.userId);
          return Players.#kickUser(user);
        }
      },
      {
        label: "PLAYERS.Ban",
        icon: "fa-solid fa-ban",
        visible: li => {
          const user = game.users.get(li.dataset.userId);
          return game.user.isGM && !user.isSelf && (user.role !== CONST.USER_ROLES.NONE);
        },
        onClick: (event, li) => {
          const user = game.users.get(li.dataset.userId);
          return Players.#banUser(user);
        }
      },
      {
        label: "PLAYERS.UnBan",
        icon: "fa-solid fa-ban",
        visible: li => {
          const user = game.users.get(li.dataset.userId);
          return game.user.isGM && !user.isSelf && (user.role === CONST.USER_ROLES.NONE);
        },
        onClick: (event, li) => {
          const user = game.users.get(li.dataset.userId);
          return Players.#unbanUser(user);
        }
      },
      {
        label: "WEBRTC.TooltipShowUser",
        icon: "fa-solid fa-eye",
        visible: li => {
          const userId = li.dataset.userId;
          return game.webrtc.settings.client.users[userId]?.blocked;
        },
        onClick: async (event, 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}});
  }
}

/**
 * @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 {boolean} [interaction]  Does this tool allow interaction with placeables?
 * @property {boolean} [control]      Does this tool allow placeables to be controlled?
 * @property {boolean} [creation]     Does this tool create placeables?
 * @property {object} [createData]  Default creation data
 * @property {object} [shapeData]   The data of the shape this tool creates
 * @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, active: boolean) => void} [onToolChange]
 * A callback invoked when a tool is activated or deactivated
 */

/**
 * @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|null}
   */
  get tool() {
    const activeTool = this.#tools[this.#control];
    return this.tools[activeTool] || null;
  }

  /* -------------------------------------------- */

  /**
   * Global palette state.
   * @type {boolean}
   */
  get paletteOpen() {
    return this.#paletteOpen;
  }

  #paletteOpen = false;

  /* -------------------------------------------- */

  /**
   * The placeable palette class of the currently active control, if any.
   * @type {Function|null}
   */
  get #paletteClass() {
    const layerName = this.control?.layer;
    if ( !layerName ) return null;
    const layer = canvas[layerName];
    if ( !(layer instanceof PlaceablesLayer) ) return null;
    return layer.constructor.paletteClass;
  }

  /* -------------------------------------------- */
  /*  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 isActiveTool = this.#tools[this.#control] === k;
        if ( isActiveTool && ui.placeablesPalette?.rendered && (options.toggles?.palette === undefined) ) {
          const { createData } = tool;
          ui.placeablesPalette.render({ preset: createData });
        }
        const active = isActiveTool || (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");
    const isSelect = this.#tools[this.#control] === "select";

    // 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 paletteClass = this.#paletteClass;
    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;
      if ( t.button && paletteClass && t.createData ) t.pip = paletteClass.isActivePreset(t.createData);
    }
    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, isSelect};
  }

  /* -------------------------------------------- */

  /** @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);
  }

  /* -------------------------------------------- */

  /**
   * Update the active state of the preset pips.
   * @internal
   */
  _updatePresetPips() {
    const paletteClass = this.#paletteClass;
    if ( !paletteClass || !this.element ) return;
    for ( const tool of Object.values(this.tools) ) {
      if ( !tool.button || !tool.createData ) continue;
      const button = this.element.querySelector(`button.tool[data-tool="${tool.name}"]`);
      const pip = button?.closest("li").querySelector(".notification-pip");
      pip?.classList.toggle("active", paletteClass.isActivePreset(tool.createData));
    }
  }

  /* -------------------------------------------- */

  /** @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;
      }
      control.tools ??= {};
      const hadTools = !foundry.utils.isEmpty(control.tools);
      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 ( hadTools && foundry.utils.isEmpty(control.tools) ) delete controls[controlId]; // No available tools
      if ( !this.#tools[control.name] || !(this.#tools[control.name] in control.tools) ) {
        this.#tools[control.name] = (control.activeTool ? control.activeTool
          : (Object.values(control.tools).filter(t => !t.toggle && !t.button)
            .sort((a, b) => a.order - b.order)[0]?.name ?? null));
      }
    }
    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) || (name === "togglePalette")) ) {
        toggle.active = active;
        toggleChanges[name] = active;
      }
    }

    // Deactivate prior tool and control
    if ( toolChange && priorTool ) this.#onToolChange(priorControl, priorTool, event, false);
    if ( controlChange && priorControl ) 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.control ) this.#onChange(this.control, event, true);
    if ( toolChange && this.tool ) 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);
    }
    if ( ui.placeablesPalette?.rendered ) {
      if ( !("togglePalette" in this.tools) ) this.togglePlaceablePalette(false);
    } else if ( !ui.placeablesPalette?.element ) {
      if ( ("togglePalette" in this.tools) && this.paletteOpen ) this.togglePlaceablePalette(true);
    }
  }

  /* -------------------------------------------- */

  /**
   * 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 ( typeof toolOrControl.onChange === "function" ) toolOrControl.onChange(event, active);
    /** @deprecated since 13 */
    if ( typeof toolOrControl.onClick === "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 ( typeof control.onToolChange === "function" ) control.onToolChange(event, tool, active);

    // 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);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the tool palette for the active tool.
   * @param {boolean} [toggle]  Whether to toggle it on or off.
   * @returns {Promise<void>}
   */
  async togglePlaceablePalette(toggle) {
    await ui.placeablesPalette?.close({ animate: false });
    const paletteClass = this.#paletteClass;
    if ( !paletteClass || !("togglePalette" in this.tools) ) return;
    this.#setPaletteOpen(toggle !== undefined ? !!toggle : this.#paletteOpen);
    if ( !this.#paletteOpen ) return;
    const palette = new paletteClass({ initialData: this.tool.createData ?? {}, position: { top: 180, left: 100 } });
    await palette.render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Update the open state of the palette.
   * @param {boolean} paletteOpen
   */
  #setPaletteOpen(paletteOpen) {
    if ( this.#paletteOpen === paletteOpen ) return;
    this.#paletteOpen = paletteOpen;
    for ( const control of Object.values(this.controls) ) {
      if ( control.tools.togglePalette ) control.tools.togglePalette.active = paletteOpen;
    }
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * 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 ? "⌘" : _loc("CONTROLS.CtrlAbbr");
    const alt = isMac ? "⌥" : _loc("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: {
      cycleLevel: SceneNavigation.#onCycleLevel,
      viewLevel: SceneNavigation.#onViewLevel,
      viewScene: SceneNavigation.#onViewScene,
      toggleExpand: SceneNavigation.#onToggleExpand
    }
  };

  /** @override */
  static PARTS = {
    scenes: {
      root: true,
      template: "templates/ui/scene-navigation.hbs",
      scrollable: [".scene-levels"]
    }
  };

  /**
   * 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 = this.#prepareUserPips(u => u.viewedScene);
    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 icons = [];
      if ( isView ) icons.push({class: "eye", tooltip: _loc("SCENE_NAVIGATION.SCENE.Viewing")});
      if ( active ) icons.push({class: "bullseye", tooltip: _loc("SCENE_NAVIGATION.SCENE.Active")});
      if ( game.user.isGM && (scene.ownership.default === 0) && !active ) {
        icons.push({class: "eye-slash", tooltip: _loc("SCENE_NAVIGATION.SCENE.Hidden")});
      }
      const context = {
        id: scene.id,
        active, isView, icons,
        navOrder: scene.navOrder,
        name: scene.navName || scene.name,
        tooltip: (scene.navName && game.user.isGM) ? scene.name : "",
        users: userScenes[scene.id]
      };
      if ( isView ) {
        scenes.viewed = context;
        scenes.levels = this.#prepareLevels(scene);
      }
      else if ( active || context.users?.length ) scenes.active.push(context);
      else scenes.inactive.push(context);
    }
    scenes.active.sort((a, b) => (b.active - a.active) || (a.navOrder - b.navOrder));
    scenes.inactive.sort((a, b) => a.navOrder - b.navOrder);
    return scenes;
  }

  /* -------------------------------------------- */

  /**
   * Prepare render context for a given scene's levels.
   * @param {Scene} scene  The scene.
   * @returns {object[]|null}
   */
  #prepareLevels(scene) {
    const availableLevels = Array.from(scene.availableLevels);
    if ( availableLevels.length < 2 ) return null;
    const userLevels = this.#prepareUserPips(u => u.viewedScene === scene.id ? u.viewedLevel : null);
    return availableLevels.toReversed().map(level => ({
      id: level.id, sceneId: level.parent.id, name: level.name, css: level.isView ? "view" : "",
      users: userLevels[level.id]
    }));
  }

  /* -------------------------------------------- */

  /**
   * Prepare a map of user pip data grouped by a given key.
   * @param {(User) => string|null} keyFn  Returns the grouping key for each active user, or null to exclude.
   * @returns {Record<string, object[]>}
   */
  #prepareUserPips(keyFn) {
    return game.users.reduce((obj, u) => {
      if ( !u.active ) return obj;
      const key = keyFn(u);
      if ( !key ) return obj;
      obj[key] ||= [];
      obj[key].push({ name: u.name, letter: u.name[0], color: u.color.multiply(0.5).css, border: u.color });
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /** @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) {
    if ( options.scrollToActiveLevel ) {
      this.element.querySelector(".scene-level.active")?.scrollIntoView({ block: "nearest" });
    }
    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", _loc(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 [
      {
        label: "SCENE.View",
        icon: "fa-solid fa-eye",
        visible: li => {
          if ( !li.dataset.sceneId ) return false;
          if ( li.dataset.sceneId !== canvas.scene?.id ) return true;
          if ( !li.dataset.levelId ) return false;
          return li.dataset.levelId !== canvas.level?.id;
        },
        onClick: (event, li) => {
          const scene = game.scenes.get(li.dataset.sceneId);
          scene?.view({level: li.dataset.levelId});
        }
      },
      {
        label: "SCENE.Activate",
        icon: "fa-solid fa-bullseye",
        visible: li => {
          if ( !li.dataset.sceneId || li.dataset.levelId || !game.user.isGM ) return false;
          const scene = game.scenes.get(li.dataset.sceneId);
          return scene && !scene.active;
        },
        onClick: (event, li) => {
          const scene = game.scenes.get(li.dataset.sceneId);
          scene.activate();
        }
      },
      {
        label: "SCENE.PullEveryone",
        icon: "fa-solid fa-diamond-turn-right",
        visible: li => {
          if ( !li.dataset.sceneId || !game.user.isGM ) return false;
          for ( const user of game.users ) {
            if ( !user.active ) continue;
            if ( user.viewedScene !== li.dataset.sceneId ) return true;
            if ( !li.dataset.levelId ) continue;
            if ( user.viewedLevel !== li.dataset.levelId ) return true;
          }
          return false;
        },
        onClick: (event, li) => {
          const scene = game.scenes.get(li.dataset.sceneId);
          scene?.pullUsers(game.users, {level: li.dataset.levelId});
        }
      },
      {
        label: "SIDEBAR.Edit",
        icon: "fa-solid fa-edit",
        visible: li => li.dataset.sceneId && game.user.isGM,
        onClick: (event, li) => {
          const scene = game.scenes.get(li.dataset.sceneId);
          if ( !scene ) return;
          if ( li.dataset.levelId ) {
            const level = scene.levels.get(li.dataset.levelId);
            level?.sheet.render(true);
          }
          else scene.sheet.render(true);
        }
      },
      {
        label: "SCENE.Notes",
        icon: "fa-solid fa-scroll",
        visible: li => {
          if ( !li.dataset.sceneId || li.dataset.levelId || !game.user.isGM ) return false;
          const scene = game.scenes.get(li.dataset.sceneId);
          return !!scene?.journal;
        },
        onClick: (event, 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);
          }
        }
      },
      {
        label: "SCENE.Preload",
        icon: "fa-solid fa-download",
        visible: li => li.dataset.sceneId && game.user.isGM,
        onClick: (event, li) => {
          const sceneId = li.dataset.sceneId;
          game.scenes.preload(sceneId, {level: li.dataset.levelId, broadcast: true});
        }
      },
      {
        label: "SCENE.ToggleNav",
        icon: "fa-solid fa-compass",
        visible: li => li.dataset.sceneId && !li.dataset.levelId && game.user.isGM,
        onClick: (event, 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 cycling through available levels.
   * @this {SceneNavigation}
   * @param {PointerEvent} event        The triggering event.
   * @param {HTMLButtonElement} target  The action target.
   */
  static #onCycleLevel(event, target) {
    const { direction } = target.closest("[data-direction]")?.dataset ?? {};
    if ( Number.isNumeric(direction) ) return canvas.scene.cycleLevel(Number(direction));
  }

  /* -------------------------------------------- */

  /**
   * Handle switching to a specific level.
   * @this {SceneNavigation}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onViewLevel(event, target) {
    const { levelId } = target.closest("[data-level-id]")?.dataset ?? {};
    return canvas.scene.view({ level: levelId });
  }

  /* -------------------------------------------- */

  /**
   * 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$C = /*#__PURE__*/Object.freeze({
  __proto__: null,
  GamePause: GamePause,
  Hotbar: Hotbar,
  MainMenu: MainMenu,
  Notifications: Notifications,
  Players: Players,
  SceneControls: SceneControls,
  SceneNavigation: SceneNavigation
});

/** @module applications */


/**
 * A registry of currently rendered ApplicationV2 instances.
 * @type {Map<string, ApplicationV2>}
 */
const instances = new Map();

// Detached windowing API.
const detached = new DetachedWindowManager();

/**
 * 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$B = /*#__PURE__*/Object.freeze({
  __proto__: null,
  api: _module$15,
  apps: _module$R,
  detached: detached,
  dice: _module$Q,
  elements: _module$P,
  fields: fields$1,
  handlebars: handlebars,
  hud: _module$O,
  instances: instances,
  parseHTML: parseHTML,
  settings: _module$M,
  sheets: _module$I,
  sidebar: _module$D,
  types: _types$b,
  ui: _module$C,
  ux: _module$13
});

/**
 * @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 ( typeof this.data.render === "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: _loc("COMMON.Yes"),
          callback: html => yes ? yes(html) : true
        },
        no: {
          icon: '<i class="fa-solid fa-xmark"></i>',
          label: _loc("COMMON.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 = typeof cb === "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 = typeof originalClose === "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$A = /*#__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 `[${_loc(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(b => b.class === "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: _loc(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 = _loc("COMMON.Warning");
      const warning = _loc("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;
  }
}

/**
 * @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, _loc("JOURNALENTRYPAGE.Level", {level})];
      }))
    });
    if ( categories.length ) context.categories = [
      { value: "", label: _loc("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());
    }
  }
}

var _module$z = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActorSheet: ActorSheet,
  AdventureImporter: AdventureImporter,
  ItemSheet: ItemSheet,
  JournalPageSheet: JournalPageSheet,
  JournalSheet: JournalSheet,
  JournalTextPageSheet: JournalTextPageSheet
});

/** @module appv1 */

var _module$y = /*#__PURE__*/Object.freeze({
  __proto__: null,
  api: _module$A,
  sheets: _module$z
});

/**
 * @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 || _loc("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: Object.hasOwn(await this.getAudioSources(), audioSrc) ? { exact: audioSrc } : { 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: Object.hasOwn(await this.getVideoSources(), videoSrc) ? { exact: videoSrc } : { ideal: videoSrc },
      width: { ideal: 320 },
      height: { ideal: 240 }
    } : false;

    if ( !videoParams && !audioParams ) return null;
    let stream = await this.#createMediaStream({video: videoParams, audio: audioParams});
    if ( (videoParams && audioParams) && Error.isError(stream) ) {
      // 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 ( Error.isError(stream) && videoParams ) {
        stream = await this.#createMediaStream({video: videoParams, audio: false});
      }
    }

    if ( Error.isError(stream) ) {
      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) {
      let tryAgain = false;
      for ( const key of ["audio", "video"] ) {
        const constraints = params[key];
        if ( !foundry.utils.isPlainObject(constraints) ) continue;
        const constraint = constraints.deviceId;
        if ( !foundry.utils.isPlainObject(constraint) || !("exact" in constraint) ) continue;
        constraint.ideal = constraint.exact;
        delete constraint.exact;
        tryAgain = true;
      }
      if ( tryAgain ) {
        try {
          return await navigator.mediaDevices.getUserMedia(params);
        } catch(e) {
          err = e;
        }
      }

      // Firefox bug: Error.isError(err) returns false
      if ( !Error.isError(err) ) {
        const error = new Error(err.message);
        error.name = err.name;
        error.stack = err.stack;
        err = error;
      }
      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$x = /*#__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$w = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AVClient: AVClient,
  AVMaster: AVMaster,
  AVSettings: AVSettings,
  clients: _module$x
});

/**
 * @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})`;
  }
}

/**
 * @import TileDocument from "@client/documents/tile.mjs";
 * @import Tile from "@client/canvas/placeables/tile.mjs";
 * @import TilesLayer from "@client/canvas/layers/tiles.mjs";
 * @import {RectangleShapeData} from "@common/data/_module.mjs";
 */

/**
 * Controls for a Tile shape.
 * @extends {ShapeControls<TileDocument, Tile, TilesLayer, RectangleShapeData>}
 */
class TileShapeControls extends ShapeControls {

  /** @inheritDoc */
  _onDragStart(event) {
    super._onDragStart(event);

    // Handle transformation of other controlled tiles
    if ( event.interactionData.handle.name === "translate" ) {
      const others = event.interactionData.others = [];
      for ( const object of this.layer.controlled ) {
        if ( object !== this.object ) others.push(ShapeControls._createDragPreview(object));
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _updateDragPreview(event) {
    const {shape, preview} = event.interactionData;
    const {x, y, width, height, rotation} = shape;
    preview.document.updateSource({x, y, width, height, rotation});
    preview.renderFlags.set({refreshTransform: true});

    // Handle translation of other controlled tiles
    if ( !event.interactionData.others?.length ) return;
    const dx = preview.document.x - this.document.x;
    const dy = preview.document.y - this.document.y;
    for ( const preview of event.interactionData.others ) {
      const {x, y} = preview._original.document._source;
      preview.document.updateSource({x: x + dx, y: y + dy});
      preview.renderFlags.set({refreshPosition: true});
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareDragDropUpdate(event) {
    const {x, y, width, height, rotation} = event.interactionData.preview.document._source;
    return {x, y, width, height, rotation};
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragDrop(event) {

    // Handle translation of multiple controlled tiles
    if ( event.interactionData.others?.length ) {
      event.interactionData.clearPreviewContainer = false;
      const {x, y} = event.interactionData.preview.document._source;
      const updates = [{_id: this.document.id, x, y}];
      for ( const preview of event.interactionData.others ) {
        const {x, y} = preview.document._source;
        updates.push({_id: preview._original.document.id, x, y});
      }
      this.document.parent.updateEmbeddedDocuments("Tile", updates).finally(() => {
        this.layer.clearPreviewContainer();
      });
      return;
    }

    return super._onDragDrop(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _onClick2(event) {
    this.document.sheet.render({force: true, tab: "position"});
  }
}

/**
 * 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 ShapeObjectMixin(PlaceableObject) {

  /* -------------------------------------------- */
  /*  Attributes                                  */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static embeddedName = "Tile";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshTransform", "refreshMesh", "refreshElevation", "refreshVideo"], alias: true},
    refreshState: {propagate: ["refreshVisibility", "refreshPerception"]},
    refreshVisibility: {},
    refreshTransform: {propagate: ["refreshPosition", "refreshRotation", "refreshSize"], alias: true},
    refreshPosition: {propagate: ["refreshPerception"]},
    refreshRotation: {propagate: ["refreshPerception"]},
    refreshSize: {propagate: ["refreshPerception"]},
    refreshMesh: {},
    refreshElevation: {propagate: ["refreshPerception"]},
    refreshPerception: {},
    refreshVideo: {},
    /** @deprecated since v14 */
    refreshFrame: {deprecated: {since: 14, until: 16}, alias: true}
  };

  /**
   * The Tile border frame
   * @type {PIXI.Container}
   */
  frame;

  /**
   * The shape controls.
   * @type {TileShapeControls}
   */
  controls;

  /**
   * 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.Sprite|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);
  }

  /* -------------------------------------------- */

  /**
   * 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 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");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);

    // Load Tile texture
    let texture;
    if ( this._original && (this._original.document.texture.src === this.document.texture.src) ) {
      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.Sprite(PIXI.Texture.WHITE));
      this.bg.alpha = 0.5;
      this.bg.eventMode = "none";
    }

    // Hitbox
    this.frame = this.addChild(this.#drawFrame());

    // Shape controls
    this.controls = this.addChild(new TileShapeControls(this.document.shape.clone()));
    await this.controls.draw();

    // Interactivity
    this.cursor = "pointer";
  }

  /* -------------------------------------------- */

  /**
   * Create elements for the Tile hitbox
   * @returns {PIXI.Container}
   */
  #drawFrame() {
    const point = new PIXI.Point();
    const frame = new PIXI.Container();
    frame.eventMode = "auto";
    frame.hitArea = {
      contains: (x, y) => this.document.shape.testPoint(point.set(x, y))
    };
    return frame;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _clear() {
    if ( this.mesh ) {
      this.mesh.texture = PIXI.Texture.EMPTY;
      this.mesh.visible = false;
    }
    if ( this.#unlinkedVideo ) {
      this.texture?.baseTexture?.destroy(); // Base texture destroyed for non preview video
      this.texture = null;
      this.#unlinkedVideo = false;
    }
    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                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyRenderFlags(flags) {
    super._applyRenderFlags(flags);
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshRotation ) this._refreshRotation();
    if ( flags.refreshSize ) this._refreshSize();
    if ( flags.refreshMesh ) this._refreshMesh();
    if ( flags.refreshElevation ) this._refreshElevation();
    if ( flags.refreshPerception ) this.#refreshPerception();
    if ( flags.refreshVideo ) this._refreshVideo();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the position.
   * @protected
   */
  _refreshPosition() {
    const {x, y} = this.document.shape;
    this.controls.shape.updateSource({x, y});
    this.controls.refresh();
    if ( this.mesh ) this.mesh.position.set(x, y);
    else this.bg.position.set(x, y);
    if ( this.hasActiveHUD ) this.layer.hud.setPosition();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the rotation.
   * @protected
   */
  _refreshRotation() {
    const {rotation} = this.document.shape;
    this.controls.shape.updateSource({rotation});
    this.controls.refresh();
    if ( this.mesh ) this.mesh.angle = rotation;
    else this.bg.angle = rotation;
    if ( this.hasActiveHUD ) this.layer.hud.setPosition();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the size.
   * @protected
   */
  _refreshSize() {
    const {width, height, anchorX, anchorY} = this.document.shape;
    this.controls.shape.updateSource({width, height, anchorX, anchorY});
    this.controls.refresh();
    if ( this.mesh ) {
      const {texture: {fit, scaleX, scaleY}} = this.document;
      this.mesh.resize(width, height, {fit, scaleX, scaleY});
      this.mesh.anchor.set(anchorX, anchorY);
    } else {
      this.bg.width = width;
      this.bg.height = height;
      this.bg.anchor.set(anchorX, anchorY);
    }
    if ( this.hasActiveHUD ) this.layer.hud.setPosition();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshVisibility() {
    super._refreshVisibility();
    if ( this.mesh ) this.mesh.visible = this.visible;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshState() {
    super._refreshState();
    const {hidden, locked, sort} = this.document;
    if ( this.bg ) this.bg.visible = this.layer.active;
    const colors = CONFIG.Canvas.dispositionColors;
    this.controls.tint = this.controlled || this.isPreview ? colors.CONTROLLED
      : (locked ? colors.HOSTILE : colors.INACTIVE);
    this.controls.visible = this.controlled || this.hover || this.layer.highlightObjects || ["dragging", "wheel", "controls"].includes(this.previewType);
    this.controls.handles.visible = this.controlled && !locked;
    this.controls.editable = this.layer.active && !!ui.controls.tool?.interaction
      && !!ui.controls.tool?.control && !locked;
    this.controls.dashed = hidden;
    if ( !this.mesh ) return;
    this.mesh.sort = sort;
    this.mesh.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.TILES;
    this.mesh.zIndex = this.zIndex;
    const isDragging = ["dragging", "creation"].includes(this.previewType);
    this.mesh.alpha = this.alpha * (isDragging ? 0.8 : 1) * (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 {alpha, occlusion, texture} = this.document;
    const {tint, alphaThreshold} = texture;
    const M = CONST.OCCLUSION_MODES;
    this.mesh.unoccludedAlpha = alpha;
    this.mesh.occludedAlpha = occlusion.alpha;
    this.mesh.occlusionMode = occlusion.modes.reduce((modes, mode) => modes |= mode, M.NONE);
    this.mesh.hoverFade = (this.mesh.occlusionMode !== M.NONE) && !(this.mesh.occlusionMode & M.SURFACE);
    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 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),
      refreshSize: ("width" in changed) || ("height" in changed) || (("texture" in changed)
        && ["scaleX", "scaleY", "anchorX", "anchorY"].some(k => k in changed.texture)),
      refreshRotation: ("rotation" in changed),
      refreshMesh: ("alpha" in changed) || ("occlusion" in changed) || (("texture" in changed)
        && ["tint", "alphaThreshold"].some(k => k in changed.texture)),
      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;
  }

  /* -------------------------------------------- */

  /** @override */
  _hasShapeChanged(changed) {
    if ( ["x", "y", "rotation", "width", "height"].some(k => k in changed) ) return true;
    if ( !changed.texture ) return false;
    return ["anchorX", "anchorY"].some(k => k in changed.texture);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  static createPreview(data) {
    foundry.utils.logCompatibilityWarning("Tile.createPreview has been deprecated without replacement.",
      {since: 14, until: 16, once: true});
    data.width = data.height = 1;
    data.elevation ??= canvas.level.elevation.base;
    data.sort = Math.max(canvas.tiles.getMaxSort() + 1, 0);

    // Create a pending TileDocument
    const documentClass = getDocumentClass$1("Tile");
    const document = new documentClass(data, {parent: canvas.scene});

    // Render the preview Tile object
    const tileClass = getPlaceableObjectClass("Tile");
    const tile = new tileClass(document);
    tile._previewType = "creation";
    return tile;
  }
};

/**
 * @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 and surfaces?
 * @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 and surfaces
 * @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 ) {
      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);
    }

    // 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 and surfaces 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: canvas.level.elevation.base};
    const source = new CONFIG.Canvas.soundSourceClass({object: null});
    source.initialize({
      x: origin.x,
      y: origin.y,
      elevation: origin.elevation ?? canvas.level.elevation.base,
      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();
  }
}

/**
 * @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.isPlainObject(socketOptions) ? 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 _loc("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);
  }
}

/**
 * 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$v = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AudioBufferCache: AudioBufferCache,
  AudioHelper: AudioHelper,
  AudioTimeout: AudioTimeout,
  BiquadFilterEffect: BiquadFilterEffect,
  ConvolverEffect: ConvolverEffect,
  Sound: Sound
});

/**
 * @typedef KTX2ParserInitOptions
 * @property {string} [wasmPath]  The URL of the libktx WebAssembly module.
 */

/**
 * @typedef KTX2ParserParseOptions
 * @property {string} [transcodeTarget]  A Khronos transcode target name.
 */

/**
 * @typedef KTX2Header
 * @property {number} gpuFormat               The KTX2 GPU format identifier.
 * @property {number} pixelWidth              The base level width.
 * @property {number} pixelHeight             The base level height.
 * @property {number} pixelDepth              The base level depth.
 * @property {number} layerCount              The array layer count.
 * @property {number} faceCount               The face count.
 * @property {number} levelCount              The embedded mip level count.
 * @property {number} supercompressionScheme  The KTX2 supercompression scheme.
 */

/**
 * @typedef KTX2TranscodeTarget
 * @property {string} name    The Khronos transcode target name.
 * @property {number} target  The Khronos transcode target enum value.
 */

var _types$a = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * @import {KTX2Header, KTX2ParserInitOptions, KTX2ParserParseOptions, KTX2TranscodeTarget} from "./_types.mjs";
 */

/**
 * A KTX2 PIXI loader parser using the official Khronos KTX module.
 */
class KTX2Parser {

  /**
   * The default path to the Khronos libktx WebAssembly module.
   * @type {string}
   */
  static WASM_PATH = "scripts/ktx2/libktx.wasm";

  /**
   * The binary KTX2 identifier.
   * @type {number[]}
   */
  static #IDENTIFIER = [0xAB, 0x4B, 0x54, 0x58, 0x20, 0x32, 0x30, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A];

  /**
   * Map KTX2 GPU format identifiers to PIXI internal formats.
   * @type {Object<number, number>}
   */
  static #KTX2_FORMAT_TO_INTERNAL_FORMAT = {
    131: PIXI.INTERNAL_FORMATS.COMPRESSED_RGB_S3TC_DXT1_EXT,
    132: PIXI.INTERNAL_FORMATS.COMPRESSED_RGB_S3TC_DXT1_EXT,
    133: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT1_EXT,
    134: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT1_EXT,
    135: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT3_EXT,
    136: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT3_EXT,
    137: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT5_EXT,
    138: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_S3TC_DXT5_EXT,
    145: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_BPTC_UNORM_EXT,
    146: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_BPTC_UNORM_EXT,
    147: PIXI.INTERNAL_FORMATS.COMPRESSED_RGB8_ETC2,
    148: PIXI.INTERNAL_FORMATS.COMPRESSED_RGB8_ETC2,
    149: PIXI.INTERNAL_FORMATS.COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2,
    150: PIXI.INTERNAL_FORMATS.COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2,
    151: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA8_ETC2_EAC,
    152: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA8_ETC2_EAC,
    153: PIXI.INTERNAL_FORMATS.COMPRESSED_R11_EAC,
    154: PIXI.INTERNAL_FORMATS.COMPRESSED_SIGNED_R11_EAC,
    155: PIXI.INTERNAL_FORMATS.COMPRESSED_RG11_EAC,
    156: PIXI.INTERNAL_FORMATS.COMPRESSED_SIGNED_RG11_EAC,
    157: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_ASTC_4x4_KHR,
    158: PIXI.INTERNAL_FORMATS.COMPRESSED_RGBA_ASTC_4x4_KHR
  };

  /**
   * Extension names used by the current WebGL compressed texture APIs.
   * @type {Object<string, string[]>}
   */
  static #WEBGL_EXTENSIONS = {
    astc: ["WEBGL_compressed_texture_astc"],
    bptc: ["EXT_texture_compression_bptc"],
    etc: ["WEBGL_compressed_texture_etc"],
    s3tc: ["WEBGL_compressed_texture_s3tc"]
  };

  /**
   * A PIXI asset detection parser for KTX2 textures.
   * @type {object}
   */
  static detectKTX2 = {
    extension: {
      type: PIXI.ExtensionType.DetectionParser,
      priority: 3
    },
    test: async () => !!globalThis.WebAssembly,
    add: async formats => [...formats, "ktx2"],
    remove: async formats => formats.filter(format => format !== "ktx2")
  };

  /**
   * A PIXI asset resolver for KTX2 texture URLs.
   * @type {object}
   */
  static resolveKTX2TextureUrl = {
    extension: PIXI.ExtensionType.ResolveParser,
    test: value => PIXI.utils.path.extname(value).toLowerCase() === ".ktx2",
    parse: value => ({
      resolution: PIXI.utils.getResolutionOfUrl(value),
      format: "ktx2",
      src: value
    })
  };

  /**
   * A PIXI asset loader parser for KTX2 textures.
   * @type {object}
   */
  static loadKTX2 = {
    extension: {
      type: PIXI.ExtensionType.LoadParser,
      priority: PIXI.LoaderParserPriority.High
    },
    name: "loadKTX2",
    test: url => PIXI.checkExtension(url, ".ktx2"),
    load: async (url, asset, loader) => {
      const resource = await KTX2Parser.loadResource(url);
      const baseTexture = new PIXI.BaseTexture(resource, {
        alphaMode: resource.isPremultiplied ? PIXI.ALPHA_MODES.PREMULTIPLIED_ALPHA
          : PIXI.ALPHA_MODES.NO_PREMULTIPLIED_ALPHA,
        mipmap: resource.levels > 1 ? PIXI.MIPMAP_MODES.ON_MANUAL : PIXI.MIPMAP_MODES.OFF,
        resolution: PIXI.utils.getResolutionOfUrl(url),
        ...asset.data
      });
      return PIXI.createTexture(baseTexture, loader, url);
    },
    unload: texture => texture.destroy(true)
  };

  /**
   * The initialized Khronos KTX module.
   * @type {object|null}
   */
  static #module = null;

  /**
   * The pending Khronos KTX module initialization.
   * @type {Promise<object>|null}
   */
  static #modulePromise = null;

  /* -------------------------------------------- */

  /**
   * The initialized Khronos KTX module.
   * @type {object|null}
   */
  static get module() {
    return this.#module;
  }

  /* -------------------------------------------- */

  /**
   * Has the Khronos KTX module been initialized?
   * @type {boolean}
   */
  static get initialized() {
    return !!this.#module;
  }

  /* -------------------------------------------- */

  /**
   * Initialize the Khronos KTX module.
   * @param {KTX2ParserInitOptions} [options={}]  Initialization options.
   * @returns {Promise<object>}                   The initialized Khronos KTX module.
   */
  static initialize(options={}) {
    if ( this.#module ) return Promise.resolve(this.#module);
    if ( this.#modulePromise ) return this.#modulePromise;
    const wasmPath = options.wasmPath ?? this.WASM_PATH;
    this.#modulePromise = this.#initialize(wasmPath).catch(err => {
      this.#modulePromise = null;
      throw err;
    });
    return this.#modulePromise;
  }

  /* -------------------------------------------- */

  /**
   * Load a KTX2 URL as a PIXI compressed texture resource.
   * @param {string} url                           The texture URL.
   * @param {KTX2ParserParseOptions} [options]     Parser options.
   * @returns {Promise<PIXI.CompressedTextureResource>} The compressed texture resource.
   */
  static async loadResource(url, options={}) {
    const response = await PIXI.settings.ADAPTER.fetch(url);
    const arrayBuffer = await response.arrayBuffer();
    return this.parse(arrayBuffer, options);
  }

  /* -------------------------------------------- */

  /**
   * Parse KTX2 data as a PIXI compressed texture resource.
   * @param {ArrayBuffer|Uint8Array} data          The KTX2 file data.
   * @param {KTX2ParserParseOptions} [options]     Parser options.
   * @returns {Promise<PIXI.CompressedTextureResource>} The compressed texture resource.
   */
  static async parse(data, options={}) {
    const ktx = await this.initialize();
    const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
    const header = this.#readHeader(bytes);
    this.#validateHeader(header);

    const texture = new ktx.texture(bytes);
    try {

      // Read isPremultiplied before transcoding because it becomes false when transcoded for some reason
      const isPremultiplied = texture.isPremultiplied;
      if ( texture.needsTranscoding ) this.#transcodeTexture(ktx, texture, options);
      const format = this.#getInternalFormat(texture.vkFormat || header.gpuFormat);
      const levelBuffers = this.#getLevelBuffers(texture, header);
      const resource = new PIXI.CompressedTextureResource(null, {
        format,
        width: texture.baseWidth || header.pixelWidth,
        height: texture.baseHeight || header.pixelHeight,
        levels: levelBuffers.length,
        levelBuffers
      });
      resource.isPremultiplied = isPremultiplied;
      return resource;
    }
    finally {
      texture.delete();
    }
  }

  /* -------------------------------------------- */

  /**
   * Initialize the Khronos KTX module from its preloaded JavaScript factory.
   * @param {string} wasmPath   The URL of the libktx WebAssembly module.
   * @returns {Promise<object>} The initialized Khronos KTX module.
   */
  static async #initialize(wasmPath) {
    const {createKtxModule} = globalThis;
    if ( typeof createKtxModule !== "function" ) {
      throw new Error("KTX2Parser requires the Khronos libktx script to be loaded.");
    }
    const ktx = await createKtxModule({locateFile: path => path === "libktx.wasm" ? wasmPath : path});
    if ( !ktx?.ktxTexture || !ktx.TranscodeTarget ) {
      throw new Error("KTX2Parser initialized an invalid Khronos libktx module.");
    }
    return this.#module = ktx;
  }

  /* -------------------------------------------- */

  /**
   * Read the KTX2 file header.
   * @param {Uint8Array} bytes  The file bytes.
   * @returns {KTX2Header}      The parsed KTX2 header.
   */
  static #readHeader(bytes) {
    if ( bytes.byteLength < 68 ) throw new Error("KTX2Parser received an invalid KTX2 file.");
    for ( let i = 0; i < this.#IDENTIFIER.length; i++ ) {
      if ( bytes[i] !== this.#IDENTIFIER[i] ) throw new Error("KTX2Parser received an invalid KTX2 file.");
    }
    const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
    return {
      gpuFormat: view.getUint32(12, true),
      pixelWidth: view.getUint32(20, true),
      pixelHeight: view.getUint32(24, true),
      pixelDepth: view.getUint32(28, true),
      layerCount: view.getUint32(32, true) || 1,
      faceCount: view.getUint32(36, true) || 1,
      levelCount: view.getUint32(40, true) || 1,
      supercompressionScheme: view.getUint32(44, true)
    };
  }

  /* -------------------------------------------- */

  /**
   * Validate that a KTX2 texture is compatible with Foundry's 2D texture pipeline.
   * @param {KTX2Header} header  The parsed KTX2 header.
   */
  static #validateHeader(header) {
    if ( !header.pixelWidth || !header.pixelHeight ) {
      throw new Error("KTX2Parser requires a 2D KTX2 texture with non-zero dimensions.");
    }
    if ( header.pixelDepth > 0 ) throw new Error("KTX2Parser does not support 3D KTX2 textures.");
    if ( header.layerCount > 1 ) throw new Error("KTX2Parser does not support array KTX2 textures.");
    if ( header.faceCount > 1 ) throw new Error("KTX2Parser does not support cubemap KTX2 textures.");
  }

  /* -------------------------------------------- */

  /**
   * Transcode a Basis Universal KTX2 texture to the best supported compressed target.
   * @param {object} ktx                         The Khronos KTX module.
   * @param {object} texture                     The KTX texture object.
   * @param {KTX2ParserParseOptions} [options]   Parser options.
   */
  static #transcodeTexture(ktx, texture, options={}) {
    const target = options.transcodeTarget
      ? this.#getNamedTranscodeTarget(ktx, options.transcodeTarget)
      : this.#getTranscodeTarget(ktx);
    const result = texture.transcodeBasis(target.target, 0);
    const resultCode = result?.value ?? result;

    if ( resultCode !== ktx.ErrorCode.SUCCESS.value ) {
      throw new Error(`KTX2Parser failed to transcode the texture to ${target.name}.`);
    }
  }

  /* -------------------------------------------- */

  /**
   * Get a named Khronos transcode target.
   * @param {object} ktx       The Khronos KTX module.
   * @param {string} name      The transcode target name.
   * @returns {KTX2TranscodeTarget} The transcode target.
   */
  static #getNamedTranscodeTarget(ktx, name) {
    const target = ktx.TranscodeTarget[name];
    if ( target === undefined ) throw new Error(`KTX2Parser does not recognize transcode target ${name}.`);
    return {name, target};
  }

  /* -------------------------------------------- */

  /**
   * Select the best supported Khronos transcode target.
   * @param {object} ktx       The Khronos KTX module.
   * @returns {KTX2TranscodeTarget} The selected transcode target.
   */
  static #getTranscodeTarget(ktx) {
    const supported = this.#getSupportedExtensions();
    const targets = [
      {name: "ASTC_4x4_RGBA", extension: "astc"},
      {name: "BC7_RGBA", extension: "bptc"},
      {name: "BC1_OR_3", extension: "s3tc"},
      {name: "ETC2_RGBA", extension: "etc"}
    ];

    for ( const target of targets ) {
      if ( !supported[target.extension] ) continue;
      const value = ktx.TranscodeTarget[target.name];
      if ( value === undefined ) continue;
      return {name: target.name, target: value};
    }

    throw new Error("KTX2Parser could not find a supported compressed texture target.");
  }

  /* -------------------------------------------- */

  /**
   * Get the supported WebGL compressed texture extensions.
   * @returns {Object<string, boolean>} The supported extension map.
   */
  static #getSupportedExtensions() {
    const rendererExtensions = globalThis.canvas?.app?.renderer?.context?.extensions;
    if ( rendererExtensions ) return {
      astc: !!rendererExtensions.astc,
      bptc: !!rendererExtensions.bptc,
      etc: !!rendererExtensions.etc,
      s3tc: !!rendererExtensions.s3tc
    };

    const gl = this.#getWebGLContext();
    if ( !gl ) return {};

    const supported = {};
    for ( const [key, names] of Object.entries(this.#WEBGL_EXTENSIONS) ) {
      supported[key] = names.some(name => !!gl.getExtension(name));
    }
    return supported;
  }

  /* -------------------------------------------- */

  /**
   * Get the active WebGL context for compressed texture capability checks.
   * @returns {WebGLRenderingContext|WebGL2RenderingContext|null} The WebGL context.
   */
  static #getWebGLContext() {
    const renderer = globalThis.canvas?.app?.renderer;
    if ( renderer?.gl ) return renderer.gl;
    return renderer?.context?.gl ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Get the PIXI internal format for a KTX2 GPU format identifier.
   * @param {number} gpuFormat The KTX2 GPU format identifier.
   * @returns {number}         The PIXI internal format.
   */
  static #getInternalFormat(gpuFormat) {
    const format = this.#KTX2_FORMAT_TO_INTERNAL_FORMAT[gpuFormat];
    if ( !format ) throw new Error(`KTX2Parser does not support KTX2 GPU format ${gpuFormat}.`);
    return format;
  }

  /* -------------------------------------------- */

  /**
   * Extract the embedded KTX2 mip levels.
   * @param {object} texture     The KTX texture object.
   * @param {KTX2Header} header  The parsed KTX2 header.
   * @returns {object[]}         The PIXI compressed texture level buffers.
   */
  static #getLevelBuffers(texture, header) {
    const levels = header.levelCount;
    const levelBuffers = [];
    for ( let levelID = 0; levelID < levels; levelID++ ) {
      const levelBuffer = texture.getImage(levelID, 0, 0);
      if ( !levelBuffer ) throw new Error(`KTX2Parser could not read mip level ${levelID}.`);
      levelBuffers.push({
        levelID,
        levelWidth: Math.max(1, header.pixelWidth >> levelID),
        levelHeight: Math.max(1, header.pixelHeight >> levelID),
        levelBuffer: new Uint8Array(levelBuffer)
      });
    }
    return levelBuffers;
  }
}

PIXI.extensions.add(KTX2Parser.detectKTX2, KTX2Parser.resolveKTX2TextureUrl, KTX2Parser.loadKTX2);

/**
 * @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<void>}
   */
  async #wait() {
    const gl = this.#renderer.gl;
    const sync = this.#glSync;

    try {
      await new Promise((resolve, reject) => {
        function wait() {
          const res = gl.clientWaitSync(sync, 0, 0);
          if ( res === gl.WAIT_FAILED ) {
            reject(new Error("The sync object has failed to wait."));
            return;
          }
          if ( res === gl.TIMEOUT_EXPIRED ) {
            setTimeout(wait, 10);
            return;
          }
          resolve();
        }
        wait();
      });
    }
    catch(err) {
      this.#clear({buffer: true, syncObject: true, rt: true});
      throw err;
    }
  }

  /* -------------------------------------------- */

  /**
   * 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}`);
  }
}

/**
 * @import {ColorSource} from "../../../common/_types.mjs";
 * @import {Color} from "../../../common/utils/_module.mjs";
 * @import ParticleGenerator from "./particle-generator.mjs";
 * @import SpriteMesh from "../containers/elements/sprite-mesh.mjs";
 * @import BaseSamplerShader from "../rendering/shaders/samplers/base-sampler.mjs";
 */

/**
 * @typedef {"ambient"|"effect"} ParticleGeneratorMode
 */

/**
 * @typedef {number|number[]|{min: number, max: number}} ParticleGeneratorRange
 *   A numeric range:
 *   - number: a fixed value
 *   - [min, max]: a uniform range
 *   - {min, max}: a uniform range
 */

/**
 * @typedef ParticleGeneratorCurvePoint
 * @property {number} time    Normalized lifetime position, from 0 to 1.
 * @property {number} value   Value at this lifetime position.
 */

/**
 * A user-defined function that computes a particle value during particle update.
 * @callback ParticleGeneratorValueFunction
 * @param {ParticleMesh} particle The particle being evaluated.
 * @param {number} dt Frame delta in milliseconds.
 * @returns {number}
 */

/**
 * Advanced particle value configuration.
 * If a base range is combined with a curve, the owning attribute decides how they compose.
 * `fn` is a complete override and must not be combined with the other fields.
 * @typedef ParticleGeneratorValueOptions
 * @property {number} [min]                           Minimum base value sampled once per particle.
 * @property {number} [max]                           Maximum base value sampled once per particle.
 * @property {ParticleGeneratorCurvePoint[]} [curve]  Optional curve over normalized lifetime. Points must start at
 *   time 0, end at time 1, and use strictly increasing times.
 * @property {boolean} [step=false]                   Evaluate curve values in discrete steps instead of interpolation.
 * @property {ParticleGeneratorValueFunction} [fn]    Complete custom value function.
 */

/**
 * @typedef {number|number[]|ParticleGeneratorValueOptions} ParticleGeneratorValue
 */

/**
 * @typedef ParticleGeneratorColorCurvePoint
 * @property {number} time    Normalized lifetime position, from 0 to 1.
 * @property {ColorSource} value A color source at this lifetime position.
 */

/**
 * A user-defined function that computes a particle color during particle update.
 * @callback ParticleGeneratorColorFunction
 * @param {ParticleMesh} particle The particle being evaluated.
 * @param {number} dt Frame delta in milliseconds.
 * @returns {number} A 0xRRGGBB color value.
 */

/**
 * Advanced particle color configuration.
 * `fn` is a complete override and must not be combined with `curve`.
 * @typedef ParticleGeneratorColorValueOptions
 * @property {ParticleGeneratorColorCurvePoint[]} [curve] Optional color curve over normalized lifetime. Points must
 *   start at time 0, end at time 1, and use strictly increasing times.
 * @property {ParticleGeneratorColorFunction} [fn] Complete custom color function.
 */

/**
 * @typedef {ColorSource|ParticleGeneratorColorValueOptions} ParticleGeneratorColorValue
 */

/**
 * @typedef {PIXI.IPointData} ParticleGeneratorPoint
 */

/**
 * @typedef {PIXI.Rectangle|{x: number, y: number, width: number, height: number}} ParticleGeneratorRectangle
 */

/**
 * An anchor source used to attach spawn areas and behaviors to a moving object.
 * Supported sources:
 * - A {@link PlaceableObject} (for example a Token): uses {@link ParticleGeneratorAnchorPoint} to choose a point.
 * - A point in scene coordinates.
 * - A function which returns a point-like object in scene coordinates.
 * @typedef {PIXI.IPointData|(() => ParticleGeneratorPoint)|null} ParticleGeneratorAnchor
 */

/**
 * Which point to use when anchoring to an object.
 * - "center": use `source.center` when available (recommended for Tokens).
 * - "position": use `{x: source.x, y: source.y}`.
 * - function: invoked as (source) => ({x, y}).
 * @typedef {"center"|"position"|((source: object) => ParticleGeneratorPoint)} ParticleGeneratorAnchorPoint
 */

/**
 * @typedef {"default"|"orbit"|"follow"} ParticleGeneratorBehaviorId
 */

/**
 * @typedef ParticleGeneratorOrbitOptions
 * @property {ParticleGeneratorRange|null} [radius=null] Orbit radius in pixels.
 *   If null, use the particle's initial distance from the anchor.
 * @property {ParticleGeneratorRange} [angularSpeed=120] Angular speed in degrees per second.
 * @property {ParticleGeneratorRange} [phase=[0, 360]] Initial angle in degrees.
 *   Only used when {@link ParticleGeneratorOrbitOptions#radius} is provided.
 * @property {ParticleGeneratorRange} [radialSpeed=0] Radial speed in pixels per second.
 * @property {1|-1|"random"} [direction=1] Orbit direction.
 * @property {"none"|"tangent"|"radial"} [rotation="none"] If set, override sprite rotation each frame.
 */

/**
 * @typedef ParticleGeneratorFollowOptions
 * @property {ParticleGeneratorPoint|null} [offset=null] Fixed local offset from the anchor in pixels.
 *   If null, use the particle's initial offset from the anchor.
 * @property {number} [stiffness=1] A 0..1 smoothing factor. 1 snaps to the target every frame.
 */

/**
 * @typedef ParticleGeneratorBehavior
 * @property {(generator: ParticleGenerator) => void} [initialize]             Called once during construction.
 * @property {(particle: ParticleMesh, ctx: object) => void} [spawn]           Called for each spawned particle.
 * @property {(particle: ParticleMesh, dt: number, ctx: object) => boolean|void} [update]
 *   Called for each particle during update.
 *   Return true to indicate the behavior handled positional integration for this particle.
 */

/**
 * @typedef {"interior"|"boundary"} ParticleGeneratorAreaSampleMode
 */

/**
 * Source data for a {@link foundry.data.BaseShapeData} subclass.
 * @typedef {object} ParticleGeneratorShapeDataSource
 * @property {string} type A valid type from {@link foundry.data.BaseShapeData.TYPES}.
 */

/**
 * @typedef {ParticleGeneratorPoint[]} ParticleGeneratorPointList
 */

/**
 * @typedef {ParticleGeneratorPointList|ParticleGeneratorPointList[]} ParticleGeneratorPolylineDefinition
 */

/**
 * A spawn area definition in scene coordinates.
 * When an {@link ParticleGeneratorAnchor} is provided, object-based areas are interpreted as offsets relative
 * to the anchor point. To provide an absolute rectangle while anchored, pass a {@link PIXI.Rectangle}.
 * {@link foundry.data.BaseShapeData} instances and source data are always interpreted in absolute scene coordinates.
 * Supported shapes:
 * - Point: `{x, y}`
 * - Rect: `{x, y, width, height}` or `PIXI.Rectangle`
 * - Circle: `{x, y, radius}`
 * - Ring: `{x, y, innerRadius, outerRadius}` or `{x, y, radius: [inner, outer]}`
 * - Line: `{from: {x, y}, to: {x, y}}`
 * - Points: `{points: [{x, y}, ...]}` or `{shape: "points", points: [{x, y}, ...]}`
 * - Polyline: `{path: [{x, y}, ...]}`, `{shape: "path", path: [...]}`, or
 *   `{shape: "polyline", points: [{x, y}, ...]}`
 * - Ellipse: `{x, y, radiusX, radiusY}`, `{shape: "ellipse", x, y, radiusX, radiusY}`, with optional
 *   `holeScale`, `minAngle`, `maxAngle`, `shapeRotation`, and `affectRotation`.
 * - Shape instance: {@link foundry.data.BaseShapeData}
 * - Shape source data: `{type: "circle", x, y, radius}` using the {@link foundry.data.BaseShapeData} schema.
 * Plain object areas without a `type` are inferred from their properties.
 * @typedef {PIXI.Rectangle|foundry.data.PolygonTree|foundry.data.BaseShapeData|ParticleGeneratorShapeDataSource|
 *   ParticleGeneratorPoint|
 *   {x: number, y: number, width: number, height: number}|
 *   {x: number, y: number, radius: number|number[]}|
 *   {x: number, y: number, innerRadius: number, outerRadius: number}|
 *   {from: ParticleGeneratorPoint, to: ParticleGeneratorPoint}|
 *   {points: ParticleGeneratorPointList, shape?: "points"}|
 *   {path: ParticleGeneratorPolylineDefinition, shape?: "path"|"polyline"}|
 *   {points: ParticleGeneratorPolylineDefinition, shape: "polyline"}|
 *   {x: number, y: number, radiusX: number, radiusY: number, shape?: "ellipse", holeScale?: number,
 *     minAngle?: number, maxAngle?: number, shapeRotation?: number, rotation?: number,
 *     affectRotation?: boolean}} ParticleGeneratorArea
 */

/**
 * @typedef ParticleGeneratorFadeOptions
 * @property {number} [in=0] Fade-in duration in milliseconds, or a fraction of lifetime if 0 < value < 1.
 * @property {number} [out=0] Fade-out duration in milliseconds, or a fraction of lifetime if 0 < value < 1.
 */

/**
 * Particle rotation configuration that controls initial rotation of spawned particles and their rotational speed.
 *
 * Initial rotation is determined as:
 * 1. Base: Zero or, if `alignVelocity` is true, the particle's velocity angle
 * 2. Offset: `initial` is added to the base
 * 3. Spread: a random value in `[-spread, spread]` is added, default full-circle randomization
 *
 * After spawn, `speed` (degrees per second) governs ongoing rotation.
 *
 * @typedef ParticleGeneratorRotationOptions
 * @property {boolean} [alignVelocity=false]      Align initial rotation to the particle's velocity direction.
 * @property {number} [initial=0]                 Fixed rotation offset in radians, additive to the base.
 * @property {number} [spread=Math.PI]            Symmetric random spread in radians around the base+initial.
 *                                                0 means no randomization; Math.PI gives full-circle random.
 * @property {ParticleGeneratorValue} [speed=0]   Rotation speed over the particle lifetime. Initially specified
 *                                                in degrees-per-second, internally managed in radians-per-second.
 */

/**
 * Particle velocity configuration.
 * Supported shapes:
 * - Fixed vector: `{x, y}`
 * - Cartesian ranges: `{x: range, y: range}`
 * - Polar: `{speed: value, angle: range}` where angle is in degrees.
 * - Function: `{fn: (particle, dt, out) => { ... }}` where `out` is a reusable PIXI.Point. This is evaluated at
 *   spawn and during each update as the full velocity vector for that frame.
 */

/**
 * A user-defined function that computes a particle velocity vector in pixels per second.
 * The function may either write into `out` and return void, or return a point-like velocity object.
 * @callback ParticleGeneratorVelocityFunction
 * @param {ParticleMesh} particle The particle being evaluated.
 * @param {number} dt Frame delta in milliseconds.
 * @param {PIXI.Point} out Reusable point which may receive the velocity.
 * @returns {PIXI.IPointData|void|null}
 */

/**
 * @typedef {ParticleGeneratorPoint|{x: ParticleGeneratorRange, y: ParticleGeneratorRange}|
 *   {speed: ParticleGeneratorValue, angle: ParticleGeneratorRange}|
 *   {fn: ParticleGeneratorVelocityFunction}} ParticleGeneratorVelocityOptions
 */

/**
 * @typedef {"none"|"kill"|"clamp"|"wrap"|"bounce"} ParticleGeneratorConstraintMode
 */

/**
 * @typedef {"random"|"palette"|"byTexture"} ParticleGeneratorDebugTintMode
 */

/**
 * @typedef ParticleGeneratorDebugTintOptions
 * @property {ParticleGeneratorDebugTintMode} [mode="random"] How to apply debug tinting.
 * @property {number[]} [palette] A list of 0xRRGGBB colors used for "palette" or "byTexture" modes.
 */

/**
 * @typedef ParticleGeneratorDebugOptions
 * @property {boolean} [useWhiteTexture=false]  If true, fall back to {@link PIXI.Texture.WHITE} when no textures are
 *                                             configured.
 * @property {ParticleGeneratorDebugTintOptions|boolean|null} [tint=null]  Optional automatic tinting for spawned
 *                                                                         particles.
 * @property {boolean} [stats=false]            Whether to collect debug statistics.
 * @property {boolean} [profile=false]          Whether to capture per-tick timings (requires stats).
 */

/**
 * @typedef ParticleGeneratorDebugStats
 * @property {number} active Current number of active particles.
 * @property {number} pool Current number of pooled particles.
 * @property {number} target Current adjusted target particle count.
 * @property {number} spawnAttempts Number of spawn attempts.
 * @property {number} spawned Number of successfully spawned particles.
 * @property {number} spawnRejectedProbability Spawn attempts rejected by probability (auto-spawn only).
 * @property {number} spawnRejectedPositionTest Spawn attempts rejected by
 *   {@link ParticleGeneratorConfiguration#positionTest}.
 * @property {number} spawnRejectedNoArea Spawn attempts rejected because no valid spawn area was available.
 * @property {number} recycledLifetime Particles recycled due to lifetime expiration.
 * @property {number} recycledConstraint Particles recycled due to constraint handling.
 * @property {number} recycledStop Particles recycled/cleared due to a hard stop.
 * @property {number} newlyVisibleAreaCount Number of newly-visible rectangles this frame (ambient mode).
 * @property {number} updateMS Time spent updating particles during the most recent tick (milliseconds).
 * @property {number} spawnMS Time spent auto-spawning particles during the most recent tick (milliseconds).
 * @property {number} tickMS Total tick time for the most recent tick (milliseconds).
 */

/**
 * @typedef ParticleGeneratorClipOptions
 * @property {boolean|null} [enabled=null] Whether to apply a managed clip mask.
 *   If null, defaults to true in ambient mode and false in effect mode.
 * @property {ParticleGeneratorRectangle|null} [rect=null] Optional clip rectangle in scene coordinates.
 *   If omitted, a shape-based spawn area is used when available. Otherwise the generator bounds are used.
 */

/**
 * @callback ParticleGeneratorPositionTest
 * @param {number} x
 * @param {number} y
 * @param {{generator: ParticleGenerator, particle: ParticleMesh}} context
 * @returns {boolean}
 */

/**
 * @callback ParticleGeneratorParticleCallback
 * @param {ParticleMesh} particle
 * @param {{generator: ParticleGenerator}} context
 * @returns {void}
 */

/**
 * @callback ParticleGeneratorDeathCallback
 * @param {ParticleMesh} particle
 * @param {{generator: ParticleGenerator, reason: string}} context
 * @returns {void}
 */

/**
 * @callback ParticleGeneratorTickCallback
 * @param {number} dt
 * @param {ParticleGenerator} generator
 * @returns {void}
 */

/**
 * @typedef ParticleGeneratorConfiguration
 * @property {ParticleGeneratorMode} [mode="ambient"] The runtime mode.
 *   - "ambient": maintains a stable density in the visible region (viewport-based budget).
 *   - "effect": spawns in a defined area; particles are lifetime-driven unless constrained.
 * @property {ParticleGeneratorRectangle|null} [bounds=null] Optional generator bounds in scene coordinates.
 *   This is used for coordinate conversion, viewport clamping, and optional clipping.
 *   Defaults to the current Scene dimensions.
 * @property {number} [count=0] The target particle count.
 *   - In "ambient" mode, this is the maximum for the full bounds and is scaled by visible area.
 *   - In "effect" mode, this is the absolute target.
 * @property {number} [spawnRate=300] The maximum number of particles that may be spawned per second
 *   (auto-spawn mode).
 * @property {number} [perFrame=5] Deprecated since v14. Use `spawnRate` instead. If `spawnRate` is omitted or null,
 *   this legacy per-frame value is converted using the Pixi ticker target frame rate.
 * @property {number} [initial=0.25] The initial proportion (0..1) of the computed target particle count to spawn on
 *   start.
 * @property {boolean|null} [manual=null] If true, particles are never spawned automatically.
 *   If null, defaults to true in "effect" mode unless `spawnRate` is explicitly configured and false in "ambient"
 *   mode. Legacy `perFrame` does not change the default.
 * @property {number} [probability=1] The chance (0..1) that a spawn attempt actually creates a particle.
 * @property {number} [viewPadding=0] A proportion (0..1+) of extra area around the visible region used for spawning.
 *                                For example, 0.2 extends the spawn region by 20% in each dimension.
 * @property {boolean} [newlyVisible=true] If true, prioritize spawning particles in newly-visible areas when the view
 *   changes (pan/zoom).
 * @property {boolean} [randomizeAgeInPadding=true] If true, particles spawned in padded regions can start partially
 *   through their lifetime.
 * @property {ParticleGeneratorArea|null} [area=null] The default spawn area in "effect" mode (scene coordinates).
 * @property {ParticleGeneratorAreaSampleMode} [sampleMode="interior"] Which part of the spawn area to sample.
 * @property {ParticleGeneratorPositionTest|null} [positionTest=null] An optional spawn validator.
 *   The function is invoked as (x, y, {generator, particle}) and must return true if the location is valid.
 *   Coordinates are scene coordinates in pixels. The generator evaluates a single candidate position per spawn attempt.
 * @property {ParticleGeneratorConstraintMode|null} [constraintMode=null] How to handle particles leaving the constraint
 *   area.
 *   If null, defaults to "kill" in ambient mode and "none" in effect mode.
 * @property {"budget"|"view"|"world"|ParticleGeneratorRectangle|null} [constraintArea=null] The constraint area.
 *   - "budget": the padded viewport rectangle.
 *   - "view": the unpadded viewport rectangle.
 *   - "world": the generator bounds.
 *   - Rectangle: a custom rectangle in scene coordinates.
 *   If null, defaults to "budget" in ambient mode when constraintMode is not "none".
 * @property {number} [restitution=1] Bounce restitution factor (0..1) used when constraintMode is "bounce".
 * @property {ParticleGeneratorClipOptions|ParticleGeneratorRectangle|boolean} [clip]
 *   Clip (=> mask) configuration. Use true for default clipping. If no clip rectangle is provided and
 *   {@link ParticleGeneratorConfiguration#area} is a {@link foundry.data.BaseShapeData}, that shape is used.
 * @property {PIXI.DisplayObject|PIXI.IShape|foundry.data.BaseShapeData|null} [mask=null] An explicit mask for the
 *   particle container. Accepts either a pre-built PIXI.DisplayObject, a PIXI shape, or a
 *   {@link foundry.data.BaseShapeData} which is drawn into a PIXI.Graphics.
 *   Presence implies masking is desired, and this mask takes precedence over `clip`.
 * @property {ParticleGeneratorRange} [lifetime=1000] The particle lifetime in milliseconds.
 * @property {ParticleGeneratorFadeOptions} [fade] Fade envelope configuration.
 * @property {ParticleGeneratorVelocityOptions|null} [velocity=null] The particle velocity in pixels per second.
 * @property {ParticleGeneratorRotationOptions} [rotation] Rotation configuration for particles.
 * @property {{enabled: boolean, intensity: number}} [drift] Optional random drift configuration.
 * @property {(PIXI.Texture|string)[]} [textures=[]] The particle texture sources.
 *   Each entry may be a PIXI.Texture or a string path usable by foundry.canvas.getTexture/PIXI.Texture.from.
 * @property {ParticleGeneratorPoint|null} [particleAnchor=null] Optional sprite anchor override for particle textures.
 *   If null, each texture's `defaultAnchor` is used.
 * @property {typeof BaseSamplerShader|null} [shaderClass=null] Optional shader class used to render particles.
 *   Defaults to {@link BaseSamplerShader}. Batchable shaders are the fast path for sustained effects.
 *   ParticleGenerator creates plain {@link SpriteMesh} instances, so shaders which require
 *   {@link foundry.canvas.primary.PrimarySpriteMesh} occlusion or depth data are not supported.
 * @property {PIXI.BLEND_MODES} [blend=PIXI.BLEND_MODES.NORMAL] The blend mode used to render particles.
 * @property {number|{intensity: number, quality?: number}|
 *   {enabled: boolean, intensity: number, quality?: number}|null} [blur=null]
 *   An optional blur filter applied to the internal container.
 * @property {ParticleGeneratorValue} [alpha=1] The alpha value for particles.
 * @property {ParticleGeneratorValue} [scale=1] The scale value for particles.
 * @property {ParticleGeneratorColorValue} [tint=0xFFFFFF] The tint color for particles.
 * @property {number} [elevation=0] The elevation for the particle container.
 * @property {number} [sort=0] The sorting key for the particle container.
 * @property {PIXI.Container} [container] The parent container which receives the internal particle container. Defaults
 *   to canvas.primary.
 * @property {PIXI.Ticker} [ticker] The ticker used to drive the update loop. Defaults to
 *   {@link CanvasAnimation.ticker}.
 * @property {ParticleGeneratorAnchor} [anchor=null] An optional anchor used to attach areas and behaviors.
 * @property {ParticleGeneratorAnchorPoint} [anchorPoint="center"] Which point to use when anchoring.
 * @property {ParticleGeneratorPoint|null} [anchorOffset=null] A fixed offset (scene pixels) applied to the anchor.
 * @property {ParticleGeneratorBehaviorId|ParticleGeneratorBehavior|null} [behavior=null] Optional behavior.
 * @property {ParticleGeneratorOrbitOptions} [orbit] Orbit behavior options.
 * @property {ParticleGeneratorFollowOptions} [follow] Follow behavior options.
 * @property {ParticleGeneratorParticleCallback|null} [onSpawn=null]
 *   An optional callback called after the particle has been placed and configured.
 *   This is a handy place to attach custom per-particle data for batch shaders.
 * @property {ParticleGeneratorParticleCallback|null} [onUpdate=null]
 *   An optional callback called each frame for each live particle, after position, rotation, tint, and alpha have been
 *   computed.
 * @property {ParticleGeneratorDeathCallback|null} [onDeath=null]
 *   An optional callback called when a particle is recycled.
 * @property {ParticleGeneratorTickCallback|null} [onTick=null]
 *   An optional callback called per frame (not per particle!).
 * @property {ParticleGeneratorDebugOptions|boolean|null} [debug=null] Optional debugging helpers.
 */

/**
 * @typedef {SpriteMesh & {
 *   generator: ParticleGenerator,
 *   elapsedTime: number,
 *   time: number,
 *   lifetime: number,
 *   fadeInDuration: number,
 *   fadeOutDuration: number,
 *   maxAlpha: number,
 *   _baseScale: number,
 *   _baseTint: number,
 *   _baseSpeed?: number,
 *   _baseRotationSpeed: number,
 *   rotationSpeed: number,
 *   movementSpeed: PIXI.Point,
 *   _movementDirectionX?: number,
 *   _movementDirectionY?: number,
 *   _movementDriftX?: number,
 *   _movementDriftY?: number,
 *   _velocityFunctionBounceX?: number,
 *   _velocityFunctionBounceY?: number
 * }} ParticleMesh
 */

/**
 * @typedef {object} ParticleGeneratorPolylineSegment
 * @property {number} ax
 * @property {number} ay
 * @property {number} bx
 * @property {number} by
 * @property {number} length
 */

/**
 * @typedef {object} ParticleGeneratorLocalArea
 * @property {"shape"|"polygonTree"|"point"|"points"|"polyline"|"ellipse"|"ambientRect"|"line"} type
 * @property {foundry.data.BaseShapeData|foundry.data.PolygonTree} [data]
 * @property {ParticleGeneratorAreaSampleMode} [sampleMode]
 * @property {number} [x]
 * @property {number} [y]
 * @property {ParticleGeneratorPointList} [points]
 * @property {ParticleGeneratorPolylineSegment[]} [segments]
 * @property {number[]} [cumulativeLengths]
 * @property {number} [totalLength]
 * @property {ParticleGeneratorPoint|null} [fallbackPoint]
 * @property {number} [radiusX]
 * @property {number} [radiusY]
 * @property {number} [minAngle]
 * @property {number} [maxAngle]
 * @property {number} [shapeRotation]
 * @property {boolean} [affectRotation]
 * @property {number} [cosRotation]
 * @property {number} [sinRotation]
 * @property {number} [holeAreaScale]
 * @property {PIXI.Rectangle} [rect]
 * @property {number} [ax]
 * @property {number} [ay]
 * @property {number} [bx]
 * @property {number} [by]
 */

/**
 * @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
 */

/**
 * @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
 */

var _types$9 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * @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);
  }
}

/**
 * @import {BaseEffectSourceData, BaseEffectSourceOptions} from "./_types.mjs";
 * @import Collection from "@common/utils/collection.mjs"
 * @import {ElevatedPoint} from "../../_types.mjs"
 * @import PlaceableObject from "../placeables/placeable-object.mjs"
 * @import Level from "@client/documents/level.mjs";
 */

/**
 * 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,
    level: null,
    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 {string}
   * @readonly
   */
  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;
  }

  /**
   * The level this source is in.
   * @type {Level}
   */
  get level() {
    return this.#level;
  }

  #level;

  /* -------------------------------------------- */
  /*  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 {this}                          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
    let shapesChanged = false;
    if ( !this.shape || this._couldShapesChange(changes) ) {
      try {
        this._createShapes();
        this.#updateId++;
        shapesChanged = true;
      }
      catch(err) {
        console.error(err);
        this.remove();
      }
    }

    // Configure attached and non disabled sources
    if ( this.#attached && !this.data.disabled && (shapesChanged || !foundry.utils.isEmpty(changes)) ) {
      this._configure(changes);
    }
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Subclass specific data initialization steps.
   * @param {Partial<TSourceData>} data    Provided data for configuration
   * @protected
   */
  _initialize(data) {
    this.data.x = Math.round(this.data.x);
    this.data.y = Math.round(this.data.y);
    this.data.level ??= canvas.level.id;
    this.#level = canvas.scene.levels.get(this.data.level);
    if ( !this.#level ) throw new Error(`Level [${this.data.level}] does not exist.`);
  }

  /* -------------------------------------------- */

  /**
   * If true is returned, {@link BaseEffectSource#_createShapes} is called in {@link BaseEffectSource#initialize}.
   * @param {Partial<TSourceData>} changes   Changes to the source data which were applied
   * @returns {boolean}
   * @protected
   */
  _couldShapesChange(changes) {
    return true;
  }

  /* -------------------------------------------- */

  /**
   * 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 and its data or shapes have changed.
   * @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;
    const {elevation, radius} = this.data;
    const distancePixels = canvas.dimensions.distancePixels;
    const sourceZ = elevation * distancePixels;
    const pointZ = point.elevation * distancePixels;
    if ( Math.abs(pointZ - sourceZ) > radius + 1e-8 ) return false;
    if ( !this.shape.contains(point.x, point.y) ) return false;
    if ( !this.data.walls ) return true;
    return !canvas.scene.testSurfaceCollision(this.origin, point, {type: this.constructor.sourceType, mode: "any",
      level: this.level.id});
  }
}

/**
 * @import BaseEffectSource from "./base-effect-source.mjs"
 * @import {EdgeCreationOptions, ClockwiseSweepPolygonConfig} from "../geometry/_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 and surfaces
 * @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;
    }

    /* -------------------------------------------- */

    /**
     * Get the polygon backend that is used to create the shapes for this source.
     * @returns {typeof PointSourcePolygon}
     * @protected
     */
    _getPolygonBackend() {
      return CONFIG.Canvas.polygonBackends[this.constructor.sourceType];
    }

    /* -------------------------------------------- */

    /**
     * Configure the parameters of the polygon that is generated for this source.
     * @returns {ClockwiseSweepPolygonConfig}
     * @protected
     */
    _getPolygonConfiguration() {
      return {
        type: this.constructor.sourceType,
        level: this.level,
        edgeTypes: {
          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 = this._getPolygonBackend();
      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 points = [...this.shape.points];
      const edgeOptions = this._getEdgeCreationOptions();

      // 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, {...edgeOptions, id});
        this.edges.push(edge);
        canvas.edges.set(edge.id, edge);
        p0 = p1;
      }
    }

    /* -------------------------------------------- */

    /**
     * Get the options used for Edge creation.
     * @returns {EdgeCreationOptions}
     * @protected
     * @abstract
     */
    _getEdgeCreationOptions() {
      throw new Error("A subclass of the PointEffectSource that creates Edges must implement the _getEdgeCreationOptions method.");
    }

    /* -------------------------------------------- */

    /**
     * Remove edges from the active Edges collection.
     * @protected
     */
    _deleteEdges() {
      for ( const edge of this.edges ) canvas.edges.delete(edge.id);
      this.edges.length = 0;
    }
  };
}

/**
 * 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";
}

/**
 * Invisibility effect filter for placeables.
 */
class InvisibilityFilter extends AbstractBaseFilter {

  /** @override */
  static _createFragmentShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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 get defaultUniforms() {
    return {
      uSampler: null,
      color: [0.5, 1, 1]
    };
  }
}

/**
 * @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$8 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * @import {RenderedEffectLayerConfig, RenderedEffectSourceAnimationConfig,
 *   RenderedEffectSourceData, RenderedEffectSourceLayer} from "./_types.mjs";
 * @import {LightingLevel} from "@common/constants.mjs"
 * @import {AbstractBaseShader, AdaptiveBackgroundShader, AdaptiveColorationShader, AdaptiveDarknessShader,
 *   AdaptiveIlluminationShader, AdaptiveLightingShader} from "../rendering/shaders/_module.mjs";
 * @import {BaseEffectSourceData} from "./base-effect-source.mjs";
 */

/**
 * 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.
 * @template {RenderedEffectSourceData} [TSourceData=RenderedEffectSourceData]
 * @template {PIXI.Polygon} [TSourceShape=PIXI.Polygon]
 * @extends {BaseEffectSource<TSourceData, TSourceShape>}
 * @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 = {};

  /**
   * 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 {(() => void)[]}
   */
  #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 {[r: number, g: number, b: 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                  */
  /* -------------------------------------------- */

  // TODO: move these getters to BaseLightSource and PointVisionSource (https://github.com/foundryvtt/foundryvtt/issues/14268)
  /**
   * 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;
    }
  }
}

/**
 * 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 - (0.5 / 255.0), 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 get SHADER_HEADER() {
    return `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}
    `;
  }

  /** @override */
  static _createFragmentShader() {
    return `
    ${this.SHADER_HEADER}
    ${this.PERCEIVED_BRIGHTNESS}

    void main() {
      ${this.FRAGMENT_BEGIN}
      ${this.ADJUSTMENTS}
      ${this.BACKGROUND_TECHNIQUES}
      ${this.FALLOFF}
      ${this.FRAGMENT_END}
    }`;
  }

  /** @override */
  static get defaultUniforms() {
    return {
      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 get SHADER_HEADER() {
    return `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}
    `;
  }

  /** @override */
  static _createFragmentShader() {
    return `
    ${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 get defaultUniforms() {
    return {
      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 get SHADER_HEADER() {
    return `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}
    `;
  }

  /** @override */
  static _createFragmentShader() {
    return `
    ${this.SHADER_HEADER}
    ${this.PERCEIVED_BRIGHTNESS}

    void main() {
      ${this.FRAGMENT_BEGIN}
      ${this.VISION_COLOR}
      ${this.ILLUMINATION_TECHNIQUES}
      ${this.ADJUSTMENTS}
      ${this.FALLOFF}
      ${this.FRAGMENT_END}
    }`;
  }

  /** @override */
  static get defaultUniforms() {
    return {
      technique: 1,
      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 {VisionSourceData} from "./_types.mjs";
 * @import {LightingLevel} from "@common/constants.mjs";
 * @import {RenderedEffectSourceData} from "./rendered-effect-source.mjs";
 * @import {PointSourcePolygon} from "../geometry/_module.mjs";
 * @import VisionMode from "../perception/vision-mode.mjs";
 * @import AdaptiveVisionShader from "../rendering/shaders/vision/base-vision.mjs";
 */

/**
 * A specialized subclass of RenderedEffectSource which represents a source of point-based vision.
 * @extends {RenderedEffectSource<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 {LightingLevel}
   * @protected
   */
  static _dimLightingLevel = LIGHTING_LEVELS.DIM;

  /**
   * The corresponding lighting levels for bright light.
   * @type {LightingLevel}
   * @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 {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),
      surfaceExposure: {threshold: canvas.grid.distance},
      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.
   */
  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.
   * @type {{DISABLED: 0; ENABLED: 1; REQUIRED: 2}}
   */
  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 get SHADER_HEADER() {
    return `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}
  ${this.SWITCH_COLOR}
    `;
  }

  /** @override */
  static _createFragmentShader() {
    return `
    ${this.SHADER_HEADER}
    ${this.PERCEIVED_BRIGHTNESS}

    void main() {
      ${this.FRAGMENT_BEGIN}
      ${this.ADJUSTMENTS}
      ${this.BACKGROUND_TECHNIQUES}
      ${this.FALLOFF}
      ${this.FRAGMENT_END}
    }`;
  }

  /** @override */
  static get defaultUniforms() {
    return {
      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]
    };
  }

  /**
   * 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 get SHADER_HEADER() {
    return `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}
  ${this.SWITCH_COLOR}
    `;
  }

  /** @override */
  static _createFragmentShader() {
    return `
    ${this.SHADER_HEADER}
    ${this.PERCEIVED_BRIGHTNESS}

    void main() {
      ${this.FRAGMENT_BEGIN}
      finalColor = color * colorationAlpha;
      ${this.COLORATION_TECHNIQUES}
      ${this.ADJUSTMENTS}
      ${this.FALLOFF}
      ${this.FRAGMENT_END}
    }`;
  }

  /** @override */
  static get defaultUniforms() {
    return {
      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]
    };
  }

  /**
   * Flag whether the coloration shader is currently required.
   * @type {boolean}
   */
  get isRequired() {
    const vs = canvas.visibility.lightingVisibility;

    // Checking if a vision mode is forcing the rendering
    if ( vs.coloration === VisionMode.LIGHTING_VISIBILITY.REQUIRED ) return true;

    // Checking if disabled
    if ( vs.coloration === VisionMode.LIGHTING_VISIBILITY.DISABLED ) return false;

    // Otherwise, we need the coloration if it has color
    return this.constructor.forceDefaultColor || this.uniforms.hasColor;
  }
}

/**
 * The default coloration shader used by standard rendering and animations.
 * A fragment shader which creates a solid light source.
 */
class AdaptiveDarknessShader extends AdaptiveLightingShader {

  /** @override */
  update() {
    super.update();
    this.uniforms.darknessLevel = canvas.environment.darknessLevel;
  }

  /* -------------------------------------------- */

  /**
   * Flag whether the darkness shader is currently required.
   * Check vision modes requirements first, then
   * if key uniforms are at their default values, we don't need to render the background container.
   * @type {boolean}
   */
  get isRequired() {
    const vs = canvas.visibility.lightingVisibility;

    // Checking if darkness layer is disabled
    if ( vs.darkness === VisionMode.LIGHTING_VISIBILITY.DISABLED ) return false;

    // Otherwise, returns true in every circumstances
    return true;
  }

  /* -------------------------------------------- */
  /*  GLSL Statics                                */
  /* -------------------------------------------- */

  /** @override */
  static get defaultUniforms() {
    return {
      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
    };
  }

  /* -------------------------------------------- */

  /**
   * 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 - (0.5 / 255.0), 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 get SHADER_HEADER() {
    return `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}
    `;
  }

  /* -------------------------------------------- */

  /** @override */
  static _createFragmentShader() {
    return `
    ${this.SHADER_HEADER}
    ${this.PERCEIVED_BRIGHTNESS}

    void main() {
      ${this.FRAGMENT_BEGIN}
      finalColor *= (mix(color, color * 0.33, darknessLevel) * colorationAlpha);
      ${this.FRAGMENT_END}
    }`;
  }
}

/**
 * The default coloration shader used by standard rendering and animations.
 * A fragment shader which creates a solid light source.
 */
class AdaptiveIlluminationShader extends AdaptiveLightingShader {

  /** @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 get SHADER_HEADER() {
    return `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}
  ${this.SWITCH_COLOR}
    `;
  }

  /** @override */
  static _createFragmentShader() {
    return `
    ${this.SHADER_HEADER}
    ${this.PERCEIVED_BRIGHTNESS}

    void main() {
      ${this.FRAGMENT_BEGIN}
      ${this.TRANSITION}
      ${this.ILLUMINATION_TECHNIQUES}
      ${this.ADJUSTMENTS}
      ${this.FALLOFF}
      ${this.FRAGMENT_END}
    }`;
  }

  /** @override */
  static get defaultUniforms() {
    return {
      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]
    };
  }

  /**
   * 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;
  }
}

/**
 * Bewitching Wave animation illumination shader
 */
class BewitchingWaveIlluminationShader extends AdaptiveIlluminationShader {

  /** @override */
  static _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 {

  /** @override */
  static _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 {

  /** @override */
  static _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 {

  /** @override */
  static _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 _createFragmentShader() {
    return `
    ${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 {

  /** @override */
  static _createFragmentShader() {
    return `
    ${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 {

  /** @override */
  static _createFragmentShader() {
    return `
    ${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 get defaultUniforms() {
    return {
      ...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 {

  /** @override */
  static _createFragmentShader() {
    return `
    ${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 get defaultUniforms() {
    return {
      ...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 {

  /** @override */
  static _createFragmentShader() {
    return `
    ${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 get defaultUniforms() {
    return {
      ...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 get FRAGMENT_HEADER() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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 get defaultUniforms() {
    return {
      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
    };
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static _createVertexShader() {
    return `
    precision ${PIXI.Program.defaultVertexPrecision} 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;
  }

  /**
   * Default uniforms for a specific class
   * @abstract
   */
  static get commonUniforms() { return this.defaultUniforms; }
}

/**
 * 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 {

  /** @override */
  static get defaultUniforms() {
    return {
      ...super.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 _createFragmentShader() {
    const mode = canvas?.performance.mode ?? 2;
    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 {

  /** @override */
  static get defaultUniforms() {
    return {
      ...super.defaultUniforms,
      opacity: 1,
      intensity: 1,
      strength: 1,
      rotation: 0.5,
      resolution: [3200, 80] // The resolution to have nice rain ropes with the voronoi cells
    };
  }

  /* -------------------------------------------- */

  /** @override */
  static _createFragmentShader() {
    return `
    ${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 {

  /** @override */
  static get defaultUniforms() {
    return {
      ...super.defaultUniforms,
      direction: 1.2
    };
  }

  /* -------------------------------------------- */

  /** @override */
  static _createFragmentShader() {
    return `
    ${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.
 */
class HighlightRegionShader extends RegionShader {

  /** @override */
  static _createVertexShader() {
    return `\
    precision ${PIXI.Program.defaultVertexPrecision} float;

    ${this.CONSTANTS}

    attribute vec2 aVertexPosition;

    uniform mat3 translationMatrix;
    uniform mat3 projectionMatrix;
    uniform vec2 canvasDimensions;
    uniform vec4 sceneDimensions;
    uniform vec2 screenDimensions;
    uniform ${PIXI.Program.defaultFragmentPrecision} 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 _createFragmentShader() {
    return `\
    precision ${PIXI.Program.defaultFragmentPrecision} float;

    varying float vHatchOffset;

    uniform vec4 tintAlpha;
    uniform float resolution;
    uniform bool hatchEnabled;
    uniform ${PIXI.Program.defaultFragmentPrecision} 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, topInclusive} = mesh.region.document.elevation;
    const u = this.uniforms;
    u.bottom = canvas.masks.depth.mapElevation(bottom);
    u.top = top === bottom ? u.bottom : canvas.masks.depth.mapElevation(top, topInclusive);
    if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
  }
}

/* ---------------------------------------- */

/**
 * Render the RegionMesh with darkness level adjustments.
 * @internal
 * @ignore
 */
class AdjustDarknessLevelRegionShader extends AbstractDarknessLevelRegionShader {

  /** @override */
  static _createFragmentShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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 - (0.5 / 255.0), 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 _createFragmentShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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 - (0.5 / 255.0), top) * step(bottom, (254.5 / 255.0) - depthColor.r);
      gl_FragColor = vec4(1.0) * tintAlpha * depth;
    }
    `;
  }
}

/**
 * Compute baseline illumination according to darkness level encoded texture.
 */
class BaselineIlluminationSamplerShader extends BaseSamplerShader {

  /** @override */
  static classPluginName = null;

  /** @override */
  static _createFragmentShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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;
    }`;
  }

  /** @override */
  static get defaultUniforms() {
    return {
      tintAlpha: [1, 1, 1, 1],
      ambientDarkness: [0, 0, 0],
      ambientDaylight: [1, 1, 1],
      sampler: null
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preRender(mesh, renderer) {
    super._preRender(mesh, renderer);
    const c = canvas.colors;
    const u = this.uniforms;
    c.ambientDarkness.applyRGB(u.ambientDarkness);
    c.ambientDaylight.applyRGB(u.ambientDaylight);
  }
}

/**
 * A color adjustment shader.
 */
class ColorAdjustmentsSamplerShader extends BaseSamplerShader {

  /** @override */
  static classPluginName = null;

  /* -------------------------------------------- */

  /** @override */
  static _createVertexShader() {
    return `
    precision ${PIXI.Program.defaultVertexPrecision} 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 _createFragmentShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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;
    }`;
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultUniforms() {
    return {
      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 _createVertexShader() {
    return `
    precision ${PIXI.Program.defaultVertexPrecision} 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() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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;
    }`;
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultUniforms() {
    return {
      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 _createFragmentShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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 _createVertexShader() {
    return `
    precision ${PIXI.Program.defaultVertexPrecision} 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 _createFragmentShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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;
    }`;
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultUniforms() {
    return {
      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));
    }

    /* -------------------------------------------- */

    vec3 unpremultiplySafe(in vec4 c) {
      if ( c.a <= 0.0 ) return vec3(0.0);
      return min(c.rgb, vec3(c.a)) / c.a;
    }

    /* -------------------------------------------- */

    vec4 colorizeTokenRing(in vec4 tokenRing, in float dist) {
      vec3 tokenColor = unpremultiplySafe(tokenRing);

      // Sample the mask texture
      vec4 maskTex = texture(tokenRingTexture, vMaskTextureCoord);
      vec3 maskColor = unpremultiplySafe(maskTex);

      // 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(unpremultiplySafe(finalColor));
      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 (avoid division by 0)
    vec4 alphaAdjustedColor = (vColor.a > 0.0) ? (color * (vColor / vColor.a)) : vec4(0.0);

    // 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.
 */
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$u = /*#__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$8
});

/**
 * @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 foundry.canvas.containers.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 {TokenCoordinates, TokenDetectionMode, TokenDimensions, TokenPosition} from "@common/documents/_types.mjs";
 * @import {DatabaseUpdateOperation} from "@common/abstract/_types.mjs";
 * @import {GridMeasurePathResult} from "@common/grid/_types.mjs";
 * @import {
 *   TokenFindMovementPathJob, TokenDragContext, TokenConstrainMovementPathOptions, TokenMeasureMovementPathOptions,
 *   TokenFindMovementPathOptions, TokenAnimationContext, TokenAnimationData, TokenAnimationOptions, ReticuleOptions,
 *   TokenAnimationTransition, TokenPlannedMovement, TokenCreateTerrainMovementPathOptions, TokenPanningOptions,
 * } from "../../_types.mjs";
 * @import {
 *   TokenMovementCostFunction, TokenProcessedMovementWaypoint, TokenMovementSegmentData
 *   TokenMeasuredMovementWaypoint, TokenMovementWaypoint, TokenMeasurableMovementWaypoint, TokenMovementOptions,
 * } from "../../documents/_types.mjs";
 * @import {PointSourcePolygonConfig, 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, RegionDocument, User} from "../../documents/_module.mjs";
 * @import {EdgeRestrictionType} from "@common/constants.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: {}
  };

  /**
   * 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 indicator for "this token is not in the viewed level".
   * @type {PIXI.Sprite}
   */
  levelIndicator;

  /**
   * 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;

  /**
   * Vision version incremented each time vision (and light) are updated.
   * @type {number}
   * @internal
   */
  _visionSourceVersion = 0;

  /**
   * 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;

  /**
   * 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;
  }

  /**
   * @type {Map<string, TokenAnimationContext>}
   */
  #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 animation data
    this.#animationData = foundry.utils.deepSeal(this._getAnimationData());
    this.#priorAnimationData = foundry.utils.deepSeal(foundry.utils.deepClone(this.#animationData));
  }

  /* -------------------------------------------- */

  /**
   * Initialize a TokenRing instance for this Token, if a dynamic ring is enabled.
   */
  #initializeRing() {

    // Construct a TokenRing instance
    if ( this.document.ring.enabled ) {
      if ( !this.hasDynamicRing ) {
        const cls = CONFIG.Token.ring.ringClass;
        if ( !foundry.utils.isSubclass(cls, 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.
   * @param {Point} point      The center point.
   * @param {number} offsetX   The x-offset.
   * @param {number} offsetY   The y-offset.
   * @returns {Point}          The adjusted center point.
   */
  static #getMovementAdjustedPoint(point, offsetX, offsetY) {
    const x = Math.round(point.x);
    const y = Math.round(point.y);
    const elevation = point.elevation;
    point = {x, y, elevation};
    if ( ((offsetX === 0) && (offsetY === 0)) || !Token.#needsMovementAdjustment(point) ) return point;
    point.x -= offsetX;
    point.y -= offsetY;
    return point;
  }

  /* -------------------------------------------- */

  /**
   * Does the token position need movement adjustment?
   * @param {Point} origin   The movement origin
   * @returns {boolean}      Adjustment needed?
   */
  static #needsMovementAdjustment(origin) {

    // Define a bounding box around the point to query relevant edges using the quadtree
    const bounds = new PIXI.Rectangle(origin.x, origin.y, 0, 0);

    // Define a collision test that returns only edges that block movement with the origin on their segment
    const collisionTest = edge => {
      if ( !edge.move ) return false;
      if ( foundry.utils.orient2dFast(edge.a, edge.b, origin) !== 0 ) return false;
      const minX = Math.min(edge.a.x, edge.b.x);
      if ( origin.x < minX ) return false;
      const minY = Math.min(edge.a.y, edge.b.y);
      if ( origin.y < minY ) return false;
      const maxX = Math.max(edge.a.x, edge.b.x);
      if ( origin.x > maxX ) return false;
      const maxY = Math.max(edge.a.y, edge.b.y);
      if ( origin.y > maxY ) return false;
      return true;
    };

    // Retrieve candidate edges with inner and outer bounds included
    const collinearEdges = canvas.edges.getEdges(bounds, {
      includeInnerBounds: true,
      includeOuterBounds: true,
      collisionTest,
      collisionTestBounds: true
    });

    // If at least one edge is collinear, adjustment is needed
    return collinearEdges.size > 0;
  }

  /* -------------------------------------------- */

  /**
   * 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 {{[id: string]: 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.
   *
   * @override
   */
  get isVisible() {
    // Clear the detection filter
    this.detectionFilter = null;

    if ( this.isPreview ) return true;
    if ( this._preview?._previewType === "config" ) return false;
    if ( this.controlled ) return true;
    if ( this.isFilteredOut ) return false;

    // 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 !this._testCulled();

    // Otherwise, test visibility against current sight polygons
    if ( this.vision?.active ) return true;
    if ( gm && !canvas.effects.visionSources.some(s => s.active) ) return !this._testCulled();
    return canvas.visibility.testVisibility(this.document.getVisibilityTestPoints(), {tolerance: 0, object: this});
  }

  /* -------------------------------------------- */

  /**
   * Test if this Token should be culled.
   * @returns {boolean}
   * @protected
   */
  _testCulled() {
    if ( this.document.level === canvas.level.id ) return false;
    let minElevation;
    let maxElevation;
    const tokenElevation = this.document.getMovementOrigin().elevation;
    if ( tokenElevation < canvas.level.elevation.bottom ) {
      minElevation = Math.nextUp(tokenElevation);
      maxElevation = canvas.level.elevation.bottom;
    } else if ( tokenElevation >= canvas.level.elevation.top ) {
      minElevation = canvas.level.elevation.top;
      maxElevation = tokenElevation;
    }
    else return false;
    let tokenBounds;
    const relevantSurfaces = [];
    for ( const surface of canvas.scene.getSurfaces({level: canvas.level.id, culling: true}) ) {
      if ( surface.elevation > maxElevation ) break;
      if ( surface.elevation < minElevation ) continue;
      tokenBounds ??= this.bounds;
      const {polygonTree} = surface.region;
      if ( polygonTree.bounds.intersects(tokenBounds) ) relevantSurfaces.push(polygonTree);
    }
    if ( !relevantSurfaces.length ) return false;
    for ( const point of this.document.getOcclusionTestPoints() ) {
      if ( !relevantSurfaces.some(t => t.testPoint(point)) ) return false;
    }
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  get isInteractable() {
    if ( this.isPreview ) return false;
    if ( canvas.regions._placementContext ) return canvas.regions._placementContext.attachToToken;
    if ( !this.layer.active ) return false;
    if ( this.layer._placementContext ) return false;
    if ( this.layer._movementPlanningContext ) return this.layer._movementPlanningContext.object === this;
    if ( !ui.controls.tool?.interaction ) return false;
    if ( this.document.isSecret ) return false;
    return true;
  }

  /* -------------------------------------------- */
  /*  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.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.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.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}={}) {
    if ( this.destroyed || ((this._preview?._previewType === "config") && !deleted) ) return;
    const perceptionFlags = {
      initializeVision: false,
      initializeLighting: false,
      refreshLighting: true,
      refreshVision: true
    };
    const affectedLevels = new Set();

    // Handle updates based on the prior source having edges
    if ( this.light?.requiresEdges ) {
      perceptionFlags.initializeLighting = true;
      perceptionFlags.initializeVision ||= this.light.level.isView;
      affectedLevels.add(this.light.level);
    }

    // Handle deletion
    if ( deleted || !this._isLightSource() ) this.#destroyLightSource();
    else {

      // Create if missing or recreate if type changed
      const sourceType = this.document.light.negative ? "darkness" : "light";
      if ( this.light && (this.light.constructor.sourceType !== sourceType) ) this.#destroyLightSource();
      this.light ??= this.#createLightSource();

      // Re-initialize source data and add to the active collection
      this.light.initialize(this._getLightSourceData());
      this.light.add();

      // Handle updates based on the current source having edges
      if ( this.light.requiresEdges ) {
        perceptionFlags.initializeLighting = true;
        perceptionFlags.initializeVision ||= this.light.level.isView;
        affectedLevels.add(this.light.level);
      }
    }

    // Update perception and rendering
    canvas.perception.update(perceptionFlags);
    for ( const level of affectedLevels ) level.updateRegionShapeConstraints(["light", "darkness", "sight"]);
  }

  /* -------------------------------------------- */

  /**
   * Get the light source data.
   * @returns {LightSourceData}
   * @protected
   */
  _getLightSourceData() {
    const {x, y, elevation} = this.document.getLightOrigin();
    const {level, rotation, light} = this.document;
    return foundry.utils.mergeObject(light.toObject(false), {
      x, y, elevation, level, rotation,
      dim: this.getLightRadius(light.dim),
      bright: this.getLightRadius(light.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}={}) {
    if ( this.destroyed || ((this._preview?._previewType === "config") && !deleted) ) return;

    this._visionSourceVersion = (this._visionSourceVersion ?? 0) + 1;
    const isVisionSource = !deleted && this._isVisionSource();

    // Nothing to do: no existing source to remove, and no new source to create
    if ( !this.vision && !isVisionSource ) return;

    // Remove an existing source if it was deleted or is no longer valid.
    if ( !isVisionSource ) {
      if ( this.vision.active ) canvas.perception.update({
        initializeVisionModes: true,
        refreshVision: true,
        refreshLighting: true
      });
      this.#destroyVisionSource();
      return;
    }

    // Create / (re)initialize a valid source.
    const wasVision = !!this.vision;
    this.vision ??= this.#createVisionSource();

    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, elevation} = this.document.getVisionOrigin();
    const {level, rotation, sight} = this.document;
    return {
      x, y, elevation, level, 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 tokens in the viewed level can see
    if ( this.document.level !== canvas.level.id ) 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);
  }

  /* -------------------------------------------- */

  /**
   * Test whether this Token should contribute to shared Fog of War exploration.
   * @returns {boolean}
   * @protected
   */
  _isFogExplorationSource() {
    if ( !canvas.visibility.tokenVision ) return false;
    if ( this.isPreview ) return false;
    if ( this.document.hidden ) return false;
    if ( !this.hasSight ) return false;
    if ( this.document.level !== canvas.level.id ) return false;
    if ( !this.actor?.hasPlayerOwner ) return false;
    return true;
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Plan a movement for this Token.
   * @param {object} [options]                       Additional options.
   * @param {Iterable<string>|null} [options.allowedActions]
   *   The possible movement actions that can be used to this planned movement.
   *   Default: `null` (all selectable movement actions).
   * @param {boolean} [options.direct]      Only allow direct movement between the origin and destination?
   *                                        Default: `false`.
   * @param {number} [options.minCost]      The minimum cost of this planned movement at the time of the planning.
   *                                        The actual cost of the movement when it is executed might be less than
   *                                        the minimum cost. Default: `0`.
   * @param {number} [options.maxCost]      The maximum cost of this planned movement at the time of the planning.
   *                                        The actual cost of the movement when it is executed might be greater than
   *                                        the maximum cost. Default: `Infinity`.
   * @param {number} [options.minDistance]  The minimum distance of this planned movement at the time of the planning.
   *                                        The actual cost of the movement when it is executed might be less than
   *                                        the minimum cost. Default: `0`.
   * @param {number} [options.maxDistance]  The maximum distance of this planned movement at the time of the planning.
   *                                        The actual cost of the movement when it is executed might be greater than
   *                                        the maximum cost. Default: `Infinity`.
   * @param {number} [options.preventDrop]  Prevent drop if any of the cost or distance limits are violated.
   *                                        Default: `false`.
   * @param {Omit<TokenCreateTerrainMovementPathOptions, "preview">} [options.terrainOptions]
   *                                        The terrain movement options.
   * @param {Omit<TokenConstrainMovementPathOptions, "preview"|"history"|"measureOptions">} [options.constrainOptions]
   *                                        The options to constrain movement.
   * @param {Omit<TokenMeasureMovementPathOptions, "preview">} [options.measureOptions]
   *                                        The options to measure movement.
   * @param {Omit<TokenFindMovementPathOptions, "preview"|"terrainOptions"|"constrainOptions"|
   *   "measureOptions">} [options.pathfindingOptions]
   *                                        The pathfinding options.
   * @param {Omit<TokenMovementOptions, "id"|"method"|"terrainOptions"|"constrainOptions"|"measureOptions"|
   *   "planned">} [options.moveOptions]    The movement options.
   * @returns {Promise<{
   *     id: string;
   *     origin: TokenPosition;
   *     destination: TokenPosition;
   *     waypoints: TokenMovementWaypoint[];
   *   }|null>}  The planned movement ID, origin, destination, and waypoints, or null if the dismiss key was pressed,
   *             the Token was released, the game was paused and the user is not a GM, or the Token is locked.
   * @see {@link foundry.documents.TokenDocument#startMovement}
   * @example
   * ```js
   * const plan = await token.planMovement({
   *   allowedActions: ["blink"],
   *   direct: true,
   *   maxDistance: 30,
   *   preventDrop: true
   * });
   * if ( !plan ) return;
   * await token.document.startMovement(plan.id);
   */
  async planMovement({allowedActions=null, direct=false, minCost=0, maxCost=Infinity, minDistance=0,
    maxDistance=Infinity, preventDrop=false, terrainOptions={}, constrainOptions={}, measureOptions={},
    pathfindingOptions={}, moveOptions={}}={}) {
    if ( allowedActions ) {
      allowedActions = Array.from(allowedActions);
      if ( allowedActions.length === 0 ) throw new Error("The allowed actions must not be empty.");
      if ( !allowedActions.every(action => action in CONFIG.Token.movement.actions) ) {
        throw new Error("Invalid movement action.");
      }
    }
    if ( !canvas.ready ) throw new Error("The canvas is not ready.");
    if ( !this.document.isOwner ) throw new Error("You must be an owner of the Token in order to move it.");
    if ( this.document.hidden && !game.user.isGM ) throw new Error("A hidden Token cannot be moved by a non-GM user.");

    // Cancel the current movement planning if there is one
    this.layer._cancelMovementPlanning();

    // Cancel the current placement if there is one
    this.layer._cancelPlacement();
    canvas.regions._cancelPlacement();

    // Cancel current drag workflow
    if ( canvas.currentMouseManager ) {
      canvas.currentMouseManager.interactionData.cancelled = true;
      canvas.currentMouseManager.cancel();
    }

    // Non-GMs cannot move a token while the game is paused
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return null;
    }

    // Locked tokens cannot be moved
    if ( this.document.locked ) {
      ui.notifications.warn("CONTROLS.ObjectIsLocked", {format: {type: _loc("DOCUMENT.Token")}});
      return null;
    }

    // Create planning context
    const {promise, resolve, reject} = Promise.withResolvers();
    this.layer._movementPlanningContext = {object: this, allowedActions, direct, minCost, maxCost, minDistance,
      maxDistance, preventDrop, terrainOptions: foundry.utils.deepClone(terrainOptions),
      constrainOptions: foundry.utils.deepClone(constrainOptions),
      measureOptions: foundry.utils.deepClone(measureOptions),
      pathfindingOptions: foundry.utils.deepClone(pathfindingOptions),
      moveOptions: foundry.utils.deepClone(moveOptions), result: null, resolve, reject, violations: []};

    // Activate token layer, control and pan to this token
    this.layer.activate({tool: "select"});
    this.layer.setAllRenderFlags({refreshState: true});
    this.panCanvas({force: true});
    this.control({force: true});

    return promise;
  }

  /* -------------------------------------------- */
  /*  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.texture = null;
      this.#unlinkedVideo = false;
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _destroy(options) {
    this.#cancelDrag();
    this._removeAllFilterEffects();
    this.stopAnimation();                       // Cancel movement animations
    canvas.primary.removeToken(this);           // Remove the PrimarySpriteMesh from the PrimaryCanvasGroup
    this.initializeSources({deleted: true});    // Destroy the LightSource and VisionSource
    if ( this._previewType === "config" ) this._original.initializeSources();
    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 && (this._original.document.texture.src === this.document.texture.src) ) {
      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.levelIndicator ||= this.addChild(this.#drawLevelIndicator());
    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._previewType === "config" ) {
      this._original.initializeSources({deleted: true});
      this.initializeSources();
    }
    else if ( !this.isPreview ) this.initializeSources();

    this.cursor = "pointer";
  }

  /* -------------------------------------------- */

  /**
   * 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});
  }

  /* -------------------------------------------- */

  /**
   * Create an unattached VisionSource instance used for shared fog exploration.
   * @returns {PointVisionSource}
   * @internal
   */
  _createSharedFogVisionSource() {
    return this.#createVisionSource({sourceId: `${this.sourceId}.shared`});
  }

  /* -------------------------------------------- */

  /**
   * 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                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyRenderFlags(flags) {
    super._applyRenderFlags(flags);
    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.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();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshVisibility() {
    super._refreshVisibility();
    this.mesh.visible = this.visible && this.renderable;
    if ( this.layer.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.VISIBLE ) {
      canvas.perception.update({refreshOcclusion: true});
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshState() {
    super._refreshState();
    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.levelIndicator.visible = !isSecret && (this.controlled || isHover)
      && (this.document.level !== canvas.level.id);
    this.effects.visible = !isSecret;
    this.targetPips.visible = this.targetArrows.visible = !isSecret;
    this.mesh.zIndex = this.zIndex;
    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();
    const offset = CONFIG.Canvas.objectBorderThickness * 0.75 * canvas.dimensions.uiScale;
    this.nameplate.position.set(width / 2, height + offset);
    this.tooltip.position.set(width / 2, -offset);
    this.levelIndicator.x = width / 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();
    const s = canvas.dimensions.uiScale;
    const {fontSize} = PIXI.TextMetrics.measureFont(this.tooltip.style.toFontString());
    this.levelIndicator.width = this.levelIndicator.height = fontSize * s * 1.25;
    this.levelIndicator.y = this.tooltip.y - (this.tooltip.text ? this.tooltip.height : 0);
  }

  /* -------------------------------------------- */

  /**
   * 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() {
    foundry.canvas.borders.drawBorder(this.border, this.shape, {alignment: 1, dashed: this.document.hidden});
  }

  /* -------------------------------------------- */

  /**
   * Configure bespoke bar colors for a given bar. If this method is not implemented, the default colors from
   * CONFIG.Token.barConfig will be used.
   * @param {number} index  The bar index.
   * @param {object} data    Resource data for the base.
   * @returns {{empty: Color, full: Color}}
   * @protected
   */
  _getBarColors(index, data) {
    const config = CONFIG.Token.barConfig;
    return (index === 0 ? config.bar1 : config.bar2).colors;
  }

  /* -------------------------------------------- */

  /**
   * 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;
    }
  }

  /* -------------------------------------------- */

  /**
   * 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((key, index) => {
      const bar = this.bars[key];
      const data = this.document[key];
      if ( !data.max ) return bar.visible = false;
      this._drawBar(index, bar, data);
      bar.visible = true;
    });
  }

  /* -------------------------------------------- */

  /**
   * Draw a single resource bar, given provided data
   * @param {number} index        The Bar index
   * @param {PIXI.Graphics} bar   The Bar container
   * @param {object} data         Resource data for this bar
   * @protected
   */
  _drawBar(index, 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
    const colors = this._getBarColors(index, data);
    const color = Color.mix(colors.empty, colors.full, pct);

    // 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 = index === 0 ? height - bh : 0;
    bar.position.set(0, posY);
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * Draw the "not in viewed level" indicator.
   * @returns {PIXI.Sprite}
   */
  #drawLevelIndicator() {
    const indicator = new PIXI.Sprite(getTexture(CONFIG.controlIcons.levels));
    indicator.anchor.set(0.5, 1);
    return indicator;
  }

  /* -------------------------------------------- */

  /**
   * Return the text which should be displayed in a token's tooltip field.
   * @returns {string}
   * @protected
   */
  _getTooltipText() {
    const elevation = (this.document.elevation - canvas.level.elevation.base).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 SHOW_ICON = CONST.ACTIVE_EFFECT_SHOW_ICON;
    const activeEffects = this.actor?.appliedEffects.filter(e => ((e.showIcon === SHOW_ICON.ALWAYS)
      || ((e.showIcon === SHOW_ICON.CONDITIONAL) && e.isTemporary))) ?? [];
    const overlayEffect = activeEffects.findLast(e => e.flags.core?.overlay);

    // Draw effects
    const promises = [];
    for ( const [i, effect] of activeEffects.entries() ) {
      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 && (!["completed", "stopped"].includes(movement.state)
      || this.animationContexts.has(this.movementAnimationName)) ) {
      passedWaypoints = [
        ...movement.history.recorded.waypoints,
        ...movement.history.unrecorded.waypoints,
        ...movement.passed.waypoints
      ];
      if ( ["pending", "paused"].includes(movement.state) || ((movement.state === "planned")
        && !(movement.user.id in this._plannedMovement)) ) {
        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, depth, level, 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
    };
    const bar1 = {value: doc.bar1.value, max: doc.bar1.max};
    const bar2 = {value: doc.bar2.value, max: doc.bar2.max};
    return {x, y, elevation, width, height, depth, level, rotation, alpha, texture, ring: {subject}, bar1, bar2};
  }

  /* -------------------------------------------- */

  /**
   * 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) {

    // 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.document);
      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);
      }
    }
    else if ( !duration && (to.bar1 || to.bar2) ) {
      duration ??= 0;
      for ( const key of ["bar1", "bar2"] ) {
        if ( !foundry.utils.isPlainObject(to[key]) ) continue;
        const priorPercent = from[key].value / (from[key].max || NaN);
        const newPercent = to[key].value / (to[key].max || NaN);
        const absDifference = Math.abs(newPercent - priorPercent);
        if ( Number.isFinite(absDifference) ) {
          duration = Math.max(duration, 1500 * absDifference); // 1.5 seconds for 0 to 100%
        }
      }
    }
    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) {
    speed *= (options.speedMultiplier ?? 1);
    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"|"level">} from         The from-position
   * @param {Partial<Omit<TokenPosition, "elevation"|"shape"|"level">>} 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 dz = (from.elevation - (to.elevation ?? from.elevation)) * canvas.dimensions.distancePixels;
    const dw = from.width - (to.width ?? from.width);
    const dh = from.height - (to.height ?? from.height);
    const dd = from.depth - (to.depth ?? from.depth);
    return Math.max(Math.hypot(dx, dy, dz) / canvas.dimensions.size, Math.hypot(dw, dh, dd) * 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) {
    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).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();
      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 depthChanged = "depth" in changed;
    const levelChanged = "level" 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 || depthChanged || levelChanged,
      refreshPosition: positionChanged,
      refreshElevation: elevationChanged || levelChanged,
      refreshRotation: rotationChanged && !this.document.lockRotation,
      refreshSize: sizeChanged || ringSubjectScaleChanged,
      refreshMesh: textureChanged || ("alpha" in changed),
      refreshBars: ("bar1" in changed) || ("bar2" in changed)
    });

    // Update occlusion and/or sounds and the HUD if necessary
    if ( positionChanged || elevationChanged || sizeChanged || depthChanged || levelChanged ) {
      const flags = {refreshSounds: true, refreshOcclusionMask: true, refreshOcclusionStates: true};

      // Shared FoW ?
      const shared = canvas.fog.sharedExploration;
      const contributes = shared && !this.vision?.active && (levelChanged || this._isFogExplorationSource());
      if ( contributes ) {
        flags.refreshVision = true;
        flags.refreshLighting = true;
      }

      canvas.perception.update(flags);
      if ( this.hasActiveHUD ) this.layer.hud.setPosition();
    }

    // Exit early if sight, attached light sources, and attached regions are not animated
    if ( !game.settings.get("core", "visionAnimation") ) return;

    // Update light and sight sources
    const perspectiveChanged = positionChanged || elevationChanged || sizeChanged || depthChanged
      || (rotationChanged && this.hasLimitedSourceAngle) || levelChanged;
    const visionChanged = perspectiveChanged && this.hasSight;
    const lightChanged = perspectiveChanged && this._isLightSource();
    if ( visionChanged || lightChanged ) this.initializeSources();

    // Update the animation state of attached regions
    if ( !this.document.attachments.regions.size ) return;
    if ( !positionChanged && !elevationChanged && !rotationChanged && (context.time > 0) ) return;
    this.#animateAttachments();
  }

  /* -------------------------------------------- */

  /**
   * Animate Region attached to this Token.
   */
  #animateAttachments() {
    for ( const region of this.document.attachments.regions ) {
      if ( region.rendered ) region.object._onTokenAnimationFrame();
    }
  }

  /* -------------------------------------------- */

  /**
   * 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, depth, shape} = token._source;
    const elevation = point.elevation ?? canvas.level.elevation.base;
    const pivot = token.getCenterPoint({x: 0, y: 0, width, height, shape});
    let position = {
      x: point.x - pivot.x,
      y: point.y - pivot.y,
      elevation, width, height, depth, shape
    };
    if ( snap ) position = token.getSnappedPosition(position);
    position.x = Math.round(position.x);
    position.y = Math.round(position.y);
    position.elevation = elevation;
    position.width = width;
    position.height = height;
    position.depth = depth;
    position.shape = shape;
    position.level = canvas.level.id;

    // Make sure large tokens don't end up above a ceiling of the viewed level
    if ( point.elevation === undefined ) {
      const origin = token.getMovementOrigin(position);
      const offset = origin.elevation - position.elevation;
      const {bottom, top} = canvas.level.elevation;
      if ( (origin.elevation >= top) && (top >= bottom + canvas.grid.distance)
        && canvas.scene.getSurfaces({type: "move"}).some(s => (top <= s.elevation)
        && (s.elevation <= origin.elevation) && s.region.polygonTree.testPoint(origin)) ) {
        position.elevation = (top - offset - (canvas.grid.distance / canvas.grid.size))
          .toNearest(canvas.grid.distance, "floor");
      }
    }

    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"}={}) {
    const level = this.scene.levels.get(this.document._source.level);
    if ( !level ) {
      if ( mode === "any" ) return false;
      if ( mode === "all" ) return [];
      return null;
    }

    // Round origin and destination such that the top-left point (i.e. the Token's position) is integer
    const {elevation, width, height, depth, shape} = this.document._source;
    const {x: cx, y: cy, elevation: ce} = this.document.getMovementOrigin({x: 0, y: 0, elevation: 0, width, height,
      depth, shape});
    if ( !origin ) origin = this.document.getMovementOrigin(this.document._source);
    else {
      origin = {
        x: Math.round(origin.x - cx) + cx,
        y: Math.round(origin.y - cy) + cy,
        elevation: origin.elevation ?? (elevation + ce)
      };
    }
    destination = {
      x: Math.round(destination.x - cx) + cx,
      y: Math.round(destination.y - cy) + cy,
      elevation: destination.elevation ?? (elevation + ce)
    };

    // Reference the correct source object
    let source;
    switch ( type ) {
      case "move":
        source = this.#getMovementSource(origin, level.id); 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, level});
  }

  /* -------------------------------------------- */

  /**
   * Prepare a PointMovementSource for the document
   * @param {ElevatedPoint} origin        The origin of the source
   * @param {string} level                The ID of the level
   * @returns {PointMovementSource}
   */
  #getMovementSource(origin, level) {
    const movement = new PointMovementSource({object: this});
    movement.initialize({...origin, level});
    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 document = this.scene === canvas.scene ? this.document
      : this.document.clone({}, {parent: canvas.scene});
    const {elevation, width, height, depth, shape, level} = document._source;
    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 originLevel = this.document.parent.levels.get(level);
    const relativeElevation = (position.elevation ?? elevation) - (originLevel?.elevation.base ?? 0);
    position.elevation = canvas.level.clampElevation(relativeElevation + canvas.level.elevation.base, depth);
    const d = canvas.dimensions;
    const {x: cx, y: cy} = document.getCenterPoint({x: 0, y: 0, width, height, shape});
    return {
      x: Math.clamp(position.x, -Math.floor(cx), d.width - Math.ceil(cx)),
      y: Math.clamp(position.y, -Math.floor(cy), d.height - Math.ceil(cy)),
      elevation: position.elevation,
      level: canvas.level.id,
      hidden: hidden || document._source.hidden
    };
  }

  /* -------------------------------------------- */

  /**
   * Measure the movement path for this Token.
   * @param {TokenMeasurableMovementWaypoint[]} 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 {Partial<TokenProcessedMovementWaypoint>[]} waypoints  The waypoints of movement
   * @param {TokenConstrainMovementPathOptions} [options]          Additional options
   * @returns {[constrainedPath: TokenProcessedMovementWaypoint[], 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, options={}) {
    if ( waypoints.length === 0 ) return [[], false];

    // Ignore preview if token vision is disabled or the current user is a GM
    let {preview=false, ignoreWalls=false} = options;
    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,
      depth=source.depth, shape=source.shape, level=source.level, 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, depth, shape, level, action, terrain, snapped, explicit, checkpoint,
      intermediate};
    result.path.push(waypoint);

    // Compute adjusted origin
    let offsetX;
    let offsetY;
    let center;
    let adjustedCenter;
    if ( !ignoreWalls ) {
      offsetX = 0;
      offsetY = 0;
      center = this.document.getMovementOrigin(waypoint);
      adjustedCenter = Token.#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,
        depth=waypoint.depth, shape=waypoint.shape, level=waypoint.level, 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, depth, shape, level, action, terrain, snapped, explicit, checkpoint,
        intermediate};

      // Test scene bounds
      const priorCenter = center;
      center = this.document.getMovementOrigin(waypoint);
      if ( !canvas.dimensions.rect.contains(center.x, center.y) ) {
        result.constrained = true;
        break;
      }

      // Compute adjusted destination
      const priorAdjustedCenter = adjustedCenter;
      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;
        adjustedCenter = Token.#getMovementAdjustedPoint(center, offsetX, offsetY);
      }

      // Check for collisions with walls unless teleporting
      const restrictionType = CONFIG.Token.movement.actions[action].walls;
      if ( !ignoreWalls && restrictionType ) {
        const priorCenter = this.document.getMovementOrigin({
          x: priorWaypoint.x,
          y: priorWaypoint.y,
          elevation: priorWaypoint.elevation,
          width: waypoint.width,
          height: waypoint.height,
          shape: waypoint.shape
        });
        const height = waypoint.depth * canvas.grid.distance;
        const from = {
          waypoint: priorWaypoint,
          center: priorCenter,
          adjustedCenter: {x: priorAdjustedCenter.x, y: priorAdjustedCenter.y, elevation: priorCenter.elevation},
          feet: {x: priorCenter.x, y: priorCenter.y, elevation: priorWaypoint.elevation},
          head: {x: priorCenter.x, y: priorCenter.y, elevation: priorWaypoint.elevation + height}
        };
        const feet = {x: center.x, y: center.y, elevation: waypoint.elevation};
        const head = {x: center.x, y: center.y, elevation: waypoint.elevation + height};
        const to = {waypoint, center, adjustedCenter: adjustedCenter, feet, head};
        const collision = this.#testCollision(from, to, preview, options);
        if ( collision ) {
          result.constrained = true;

          // Restore prior center and x/y-offset
          center = priorCenter;
          offsetX = priorOffsetX;
          offsetY = priorOffsetY;

          // Get the collision waypoint
          const collisionWaypoint = this.#getCollisionWaypoint(waypoint, collision, from, offsetX, offsetY,
            preview, options);
          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)
            && collisionWaypoint.elevation.almostEqual(priorWaypoint.elevation) ) break;

          // Add collision waypoint to the result
          result.path.push(collisionWaypoint);
          break;
        }
      }

      result.path.push(waypoint);
    }

    // Don't allow movement that exceeds the maximum cost or distance
    const constrainedCostOrDistance = this.#constrainMovementPathCostAndDistance(result, options);

    // Recalculate final center and adjusted center if necessary
    if ( !ignoreWalls && constrainedCostOrDistance && (result.path.length > 0) ) {
      offsetX = 0;
      offsetY = 0;
      let center = this.document.getMovementOrigin(result.path[0]);
      for ( let i = 1; i < result.path.length; i++ ) {
        const waypoint = result.path[i];
        const nextCenter = this.document.getMovementOrigin(waypoint);
        if ( nextCenter.x !== center.x ) offsetX = Math.sign(nextCenter.x - center.x);
        if ( nextCenter.y !== center.y ) offsetY = Math.sign(nextCenter.y - center.y);
        center = nextCenter;
      }
      adjustedCenter = Token.#getMovementAdjustedPoint(center, offsetX, offsetY);
    }

    // Apply offset to the final waypoint if necessary
    if ( !ignoreWalls && (result.path.length > 0)
      && ((Math.round(center.x) !== adjustedCenter.x) || (Math.round(center.y) !== adjustedCenter.y)) ) {
      const last = result.path.at(-1);
      last.x -= offsetX;
      last.y -= offsetY;
      last.snapped = false;
      result.constrained = true;
    }

    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 {{waypoint: TokenMovementWaypoint; center: ElevatedPoint; adjustedCenter: ElevatedPoint;
   *   feet: ElevatedPoint; head: ElevatedPoint}} from
   *   The (un)adjusted origin, and feet and head of the origin
   * @param {number} offsetX                  The current x-offset
   * @param {number} offsetY                  The current y-offset
   * @param {boolean} preview                 Is this a preview?
   * @param {TokenConstrainMovementPathOptions} options
   * @returns {TokenProcessedMovementWaypoint|void}  The collision waypoint, or undefined if the movement
   *                                                 should stop at the origin
   */
  #getCollisionWaypoint(waypoint, collision, from, offsetX, offsetY, preview, options) {
    const origin = from.adjustedCenter;
    const level = from.waypoint.level;
    const pivot = this.document.getMovementOrigin({x: 0, y: 0, elevation: 0, width: waypoint.width,
      height: waypoint.height, depth: waypoint.depth, shape: waypoint.shape});
    const priorOffsetX = offsetX;
    const priorOffsetY = offsetY;

    // If not snapped or gridless, we use the exact point of collision if possible
    const d = canvas.dimensions;
    if ( !waypoint.snapped || canvas.grid.isGridless ) {
      const k = Math.ceil(d.size / 4);
      const n = Math.ceil(Math.hypot(collision.x - origin.x, collision.y - origin.y,
        (collision.elevation - origin.elevation) * d.distancePixels));
      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) - pivot.x),
          y: Math.round(Math.mix(collision.y, origin.y, t) - pivot.y),
          elevation: Math.mix(collision.elevation, origin.elevation, t) - pivot.elevation,
          width: waypoint.width, height: waypoint.height, depth: waypoint.depth, shape: waypoint.shape,
          level, action: waypoint.action, terrain: waypoint.terrain, snapped: false,
          explicit: false, checkpoint: false, intermediate: false
        };
        const center = this.document.getMovementOrigin(p);
        const feet = {x: center.x, y: center.y, elevation: p.elevation};
        const head = {x: center.x, y: center.y, elevation: p.elevation + (p.depth * d.distance)};
        const ox = Math.sign(center.x - from.center.x);
        const oy = Math.sign(center.y - from.center.y);
        if ( ox !== 0 ) offsetX = ox;
        if ( oy !== 0 ) offsetY = oy;
        const adjustedCenter = Token.#getMovementAdjustedPoint(center, offsetX, offsetY);
        const to = {waypoint: p, center, adjustedCenter, feet, head};
        if ( !this.#testCollision(from, to, preview, options) ) {
          if ( (Math.round(center.x) !== adjustedCenter.x) || (Math.round(center.y) !== adjustedCenter.y) ) {
            p.x -= offsetX;
            p.y -= offsetY;
          }
          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 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) - pivot.x,
          y: Math.mix(collision.y, origin.y, t) - pivot.y,
          elevation: Math.mix(collision.elevation, origin.elevation, t) - pivot.elevation,
          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.depth = waypoint.depth;
        p.shape = waypoint.shape;
        p.level = level;
        p.action = waypoint.action;
        p.terrain = waypoint.terrain;
        p.snapped = true;
        p.explicit = false;
        p.checkpoint = false;
        p.intermediate = false;
        const center = this.document.getMovementOrigin(p);
        const feet = {x: center.x, y: center.y, elevation: p.elevation};
        const head = {x: center.x, y: center.y, elevation: p.elevation + (p.depth * d.distance)};
        const ox = Math.sign(center.x - from.center.x);
        const oy = Math.sign(center.y - from.center.y);
        if ( ox !== 0 ) offsetX = ox;
        if ( oy !== 0 ) offsetY = oy;
        const adjustedCenter = Token.#getMovementAdjustedPoint(center, offsetX, offsetY);
        const to = {waypoint: p, center, adjustedCenter, feet, head};
        if ( !this.#testCollision(from, to, preview, options) ) {
          if ( (Math.round(center.x) !== adjustedCenter.x) || (Math.round(center.y) !== adjustedCenter.y) ) {
            p.x -= offsetX;
            p.y -= offsetY;
            p.snapped = false;
          }
          return p;
        }
        offsetX = priorOffsetX;
        offsetY = priorOffsetY;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Test for wall and surface collision for a movement between two points.
   * @param {{waypoint: TokenMovementWaypoint; center: ElevatedPoint; adjustedCenter: ElevatedPoint;
   *   feet: ElevatedPoint; head: ElevatedPoint}} from
   *   The (un)adjusted origin, and feet and head of the origin
   * @param {{waypoint: TokenMovementWaypoint; center: ElevatedPoint; adjustedCenter: ElevatedPoint;
   *   feet: ElevatedPoint; head: ElevatedPoint}} to
   *   The (un)adjusted origin, and feet and head of the destination
   * @param {boolean} preview              Is preview?
   * @param {TokenConstrainMovementPathOptions} options
   * @returns {ElevatedPoint|null}         The collision point with a wall or surface, if any
   */
  #testCollision(from, to, preview, options) {
    const type = CONFIG.Token.movement.actions[to.waypoint.action].walls;
    const level = from.waypoint.level;
    const wallCollision = this.#testWallCollision(from, to, preview, options);
    let surfaceCollision;
    const upwards = from.center.elevation <= to.center.elevation;
    if ( upwards ) {
      surfaceCollision = this.#testSurfaceCollision(from.head, to.head, type, "above", level, preview);

      // Convert to center
      if ( surfaceCollision ) surfaceCollision.elevation -= (from.head.elevation - from.center.elevation);
    } else {
      surfaceCollision = this.#testSurfaceCollision(from.feet, to.feet, type, "below", level, preview);

      // Convert to center
      if ( surfaceCollision ) surfaceCollision.elevation += (from.center.elevation - from.feet.elevation);
    }
    surfaceCollision ??= this.#testSurfaceCollision(from.center, to.center, type, "below", level, preview);
    if ( !surfaceCollision ) return wallCollision;
    if ( !wallCollision ) return surfaceCollision;
    if ( upwards ) {
      return wallCollision.elevation <= surfaceCollision.elevation ? wallCollision : surfaceCollision;
    } else {
      return wallCollision.elevation >= surfaceCollision.elevation ? wallCollision : surfaceCollision;
    }
  }

  /* -------------------------------------------- */

  /**
   * Test for wall collision for a movement between two points.
   * @param {{waypoint: TokenMovementWaypoint; center: ElevatedPoint;
   *   adjustedCenter: ElevatedPoint}} from  The (un)adjusted origin
   * @param {{waypoint: TokenMovementWaypoint; center: ElevatedPoint;
   *   adjustedCenter: ElevatedPoint}} to    The (un)adjusted destination
   * @param {boolean} preview              Is preview?
   * @param {TokenConstrainMovementPathOptions} options
   * @returns {ElevatedPoint|null}         The collision point with a wall, if any
   */
  #testWallCollision(from, to, preview, options) {
    if ( (from.center.x === to.center.x) && (from.center.y === to.center.y) ) return null;
    const origin = from.adjustedCenter;
    const destination = to.adjustedCenter;
    const {x: _x, y: _y, elevation: _elevation, ...segment} = to.waypoint;
    segment.level = from.waypoint.level;
    segment.actionConfig = CONFIG.Token.movement.actions[segment.action];
    let collision = null;
    const config = this._getMovementCollisionTestConfiguration(segment, {...options, preview});
    if ( !config.level ) return null;
    config.source?.initialize({...origin, level: config.level.id});
    const polygonBackend = CONFIG.Canvas.polygonBackends[config.type];
    if ( preview ) {
      // TODO: open doors that are not visible should be considered closed
      const collisions = polygonBackend.testCollision(origin, destination, {...config, mode: "all"});

      // 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, {...config, mode: "closest"});

    // Calculate the 3D collision point
    if ( !collision ) return null;
    return {
      x: collision.x,
      y: collision.y,
      elevation: Math.mix(origin.elevation, destination.elevation, collision._distance)
    };
  }

  /* -------------------------------------------- */

  /**
   * Get movement wall collision configuration that are passed to
   * {@link foundry.canvas.geometry.shapes.PointSourcePolygon.testCollision}
   * as part of {@link Token#constrainMovementPath}.
   * @param {Omit<TokenMovementSegmentData, "teleport"|"measure">} segment  The movement segment
   * @param {TokenConstrainMovementPathOptions} options  The constrain options. The `preview` option is always defined
   *                                                     and true for GMs and when Token Vision is disabled.
   * @returns {PointSourcePolygonConfig}
   * @protected
   */
  _getMovementCollisionTestConfiguration(segment, options) {
    const type = segment.actionConfig.walls;
    const level = this.scene.levels.get(segment.level);
    const source = new PointMovementSource({object: this});
    return {type, source, level};
  }

  /* -------------------------------------------- */

  /**
   * Test for surface collision for a movement between two points.
   * @param {ElevatedPoint} origin         The adjusted origin
   * @param {ElevatedPoint} destination    The adjusted destination
   * @param {EdgeRestrictionType} type     The restriction type
   * @param {"below"|"above"} side         The side of the surface that counts as colliding
   *                                       when the ray originates on the surface
   * @param {string} level                 The ID of the level the movement segment is in
   * @param {boolean} preview              Is preview?
   * @returns {ElevatedPoint|null}         The collision point with a surface, if any
   */
  #testSurfaceCollision(origin, destination, type, side, level, preview) {
    if ( !preview ) return canvas.scene.testSurfaceCollision(origin, destination, {type, mode: "closest", side, level});
    // TODO: open doors that are not visible should be considered closed
    let collision = null;
    const collisions = canvas.scene.testSurfaceCollision(origin, destination, {type, mode: "all", side, level});

    // 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;
      }
    }
    return collision;
  }

  /* -------------------------------------------- */

  /**
   * Discard the first waypoint that exceeds the maximum cost or distance and all waypoints after this one.
   * @param {{path: TokenMovementWaypoint[]; constrained: boolean}} result    The result
   * @param {TokenConstrainMovementPathOptions} options                       The options
   * @returns {boolean}                                                       Constrained?
   */
  #constrainMovementPathCostAndDistance(result, {preview=false, ignoreCost=false, maxCost=Number.MAX_VALUE,
    maxDistance=Infinity, history=false, measureOptions}) {
    if ( ignoreCost ) maxCost = Infinity;
    if ( (result.path.length === 0) || ((maxCost === Infinity) && (maxDistance === Infinity)) ) return false;
    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, depth, shape, level} = origin;
        history = [...history, {x, y, elevation, width, height, depth, shape, level, action: "displace", cost: 0}];
      }
    }

    // Discard the first waypoint that exceeds the maximum cost or distance and all waypoints after this one
    const measurement = this.measureMovementPath(history.concat(result.path), {...measureOptions, preview});
    if ( (measurement.cost <= maxCost) && (measurement.distance <= maxDistance) ) return false;
    let n = history.length;
    while ( (n < measurement.waypoints.length) && (measurement.waypoints[n].cost <= maxCost)
      && (measurement.waypoints[n].distance <= maxDistance) ) n++;
    n -= history.length;
    if ( result.path.length === n ) return false;
    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;
    return 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 {Partial<TokenMovementWaypoint>[]} waypoints    The waypoints of movement
   * @param {TokenFindMovementPathOptions} [options]        Additional options
   * @returns {TokenFindMovementPathJob}                    The job of the movement pathfinder
   */
  findMovementPath(waypoints, options={}) {
    let {preview=false, terrainOptions, constrainOptions, measureOptions} = options;

    /** @deprecated since v14 */
    if ( !constrainOptions ) {
      constrainOptions = {};
      for ( const key of ["ignoreWalls", "ignoreCost", "history"] ) {
        if ( !(key in options) ) continue;
        foundry.utils.logCompatibilityWarning(`The ${key} option of Token#findMovementPath is deprecated. `
          + `Pass ${key} through the constrainOptions option instead.`, {since: 14, until: 16, once: true});
        constrainOptions[key] = options[key];
      }
    }

    // Add terrain waypoints
    let path = this.createTerrainMovementPath(waypoints, {...terrainOptions, preview});

    // Constrain path with terrain data
    [path] = this.constrainMovementPath(path, {...constrainOptions, measureOptions, preview});

    // 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 {Partial<Omit<TokenProcessedMovementWaypoint, "terrain">>[]} waypoints  The waypoints of movement
   * @param {TokenCreateTerrainMovementPathOptions} [options]                       Additional options
   * @returns {TokenProcessedMovementWaypoint[]}                                    The movement path with terrain data
   */
  createTerrainMovementPath(waypoints, options={}) {
    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
    let {preview=false} = options;
    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,
      depth=source.depth, shape=source.shape, level=source.level, 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, depth, shape, level, action, terrain: null, snapped, explicit,
      checkpoint, intermediate: false});
    let from = {x, y, elevation, width, height, depth, shape, level, action};

    // Create region states
    const regionStates = this.scene.regions.reduce((states, region) => {
      if ( !region.hidden ) states.push({region, active: false, effects: []});
      return states;
    }, []);

    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, depth=from.depth,
        shape=from.shape, level=from.level, 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, depth, shape, level, 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.getMovementOrigin(from);
        const c1 = this.document.getMovementOrigin(to);
        const pivot = this.document.getMovementOrigin({x: 0, y: 0, elevation: 0, width, height, depth, 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, depth, shape, level: from.level}, 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, depth, shape, level: from.level, 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 ( state.region.includedInLevel(from.level) && (explored !== false) ) {
            for ( const behavior of state.region.behaviors ) {
              if ( behavior.disabled ) continue;
              state.effects.push(...behavior.system._getTerrainEffects(this.document, segment, options));
            }
          }
          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.getMovementOrigin(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.getMovementOrigin(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, depth: from.depth, shape: from.shape, level: from.level,
              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;
              }, []),
              options
            );

            // 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;
            }, []),
            options
          );

          // 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, depth, shape, level, 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, depth, shape, level, 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;
            }, []),
            options
          );
        }

        // 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; last: boolean})[], 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.getMovementOrigin(from);
    let rayIndex = 0;
    let lastWaypoint;

    // Create region states
    const regionStates = [];
    for ( const region of this.scene.regions ) {
      if ( region.hidden || !region.viewed || this.document.attachments.regions.has(region) ) continue;
      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.getMovementOrigin(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, depth: from.depth, shape: from.shape, level: from.level,
            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, depth, shape, level, regions} = regionWaypoints[j++];

        // Remove redundant region waypoints
        if ( previousRegions?.equals(regions) ) path.pop();

        path.push({x, y, elevation, width, height, depth, shape, level, 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, depth: to.depth,
        shape: to.shape, level: to.level, action: to.action, terrain: to.terrain, regions, ray: null, last: false});

      if ( !to.intermediate ) {
        const center = this.document.getMovementOrigin(to);
        lastWaypoint = path[rayIndex];
        lastWaypoint.ray = new Ray(previousCenter, center);
        previousCenter = center;
        rayIndex = path.length;
      }

      from = to;
    }

    lastWaypoint.last = true;
    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: {force: true}}); // Assume control
    }
    canvas.perception.update({refreshOcclusion: true});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {

    // Cancel movement planning for this token when it becomes locked
    if ( (this.layer._movementPlanningContext?.object === this) && ("locked" in changed) && this.document.locked ) {
      this.layer._cancelMovementPlanning();
      ui.notifications.warn("CONTROLS.ObjectIsLocked", {format: {type: _loc("DOCUMENT.Token")}});
    }

    super._onUpdate(changed, options, userId);
    const doc = this.document;

    // Update drag preview
    this.#onUpdateDrag(changed, options, userId);

    // Acquire or release Token control
    const hiddenChanged = "hidden" in changed;
    if ( hiddenChanged ) {
      // Gain control over your token as a player if it becomes visible and you don't already control something
      if ( this.isOwner && !game.user.isGM && (changed.hidden === false) && !canvas.tokens.controlled.length ) {
        this.control({pan: {force: true}});
      }
      if ( this.isOwner && (this.layer.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.OWNED) ) {
        canvas.perception.update({refreshOcclusion: true});
      }
    }

    // Automatically pan the canvas
    const centerChanged = ["x", "y", "width", "height"].some(k => k in changed);
    if ( centerChanged && this.controlled && (options.pan !== false) ) {
      this.panCanvas(foundry.utils.isPlainObject(options.pan) ? options.pan : {});
    }

    const actorIdentityChanged = ("actorId" in changed) || ("actorLink" in changed);
    const barsChanged = ["displayBars", "bar1", "bar2"].some(k => k in changed);

    // Handle animation
    if ( (actorIdentityChanged || barsChanged) && this.#animationContexts.size ) this.stopAnimation();
    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.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));
    const movement = !!options._movement?.[this.document.id] || ("_movementHistory" in changed);
    this.renderFlags.set({
      redraw: ringEnabledChanged || actorIdentityChanged || ("delta" in changed),
      refreshState: hiddenChanged || ("locked" in changed) || ("sort" in changed) || ("disposition" in changed) || ("displayBars" in changed) || ("displayName" in changed) || movement,
      refreshRotation: "lockRotation" in changed,
      refreshMesh: textureChanged && ("fit" in changed.texture),
      refreshShape: "shape" in changed,
      refreshBorder: hiddenChanged,
      refreshBars: barsChanged,
      refreshNameplate: ["displayName", "name"].some(k => k in changed),
      refreshRingVisuals: ringVisualsChanged,
      refreshTurnMarker: ("turnMarker" in changed) || ("disposition" in changed),
      refreshRuler: movement
    });
  }

  /* -------------------------------------------- */

  /**
   * 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;

    // Cancel dragging of this token if it becomes locked
    if ( ("locked" in changed) && this.document.locked ) {
      this.#cancelDrag();
      return;
    }

    // Update the preview token, but keep current position
    context.clonedToken.document.updateSource(changed);
    for ( const key of TokenDocument.MOVEMENT_FIELDS ) {
      context.clonedToken.document[key] = context.destination[key] ?? this.document._source[key];
    }
    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 = Token.#copyPosition(this.document._source);

      // 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);

        // 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;
    }
    let movement = options._movement?.[this.document.id];
    if ( !movement?.passed.waypoints.length ) movement = undefined;
    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}}});
    }

    // If Token Vision Animation is disabled, set region animation state to the origin position of the token
    // and update the animation state at the end of the token animation
    if ( !game.settings.get("core", "visionAnimation") && (movement || ("rotation" in to)) ) this.#animateAttachments();

    // Animate movement separately from the non-movement-related fields
    let movementAnimationDuration;
    if ( movement ) {
      const previousMovementAnimationPromise = this.movementAnimationPromise;
      let previousMovementAnimationDuration = 0;
      const previousContext = this.#animationContexts.get(this.movementAnimationName);
      if ( previousContext ) {
        previousMovementAnimationDuration = previousContext.duration;
        for ( const {options: {duration}} of previousContext.chain ) previousMovementAnimationDuration += duration;
        previousMovementAnimationDuration = Math.max(previousMovementAnimationDuration - previousContext.time, 0);
      }
      let movementAnimationPromise = previousMovementAnimationPromise;
      this.#showRuler = movement.showRuler;

      // Prevent keyboard movement until right before the movement animation ends
      this._preventKeyboardMovement = true;

      let offsetX = 0;
      let offsetY = 0;
      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
              });
            }
            if ( waypoint.last ) offsetX = offsetY = 0;
            else {
              if ( ray.dx !== 0 ) offsetX = Math.sign(ray.dx);
              if ( ray.dy !== 0 ) offsetY = Math.sign(ray.dy);
            }
          }
        }

        // Adjusted waypoint position
        const position = {
          x: waypoint.x,
          y: waypoint.y,
          elevation: waypoint.elevation,
          width: waypoint.width,
          height: waypoint.height,
          depth: waypoint.depth,
          shape: waypoint.shape,
          level: waypoint.level
        };
        if ( (offsetX !== 0) || (offsetY !== 0) ) {
          const origin = this.document.getMovementOrigin(position);
          origin.x = Math.round(origin.x);
          origin.y = Math.round(origin.y);
          if ( Token.#needsMovementAdjustment(origin) ) {
            position.x -= offsetX;
            position.y -= offsetY;
          }
        }

        // Dispatch animation for the waypoint
        const start = movementAnimationPromise;
        const end = this.animate(position, {
          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 sources and the region animation states 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 || ("depth" in changed)
            || (rotationChanged && this.hasLimitedSourceAngle) || ("level" in changed);
          const visionChanged = perspectiveChanged && this.hasSight;
          const lightChanged = perspectiveChanged && this._isLightSource();
          if ( visionChanged || lightChanged ) this.initializeSources();
          this.#animateAttachments();
        };
        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;
      }

      // Assign the animation promises and duration to the movement state
      if ( previousMovementAnimationPromise ) {
        this.document._movement.animation.started = previousMovementAnimationPromise;
      }
      this.document._movement.animation.ended = movementAnimationPromise ?? this.document._movement.animation.started;
      this.document._movement.animation.duration = movementAnimationDuration - previousMovementAnimationDuration;
      Object.freeze(this.document._movement.animation);

      // 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
    const animationPromise = this.animate(to, animationOptions);

    // Update the region animation state and sight/light sources at the end of the token animation
    // if Token Vision Animation is disabled
    if ( !game.settings.get("core", "visionAnimation") && ("rotation" in to) ) {
      animationPromise.then(() => {
        const perspectiveChanged = this.hasLimitedSourceAngle;
        const visionChanged = perspectiveChanged && this.hasSight;
        const lightChanged = perspectiveChanged && this._isLightSource();
        if ( visionChanged || lightChanged ) this.initializeSources();
        this.#animateAttachments();
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * 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: Token.#copyPosition(from)
      };
      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: Token.#copyPosition(to)
      };
      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 to this Token.
   * @param {TokenPanningOptions} [options]    Additional options
   * @returns {Promise<void>}  Resolves once the panning/transition animation is complete.
   */
  async panCanvas({transitionType=null, duration, speed, easing, force=false}={}) {
    if ( !force ) {

      // 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) ) return;
    }

    if ( transitionType ) await canvas.transition.run({
      operation: () => canvas.pan(this.center),
      transitionType,
      duration
    });
    else await canvas.animatePan({...this.center, duration, speed, easing});
  }

  /* -------------------------------------------- */

  /**
   * 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 {boolean|TokenPanningOptions} [options.pan=false] Pan to the controlled Token
   * @protected
   * @inheritDoc
   */
  _onControl({releaseOthers=true, pan=false, ...options}={}) {
    super._onControl(options);
    for ( const token of this.layer.placeables ) 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 ) this.panCanvas(foundry.utils.isPlainObject(pan) ? pan : {});

    // View the token's level if it's the only token that is being controlled
    if ( (this.document._source.level !== canvas.level.id) && releaseOthers ) {
      this.scene.view({level: this.document._source.level, controlledTokens: [this.document.id]});
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onRelease(options) {
    super._onRelease(options);
    for ( const token of this.layer.placeables ) token.initializeVisionSource();
    canvas.perception.update({
      refreshVision: true,
      refreshSounds: true,
      refreshOcclusion: this.layer.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.CONTROLLED
    });
    if ( this.layer._movementPlanningContext?.object === this ) this.layer._cancelMovementPlanning();
  }

  /* -------------------------------------------- */

  /** @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                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _canControl(user, event) {
    if ( !this.layer.active ) return false;
    if ( canvas.regions._placementContext ) return false;
    if ( this.layer._draggedToken ) return false;
    if ( this.layer._movementPlanningContext?.object === this ) return true;
    const rulerClass = CONFIG.Canvas.rulerClass;
    if ( canvas.controls.ruler.active || (rulerClass.canMeasure && (event?.type === "pointerdown")) ) return false;

    // Make sure _canControl called in Token#control doesn't enter the "target tool" path
    if ( (game.activeTool === "target") && (event?.type === "pointerdown") ) return true;

    return super._canControl(user, event);
  }

  /* -------------------------------------------- */

  /** @override */
  _canHUD(user, event) {
    if ( !this.layer.active ) return false;
    if ( canvas.regions._placementContext ) return false;
    if ( this.layer._draggedToken ) 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.active ) return false;
    if ( canvas.regions._placementContext ) return false;
    if ( this.layer._draggedToken ) return false;
    if ( canvas.controls.ruler.active || (CONFIG.Canvas.rulerClass.canMeasure && (event?.type === "pointerdown")) ) return false;
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _canHover(user, event) {
    return !this.document.isSecret && !this.layer._draggedToken;
  }

  /* -------------------------------------------- */

  /** @override */
  _canView(user, event) {
    if ( !this.layer.active ) return false;
    if ( canvas.regions._placementContext ) return false;
    if ( this.layer._draggedToken ) 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.controlled ) return false;
    if ( canvas.regions._placementContext ) 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, options) {
    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, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickLeft(event) {
    if ( game.activeTool === "target" ) {
      event.stopPropagation();
      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.isOwner && game.user.can("TOKEN_CONFIGURE") ) return super._onClickRight2(event);
    event.stopPropagation();
    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;
    MouseInteractionManager.emulateMoveEvent();

    // 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 origin = Token.#copyPosition(this.document._source);
    let snapped = false;
    if ( !canvas.grid.isGridless ) {
      const snappedPosition = this.document.getSnappedPosition(origin);
      snapped = (origin.x === Math.round(snappedPosition.x)) && (origin.y === Math.round(snappedPosition.y))
        && (origin.elevation.almostEqual(snappedPosition.elevation));
    }
    const destination = {...origin, 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;
    return this.layer._draggedToken?.mouseInteractionManager.interactionData.contexts[this.document.id];
  }

  /* -------------------------------------------- */

  /**
   * 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 terrain options used during the drag operation.
   * @returns {Omit<TokenCreateTerrainMovementPathOptions, "preview">}    The terrain options
   * @protected
   */
  _getDragTerrainOptions() {
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Get the terrain options used during the drag operation.
   * @returns {Omit<TokenCreateTerrainMovementPathOptions, "preview">}    The terrain options
   */
  #getDragTerrainOptions() {
    const options = this._getDragTerrainOptions();
    if ( this.layer._movementPlanningContext?.object === this ) {
      foundry.utils.mergeObject(options, this.layer._movementPlanningContext.terrainOptions);
    }
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Get the constrain options used during the drag operation.
   * @returns {Omit<TokenConstrainMovementPathOptions, "preview"|"history"|"measureOptions">}    The constrain options
   * @protected
   */
  _getDragConstrainOptions() {
    const unconstrainedMovement = game.user.isGM && game.settings.get("core", "unconstrainedMovement");
    return {ignoreWalls: unconstrainedMovement, ignoreCost: unconstrainedMovement};
  }

  /* -------------------------------------------- */

  /**
   * Get the constrain options used during the drag operation.
   * @returns {Omit<TokenConstrainMovementPathOptions, "preview"|"history"|"measureOptions">}    The constrain options
   */
  #getDragConstrainOptions() {
    const options = this._getDragConstrainOptions();
    if ( this.layer._movementPlanningContext?.object === this ) {
      foundry.utils.mergeObject(options, this.layer._movementPlanningContext.constrainOptions);
    }
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Get the measure options used during the drag operation.
   * @returns {Omit<TokenMeasureMovementPathOptions, "preview">}    The measure options
   * @protected
   */
  _getDragMeasureOptions() {
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Get the measure options used during the drag operation.
   * @returns {Omit<TokenMeasureMovementPathOptions, "preview">}    The measure options
   */
  #getDragMeasureOptions() {
    const options = this._getDragMeasureOptions();
    if ( this.layer._movementPlanningContext?.object === this ) {
      foundry.utils.mergeObject(options, this.layer._movementPlanningContext.measureOptions);
    }
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Get the pathfinding options used during the drag operation to find the path of movement through the waypoints.
   * @returns {Omit<TokenFindMovementPathOptions, "preview"|"terrainOptions"|"constrainOptions"|
   *   "measureOptions">} The pathfinding options
   * @protected
   */
  _getDragPathfindingOptions() {
    return {delay: 250};
  }

  /* -------------------------------------------- */

  /**
   * Get the pathfinding options used during the drag operation to find the path of movement through the waypoints.
   * @returns {TokenFindMovementPathOptions}    The pathfinding options
   */
  #getDragPathfindingOptions() {
    const options = this._getDragPathfindingOptions();
    if ( this.layer._movementPlanningContext?.object === this ) {
      foundry.utils.mergeObject(options, this.layer._movementPlanningContext.pathfindingOptions);
    }
    options.preview = true;
    options.terrainOptions = this.#getDragTerrainOptions();
    options.constrainOptions = this.#getDragConstrainOptions();
    options.measureOptions = this.#getDragMeasureOptions();
    /** @deprecated since v14 */
    for ( const key of ["ignoreWalls", "ignoreCost", "history"] ) {
      let value = options.constrainOptions[key];
      Object.defineProperty(options, key, {
        get() {
          foundry.utils.logCompatibilityWarning(`The ${key} option of Token#findMovementPath is deprecated. `
            + `Pass ${key} through the constrainOptions option instead.`, {since: 14, until: 16, once: true});
          return value;
        },
        set(v) { value = v; },
        enumerable: true
      });
    }
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Get the movement action for the waypoints placed during a drag operation.
   * @returns {string}    The movement action
   * @protected
   */
  _getDragMovementAction() {
    return this.document.movementAction;
  }

  /* -------------------------------------------- */

  /**
   * Get the movement action for the waypoints placed during a drag operation.
   * @returns {string}    The movement action
   */
  #getDragMovementAction() {
    if ( this.layer._dragMovementAction ) return this.layer._dragMovementAction;
    if ( (this.layer._movementPlanningContext?.object === this)
      && this.layer._movementPlanningContext.allowedActions ) {
      return this.layer._movementPlanningContext.allowedActions[0];
    }
    return this._getDragMovementAction();
  }

  /* -------------------------------------------- */

  /** @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) {
    if ( (event.ctrlKey || event.metaKey) && this.ruler ) return true;
    if ( this.layer._movementPlanningContext?.object !== this ) return false;
    if ( !this.layer._movementPlanningContext.preventDrop ) return false;
    const violations = this.layer._movementPlanningContext.violations;
    if ( !violations.length ) return false;
    for ( const constraint of violations ) {
      ui.notifications.warn(`TOKEN.MOVEMENT.NOTIFICATIONS.PLAN.${constraint.capitalize()}Violation`, {localize: true});
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Get the update operation options that should be used for a drag-left-drop operation.
   * @returns {Partial<Omit<DatabaseUpdateOperation, "updates"> & Omit<TokenMovementOptions, "id"|"method">}
   * @protected
   */
  _getDragLeftDropUpdateOptions() {
    const options = {
      terrainOptions: this.#getDragTerrainOptions(),
      constrainOptions: this.#getDragConstrainOptions(),
      measureOptions: this.#getDragMeasureOptions()
    };
    if ( this.layer._movementPlanningContext?.object === this ) {
      foundry.utils.mergeObject(options, this.layer._movementPlanningContext.moveOptions);
    }
    return options;
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareDragLeftDropUpdates(event) {
    const updates = [];
    const movement = {};
    for ( const [id, context] of Object.entries(event.interactionData.contexts) ) {
      if ( context.foundPath.length <= 1 ) continue;
      let movementId;
      const waypoints = context.foundPath.slice();
      const planned = this.layer._movementPlanningContext?.object === context.token;
      if ( planned ) {
        movementId = foundry.utils.randomID();
        const origin = context.foundPath.at(0);
        const destination = context.foundPath.at(-1);
        this.layer._movementPlanningContext.result = {
          id: movementId,
          origin: {x: origin.x, y: origin.y, elevation: origin.elevation,
            width: origin.width, height: origin.height, depth: origin.depth, shape: origin.shape,
            level: origin.level},
          destination: {x: destination.x, y: destination.y, elevation: destination.elevation,
            width: destination.width, height: destination.height, depth: destination.depth, shape: destination.shape,
            level: destination.level},
          waypoints: context.foundPath.slice(1)
        };
        const {minCost, maxCost, minDistance, maxDistance, measureOptions} = this.layer._movementPlanningContext;
        context.token.#constrainPlannedMovement(waypoints, {preview: false, minCost, maxCost, minDistance, maxDistance,
          measureOptions});
      }
      waypoints.shift();
      if ( !waypoints.length ) continue;
      updates.push({_id: id});
      movement[id] = {id: movementId, waypoints, planned};
    }
    return [updates, {...this._getDragLeftDropUpdateOptions(), method: "dragging", 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 if ( !this._shouldPreventDragLeftDrop(event) ) 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}={}) {
    if ( this.layer._movementPlanningContext?.direct ) {
      ui.notifications.warn("TOKEN.MOVEMENT.NOTIFICATIONS.PLAN.DirectViolation", {localize: true});
      return;
    }

    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;
    MouseInteractionManager.emulateMoveEvent();

    // 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() {
    if ( this._original ) {
      this._original.initializeSources();
      const movementPlanningContext = this.layer._movementPlanningContext;
      if ( movementPlanningContext?.object === this._original ) {
        this.layer._movementPlanningContext = null;
        this.layer.setAllRenderFlags({refreshState: true});
        movementPlanningContext.resolve(movementPlanningContext.result);
      }
    }
    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<Omit<TokenCoordinates, "level">>} current
   * @param {DeepReadonly<Partial<ElevatedPoint>>} changes
   * @param {object} [options]
   * @param {boolean} [options.snap=false]
   * @returns {Omit<TokenCoordinates, "level"> & 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, level=source.level} = 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.document.level = level;
    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});
    context.token.#updatePlannedMovement();
    context.token.renderFlags.set({refreshRuler: true, refreshState: 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, depth=context.origin.depth,
      shape=context.origin.shape, level=context.origin.level, action, snapped, explicit,
      checkpoint} of [...context.waypoints, destination] ) {
      const waypoint = {x, y, elevation, width, height, depth, shape, level, 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 && !this._movementPlanningContext?.direct ) {
      const pathfindingOptions = context.token.#getDragPathfindingOptions();
      context.search = context.token.findMovementPath(explicitWaypoints, pathfindingOptions);
    } else {
      const foundPath = [];
      for ( const {x, y, elevation, width=context.origin.width, height=context.origin.height,
        depth=context.origin.depth, shape=context.origin.shape, level=context.origin.level,
        action=context.token.document.movementAction, snapped=false, explicit=false,
        checkpoint=false} of explicitWaypoints ) {
        foundPath.push({x, y, elevation, width, height, depth, shape, level, 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;
        }
      }

      // Constrain the found path if planned
      if ( context.token.layer._movementPlanningContext?.object === context.token ) {
        const {minCost, maxCost, minDistance, maxDistance,
          measureOptions} = context.token.layer._movementPlanningContext;
        context.token.layer._movementPlanningContext.violations = context.token.#constrainPlannedMovement(foundPath,
          {preview: true, minCost, maxCost, minDistance, maxDistance, measureOptions});
      }

      // 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);
    }
  }

  /* -------------------------------------------- */

  /**
   * Constrain the planned movement.
   * @param {TokenMovementWaypoint[]} path  The movement path.
   * @param {object} [options]              Additional options.
   * @param {boolean} [options.preview]     Constrain a preview path?
   * @param {number} [options.minCost]      Minimum cost. Default: `0`.
   * @param {number} [options.maxCost]      Maximum cost. Default: `Infinity`.
   * @param {number} [options.minDistance]  Minimum distance. Default: `0`.
   * @param {number} [options.maxDistance]  Maximum distance. Default: `Infinity`.
   * @param {Omit<TokenMeasureMovementPathOptions, "preview">} [options.measureOptions]  The measurement options.
   * @returns {("minCost"|"maxCost"|"minDistance"|"maxDistance")[]}  The violated constraints.
   */
  #constrainPlannedMovement(path, {preview=false, minCost=0, maxCost=Infinity, minDistance=0,
    maxDistance=Infinity, measureOptions}={}) {
    if ( path.length <= 1 ) return [];
    if ( (minCost <= 0) && (maxCost === Infinity) && (minDistance <= 0) && (maxDistance === Infinity) ) return [];
    let history = this.document.movementHistory;

    // We only need to consider the history in alternating diagonal grid if the cost is constrained
    if ( ![CONST.GRID_DIAGONALS.ALTERNATING_1, CONST.GRID_DIAGONALS.ALTERNATING_2].includes(canvas.grid.diagonals)
        || ((minCost <= 0) && (maxCost === Infinity)) ) history = [];

    // 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 = path[0];
      if ( !TokenDocument.arePositionsEqual(previous, origin) ) {
        const {x, y, elevation, width, height, depth, shape, level} = origin;
        history = [...history, {x, y, elevation, width, height, depth, shape, level, action: "displace", cost: 0}];
      }
    }

    // Discard the first waypoint that exceeds the maximum cost or distance and all waypoints after this one
    const measurement = this.measureMovementPath(history.concat(path), {...measureOptions, preview});
    const {cost: historyCost, distance: historyDistance} = history.length ? measurement.waypoints[history.length - 1]
      : {cost: 0, distance: 0};
    const violations = [];
    if ( measurement.cost - historyCost < minCost ) {
      path.length = 1;
      violations.push("minCost");
    }
    if ( measurement.distance - historyDistance < minDistance ) {
      path.length = 1;
      violations.push("minDistance");
    }
    if ( violations.length ) return violations;
    let n = history.length;
    while ( (n < measurement.waypoints.length) && (measurement.waypoints[n].cost - historyCost <= maxCost)
      && (measurement.waypoints[n].distance - historyDistance <= maxDistance) ) n++;
    if ( n === measurement.waypoints.length ) return violations;
    if ( measurement.waypoints[n].cost - historyCost > maxCost ) violations.push("maxCost");
    if ( measurement.waypoints[n].distance - historyDistance > maxDistance ) violations.push("maxDistance");
    path.length = n - history.length;
    return violations;
  }

  /* -------------------------------------------- */

  /**
   * 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 terrainOptions = {...this.#getDragTerrainOptions(), preview: true};
    const foundPath = this.document.getCompleteMovementPath(
      this.createTerrainMovementPath(context.foundPath, terrainOptions));
    const unreachableWaypoints = this.document.getCompleteMovementPath(
      this.createTerrainMovementPath([foundPath.at(-1), ...context.unreachableWaypoints], terrainOptions)).slice(1);
    const combinedPath = [...this.document.movementHistory, ...foundPath, ...unreachableWaypoints];
    const measureOptions = {...this.#getDragMeasureOptions(), preview: true};
    const measurement = this.measureMovementPath(combinedPath, measureOptions);
    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.equals(previousPlannedMovement, plannedMovement) ) return;
    this._plannedMovement[game.user.id] = plannedMovement;
    this.#throttleBroadcastPlannedMovement();

    // Refresh ruler and state
    this.renderFlags.set({refreshRuler: true, refreshState: !previousPlannedMovement});

    // 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});
    }
  }

  /* -------------------------------------------- */

  /**
   * 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}});
  }

  /* -------------------------------------------- */

  /**
   * Copy the given position.
   * @param {TokenPosition} position
   * @returns {TokenPosition}
   */
  static #copyPosition(position) {
    return TokenDocument.MOVEMENT_FIELDS.reduce((copy, key) => {
      copy[key] = position[key];
      return copy;
    }, {});
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @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: 15, 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: 15, once: true});
    return this.targetArrows;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  getMovementAdjustedPoint(point, {offsetX=0, offsetY=0}={}) {
    foundry.utils.logCompatibilityWarning("Token#getMovementAdjustedPoint is deprecated with no replacement. Movement-based adjustment of center points is no longer required. "
      + "Use the unadjusted point instead, rounding the x and y coordinates as needed.", {since: 14, until: 16, once: true});
    const adjusted = Token.#getMovementAdjustedPoint(point, offsetX, offsetY);
    if ( point.elevation === undefined ) delete adjusted.elevation;
    return adjusted;
  }
};

/**
 * @import {ChatBubbleOptions} from "./_types.mjs";
 */

/**
 * 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.hbs";

  /* -------------------------------------------- */

  /**
   * A repository of active chat bubble references by token ID.
   * @type {Record<string, HTMLElement>}
   */
  get bubbles() {
    const result = {};
    for ( const el of this.element?.querySelectorAll(".chat-bubble[data-token-id]") ?? [] ) {
      result[el.dataset.tokenId] = el;
    }
    return result;
  }

  /* -------------------------------------------- */

  /**
   * 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 {Token|TokenDocument} 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 with the created bubble HTML, or null
   */
  async broadcast(token, message, options={}) {
    if ( token instanceof Token$1 ) 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(canvas.dimensions.scale.default, canvas.stage.scale.x);
      panPromise = canvas.animatePan({...token.center, 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");
  }
}

/**
 * A smooth noise generator for one-dimensional values.
 * @param {object} options                        Configuration options for the noise process.
 * @param {number} [options.amplitude=1]          The generated noise will be on the range [0, amplitude].
 * @param {number} [options.scale=1]              An adjustment factor for the input x values which place them on an
 *                                                appropriate range.
 * @param {number} [options.maxReferences=256]    The number of pre-generated random numbers to generate.
 */
class SmoothNoise {
  constructor({amplitude=1, scale=1, maxReferences=256}={}) {

    // Configure amplitude
    this.amplitude = amplitude;

    // Configure scale
    this.scale = scale;

    // Create pre-generated random references
    if ( !Number.isInteger(maxReferences) || !PIXI.utils.isPow2(maxReferences) ) {
      throw new Error("SmoothNoise maxReferences must be a positive power-of-2 integer.");
    }
    Object.defineProperty(this, "_maxReferences", {value: maxReferences || 1, writable: false});
    Object.defineProperty(this, "_references", {value: [], writable: false});
    for ( let i = 0; i < this._maxReferences; i++ ) {
      this._references.push(Math.random());
    }
  }

  /**
   * Amplitude of the generated noise output
   * The noise output is multiplied by this value
   * @type {number}
   */
  get amplitude() {
    return this._amplitude;
  }
  set amplitude(amplitude) {
    if ( !Number.isFinite(amplitude) || (amplitude === 0) ) {
      throw new Error("SmoothNoise amplitude must be a finite non-zero number.");
    }
    this._amplitude = amplitude;
  }
  _amplitude;

  /**
   * Scale factor of the random indices
   * @type {number[]}
   */
  get scale() {
    return this._scale;
  }
  set scale(scale) {
    if ( !Number.isFinite(scale) || (scale <= 0 ) ) {
      throw new Error("SmoothNoise scale must be a finite positive number.");
    }
    this._scale = scale;
  }
  _scale;

  /**
   * Generate the noise value corresponding to a provided numeric x value.
   * @param {number} x      Any finite number
   * @return {number}       The corresponding smoothed noise value
   */
  generate(x) {
    const scaledX = x * this._scale;                                         // The input x scaled by some factor
    const xFloor = Math.floor(scaledX);                                      // The integer portion of x
    const t = scaledX - xFloor;                                              // The fractional remainder, zero in the case of integer x
    const tSmooth = t * t * (3 - 2 * t);                                     // Smooth cubic [0, 1] for mixing between random numbers
    const i0 = xFloor & (this._maxReferences - 1);                           // The current index of the references array
    const i1 = (i0 + 1) & (this._maxReferences - 1);                         // The next index of the references array
    const y = Math.mix(this._references[i0], this._references[i1], tSmooth); // Smoothly mix between random numbers
    return y * this._amplitude;                                              // The final result is multiplied by the requested amplitude
  };
}

/**
 * A 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());

/**
 * A lightweight screen/object shake utility.
 *
 * CanvasShakeEffect applies a smooth, time-based positional jitter to a target {@link PIXI.DisplayObject}
 * by offsetting its `x` and `y` coordinates relative to a captured reference point. The shake motion is produced using
 * two independent {@link foundry.canvas.animation.SmoothNoise} generators (one per axis) to avoid harsh,
 * frame-to-frame randomness and provide a more natural camera-like shake.
 *
 * The effect runs for {@link CanvasShakeEffect#duration} milliseconds, with a linearly decaying amplitude from
 * {@link CanvasShakeEffect#maxDisplacement} down to zero. After the active shake window ends, the target smoothly returns
 * to its reference point using {@link CanvasShakeEffect#returnSpeed} as a per-tick interpolation factor.
 *
 * If multiple shake instances target the same object, the newest shake replaces the currently active one using a
 * smooth transition, and the target returns to its original pre-shake position once shaking completes.
 * Important: Changes of `x` and `y` from external sources outside of CanvasShakeEffect are taken into account.
 *
 * Safety/termination conditions:
 * - If the target object is destroyed, the effect stops immediately.
 * - If {@link Canvas#photosensitiveMode} is enabled, the effect stops immediately.
 * - If {@link CanvasShakeEffect#maxDisplacement} or {@link CanvasShakeEffect#duration} are zero,
 *   the effect stops immediately.
 *
 * @example
 * // Shake a target for 6 seconds with a 20px peak amplitude
 * const shake = new foundry.canvas.animation.CanvasShakeEffect({
 *   target: canvas.stage,
 *   duration: 6000,
 *   maxDisplacement: 20,
 *   smoothness: 0.6,
 *   returnSpeed: 0.15,
 *   invalidateMasks: true
 * });
 * await shake.play();
 *
 * @example
 * // Use a custom ticker and a deterministic time offset
 * const shake = new foundry.canvas.animation.CanvasShakeEffect({
 *   target: someContainer,
 *   seed: 12345,
 *   ticker: myTicker
 * });
 * shake.play();
 */
class CanvasShakeEffect {
  /**
   * Create a new CanvasShakeEffect
   * @param {object} options
   * @param {PIXI.DisplayObject} [options.target=null] The target PIXI display object to shake. Defaults to canvas.stage
   * @param {number} [options.duration=5000]           Total shake duration in MS.
   * @param {number} [options.maxDisplacement=35]      Maximum displacement in pixels.
   * @param {number} [options.smoothness=0.5]          Smoothness in the range [0, 1]. Higher is smoother.
   * @param {number} [options.returnSpeed=0.1]         "Return to origin" lerp factor per tick in the range [0, 1].
   * @param {boolean} [options.invalidateMasks=false]  Should hidden canvas group masks be invalidated each frame?
   * @param {number|null} [options.seed=null]          Optional seed used to derive a deterministic time offset.
   * @param {PIXI.Ticker|null} [options.ticker=null]   Optional PIXI ticker. Defaults to {@link foundry.canvas.animation.CanvasAnimation.ticker}.
   */
  constructor({target=null, duration=5000, maxDisplacement=35, smoothness=0.5, returnSpeed=0.1, invalidateMasks=false, seed=null, ticker=null}) {
    this.#target = target ?? canvas.stage;

    // Sanitize inputs
    this.duration = Math.max(0, duration);
    this.maxDisplacement = Math.max(0, maxDisplacement);
    this.smoothness = Math.clamp(smoothness, 0, 1);
    this.returnSpeed = Math.clamp(returnSpeed, 0, 1);

    // Ticker
    this.#ticker = ticker ?? CanvasAnimation.ticker;

    // Noise scale mapping
    const minScale = 0.03;
    const maxScale = 0.001;
    const noiseScale = Math.mix(minScale, maxScale, this.smoothness);

    // Random offset
    this.#seed = seed;
    if ( this.#seed !== null ) {
      const twister = new MersenneTwister(this.#seed);
      this.randomOffset = Math.floor(twister.random() * 999999);
    }
    else this.randomOffset = 0;

    // Reference point initialized
    this.#referencePoint = {x: this.#target.x, y: this.#target.y};

    // Hidden canvas group masks invalidation
    this.#invalidateMasks = invalidateMasks ?? false;

    // Smooth noise initialisation
    const amp = this.maxDisplacement * 2;
    this.#noiseX = new SmoothNoise({amplitude: amp, scale: noiseScale});
    this.#noiseY = new SmoothNoise({amplitude: amp, scale: noiseScale});
  }

  /* -------------------------------------------- */
  /* Static Properties                            */
  /* -------------------------------------------- */

  /**
   * Duration in ms of the smooth takeover transition when a new shake replaces an active shake.
   * @type {number}
   */
  static TAKEOVER_DURATION_MS = 150;

  /* -------------------------------------------- */

  /**
   * The unique shake state symbol. Stored as a non-enumerable property on the target.
   * @type {symbol}
   */
  static #SHAKE_STATE = Symbol.for("foundry.canvas.animation.CanvasShakeEffect");

  /* -------------------------------------------- */
  /* Public Properties                            */
  /* -------------------------------------------- */

  /**
   * Total shake duration in MS. After this duration elapses, the effect transitions into a return-to-origin phase.
   * @type {number}
   */
  duration;

  /**
   * Maximum displacement in pixels during the shake. This value is used as the target maximum offset along each axis.
   * @type {number}
   */
  maxDisplacement;

  /**
   * Smoothness parameter in the range [0, 1]. Higher values produce smoother, lower-frequency motion.
   * @type {number}
   */
  smoothness;

  /**
   * Return-to-origin interpolation factor per tick, in the range [0, 1].
   * Higher values restore the target to its reference point more quickly.
   * @type {number}
   */
  returnSpeed;

  /**
   * The deterministic time offset derived from {@link CanvasShakeEffect#seed}.
   * Applied to the elapsed time before generating noise.
   * @type {number}
   */
  randomOffset;

  /**
   * Whether the shake effect is currently active.
   * @type {boolean}
   * @readonly
   */
  get playing() {
    return this.#playing;
  }
  #playing = false;

  /* -------------------------------------------- */
  /* Private Properties                           */
  /* -------------------------------------------- */

  /**
   * A promise resolve function for the active {@link CanvasShakeEffect#play} call.
   * This is cleared after the promise is resolved to ensure it fires only once.
   * @type {Function|null}
   */
  #resolve = null;

  /**
   * The promise returned by the most recent {@link CanvasShakeEffect#play} call.
   * When the effect is already playing, subsequent calls can return this same promise.
   * @type {Promise<void>|null}
   */
  #promise = null;

  /**
   * The ticker used to drive the effect.
   * Defaults to {@link foundry.canvas.animation.CanvasAnimation.ticker} if not provided.
   * @type {PIXI.Ticker}
   */
  #ticker;

  /**
   * The target PIXI display object to apply the shake effect to.
   * @type {PIXI.DisplayObject}
   */
  #target;

  /**
   * Optional random seed used to derive a deterministic time offset.
   * @type {number|null}
   */
  #seed;

  /**
   * The reference position captured for the target. The shake offsets are applied relative to this point.
   * @type {{x: number, y: number}}
   */
  #referencePoint;

  /**
   * The SmoothNoise generator used for horizontal displacement.
   * @type {foundry.canvas.animation.SmoothNoise}
   */
  #noiseX;

  /**
   * The SmoothNoise generator used for vertical displacement.
   * @type {foundry.canvas.animation.SmoothNoise}
   */
  #noiseY;

  /**
   * Whether hidden canvas group masks should be invalidated each frame.
   * @type {boolean}
   */
  #invalidateMasks = false;

  /* -------------------------------------------- */
  /* Public API                                   */
  /* -------------------------------------------- */

  /**
   * Start the shake effect.
   * Registers a ticker callback and returns a promise that resolves once the effect ends.
   * If the effect is already playing, returns the existing promise (or a resolved one as a fallback).
   * @returns {Promise<void>} A promise that resolves when the effect completes or is stopped.
   */
  play() {
    if ( this.#playing ) return this.#promise ?? Promise.resolve();

    const state = CanvasShakeEffect.#getShakeState(this.#target);
    const active = state.active;
    const replaced = active && (active !== this) && active.playing;

    // If another shake is active on this target, replace it smoothly
    if ( replaced ) {
      state.blendFromX = state.offX;
      state.blendFromY = state.offY;
      state.blendStartMS = this.#ticker.lastTime;
      state.blendDurationMS = CanvasShakeEffect.TAKEOVER_DURATION_MS;
      active.stop({snap: false, release: false});
    }

    // If no shake was active, capture the original base position
    else {
      state.baseX = this.#target.x;
      state.baseY = this.#target.y;
      state.offX = 0;
      state.offY = 0;
      state.blendDurationMS = 0;
    }

    this.#promise = new Promise((resolve) => {
      if ( this.#target.destroyed ) return resolve();

      this.#playing = true;
      this.#resolve = resolve;

      // Become the active shake for this target
      state.active = this;

      // Capture reference (the base position before any shake)
      this.#referencePoint.x = state.baseX;
      this.#referencePoint.y = state.baseY;

      this.startTime = this.#ticker.lastTime;
      this.#ticker.add(this.#onTick, this);
    });

    return this.#promise;
  }

  /* -------------------------------------------- */

  /**
   * Stop the shake effect immediately. Removes the ticker callback, optionally snaps the target back to its base
   * position, and resolves the active promise.
   * @param {object} [options]
   * @param {boolean} [options.snap=true]     Snap the target back to its base position.
   * @param {boolean} [options.release=true]  Release the target shake state if no other shake is active.
   */
  stop({snap=true, release=true}={}) {
    if ( !this.#playing ) return;

    const state = this.#target[CanvasShakeEffect.#SHAKE_STATE];

    if ( snap && !this.#target.destroyed ) {
      if ( state ) {
        const EPS = 1e-4;
        const expectedX = state.baseX + state.offX;
        const expectedY = state.baseY + state.offY;

        // If the target no longer matches our expected position, assume an external system has moved it
        // Rebase to the current position and clear any pending offsets/blend
        if ( (Math.abs(this.#target.x - expectedX) > EPS) || (Math.abs(this.#target.y - expectedY) > EPS) ) {
          state.baseX = this.#target.x;
          state.baseY = this.#target.y;
        }

        // Otherwise, remove the last applied offset to recover the base
        else {
          this.#target.x -= state.offX;
          this.#target.y -= state.offY;
          state.baseX = this.#target.x;
          state.baseY = this.#target.y;
        }

        state.offX = 0;
        state.offY = 0;
        state.blendDurationMS = 0;
      }
      else {
        this.#target.x = this.#referencePoint.x;
        this.#target.y = this.#referencePoint.y;
      }
    }

    this.#ticker.remove(this.#onTick, this);
    this.#playing = false;

    if ( state?.active === this ) state.active = null;
    if ( release && state ) CanvasShakeEffect.#clearShakeState(this.#target, state);
    if ( this.#invalidateMasks ) canvas.hidden.invalidateMasks();

    if ( this.#resolve ) {
      this.#resolve();
      this.#resolve = null;
    }
  }

  /* -------------------------------------------- */
  /* Private Methods                              */
  /* -------------------------------------------- */

  /**
   * Ticker update callback. Applies shake offsets during the active phase, then smoothly returns the target to its
   * base position once the duration elapses.
   */
  #onTick() {
    if ( this.#target.destroyed ) return this.stop({snap: false});

    // If intensity is zero, short-circuit safely (and disable for photosensitive mode)
    if ( canvas.photosensitiveMode || this.maxDisplacement <= 0 || this.duration <= 0 ) return this.stop();

    const state = this.#target[CanvasShakeEffect.#SHAKE_STATE];
    if ( !state || (state.active !== this) ) return this.stop({snap: false});

    // Follow the target base position if something else moves the target during the shake
    // If the target no longer matches our expected position, treat its current position as the new base
    const EPS = 1e-4;
    const expectedX = state.baseX + state.offX;
    const expectedY = state.baseY + state.offY;

    if ( (Math.abs(this.#target.x - expectedX) > EPS) || (Math.abs(this.#target.y - expectedY) > EPS) ) {
      state.baseX = this.#target.x;
      state.baseY = this.#target.y;
      state.offX = 0;
      state.offY = 0;
      state.blendDurationMS = 0;
    }
    else {
      state.baseX = this.#target.x - state.offX;
      state.baseY = this.#target.y - state.offY;
    }

    this.#referencePoint.x = state.baseX;
    this.#referencePoint.y = state.baseY;

    const now = this.#ticker.lastTime;
    const elapsed = now - this.startTime;
    const durationMS = this.duration;

    // After duration: return to base position
    if ( elapsed >= durationMS ) {

      // If returnSpeed is 0, snap instantly
      if ( this.returnSpeed <= 0 ) return this.stop();

      const offsetX = Math.mix(state.offX, 0, this.returnSpeed);
      const offsetY = Math.mix(state.offY, 0, this.returnSpeed);
      this.#target.x = this.#referencePoint.x + offsetX;
      this.#target.y = this.#referencePoint.y + offsetY;
      state.offX = offsetX;
      state.offY = offsetY;

      const done = Math.abs(state.offX) < 0.1 && Math.abs(state.offY) < 0.1;
      if ( done ) this.stop();
      return;
    }

    // Shake phase
    const noiseElapsed = elapsed + this.randomOffset;

    // Current amplitude in pixels
    const intensity = Math.mix(0, this.maxDisplacement, 1 - elapsed / durationMS);

    // Generate smooth noise for vertical and horizontal displacements
    const rawNoiseX = this.#noiseX.generate(noiseElapsed);
    const rawNoiseY = this.#noiseY.generate(noiseElapsed);
    const baseX = rawNoiseX - this.maxDisplacement;
    const baseY = rawNoiseY - this.maxDisplacement;
    let offsetX = baseX * (intensity / this.maxDisplacement);
    let offsetY = baseY * (intensity / this.maxDisplacement);

    // Smooth blend
    if ( state.blendDurationMS > 0 ) {
      const bt = (now - state.blendStartMS) / state.blendDurationMS;
      if ( bt >= 1 ) state.blendDurationMS = 0;
      else {
        const t = bt * bt * (3 - 2 * bt);
        offsetX = Math.mix(state.blendFromX, offsetX, t);
        offsetY = Math.mix(state.blendFromY, offsetY, t);
      }
    }

    this.#target.x = this.#referencePoint.x + offsetX;
    this.#target.y = this.#referencePoint.y + offsetY;
    state.offX = offsetX;
    state.offY = offsetY;

    if ( this.#invalidateMasks ) canvas.hidden.invalidateMasks();
  }

  /* -------------------------------------------- */
  /* Static Private Methods                       */
  /* -------------------------------------------- */

  /**
   * Get or create the shared shake state for a target.
   * @param {PIXI.DisplayObject} target
   * @returns {object}
   */
  static #getShakeState(target) {
    let state = target[this.#SHAKE_STATE];
    if ( state ) return state;

    state = {
      active: null,
      baseX: target.x,
      baseY: target.y,
      offX: 0,
      offY: 0,
      blendFromX: 0,
      blendFromY: 0,
      blendStartMS: 0,
      blendDurationMS: 0
    };

    Object.defineProperty(target, this.#SHAKE_STATE, {
      value: state,
      configurable: true,
      enumerable: false
    });

    return state;
  }

  /* -------------------------------------------- */

  /**
   * Clear the shared shake state from a target if no shake remains active.
   * @param {PIXI.DisplayObject} target
   * @param {object} state
   */
  static #clearShakeState(target, state) {
    if ( state?.active ) return;
    delete target[this.#SHAKE_STATE];
  }
}

/**
 * A lightweight primary-canvas container designed for particle effects.
 * This container intentionally avoids any internal sorting or depth participation. Children render in insertion order.
 */
class PrimaryCanvasParticleContainer extends PrimaryCanvasContainer {
  constructor(...args) {
    super(...args);

    // Particles are noninteractive
    this.eventMode = "none";
    this.interactiveChildren = false;

    // Particles are not sorted
    this.sortableChildren = false;
  }

  /* -------------------------------------------- */

  /** @override */
  _onAddedPrimary() {
    this._inPrimary = true;

    // Do not propagate this event because particles are not PCOs
  }

  /* -------------------------------------------- */

  /** @override */
  _onRemovedPrimary() {
    this._inPrimary = false;

    // Do not propagate this event because particles are not PCOs
  }

  /* -------------------------------------------- */

  /** @override */
  _onElevationChange() {
    // Do not propagate elevation because particles are not PCOs
  }

  /* -------------------------------------------- */

  /** @override */
  _shouldRenderDepth() {
    return false; // Particles are not depth renderable
  }
}

/**
 * @import {
 *   ParticleGeneratorAnchor,
 *   ParticleGeneratorArea,
 *   ParticleGeneratorAreaSampleMode,
 *   ParticleGeneratorBehavior,
 *   ParticleGeneratorBehaviorId,
 *   ParticleGeneratorClipOptions,
 *   ParticleGeneratorColorFunction,
 *   ParticleGeneratorColorValue,
 *   ParticleGeneratorConfiguration,
 *   ParticleGeneratorConstraintMode,
 *   ParticleGeneratorCurvePoint,
 *   ParticleGeneratorDeathCallback,
 *   ParticleGeneratorDebugOptions,
 *   ParticleGeneratorDebugStats,
 *   ParticleGeneratorDebugTintMode,
 *   ParticleGeneratorDebugTintOptions,
 *   ParticleGeneratorFadeOptions,
 *   ParticleGeneratorFollowOptions,
 *   ParticleGeneratorLocalArea,
 *   ParticleGeneratorMode,
 *   ParticleGeneratorOrbitOptions,
 *   ParticleGeneratorParticleCallback,
 *   ParticleGeneratorPoint,
 *   ParticleGeneratorPositionTest,
 *   ParticleGeneratorRange,
 *   ParticleGeneratorRectangle,
 *   ParticleGeneratorTickCallback,
 *   ParticleGeneratorValue,
 *   ParticleGeneratorValueFunction,
 *   ParticleGeneratorVelocityFunction,
 *   ParticleGeneratorVelocityOptions,
 *   ParticleMesh
 * } from "./_types.mjs";
 */

/* -------------------------------------------- */

/**
 * A lightweight, native particle generator designed for VFX.
 *
 * ParticleGenerator manages:
 * - An internal container on the chosen canvas layer (usually `canvas.primary`)
 * - Particle pooling (reusing sprites instead of constantly allocating new ones)
 * - Lifetime, fade-in/out, basic motion, and optional constraints
 * - Two usage styles:
 *   - **ambient**: keep a steady density in the visible area (viewport-driven budget)
 *   - **effect**: spawn particles in a specific area (manual spawns or a fixed target count)
 *
 * The API is intentionally compact:
 * - Put most settings at the top level (`textures`, `blend`, `alpha`, `scale`, `count`, `lifetime`, etc.)
 * - Use `area` to define where particles spawn
 * - Use `spawnParticle()` or `spawnParticles()` to spawn particles (optionally overriding texture/area/position)
 * - Use `start({spawn: n})` when you want to immediately seed a certain number of particles on start
 * - Use `shaderClass` when you want a custom sampler shader for particles
 * - Use `onSpawn` to customize each particle after it has been positioned and configured
 * - Use `onUpdate` to apply per-particle per-frame logic after position, rotation, tint, and alpha are computed
 * - Use `onDeath` to react when particles are recycled (optional)
 *
 * ## Core concepts
 *
 * ### Mode
 * - `mode: "ambient"` maintains a stable density in the visible region.
 *   `count` is scaled by the visible area ratio, and spawning uses the padded viewport (`viewPadding`).
 * - `mode: "effect"` spawns in a defined `area`. `count` is treated as an absolute target.
 *    You can use `manual: true` and spawn at your own rate via `spawnParticle()` / `spawnParticles()`.
 *
 * ### Fade
 * `fade.in` / `fade.out` accept:
 * - milliseconds when value >= 1
 * - a fraction of the particle lifetime when 0 < value < 1
 *
 * ### Velocity
 * `velocity` can be:
 * - fixed: `{x, y}` or `new PIXI.Point(x, y)`
 * - ranged: `{x: [min, max], y: [min, max]}`
 * - polar: `{speed: value, angle: [min, max]}` (angle in degrees)
 * - function: `{fn: (particle, dt, out) => {out.x = vx; out.y = vy;}}`
 *   evaluated at spawn and during each update as the full velocity vector for that frame
 *
 * ### Attribute Values
 * `alpha`, `scale`, polar `velocity.speed`, and `rotation.speed` accept fixed values, random ranges,
 * `{min, max, curve}` objects, or `{fn}` objects.
 * `tint` accepts fixed color sources or `{curve}` / `{fn}` color objects. Curve points use color sources; `fn` returns
 * a 0xRRGGBB number. Curve time is normalized over each particle lifetime.
 * - `alpha` is multiplied by the fade envelope
 * - `scale` is applied directly to the particle
 * - `velocity.speed` updates magnitude while preserving the spawn direction
 * - `rotation.speed` updates angular velocity in degrees per second
 * - `tint` interpolates RGB channels between curve points
 *
 * ### Shader
 * Pass `shaderClass` to render particles with a custom sampler shader.
 * - Batchable shaders are recommended for anything more than modest particle counts.
 * - ParticleGenerator uses plain `SpriteMesh` instances, so the shader must be compatible with `SpriteMesh`.
 * - If your batch shader needs custom per-particle data, attach it in `onSpawn` or `onUpdate` and read it from
 *   `element.object` in the shader `_packInterleavedGeometry` override.
 *
 * ### Area
 * `area` supports:
 * - Point: `{x, y}`
 * - Rect: `{x, y, width, height}` or `new PIXI.Rectangle(x, y, w, h)`
 * - Circle: `{x, y, radius}`
 * - Ring: `{x, y, innerRadius, outerRadius}` or `{x, y, radius: [inner, outer]}`
 * - Line: `{from: {x, y}, to: {x, y}}`
 * - Points: `{points: [{x, y}, ...]}` or `{shape: "points", points: [{x, y}, ...]}`
 * - Polyline: `{path: [{x, y}, ...]}`, `{shape: "path", path: [...]}`, or
 *   `{shape: "polyline", points: [{x, y}, ...]}`
 * - Ellipse: `{x, y, radiusX, radiusY}`, `{shape: "ellipse", x, y, radiusX, radiusY}`, with optional
 *   `holeScale`, `minAngle`, `maxAngle`, `shapeRotation`, and `affectRotation`.
 * - Shape instance: {@link foundry.data.BaseShapeData}
 * - Shape source data: `{type: "circle", x, y, radius}` using the {@link foundry.data.BaseShapeData} schema.
 * Plain object areas without a `type` are inferred from their properties.
 *
 * Set `sampleMode` to `"boundary"` to spawn along the edge of area types that support boundary sampling.
 *
 * If you provide an `anchor`, object-based areas can be treated as offsets relative to that anchor.
 * To force an absolute rectangle while anchored, pass a `PIXI.Rectangle`.
 * Plain objects with a valid {@link foundry.data.BaseShapeData} `type` are shape source data, not object areas,
 * and are always interpreted in absolute scene coordinates.
 *
 * ### Debug
 * Use `debug` while tuning a generator or diagnosing why particles are not spawning as expected. It is optional and
 * should usually be disabled in final effects.
 * - `debug: true` enables spawn and recycle counters.
 * - `debug.stats: true` enables `generator.debugStats`.
 * - `debug.profile: true` also records the most recent update, spawn, and tick timings.
 * - `debug.tint: true` gives each spawned particle a random tint.
 * - `debug.tint: {mode: "palette", palette: [0xFFAA00, 0x66CCFF]}` tints particles from a palette.
 * - `debug.tint: {mode: "byTexture"}` gives each texture a stable debug color.
 * - `debug.useWhiteTexture: true` falls back to `PIXI.Texture.WHITE` when no textures are configured.
 *
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 100,
 *   spawnRate: 300,
 *   area: {x: 1200, y: 900, radius: 180},
 *   debug: {
 *     stats: true,
 *     profile: true,
 *     tint: {mode: "byTexture"},
 *     useWhiteTexture: true
 *   }
 * });
 * gen.start({spawn: 100});
 *
 * console.table(gen.debugStats);
 * ```
 *
 * ## Performance tips
 * - Preload textures before creating the generator.
 * - Prefer calling `spawnParticle()` or `spawnParticles()` with no options when you can. It avoids per-spawn object
 *   allocations.
 * - Keep `positionTest` cheap. It runs in a hot path.
 * - Prefer batchable shaders for sustained effects. Direct-render shaders are best kept to smaller particle counts.
 * - Avoid blur unless you really need it (pre-blurred textures are better).
 *
 * ## Practical limits
 * ParticleGenerator is meant for local, short-lived effects (bursts, embers, motes, small auras),
 * not for filling the entire scene with massive particle counts. If you push it into many thousands of active
 * particles, performance could degrade quickly. In practice, you'll get better results by using moderate counts,
 * short lifetimes, and small spawn areas, and by stopping the effect when it's no longer needed.
 *
 * Texture size also matters. Large particle textures (or heavy blur) increase pixel work,
 * especially with additive/screen blends and overdraw, which can become fill-rate bound on some GPUs.
 * Prefer small textures (and sprite sheets!), keep particle sizes reasonable on screen, and avoid expensive full-screen
 * coverage when you only need a local effect.
 *
 * ## Examples
 *
 * @example
 * ### 1) Ambient leaves (steady density in view)
 * Use this for soft, always-on atmospheric particles.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "ambient",
 *   textures: [
 *     "ui/particles/leaf1.png",
 *     "ui/particles/leaf2.png",
 *     "ui/particles/leaf3.png",
 *     "ui/particles/leaf4.png",
 *     "ui/particles/leaf5.png",
 *     "ui/particles/leaf6.png"
 *   ],
 *   blend: PIXI.BLEND_MODES.NORMAL,
 *   count: 300,
 *   viewPadding: 0.25,
 *   lifetime: [1200, 2200],
 *   fade: {in: 0.15, out: 0.25},
 *   velocity: {x: [-5, 5], y: [-10, -30]},
 *   alpha: [0.2, 0.4],
 *   scale: [0.35, 0.85]
 * });
 * gen.start();
 * ```
 *
 * ### 2) Ambient snow (padding + random age)
 * Great for snowfall that looks continuous when you pan the camera.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "ambient",
 *   textures: ["ui/particles/snow.png"],
 *   count: 400,
 *   viewPadding: 0.3,
 *   randomizeAgeInPadding: true,
 *   lifetime: [2500, 4500],
 *   fade: {in: 0.2, out: 0.2},
 *   velocity: {x: [-8, 8], y: [25, 55]},
 *   alpha: [0.12, 0.25],
 *   scale: [0.3, 0.9],
 *   blend: PIXI.BLEND_MODES.SCREEN
 * });
 * gen.start();
 * ```
 *
 * ### 3) Manual burst at a point (effect mode)
 * Use this for clicks, impacts, or quick one-shot effects.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   manual: true,
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [450, 900],
 *   fade: {in: 0.05, out: 0.4},
 *   velocity: {speed: [80, 200], angle: [0, 360]},
 *   alpha: [0.4, 0.8],
 *   scale: [0.3, 0.8]
 * });
 * gen.start();
 *
 * // Spawn a burst at {x,y}
 * const p = {x: 1000, y: 800};
 * gen.spawnParticles(120, {position: p});
 * ```
 *
 * ### 4) Anchored emitter (leaves around a token)
 * Use this for a continuous local effect attached to a moving object.
 * ```js
 * const token = canvas.tokens.controlled[0];
 * const area = {radius: 70}; // relative to anchor, no x/y needed
 *
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   anchor: token,
 *   anchorPoint: "center",
 *   area,
 *   count: 120,
 *   spawnRate: 90,
 *   elevation: token.document.elevation ?? 0,
 *   textures: [
 *     "ui/particles/leaf1.png",
 *     "ui/particles/leaf2.png",
 *     "ui/particles/leaf3.png",
 *     "ui/particles/leaf4.png",
 *     "ui/particles/leaf5.png",
 *     "ui/particles/leaf6.png"
 *   ],
 *   lifetime: [450, 900],
 *   fade: {in: 0.05, out: 0.4},
 *   velocity: {x: [-160, 160], y: [-160, 160]},
 *   rotation: {speed: 180},
 *   alpha: [0.5, 0.75],
 *   scale: [0.25, 0.75],
 *   blend: PIXI.BLEND_MODES.NORMAL
 * });
 * gen.start();
 * ```
 *
 * ### 5) Custom onTick spawning (leaves around a token)
 * Use this when custom logic decides whether the generator should spawn during a tick.
 * ```js
 * const token = canvas.tokens.controlled[0];
 * let spawnBudget = 0;
 *
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   manual: true,
 *   anchor: token,
 *   anchorPoint: "center",
 *   area: {radius: 80},
 *   count: 120,
 *   textures: [
 *     "ui/particles/leaf1.png",
 *     "ui/particles/leaf2.png",
 *     "ui/particles/leaf3.png"
 *   ],
 *   lifetime: [700, 1300],
 *   fade: {in: 0.1, out: 0.35},
 *   velocity: {x: [-90, 90], y: [-120, -20]},
 *   alpha: [0.35, 0.7],
 *   scale: [0.25, 0.65],
 *   onTick: (dt, generator) => {
 *     if ( !token?.visible ) return;
 *     const available = generator.adjustedMaxParticles - generator.particles.length;
 *     if ( available <= 0 ) return;
 *     spawnBudget += 45 * dt * 0.001;
 *     const n = Math.min(Math.floor(spawnBudget), available);
 *     if ( n <= 0 ) return;
 *     spawnBudget -= n;
 *     generator.spawnParticles(n);
 *   }
 * });
 * gen.start();
 * ```
 *
 * ### 6) Spawn-position direction toward a focal point
 * Use `velocity.fn` when each particle direction depends on where it spawned.
 * ```js
 * const dims = canvas.scene.dimensions;
 * const bounds = new PIXI.Rectangle(dims.sceneX, dims.sceneY, dims.sceneWidth, dims.sceneHeight);
 * const focal = {x: 1200, y: 900};
 * const focalLocal = {x: focal.x - bounds.x, y: focal.y - bounds.y};
 *
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   manual: true,
 *   bounds,
 *   textures: ["ui/particles/snow.png"],
 *   area: {x: focal.x, y: focal.y, innerRadius: 180, outerRadius: 260},
 *   lifetime: [700, 1100],
 *   fade: {in: 0.1, out: 0.3},
 *   alpha: [0.4, 0.9],
 *   scale: [0.3, 0.7],
 *   velocity: {
 *     fn: (p, dt, out) => {
 *       if ( p.time === 0 ) {
 *         let dx = focalLocal.x - p.x;
 *         let dy = focalLocal.y - p.y;
 *
 *         const distance = Math.hypot(dx, dy) || 1;
 *         dx /= distance;
 *         dy /= distance;
 *
 *         const speed = 260;
 *         p.focalVelocityX = dx * speed;
 *         p.focalVelocityY = dy * speed;
 *       }
 *
 *       out.x = p.focalVelocityX;
 *       out.y = p.focalVelocityY;
 *     }
 *   }
 * });
 * gen.start({spawn: 120});
 * ```
 *
 * ### 7) Direction evolving over lifetime (spiral)
 * Use `velocity.fn` when `particle.time` should bend the path during update.
 * ```js
 * const dims = canvas.scene.dimensions;
 * const bounds = new PIXI.Rectangle(dims.sceneX, dims.sceneY, dims.sceneWidth, dims.sceneHeight);
 * const origin = {x: 1200, y: 900};
 * const originLocal = {x: origin.x - bounds.x, y: origin.y - bounds.y};
 *
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   manual: true,
 *   bounds,
 *   textures: ["ui/particles/snow.png"],
 *   area: {x: origin.x, y: origin.y, radius: 40},
 *   lifetime: [900, 1300],
 *   fade: {in: 0.1, out: 0.35},
 *   alpha: [0.35, 0.8],
 *   scale: {min: 0.35, max: 0.7, curve: [{time: 0, value: 1}, {time: 1, value: 0.35}]},
 *   velocity: {
 *     fn: (p, dt, out) => {
 *       let dx = p.x - originLocal.x;
 *       let dy = p.y - originLocal.y;
 *
 *       const distance = Math.hypot(dx, dy) || 1;
 *       dx /= distance;
 *       dy /= distance;
 *
 *       const radial = 90 + (180 * p.time);
 *       const tangent = 240 * (1 - (0.35 * p.time));
 *       out.x = (dx * radial) + (-dy * tangent);
 *       out.y = (dy * radial) + (dx * tangent);
 *     }
 *   }
 * });
 * gen.start({spawn: 90});
 * ```
 *
 * ### 8) Keep particles inside a rectangle (wrap)
 * Use this for localized ambient effects in a region.
 * ```js
 * const zone = new PIXI.Rectangle(900, 700, 600, 400);
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 250,
 *   spawnRate: 180,
 *   area: zone,
 *   constraintMode: "wrap",
 *   constraintArea: zone,
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [1200, 2200],
 *   fade: {in: 0.2, out: 0.2},
 *   velocity: {x: [-20, 20], y: [-10, 10]},
 *   alpha: [0.1, 0.25],
 *   scale: [0.4, 0.9]
 * });
 * gen.start({spawn: 250});
 * ```
 *
 * ### 9) Bounce inside bounds (arcade-style)
 * Great for “energy balls” in a box or magic motes in a bounded area.
 * ```js
 * const zone = new PIXI.Rectangle(900, 700, 600, 400);
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 80,
 *   spawnRate: 60,
 *   area: zone,
 *   constraintMode: "bounce",
 *   constraintArea: zone,
 *   restitution: 0.9,
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [2000, 4000],
 *   fade: {in: 0.15, out: 0.25},
 *   velocity: {x: [-120, 120], y: [-120, 120]},
 *   alpha: [0.25, 0.6],
 *   scale: [0.5, 1.0],
 *   blend: PIXI.BLEND_MODES.SCREEN
 * });
 * gen.start({spawn: 80});
 * ```
 *
 * ### 10) Use a clip mask (hard visual boundary)
 * Use this when you need a strict rectangle cutout.
 * ```js
 * const clipRect = new PIXI.Rectangle(900, 700, 600, 400);
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 300,
 *   spawnRate: 120,
 *   area: clipRect,
 *   clip: clipRect,
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [2000, 4000],
 *   fade: {in: 0.2, out: 0.2},
 *   velocity: {x: [-10, 10], y: [-10, 10]},
 *   alpha: [0.08, 0.18],
 *   scale: [0.8, 1.6],
 *   blend: PIXI.BLEND_MODES.SCREEN
 * });
 * gen.start({spawn: 300});
 * ```
 *
 * ### 11) Multiple textures + additive blend (magic leaves)
 * Use this for “sparkly” looks with layered variation.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 150,
 *   spawnRate: 90,
 *   area: {x: 1200, y: 900, radius: 250},
 *   textures: [
 *     "ui/particles/leaf1.png",
 *     "ui/particles/leaf2.png",
 *     "ui/particles/leaf3.png",
 *     "ui/particles/leaf4.png",
 *     "ui/particles/leaf5.png",
 *     "ui/particles/leaf6.png"
 *   ],
 *   blend: PIXI.BLEND_MODES.SCREEN,
 *   lifetime: [1500, 2600],
 *   fade: {in: 0.2, out: 0.35},
 *   velocity: {speed: [5, 20], angle: [0, 360]},
 *   rotation: {speed: 45},
 *   alpha: [0.15, 0.45],
 *   scale: [0.3, 0.8]
 * });
 * gen.start({spawn: 150});
 * ```
 *
 * ### 12) Force a specific texture per spawn
 * Useful when you want a rare “special” particle occasionally.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   manual: true,
 *   textures: [
 *     "ui/particles/leaf1.png",
 *     "ui/particles/leaf2.png",
 *     "ui/particles/leaf3.png",
 *     "ui/particles/leaf4.png",
 *     "ui/particles/leaf5.png",
 *     "ui/particles/leaf6.png"
 *   ]
 * });
 * gen.start();
 * gen.spawnParticle({texture: "ui/particles/snow.png", position: {x: 1200, y: 900}});
 * ```
 *
 * ### 13) Spawn into a temporary override area
 * Use this to reuse one generator for multiple nearby spawns without rebuilding it.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   manual: true,
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [500, 900]
 * });
 *
 * // Start the generator
 * gen.start();
 *
 * const r1 = new PIXI.Rectangle(900, 700, 200, 200);
 * const r2 = new PIXI.Rectangle(1500, 700, 200, 200);
 * gen.spawnParticles(50, {area: r1});
 * gen.spawnParticles(50, {area: r2});
 *
 * // Stop the generator (soft stop by default)
 * gen.stop()
 * ```
 *
 * ### 14) Use a custom batch shader
 * Assume `MySparkleSamplerShader` is a `BaseSamplerShader` subclass whose batch plugin was already registered.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 120,
 *   spawnRate: 120,
 *   area: {x: 1200, y: 900, radius: 180},
 *   textures: ["ui/particles/snow.png"],
 *   shaderClass: MySparkleSamplerShader,
 *   lifetime: [900, 1600],
 *   fade: {in: 0.15, out: 0.3},
 *   velocity: {speed: [10, 45], angle: [0, 360]},
 *   alpha: [0.2, 0.5],
 *   scale: [0.25, 0.7],
 *   onSpawn: p => {
 *     p.sparklePhase = Math.random() * (Math.PI * 2);
 *     p.sparkleStrength = Math.mix(0.5, 1.0, Math.random());
 *   }
 * });
 * gen.start({spawn: 120});
 * ```
 *
 * ### 15) Use a simple BaseShapeData source area (interior)
 * BaseShapeData source objects can be passed directly as areas. A simple shape, such as a circle, uses the shape's
 * optimized point sampler after construction.
 * ```js
 * const area = {type: "circle", x: 1200, y: 900, radius: 160};
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 100,
 *   spawnRate: 120,
 *   area,
 *   sampleMode: "interior",
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [800, 1400],
 *   fade: {in: 0.15, out: 0.3},
 *   velocity: {speed: [5, 25], angle: [0, 360]},
 *   alpha: [0.15, 0.4],
 *   scale: [0.3, 0.8],
 *   blend: PIXI.BLEND_MODES.SCREEN
 * });
 * gen.start({spawn: 100});
 * ```
 *
 * ### 16) Use a complex BaseShapeData area (boundary)
 * On a gridded scene, GridShapeData can represent a union of multiple grid spaces. The resulting geometry is sampled
 * through its PolygonTree, which is useful for complex boundaries.
 * ```js
 * const origin = canvas.grid.getOffset({x: 1200, y: 900});
 * const area = new foundry.data.GridShapeData({
 *   offsets: [
 *     origin,
 *     {i: origin.i + 1, j: origin.j},
 *     {i: origin.i + 1, j: origin.j + 1},
 *     {i: origin.i + 2, j: origin.j + 1}
 *   ]
 * }, {parent: canvas.scene});
 *
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 120,
 *   spawnRate: 120,
 *   area,
 *   sampleMode: "boundary",
 *   textures: ["ui/particles/snow.png"],
 *   lifetime: [900, 1600],
 *   fade: {in: 0.1, out: 0.35},
 *   velocity: {speed: [15, 45], angle: [0, 360]},
 *   alpha: [0.2, 0.55],
 *   scale: [0.25, 0.7],
 *   blend: PIXI.BLEND_MODES.SCREEN
 * });
 * gen.start({spawn: 120});
 * ```
 *
 * ### 17) Continuous spray with over-life curves
 * Use `spawnRate` and over-life curves for effects that accelerate, shrink, or fade without custom callbacks.
 * ```js
 * const gen = new foundry.canvas.animation.ParticleGenerator({
 *   mode: "effect",
 *   count: 220,
 *   spawnRate: 180,
 *   area: {from: {x: 900, y: 700}, to: {x: 1300, y: 760}},
 *   textures: ["ui/particles/spray.png"],
 *   lifetime: [900, 1300],
 *   velocity: {
 *     angle: [70, 110],
 *     speed: {min: 525, max: 700, curve: [{time: 0, value: 1}, {time: 1, value: 0.2}]}
 *   },
 *   rotation: {
 *     speed: {min: -120, max: 120, curve: [{time: 0, value: 1}, {time: 1, value: 0.25}]}
 *   },
 *   alpha: {
 *     min: 0.45,
 *     max: 0.8,
 *     curve: [{time: 0, value: 0}, {time: 0.15, value: 1}, {time: 1, value: 0}]
 *   },
 *   scale: {min: 0.48, max: 0.8, curve: [{time: 0, value: 1.4}, {time: 1, value: 0.25}]},
 *   tint: {
 *     curve: [{time: 0, value: 0x66CCFF}, {time: 0.65, value: 0xFFFFFF}, {time: 1, value: 0x4F6DFF}]
 *   }
 * });
 * gen.start();
 * ```
 */
class ParticleGenerator {
  /**
   * @param {ParticleGeneratorConfiguration} [config]
   */
  constructor(config={}) {
    const cfg = foundry.utils.mergeObject(this.constructor.DEFAULT_OPTIONS, config, {
      inplace: false
    });
    this.#preserveParticleValueMerge(config, cfg, "alpha");
    this.#preserveParticleValueMerge(config, cfg, "scale");
    this._migrateConfig(config, cfg);

    // Stage 1: base configuration (subclass hook)
    this._configureOptions(cfg);

    // Stage 2+: constructor-only initialization steps
    this._initializeBehaviors(cfg);
    this._initializeCachedGenerators(cfg);
  }

  /* -------------------------------------------- */
  /*  Static Defaults                             */
  /* -------------------------------------------- */

  /**
   * Default generator config.
   * @type {ParticleGeneratorConfiguration}
   */
  static DEFAULT_OPTIONS = {
    mode: "ambient",
    bounds: null,
    count: 0,
    spawnRate: 5 * PIXI.Ticker.targetFPMS * 1000,
    perFrame: 5,
    initial: 0.25,
    manual: null,
    probability: 1,
    viewPadding: 0,
    newlyVisible: true,
    randomizeAgeInPadding: true,
    area: null,
    sampleMode: "interior",
    positionTest: null,
    constraintMode: null,
    constraintArea: null,
    restitution: 1,
    clip: {
      enabled: null,
      rect: null
    },
    mask: null,
    lifetime: 1000,
    fade: {
      in: 0,
      out: 0
    },
    velocity: null,
    rotation: {},
    drift: {
      enabled: false,
      intensity: 1
    },
    textures: [],
    particleAnchor: null,
    shaderClass: null,
    blend: PIXI.BLEND_MODES.NORMAL,
    blur: null,
    alpha: {min: 1, max: 1},
    scale: {min: 1, max: 1},
    tint: 0xFFFFFF,
    elevation: 0,
    sort: 0,
    container: null,
    ticker: null,
    anchor: null,
    anchorPoint: "center",
    anchorOffset: null,
    behavior: null,
    orbit: {},
    follow: {},
    onSpawn: null,
    onUpdate: null,
    onDeath: null,
    onTick: null,
    debug: null
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The runtime mode.
   * @type {ParticleGeneratorMode}
   */
  mode;

  /**
   * The parent container which receives the internal particle container.
   * @type {PIXI.Container}
   */
  container;

  /**
   * An optional anchor used to attach areas and behaviors.
   * @type {ParticleGeneratorAnchor}
   */
  anchor;

  /**
   * Which point to use when anchoring.
   * @type {ParticleGeneratorAnchorPoint}
   */
  anchorPoint;

  /**
   * A fixed offset (scene pixels) applied to the anchor.
   * @type {{x: number, y: number}|null}
   */
  anchorOffset;

  /**
   * The configured particle textures.
   * @type {PIXI.Texture[]}
   */
  textures;

  /**
   * An optional sprite anchor override for all particles.
   * @type {{x: number, y: number}|null}
   */
  particleAnchor;

  /**
   * The shader class used to render particles.
   * @type {typeof BaseSamplerShader}
   */
  shaderClass;

  /**
   * Viewport-related behavior (used primarily in ambient mode).
   * @type {{padding: number, newlyVisible: boolean, randomizeAgeInPadding: boolean}}
   */
  viewport;

  /**
   * The target particle count.
   * @type {number}
   */
  maxParticles;

  /**
   * The initial proportion (0..1) of the computed target particle count to spawn on start.
   * @type {number}
   */
  initialBatch;

  /**
   * If true, particles are never spawned automatically.
   * @type {boolean}
   */
  manualSpawning;

  /**
   * The chance (0..1) that a spawn attempt actually creates a particle.
   * @type {number}
   */
  spawnProbability;

  /**
   * An optional spawn validator.
   * @type {ParticleGeneratorPositionTest|null}
   */
  positionTest;

  /**
   * Which part of the spawn area to sample.
   * @type {ParticleGeneratorAreaSampleMode}
   */
  sampleMode;

  /**
   * Out-of-bounds constraint configuration.
   * @type {{
   *   mode: ParticleGeneratorConstraintMode,
   *   area: "budget"|"view"|"world"|PIXI.Rectangle|null,
   *   restitution: number
   * }}
   */
  constraints;

  /**
   * Clip (mask) options for the default clip behavior.
   * @type {{enabled: boolean|null, rect: PIXI.Rectangle|null}}
   */
  clip;

  /**
   * Whether the current mask was provided externally (not managed by this generator).
   * External masks are not destroyed during cleanup.
   * @type {boolean}
   */
  #externalMask = false;

  /**
   * The particle lifetime configuration in milliseconds.
   * @type {number|number[]|{min: number, max: number}}
   */
  particleLifetime;

  /**
   * The fade-in duration in milliseconds, or a fraction of lifetime if 0 < value < 1.
   * @type {number}
   */
  fadeInDuration;

  /**
   * The fade-out duration in milliseconds, or a fraction of lifetime if 0 < value < 1.
   * @type {number}
   */
  fadeOutDuration;

  /**
   * The blend mode used to render particles.
   * @type {PIXI.BLEND_MODES}
   */
  blendMode;

  /**
   * Resolved rotation configuration for particles.
   * @type {{alignVelocity: boolean, initial: number, spread: number, speed: {min: number, max: number}}}
   */
  rotation;

  /**
   * The velocity configuration used to generate per-particle movement.
   * @type {ParticleGeneratorVelocityOptions|null}
   */
  velocity;

  /**
   * Optional random drift configuration.
   * @type {{enabled: boolean, intensity: number}}
   */
  drift;

  /**
   * Optional blur filter options applied to the internal container.
   * @type {{enabled: boolean, intensity: number, quality: number|undefined}|null}
   */
  blurOptions;

  /**
   * The elevation for the particle container.
   * @type {number}
   */
  elevation;

  /**
   * The sorting key for the particle container.
   * @type {number}
   */
  sort;

  /**
   * Orbit behavior options.
   * @type {ParticleGeneratorOrbitOptions}
   */
  orbit;

  /**
   * Follow behavior options.
   * @type {ParticleGeneratorFollowOptions}
   */
  follow;

  /**
   * An optional callback called after the particle has been placed and configured.
   * @type {ParticleGeneratorParticleCallback|null}
   */
  onSpawn;

  /**
   * An optional callback called each frame for each live particle.
   * @type {ParticleGeneratorParticleCallback|null}
   */
  onUpdate;

  /**
   * An optional callback called when a particle is recycled.
   * @type {ParticleGeneratorDeathCallback|null}
   */
  onDeath;

  /**
   * An optional callback called one time per frame (not per particle!).
   * @type {ParticleGeneratorTickCallback|null}
   */
  onTick;

  /**
   * The computed target particle count based on visible area (ambient mode) or the configured budget (effect mode).
   * @type {number}
   */
  adjustedMaxParticles = 0;

  /**
   * The currently active particle instances.
   * @type {ParticleMesh[]}
   */
  particles = [];

  /**
   * A pool of recycled particles ready to be reused.
   * @type {ParticleMesh[]}
   */
  particlePool = [];

  /* -------------------------------------------- */
  /*  Private Fields                              */
  /* -------------------------------------------- */

  /**
   * The ticker used to drive the update loop.
   * @type {PIXI.Ticker}
   */
  #ticker;

  /* -------------------------------------------- */
  /*  Internal State                              */
  /* -------------------------------------------- */

  /**
   * Generator bounds in scene coordinates.
   * @type {PIXI.Rectangle}
   * @protected
   */
  _bounds;

  /**
   * The configured default spawn area (effect mode).
   * This area is defined in scene coordinates and may be interpreted relative to an anchor.
   * {@link foundry.data.BaseShapeData} instances and source data are always interpreted in absolute scene coordinates.
   * Shape source data is converted to a {@link foundry.data.BaseShapeData} instance.
   * Re-read each frame, so it can be replaced or its values mutated at runtime to animate the spawn region.
   * @type {ParticleGeneratorArea|null}
   */
  spawnArea = null;

  /**
   * Optional custom constraint rectangle in local coordinates.
   * @type {PIXI.Rectangle|null}
   * @protected
   */
  _constraintRect = null;

  /**
   * Whether the generator is soft-stopped.
   * @type {boolean}
   * @protected
   */
  _stopped = true;

  /**
   * Whether the update callback is attached to the ticker.
   * @type {boolean}
   * @protected
   */
  _tickerAttached = false;

  /**
   * Whether the generator has spawned its initial batch.
   * @type {boolean}
   * @protected
   */
  _initialized = false;

  /**
   * Whether an auto-spawn rate was provided by the caller.
   * @type {boolean}
   */
  #hasExplicitSpawnRate = false;

  /**
   * The maximum number of particles that may be spawned per second (auto-spawn mode).
   * @type {number}
   */
  #spawnRate = 0;

  /**
   * The legacy per-frame spawn budget derived from spawnRate.
   * @type {number}
   */
  #maxParticlesPerFrame = 0;

  /**
   * Compiled alpha value configuration.
   * @type {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean}}
   */
  #alphaValue;

  /**
   * Legacy alpha range fallback for value configurations without a base range.
   * @type {{min: number, max: number}}
   */
  #alphaRange = {min: 1, max: 1};

  /**
   * Compiled scale value configuration.
   * @type {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean}}
   */
  #scaleValue;

  /**
   * Legacy scale range fallback for value configurations without a base range.
   * @type {{min: number, max: number}}
   */
  #scaleRange = {min: 1, max: 1};

  /**
   * Compiled tint color configuration.
   * @type {{color: number, curve: {times: Float32Array, colors: Uint32Array}|null,
   *   fn: ParticleGeneratorColorFunction|null, dynamic: boolean}}
   */
  #tintValue;

  /**
   * Compiled polar speed value configuration.
   * @type {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean}|null}
   */
  #speedValue;

  /**
   * Custom particle velocity function.
   * @type {ParticleGeneratorVelocityFunction|null}
   */
  #velocityFunction = null;

  /**
   * Compiled rotation speed value configuration, in radians per second.
   * @type {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean}}
   */
  #rotationSpeedValue;

  /**
   * Fractional spawn budget preserved between ticks.
   * @type {number}
   */
  #spawnAccumulator = 0;

  /**
   * The internal container which holds all particles.
   * @type {PrimaryCanvasParticleContainer|null}
   * @protected
   */
  _particlesContainer = null;

  /**
   * The display object used to mask particle rendering.
   * @type {PIXI.DisplayObject|null}
   * @protected
   */
  _mask = null;

  /**
   * The blur filter applied to the internal container, if any.
   * @type {PIXI.Filter|null}
   * @protected
   */
  _blurFilter = null;

  /**
   * The local-space viewport rectangle without padding.
   * @type {PIXI.Rectangle}
   * @protected
   */
  _viewRectLocal = new PIXI.Rectangle();

  /**
   * The local-space viewport rectangle with padding.
   * @type {PIXI.Rectangle}
   * @protected
   */
  _budgetRectLocal = new PIXI.Rectangle();

  /**
   * The local-space generator bounds.
   * @type {PIXI.Rectangle}
   * @protected
   */
  _worldRectLocal = new PIXI.Rectangle();

  /**
   * The previous-frame budget rectangle.
   * @type {PIXI.Rectangle}
   * @protected
   */
  _oldBudgetRectLocal = new PIXI.Rectangle();

  /**
   * Whether the previous-frame budget rectangle is initialized.
   * @type {boolean}
   * @protected
   */
  _hasOldBudgetRectLocal = false;

  /**
   * A fixed pool of rectangles used to describe newly visible areas.
   * @type {PIXI.Rectangle[]}
   * @protected
   */
  _newlyVisibleAreaPool = [
    new PIXI.Rectangle(),
    new PIXI.Rectangle(),
    new PIXI.Rectangle(),
    new PIXI.Rectangle()
  ];

  /**
   * The list of newly visible areas for the current frame in local coordinates.
   * @type {PIXI.Rectangle[]}
   * @protected
   */
  _newlyVisibleAreas = [];

  /**
   * The current anchor position in scene coordinates.
   * @type {PIXI.Point}
   * @protected
   */
  _anchorScene = new PIXI.Point(0, 0);

  /**
   * The current anchor position in local coordinates.
   * @type {PIXI.Point}
   * @protected
   */
  _anchorLocal = new PIXI.Point(0, 0);

  /**
   * The active behavior implementation.
   * @type {ParticleGeneratorBehavior|null}
   * @protected
   */
  _behavior = null;

  /**
   * A cached context object passed to behavior hooks.
   * @type {object}
   * @protected
   */
  _behaviorContext;

  /**
   * Temp point used to avoid per-frame allocations.
   * @type {PIXI.Point}
   * @protected
   */
  _tlScreen = new PIXI.Point(0, 0);

  /**
   * Temp point used to avoid per-frame allocations.
   * @type {PIXI.Point}
   * @protected
   */
  _brScreen = new PIXI.Point(0, 0);

  /**
   * Temp point used to avoid per-frame allocations.
   * @type {PIXI.Point}
   * @protected
   */
  _tlLocal = new PIXI.Point(0, 0);

  /**
   * Temp point used to avoid per-frame allocations.
   * @type {PIXI.Point}
   * @protected
   */
  _brLocal = new PIXI.Point(0, 0);

  /**
   * Temp point used to sample shape-based spawn areas.
   * @type {PIXI.Point}
   * @protected
   */
  _spawnPoint = new PIXI.Point(0, 0);

  /**
   * A function which generates per-particle movement speed vectors.
   * @type {(out: PIXI.Point, particle: ParticleMesh) => void}
   * @protected
   */
  _generateMovementSpeed;

  /**
   * Normalized debug options.
   * @type {ParticleGeneratorDebugOptions|null}
   * @protected
   */
  _debug = null;

  /**
   * Debug statistics and profiling output.
   * Null when debug stats are disabled.
   * @type {ParticleGeneratorDebugStats|null}
   * @protected
   */
  _debugStats = null;

  /**
   * Whether profiling is enabled.
   * @type {boolean}
   * @protected
   */
  _debugProfile = false;

  /**
   * Cached debug tint options.
   * @type {{mode: ParticleGeneratorDebugTintMode, palette: number[]}|null}
   * @protected
   */
  _debugTint = null;

  /**
   * A mapping from textures to deterministic tint values.
   * @type {WeakMap<PIXI.Texture, number>|null}
   * @protected
   */
  _debugTintByTexture = null;

  /* -------------------------------------------- */
  /*  Public Getters                              */
  /* -------------------------------------------- */

  /**
   * The bounding rectangle of the generator in scene coordinates.
   * Used to convert between local particle coordinates and scene coordinates.
   * @type {PIXI.Rectangle}
   */
  get bounds() {
    return this._bounds;
  }

  /* -------------------------------------------- */

  /**
   * The PIXI container that holds all particle display objects.
   * @type {PIXI.Container|null}
   */
  get particlesContainer() {
    return this._particlesContainer;
  }

  /* -------------------------------------------- */

  /**
   * The current unpadded viewport rectangle in the generator's local space.
   * @type {PIXI.Rectangle}
   */
  get viewRectLocal() {
    return this._viewRectLocal;
  }

  /* -------------------------------------------- */

  /**
   * The current padded viewport rectangle used for budget/spawning in ambient mode.
   * @type {PIXI.Rectangle}
   */
  get budgetRectLocal() {
    return this._budgetRectLocal;
  }

  /* -------------------------------------------- */

  /**
   * The maximum number of particles that may be spawned per second (auto-spawn mode).
   * @type {number}
   */
  get spawnRate() {
    return this.#spawnRate;
  }

  set spawnRate(value) {
    const targetFPS = PIXI.Ticker.targetFPMS * 1000;
    this.#spawnRate = Math.max(0, Number(value) || 0);
    this.#maxParticlesPerFrame = targetFPS > 0 ? this.#spawnRate / targetFPS : 0;
  }

  /* -------------------------------------------- */

  /**
   * The mask applied to the particle container. Set to null to remove the mask.
   * The generator does not manage the lifecycle of externally assigned masks.
   * @type {PIXI.DisplayObject|null}
   */
  get mask() {
    return this._mask;
  }

  set mask(value) {
    this.#clearMask();
    if ( !value ) return;
    this._mask = value;
    this.#externalMask = true;
    if ( this._particlesContainer ) {
      if ( !value.parent ) this.container.addChild(value);
      this._particlesContainer.mask = value;
    }
  }

  /* -------------------------------------------- */

  /**
   * Debug statistics and profiling output.
   * Returns null if {@link ParticleGeneratorDebugOptions#stats} is not enabled.
   *
   * Note: This getter returns a stable object reference and updates the live values (active/pool/target)
   * on access.
   * @type {ParticleGeneratorDebugStats|null}
   */
  get debugStats() {
    const stats = this._debugStats;
    if ( !stats ) return null;
    stats.active = this.particles.length;
    stats.pool = this.particlePool.length;
    stats.target = this.adjustedMaxParticles;
    stats.newlyVisibleAreaCount = this._newlyVisibleAreas.length;
    return stats;
  }

  /* -------------------------------------------- */
  /*  Public Methods                              */
  /* -------------------------------------------- */

  /**
   * Start the generator, create the update loop and optionally spawn an initial batch.
   * @param {object} [options]
   * @param {number} [options.spawn=0] Spawn this many particles immediately after starting.
   *                                   If {@link ParticleGenerator#manualSpawning} is false, this is capped
   *                                   to the remaining budget (target - active).
   */
  start(options) {
    this._stopped = false;
    this.#createInternalContainer();

    if ( !this._tickerAttached ) {
      this.#ticker.add(this._onTick, this);
      this._tickerAttached = true;
    }

    this._calculateGeneratorProperties();

    if ( !this.manualSpawning && !this._initialized ) this._initializeParticles();
    this._initialized = true;

    const spawn = Math.floor(Number(options?.spawn ?? 0) || 0);
    if ( spawn > 0 ) this.spawnParticles(spawn);
  }

  /* -------------------------------------------- */

  /**
   * Stop the generator.
   * @param {object} [options]
   * @param {boolean} [options.hard=false] If true, detach the update loop and destroy internal resources.
   *                                       If false, stop spawning and let existing particles expire naturally.
   */
  stop({hard=false}={}) {
    this._stopped = true;
    if ( hard === false ) return;

    if ( this._tickerAttached ) {
      this.#ticker.remove(this._onTick, this);
      this._tickerAttached = false;
    }

    if ( this._blurFilter ) {
      canvas.blurFilters.delete(this._blurFilter);
      this._blurFilter = null;
    }

    for ( const p of this.particlePool ) {
      if ( !p.destroyed ) p.destroy({texture: false, baseTexture: false});
    }
    this.particlePool.length = 0;

    const stats = this._debugStats;
    if ( stats ) stats.recycledStop += this.particles.length;

    if ( this.onDeath ) {
      for ( const p of this.particles ) this.onDeath(p, {generator: this, reason: "stop"});
    }
    this.particles.length = 0;

    this.#clearMask();
    if ( this._particlesContainer ) {
      this._particlesContainer.removeFromParent();
      this._particlesContainer.destroy({children: true, texture: false, baseTexture: false});
    }
    this._particlesContainer = null;

    this.adjustedMaxParticles = 0;
    this.#spawnAccumulator = 0;

    this._hasOldBudgetRectLocal = false;
    this._newlyVisibleAreas.length = 0;
    this._initialized = false;
  }

  /* -------------------------------------------- */

  /**
   * Spawn a single particle.
   * In "ambient" mode, the default spawn area is the current padded viewport rectangle.
   * In "effect" mode, the default spawn area is the configured {@link ParticleGeneratorArea}.
   * @param {object} [options]
   * @param {PIXI.Texture|string} [options.texture] A texture (or texture source string) to force for this particle.
   * @param {ParticleGeneratorAreaSampleMode} [options.sampleMode] Which part of the spawn area to sample.
   *   Defaults to the configured generator sample mode.
   * @param {ParticleGeneratorArea|null} [options.area] An optional spawn area override.
   *       Interpreted the same as the configured area (scene coordinates, or relative-to-anchor when anchored).
   * @param {PIXI.Point|{x: number, y: number}|null} [options.position] An optional explicit spawn position.
   *   Coordinates are in scene space.
   * @returns {ParticleMesh|null}
   */
  spawnParticle({texture, sampleMode=this.sampleMode, area=null, position=null}={}) {
    if ( this._stopped || !this._tickerAttached || !this._particlesContainer ) this.start();

    const stats = this._debugStats;
    if ( stats ) stats.spawnAttempts++;

    const usedTexture = this.#lookupTexture(texture) ?? this._getRandomTexture();
    if ( !usedTexture ) return null;

    const localArea = this.#computeSpawnAreaLocal(area, position, sampleMode);
    if ( !localArea ) {
      if ( stats ) stats.spawnRejectedNoArea++;
      return null;
    }

    return this.#spawnParticleLocal(localArea, usedTexture);
  }

  /* -------------------------------------------- */

  /**
   * Spawn multiple particles.
   * @param {number} count The number of particles to spawn.
   * @param {object} [options]
   * @param {PIXI.Texture|string} [options.texture] A texture (or texture source string) to force for this burst.
   * @param {ParticleGeneratorAreaSampleMode} [options.sampleMode] Which part of the spawn area to sample.
   *   Defaults to the configured generator sample mode.
   * @param {ParticleGeneratorArea|null} [options.area] An optional spawn area override.
   *       Interpreted the same as the configured area (scene coordinates, or relative-to-anchor when anchored).
   * @param {ParticleGeneratorPoint|null} [options.position] An optional explicit spawn position (scene coordinates).
   * @returns {number} The number of successfully spawned particles.
   */
  spawnParticles(count, {texture, sampleMode=this.sampleMode, area=null, position=null}={}) {
    if ( this._stopped || !this._tickerAttached || !this._particlesContainer ) this.start();

    const stats = this._debugStats;

    let n = Math.floor(Number(count) || 0);
    if ( n <= 0 ) return 0;

    // If we're in auto-spawn mode, cap to the remaining budget
    if ( !this.manualSpawning ) {
      const available = this.adjustedMaxParticles - this.particles.length;
      if ( available <= 0 ) return 0;
      if ( n > available ) n = available;
    }

    const textures = this.textures;
    const texCount = textures.length;

    const forcedTexture = this.#lookupTexture(texture);

    // No textures
    if ( !forcedTexture && (texCount === 0) ) return 0;

    // Compute local spawn area once when overridden or in effect mode.
    let localArea = null;

    if ( (position != null) || (area != null) || (this.mode === "effect") ) {
      localArea = this.#computeSpawnAreaLocal(area, position, sampleMode);
      if ( !localArea ) {
        if ( stats ) {
          stats.spawnAttempts += n;
          stats.spawnRejectedNoArea += n;
        }
        return 0;
      }
    }

    let created = 0;

    // Ambient default
    if ( !localArea ) {
      for ( let i = 0; i < n; i++ ) {
        if ( stats ) stats.spawnAttempts++;
        if ( this.#spawnParticleDefault() ) created++;
      }
      return created;
    }

    if ( forcedTexture ) {
      for ( let i = 0; i < n; i++ ) {
        if ( stats ) stats.spawnAttempts++;
        if ( this.#spawnParticleLocal(localArea, forcedTexture) ) created++;
      }
      return created;
    }

    for ( let i = 0; i < n; i++ ) {
      if ( stats ) stats.spawnAttempts++;
      const usedTexture = textures[(Math.random() * texCount) | 0];
      if ( this.#spawnParticleLocal(localArea, usedTexture) ) created++;
    }

    return created;
  }

  /* -------------------------------------------- */
  /*  Configuration                               */
  /* -------------------------------------------- */

  /**
   * Migrate deprecated configuration options.
   * @param {ParticleGeneratorConfiguration} config  The user-provided configuration object.
   * @param {ParticleGeneratorConfiguration} cfg     The prepared configuration object.
   * @protected
   */
  _migrateConfig(config, cfg) {
    const hasLegacyPerFrame = !!(config && Object.hasOwn(config, "perFrame"));
    const hasLegacyRotationSpeed = !!(config && Object.hasOwn(config, "rotationSpeed"));
    const hasParticleAnchor = !!(config && Object.hasOwn(config, "particleAnchor"));
    const hasSpawnRate = !!(config && Object.hasOwn(config, "spawnRate"));
    const hasExplicitSpawnRate = hasSpawnRate && (config.spawnRate != null);
    const hasExplicitLegacyPerFrame = hasLegacyPerFrame && (config.perFrame != null);
    this.#hasExplicitSpawnRate = hasExplicitSpawnRate;

    if ( hasLegacyPerFrame ) {
      foundry.utils.logCompatibilityWarning("ParticleGenerator config.perFrame is deprecated."
        + " Use config.spawnRate instead.", {since: 14, until: 16, once: true});
      if ( !hasExplicitSpawnRate && hasExplicitLegacyPerFrame ) {
        const perFrame = Number(config.perFrame) || 5;
        cfg.spawnRate = Math.max(0, perFrame) * PIXI.Ticker.targetFPMS * 1000;
      }
    }

    if ( (hasLegacyPerFrame || hasLegacyRotationSpeed) && !hasParticleAnchor ) {
      foundry.utils.logCompatibilityWarning("ParticleGenerator legacy configurations use a centered particle anchor."
        + " Set config.particleAnchor explicitly, or migrate deprecated fields to use texture.defaultAnchor.",
      {since: 14, until: 16, once: true});
      cfg.particleAnchor = {x: 0.5, y: 0.5};
    }
  }

  /* -------------------------------------------- */

  /**
   * Preserve exact particle value options where recursive default merging would otherwise inject legacy range keys.
   * @param {ParticleGeneratorConfiguration} config  The user-provided configuration object.
   * @param {ParticleGeneratorConfiguration} cfg     The prepared configuration object.
   * @param {"alpha"|"scale"} key                    The value configuration key.
   */
  #preserveParticleValueMerge(config, cfg, key) {
    const value = config?.[key];
    if ( !value || (typeof value !== "object") || Array.isArray(value) ) return;
    if ( !("curve" in value) && !("fn" in value) ) return;

    const merged = cfg[key];
    if ( !merged || (typeof merged !== "object") || Array.isArray(merged) ) return;
    if ( !("min" in value) ) delete merged.min;
    if ( !("max" in value) ) delete merged.max;
  }

  /* -------------------------------------------- */

  /**
   * Apply the configuration to the ParticleGenerator instance.
   * @param {ParticleGeneratorConfiguration} cfg   The configuration object.
   * @protected
   */
  _configureOptions(cfg) {
    this.mode = (cfg.mode === "effect") ? "effect" : "ambient";
    this.#ticker = cfg.ticker ?? CanvasAnimation.ticker;
    this.container = cfg.container ?? canvas.primary;

    // Bounds
    this._bounds = this.#normalizeRect(cfg.bounds) ?? this._getDefaultBounds();
    this._worldRectLocal.x = 0;
    this._worldRectLocal.y = 0;
    this._worldRectLocal.width = this._bounds.width;
    this._worldRectLocal.height = this._bounds.height;

    // Anchor
    this.anchor = cfg.anchor ?? null;
    this.anchorPoint = cfg.anchorPoint ?? "center";
    this.anchorOffset = this.#normalizePoint(cfg.anchorOffset);

    // View (ambient budget/culling area)
    this.viewport = {
      padding: Math.max(0, Number(cfg.viewPadding ?? 0) || 0),
      newlyVisible: (cfg.newlyVisible ?? true) !== false,
      randomizeAgeInPadding: (cfg.randomizeAgeInPadding ?? true) !== false
    };

    // Budget
    const uncapped = (cfg.count === null) || (cfg.count === undefined);
    this.maxParticles = uncapped ? Infinity : (Number(cfg.count) || 0);
    this.spawnRate = cfg.spawnRate;
    this.initialBatch = uncapped ? 0 : (Number(cfg.initial ?? 0.25) || 0);

    // Protection to ensure that uncapped generators won't exceed a dangerous steady-state particle count.
    if ( uncapped ) {
      const avgLifetime = ParticleGenerator.#computeAvgLifetime(cfg.lifetime);
      const steadyState = Math.ceil(this.spawnRate * avgLifetime / 1000);
      if ( steadyState > 20000 ) {
        throw new Error(`ParticleGenerator: uncapped count with spawnRate=${Math.round(this.spawnRate)} and`
        + ` avgLifetime=${Math.round(avgLifetime)}ms would produce ~${steadyState} particles, exceeding the 20000`
        + " maximum that is allowed for uncapped configurations. Reduce spawnRate or lifetime.");
      }
    }

    const manual = cfg.manual;
    this.manualSpawning = (manual != null) ? !!manual : ((this.mode === "effect") && !this.#hasExplicitSpawnRate);

    const prob = cfg.probability ?? 1;
    this.spawnProbability = Math.clamp(Number(prob) || 0, 0, 1);

    // Spawn
    this.positionTest = (typeof cfg.positionTest === "function") ? cfg.positionTest : null;
    this.spawnArea = cfg.area ?? null;
    this.sampleMode = (cfg.sampleMode === "boundary") ? "boundary" : "interior";

    // Constraints
    const defaultConstraintMode = (this.mode === "ambient") ? "kill" : "none";

    this.constraints = {
      mode: (cfg.constraintMode ?? null) || defaultConstraintMode,
      area: cfg.constraintArea ?? null,
      restitution: Math.clamp(Number(cfg.restitution ?? 1) || 1, 0, 1)
    };

    // Clip
    this.clip = this.#normalizeClip(cfg.clip);

    // Mask
    const cfgMask = cfg.mask ?? null;
    if ( cfgMask instanceof PIXI.DisplayObject ) {
      this._mask = cfgMask;
      this.#externalMask = true;
    } else if ( cfgMask ) {
      this._mask = this.#createMaskFromShape(cfgMask);
      this.#externalMask = false;
    }

    // Lifetime
    this.particleLifetime = cfg.lifetime;
    this.fadeInDuration = cfg.fade?.in ?? 0;
    this.fadeOutDuration = cfg.fade?.out ?? 0;

    // Motion
    this.velocity = cfg.velocity ?? null;
    this.#velocityFunction = this.#compileVelocityFunction(this.velocity);
    this.#speedValue = this.#velocityFunction ? null : this.#compileVelocitySpeedValue(this.velocity);

    const drift = cfg.drift ?? {};
    this.drift = {
      enabled: !!drift.enabled,
      intensity: Number(drift.intensity ?? 1) || 1
    };

    // Rotation - initial and speed
    const rotation = cfg.rotation ?? {};
    if ( cfg.rotationSpeed !== undefined ) {
      foundry.utils.logCompatibilityWarning("ParticleGenerator config.rotationSpeed is deprecated."
        + " Use config.rotation.speed instead.", {since: 14, until: 16, once: true});
      rotation.speed ??= cfg.rotationSpeed;
    }
    this.#rotationSpeedValue = this.#compileRotationSpeedValue(rotation.speed);
    const speedRange = this.#rotationSpeedValue.range ?? {min: 0, max: 0};
    this.rotation = {
      alignVelocity: !!rotation.alignVelocity,
      initial: Number(rotation.initial) || 0,
      spread: rotation.spread ?? Math.PI,
      speed: speedRange
    };

    // Render
    this.shaderClass = this.#validateShaderClass(cfg.shaderClass);
    this.blendMode = cfg.blend ?? PIXI.BLEND_MODES.NORMAL;
    this.blurOptions = this.#normalizeBlur(cfg.blur);
    this.#alphaValue = this.#compileParticleValue(cfg.alpha, {min: 1, max: 1});
    this.#scaleValue = this.#compileParticleValue(cfg.scale, {min: 1, max: 1});
    this.#alphaRange = this.#alphaValue.range ?? {min: 1, max: 1};
    this.#scaleRange = this.#scaleValue.range ?? {min: 1, max: 1};
    this.#tintValue = this.#compileColorValue(cfg.tint);
    this.textures = this.#loadTextures(cfg.textures);
    this.particleAnchor = this.#normalizePoint(cfg.particleAnchor);

    // Layer
    this.elevation = Number(cfg.elevation ?? 0) || 0;
    this.sort = Number(cfg.sort ?? 0) || 0;

    // Callbacks
    this.onSpawn = (typeof cfg.onSpawn === "function") ? cfg.onSpawn : null;
    this.onUpdate = (typeof cfg.onUpdate === "function") ? cfg.onUpdate : null;
    this.onDeath = (typeof cfg.onDeath === "function") ? cfg.onDeath : null;
    this.onTick = (typeof cfg.onTick === "function") ? cfg.onTick : null;

    // Debug (optional)
    this._configureDebug(cfg.debug);

    // Mode-dependent defaults
    if ( this.clip.enabled == null ) this.clip.enabled = (this.mode === "ambient");

    if ( this.constraints.area == null ) {
      if ( this.constraints.mode === "none" ) this.constraints.area = null;
      else this.constraints.area = (this.mode === "ambient") ? "budget" : "world";
    }

    // Precompute custom constraint rect (if any)
    this._constraintRect = null;
    const constraintAreaRect = this.#normalizeRect(this.constraints.area);
    if ( constraintAreaRect ) {
      this.constraints.area = constraintAreaRect;
      this._constraintRect = this.#rectToLocal(constraintAreaRect);
    }
  }

  /* -------------------------------------------- */

  /**
   * Configure optional debug helpers.
   * This feature set is fully opt-in and is designed to have near-zero overhead when disabled.
   * @param {ParticleGeneratorDebugOptions|boolean|null|undefined} debug
   * @protected
   */
  _configureDebug(debug) {
    const d = (debug === true) ? {stats: true} : debug;

    if ( !d || (typeof d !== "object") ) {
      this._debug = null;
      this._debugStats = null;
      this._debugProfile = false;
      this._debugTint = null;
      this._debugTintByTexture = null;
      return;
    }

    const useWhiteTexture = !!d.useWhiteTexture;
    const profile = !!d.profile;
    const stats = !!d.stats || profile;

    /** @type {{mode: ParticleGeneratorDebugTintMode, palette: number[]}|null} */
    let tint = null;
    if ( d.tint ) {
      if ( d.tint === true ) tint = {mode: "random", palette: []};
      else if ( typeof d.tint === "object" ) {
        const mode = (["random", "palette", "byTexture"].includes(d.tint.mode)) ? d.tint.mode : "random";
        tint = {mode, palette: this.#normalizeDebugPalette(d.tint.palette)};
      }
    }

    // Apply texture fallback before building any tint maps.
    if ( useWhiteTexture && (this.textures.length === 0) ) this.textures = [PIXI.Texture.WHITE];

    // Persist normalized debug options.
    this._debug = {
      useWhiteTexture,
      tint: tint ? {mode: tint.mode, palette: tint.palette} : null,
      stats,
      profile
    };

    this._debugProfile = profile;
    this._debugTint = tint;

    this._debugStats = stats ? this.#createDebugStats() : null;
    this._debugTintByTexture = (tint?.mode === "byTexture") ? this.#buildDebugTintByTextureMap(tint.palette) : null;
  }

  /* -------------------------------------------- */

  /**
   * Initialize behaviors from the configuration object.
   * @param {ParticleGeneratorConfiguration} cfg
   * @protected
   */
  _initializeBehaviors(cfg) {
    this.orbit = this.#normalizeOrbit(cfg.orbit);
    this.follow = this.#normalizeFollow(cfg.follow);
    this._behavior = this.#normalizeBehavior(cfg.behavior);

    this._behaviorContext = {
      generator: this,
      anchor: {
        active: false,
        scene: this._anchorScene,
        local: this._anchorLocal
      },
      dt: 0,
      ds: 0
    };

    if ( this._behavior?.initialize ) this._behavior.initialize(this);
  }

  /* -------------------------------------------- */

  /**
   * Initialize cached generators from the configuration object.
   * @param {ParticleGeneratorConfiguration} _cfg
   * @protected
   */
  _initializeCachedGenerators(_cfg) {
    this._generateMovementSpeed = this.#buildVelocityGenerator(this.velocity);
  }

  /* -------------------------------------------- */
  /*  Internal Container                          */
  /* -------------------------------------------- */

  /**
   * Create the internal container if it doesn't exist yet.
   */
  #createInternalContainer() {
    if ( this._particlesContainer ) return;

    const inPrimary = (this.container === canvas.primary) || (this.container instanceof PrimaryCanvasContainer);
    if ( inPrimary ) this._particlesContainer = new PrimaryCanvasParticleContainer();
    else {
      this._particlesContainer = new PIXI.Container();
      this._particlesContainer.eventMode = "none";
      this._particlesContainer.interactiveChildren = false;
      this._particlesContainer.sortableChildren = false;
    }
    this._particlesContainer.elevation = this.elevation;
    this._particlesContainer.sort = this.sort;
    this._particlesContainer.position.set(this._bounds.x, this._bounds.y);

    if ( this.blurOptions?.enabled && (this.blurOptions.intensity > 0) ) {
      this._blurFilter = canvas.createBlurFilter(this.blurOptions.intensity, this.blurOptions.quality);
      this._particlesContainer.filters = [this._blurFilter];
    }

    this.container.addChild(this._particlesContainer);

    // Apply configured mask (takes precedence) or default clip behavior
    if ( this._mask ) {
      if ( !this._mask.parent ) this.container.addChild(this._mask);
      this._particlesContainer.mask = this._mask;
    } else this.#refreshClip();
  }

  /* -------------------------------------------- */

  /**
   * Create or update the clip mask using the default clip behavior.
   */
  #refreshClip() {
    const enabled = this.clip.enabled;
    if ( !enabled ) {
      this.#clearMask();
      return;
    }
    this.#clearMask();

    const clipShape = this.#getClipMaskShape();
    this._mask = this.#createMaskFromShape(clipShape);
    this.#externalMask = false;
    this.container.addChild(this._mask);
    this._particlesContainer.mask = this._mask;
  }

  /* -------------------------------------------- */

  /**
   * Get the effective clip shape managed by this generator.
   * If no explicit clip rectangle is configured, a shape-based spawn area becomes the clip shape.
   * @returns {PIXI.IShape|foundry.data.BaseShapeData}
   */
  #getClipMaskShape() {
    if ( this.clip.rect instanceof PIXI.Rectangle ) return this.clip.rect;
    const hasAnchor = this.#updateAnchor();
    const local = this.#computeAreaLocal(this.spawnArea, hasAnchor ? this._anchorScene : null, this.sampleMode);
    return local && ((local.type === "shape") || (local.type === "polygonTree")) ? local.data : this._bounds;
  }

  /* -------------------------------------------- */

  /**
   * Create a PIXI.Graphics mask from a PIXI shape or shape data.
   * @param {PIXI.IShape|foundry.data.BaseShapeData} shape   The shape to draw
   * @returns {PIXI.Graphics}     The constructed mask graphics object
   */
  #createMaskFromShape(shape) {
    const graphics = new PIXI.Graphics();
    graphics.renderable = false;
    graphics.eventMode = "none";
    graphics.beginFill(0xFFFFFF);
    if ( shape instanceof foundry.data.BaseShapeData ) shape.drawShape(graphics);
    else graphics.drawShape(shape);
    graphics.endFill();
    return graphics;
  }

  /* -------------------------------------------- */

  /**
   * Remove and optionally destroy the current mask.
   * External masks are detached but not destroyed.
   */
  #clearMask() {
    if ( !this._mask ) return;
    if ( this._particlesContainer ) this._particlesContainer.mask = null;
    if ( this._mask.parent === this.container ) this._mask.removeFromParent();
    if ( !this.#externalMask ) this._mask.destroy({children: true});
    this._mask = null;
    this.#externalMask = false;
  }

  /* -------------------------------------------- */
  /*  Viewport Computation                        */
  /* -------------------------------------------- */

  /**
   * Compute the current viewport rectangles and target particle count.
   * All rectangles are in the local coordinate space of the internal container.
   * @protected
   */
  _calculateGeneratorProperties() {
    if ( this._hasOldBudgetRectLocal ) this._oldBudgetRectLocal.copyFrom(this._budgetRectLocal);

    const renderer = canvas.app.renderer;
    this._brScreen.set(renderer.width, renderer.height);

    this._particlesContainer.toLocal(this._tlScreen, undefined, this._tlLocal);
    this._particlesContainer.toLocal(this._brScreen, undefined, this._brLocal);

    const x1 = Math.min(this._tlLocal.x, this._brLocal.x);
    const y1 = Math.min(this._tlLocal.y, this._brLocal.y);
    const x2 = Math.max(this._tlLocal.x, this._brLocal.x);
    const y2 = Math.max(this._tlLocal.y, this._brLocal.y);

    const lx1 = Math.clamp(x1, 0, this._bounds.width);
    const ly1 = Math.clamp(y1, 0, this._bounds.height);
    const lx2 = Math.clamp(x2, 0, this._bounds.width);
    const ly2 = Math.clamp(y2, 0, this._bounds.height);

    const viewW = Math.max(0, lx2 - lx1);
    const viewH = Math.max(0, ly2 - ly1);

    if ( (viewW <= 0) || (viewH <= 0) ) {
      this._viewRectLocal.x = 0;
      this._viewRectLocal.y = 0;
      this._viewRectLocal.width = 0;
      this._viewRectLocal.height = 0;

      this._budgetRectLocal.x = 0;
      this._budgetRectLocal.y = 0;
      this._budgetRectLocal.width = 0;
      this._budgetRectLocal.height = 0;
      this._newlyVisibleAreas.length = 0;
      this.adjustedMaxParticles = 0;
      this._hasOldBudgetRectLocal = true;
      return;
    }

    this._viewRectLocal.x = lx1;
    this._viewRectLocal.y = ly1;
    this._viewRectLocal.width = viewW;
    this._viewRectLocal.height = viewH;

    const padX = viewW * this.viewport.padding;
    const padY = viewH * this.viewport.padding;

    const px1 = Math.clamp(lx1 - padX, 0, this._bounds.width);
    const py1 = Math.clamp(ly1 - padY, 0, this._bounds.height);
    const px2 = Math.clamp(lx2 + padX, 0, this._bounds.width);
    const py2 = Math.clamp(ly2 + padY, 0, this._bounds.height);

    const paddedW = Math.max(0, px2 - px1);
    const paddedH = Math.max(0, py2 - py1);

    this._budgetRectLocal.x = px1;
    this._budgetRectLocal.y = py1;
    this._budgetRectLocal.width = paddedW;
    this._budgetRectLocal.height = paddedH;

    if ( this.mode === "ambient" ) {
      const totalArea = this._bounds.width * this._bounds.height;
      const visibleArea = paddedW * paddedH;
      this.adjustedMaxParticles = Math.floor(this.maxParticles * Math.clamp(visibleArea / totalArea, 0, 1));
    }
    else {
      this.adjustedMaxParticles = Math.max(0, Math.floor(this.maxParticles));
    }

    this._newlyVisibleAreas.length = 0;
    if ( (this.mode === "ambient") && this.viewport.newlyVisible && this._hasOldBudgetRectLocal ) {
      this._computeNewlyVisibleAreas(this._oldBudgetRectLocal, this._budgetRectLocal);
    }

    this._hasOldBudgetRectLocal = true;
  }

  /* -------------------------------------------- */

  /**
   * Compute the portions of newRect that were not visible in oldRect.
   * This method reuses a fixed pool of rectangles to avoid per-frame allocations.
   * @param {PIXI.Rectangle} oldRect
   * @param {PIXI.Rectangle} newRect
   * @protected
   */
  _computeNewlyVisibleAreas(oldRect, newRect) {
    if ( this.#containsRect(oldRect, newRect) ) return;

    const ix = Math.max(oldRect.x, newRect.x);
    const iy = Math.max(oldRect.y, newRect.y);
    const ix2 = Math.min(oldRect.x + oldRect.width, newRect.x + newRect.width);
    const iy2 = Math.min(oldRect.y + oldRect.height, newRect.y + newRect.height);

    const iw = ix2 - ix;
    const ih = iy2 - iy;

    if ( (iw <= 0) || (ih <= 0) ) {
      const r = this._newlyVisibleAreaPool[0];
      r.x = newRect.x;
      r.y = newRect.y;
      r.width = newRect.width;
      r.height = newRect.height;
      this._newlyVisibleAreas.push(r);
      return;
    }

    let n = 0;

    const topH = iy - newRect.y;
    if ( topH > 0 ) {
      const r = this._newlyVisibleAreaPool[n++];
      r.x = newRect.x;
      r.y = newRect.y;
      r.width = newRect.width;
      r.height = topH;
      this._newlyVisibleAreas.push(r);
    }

    const bottomY = iy2;
    const bottomH = (newRect.y + newRect.height) - bottomY;
    if ( bottomH > 0 ) {
      const r = this._newlyVisibleAreaPool[n++];
      r.x = newRect.x;
      r.y = bottomY;
      r.width = newRect.width;
      r.height = bottomH;
      this._newlyVisibleAreas.push(r);
    }

    const leftW = ix - newRect.x;
    if ( (leftW > 0) && (ih > 0) ) {
      const r = this._newlyVisibleAreaPool[n++];
      r.x = newRect.x;
      r.y = iy;
      r.width = leftW;
      r.height = ih;
      this._newlyVisibleAreas.push(r);
    }

    const rightX = ix2;
    const rightW = (newRect.x + newRect.width) - rightX;
    if ( (rightW > 0) && (ih > 0) ) {
      const r = this._newlyVisibleAreaPool[n++];
      r.x = rightX;
      r.y = iy;
      r.width = rightW;
      r.height = ih;
      this._newlyVisibleAreas.push(r);
    }
  }

  /* -------------------------------------------- */
  /*  Update Loop                                 */
  /* -------------------------------------------- */

  /**
   * Spawn the initial particle batch.
   * In ambient mode, particle ages are randomized so the scene appears pre-settled.
   */
  _initializeParticles() {
    const n = Math.ceil(this.adjustedMaxParticles * Math.clamp(this.initialBatch, 0, 1));
    if ( n <= 0 ) return;
    this.spawnParticles(n);
    if ( this.mode === "ambient" ) {
      const velocityFunction = this.#velocityFunction;
      const speedValue = this.#speedValue;
      const rotationSpeedValue = this.#rotationSpeedValue;
      const scaleValue = this.#scaleValue;
      const tintValue = this.#tintValue;
      const updateSpeed = !velocityFunction && speedValue?.dynamic;
      const updateRotationSpeed = rotationSpeedValue.dynamic;
      const updateScale = scaleValue.dynamic;
      const updateTint = tintValue.dynamic;
      for ( const p of this.particles ) {
        const lifetime = p.lifetime;
        p.elapsedTime = Math.random() * lifetime;
        const normalizedAge = p.time = lifetime > 0 ? p.elapsedTime / lifetime : 1;
        if ( velocityFunction ) {
          const rotationOffset = this.rotation.alignVelocity
            ? p.rotation - Math.atan2(p.movementSpeed.y, p.movementSpeed.x)
            : null;
          this.#applyVelocityFunction(p, 0, velocityFunction);
          if ( rotationOffset !== null ) {
            p.rotation = Math.atan2(p.movementSpeed.y, p.movementSpeed.x) + rotationOffset;
          }
        }
        else if ( updateSpeed ) {
          const speed = this.#computeParticleValue(speedValue, p, p._baseSpeed, normalizedAge, 0);
          p.movementSpeed.x = (p._movementDirectionX * speed) + p._movementDriftX;
          p.movementSpeed.y = (p._movementDirectionY * speed) + p._movementDriftY;
        }
        if ( updateRotationSpeed ) p.rotationSpeed = this.#computeRotationSpeed(p, normalizedAge, 0);
        if ( updateScale ) p.scale.set(this.#computeParticleValue(scaleValue, p, p._baseScale, normalizedAge, 0));
        if ( updateTint ) p.tint = this.#computeColorValue(tintValue, p, normalizedAge, 0);
        p.alpha = this.#computeAlpha(p, normalizedAge, 0);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Ticker callback.
   * @protected
   */
  _onTick() {
    const stats = this._debugStats;
    const profile = this._debugProfile && !!stats;
    const tTickStart = profile ? performance.now() : 0;

    if ( this.container.destroyed || this._particlesContainer.destroyed ) return this.stop({hard: true});
    this._calculateGeneratorProperties();

    const dt = this.#ticker.deltaMS;
    const onTick = this.onTick;
    if ( onTick && !this._stopped ) onTick(dt, this);

    if ( profile ) {
      const t0 = performance.now();
      this._updateExistingParticles(dt);
      stats.updateMS = performance.now() - t0;
    }
    else this._updateExistingParticles(dt);

    if ( !this.manualSpawning && !this._stopped ) {
      if ( profile ) {
        const t0 = performance.now();
        this._autoSpawnParticles();
        stats.spawnMS = performance.now() - t0;
      }
      else this._autoSpawnParticles();
    }
    else if ( profile ) stats.spawnMS = 0;

    if ( profile ) stats.tickMS = performance.now() - tTickStart;
    if ( this._stopped && (this.particles.length === 0) ) this.stop({hard: true});
  }

  /* -------------------------------------------- */

  /**
   * Update all active particles.
   * @param {number} dt Delta time in milliseconds.
   * @protected
   */
  _updateExistingParticles(dt) {
    const ds = dt * 0.001;
    const particles = this.particles;

    const behavior = this._behavior;
    const bctx = behavior ? this._behaviorContext : null;
    if ( bctx ) {
      bctx.dt = dt;
      bctx.ds = ds;
      this.#updateAnchor({fallbackToBoundsCenter: true});
    }

    const mode = this.constraints.mode;
    if ( mode === "none" ) return this._updateParticlesUnconstrained(dt, ds, particles, behavior, bctx);

    const bounds = this.#getConstraintRectLocal();

    // If the constraint rect is empty, we cannot apply clamp/wrap/bounce
    // For kill mode with viewport constraints, an empty rect means everything is out of bounds
    if ( !bounds || (bounds.width <= 0) || (bounds.height <= 0) ) {
      const area = this.constraints.area;
      if ( (mode === "kill") && ((area === "budget") || (area === "view")) ) {
        const reason = "constraint";
        for ( let i = particles.length - 1; i >= 0; i-- ) this._recycleParticle(particles[i], reason);
        particles.length = 0;
        return;
      }
      return this._updateParticlesUnconstrained(dt, ds, particles, behavior, bctx);
    }

    this._updateParticlesConstrained(dt, ds, bounds, particles, behavior, bctx, mode);
  }

  /* -------------------------------------------- */

  /**
   * Update particles without constraints.
   * @param {number} dt Delta time in milliseconds.
   * @param {number} ds Delta time in seconds.
   * @param {object[]} particles
   * @param {ParticleGeneratorBehavior|null} behavior
   * @param {object|null} bctx
   * @protected
   */
  _updateParticlesUnconstrained(dt, ds, particles, behavior, bctx) {
    const velocityFunction = this.#velocityFunction;
    const speedValue = this.#speedValue;
    const rotationSpeedValue = this.#rotationSpeedValue;
    const scaleValue = this.#scaleValue;
    const tintValue = this.#tintValue;
    const updateSpeed = !velocityFunction && speedValue?.dynamic;
    const updateRotationSpeed = rotationSpeedValue.dynamic;
    const updateScale = scaleValue.dynamic;
    const updateTint = tintValue.dynamic;

    for ( let i = particles.length - 1; i >= 0; i-- ) {
      const p = particles[i];

      const t = (p.elapsedTime += dt);
      const lifetime = p.lifetime;
      const normalizedAge = p.time = (lifetime > 0) && (t < lifetime) ? t / lifetime : 1;
      if ( velocityFunction ) this.#applyVelocityFunction(p, dt, velocityFunction);
      else if ( updateSpeed ) {
        const speed = this.#computeParticleValue(speedValue, p, p._baseSpeed, normalizedAge, dt);
        p.movementSpeed.x = (p._movementDirectionX * speed) + p._movementDriftX;
        p.movementSpeed.y = (p._movementDirectionY * speed) + p._movementDriftY;
      }
      if ( updateRotationSpeed ) p.rotationSpeed = this.#computeRotationSpeed(p, normalizedAge, dt);
      p.rotation += p.rotationSpeed * ds;

      const behaviorHandled = behavior?.update ? (behavior.update(p, dt, bctx) === true) : false;
      if ( !behaviorHandled ) {
        p.x += p.movementSpeed.x * ds;
        p.y += p.movementSpeed.y * ds;
      }
      if ( updateScale ) p.scale.set(this.#computeParticleValue(scaleValue, p, p._baseScale, normalizedAge, dt));
      if ( updateTint ) p.tint = this.#computeColorValue(tintValue, p, normalizedAge, dt);
      p.alpha = this.#computeAlpha(p, normalizedAge, dt);
      if ( this.onUpdate ) this.onUpdate(p, {generator: this});

      if ( t < lifetime ) continue;

      const last = particles.pop();
      if ( i < particles.length ) particles[i] = last;
      this._recycleParticle(p, "lifetime");
    }
  }

  /* -------------------------------------------- */

  /**
   * Update particles with constraints applied.
   * @param {number} dt
   * @param {number} ds
   * @param {PIXI.Rectangle} bounds
   * @param {ParticleMesh[]} particles
   * @param {ParticleGeneratorBehavior|null} behavior
   * @param {object|null} bctx
   * @param {ParticleGeneratorConstraintMode} mode
   * @protected
   */
  _updateParticlesConstrained(dt, ds, bounds, particles, behavior, bctx, mode) {
    const minX = bounds.x;
    const minY = bounds.y;
    const maxX = bounds.x + bounds.width;
    const maxY = bounds.y + bounds.height;

    const restitution = this.constraints.restitution;
    const velocityFunction = this.#velocityFunction;
    const speedValue = this.#speedValue;
    const rotationSpeedValue = this.#rotationSpeedValue;
    const scaleValue = this.#scaleValue;
    const tintValue = this.#tintValue;
    const updateSpeed = !velocityFunction && speedValue?.dynamic;
    const updateRotationSpeed = rotationSpeedValue.dynamic;
    const updateScale = scaleValue.dynamic;
    const updateTint = tintValue.dynamic;

    for ( let i = particles.length - 1; i >= 0; i-- ) {
      const p = particles[i];

      const t = (p.elapsedTime += dt);
      const lifetime = p.lifetime;
      const normalizedAge = p.time = (lifetime > 0) && (t < lifetime) ? t / lifetime : 1;
      if ( velocityFunction ) this.#applyVelocityFunction(p, dt, velocityFunction);
      else if ( updateSpeed ) {
        const speed = this.#computeParticleValue(speedValue, p, p._baseSpeed, normalizedAge, dt);
        p.movementSpeed.x = (p._movementDirectionX * speed) + p._movementDriftX;
        p.movementSpeed.y = (p._movementDirectionY * speed) + p._movementDriftY;
      }
      if ( updateRotationSpeed ) p.rotationSpeed = this.#computeRotationSpeed(p, normalizedAge, dt);
      p.rotation += p.rotationSpeed * ds;

      const behaviorHandled = behavior?.update ? (behavior.update(p, dt, bctx) === true) : false;
      if ( !behaviorHandled ) {
        p.x += p.movementSpeed.x * ds;
        p.y += p.movementSpeed.y * ds;
      }
      if ( updateScale ) p.scale.set(this.#computeParticleValue(scaleValue, p, p._baseScale, normalizedAge, dt));
      if ( updateTint ) p.tint = this.#computeColorValue(tintValue, p, normalizedAge, dt);
      p.alpha = this.#computeAlpha(p, normalizedAge, dt);
      if ( this.onUpdate ) this.onUpdate(p, {generator: this});

      // Lifetime ends first
      if ( t >= lifetime ) {
        const last = particles.pop();
        if ( i < particles.length ) particles[i] = last;
        this._recycleParticle(p, "lifetime");
        continue;
      }

      // Constraints
      let kill = false;
      if ( mode === "kill" ) {
        kill = (p.x < minX) || (p.x > maxX) || (p.y < minY) || (p.y > maxY);
      }
      else if ( mode === "clamp" ) {
        p.x = Math.clamp(p.x, minX, maxX);
        p.y = Math.clamp(p.y, minY, maxY);
      }
      else if ( mode === "wrap" ) {
        const w = bounds.width;
        const h = bounds.height;
        if ( w > 0 ) {
          if ( p.x < minX ) p.x += w;
          else if ( p.x > maxX ) p.x -= w;
        }
        if ( h > 0 ) {
          if ( p.y < minY ) p.y += h;
          else if ( p.y > maxY ) p.y -= h;
        }
      }
      else if ( mode === "bounce" ) {
        if ( p.x < minX ) {
          p.x = minX;
          p.movementSpeed.x = -p.movementSpeed.x * restitution;
          if ( velocityFunction ) {
            p._velocityFunctionBounceX = -p._velocityFunctionBounceX;
            p._movementDriftX = -p._movementDriftX;
          }
          else if ( updateSpeed ) {
            p._movementDirectionX = -p._movementDirectionX;
            p._movementDriftX = -p._movementDriftX;
          }
        }
        else if ( p.x > maxX ) {
          p.x = maxX;
          p.movementSpeed.x = -p.movementSpeed.x * restitution;
          if ( velocityFunction ) {
            p._velocityFunctionBounceX = -p._velocityFunctionBounceX;
            p._movementDriftX = -p._movementDriftX;
          }
          else if ( updateSpeed ) {
            p._movementDirectionX = -p._movementDirectionX;
            p._movementDriftX = -p._movementDriftX;
          }
        }

        if ( p.y < minY ) {
          p.y = minY;
          p.movementSpeed.y = -p.movementSpeed.y * restitution;
          if ( velocityFunction ) {
            p._velocityFunctionBounceY = -p._velocityFunctionBounceY;
            p._movementDriftY = -p._movementDriftY;
          }
          else if ( updateSpeed ) {
            p._movementDirectionY = -p._movementDirectionY;
            p._movementDriftY = -p._movementDriftY;
          }
        }
        else if ( p.y > maxY ) {
          p.y = maxY;
          p.movementSpeed.y = -p.movementSpeed.y * restitution;
          if ( velocityFunction ) {
            p._velocityFunctionBounceY = -p._velocityFunctionBounceY;
            p._movementDriftY = -p._movementDriftY;
          }
          else if ( updateSpeed ) {
            p._movementDirectionY = -p._movementDirectionY;
            p._movementDriftY = -p._movementDriftY;
          }
        }
      }

      if ( !kill ) continue;

      const last = particles.pop();
      if ( i < particles.length ) particles[i] = last;
      this._recycleParticle(p, "constraint");
    }
  }

  /* -------------------------------------------- */

  /**
   * Spawn particles to move toward the current target count.
   * @protected
   */
  _autoSpawnParticles() {
    const target = this.adjustedMaxParticles;
    if ( target <= 0 ) return;

    const available = target - this.particles.length;
    if ( available <= 0 ) return;
    this.#spawnAccumulator += this.spawnRate * this.#ticker.deltaMS * 0.001;

    // Keep fractional budget between ticks so the average spawn rate remains stable
    const desiredParticles = Math.floor(this.#spawnAccumulator);
    if ( desiredParticles <= 0 ) return;

    this.#spawnAccumulator -= desiredParticles;
    const toCreate = Math.min(desiredParticles, available);

    const stats = this._debugStats;
    const localArea = this.mode === "effect" ? this.#computeSpawnAreaLocal(null, null) : null;

    for ( let i = 0; i < toCreate; i++ ) {
      if ( stats ) stats.spawnAttempts++;
      if ( Math.random() > this.spawnProbability ) {
        if ( stats ) stats.spawnRejectedProbability++;
        continue;
      }
      if ( localArea ) {
        const usedTexture = this._getRandomTexture();
        if ( usedTexture ) this.#spawnParticleLocal(localArea, usedTexture);
      }
      else if ( this.mode === "effect" ) {
        if ( stats ) stats.spawnRejectedNoArea++;
      }
      else this.#spawnParticleDefault();
    }
  }

  /* -------------------------------------------- */
  /*  Spawning Internals                          */
  /* -------------------------------------------- */

  /**
   * Spawn one particle using the generator defaults.
   */
  #spawnParticleDefault() {
    const stats = this._debugStats;

    const usedTexture = this._getRandomTexture();
    if ( !usedTexture ) return null;

    if ( this.mode === "ambient" ) {
      let rect = this._budgetRectLocal;
      const n = this._newlyVisibleAreas.length;
      if ( n ) rect = this._newlyVisibleAreas[Math.floor(Math.random() * n)];

      // Guard against degenerate viewport rectangles.
      if ( (rect.width <= 0) || (rect.height <= 0) ) {
        if ( stats ) stats.spawnRejectedNoArea++;
        return null;
      }

      return this.#spawnParticleLocal({type: "ambientRect", rect}, usedTexture);
    }

    const localArea = this.#computeSpawnAreaLocal(null, null);
    if ( !localArea ) {
      if ( stats ) stats.spawnRejectedNoArea++;
      return null;
    }
    return this.#spawnParticleLocal(localArea, usedTexture);
  }

  /* -------------------------------------------- */

  /**
   * Spawn a particle in local-space.
   * @param {ParticleGeneratorLocalArea} areaLocal
   * @param {PIXI.Texture} usedTexture
   * @returns {ParticleMesh|null}
   */
  #spawnParticleLocal(areaLocal, usedTexture) {
    if ( !this._particlesContainer ) return null;

    const stats = this._debugStats;

    /** @type {ParticleMesh} */
    let particle = this.particlePool.pop();
    if ( particle ) {
      particle.texture = usedTexture;
      const anchor = this.particleAnchor;
      if ( anchor ) particle.anchor.set(anchor.x, anchor.y);
      else {
        const da = usedTexture.defaultAnchor;
        particle.anchor.set(da?.x ?? 0.5, da?.y ?? 0.5);
      }
    } else {
      particle = this._createNewParticle(usedTexture);
      particle.visible = false;
      particle.renderable = false;
      this._particlesContainer.addChild(particle);
    }

    this._setupParticleBase(particle);
    if ( !this.#assignSpawnPosition(areaLocal, particle) ) {
      if ( stats ) stats.spawnRejectedNoArea++;
      this.particlePool.push(particle);
      particle.visible = false;
      particle.renderable = false;
      return null;
    }

    const velocityFunction = this.#velocityFunction;
    if ( velocityFunction ) {
      this.#applyVelocityFunction(particle, 0, velocityFunction);
      this.#initializeParticleRotation(particle);
    }

    const behavior = this._behavior;
    if ( behavior?.spawn ) {
      this.#updateAnchor({fallbackToBoundsCenter: true});
      behavior.spawn(particle, this._behaviorContext);
    }

    if ( !this.#testPosition(particle) ) {
      if ( stats ) stats.spawnRejectedPositionTest++;
      this.particlePool.push(particle);
      particle.visible = false;
      particle.renderable = false;
      return null;
    }

    if ( this.drift.enabled ) this._applyRandomDrift(particle);

    if ( this._debugTint && !this.#tintValue.dynamic ) this.#applyDebugTint(particle, usedTexture);

    if ( this.onSpawn ) this.onSpawn(particle, {generator: this});

    this.particles.push(particle);
    if ( stats ) stats.spawned++;
    return particle;
  }

  /* -------------------------------------------- */

  /**
   * Assign a spawn position (local coordinates).
   * @param {ParticleGeneratorLocalArea} areaLocal
   * @param {ParticleMesh} particle
   * @returns {boolean}
   */
  #assignSpawnPosition(areaLocal, particle) {
    let randomizeAge = false;

    switch ( areaLocal.type ) {
      case "shape":
      case "polygonTree": {
        const data = areaLocal.data;
        if ( data.isEmpty ) return false;
        const point = this._spawnPoint;
        if ( areaLocal.sampleMode === "boundary" ) data.sampleBoundary(point);
        else data.sampleInterior(point);
        particle.x = point.x - this._bounds.x;
        particle.y = point.y - this._bounds.y;
        break;
      }
      case "point":
        particle.x = areaLocal.x;
        particle.y = areaLocal.y;
        break;
      case "points": {
        const point = areaLocal.points[(Math.random() * areaLocal.points.length) | 0];
        particle.x = point.x;
        particle.y = point.y;
        break;
      }
      case "polyline": {
        if ( areaLocal.totalLength <= 0 ) {
          const point = areaLocal.fallbackPoint;
          if ( !point ) return false;
          particle.x = point.x;
          particle.y = point.y;
          break;
        }

        const target = Math.random() * areaLocal.totalLength;
        let low = 0;
        let high = areaLocal.cumulativeLengths.length - 1;
        while ( low < high ) {
          const mid = (low + high) >> 1;
          if ( target <= areaLocal.cumulativeLengths[mid] ) high = mid;
          else low = mid + 1;
        }

        const i = low;
        const segment = areaLocal.segments[i];
        const previous = i ? areaLocal.cumulativeLengths[i - 1] : 0;
        const t = (target - previous) / segment.length;
        particle.x = segment.ax + ((segment.bx - segment.ax) * t);
        particle.y = segment.ay + ((segment.by - segment.ay) * t);
        break;
      }
      case "ellipse": {
        const angle = Math.mix(areaLocal.minAngle, areaLocal.maxAngle, Math.random());
        const scale = Math.sqrt(areaLocal.holeAreaScale + (Math.random() * (1 - areaLocal.holeAreaScale)));
        let localX = areaLocal.radiusX * scale * Math.cos(angle);
        let localY = areaLocal.radiusY * scale * Math.sin(angle);

        if ( areaLocal.shapeRotation ) {
          const rotatedX = (localX * areaLocal.cosRotation) - (localY * areaLocal.sinRotation);
          const rotatedY = (localX * areaLocal.sinRotation) + (localY * areaLocal.cosRotation);
          localX = rotatedX;
          localY = rotatedY;
        }

        particle.x = areaLocal.x + localX;
        particle.y = areaLocal.y + localY;
        if ( areaLocal.affectRotation ) particle.rotation += areaLocal.shapeRotation + angle;
        break;
      }
      case "ambientRect": {
        const rect = areaLocal.rect;
        particle.x = rect.x + (Math.random() * rect.width);
        particle.y = rect.y + (Math.random() * rect.height);
        if ( (this.mode === "ambient") && this.viewport.randomizeAgeInPadding
          && !this._viewRectLocal.contains(particle.x, particle.y) ) {
          particle.elapsedTime = Math.random() * particle.lifetime;
          randomizeAge = true;
        }
        break;
      }
      case "line": {
        const t = Math.random();
        particle.x = areaLocal.ax + ((areaLocal.bx - areaLocal.ax) * t);
        particle.y = areaLocal.ay + ((areaLocal.by - areaLocal.ay) * t);
        break;
      }
      default:
        return false;
    }

    const speedValue = this.#speedValue;
    const rotationSpeedValue = this.#rotationSpeedValue;
    const scaleValue = this.#scaleValue;
    const tintValue = this.#tintValue;
    const alphaValue = this.#alphaValue;
    const updateSpeed = speedValue?.dynamic && (randomizeAge || speedValue.fn);
    const updateRotationSpeed = rotationSpeedValue.dynamic && (randomizeAge || rotationSpeedValue.fn);
    const updateScale = scaleValue.dynamic && (randomizeAge || scaleValue.fn);
    const updateTint = tintValue.dynamic && (randomizeAge || tintValue.fn);
    const updateAlpha = randomizeAge || (alphaValue.dynamic && alphaValue.fn);
    if ( !(updateSpeed || updateRotationSpeed || updateScale || updateTint || updateAlpha) ) return true;

    let normalizedAge = particle.time;
    if ( randomizeAge ) {
      const lifetime = particle.lifetime;
      normalizedAge = particle.time = lifetime > 0 ? particle.elapsedTime / lifetime : 1;
    }
    if ( updateSpeed ) {
      const speed = this.#computeParticleValue(speedValue, particle, particle._baseSpeed, normalizedAge, 0);
      particle.movementSpeed.x = (particle._movementDirectionX * speed) + particle._movementDriftX;
      particle.movementSpeed.y = (particle._movementDirectionY * speed) + particle._movementDriftY;
    }
    if ( updateRotationSpeed ) particle.rotationSpeed = this.#computeRotationSpeed(particle, normalizedAge, 0);
    if ( updateScale ) particle.scale.set(this.#computeParticleValue(scaleValue, particle, particle._baseScale,
      normalizedAge, 0));
    if ( updateTint ) particle.tint = this.#computeColorValue(tintValue, particle, normalizedAge, 0);
    if ( updateAlpha ) particle.alpha = this.#computeAlpha(particle, normalizedAge, 0);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Apply a random drift vector to a particle.
   * @param {ParticleMesh} particle
   * @protected
   */
  _applyRandomDrift(particle) {
    const angle = Math.random() * (Math.PI * 2);
    const driftMag = this.drift.intensity;
    const dx = Math.cos(angle) * driftMag;
    const dy = Math.sin(angle) * driftMag;
    particle.movementSpeed.x += dx;
    particle.movementSpeed.y += dy;
    particle._movementDriftX = dx;
    particle._movementDriftY = dy;
  }

  /* -------------------------------------------- */
  /*  Particle Internals                          */
  /* -------------------------------------------- */

  /**
   * Create a new particle instance.
   * @param {PIXI.Texture} texture
   * @returns {ParticleMesh}
   * @protected
   */
  _createNewParticle(texture) {
    const particle = /** @type {ParticleMesh} */ new SpriteMesh(texture, this.shaderClass);
    const anchor = this.particleAnchor;
    if ( anchor ) particle.anchor.set(anchor.x, anchor.y);
    else {
      const da = texture.defaultAnchor;
      particle.anchor.set(da?.x ?? 0.5, da?.y ?? 0.5);
    }
    return particle;
  }

  /* -------------------------------------------- */

  /**
   * Initialize/refresh base particle properties.
   * @param {ParticleMesh} particle
   */
  _setupParticleBase(particle) {
    particle.visible = true;
    particle.renderable = true;

    particle.alpha = 0;
    particle.generator = this;
    particle.elapsedTime = 0;
    particle.time = 0;
    particle.blendMode = this.blendMode;
    particle.lifetime = this.#computeLifetime();
    particle.fadeInDuration = this.#computeFadeDuration(this.fadeInDuration, particle.lifetime);
    particle.fadeOutDuration = this.#computeFadeDuration(this.fadeOutDuration, particle.lifetime);

    const scale = this.#sampleParticleValue(this.#scaleValue, 1);
    const maxAlpha = this.#sampleParticleValue(this.#alphaValue, 1);
    const tintValue = this.#tintValue;

    particle.scale.set(scale, scale);
    particle._baseScale = scale;
    particle._baseTint = tintValue.color;
    particle.maxAlpha = maxAlpha;
    particle._movementDriftX = 0;
    particle._movementDriftY = 0;
    particle._velocityFunctionBounceX = 1;
    particle._velocityFunctionBounceY = 1;
    particle._baseSpeed = 0;
    particle._baseRotationSpeed = this.#rotationSpeedValue.dynamic ? this.#sampleRotationSpeed() : 0;

    if ( !particle.movementSpeed ) particle.movementSpeed = new PIXI.Point(0, 0);
    if ( this.#velocityFunction ) particle.movementSpeed.set(0, 0);
    else {
      this._generateMovementSpeed(particle.movementSpeed, particle);
      this.#initializeParticleRotation(particle);
    }

    if ( this.#scaleValue.dynamic ) {
      particle.scale.set(this.#computeParticleValue(this.#scaleValue, particle, particle._baseScale, 0, 0));
    }
    particle.tint = tintValue.dynamic ? this.#computeColorValue(tintValue, particle, 0, 0) : particle._baseTint;
    particle.alpha = this.#computeAlpha(particle, 0, 0);
  }

  /* -------------------------------------------- */

  /**
   * Initialize the particle rotation from the current movement vector.
   * @param {ParticleMesh} particle
   */
  #initializeParticleRotation(particle) {
    const {alignVelocity, initial, spread} = this.rotation;
    const base = alignVelocity ? Math.atan2(particle.movementSpeed.y, particle.movementSpeed.x) : 0;
    particle.rotation = base + initial + (((Math.random() * 2) - 1) * spread);
    if ( !this.#rotationSpeedValue.dynamic ) particle._baseRotationSpeed = this.#sampleRotationSpeed();
    particle.rotationSpeed = this.#computeRotationSpeed(particle, particle.time, 0);
  }

  /* -------------------------------------------- */

  /**
   * Recycle a particle to the pool.
   * @param {ParticleMesh} particle
   * @param {string} reason
   * @protected
   */
  _recycleParticle(particle, reason) {
    reason = (typeof reason === "string") ? reason : "unknown";

    const stats = this._debugStats;
    if ( stats ) {
      if ( reason === "lifetime" ) stats.recycledLifetime++;
      else if ( reason === "constraint" ) stats.recycledConstraint++;
    }

    if ( this.onDeath ) this.onDeath(particle, {generator: this, reason});
    particle.renderable = false;
    particle.visible = false;
    this.particlePool.push(particle);
  }

  /* -------------------------------------------- */
  /*  Alpha & Lifetime                            */
  /* -------------------------------------------- */

  /**
   * Compute alpha for a particle given its age and state.
   * @param {ParticleMesh} p
   * @param {number} [normalizedAge]
   * @param {number} [dt=0]
   * @returns {number}
   */
  #computeAlpha(p, normalizedAge, dt=0) {
    const t = p.elapsedTime;
    const lifetime = p.lifetime;

    let a = 1;
    if ( p.fadeInDuration > 0 ) a = Math.min(a, Math.clamp(t / p.fadeInDuration, 0, 1));
    if ( p.fadeOutDuration > 0 ) a = Math.min(a, Math.clamp((lifetime - t) / p.fadeOutDuration, 0, 1));
    if ( this.#alphaValue.dynamic ) {
      normalizedAge ??= (lifetime > 0) && (t < lifetime) ? t / lifetime : 1;
      return a * this.#computeParticleValue(this.#alphaValue, p, p.maxAlpha, normalizedAge, dt);
    }

    return a * p.maxAlpha;
  }

  /* -------------------------------------------- */

  /**
   * Compute rotation speed for a particle, in radians per second.
   * @param {ParticleMesh} p
   * @param {number} normalizedAge
   * @param {number} [dt=0]
   * @returns {number}
   */
  #computeRotationSpeed(p, normalizedAge, dt=0) {
    const value = this.#rotationSpeedValue;
    return value.dynamic
      ? this.#computeParticleValue(value, p, p._baseRotationSpeed, normalizedAge, dt)
      : p._baseRotationSpeed;
  }

  /* -------------------------------------------- */

  /**
   * Sample the public rotation speed range in radians per second.
   * @returns {number}
   */
  #sampleRotationSpeed() {
    const range = this.rotation.speed;
    if ( !range ) return this.#sampleParticleValue(this.#rotationSpeedValue, 0);
    const min = Number(range.min) || 0;
    const max = Number(range.max) || 0;
    return Math.mix(min, max, Math.random());
  }

  /* -------------------------------------------- */

  /**
   * Compute a per-particle lifetime.
   * @returns {number}
   */
  #computeLifetime() {
    const v = this.particleLifetime;

    if ( typeof v === "number" ) return Math.max(0, v);

    if ( Array.isArray(v) && (v.length >= 2) ) {
      const min = Number(v[0]) || 0;
      const max = Number(v[1]) || 0;
      return Math.max(0, Math.mix(min, max, Math.random()));
    }

    const min = Number(v.min ?? 0) || 0;
    const max = Number(v.max ?? min) || min;
    return Math.max(0, Math.mix(min, max, Math.random()));
  }

  /* -------------------------------------------- */

  /**
   * Compute a fade duration.
   * @param {number} value
   * @param {number} lifetime
   * @returns {number}
   */
  #computeFadeDuration(value, lifetime) {
    const v = Number(value ?? 0) || 0;
    if ( (v > 0) && (v < 1) ) return Math.clamp(lifetime * v, 0, lifetime);
    return Math.clamp(v, 0, lifetime);
  }

  /* -------------------------------------------- */
  /*  Texture Helpers                             */
  /* -------------------------------------------- */

  /**
   * Get a random texture from the configured set.
   * @returns {PIXI.Texture|null}
   * @protected
   */
  _getRandomTexture() {
    const textures = this.textures;
    if ( !textures.length ) return null;
    return textures[Math.floor(Math.random() * textures.length)];
  }

  /* -------------------------------------------- */

  /**
   * Load texture sources.
   * @param {(PIXI.Texture|string)[]} sources
   * @returns {PIXI.Texture[]}
   */
  #loadTextures(sources) {
    const out = [];
    for ( const s of sources ) {
      const t = this.#lookupTexture(s);
      if ( t ) out.push(t);
    }
    return out;
  }

  /* -------------------------------------------- */

  /**
   * Assign a texture source into a PIXI.Texture.
   * @param {PIXI.Texture|string|null|undefined} source
   * @returns {PIXI.Texture|null}
   */
  #lookupTexture(source) {
    if ( !source ) return null;
    if ( source instanceof PIXI.Texture ) return source;

    const cached = foundry.canvas.getTexture(source);
    if ( cached instanceof PIXI.Texture ) return cached;

    return PIXI.Texture.from(source);
  }

  /* -------------------------------------------- */
  /*  Spawn Validation                            */
  /* -------------------------------------------- */

  /**
   * Test whether the particle position is valid.
   * @param {ParticleMesh} particle
   * @returns {boolean}
   */
  #testPosition(particle) {
    if ( !this.positionTest ) return true;
    const x = particle.x + this._bounds.x;
    const y = particle.y + this._bounds.y;
    return this.positionTest(x, y, {generator: this, particle});
  }

  /* -------------------------------------------- */
  /*  Velocity Helpers                            */
  /* -------------------------------------------- */

  /**
   * Apply the configured custom velocity function into a particle's reusable movement point.
   * @param {ParticleMesh} particle
   * @param {number} dt
   * @param {ParticleGeneratorVelocityFunction} fn
   */
  #applyVelocityFunction(particle, dt, fn) {
    const out = particle.movementSpeed;
    out.x = 0;
    out.y = 0;

    const velocity = fn(particle, dt, out);
    if ( velocity ) {
      out.set(velocity.x, velocity.y);
    }

    out.x = (out.x * particle._velocityFunctionBounceX) + particle._movementDriftX;
    out.y = (out.y * particle._velocityFunctionBounceY) + particle._movementDriftY;
  }

  /* -------------------------------------------- */

  /**
   * Compile the custom velocity function, if one is configured.
   * @param {ParticleGeneratorVelocityOptions|null} cfg
   * @returns {ParticleGeneratorVelocityFunction|null}
   */
  #compileVelocityFunction(cfg) {
    if ( !cfg || (typeof cfg !== "object") || !("fn" in cfg) ) return null;
    if ( typeof cfg.fn !== "function" ) {
      throw new Error("ParticleGenerator: velocity.fn must be a function.");
    }

    if ( ("x" in cfg) || ("y" in cfg) || ("speed" in cfg) || ("angle" in cfg) ) {
      throw new Error("ParticleGenerator: velocity.fn cannot be combined with x, y, speed, or angle.");
    }

    return cfg.fn;
  }

  /* -------------------------------------------- */

  /**
   * Build a per-particle velocity generator which writes into an existing PIXI.Point.
   * @param {ParticleGeneratorVelocityOptions|null} cfg
   * @returns {(out: PIXI.Point, particle: ParticleMesh) => void}
   */
  #buildVelocityGenerator(cfg) {
    if ( cfg instanceof PIXI.Point ) {
      const x = cfg.x;
      const y = cfg.y;
      return out => out.set(x, y);
    }

    const speedValue = this.#speedValue;
    if ( cfg && (typeof cfg === "object") && ("angle" in cfg) && speedValue ) {
      const angleRange = this.#normalizeRange(cfg.angle, {min: 0, max: 0}) ?? {min: 0, max: 0};

      const angleMin = Math.toRadians(angleRange.min);
      const angleMax = Math.toRadians(angleRange.max);

      return (out, particle) => {
        const angle = Math.mix(angleMin, angleMax, Math.random());
        const dx = Math.cos(angle);
        const dy = Math.sin(angle);
        particle._baseSpeed = this.#sampleParticleValue(speedValue, 0);
        const speed = speedValue.dynamic
          ? this.#computeParticleValue(speedValue, particle, particle._baseSpeed, 0, 0)
          : particle._baseSpeed;
        if ( speedValue.dynamic ) {
          particle._movementDirectionX = dx;
          particle._movementDirectionY = dy;
        }
        out.set(dx * speed, dy * speed);
      };
    }

    if ( cfg && (typeof cfg === "object") && ("x" in cfg) && ("y" in cfg) ) {
      const x = this.#normalizeRange(cfg.x, {min: 0, max: 0}) ?? {min: 0, max: 0};
      const y = this.#normalizeRange(cfg.y, {min: 0, max: 0}) ?? {min: 0, max: 0};
      return out => {
        const vx = Math.mix(x.min, x.max, Math.random());
        const vy = Math.mix(y.min, y.max, Math.random());
        out.set(vx, vy);
      };
    }

    return out => out.set(0, 0);
  }

  /* -------------------------------------------- */
  /*  Rectangle Helpers                           */
  /* -------------------------------------------- */

  /**
   * Check whether rectA fully contains rectB.
   * @param {PIXI.Rectangle} rectA
   * @param {PIXI.Rectangle} rectB
   * @returns {boolean}
   */
  #containsRect(rectA, rectB) {
    return (
      (rectB.x >= rectA.x)
      && (rectB.y >= rectA.y)
      && ((rectB.x + rectB.width) <= (rectA.x + rectA.width))
      && ((rectB.y + rectB.height) <= (rectA.y + rectA.height))
    );
  }

  /* -------------------------------------------- */
  /*  Ranges                                      */
  /* -------------------------------------------- */

  /**
   * Compile the polar velocity speed value, if one is configured.
   * @param {ParticleGeneratorVelocityOptions|null} cfg
   * @returns {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean}|null}
   */
  #compileVelocitySpeedValue(cfg) {
    if ( !cfg || (typeof cfg !== "object") || !("angle" in cfg) || !("speed" in cfg) ) return null;
    return this.#compileParticleValue(cfg.speed, {min: 0, max: 0});
  }

  /* -------------------------------------------- */

  /**
   * Compile the rotation speed value into radians per second.
   * The config API accepts degrees per second. Curves paired with a range remain unitless multipliers.
   * @param {ParticleGeneratorValue|null|undefined} value
   * @returns {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean}}
   */
  #compileRotationSpeedValue(value) {
    const compiled = this.#compileParticleValue(value, {min: 0, max: 0});

    const range = compiled.range;
    if ( range ) {
      range.min = Math.toRadians(range.min);
      range.max = Math.toRadians(range.max);
    }

    if ( compiled.curve && !range ) {
      const values = compiled.curve.values;
      for ( let i = 0; i < values.length; i++ ) values[i] = Math.toRadians(values[i]);
    }

    if ( compiled.fn ) {
      const fn = compiled.fn;
      compiled.fn = (particle, dt) => Math.toRadians(fn(particle, dt));
    }

    return compiled;
  }

  /* -------------------------------------------- */

  /**
   * Compile a particle value for spawn-time sampling and update-time evaluation.
   * Curves are linearly interpolated by default, or evaluated in discrete steps if the `step` flag is true.
   * @param {ParticleGeneratorValue|null|undefined} value
   * @param {{min: number, max: number}} fallback
   * @returns {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, dynamic: boolean, step: boolean}}
   */
  #compileParticleValue(value, fallback) {
    const hasOptions = value && (typeof value === "object") && !Array.isArray(value);
    if ( hasOptions && ("fn" in value) ) {
      const fn = this.#validateValueFunction(value, "value", ["min", "max", "curve", "step"],
        "min, max, curve, or step");
      return {range: null, curve: null, fn, dynamic: true, step: false};
    }
    const hasCurve = hasOptions && ("curve" in value);
    const hasRange = hasOptions && (("min" in value) || ("max" in value));
    if ( hasOptions && !hasCurve && !hasRange ) {
      throw new Error("ParticleGenerator: value object must define min, max, curve, or fn.");
    }
    const curve = hasCurve ? this.#compileValueCurve(value.curve) : null;
    let range = null;
    if ( hasCurve ) {
      if ( ("min" in value) || ("max" in value) ) range = this.#normalizeValueRange(value, null);
    } else range = this.#normalizeValueRange(value, fallback);
    if ( !range && !curve ) range = fallback;
    return {range, curve, fn: null, dynamic: !!curve, step: !!(curve && value.step)};
  }

  /* -------------------------------------------- */

  /**
   * Validate a custom particle value function.
   * @param {object} value
   * @param {string} field
   * @param {string[]} conflicts
   * @param {string} conflictLabel
   * @returns {ParticleGeneratorValueFunction|ParticleGeneratorColorFunction}
   */
  #validateValueFunction(value, field, conflicts, conflictLabel) {
    if ( typeof value.fn !== "function" ) {
      throw new Error(`ParticleGenerator: ${field}.fn must be a function.`);
    }
    for ( const conflict of conflicts ) {
      if ( conflict in value ) {
        throw new Error(`ParticleGenerator: ${field}.fn cannot be combined with ${conflictLabel}.`);
      }
    }
    return value.fn;
  }

  /* -------------------------------------------- */

  /**
   * Validate a raw curve source.
   * @param {any} raw
   * @param {string} field
   * @returns {number}
   */
  #validateCurveSource(raw, field) {
    if ( !Array.isArray(raw) ) throw new Error(`ParticleGenerator: ${field}.curve must be an array.`);
    if ( raw.length < 2 ) {
      throw new Error(`ParticleGenerator: ${field}.curve must contain at least two points.`);
    }
    return raw.length;
  }

  /* -------------------------------------------- */

  /**
   * Validate a raw curve point.
   * @param {any} point
   * @param {number} i
   * @param {number} n
   * @param {number} previousTime
   * @param {string} field
   * @returns {number}
   */
  #validateCurvePoint(point, i, n, previousTime, field) {
    if ( !point || (typeof point !== "object") ) {
      throw new Error(`ParticleGenerator: ${field}.curve[${i}] must be an object.`);
    }
    const time = point.time;
    if ( (typeof time !== "number") || !Number.isFinite(time) ) {
      throw new Error(`ParticleGenerator: ${field}.curve[${i}].time must be a finite number.`);
    }
    if ( (time < 0) || (time > 1) ) {
      throw new Error(`ParticleGenerator: ${field}.curve[${i}].time must be between 0 and 1.`);
    }
    if ( (i === 0) && (time !== 0) ) {
      throw new Error(`ParticleGenerator: ${field}.curve must start with time 0.`);
    }
    if ( (i === (n - 1)) && (time !== 1) ) {
      throw new Error(`ParticleGenerator: ${field}.curve must end with time 1.`);
    }
    if ( time <= previousTime ) {
      throw new Error(`ParticleGenerator: ${field}.curve times must be strictly increasing.`);
    }
    return time;
  }

  /* -------------------------------------------- */

  /**
   * Validate and compile curve points for update-time evaluation.
   * @param {ParticleGeneratorCurvePoint[]} raw
   * @param {string} [field="value"]  Field name used in error messages.
   * @returns {{times: Float32Array, values: Float32Array}}
   */
  #compileValueCurve(raw, field="value") {
    const n = this.#validateCurveSource(raw, field);
    const times = new Float32Array(n);
    const values = new Float32Array(n);
    let previousTime = -Infinity;
    for ( let i = 0; i < n; i++ ) {
      const point = raw[i];
      const time = this.#validateCurvePoint(point, i, n, previousTime, field);
      const value = point.value;
      if ( (typeof value !== "number") || !Number.isFinite(value) ) {
        throw new Error(`ParticleGenerator: ${field}.curve[${i}].value must be a finite number.`);
      }
      times[i] = time;
      values[i] = value;
      previousTime = time;
    }
    return {times, values};
  }

  /* -------------------------------------------- */

  /**
   * Evaluate a compiled value curve at a normalized age.
   * @param {{times: Float32Array, values: Float32Array}} curve
   * @param {number} t                  Normalized time [0, 1]
   * @param {boolean} [step=false]      Evaluate in discrete steps rather than interpolation
   * @returns {number}                  The evaluated value
   */
  #evaluateValueCurve(curve, t, step=false) {
    const times = curve.times;
    const values = curve.values;
    const last = times.length - 1;

    if ( t <= times[0] ) return values[0];
    if ( t >= times[last] ) return values[last];

    for ( let i = 1; i <= last; i++ ) {
      const t1 = times[i];
      if ( t > t1 ) continue;
      if ( step ) return values[i - 1];
      const t0 = times[i - 1];
      const f = (t - t0) / (t1 - t0);
      return Math.mix(values[i - 1], values[i], f);
    }

    return values[last];
  }

  /* -------------------------------------------- */

  /**
   * Sample a particle value's base range.
   * @param {{range: {min: number, max: number}|null}} value
   * @param {number} fallback
   * @returns {number}
   */
  #sampleParticleValue(value, fallback) {
    const range = value.range;
    if ( !range ) return fallback;
    return Math.mix(range.min, range.max, Math.random());
  }

  /* -------------------------------------------- */

  /**
   * Compute a particle value at the provided lifetime ratio.
   * @param {{range: {min: number, max: number}|null, curve: {times: Float32Array, values: Float32Array}|null,
   *   fn: ParticleGeneratorValueFunction|null, step: boolean}} value
   * @param {ParticleMesh} particle
   * @param {number} baseValue
   * @param {number} normalizedAge
   * @param {number} [dt=0]
   * @returns {number}
   */
  #computeParticleValue(value, particle, baseValue, normalizedAge, dt=0) {
    if ( value.fn ) {
      return value.fn(particle, dt);
    }

    if ( !value.curve ) return baseValue;

    const curveValue = this.#evaluateValueCurve(value.curve, normalizedAge, value.step);
    return value.range ? baseValue * curveValue : curveValue;
  }

  /* -------------------------------------------- */

  /**
   * Compile a particle tint value for spawn-time sampling and update-time evaluation.
   * @param {ParticleGeneratorColorValue|null|undefined} value
   * @returns {{color: number, curve: {times: Float32Array, colors: Uint32Array}|null,
   *   fn: ParticleGeneratorColorFunction|null, dynamic: boolean}}
   */
  #compileColorValue(value) {
    if ( value == null ) return {color: 0xFFFFFF, curve: null, fn: null, dynamic: false};
    const hasOptions = value && (typeof value === "object") && !Array.isArray(value) && !(value instanceof Color)
      && (("fn" in value) || ("curve" in value));
    if ( !hasOptions ) return {color: this.#normalizeColor(value, "tint"), curve: null, fn: null, dynamic: false};
    if ( "fn" in value ) {
      const fn = this.#validateValueFunction(value, "tint", ["curve"], "curve");
      return {color: 0xFFFFFF, curve: null, fn, dynamic: true};
    }
    if ( "curve" in value ) {
      return {color: 0xFFFFFF, curve: this.#compileColorCurve(value.curve), fn: null, dynamic: true};
    }
    throw new Error("ParticleGenerator: tint must be a color source or an object with curve/fn.");
  }

  /* -------------------------------------------- */

  /**
   * Validate and compile color curve points for update-time evaluation.
   * @param {object[]} raw
   * @returns {{times: Float32Array, colors: Uint32Array}}
   */
  #compileColorCurve(raw) {
    const n = this.#validateCurveSource(raw, "tint");
    const times = new Float32Array(n);
    const colors = new Uint32Array(n);
    let previousTime = -Infinity;
    for ( let i = 0; i < n; i++ ) {
      const point = raw[i];
      const time = this.#validateCurvePoint(point, i, n, previousTime, "tint");
      times[i] = time;
      colors[i] = this.#normalizeColor(point.value, `tint.curve[${i}].value`);
      previousTime = time;
    }
    return {times, colors};
  }

  /* -------------------------------------------- */

  /**
   * Evaluate a compiled color curve.
   * @param {{times: Float32Array, colors: Uint32Array}} curve
   * @param {number} t
   * @returns {number}
   */
  #evaluateColorCurve(curve, t) {
    const times = curve.times;
    const colors = curve.colors;
    const last = times.length - 1;
    if ( t <= times[0] ) return colors[0];
    if ( t >= times[last] ) return colors[last];
    for ( let i = 1; i <= last; i++ ) {
      const t1 = times[i];
      if ( t > t1 ) continue;
      const t0 = times[i - 1];
      const f = (t - t0) / (t1 - t0);
      return Color.mix(colors[i - 1], colors[i], f);
    }
    return colors[last];
  }

  /* -------------------------------------------- */

  /**
   * Compute a particle tint at the provided lifetime ratio.
   * @param {{color: number, curve: {times: Float32Array, colors: Uint32Array}|null,
   *   fn: ParticleGeneratorColorFunction|null}} value
   * @param {ParticleMesh} particle
   * @param {number} normalizedAge
   * @param {number} [dt=0]
   * @returns {number}
   */
  #computeColorValue(value, particle, normalizedAge, dt=0) {
    if ( value.fn ) {
      return Math.clamp(Math.round(value.fn(particle, dt)), 0, 0xFFFFFF);
    }
    if ( !value.curve ) return value.color;
    return this.#evaluateColorCurve(value.curve, normalizedAge);
  }

  /* -------------------------------------------- */

  /**
   * Normalize a color source into a 0xRRGGBB color number.
   * @param {any} value
   * @param {string} field
   * @returns {number}
   */
  #normalizeColor(value, field) {
    const color = Color.from(value);
    if ( !color.valid ) {
      throw new Error(`ParticleGenerator: ${field} must be a valid color source.`);
    }
    return color.valueOf();
  }

  /* -------------------------------------------- */

  /**
   * Normalize a particle value's base range.
   * @param {any} value
   * @param {{min: number, max: number}|null} fallback
   * @returns {{min: number, max: number}|null}
   */
  #normalizeValueRange(value, fallback) {
    if ( value == null ) return fallback;
    if ( typeof value === "number" ) return {min: value, max: value};
    if ( Array.isArray(value) && (value.length >= 2) ) return {min: value[0], max: value[1]};
    if ( !value || (typeof value !== "object") ) return fallback;

    const hasMin = "min" in value;
    const hasMax = "max" in value;
    if ( hasMin && hasMax ) return {min: Number(value.min) || 0, max: Number(value.max) || 0};
    if ( hasMin ) {
      const n = Number(value.min) || 0;
      return {min: n, max: n};
    }
    if ( hasMax ) {
      const n = Number(value.max) || 0;
      return {min: n, max: n};
    }
    return fallback;
  }

  /* -------------------------------------------- */

  /**
   * Normalize an input range value to {min,max}.
   * @param {any} value
   * @param {{min: number, max: number}|null} fallback
   * @returns {{min: number, max: number}|null}
   */
  #normalizeRange(value, fallback) {
    if ( value == null ) return fallback;
    if ( typeof value === "number" ) return {min: value, max: value};
    if ( Array.isArray(value) && (value.length >= 2) ) return {min: value[0], max: value[1]};
    return {min: value.min ?? 0, max: value.max ?? value.min ?? 0};
  }

  /* -------------------------------------------- */
  /*  Bounds and Coordinate Helpers               */
  /* -------------------------------------------- */

  /**
   * Normalize a rectangle input to PIXI.Rectangle.
   * @param {any} value
   * @returns {PIXI.Rectangle|null}
   */
  #normalizeRect(value) {
    if ( !value ) return null;
    if ( value instanceof PIXI.Rectangle ) return value.clone();

    if ( (typeof value === "object") && ("x" in value) && ("y" in value) && ("width" in value) && ("height" in value) ) {
      const x = Number(value.x) || 0;
      const y = Number(value.y) || 0;
      const w = Number(value.width) || 0;
      const h = Number(value.height) || 0;
      return new PIXI.Rectangle(x, y, w, h);
    }

    return null;
  }

  /* -------------------------------------------- */

  /**
   * Convert a scene-space rectangle into local coordinates.
   * @param {PIXI.Rectangle} rect
   * @returns {PIXI.Rectangle}
   */
  #rectToLocal(rect) {
    return new PIXI.Rectangle(
      rect.x - this._bounds.x,
      rect.y - this._bounds.y,
      rect.width,
      rect.height
    );
  }

  /* -------------------------------------------- */

  /**
   * Normalize the effective clip configuration.
   * @param {ParticleGeneratorClipOptions|ParticleGeneratorRectangle|boolean} clip
   * @returns {{enabled: boolean|null, rect: PIXI.Rectangle|null}}
   */
  #normalizeClip(clip) {
    if ( typeof clip === "boolean" ) return {enabled: clip, rect: null};
    if ( !clip ) return {enabled: null, rect: null};

    // Shorthand: provide a rectangle directly.
    if ( clip instanceof PIXI.Rectangle ) return {enabled: true, rect: clip.clone()};
    if ( (typeof clip === "object") && !("enabled" in clip) && !("rect" in clip) ) {
      const rect = this.#normalizeRect(clip);
      if ( rect ) return {enabled: true, rect};
    }

    const enabled = clip.enabled ?? null;
    const rect = this.#normalizeRect(clip.rect);
    return {enabled, rect};
  }

  /* -------------------------------------------- */

  /**
   * Normalize blur options.
   * @param {number|{enabled?: boolean, intensity: number, quality?: number}|
   *   {intensity: number, quality?: number}|null|undefined} blur
   * @returns {{enabled: boolean, intensity: number, quality: number|undefined}|null}
   */
  #normalizeBlur(blur) {
    if ( blur == null ) return null;

    if ( typeof blur === "number" ) {
      const intensity = Number(blur) || 0;
      if ( intensity <= 0 ) return null;
      return {enabled: true, intensity, quality: undefined};
    }

    if ( typeof blur === "object" ) {
      if ( blur.enabled === false ) return null;
      const intensity = Number(blur.intensity ?? 0) || 0;
      if ( intensity <= 0 ) return null;
      return {enabled: true, intensity, quality: blur.quality};
    }

    return null;
  }

  /* -------------------------------------------- */
  /*  Shaders                                     */
  /* -------------------------------------------- */

  /**
   * Validate the effective particle shader class.
   * @param {typeof BaseSamplerShader|null|undefined} shaderClass
   * @returns {typeof BaseSamplerShader}
   */
  #validateShaderClass(shaderClass) {
    shaderClass ??= BaseSamplerShader;
    if ( !foundry.utils.isSubclass(shaderClass, BaseSamplerShader) ) {
      throw new Error("ParticleGenerator shaderClass must inherit from BaseSamplerShader.");
    }

    if ( foundry.utils.isSubclass(shaderClass, OccludableSamplerShader)
      || foundry.utils.isSubclass(shaderClass, DepthSamplerShader) ) {
      throw new Error("ParticleGenerator shaderClass must be compatible with SpriteMesh."
        + " Shaders that require occlusion or depth data are not supported.");
    }

    const pluginName = shaderClass.classPluginName;
    if ( pluginName && !BatchRenderer.hasPlugin(pluginName) ) {
      throw new Error(`ParticleGenerator shaderClass ${shaderClass.name} requires the "${pluginName}" batch plugin,`
        + " but it has not been registered.");
    }

    return shaderClass;
  }

  /* -------------------------------------------- */
  /*  Behaviors                                   */
  /* -------------------------------------------- */

  /**
   * Normalize orbit behavior options.
   * @param {ParticleGeneratorOrbitOptions|undefined|null} cfg
   * @returns {ParticleGeneratorOrbitOptions}
   */
  #normalizeOrbit(cfg) {
    const o = cfg ?? {};

    const radius = (o.radius == null) ? null : this.#normalizeRange(o.radius, null);
    const angularSpeed = this.#normalizeRange(o.angularSpeed ?? 120, {min: 120, max: 120}) ?? {min: 120, max: 120};
    const phase = this.#normalizeRange(o.phase ?? [0, 360], {min: 0, max: 360}) ?? {min: 0, max: 360};
    const radialSpeed = this.#normalizeRange(o.radialSpeed ?? 0, {min: 0, max: 0}) ?? {min: 0, max: 0};

    const direction = ([1, -1, "random"].includes(o.direction)) ? o.direction : 1;
    const rotation = (["none", "tangent", "radial"].includes(o.rotation)) ? o.rotation : "none";

    return {radius, angularSpeed, phase, radialSpeed, direction, rotation};
  }

  /* -------------------------------------------- */

  /**
   * Normalize follow behavior options.
   * @param {ParticleGeneratorFollowOptions|undefined|null} cfg
   * @returns {ParticleGeneratorFollowOptions}
   */
  #normalizeFollow(cfg) {
    const f = cfg ?? {};
    const offset = this.#normalizePoint(f.offset);
    const stiffness = Math.clamp(Number(f.stiffness ?? 1) || 0, 0, 1);
    return {offset, stiffness};
  }

  /* -------------------------------------------- */

  /**
   * Normalize a behavior definition.
   * @param {ParticleGeneratorBehaviorId|ParticleGeneratorBehavior|null|undefined} behavior
   * @returns {ParticleGeneratorBehavior|null}
   */
  #normalizeBehavior(behavior) {
    if ( !behavior || (behavior === "default") ) return null;

    if ( typeof behavior === "object" ) return behavior;

    if ( behavior === "orbit" ) {
      return {
        spawn: this.#orbitSpawn.bind(this),
        update: this.#orbitUpdate.bind(this)
      };
    }

    if ( behavior === "follow" ) {
      return {
        spawn: this.#followSpawn.bind(this),
        update: this.#followUpdate.bind(this)
      };
    }

    return null;
  }

  /* -------------------------------------------- */

  /**
   * Orbit behavior spawn hook.
   * @param {ParticleMesh} particle
   * @param {object} ctx
   */
  #orbitSpawn(particle, ctx) {
    const anchor = ctx.anchor?.local;
    if ( !anchor ) return;

    const orbit = this.orbit;

    let angle = 0;
    let radius = 0;

    if ( orbit.radius ) {
      radius = Math.max(0, Math.mix(orbit.radius.min, orbit.radius.max, Math.random()));
      angle = Math.toRadians(Math.mix(orbit.phase.min, orbit.phase.max, Math.random()));
    }
    else {
      const dx = particle.x - anchor.x;
      const dy = particle.y - anchor.y;
      radius = Math.hypot(dx, dy);
      angle = (radius > 0) ? Math.atan2(dy, dx) : (Math.random() * (Math.PI * 2));
    }

    const wDeg = Math.mix(orbit.angularSpeed.min, orbit.angularSpeed.max, Math.random());

    let dir = orbit.direction;
    if ( dir === "random" ) dir = (Math.random() < 0.5) ? -1 : 1;
    if ( (dir !== 1) && (dir !== -1) ) dir = 1;

    const omega = Math.toRadians(wDeg) * dir;
    const radialSpeed = Math.mix(orbit.radialSpeed.min, orbit.radialSpeed.max, Math.random());

    particle._orbitAngle = angle;
    particle._orbitRadius = radius;
    particle._orbitOmega = omega;
    particle._orbitRadialSpeed = radialSpeed;
    particle._orbitRotation = orbit.rotation;

    particle.x = anchor.x + (Math.cos(angle) * radius);
    particle.y = anchor.y + (Math.sin(angle) * radius);

    if ( orbit.rotation === "tangent" ) {
      const s = (omega < 0) ? -1 : 1;
      particle.rotation = angle + (s * (Math.PI * 0.5));
    }
    else if ( orbit.rotation === "radial" ) particle.rotation = angle;
  }

  /* -------------------------------------------- */

  /**
   * Orbit behavior update hook.
   * @param {ParticleMesh} particle
   * @param {number} dt
   * @param {object} ctx
   * @returns {boolean}
   */
  #orbitUpdate(particle, dt, ctx) {
    const anchor = ctx.anchor?.local;
    if ( !anchor ) return false;

    const ds = Number(ctx.ds ?? (dt * 0.001)) || 0;

    let angle = Number(particle._orbitAngle ?? 0) || 0;
    let radius = Math.max(0, Number(particle._orbitRadius ?? 0) || 0);
    const omega = Number(particle._orbitOmega ?? 0) || 0;
    const radialSpeed = Number(particle._orbitRadialSpeed ?? 0) || 0;

    angle += omega * ds;
    radius = Math.max(0, radius + (radialSpeed * ds));

    particle._orbitAngle = angle;
    particle._orbitRadius = radius;

    particle.x = anchor.x + (Math.cos(angle) * radius);
    particle.y = anchor.y + (Math.sin(angle) * radius);

    const rot = particle._orbitRotation;
    if ( rot === "tangent" ) {
      const s = (omega < 0) ? -1 : 1;
      particle.rotation = angle + (s * (Math.PI * 0.5));
    }
    else if ( rot === "radial" ) particle.rotation = angle;

    return true;
  }

  /* -------------------------------------------- */

  /**
   * Follow behavior spawn hook.
   * @param {ParticleMesh} particle
   * @param {object} ctx
   */
  #followSpawn(particle, ctx) {
    const anchor = ctx.anchor?.local;
    if ( !anchor ) return;

    const follow = this.follow;

    let ox = 0;
    let oy = 0;

    if ( follow.offset ) {
      ox = follow.offset.x;
      oy = follow.offset.y;
    }
    else {
      ox = particle.x - anchor.x;
      oy = particle.y - anchor.y;
    }

    particle._followOffsetX = ox;
    particle._followOffsetY = oy;
  }

  /* -------------------------------------------- */

  /**
   * Follow behavior update hook.
   * @param {ParticleMesh} particle
   * @param {number} dt
   * @param {object} ctx
   * @returns {boolean}
   */
  #followUpdate(particle, dt, ctx) {
    const anchor = ctx.anchor?.local;
    if ( !anchor ) return false;

    const ox = Number(particle._followOffsetX ?? 0) || 0;
    const oy = Number(particle._followOffsetY ?? 0) || 0;

    const tx = anchor.x + ox;
    const ty = anchor.y + oy;

    const k = this.follow.stiffness ?? 1;

    if ( k >= 1 ) {
      particle.x = tx;
      particle.y = ty;
    }
    else if ( k > 0 ) {
      particle.x = Math.mix(particle.x, tx, k);
      particle.y = Math.mix(particle.y, ty, k);
    }

    return true;
  }

  /* -------------------------------------------- */

  /**
   * Update the cached anchor points.
   * @param {object} [options]
   * @param {boolean} [options.fallbackToBoundsCenter=false] Use the bounds center when no anchor is configured?
   * @returns {boolean} True if an anchor point was resolved.
   */
  #updateAnchor({fallbackToBoundsCenter=false}={}) {
    const ctxAnchor = this._behaviorContext?.anchor;
    let p = null;
    const anchor = this.anchor;

    if ( anchor ) {
      if ( typeof anchor === "function" ) p = anchor();
      else {
        const mode = this.anchorPoint ?? "center";
        if ( typeof mode === "function" ) p = mode(anchor);
        else if ( mode === "position" ) p = anchor.position ?? anchor;
        else p = anchor.center ?? anchor.position ?? anchor;
      }
    }
    else if ( fallbackToBoundsCenter ) {
      this._anchorScene.set(
        this._bounds.x + (this._bounds.width * 0.5),
        this._bounds.y + (this._bounds.height * 0.5)
      );
      this._anchorLocal.set(this._anchorScene.x - this._bounds.x, this._anchorScene.y - this._bounds.y);
      if ( ctxAnchor ) ctxAnchor.active = true;
      return true;
    }
    else {
      if ( ctxAnchor ) ctxAnchor.active = false;
      return false;
    }

    const pt = this.#normalizePoint(p);
    if ( !pt ) {
      if ( ctxAnchor ) ctxAnchor.active = false;
      return false;
    }

    let x = pt.x;
    let y = pt.y;

    const offset = this.anchorOffset;
    if ( offset ) {
      x += offset.x;
      y += offset.y;
    }

    this._anchorScene.set(x, y);
    this._anchorLocal.set(x - this._bounds.x, y - this._bounds.y);
    if ( ctxAnchor ) ctxAnchor.active = true;
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Compute the spawn area to use for one spawn request.
   * @param {ParticleGeneratorArea|null} area
   * @param {PIXI.Point|{x: number, y: number}|null} position
   * @param {ParticleGeneratorAreaSampleMode} [sampleMode]
   * @returns {ParticleGeneratorLocalArea|null}
   */
  #computeSpawnAreaLocal(area, position, sampleMode=this.sampleMode) {
    if ( position ) {
      const p = this.#normalizePoint(position);
      if ( !p ) return null;
      return {type: "point", x: p.x - this._bounds.x, y: p.y - this._bounds.y};
    }

    const hasAnchor = this.#updateAnchor();
    const anchorScene = hasAnchor ? this._anchorScene : null;
    const local = this.#computeAreaLocal(area, anchorScene, sampleMode);
    if ( local ) return local;
    if ( this.#isExplicitArea(area) ) return null;

    if ( this.mode === "ambient" ) {
      if ( (this._budgetRectLocal.width <= 0) || (this._budgetRectLocal.height <= 0) ) return null;
      return {type: "ambientRect", rect: this._budgetRectLocal};
    }

    const configured = this.#computeAreaLocal(this.spawnArea, anchorScene, sampleMode);
    if ( configured ) return configured;
    if ( this.#isExplicitArea(this.spawnArea) ) return null;

    if ( hasAnchor ) return {type: "point", x: this._anchorLocal.x, y: this._anchorLocal.y};

    // Default: a point at the center of the generator bounds.
    return {type: "point", x: this._bounds.width * 0.5, y: this._bounds.height * 0.5};
  }

  /* -------------------------------------------- */

  /**
   * Convert a spawn area definition into a local spawn area.
   * Managed object areas are resolved against the anchor before they are converted to shape data.
   * @param {ParticleGeneratorArea|null} area
   * @param {PIXI.Point|{x: number, y: number}|null} [anchorScene=null]
   * @param {ParticleGeneratorAreaSampleMode} [sampleMode]
   * @returns {ParticleGeneratorLocalArea|null}
   */
  #computeAreaLocal(area, anchorScene=null, sampleMode=this.sampleMode) {
    if ( !area ) return null;
    sampleMode = sampleMode === "boundary" ? "boundary" : "interior";

    if ( area instanceof foundry.data.BaseShapeData ) {
      return this.#shapeDataAreaLocal(area, sampleMode, {checkScene: true});
    }

    if ( area instanceof foundry.data.PolygonTree ) {
      if ( area.isEmpty ) return null;
      return {type: "polygonTree", data: area, sampleMode};
    }

    if ( area instanceof PIXI.Rectangle ) {
      if ( (area.width <= 0) || (area.height <= 0) ) return null;
      const data = this.#createShapeData("rectangle", {
        x: area.x,
        y: area.y,
        width: area.width,
        height: area.height
      });
      return this.#shapeDataAreaLocal(data, sampleMode);
    }

    if ( typeof area !== "object" ) return null;

    if ( foundry.utils.isPlainObject(area) && Object.hasOwn(area, "type") ) {
      const data = this.#createShapeData(area.type, area, {parent: canvas.scene});
      return this.#shapeDataAreaLocal(data, sampleMode);
    }

    const hasManagedShape = foundry.utils.isPlainObject(area) && (typeof area.shape === "string");
    const managedArea = this.#computeManagedAreaLocal(area, anchorScene, sampleMode);
    if ( managedArea ) return managedArea;
    if ( hasManagedShape && !["point", "rect", "circle", "ring", "line"].includes(area.shape) ) return null;

    // Rectangles map cleanly to RectangleShapeData once the anchor has been applied.
    if ( ("width" in area) && ("height" in area) ) {
      if ( !anchorScene && (!(("x" in area) && ("y" in area))) ) return null;
      const width = Number(area.width) || 0;
      const height = Number(area.height) || 0;
      if ( (width <= 0) || (height <= 0) ) return null;
      const data = this.#createShapeData("rectangle", {
        x: (anchorScene ? anchorScene.x : 0) + (Number(area.x) || 0),
        y: (anchorScene ? anchorScene.y : 0) + (Number(area.y) || 0),
        width,
        height
      });
      return this.#shapeDataAreaLocal(data, sampleMode);
    }

    // The managed line shortcut samples a centerline, not the thick LineShapeData contour.
    const a = this.#normalizePoint(area.from ?? area.a);
    const b = this.#normalizePoint(area.to ?? area.b);
    if ( a && b ) {
      const ax = anchorScene ? (anchorScene.x + a.x) : a.x;
      const ay = anchorScene ? (anchorScene.y + a.y) : a.y;
      const bx = anchorScene ? (anchorScene.x + b.x) : b.x;
      const by = anchorScene ? (anchorScene.y + b.y) : b.y;
      return {
        type: "line",
        ax: ax - this._bounds.x,
        ay: ay - this._bounds.y,
        bx: bx - this._bounds.x,
        by: by - this._bounds.y
      };
    }

    let x;
    let y;
    if ( anchorScene ) {
      x = anchorScene.x + (Number(area.x) || 0);
      y = anchorScene.y + (Number(area.y) || 0);
    } else {
      const center = this.#normalizePoint(area);
      if ( !center ) return null;
      ({x, y} = center);
    }

    // Ring shortcut: {innerRadius, outerRadius} or {radius: [inner, outer]}.
    if ( ("innerRadius" in area) || ("outerRadius" in area) || Array.isArray(area.radius) ) {
      let innerRadius;
      let outerRadius;
      if ( Array.isArray(area.radius) ) {
        innerRadius = Number(area.radius[0]) || 0;
        outerRadius = Number(area.radius[1]) || 0;
      }
      else {
        innerRadius = Number(area.innerRadius ?? 0) || 0;
        outerRadius = Number(area.outerRadius ?? 0) || 0;
      }

      innerRadius = Math.max(0, innerRadius);
      outerRadius = Math.max(innerRadius, outerRadius);
      if ( outerRadius <= innerRadius ) {
        if ( outerRadius <= 0 ) return {type: "point", x: x - this._bounds.x, y: y - this._bounds.y};
        const data = this.#createShapeData("circle", {x, y, radius: outerRadius});
        return this.#shapeDataAreaLocal(data, "boundary");
      }

      const radius = (innerRadius + outerRadius) / 2;
      const data = this.#createShapeData("ring", {
        x,
        y,
        radius,
        innerWidth: radius - innerRadius,
        outerWidth: outerRadius - radius
      });
      return this.#shapeDataAreaLocal(data, sampleMode);
    }

    if ( "radius" in area ) {
      const radius = Math.max(0, Number(area.radius) || 0);
      if ( radius <= 0 ) return {type: "point", x: x - this._bounds.x, y: y - this._bounds.y};
      const data = this.#createShapeData("circle", {x, y, radius});
      return this.#shapeDataAreaLocal(data, sampleMode);
    }

    if ( ("x" in area) && ("y" in area) ) return {type: "point", x: x - this._bounds.x, y: y - this._bounds.y};
    if ( anchorScene ) return {type: "point", x: x - this._bounds.x, y: y - this._bounds.y};
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Convert a ParticleGenerator-managed spawn shape definition into a local spawn area.
   * @param {object} area
   * @param {PIXI.Point|{x: number, y: number}|null} [anchorScene=null]
   * @param {ParticleGeneratorAreaSampleMode} [sampleMode]
   * @returns {ParticleGeneratorLocalArea|null}
   */
  #computeManagedAreaLocal(area, anchorScene=null, sampleMode=this.sampleMode) {
    const shape = typeof area.shape === "string" ? area.shape : "";

    if ( (shape === "points") || (!shape && Array.isArray(area.points)) ) {
      const points = area.points;
      if ( !Array.isArray(points) ) return null;
      const localPoints = [];
      for ( const point of points ) {
        const p = this.#normalizeAreaPointLocal(point, anchorScene);
        if ( p ) localPoints.push(p);
      }
      return localPoints.length ? {type: "points", points: localPoints} : null;
    }

    if ( (shape === "path") || (shape === "polyline") || (!shape && Array.isArray(area.path)) ) {
      return this.#computePolylineAreaLocal(area.path ?? area.points, anchorScene);
    }

    if ( (shape === "ellipse") || (!shape && (("radiusX" in area) || ("radiusY" in area))) ) {
      return this.#computeEllipseAreaLocal(area, anchorScene, sampleMode);
    }

    return null;
  }

  /* -------------------------------------------- */

  /**
   * Convert a polyline definition into a local area.
   * @param {ParticleGeneratorPolylineDefinition} path
   * @param {PIXI.Point|{x: number, y: number}|null} [anchorScene=null]
   * @returns {ParticleGeneratorLocalArea|null}
   */
  #computePolylineAreaLocal(path, anchorScene=null) {
    if ( !Array.isArray(path) || !path.length ) return null;

    const segments = [];
    const cumulativeLengths = [];
    let fallbackPoint = null;
    let totalLength = 0;

    const addPath = points => {
      let last = null;
      for ( const point of points ) {
        const p = this.#normalizeAreaPointLocal(point, anchorScene);
        if ( !p ) continue;

        fallbackPoint ??= {x: p.x, y: p.y};
        if ( last ) {
          const dx = p.x - last.x;
          const dy = p.y - last.y;
          const length = Math.hypot(dx, dy);
          if ( length > 0 ) {
            totalLength += length;
            segments.push({ax: last.x, ay: last.y, bx: p.x, by: p.y, length});
            cumulativeLengths.push(totalLength);
          }
        }

        last = p;
      }
    };

    if ( this.#normalizePoint(path[0]) ) addPath(path);
    else {
      for ( const points of path ) {
        if ( Array.isArray(points) ) addPath(points);
      }
    }

    if ( !segments.length && !fallbackPoint ) return null;
    return {type: "polyline", segments, cumulativeLengths, totalLength, fallbackPoint};
  }

  /* -------------------------------------------- */

  /**
   * Normalize a point-like value into generator local coordinates.
   * @param {any} value
   * @param {PIXI.Point|{x: number, y: number}|null} [anchorScene=null]
   * @returns {{x: number, y: number}|null}
   */
  #normalizeAreaPointLocal(value, anchorScene=null) {
    const point = this.#normalizePoint(value);
    if ( !point ) return null;
    const x = anchorScene ? (anchorScene.x + point.x) : point.x;
    const y = anchorScene ? (anchorScene.y + point.y) : point.y;
    return {x: x - this._bounds.x, y: y - this._bounds.y};
  }

  /* -------------------------------------------- */

  /**
   * Convert a ParticleGenerator-managed ellipse definition into a local area.
   * Simple ellipses are delegated to BaseShapeData. Extended ellipse options are sampled by ParticleGenerator.
   * @param {object} area
   * @param {PIXI.Point|{x: number, y: number}|null} [anchorScene=null]
   * @param {ParticleGeneratorAreaSampleMode} [sampleMode]
   * @returns {ParticleGeneratorLocalArea|null}
   */
  #computeEllipseAreaLocal(area, anchorScene=null, sampleMode=this.sampleMode) {
    const centerScene = anchorScene ? {
      x: anchorScene.x + (Number(area.x) || 0),
      y: anchorScene.y + (Number(area.y) || 0)
    } : this.#normalizePoint(area);
    if ( !centerScene ) return null;

    const radiusX = Math.max(0, Number(area.radiusX) || 0);
    const radiusY = Math.max(0, Number(area.radiusY) || 0);
    if ( (radiusX <= 0) || (radiusY <= 0) ) return null;

    const rawMinAngle = Number(area.minAngle ?? 0);
    const rawMaxAngle = Number(area.maxAngle ?? 360);
    const minAngle = Number.isFinite(rawMinAngle) ? rawMinAngle : 0;
    const maxAngle = Number.isFinite(rawMaxAngle) ? rawMaxAngle : 360;
    const shapeRotation = Math.toRadians(Number(area.shapeRotation ?? area.rotation ?? 0) || 0);
    const holeScale = Math.clamp(Number(area.holeScale ?? 0) || 0, 0, 1);

    if ( (holeScale === 0) && (minAngle === 0) && (maxAngle === 360) && !area.affectRotation ) {
      const data = this.#createShapeData("ellipse", {
        x: centerScene.x,
        y: centerScene.y,
        radiusX,
        radiusY,
        rotation: Math.toDegrees(shapeRotation)
      });
      return this.#shapeDataAreaLocal(data, sampleMode);
    }

    return {
      type: "ellipse",
      x: centerScene.x - this._bounds.x,
      y: centerScene.y - this._bounds.y,
      radiusX,
      radiusY,
      minAngle: Math.toRadians(minAngle),
      maxAngle: Math.toRadians(maxAngle),
      shapeRotation,
      affectRotation: !!area.affectRotation,
      cosRotation: Math.cos(shapeRotation),
      sinRotation: Math.sin(shapeRotation),
      holeAreaScale: holeScale * holeScale
    };
  }

  /* -------------------------------------------- */

  /**
   * Is an area an explicit absolute shape/rectangle input?
   * @param {any} area
   * @returns {boolean}
   */
  #isExplicitArea(area) {
    if ( !area ) return false;
    return (area instanceof PIXI.Rectangle) || (area instanceof foundry.data.PolygonTree)
      || (area instanceof foundry.data.BaseShapeData) || (foundry.utils.isPlainObject(area)
        && (Object.hasOwn(area, "type") || Object.hasOwn(area, "shape")));
  }

  /* -------------------------------------------- */

  /**
   * Create a shape data instance for a ParticleGenerator area.
   * @param {string} type
   * @param {object} source
   * @param {object} [options]
   * @param {foundry.abstract.DataModel} [options.parent]
   * @returns {foundry.data.BaseShapeData}
   */
  #createShapeData(type, source, {parent}={}) {
    const ShapeData = foundry.data.BaseShapeData.TYPES[type];
    if ( !ShapeData ) throw new Error(`Invalid ParticleGenerator shape data type "${type}".`);
    const data = Object.hasOwn(source, "type") ? source : {...source, type};
    return new ShapeData(data, parent ? {parent} : {});
  }

  /* -------------------------------------------- */

  /**
   * Create a local area from shape data.
   * @param {foundry.data.BaseShapeData} data
   * @param {ParticleGeneratorAreaSampleMode} sampleMode
   * @param {object} [options]
   * @param {boolean} [options.checkScene=false]
   * @returns {ParticleGeneratorLocalArea|null}
   */
  #shapeDataAreaLocal(data, sampleMode, {checkScene=false}={}) {
    if ( data.isEmpty ) return null;
    if ( checkScene && data.scene && (data.scene !== canvas.scene) ) return null;
    return {type: "shape", data, sampleMode};
  }

  /* -------------------------------------------- */

  /**
   * Normalize a point-like value.
   * @param {any} value
   * @returns {{x: number, y: number}|null}
   */
  #normalizePoint(value) {
    if ( !value ) return null;
    if ( value instanceof PIXI.Point ) return {x: value.x, y: value.y};
    if ( (typeof value === "object") && ("x" in value) && ("y" in value) ) {
      return {x: Number(value.x) || 0, y: Number(value.y) || 0};
    }
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Get the active constraint rectangle in local coordinates.
   * @returns {PIXI.Rectangle|null}
   */
  #getConstraintRectLocal() {
    const area = this.constraints.area;

    if ( area === "budget" ) return this._budgetRectLocal;
    if ( area === "view" ) return this._viewRectLocal;
    if ( area === "world" ) return this._worldRectLocal;
    if ( this._constraintRect ) return this._constraintRect;
    return null;
  }

  /* -------------------------------------------- */
  /*  Default Bounds                              */
  /* -------------------------------------------- */

  /**
   * Get default bounds from the current scene dimensions.
   * @returns {PIXI.Rectangle}
   * @protected
   */
  _getDefaultBounds() {
    const d = canvas.scene.dimensions;
    return new PIXI.Rectangle(d.sceneX, d.sceneY, d.sceneWidth, d.sceneHeight);
  }

  /* -------------------------------------------- */
  /*  Debug Helpers                               */
  /* -------------------------------------------- */

  /**
   * Normalize a debug tint palette.
   * @param {any} palette
   * @returns {number[]}
   */
  #normalizeDebugPalette(palette) {
    if ( !Array.isArray(palette) ) return [];
    const out = [];
    for ( const c of palette ) {
      const n = Number(c);
      if ( !Number.isFinite(n) ) continue;
      out.push(Math.clamp(Math.floor(n), 0, 0xFFFFFF));
    }
    return out;
  }

  /* -------------------------------------------- */

  /**
   * Create a debug stats object.
   * @returns {ParticleGeneratorDebugStats}
   */
  #createDebugStats() {
    return {
      active: 0,
      pool: 0,
      target: 0,
      spawnAttempts: 0,
      spawned: 0,
      spawnRejectedProbability: 0,
      spawnRejectedPositionTest: 0,
      spawnRejectedNoArea: 0,
      recycledLifetime: 0,
      recycledConstraint: 0,
      recycledStop: 0,
      newlyVisibleAreaCount: 0,
      updateMS: 0,
      spawnMS: 0,
      tickMS: 0
    };
  }

  /* -------------------------------------------- */

  /**
   * Build a lookup table for deterministic debug tinting.
   * @param {number[]} palette
   * @returns {WeakMap<PIXI.Texture, number>}
   */
  #buildDebugTintByTextureMap(palette) {
    const textures = this.textures;
    const usePalette = Array.isArray(palette) && (palette.length > 0);
    const map = new WeakMap();

    for ( let i = 0, n = textures.length; i < n; i++ ) {
      const t = textures[i];
      const tint = usePalette ? palette[i % palette.length] : ParticleGenerator.#debugColorForIndex(i);
      map.set(t, tint);
    }

    return map;
  }

  /* -------------------------------------------- */

  /**
   * Apply debug tinting to a particle.
   * @param {ParticleMesh} particle
   * @param {PIXI.Texture} texture
   */
  #applyDebugTint(particle, texture) {
    const cfg = this._debugTint;
    if ( !cfg ) return;

    const mode = cfg.mode;
    const palette = cfg.palette;

    let tint = 0xFFFFFF;

    if ( mode === "random" ) tint = (Math.random() * 0xFFFFFF) | 0;
    else if ( mode === "palette" ) {
      tint = palette.length
        ? palette[(Math.random() * palette.length) | 0]
        : ((Math.random() * 0xFFFFFF) | 0);
    }
    else if ( mode === "byTexture" ) {
      tint = this._debugTintByTexture?.get(texture) ?? 0xFFFFFF;
    }

    particle.tint = tint;
  }

  /**
   * Compute the average particle lifetime from a raw lifetime config value.
   * Used at construction time for steady-state budget validation.
   * @param {number|number[]|{min: number, max: number}} v
   * @returns {number}
   */
  static #computeAvgLifetime(v) {
    if ( typeof v === "number" ) return Math.max(0, v);
    if ( Array.isArray(v) && (v.length >= 2) ) return Math.max(0, ((Number(v[0]) || 0) + (Number(v[1]) || 0)) / 2);
    const min = Number(v?.min ?? 0) || 0;
    const max = Number(v?.max ?? min) || min;
    return Math.max(0, (min + max) / 2);
  }

  /* -------------------------------------------- */

  /**
   * Generate a deterministic, reasonably bright debug color for an index
   * @param {number} i
   * @returns {number}
   */
  static #debugColorForIndex(i) {
    // Use a multiplicative hash and bias toward brighter values
    const x = Math.imul((i + 1), 0x9E3779B1) >>> 0;
    return (((x >>> 8) & 0xFFFFFF) | 0x404040) >>> 0;
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  get maxParticlesPerFrame() {
    foundry.utils.logCompatibilityWarning("ParticleGenerator#maxParticlesPerFrame is deprecated."
      + " Use ParticleGenerator#spawnRate instead.", {since: 14, until: 16, once: true});
    return this.#maxParticlesPerFrame;
  }

  /**
   * @deprecated since v14
   * @ignore
   */
  set maxParticlesPerFrame(value) {
    foundry.utils.logCompatibilityWarning("ParticleGenerator#maxParticlesPerFrame is deprecated."
      + " Use ParticleGenerator#spawnRate instead.", {since: 14, until: 16, once: true});
    const targetFPS = PIXI.Ticker.targetFPMS * 1000;
    const maxParticlesPerFrame = Math.max(0, Number(value) || 0);
    this.#maxParticlesPerFrame = maxParticlesPerFrame;
    this.#spawnRate = maxParticlesPerFrame * targetFPS;
  }

  /**
   * @deprecated since v14
   * @ignore
   */
  get alphaRange() {
    foundry.utils.logCompatibilityWarning("ParticleGenerator#alphaRange is deprecated."
      + " Use ParticleGeneratorConfiguration#alpha instead.", {since: 14, until: 16, once: true});
    return this.#alphaValue?.range ?? this.#alphaRange;
  }

  /**
   * @deprecated since v14
   * @ignore
   */
  set alphaRange(value) {
    foundry.utils.logCompatibilityWarning("ParticleGenerator#alphaRange is deprecated."
      + " Use ParticleGeneratorConfiguration#alpha instead.", {since: 14, until: 16, once: true});
    const range = this.#normalizeValueRange(value, {min: 1, max: 1}) ?? {min: 1, max: 1};
    this.#alphaRange = range;
    if ( this.#alphaValue ) this.#alphaValue.range = range;
  }

  /**
   * @deprecated since v14
   * @ignore
   */
  get scaleRange() {
    foundry.utils.logCompatibilityWarning("ParticleGenerator#scaleRange is deprecated."
      + " Use ParticleGeneratorConfiguration#scale instead.", {since: 14, until: 16, once: true});
    return this.#scaleValue?.range ?? this.#scaleRange;
  }

  /**
   * @deprecated since v14
   * @ignore
   */
  set scaleRange(value) {
    foundry.utils.logCompatibilityWarning("ParticleGenerator#scaleRange is deprecated."
      + " Use ParticleGeneratorConfiguration#scale instead.", {since: 14, until: 16, once: true});
    const range = this.#normalizeValueRange(value, {min: 1, max: 1}) ?? {min: 1, max: 1};
    this.#scaleRange = range;
    if ( this.#scaleValue ) this.#scaleValue.range = range;
  }
}

var _module$t = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CanvasAnimation: CanvasAnimation,
  CanvasShakeEffect: CanvasShakeEffect,
  ChatBubbles: ChatBubbles,
  ParticleGenerator: ParticleGenerator,
  SmoothNoise: SmoothNoise,
  types: _types$9
});

/**
 * The dash line thickness.
 * @type {number}
 */
let dashLineThickness;

/**
 * The inner dash line shader.
 * @type {DashLineShader}
 */
let innerDashLineShader;

/**
 * The outer dash line shader.
 * @type {DashLineShader}
 */
let outerDashLineShader;

/**
 * Draw a border.
 * @param {PIXI.smooth.SmoothGraphics} graphics  The graphics to draw the shape into.
 * @param {PIXI.Rectangle|PIXI.RoundedRectangle|PIXI.Circle|PIXI.Ellipse|PIXI.Polygon
 *   |(graphics: PIXI.smooth.SmoothGraphics) => void} shape  The shape to draw or a draw callback.
 * @param {object} [options]                  Additional options.
 * @param {PIXI.ColorSource} [options.color]  The border color. Default: `0xFFFFFF`.
 * @param {boolean} [options.dashed]          Dashed border? Default: `false`.
 * @param {number} [options.alignment]        The alignment of the outline. Default: `0.5`.
 * @param {boolean} [options.clear]           Clear the graphics before drawing the border? Default: `0.5`.
 */
function drawBorder(graphics, shape, {color=0xFFFFFF, dashed=false, alignment=0.5, clear=true}={}) {
  const thickness = CONFIG.Canvas.objectBorderThickness * canvas.dimensions.uiScale;
  if ( dashLineThickness !== thickness ) innerDashLineShader = outerDashLineShader = undefined;
  const lineWidth = thickness / 2;
  const outlineThickness = thickness / 4;
  const outerLineStyle = {width: lineWidth + (2 * outlineThickness), color: 0x000000, join: PIXI.LINE_JOIN.ROUND,
    alignment: ((alignment - 0.5) * 0.5) + 0.5};
  const innerLineStyle = {width: lineWidth, color, join: PIXI.LINE_JOIN.ROUND, alignment};
  if ( dashed ) {
    const innerDash = thickness * 2;
    const innerGap = innerDash * (5 / 8);
    const innerOffset = innerGap;
    innerLineStyle.shader = innerDashLineShader ??= new DashLineShader(
      {dash: innerDash, gap: innerGap, offset: innerOffset});
    const outerDash = innerDash + (2 * outlineThickness);
    const outerGap = Math.max(innerGap - (2 * outlineThickness), 0);
    const outerOffset = innerGap - outlineThickness;
    outerLineStyle.shader = outerDashLineShader ??= new DashLineShader(
      {dash: outerDash, gap: outerGap, offset: outerOffset});
  }
  if ( clear ) graphics.clear();
  let drawShape;
  if ( [PIXI.Rectangle, PIXI.RoundedRectangle, PIXI.Circle, PIXI.Ellipse, PIXI.Polygon].some(
    shapeClass => shape instanceof shapeClass) ) {
    drawShape = g => g.drawShape(shape);
  }
  else drawShape = shape;
  graphics.lineStyle(outerLineStyle);
  drawShape(graphics);
  graphics.lineStyle(innerLineStyle);
  drawShape(graphics);
}

var borders = /*#__PURE__*/Object.freeze({
  __proto__: null,
  drawBorder: drawBorder
});

/**
 * @import {Point, ClipperPoint} from "@client/_types.mjs";
 */

/**
 * 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);
  };
}

/**
 * @import {Point, ClipperPoint} from "@client/_types.mjs";
 */

/**
 * 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);
  };

  /* -------------------------------------------- */

  /**
   * 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);
  };
}

/**
 * @import {Point, ClipperPoint} from "@client/_types.mjs";
 */

/**
 * 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 {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);
  }
}

/**
 * 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;
  }
}

/**
 * A minimalist filter (just used for blending)
 */
class VoidFilter extends AbstractBaseFilter {
  /** @override */
  static _createFragmentShader() {
    return `
    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();
  }
}

/**
 * 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.#configureFilterResolution(this.filter);
    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();
  }

  /* -------------------------------------------- */

  /**
   * Configure the filter resolution based on the performance mode.
   * @param {PIXI.Filter} filter
   */
  #configureFilterResolution(filter) {
    if ( canvas.performance.mode === CONST.CANVAS_PERFORMANCE_MODES.MAX ) {
      filter.resolution = null;
      return;
    }
    Object.defineProperty(filter, "resolution", {
      get() {
        let resolution;
        const renderer = canvas.app.renderer;
        const renderTextureSystem = renderer.renderTexture;
        if ( renderTextureSystem.current ) resolution = renderTextureSystem.current.resolution;
        else resolution = renderer.resolution;
        return resolution * 0.5;
      },
      set(value) {},
      configurable: true
    });
  }
}

/**
 * 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];
  }
}

/**
 * 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});
  }

  /* -------------------------------------------- */
  /*  Edge Management                             */
  /* -------------------------------------------- */

  /** @override */
  _getEdgeCreationOptions() {
    return {
      type: "source",
      object: this.object,
      direction: CONST.EDGE_DIRECTIONS.LEFT,
      light: CONST.EDGE_SENSE_TYPES.NORMAL,
      sight: CONST.EDGE_SENSE_TYPES.NORMAL,
      priority: this.data.priority
    };
  }

  /* -------------------------------------------- */
  /*  Visibility Testing                          */
  /* -------------------------------------------- */

  /** @override */
  testPoint(point) {

    // Darkness sources are vertically unbounded and aren't affected by surfaces
    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 = this._getPolygonBackend();

    // 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.elevation = this.data.elevation;
    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;
  }
}

/**
 * @import {PointLightSource, PointVisionSource} from "../sources/_module.mjs";
 * @import {ElevatedPoint} from "@client/_types.mjs";
 * @import Collection from "@common/utils/collection.mjs";
 */

/**
 * 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 {Collection<string, PointLightSource>}
   */
  lightSources = new foundry.utils.Collection();

  /**
   * A mapping of darkness sources which are active within the rendered Scene.
   * @type {Collection<string, PointDarknessSource>}
   */
  darknessSources = new foundry.utils.Collection();

  /**
   * A Collection of vision sources which are currently active within the rendered Scene.
   * @type {Collection<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 ) {
      if ( source.priority === 0 ) 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;
  }

  /* -------------------------------------------- */

  // eslint-disable-next-line jsdoc/require-param
  /**
   * 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 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();
  }
}

/**
 * @import GlobalLightSource from "../sources/global-light-source.mjs";
 */

/**
 * @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}
     */
    this.globalLightSource = new CONFIG.Canvas.globalLightSourceClass({object: this, sourceId: "globalLight"});
    Object.defineProperty(this, "globalLightSource", {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,
      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);

    // 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 ?? canvas.level?.background?.color ?? 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,
      level: canvas.level.id,
      dim: globalLight.bright ? 0 : maxR,
      bright: globalLight.bright ? maxR : 0,
      disabled: !globalLight.enabled
    }, globalLight, {overwrite: false});
    this.globalLightSource.initialize(globalLightData);
    this.globalLightSource.add();
  }
}

/**
 * @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;
  }
}

/**
 * @import {Point, ElevatedPoint, DeepReadonly} from "@common/_types.mjs";
 * @import {PolygonTree} from "@client/data/polygon-tree.mjs";
 * @import {RegionSurface} from "@client/documents/_types.mjs";
 */

/**
 * The occlusion mask which contains radial occlusion and vision occlusion from tokens.
 * Red channel: Fade occlusion.
 * Green channel: Radial occlusion.
 * Blue channel: Vision occlusion.
 * Alpha channel: Surface occlusion.
 * @category Canvas
 */
class CanvasOcclusionMask extends CachedContainer {
  constructor(...args) {
    super(...args);
    this.#createOcclusion();
    canvas.registerMouseMoveHandler(this.#onMouseMove, foundry.canvas.Canvas.MOUSE_MOVE_HANDLER_PRIORITIES.LOW, this);
  }

  /** @override */
  static textureConfiguration = {
    scaleMode: PIXI.SCALE_MODES.NEAREST,
    format: PIXI.FORMATS.RGBA,
    multisample: PIXI.MSAA_QUALITY.NONE
  };

  /**
   * Graphics in which token radial and vision occlusion shapes are drawn.
   * @type {PIXI.LegacyGraphics}
   */
  tokens;

  /**
   * Graphics in which surface occlusion shapes are drawn.
   * @type {PIXI.LegacyGraphics}
   */
  surfaces;

  /**
   * 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();

  /**
   * The occluded surfaces.
   * @type {ReadonlySet<DeepReadonly<RegionSurface>>}
   */
  get occludedSurfaces() {
    return this.#occludedSurfaces;
  }

  #occludedSurfaces = new Set();

  /**
   * The set of hovered surface keys.
   * @type {Set<string>}
   */
  #hoveredSurfaces = 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 254 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;
    this.surfaces = this.addChild(new PIXI.LegacyGraphics());
    this.surfaces.blendMode = PIXI.BLEND_MODES.MIN_ALL;
    this.surfaces.mask = new PIXI.MaskData();
    this.surfaces.mask.colorMask = PIXI.COLOR_MASK_BITS.ALPHA;
  }

  /* -------------------------------------------- */

  /**
   * Clear the occlusion mask.
   * @override
   */
  clear() {
    this.tokens.clear();
    this.surfaces.clear();
    this.#tokens = [];
    this.#occluded.clear();
    this.#occludedSurfaces.clear();
    this.#hoveredSurfaces.clear();
    this.#vision = false;
    this.#elevations = new Float64Array([-Infinity]);
    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) {

    // Finds the interval containing `elevation` and maps it to the corresponding value.
    //     [     -∞, E[0]   ]  ->  1 / 255
    //     (   E[0], E[1]   ]  ->  2 / 255
    //     (   E[1], E[2]   ]  ->  3 / 255
    //     ...
    //     ( E[n-2], E[n-1] ]  ->  (n - 1) / 255
    //     ( E[n-1], ∞      ]  ->  255 / 255
    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 + 1) / 255;
  }

  /* -------------------------------------------- */

  /**
   * Update the occludable tokens.
   * @internal
   */
  _updateOccludableTokens() {
    this.#tokens = canvas.tokens._getOccludableTokens();
  }

  /* -------------------------------------------- */

  /**
   * 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.
   * Surface occlusion draws to the alpha channel with varying intensity from [0, 1] based on elevation.
   * @internal
   */
  _updateOcclusionMask() {
    this.#vision = false;
    this.tokens.clear();
    this.surfaces.clear();
    const elevations = [];
    const queue = [];
    for ( const token of this.#tokens ) queue.push({object: token, elevation: token.document.elevation});
    for ( const surface of this.occludedSurfaces ) {
      queue.push({object: surface, elevation: Math.nextDown(surface.elevation)});
    }
    queue.sort((a, b) => a.elevation - b.elevation);
    for ( const {object, elevation} of queue) {
      if ( elevation !== elevations.at(-1) ) elevations.push(elevation);
      const occlusionElevation = Math.min(elevations.length, 254);

      // Token
      if ( object instanceof foundry.canvas.placeables.Token ) {

        // Draw vision occlusion
        if ( object.vision?.active ) {
          this.#vision = true;
          this.tokens.beginFill(0xFFFF00 | occlusionElevation).drawShape(object.vision.los).endFill();
        }

        // Draw radial occlusion (and radial into the vision channel if this token doesn't have vision)
        const origin = object.center;
        const occlusionRadius = Math.max(object.externalRadius, object.getLightRadius(
          object.document.occludable.radius));
        this.tokens.beginFill(0xFF0000 | (occlusionElevation << 8) | (object.vision?.active ? 0xFF
          : occlusionElevation)).drawCircle(origin.x, origin.y, occlusionRadius).endFill();
      }

      // Surface
      else {
        this.surfaces.beginFill(0xFFFFFF, occlusionElevation / 255);
        object.region.polygonTree.drawShape(this.surfaces);
        this.surfaces.endFill();
      }
    }
    if ( !elevations.length ) elevations.push(-Infinity);
    else elevations.length = Math.min(elevations.length, 254);
    this.#elevations = new Float64Array(elevations);
    this.renderDirty = true;
  }

  /* -------------------------------------------- */

  /**
   * Update the current occlusion status of all PCOs.
   * @internal
   */
  _updateOccludedObjects() {
    const occluded = this._identifyOccludedObjects(this.#tokens);
    for ( const pco of canvas.primary.objects ) {
      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 occludable Tokens
   * @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) ) this.#occluded.add(pco);
      }
    }
    return this.#occluded;
  }

  /* -------------------------------------------- */

  /**
   * Determine the occluded surfaces.
   * @param {object} flags  The perception render flags that are processed
   * @internal
   */
  _updateOccludedSurfaces(flags) {

    // No need to remember to check for occluded surface changes if we already initialize light and vision sources or
    // there's no surfaces with exposure in the viewed level
    const priorOccludedSurfaces = !(flags.initializeLightSources && flags.initializeVision) && canvas.scene.getSurfaces(
      {level: canvas.level.id, exposure: true}).length ? this.#occludedSurfaces.map(s => s.key) : undefined;

    this.#occludedSurfaces.clear();

    const occludableSurfaces = canvas.scene.getSurfaces({level: canvas.level.id, occlusion: true});
    if ( occludableSurfaces.length ) {

      // If the token layer is not active or the "highlight all" key is pressed, surfaces in the foreground are occluded
      if ( !canvas.tokens.active || canvas.tokens.highlightObjects ) {
        for ( let i = occludableSurfaces.length - 1; i >= 0; i-- ) {
          const surface = occludableSurfaces[i];
          if ( surface.elevation < canvas.level.elevation.top ) break;
          this.#occludedSurfaces.add(surface);
        }
      }

      // Hovered surfaces are occluded
      if ( this.#hoveredSurfaces.size ) {
        for ( const surface of occludableSurfaces ) {
          if ( this.#hoveredSurfaces.has(surface.key) ) this.#occludedSurfaces.add(surface);
        }
      }

      // Determine the surfaces that are occluded by tokens
      const candidateSurfaces = [];
      for ( const token of this.#tokens ) {
        const tokenElevation = token.document.elevation;
        let tokenBounds;
        for ( let i = occludableSurfaces.length - 1; i >= 0; i-- ) {
          const surface = occludableSurfaces[i];
          if ( tokenElevation >= surface.elevation ) break;
          tokenBounds ??= token.bounds;
          if ( !tokenBounds.intersects(surface.region.bounds) ) continue;
          candidateSurfaces.push(surface);
        }
        if ( !candidateSurfaces.length ) continue;
        for ( const point of token.document.getOcclusionTestPoints() ) {
          for ( const surface of CanvasOcclusionMask.#getOccludedSurfaces(point, tokenElevation, candidateSurfaces) ) {
            this.#occludedSurfaces.add(surface);
          }
        }
        candidateSurfaces.length = 0;
      }
    }

    // If there's a change in occluded surfaces, we need to initialize all light and vision sources
    if ( priorOccludedSurfaces?.equals(this.#occludedSurfaces.map(s => s.key)) === false ) {
      flags.initializeLightSources = flags.refreshLighting = flags.initializeVision = flags.refreshVision = true;
    }
  }

  /* -------------------------------------------- */

  /**
   * Get all surfaces that are occluded given the point and elevation.
   * @param {Point} point                 The point that is tested for occlusion.
   * @param {number} elevation            The elevation of the point.
   * @param {RegionSurface[]} [surfaces]  The occludable surfaces, order by elevation in ascending order.
   * @yields {RegionSurface}              The occluded surfaces.
   */
  static *#getOccludedSurfaces(point, elevation, surfaces) {

    // Iterate over the surfaces in ascending order of elevation
    let lowest;
    for ( const surface of surfaces ?? canvas.scene.getSurfaces({level: canvas.level.id, occlusion: true}) ) {

      // Surfaces at or below the point cannot occlude the point
      if ( surface.elevation <= elevation ) continue;
      const region = surface.region;

      // Surfaces whose elevation range does not include the point must be the lowest surface to be occluded
      const enveloped = region.elevation.bottom <= elevation;
      if ( !enveloped && (surface.elevation > lowest?.elevation) ) continue;

      // For a ceiling surface to be occluded, the point must be contained within the region's elevation range
      const ceiling = surface.elevation > region.elevation.bottom;
      const occluded = (enveloped || !ceiling) && region.polygonTree.testPoint(point);
      if ( !occluded ) continue;

      lowest ??= surface;
      yield surface;
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle mousemove events.
   * @param {PIXI.Point} mousePosition   The current mouse position
   */
  #onMouseMove(mousePosition) {
    const hoveredSurfaces = [];

    // Only visible or explored mouse positions can trigger hovering
    if ( canvas.mousePositionVisible || canvas.mousePositionExplored ) {

      // Get elevation of hovered placeable
      let mouseElevation = canvas.level.elevation.base;
      const hoveredPlaceable = canvas.activeLayer?.hover;
      if ( hoveredPlaceable ) {
        const {Drawing, Tile, Token, Region} = foundry.canvas.placeables;
        if ( (hoveredPlaceable instanceof Token) || (hoveredPlaceable instanceof Tile) ) {
          const mesh = hoveredPlaceable.mesh;
          if ( mesh ) mouseElevation = mesh.elevation;
        } else if ( hoveredPlaceable instanceof Drawing ) {
          const shape = hoveredPlaceable.shape;
          if ( shape ) mouseElevation = shape.elevation;
        } else if ( hoveredPlaceable instanceof Region ) {
          mouseElevation = hoveredPlaceable.document.elevation.top;
        } else if ( hoveredPlaceable.document.schema.has("elevation") ) {
          mouseElevation = hoveredPlaceable.document.elevation;
        }
      }

      // Determine all hovered surfaces. Only surfaces with occlusion can be hovered.
      for ( const surface of CanvasOcclusionMask.#getOccludedSurfaces(mousePosition, mouseElevation) ) {
        hoveredSurfaces.push(surface);
      }
    }

    // If the hovered surfaces changed, refresh the occlusion states of surfaces
    if ( (hoveredSurfaces.length !== this.#hoveredSurfaces.size)
      || !hoveredSurfaces.every(surface => this.#hoveredSurfaces.has(surface.key)) ) {
      canvas.perception.update({refreshOccludedSurfaces: true});
    }
    this.#hoveredSurfaces.clear();
    for ( const surface of hoveredSurfaces ) this.#hoveredSurfaces.add(surface.key);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  updateOcclusion() {
    foundry.utils.logCompatibilityWarning("CanvasOcclusionMask#updateOcclusion is deprecated. Use canvas.perception.update({refreshOcclusion: true}) instead.",
      {since: 14, until: 16, once: true});
    canvas.perception.update({refreshOcclusion: true});
  }
}

/**
 * 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
   * @param {boolean} [inclusive] If false, the value belonging to `elevation` minus epsilon is return instead.
   *                              Default: `true`.
   * @returns {number}            The value for this elevation in the range [0, 1] with 8-bit precision
   */
  mapElevation(elevation, inclusive=true) {
    const E = this.#elevations;
    let i = 0;
    let j = E.length - 1;

    // Finds the interval containing `elevation` (or `Math.nextDown(elevation)` if `inclusive` is true)
    // and maps it to the corresponding value.
    //     [     -∞, E[0]   )  ->  0 / 255
    //     [   E[0], E[1]   )  ->  1 / 255
    //     [   E[1], E[2]   )  ->  2 / 255
    //     ...
    //     [ E[n-2], E[n-1] )  ->  (n - 1) / 255
    //     [ E[n-1], ∞      ]  ->  n / 255
    if ( inclusive ) {
      if ( elevation < E[0] ) return 0;
      while ( i < j ) {
        const k = (i + j + 1) >> 1;
        const e = E[k];
        if ( e <= elevation ) i = k;
        else j = k - 1;
      }
    } else {
      if ( elevation <= E[0] ) return 0;
      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 Drawing from "../placeables/drawing.mjs"
 * @import {TextAnchorPoint} from "@common/constants.mjs"
 * @import {Point} from "@common/_types.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 s = dimensions.uiScale;
    const displayCanvasBorder = scene.padding !== 0;
    if ( displayCanvasBorder ) outline.lineStyle({
      alignment: 1,
      alpha: 0.75,
      color: 0x000000,
      join: PIXI.LINE_JOIN.BEVEL,
      width: 4 * s
    }).drawShape(dimensions.rect);
    outline.lineStyle({
      alignment: 1,
      alpha: displayCanvasBorder ? 0.25 : 0.75,
      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 {Point, ElevatedPoint, CanvasVisibilityTestConfiguration,
 *   CanvasVisibilityTextureConfiguration, Rectangle} from "@client/_types.mjs";
 * @import {PointLightSource, PointVisionSource} from "@client/canvas/sources/_module.mjs";
 * @import {CanvasVisionContainer} from "../layers/_types.mjs";
 * @import {LightingVisibility} from "../perception/_types.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 surface exposure.
   * @type {PIXI.Container}
   */
  surfaceExposure;

  /**
   * 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();

  /**
   * Cache of fog vision sources used only for shared fog commits.
   * @type {Map<string, {source: PointVisionSource, key: string}>}
   */
  #sharedFogVisionSources = new Map();

  /**
   * The maximum allowable visibility texture size.
   * @type {number}
   * @internal
   */
  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}
   */
  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 {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();
    for ( const {source} of this.#sharedFogVisionSources.values() ) 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 {LightingVisibility}
   */
  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 explored = new PIXI.Container();
    const explorationSprite = explored.addChild(canvas.fog.sprite);
    if ( this.#explorationRect ) explorationSprite.position.set(this.#explorationRect.x, this.#explorationRect.y);
    else explorationSprite.position.set(canvas.dimensions.sceneX, canvas.dimensions.sceneY);

    // Surface exposure
    this.surfaceExposure = explored.addChild(new SurfaceExposureContainer(this.vision));
    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;

    // Surface exposure of light sources
    vision.light.surfaceExposure = vision.light.addChild(new PIXI.LegacyGraphics());
    vision.light.surfaceExposure.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    vision.light.surfaceExposure.renderable = false;

    // The cached light to avoid too many geometry drawings
    vision.light.cached = vision.light.addChild(new SpriteMesh(Canvas$1.getRenderTexture({
      textureConfiguration: this.textureConfiguration
    })));
    if ( this.#explorationRect ) vision.light.cached.position.set(this.#explorationRect.x, this.#explorationRect.y);
    else 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());

    // Shared FoW for light sources, not visible by default
    vision.light.mask.shared = vision.light.mask.addChild(new PIXI.LegacyGraphics());
    vision.light.mask.shared.visible = false;

    // Surface exposure of light sources that provide vision
    vision.light.mask.surfaceExposure = vision.light.mask.addChild(new PIXI.LegacyGraphics());
    vision.light.mask.surfaceExposure.renderable = false;

    // 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;

    // Surface exposure of vision sources
    vision.sight.surfaceExposure = vision.sight.addChild(new PIXI.LegacyGraphics());
    vision.sight.surfaceExposure.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    vision.sight.surfaceExposure.renderable = false;

    // Shared FoW for sight, not visible by default
    vision.sight.shared = vision.sight.addChild(new PIXI.LegacyGraphics());
    vision.sight.shared.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    vision.sight.shared.visible = false;

    // Eraser for darkness sources
    vision.darkness = vision.addChild(new PIXI.LegacyGraphics());
    vision.darkness.blendMode = PIXI.BLEND_MODES.ERASE;

    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;

    // Destroy surface exposure container
    this.surfaceExposure.destroy({children: true});
    this.surfaceExposure = undefined;

    // Destroy explored container
    this.explored.destroy({children: true});
    this.explored = undefined;

    for ( const {source} of this.#sharedFogVisionSources.values() ) source.destroy();
    this.#sharedFogVisionSources.clear();

    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;
    const graphics = [
      vision.light.sources,
      vision.light.preview,
      vision.light.surfaceExposure,
      vision.light.global.source,
      vision.light.mask,
      vision.light.mask.preview,
      vision.light.mask.surfaceExposure,
      vision.light.mask.shared,
      vision.sight,
      vision.sight.preview,
      vision.sight.surfaceExposure,
      vision.sight.shared,
      vision.darkness
    ];

    // Begin fills
    const fillColor = 0xFF0000;
    for ( const g of graphics ) g.clear().beginFill(fillColor);
    this.#cachedLights.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;

    // Flag for shared exploration mode
    let commitSharedFog = false;

    // Flag to know whether to render surface exposure
    let hasSurfaceExposure = false;

    // Shared FoW: draw the union of player-owned Token vision sources into hidden shared graphics
    if ( canvas.fog.sharedExploration ) {
      commitSharedFog = this.#refreshSharedExploration();
      commitFog ||= commitSharedFog;
    }
    else if ( this.#sharedFogVisionSources.size ) {
      for ( const {source} of this.#sharedFogVisionSources.values() ) source.destroy();
      this.#sharedFogVisionSources.clear();
    }

    // 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;
        }

        // Draw vision surface exposure
        if ( lightSource.shape.surfaceExposure ) {
          lightSource.shape.surfaceExposure.drawShape(vision.light.mask.surfaceExposure);
          hasSurfaceExposure = true;
        }
      }

      // Draw light surface exposure
      if ( lightSource.shape.surfaceExposure ) {
        lightSource.shape.surfaceExposure.drawShape(vision.light.surfaceExposure);
        hasSurfaceExposure = 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 vision surface exposure
      if ( visionSource.shape.surfaceExposure ) {
        visionSource.shape.surfaceExposure.drawShape(vision.sight.surfaceExposure);
        hasSurfaceExposure = true;
      }

      // 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);

      // Draw light perception surface exposure
      if ( visionSource.light.surfaceExposure ) {
        visionSource.light.surfaceExposure.drawShape(vision.light.mask.surfaceExposure);
        hasSurfaceExposure = true;
      }
    }

    this.surfaceExposure.visible = hasSurfaceExposure;

    // Call visibility refresh hook
    Hooks$1.callAll("visibilityRefresh", this);

    // End fills
    for ( const g of graphics ) g.endFill();
    this.#cachedLights.endFill();

    // Update fog of war texture (if fow is activated)
    if ( commitFog ) {
      if ( commitSharedFog ) {
        vision.sight.shared.visible = vision.light.mask.shared.visible = true;
      }
      try {
        canvas.fog.commit();
      }
      finally {
        vision.sight.shared.visible = vision.light.mask.shared.visible = false;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Shared FoW update. We create vision sources for Tokens that should contribute to SHARED exploration and draw their
   * FOV and light-perception polygons into the hidden shared graphics containers.
   * @returns {boolean}  Should fog be committed?
   */
  #refreshSharedExploration() {
    const sight = this.vision.sight.shared;
    const mask = this.vision.light.mask.shared;
    const keepIds = new Set();
    let commit = false;

    for ( const token of canvas.tokens.placeables ) {
      if ( !token._isFogExplorationSource() ) continue;
      keepIds.add(token.id);

      let entry = this.#sharedFogVisionSources.get(token.id);
      if ( !entry ) {
        const source = token._createSharedFogVisionSource();
        entry = {source, version: -1};
        this.#sharedFogVisionSources.set(token.id, entry);
      }
      const source = entry.source;

      // Track changes
      if ( entry.version !== token._visionSourceVersion ) {
        const data = token._getVisionSourceData();
        const blindedStates = token._getVisionBlindedStates();
        for ( const state in blindedStates ) entry.source.blinded[state] = blindedStates[state];
        source.initialize(data);
        entry.version = token._visionSourceVersion;
      }

      const blinded = source.isBlinded;
      if ( !blinded && !source.isPreview ) {
        if ( source.radius > 0 ) {
          sight.drawShape(source.shape);
          commit = true;
        }
        if ( source.lightRadius > 0 ) {
          mask.drawShape(source.light);
          commit = true;
        }
      }
    }

    // Destroy stale sources
    for ( const [tokenId, entry] of this.#sharedFogVisionSources ) {
      if ( keepIds.has(tokenId) ) continue;
      entry.source.destroy();
      this.#sharedFogVisionSources.delete(tokenId);
    }

    return 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 {PointLightSource} 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) {
    this.#renderTransform.tx = -this.vision.light.cached.x;
    this.#renderTransform.ty = -this.vision.light.cached.y;
    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;

    const priorMousePositionVisible = canvas._mousePositionVisible;
    canvas._mousePositionVisible = this.testVisibility(canvas.mousePosition, {tolerance: 0});
    if ( canvas._mousePositionVisible !== priorMousePositionVisible ) {
      MouseInteractionManager.emulateMoveEvent();
    }

    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[]|ElevatedPoint[]} points  The point or points 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(points, 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
    if ( !Array.isArray(points) ) points = [points];
    const object = options.object ?? null;
    const config = this._createVisibilityTestConfig(points, 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 = points.every(p => !sr.contains(p.x, p.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.basicSight;
      if ( basicMode ) {
        const result = modes.basicSight.testVisibility(visionSource, basicMode, config);
        if ( result === true ) return true;
      }
      const lightMode = token.detectionModes.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 [id, mode] of Object.entries(token.detectionModes) ) {
        if ( (id === "basicSight") || (id === "lightPerception") ) continue;
        const dm = modes[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[]} points  The points 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(points, {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 elevation = points.at(0)?.elevation;
    if ( (elevation === undefined) && (object instanceof Token$1) ) {
      elevation = object.document.getMovementOrigin().elevation;
    }
    else elevation ??= canvas.level.elevation.base;
    const level = object instanceof Token$1 ? canvas.scene.levels.get(object.document.level) : canvas.level;
    const tests = [];
    for ( const point of points ) {
      for ( const [dx, dy] of offsets ) {
        const test = Object.defineProperty({
          point: {x: point.x + dx, y: point.y + dy, elevation: point.elevation ?? elevation},
          level,
          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;
          }
        });
        tests.push(test);
      }
    }
    return {object, level, tests};
  }

  /* -------------------------------------------- */
  /*  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.level?.fog?.src;
    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
      baseTex.wrapMode = PIXI.WRAP_MODES.CLAMP;
      fo.transform.setFromMatrix(bkg.localTransform);
      fo.anchor.set(0.5);

      // Store dimensions for shader calculations
      this.#visibilityOverlayDimensions = [fo.width, fo.height, bkg.width, bkg.height];
    }
    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;

      // Store dimensions for shader calculations
      this.#visibilityOverlayDimensions = [fo.width, fo.height, baseTex.width, baseTex.height];
    }

    // 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});
  }

  /* -------------------------------------------- */

  /**
   * 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;
    let height;
    if ( this.#explorationRect ) {
      width = this.#explorationRect.width;
      height = this.#explorationRect.height;
    } else {
      width = dims.sceneWidth;
      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
    };
  }
}

/* -------------------------------------------- */

class SurfaceExposureContainer extends PIXI.Container {
  /**
   * @param {CanvasVisionContainer} vision
   */
  constructor(vision) {
    super();
    this.#vision = vision;
  }

  /* -------------------------------------------- */

  /** @type {CanvasVisionContainer} */
  #vision;

  /* -------------------------------------------- */

  /** @override */
  updateTransform() {}

  /* -------------------------------------------- */

  /** @override */
  render(renderer) {
    if ( !this.visible || !this.renderable ) return;
    const vision = this.#vision;
    vision.light.surfaceExposure.renderable = true;
    vision.light.mask.surfaceExposure.renderable = true;
    vision.sight.surfaceExposure.renderable = true;
    vision.render(renderer);
    vision.light.surfaceExposure.renderable = false;
    vision.light.mask.surfaceExposure.renderable = false;
    vision.sight.surfaceExposure.renderable = false;
  }
}

var _module$r = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CanvasGroupMixin: CanvasGroupMixin,
  CanvasVisibility: CanvasVisibility,
  EffectsCanvasGroup: EffectsCanvasGroup,
  EnvironmentCanvasGroup: EnvironmentCanvasGroup,
  HiddenCanvasGroup: HiddenCanvasGroup,
  InterfaceCanvasGroup: InterfaceCanvasGroup,
  OverlayCanvasGroup: OverlayCanvasGroup,
  PrimaryCanvasGroup: PrimaryCanvasGroup,
  RenderedCanvasGroup: RenderedCanvasGroup
});

/**
 * @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$7 = /*#__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) {
    if ( event.button === 2 ) {
      event.preventDefault();
      return;
    }
    const isCtrl = event.ctrlKey || event.metaKey;
    if ( isCtrl ) {
      event.interactionData.released = true;
      event.preventDefault();
    }
  }

  /* -------------------------------------------- */

  /**
   * 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, previous, ray} = waypoint;
    const elevation = waypoint.elevation.toNearest(0.01);
    if ( !state.initialized ) {
      state.hasElevation = elevation !== 0;
      state.previousElevation = elevation;
      state.initialized = true;
    }
    else state.hasElevation ||= (elevation !== 0);
    if ( !previous ) return;
    if ( (ray.distance === 0) && (waypoint.elevation === previous.elevation) ) 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.signedString(),
      icon: "fa-solid fa-arrows-up-down",
      hidden: !state.hasElevation
    };
    const deltaElevation = (elevation - state.previousElevation).toNearest(0.01);
    if ( deltaElevation !== 0 ) context.elevation.delta = deltaElevation.signedString();
    state.previousElevation = elevation;

    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$q = /*#__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$7
});

/**
 * @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 and surfaces?
 * @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} keyboardMovableObjects  Can placeable objects in this layer be moved via keyboard?
 * @property {boolean} confirmDeleteKey     Confirm placeable object deletion with a dialog?
 * @property {boolean|string[]|() => boolean} confirmBeforeCreation
 *   Confirm before creating the drawn object (for the given tools)?
 * @property {boolean|string[]|() => boolean} controlObjectAfterCreation
 *   Control the drawn object that has been created (for the given tools)?
 * @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.
 */

/**
 * @typedef RegionPlacementOptions
 * @property {boolean} [create=true]  Create the Region? If false, the preview document is returned.
 *                                    Default: `true`. Non-GMs cannot create Regions while the game is paused.
 * @property {Partial<Omit<DatabaseCreateOperation, "parent">>} [createOptions]  Optional creation options.
 *   By default the creation option `controlObject` is true.
 * @property {boolean} [allowRotation=true]  Allow rotation of the Region? Default: `true`.
 * @property {boolean} [allowEmpty=false]    Create/return an empty Region if all shapes are skipped?
 *                                           Default: `false`.
 * @property {boolean} [attachToToken=false] Attach the Region to Tokens? If true, the Region is attached to the
 *   hovered Token and the initial elevation range passed in `data` is relative to that Token. The Region is placed
 *   unattached if no Token is hovered: check `document.attachment.token` in `preConfirm` and return false to require
 *   an attachment. Default: `false`.
 * @property {(args: {event: PIXI.FederatedEvent; preview: Region; document: RegionDocument; regionIndex: number;
 *     regionCount: number; shape: BaseShapeData; shapeIndex: number; shapeCount: number; position: Point;
 *     snap: boolean}) => boolean|void} [onMove]
 *   Called when the pointer is moved and after starting the placement of the next shape on confirm and skip. This
 *   callback replaces the default behavior if false is returned. If false is returned, the callback should modify
 *   the passed `shape` and may additionally modify `preview.document` and set the render flags on `preview`
 *   corresponding to the applied changes.
 * @property {(args: {event: WheelEvent; preview: Region; document: RegionDocument; regionIndex: number;
 *     regionCount: number; shape: BaseShapeData; shapeIndex: number; shapeCount: number;
 *     precise: boolean}) => boolean|void} [onRotate]
 *   Called when the mouse wheel is scrolled. This callback replaces the default behavior if false is returned.
 *   If false is returned, the callback should modify the `shape` and may additionally modify `preview.document`
 *   and set the render flags on `preview` corresponding to the applied changes.
 * @property {(args: {preview: Region; document: RegionDocument; regionIndex: number; regionCount: number;
 *     shape: BaseShapeData; shapeIndex: number; shapeCount: number}) => void} [onChange]
 *   Called when the Region shape that is placed has changed.
 * @property {(args: {event: PIXI.FederatedEvent; document: RegionDocument; regionIndex: number; regionCount: number;
 *     shape: BaseShapeData; shapeIndex: number; shapeCount: number}) => boolean|void} [preConfirm]
 *   Called before the confirmation (left-click) of a shape placement. This callback may return false to prevent
 *   the placement of the Region shape and display a warning.
 * @property {(args: {event: PIXI.FederatedEvent; document: RegionDocument; regionIndex: number; regionCount: number;
 *     shape: BaseShapeData; shapeIndex: number; shapeCount: number}) => boolean|void} [preSkip]
 *   Called before skipping (right-click) of a shape placement. This callback may return false to prevent
 *   skipping of the Region shape and display a warning.
 * @property {(documents: ReadonlyArray<RegionDocument>) => Promise<any|void>|void} [preCommit]
 *   Called at the end of the workflow before the Region documents are created/returned. This callback may return
 *   a falsely value other than undefined to prevent the Regions from being created/returned.
 */

var _types$6 = /*#__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 _createVertexShader() {
    return `
    precision ${PIXI.Program.defaultVertexPrecision} float;

    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 _createFragmentShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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
    varying vec2 vTextureCoord;
    varying vec2 vMaskTextureCoord;
    varying vec2 vTerrainTextureCoord;
    varying vec2 vSceneCoord;
    uniform sampler2D uSampler;
    uniform float depthElevation;

    // 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 get defaultUniforms() {
    return {
      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|ParticleGenerator>}
   */
  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() ) {
      if ( effect instanceof ParticleGenerator ) effect.stop({hard: true});
      else 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;
    }

    // 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
    let zIndex = 0;
    for ( const effect of weatherEffectsConfig.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;
      }

      // Validate that effectClass and particles are not both declared
      if ( effect.effectClass && effect.particles?.length ) {
        console.warn(`Weather effect "${effect.id}" declares both effectClass and particles which are mutually `
          + "exclusive configurations. This effect will be skipped.");
        continue;
      }

      // Legacy V13 particle effects
      if ( foundry.utils.isSubclass(effect.effectClass, foundry.canvas.containers.ParticleEffect) ) {
        zIndex = this.#constructLegacyParticleEffect(effect, zIndex);
      }

      // Shader effects
      else if ( effect.effectClass ) zIndex = this.#constructShaderEffect(effect, useOcclusionFilter, zIndex);

      // Particle effects
      else if ( effect.particles?.length ) this.#constructParticleEffects(effect);
    }
  }

  /* -------------------------------------------- */

  /**
   * Construct a legacy ParticleEffect-based effect for a single effect configuration entry.
   * @deprecated since v14
   * @param {WeatherEffectConfiguration} effect    The effect configuration entry.
   * @param {number} zIndex                        The current auto-assigned zIndex counter.
   * @returns {number}                             The updated zIndex counter.
   */
  #constructLegacyParticleEffect(effect, zIndex) {
    let ec;
    try {
      ec = new effect.effectClass(effect.config);
    } catch(err) {
      err.message = `Failed to construct weather effect: ${err.message}`;
      console.error(err);
      return zIndex;
    }
    ec.zIndex = effect.zIndex ?? zIndex++;
    ec.blendMode = effect.blendMode ?? PIXI.BLEND_MODES.NORMAL;
    this.weatherEffects.addChild(ec);
    this.effects.set(effect.id, ec);
    ec.play();
    return zIndex;
  }

  /* -------------------------------------------- */

  /**
   * Construct particle generator-based effects for a single effect configuration entry.
   * @param {WeatherEffectConfiguration} effect    The effect configuration entry.
   */
  #constructParticleEffects(effect) {
    for ( let i = 0; i < effect.particles.length; i++ ) {
      const {generatorClass=ParticleGenerator, ...config} = effect.particles[i];
      let pg;
      try {
        pg = new generatorClass({mode: "ambient", initial: 1, container: this.weatherEffects, ...config});
      } catch(err) {
        err.message = `Failed to construct weather particle generator: ${err.message}`;
        console.error(err);
        continue;
      }
      this.effects.set(`${effect.id}.${i}`, pg);
      pg.start();
    }
  }

  /* -------------------------------------------- */

  /**
   * Construct a shader-based effect for a single effect configuration entry.
   * @param {WeatherEffectConfiguration} effect    The effect configuration entry.
   * @param {boolean} useOcclusionFilter           Whether a layer-wide occlusion filter is active.
   * @param {number} zIndex                        The current auto-assigned zIndex counter.
   * @returns {number}                             The updated zIndex counter.
   */
  #constructShaderEffect(effect, useOcclusionFilter, zIndex) {
    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);
      return zIndex;
    }

    // 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();
    return zIndex;
  }

  /* -------------------------------------------- */

  /**
   * 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 PlaceablesLayer from "../base/placeables-layer.mjs";
 * @import PlaceableObject from "@client/canvas/placeables/placeable-object.mjs";
 */

/**
 * A mixin for UX shared between PlaceablesLayer with objects that have shapes.
 * @param {typeof PlaceablesLayer} Base    The PlaceablesLayer (sub)class.
 */
function ShapeLayerMixin(Base) {
  return class ShapeLayer extends Base {

    /** @inheritDoc */
    static get layerOptions() {
      return foundry.utils.mergeObject(super.layerOptions, {
        /**
         * The shape types that are allowed to be empty for the creation of a drawn object.
         * @type {string[]}
         */
        allowedEmptyShapes: [],
        /**
         * Discard the closing point of a polygon shape?
         * @type {boolean}
         */
        discardClosingPoint: true
      });
    }

    /* -------------------------------------------- */

    // TODO: https://github.com/foundryvtt/foundryvtt/issues/13831
    /**
     * The mouse wheel context.
     * @type {{preview: PlaceableObject; shape: BaseShapeData}|null}
     * @internal
     */
    _mouseWheelContext = null;

    /* -------------------------------------------- */

    /**
     * Listen for SHIFT/CTRL keyup events during mouse wheel rotation.
     * @type {() => void}
     */
    #mouseWheelKeyUpListener = () => {
      if ( !game.keyboard.isModifierActive("CONTROL") && !game.keyboard.isModifierActive("SHIFT") ) {
        this.#commitMouseWheel();
      }
    };

    /* -------------------------------------------- */

    /**
     * Can an object have multiple shapes?
     * @type {boolean}
     */
    get #multipleShapes() {
      return getDocumentClass$1(this.constructor.documentName).schema.fields.shapes?.max > 1;
    }

    /* -------------------------------------------- */

    /**
     * The objects of this layer that are either controlled or have their sheet rendered.
     * @type {PlaceableObject[]}
     */
    #getTargetedObjects() {
      const objects = [];
      for ( const object of this.placeables ) {
        if ( object.controlled || object.sheet.rendered ) objects.push(object);
      }
      return objects;
    }

    /* -------------------------------------------- */

    /** @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 */
    _deactivate() {
      this._cancelMouseWheel();
      super._deactivate();
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _tearDown(options) {
      this._cancelMouseWheel();
      return super._tearDown(options);
    }

    /* -------------------------------------------- */
    /*  Event Listeners and Handlers                */
    /* -------------------------------------------- */

    /** @inheritDoc */
    _onClickLeft(event) {
      const interaction = event.interactionData;

      // Continue polygon point placement
      if ( interaction.shape?.type === "polygon" ) {
        const {destination, freehand} = interaction;
        const point = !event.shiftKey && !freehand ? this.getSnappedPoint(destination) : destination;
        const points = interaction.shape.points;

        // Clicking on the first point closes the shape
        if ( point.x.almostEqual(points.at(0)) && point.y.almostEqual(points.at(1)) ) {
          interaction.cancelled = true;
          if ( !this.options.discardClosingPoint ) {
            interaction.shape.updateSource({points: points.toSpliced(-2, 0, point.x, point.y)});
          }
        }

        // Add the point unless it's equal to the last one
        else if ( !(point.x.almostEqual(points.at(-4)) && point.y.almostEqual(points.at(-3))) ) {
          interaction.shape.updateSource({points: points.toSpliced(-2, 0, point.x, point.y)});
          this._updateDragPreview(event);
        }
        return;
      }

      // If one of the drawing tools is selected, prevent left-click-to-release
      if ( this._isCreationToolActive() ) return;

      // Standard left-click handling
      super._onClickLeft(event);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onClickLeft2(event) {
      const interaction = event.interactionData;

      // Conclude polygon drawing with a double-click
      if ( interaction.shape?.type === "polygon" ) {
        interaction.cancelled = true;
        return;
      }

      // Standard double-click handling
      super._onClickLeft2(event);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _canDragLeftStart(user, event) {
      if ( !super._canDragLeftStart(user, event) ) return false;
      if ( this.#multipleShapes ) {
        const targets = this.#getTargetedObjects();
        if ( targets.length > 1 ) {
          const documentClass = getDocumentClass$1(this.constructor.documentName);
          ui.notifications.warn("SHAPE.NOTIFICATIONS.DrawingMultipleObjectsControlled", {format: {
            type: _loc(documentClass.metadata.label)}});
          return false;
        }
        if ( targets.at(0)?.document.locked ) {
          const documentClass = getDocumentClass$1(this.constructor.documentName);
          ui.notifications.warn("CONTROLS.ObjectIsLocked", {format: {
            type: _loc(documentClass.metadata.label)}});
          return false;
        }
      }
      return true;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onDragLeftStart(event) {
      const interaction = event.interactionData;
      interaction.cancelOnPause = true;

      // Create the preview
      const object = this.#multipleShapes ? this.#getTargetedObjects()[0] : undefined;
      if ( object ) {
        interaction.preview = object.clone();
        interaction.preview._previewType = "creation";
        interaction.preview._onDragStart();
        interaction.preview.draw();
        this.preview.addChild(interaction.preview);
      }
      else super._onDragLeftStart(event);

      // Create the shape that is drag-created
      const shapeData = this._createDragShapeData(event);
      interaction.shape = new BaseShapeData.TYPES[shapeData.type](shapeData, {parent: interaction.preview.document});
      interaction.shape._onDragStart(event);
      interaction.cancelled = (interaction.shape.type !== "polygon") || !!interaction.freehand;
      this._updateDragPreview(event);
    }

    /* -------------------------------------------- */

    /** @override */
    _onDragLeftMove(event) {
      const interaction = event.interactionData;
      if ( !interaction.shape ) return;
      interaction.shape._onDragMove(event);
      this._updateDragPreview(event);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onDragLeftDrop(event) {
      const interaction = event.interactionData;
      if ( !interaction.shape ) return;
      if ( interaction.shape.type === "polygon" ) {

        // In-progress polygon drawing
        if ( !interaction.cancelled ) {
          event.preventDefault();
          return;
        }

        // Slice off the preview point
        interaction.shape.updateSource({points: interaction.shape.points.slice(0, -2)});
        this._updateDragPreview(event);
      }

      // Do not create empty shapes
      if ( !this.options.allowedEmptyShapes.includes(interaction.shape.type) && interaction.shape.isEmpty ) return;

      super._onDragLeftDrop(event);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _commitDragLeftDrop(event) {
      const interaction = event.interactionData;
      const preview = interaction.preview;
      if ( !preview || preview._destroyed ) return;

      // Add the shape to controlled object
      const object = preview._original;
      if ( object ) {
        if ( !object.document.locked ) {
          try {
            interaction.clearPreviewContainer = false;
            await object.document.update({shapes: preview.document.toObject().shapes});
          } finally {
            this.clearPreviewContainer();
          }
        }
        return;
      }

      return super._commitDragLeftDrop(event);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onDragLeftCancel(event) {

      // Remove point from in-progress polygon drawing
      const interaction = event.interactionData;
      if ( (interaction.shape?.type === "polygon") && !interaction.cancelled ) {
        const points = interaction.shape.points.toSpliced(-4, 2);
        if ( points.length >= 4 ) {
          event.preventDefault();
          interaction.shape.updateSource({points});
          this._updateDragPreview(event);
          return;
        }
      }

      // Standard drag-cancel handling
      super._onDragLeftCancel(event);
    }

    /* -------------------------------------------- */

    /**
     * Create the shape data from the drag start event.
     * @param {PIXI.FederatedEvent} event    The pointer event
     * @returns {object}                     The initial shape data
     * @protected
     */
    _createDragShapeData(event) {
      const shapeData = foundry.utils.deepClone(ui.controls.tool?.shapeData);
      if ( !shapeData ) throw new Error("A shape drawing tool must define shapeData.");
      shapeData.hole = ui.controls.tools.hole?.active ?? false;
      return shapeData;
    }

    /* -------------------------------------------- */

    /**
     * Update the drag preview. Called when the shape has changed.
     * @param {PIXI.FederatedEvent} event    The pointer event
     * @protected
     */
    _updateDragPreview(event) {
      const interaction = event.interactionData;
      const shape = interaction.shape.toObject();
      if ( interaction.preview.document.schema.has("shapes") ) {
        const shapes = interaction.preview._original?.document.toObject().shapes ?? [];
        shapes.push(shape);
        interaction.preview.document.updateSource({shapes});
        interaction.preview.renderFlags.set({refreshShapes: true});
      } else if ( interaction.preview.document.schema.has("shape") ) {
        interaction.preview.document.updateSource({shape});
        interaction.preview.renderFlags.set({refreshShape: true});
      } else {
        throw new Error(`${this.constructor.name} must implement the _updateDragPreview method.`);
      }
    }

    /* -------------------------------------------- */

    /** @override */
    _onMouseWheel(event) {
      if ( !this._mouseWheelContext ) {

        // Prevent wheel rotation during dragging
        if ( this.preview.children.length ) return;

        // The hover must be controlled to be rotated
        if ( this.options.controllableObjects && !this.hover?.controlled ) return;

        // Rotate if the translate handle is hovered
        const hoveredHandle = this.hover?.hoveredHandle;
        if ( hoveredHandle?.name !== "translate" ) return;
        const hoveredShape = hoveredHandle.controls.shape;
        if ( hoveredShape.hasRotationalSymmetry ) return;

        if ( game.paused && !game.user.isGM ) {
          ui.notifications.warn("GAME.PausedWarning", {localize: true});
          return;
        }

        const document = hoveredShape.parent;
        if ( document.locked ) {
          ui.notifications.warn("CONTROLS.ObjectIsLocked",
            {format: {type: _loc(document.constructor.metadata.label)}});
          return false;
        }

        this._mouseWheelContext = this.#createMouseWheelContext(hoveredShape);
      }

      this._updateMouseWheelShape(event);
      this._updateMouseWheelPreview();
    }

    /* -------------------------------------------- */

    /**
     * Create the mouse wheel context.
     * @param {BaseShapeData} shape    The shape that is to be rotated.
     * @returns {object}
     */
    #createMouseWheelContext(shape) {
      const document = shape.parent;

      // Create and draw preview region
      const preview = document.object.clone();
      preview._previewType = "wheel";
      preview._onDragStart();
      this.preview.addChild(preview);
      preview.draw();

      // Listen for SHIFT/CTRL keyup events
      window.addEventListener("keyup", this.#mouseWheelKeyUpListener);

      return {preview, shape: shape.clone()};
    }

    /* -------------------------------------------- */

    /**
     * Commit the mouse wheel rotation update.
     * @returns {Promise<void>}
     */
    async #commitMouseWheel() {
      if ( !this._mouseWheelContext ) return;
      window.removeEventListener("keyup", this.#mouseWheelKeyUpListener);
      let result = this._prepareMouseWheelUpdate();
      if ( !Array.isArray(result[0]) ) result = [result];
      const [data, options] = result;
      const {preview, shape} = this._mouseWheelContext;
      const document = shape.parent;
      this._mouseWheelContext = null;
      try {
        if ( game.paused && !game.user.isGM ) {
          ui.notifications.warn("GAME.PausedWarning", {localize: true});
          return;
        }
        await document.update(data, options);
      } finally {
        if ( !preview.destroyed ) {
          preview._onDragEnd();
          preview.destroy({children: true});
        }
      }
    }

    /* -------------------------------------------- */

    /**
     * Cancel mouse wheel rotation.
     * @protected
     */
    _cancelMouseWheel() {
      if ( !this._mouseWheelContext ) return;
      window.removeEventListener("keyup", this.#mouseWheelKeyUpListener);
      const {preview} = this._mouseWheelContext;
      this._mouseWheelContext = null;
      if ( !preview.destroyed ) {
        preview._onDragEnd();
        preview.destroy({children: true});
      }
    }

    /* -------------------------------------------- */

    /**
     * Rotate the shape of the preview.
     * @param {WheelEvent} event   The mouse wheel event
     */
    _updateMouseWheelShape(event) {
      const {shape} = this._mouseWheelContext;
      const snap = shape.type === "grid" ? (canvas.grid.isSquare ? 90 : 60) : (event.shiftKey ? 15 : 5);
      const delta = snap * Math.sign(event.delta);
      shape.rotate((shape.rotation + delta).toNearest(snap) - shape.rotation);
    }

    /* -------------------------------------------- */

    /**
     * Update the mouse wheel rotation preview.
     * @protected
     */
    _updateMouseWheelPreview() {
      const {preview, shape} = this._mouseWheelContext;
      if ( preview.document.schema.has("shapes") ) {
        const shapes = preview.document.toObject().shapes;
        shapes[shape._index] = shape.toObject();
        preview.document.updateSource({shapes});
        preview.renderFlags.set({refreshShapes: true});
      } else if ( preview.document.schema.has("shape") ) {
        preview.document.updateSource({shape: shape.toObject()});
        preview.renderFlags.set({refreshShape: true});
      } else {
        throw new Error(`${this.constructor.name} must implement the _updateMouseWheelPreview method.`);
      }
    }

    /* -------------------------------------------- */

    /**
     * Prepare the database update that should occur as the result of a mouse wheel rotation.
     * @returns {object|[data: object, options?: object]}    The update data and options (optional)
     * @protected
     */
    _prepareMouseWheelUpdate() {
      const {preview} = this._mouseWheelContext;
      if ( preview.document.schema.has("shapes") ) {
        return {shapes: preview.document.toObject().shapes};
      } else if ( preview.document.schema.has("shape") ) {
        return {shape: preview.document.toObject().shape};
      } else if ( preview.document.schema.has("rotation") ) {
        return {rotation: preview.document._source.rotation};
      } else {
        throw new Error(`${this.constructor.name} must implement the _prepareMouseWheelUpdate method.`);
      }
    }
  };
}

/**
 * @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 ShapeLayerMixin(PlaceablesLayer) {

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "drawings",
      controllableObjects: true,
      rotatableObjects: true,
      zIndex: 500,
      allowedEmptyShapes: ["polygon"],
      discardClosingPoint: false
    });
  }

  /** @inheritdoc */
  static documentName = "Drawing";

  /** @inheritdoc */
  static paletteClass = DrawingPalette;

  /**
   * 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                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _getCopyableObjects(options) {
    if ( !game.user.can("DRAWING_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;
  }

  /* -------------------------------------------- */

  /**
   * 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 palette defaults
    const data = foundry.utils.deepClone(this.paletteCreateData);

    // 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 = {};

    // 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;
        data.bezierFactor = 0;
        break;
      case "ellipse":
        data.shape.type = Drawing$1.SHAPE_TYPES.ELLIPSE;
        data.shape.width = strokeWidth + 1;
        data.shape.height = strokeWidth + 1;
        data.bezierFactor = 0;
        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.bezierFactor = 0;
        data.text ||= "";
        break;
    }

    // Return the cleaned data
    return DrawingDocument.cleanData(data);
  }

  /* -------------------------------------------- */

  /** @override */
  static prepareSceneControls() {
    const sc = SceneControls;
    return {
      name: "drawings",
      order: 3,
      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: (_event, tool, active) => {
        canvas.drawings.setAllRenderFlags({refreshState: true});
        if ( active && !tool.control ) canvas.drawings.releaseAll();
      },
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.DrawingSelect",
          icon: "fa-solid fa-expand",
          interaction: true,
          control: true,
          toolclip: {
            heading: "CONTROLS.DrawingSelect",
            items: sc.buildToolclipItems(["selectAlt", "selectMultiple", "move", "hud", "edit", "delete", "rotate"])
          }
        },
        rect: {
          name: "rect",
          order: 2,
          title: "CONTROLS.DrawingRect",
          icon: "fa-solid icon-fa-rectangle",
          creation: true,
          toolclip: {
            heading: "CONTROLS.DrawingRect",
            items: sc.buildToolclipItems(["draw", "drawProportionally"])
          }
        },
        ellipse: {
          name: "ellipse",
          order: 3,
          title: "CONTROLS.DrawingEllipse",
          icon: "fa-solid icon-fa-ellipse",
          creation: true,
          toolclip: {
            heading: "CONTROLS.DrawingEllipse",
            items: sc.buildToolclipItems(["draw", "drawProportionally"])
          }
        },
        polygon: {
          name: "polygon",
          order: 4,
          title: "CONTROLS.DrawingPoly",
          icon: "fa-solid fa-draw-polygon",
          creation: true,
          toolclip: {
            heading: "CONTROLS.DrawingPoly",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonDraw", content: "CONTROLS.DrawingPolyP"}])
          }
        },
        freehand: {
          name: "freehand",
          order: 5,
          title: "CONTROLS.DrawingFree",
          icon: "fa-solid fa-signature",
          creation: true,
          toolclip: {
            src: "toolclips/tools/drawing-free.webm",
            heading: "CONTROLS.DrawingFree",
            items: sc.buildToolclipItems(["draw"])
          }
        },
        text: {
          name: "text",
          order: 6,
          title: "CONTROLS.DrawingText",
          icon: "fa-solid fa-font",
          interaction: true,
          control: true,
          creation: true,
          onChange: () => {
            const controlled = canvas.drawings.controlled;
            if ( controlled.length === 1 ) controlled[0].enableTextEditing();
          },
          toolclip: {
            heading: "CONTROLS.DrawingText",
            items: sc.buildToolclipItems(["draw", "drawProportionally", "move", "hud", "edit", "delete", "rotate"])
          }
        },
        snap: {
          name: "snap",
          order: 7,
          title: "CONTROLS.CommonForceSnap",
          icon: "fa-solid fa-plus",
          toggle: true,
          visible: !canvas.grid?.isGridless,
          active: canvas.forceSnapVertices,
          onChange: (event, toggled) => canvas.forceSnapVertices = toggled
        },
        togglePalette: { ...this.TOGGLE_PALETTE, order: 8 },
        clear: {
          name: "clear",
          order: 8,
          title: "CONTROLS.DrawingClear",
          icon: "fa-solid fa-trash",
          onChange: () => canvas.drawings.deleteAll(),
          button: true
        }
      },
      activeTool: "select"
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragLeftStart(event) {
    if ( game.activeTool === "freehand" ) {
      event.interactionData.freehand = true;
      event.interactionData.freehandSampleRate = this.constructor.placeableClass.FREEHAND_SAMPLE_RATE;
    }
    super._onDragLeftStart(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _createDragPreviewData(event) {
    return this._getNewDrawingData(event.interactionData.origin);
  }

  /* -------------------------------------------- */

  /** @override */
  _createDragShapeData(event) {
    switch ( game.activeTool ) {
      case "rect":
      case "text": return {type: "rectangle", x: 0, y: 0, width: 0, height: 0, anchorX: 0.5, anchorY: 0.5};
      case "ellipse": return {type: "ellipse", x: 0, y: 0, radiusX: 0, radiusY: 0};
      case "polygon":
      case "freehand": return {type: "polygon", points: [0, 0, 0, 0]};
      default: throw new Error("Invalid tool");
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _updateDragPreview(event) {
    const {preview, shape} = event.interactionData;
    switch ( shape.type ) {
      case "rectangle": {
        const {x, y, width, height, rotation} = shape;
        const {strokeWidth} = preview.document;
        preview.document.updateSource({
          x: x - (width / 2),
          y: y - (height / 2),
          shape: {
            width: Math.max(width, 2 * strokeWidth),
            height: Math.max(height, 2 * strokeWidth)
          },
          rotation
        });
        break;
      }
      case "ellipse": {
        const {x, y, radiusX, radiusY, rotation} = shape;
        const {strokeWidth} = preview.document;
        preview.document.updateSource({
          x: x - radiusX,
          y: y - radiusY,
          shape: {
            width: 2 * Math.max(radiusX, strokeWidth),
            height: 2 * Math.max(radiusY, strokeWidth)
          },
          rotation
        });
        break;
      }
      case "polygon": {
        let unrotatedShape;
        if ( preview.document.rotation === 0 ) unrotatedShape = shape;
        else {
          unrotatedShape = shape.clone();
          unrotatedShape.rotate(-preview.document.rotation);
        }
        let {x, y, width, height} = unrotatedShape.bounds;
        if ( (width === 0) && (height === 0) ) width = height = 1;
        const points = [];
        for ( let i = 0; i < unrotatedShape.points.length; i += 2 ) {
          points.push(unrotatedShape.points[i] - x, unrotatedShape.points[i + 1] - y);
        }
        preview.document.updateSource({x, y, shape: {width, height, points}});
        break;
      }
    }
    preview.renderFlags.set({refreshTransform: true});
  }

  /* -------------------------------------------- */

  /** @override */
  _updateMouseWheelPreview() {
    const {preview, shape} = this._mouseWheelContext;
    preview.document.updateSource({rotation: shape.rotation});
    preview.renderFlags.set({refreshRotation: true});
  }
}

/**
 * The Lighting Layer which ambient light sources as part of the CanvasEffectsGroup.
 * @category Canvas
 */
class LightingLayer extends ShapeLayerMixin(PlaceablesLayer) {

  /** @inheritDoc */
  static documentName = "AmbientLight";

  /** @inheritdoc */
  static paletteClass = AmbientLightPalette;

  /** @inheritDoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "lighting",
      controllableObjects: true,
      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});
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _activate() {
    super._activate();
    this.setAllRenderFlags({refreshField: true});
  }

  /* -------------------------------------------- */

  /** @override */
  static prepareSceneControls() {
    const sc = SceneControls;
    return {
      name: "lighting",
      order: 5,
      title: "CONTROLS.GroupLighting",
      layer: "lighting",
      icon: "fa-regular fa-lightbulb",
      visible: game.user.isGM,
      onChange: (event, active) => {
        if ( active ) canvas.lighting.activate();
      },
      onToolChange: (_event, tool, active) => {
        canvas.lighting.setAllRenderFlags({refreshState: true});
        if ( active && !tool.control ) canvas.lighting.releaseAll();
      },
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.LightSelect",
          icon: "fa-solid fa-expand",
          interaction: true,
          control: true,
          toolclip: {
            heading: "CONTROLS.LightSelect",
            items: sc.buildToolclipItems(["selectAlt", "selectMultiple", "move", "edit", "rotate", "delete"])
          }
        },
        light: {
          name: "light",
          order: 2,
          title: "CONTROLS.LightDraw",
          icon: "fa-solid fa-lightbulb",
          interaction: true,
          control: true,
          creation: true,
          toolclip: {
            heading: "CONTROLS.LightDraw",
            items: sc.buildToolclipItems(["create", "select", "edit", "rotate", "onOff"])
          }
        },
        day: {
          name: "day",
          order: 3,
          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: {
            heading: "CONTROLS.LightDay",
            items: sc.buildToolclipItems([{heading: "CONTROLS.MakeDayH", content: "CONTROLS.MakeDayP"},
              {heading: "CONTROLS.AutoLightToggleH", content: "CONTROLS.AutoLightToggleP"}])
          }
        },
        night: {
          name: "night",
          order: 4,
          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: {
            heading: "CONTROLS.LightNight",
            items: sc.buildToolclipItems([{heading: "CONTROLS.MakeNightH", content: "CONTROLS.MakeNightP"},
              {heading: "CONTROLS.AutoLightToggleH", content: "CONTROLS.AutoLightToggleP"}])
          }
        },
        reset: {
          name: "reset",
          order: 5,
          title: "CONTROLS.LightReset",
          icon: "fa-solid fa-cloud",
          onChange: () => {
            DialogV2.confirm({
              window: {title: "CONTROLS.FOWResetTitle", icon: "fa-solid fa-cloud"},
              content: `<p>${_loc("CONTROLS.FOWResetDesc")}</p>`,
              yes: {callback: () => canvas.fog.reset()}
            });
          },
          button: true,
          toolclip: {
            src: "toolclips/tools/light-reset.webm",
            heading: "CONTROLS.LightReset",
            items: [{paragraph: "CONTROLS.LightResetP"}]
          }
        },
        togglePalette: { ...this.TOGGLE_PALETTE, order: 6 },
        clear: {
          name: "clear",
          order: 7,
          title: "CONTROLS.LightClear",
          icon: "fa-solid fa-trash",
          onChange: () => canvas.lighting.deleteAll(),
          button: true
        }
      },
      activeTool: "select"
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  _createDragShapeData(event) {
    const angle = this.paletteCreateData.config?.angle;
    if ( angle < 360 ) return {type: "cone", x: 0, y: 0, radius: 0, angle, rotation: 0};
    return {type: "circle", x: 0, y: 0, radius: 0};
  }

  /* -------------------------------------------- */

  /** @override */
  _updateDragPreview(event) {
    const {shape, preview} = event.interactionData;
    const {x, y, radius} = shape;
    const dim = radius / canvas.dimensions.distancePixels;
    const bright = 0.5 * dim;
    const data = {x, y, config: {dim, bright}};
    if ( shape.type === "cone" ) {
      data.rotation = shape.rotation - 90;
      data.config.angle = shape.angle;
    }
    preview.document.updateSource(data);
    preview.renderFlags.set({refreshTransform: true, refreshField: true});
    preview.initializeLightSource();
  }

  /* -------------------------------------------- */

  /** @override */
  _updateMouseWheelPreview() {
    const {preview, shape} = this._mouseWheelContext;
    preview.document.updateSource({rotation: shape.rotation - 90});
    preview.renderFlags.set({refreshRotation: true, refreshField: true});
    preview.initializeLightSource();
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftCancel(event) {
    super._onDragLeftCancel(event);
    canvas.effects.refreshLighting();
  }

  /* -------------------------------------------- */

  /**
   * 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",
      controllableObjects: true,
      zIndex: 800,
      confirmBeforeCreation: true
    });
  }

  /** @inheritdoc */
  static documentName = "Note";

  /** @inheritdoc */
  static paletteClass = NotePalette;

  /**
   * 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);

  /* -------------------------------------------- */

  /**
   * Called when the fog is explored.
   * @type {() => void}
   */
  #onFogExplored = () => this.setAllRenderFlags({refreshVisibility: true});

  /* -------------------------------------------- */
  /*  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();
    canvas.fog.addEventListener("explored", this.#onFogExplored);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {
    canvas.fog.removeEventListener("explored", this.#onFogExplored);
    return super._tearDown(options);
  }

  /* -------------------------------------------- */

  /**
   * 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: 8,
      title: "CONTROLS.GroupNotes",
      layer: "notes",
      icon: "fa-solid fa-bookmark",
      onChange: (event, active) => {
        if ( active ) canvas.notes.activate();
      },
      onToolChange: (_event, tool, active) => {
        canvas.notes.setAllRenderFlags({refreshState: true});
        if ( active && !tool.control ) canvas.notes.releaseAll();
      },
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.NoteSelect",
          icon: "fa-solid fa-expand",
          interaction: true,
          control: true
        },
        journal: {
          name: "journal",
          order: 2,
          title: "NOTE.Create",
          visible: game.user.hasPermission("NOTE_CREATE"),
          icon: CONFIG.JournalEntry.sidebarIcon,
          interaction: true,
          control: true,
          creation: true
        },
        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)
        },
        togglePalette: { ...this.TOGGLE_PALETTE, order: 4 },
        clear: {
          name: "clear",
          order: 5,
          title: "CONTROLS.NoteClear",
          icon: "fa-solid fa-trash",
          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 = foundry.utils.mergeObject({
      x: origin.x,
      y: origin.y,
      sort: Math.max(this.getMaxSort() + 1, 0)
    }, this.paletteCreateData);
    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 Wall from "../placeables/wall.mjs";
 * @import {WallData} from "@common/documents/_types.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;

  /**
   * 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,
      controlObjectAfterCreation: false,
      zIndex: 700
    });
  }

  /** @inheritDoc */
  static documentName = "Wall";

  /** @inheritdoc */
  static paletteClass = WallPalette;

  /* -------------------------------------------- */

  /** @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);
  }

  /* -------------------------------------------- */

  /**
   * 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) {
    if ( !walls.length ) return [];
    canvas.edges.identifyIntersections();

    // 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;
      const intersections = edge.intersections[canvas.level.id];
      if ( !intersections?.length ) addEdge(a, b);
      else {
        const p = 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;
    const s = CONST.EDGE_SENSE_TYPES;
    const d = CONST.WALL_DOOR_TYPES;
    const wallData = {
      light: s.NORMAL, move: s.NORMAL, sight: s.NORMAL, sound: s.NORMAL, door: d.NONE,
      threshold: { light: null, sight: null, sound: null, attenuation: false }
    };
    return {
      name: "walls",
      order: 4,
      title: "CONTROLS.GroupWall",
      layer: "walls",
      icon: "fa-solid fa-block-brick",
      visible: game.user.isGM,
      onChange: (event, active) => {
        if ( active ) canvas.walls.activate();
      },
      onToolChange: (_event, tool, active) => {
        canvas.walls.setAllRenderFlags({refreshState: true});
        if ( active && !tool.control ) canvas.walls.releaseAll();
      },
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.WallSelect",
          icon: "fa-solid fa-expand",
          interaction: true,
          control: true,
          toolclip: {
            src: "toolclips/tools/wall-select.webm",
            heading: "CONTROLS.WallSelect",
            items: sc.buildToolclipItems(["selectAlt", "selectMultiple",
              {heading: "CONTROLS.WallSelectChain", reference: "CONTROLS.AltClick"}, "move",
              {heading: "CONTROLS.CommonMoveWithoutSnapping", reference: "CONTROLS.ShiftDrag"},
              {heading: "CONTROLS.CommonEdit", content: "CONTROLS.WallSelectEdit"}, "delete"])
          }
        },
        wall: {
          name: "wall",
          order: 2,
          title: "CONTROLS.WallDraw",
          icon: "fa-sharp fa-solid fa-share-nodes",
          interaction: true,
          control: true,
          creation: true,
          toolclip: {
            heading: "CONTROLS.WallDraw",
            items: sc.buildToolclipItems(["place", "chain", "select",
              {heading: "CONTROLS.WallSelectChain", reference: "CONTROLS.AltClick"}, "movePoint", "edit", "delete"])
          }
        },
        solid: {
          name: "solid",
          order: 3,
          title: "CONTROLS.WallSolid",
          icon: "fa-solid fa-bars",
          button: true,
          createData: foundry.utils.mergeObject(wallData, {
            light: s.NORMAL, sight: s.NORMAL, sound: s.NORMAL, move: s.NORMAL
          }, { inplace: false }),
          toolclip: {
            src: "toolclips/tools/wall-solid.webm",
            heading: "CONTROLS.WallSolid",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallSolidBlocks"}])
          },
          onChange: WallPalette.onClickPreset
        },
        terrain: {
          name: "terrain",
          order: 4,
          title: "CONTROLS.WallTerrain",
          icon: "fa-solid fa-mountain",
          button: true,
          createData: foundry.utils.mergeObject(wallData, {
            light: s.LIMITED, sight: s.LIMITED, sound: s.LIMITED, move: s.NORMAL
          }, { inplace: false }),
          toolclip: {
            src: "toolclips/tools/wall-terrain.webm",
            heading: "CONTROLS.WallTerrain",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallTerrainBlocks"}])
          },
          onChange: WallPalette.onClickPreset
        },
        invisible: {
          name: "invisible",
          order: 5,
          title: "CONTROLS.WallInvisible",
          icon: "fa-solid fa-eye-slash",
          button: true,
          createData: foundry.utils.mergeObject(wallData, {
            light: s.NONE, sight: s.NONE, sound: s.NONE, move: s.NORMAL
          }, { inplace: false }),
          toolclip: {
            src: "toolclips/tools/wall-invisible.webm",
            heading: "CONTROLS.WallInvisible",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallInvisibleBlocks"}])
          },
          onChange: WallPalette.onClickPreset
        },
        ethereal: {
          name: "ethereal",
          order: 6,
          title: "CONTROLS.WallEthereal",
          icon: "fa-solid fa-mask",
          button: true,
          createData: foundry.utils.mergeObject(wallData, {
            light: s.NORMAL, sight: s.NORMAL, sound: s.NONE, move: s.NONE
          }, { inplace: false }),
          toolclip: {
            src: "toolclips/tools/wall-ethereal.webm",
            heading: "CONTROLS.WallEthereal",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallEtherealBlocks"}])
          },
          onChange: WallPalette.onClickPreset
        },
        doors: {
          name: "doors",
          order: 7,
          title: "CONTROLS.WallDoor",
          icon: "fa-solid fa-door-open",
          button: true,
          createData: foundry.utils.mergeObject(wallData, {
            light: s.NORMAL, sight: s.NORMAL, sound: s.NORMAL, move: s.NORMAL, door: d.DOOR
          }, { inplace: false }),
          toolclip: {
            src: "toolclips/tools/wall-door.webm",
            heading: "CONTROLS.WallDoor",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallDoorBlocks"}])
          },
          onChange: WallPalette.onClickPreset
        },
        secret: {
          name: "secret",
          order: 8,
          title: "CONTROLS.WallSecret",
          icon: "fa-solid fa-user-secret",
          button: true,
          createData: foundry.utils.mergeObject(wallData, {
            light: s.NORMAL, sight: s.NORMAL, sound: s.NORMAL, move: s.NORMAL, door: d.SECRET
          }, { inplace: false }),
          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.WallDoorBlocks"}
            ])
          },
          onChange: WallPalette.onClickPreset
        },
        window: {
          name: "window",
          order: 9,
          title: "CONTROLS.WallWindow",
          icon: "fa-solid fa-window-frame",
          button: true,
          get createData() {
            const { distance } = canvas.dimensions;
            return foundry.utils.mergeObject(wallData, {
              light: s.PROXIMITY, sight: s.PROXIMITY, sound: s.NORMAL, move: s.NORMAL,
              threshold: { light: 2 * distance, sight: 2 * distance, attenuation: true }
            }, { inplace: false });
          },
          toolclip: {
            src: "toolclips/tools/wall-window.webm",
            heading: "CONTROLS.WallWindow",
            items: sc.buildToolclipItems([{heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallWindowBlocks"}])
          },
          onChange: WallPalette.onClickPreset
        },
        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"}]
          }
        },
        togglePalette: { ...this.TOGGLE_PALETTE, order: 11 },
        closeDoors: {
          name: "closeDoors",
          order: 12,
          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: 13,
          title: "CONTROLS.WallClear",
          icon: "fa-solid fa-trash",
          button: true,
          onChange: () => canvas.walls.deleteAll()
        }
      },
      activeTool: "select"
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  _createDragPreviewData(event) {
    const data = super._createDragPreviewData(event);
    const origin = event.interactionData.origin;
    const snap = !event.shiftKey;
    const isChain = this._chain || event.ctrlKey || event.metaKey;
    const pt = (isChain && this._last.point) ? this._last.point : this._getWallEndpointCoordinates(origin, {snap});
    data.c = pt.concat(pt);
    return data;
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftMove(event) {
    const {preview, destination} = event.interactionData;
    if ( !preview || preview._destroyed ) return;
    const snap = !event.shiftKey;
    preview.document.updateSource({
      c: preview.document.c.slice(0, 2).concat(this._getWallEndpointCoordinates(destination, {snap}))
    });
    preview.renderFlags.set({refreshLine: true});
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftDrop(event) {

    // Don't create wall on right-click
    if ( event.button === 2 ) return;

    const {preview} = event.interactionData;
    if ( !preview || preview._destroyed ) return;

    // Prevent default to allow chaining to continue
    if ( event.ctrlKey || event.metaKey ) {
      event.preventDefault();
      this._chain = true;
    }
    else this._chain = false;

    const coords = preview.document.c;
    const dest = coords.slice(2);

    // Maybe chain
    const maybeChain = () => {
      if ( !this._chain ) return;
      event.interactionData.origin = {x: dest[0], y: dest[1]};
      event.interactionData.clearPreviewContainer = true;
      this._onDragLeftStart(event);
    };

    // Ignore walls which are collapsed
    if ( (coords[0] === coords[2]) && (coords[1] === coords[3]) ) {
      this.clearPreviewContainer();
      maybeChain();
      return;
    }

    // Create the Wall
    this._last = {point: dest};
    this._commitDragLeftDrop(event).finally(maybeChain);
  }

  /* -------------------------------------------- */

  /** @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;
  }
}

/**
 * @import {Rectangle, TokenFindMovementPathOptions, TokenPlannedMovement} from "../../_types.mjs";
 * @import Token from "../placeables/token.mjs";
 * @import User from "@client/documents/user.mjs";
 * @import {TokenData, TokenPosition} from "@common/documents/_types.mjs";
 * @import {TokenMovementOptions, TokenMovementWaypoint} from "@client/documents/_types.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;

  /* -------------------------------------------- */

  /**
   * The movement planning context.
   * @type {{
   *   object: Token;
   *   allowedActions: string[]|null;
   *   direct: boolean;
   *   minCost: number;
   *   maxCost: number;
   *   minDistance: number;
   *   maxDistance: number;
   *   preventDrop: boolean;
   *   terrainOptions: Omit<TokenCreateTerrainMovementPathOptions, "preview">;
   *   constrainOptions: Omit<TokenConstrainMovementPathOptions, "preview"|"history"|"measureOptions">;
   *   measureOptions: Omit<TokenMeasureMovementPathOptions, "preview">;
   *   pathfindingOptions: Omit<TokenFindMovementPathOptions, "preview"|"terrainOptions"|"constrainOptions"|
   *     "measureOptions">;
   *   moveOptions: Omit<TokenMovementOptions, "id"|"method"|"terrainOptions"|"constrainOptions"|"measureOptions"|
   *     "planned">;
   *   result: {
   *     id: string;
   *     origin: TokenPosition;
   *     destination: TokenPosition;
   *     waypoints: TokenMovementWaypoint[];
   *   }|null;
   *   resolve: (document: {
   *     id: string;
   *     origin: TokenPosition;
   *     destination: TokenPosition;
   *     waypoints: TokenMovementWaypoint[];
   *   }|null) => void;
   *   reject: (error: Error) => void;
   *   violations: string[];
   * }|null}
   * @internal
   */
  _movementPlanningContext = null;

  /* -------------------------------------------- */

  /**
   * The placement context.
   * @type {{
   *   previews: Token[];
   *   placed: TokenDocument[];
   *   index: number;
   *   resolve: (document: TokenDocument[]) => void;
   *   reject: (error: Error) => void;
   *   create: boolean;
   *   createOptions: Partial<Omit<DatabaseCreateOperation, "parent">>;
   *   allowRotation: boolean;
   *   onMove: (args: {event: PIXI.FederatedEvent; preview: Token;  document: TokenDocument;
   *     index: number; count: number; position: Point; snap: boolean}) => boolean|void;
   *   onRotate: (args: {event: WheelEvent; preview: Token; document: TokenDocument;
   *     index: number; count: number; precise: boolean}) => boolean|void;
   *   onChange: (args: {preview: Token; document: TokenDocument; index: number;
   *     count: number}) => void;
   *   preConfirm: (args: {event: PIXI.FederatedEvent; document: TokenDocument;
   *     index: number; count: number}) => boolean|void;
   *   preSkip: (args: {event: PIXI.FederatedEvent; document: TokenDocument;
   *     index: number; count: number}) => boolean|void;
   *   preCommit: (documents: ReadonlyArray<TokenDocument>) => Promise<any|void>|void;
   *   rotationNotification: boolean;
   * }|null}
   * @internal
   */
  _placementContext = null;

  /* -------------------------------------------- */

  /**
   * Handle pointermove events that move the placement.
   * @type {(event: PIXI.FederatedEvent) => void}
   */
  #placementPointerMoveListener = event => {
    canvas._onDragCanvasPan(event);
    this.#movePlacement(event);
  };

  /* -------------------------------------------- */

  /**
   * Handle pointerdown events that confirm or reset the placement.
   * @type {(event: PIXI.FederatedEvent) => void}
   */
  #placementPointerDownListener = event => {
    if ( event.button === 0 ) this.#confirmPlacement(event);
    else this.#skipPlacement(event);
  };

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "tokens",
      controllableObjects: true,
      rotatableObjects: true,
      keyboardMovableObjects: 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 {() => void}
   */
  #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();
      if ( canvas.grid.isGridless ) waypoint.snapped = false;
      else {
        const snapped = object.document.getSnappedPosition(waypoint);
        waypoint.snapped = (waypoint.x === Math.round(snapped.x))
          && (waypoint.y === Math.round(snapped.y))
          && waypoint.elevation.almostEqual(snapped.elevation);
      }
      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.VISIBLE | M.HIGHLIGHTED : M.OWNED | M.VISIBLE;
    canvas.app.ticker.add(this.#animate, this);
    canvas.fog.addEventListener("explored", this.#onFogExplored);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {
    this._cancelPlacement();
    this._cancelMovementPlanning();
    canvas.regions._cancelPlacement();
    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;
    canvas.perception.update({refreshOcclusionStates: true});
    canvas.notes.setAllRenderFlags({refreshState: true});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _deactivate() {
    super._deactivate();
    this.objects.visible = true;
    if ( canvas.controls ) canvas.controls.doors.visible = false;
    canvas.perception.update({refreshOcclusionStates: true});
    this._cancelPlacement();
    this._cancelMovementPlanning();
    canvas.regions._cancelPlacement();
    canvas.notes.setAllRenderFlags({refreshState: true});
  }

  /* -------------------------------------------- */

  /**
   * 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.interactive ) 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});
    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() {
    if ( !canvas.ready ) return;
    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.HIGHLIGHTED) && this.highlightObjects ) {
      return this.placeables.filter(t => t.visible && t.interactive);
    }
    if ( (mode & M.VISIBLE) && canvas.effects.visionSources.some(s => s.active) ) {
      return this.placeables.filter(t => t.visible && t.interactive
        && (t.controlled || t.vision?.active || (t.document.disposition !== CONST.TOKEN_DISPOSITIONS.SECRET)));
    }
    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?.passed.waypoints.length ) 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 ( !ui.controls.tool?.control || 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) {
    const warnings = [];
    if ( documents.some(t => t.attachments.regions.size) ) warnings.push("TOKEN.DeleteAttachedRegionWarning");
    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 ) warnings.push("TOKEN.DeleteCombatantWarning");
    if ( !warnings.length ) return true;
    return DialogV2.confirm({
      window: {
        icon: "fa-solid fa-trash",
        title: _loc("DOCUMENT.Delete", {type: _loc(TokenDocument.metadata.label)})
      },
      content: `<p><strong>${_loc("COMMON.AreYouSure")}</strong></p>${warnings.map(w => `<p>${_loc(w)}</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: (_event, tool, active) => {
        canvas.tokens.setAllRenderFlags({refreshState: true});
        if ( active && !tool.control ) canvas.tokens.releaseAll();
      },
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.BasicSelect",
          icon: "fa-solid fa-expand",
          interaction: true,
          control: true,
          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"}
            ])
          },
          onChange: (_event, _active) => {
            canvas.notes.setAllRenderFlags({refreshState: true});
          }
        },
        target: {
          name: "target",
          order: 2,
          title: "CONTROLS.TargetSelect",
          icon: "fa-solid fa-bullseye",
          interaction: true,
          control: true,
          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",
          control: true,
          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);
    canvas.perception.update({
      refreshOcclusion: this.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.HIGHLIGHTED,
      refreshOccludedSurfaces: true
    });
  }

  /* -------------------------------------------- */

  /**
   * Cycle the drag movement action override.
   * @param {boolean} reverse
   */
  #cycleDragMovementAction(reverse) {
    const token = this._draggedToken;
    let actions;
    let current;
    if ( (this._movementPlanningContext?.object === token)
      && this._movementPlanningContext.allowedActions ) {
      actions = this._movementPlanningContext.allowedActions;
      current = this._dragMovementAction ?? actions[0];
    } else {
      const currentAction = token.movementAction;
      actions = Object.entries(CONFIG.Token.movement.actions).reduce((actions, [action, {canSelect}]) => {
        if ( canSelect(token.document) || (action === currentAction) ) actions.push(action);
        return actions;
      }, []);
      current = this._dragMovementAction ?? token._getDragMovementAction();
    }
    const currentIndex = actions.indexOf(current);
    if ( currentIndex < 0 ) this._dragMovementAction = actions[0];
    else {
      const nextIndex = (currentIndex + (reverse ? -1 : 1) + actions.length) % actions.length;
      this._dragMovementAction = actions[nextIndex];
    }
    this.recalculatePlannedMovementPaths();
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Place Tokens at the cursor.
   * Each Token is placed one after the other in the given order.
   * The placed Tokens can be rotated with the mouse wheel unless the `allowRotation` is false.
   * @param {Iterable<Partial<TokenData>>} data  The data of the Tokens to place
   * @param {object} [options]               Additional options
   * @param {boolean} [options.create=true]  Create the Tokens? If false, the preview documents is returned.
   *                                         Default: `true`. Non-GMs cannot create Tokens while the game is paused.
   * @param {Partial<Omit<DatabaseCreateOperation, "parent">>} [options.createOptions]  Optional creation options.
   *   By default the creation option `controlObject` is true.
   * @param {boolean} [options.allowRotation=true]  Allow rotation of the Tokens? Default: `true`.
   * @param {(args: {event: PIXI.FederatedEvent; preview: Token; document: TokenDocument; index: number; count: number;
   *     position: Point; snap: boolean}) => boolean|void} [options.onMove]
   *   Called when the pointer is moved and after starting the placement of the next Token on confirm and skip. This
   *   callback replaces the default behavior if false is returned. If false is returned, the callback should modify
   *   the passed `document` and set the render flags on `preview` corresponding to the applied changes.
   * @param {(args: {event: WheelEvent; preview: Token; document: TokenDocument; index: number; count: number;
   *     precise: boolean}) => boolean|void} [options.onRotate]
   *   Called when the mouse wheel is scrolled. This callback replaces the default behavior if false is returned.
   *   If false is returned, the callback should modify the `document` and set the render flags on `preview`
   *   corresponding to the applied changes.
   * @param {(args: {preview: Token; document: TokenDocument; index: number; count: number}) => void} [options.onChange]
   *   Called when the position or rotation of the Token that is placed has changed.
   * @param {(args: {event: PIXI.FederatedEvent; document: TokenDocument; index: number;
   *     count: number}) => boolean|void} [options.preConfirm]
   *   Called before the confirmation (left-click) of a Token placement. This callback may return false to prevent
   *   the placement of the Token and display a warning.
   * @param {(args: {event: PIXI.FederatedEvent; document: TokenDocument; index: number;
   *     count: number}) => boolean|void} [options.preSkip]
   *   Called before skipping (right-click) of a Token placement. This callback may return false to prevent
   *   skipping of the Token and display a warning.
   * @param {(documents: ReadonlyArray<TokenDocument>) => Promise<any|void>|void} [options.preCommit]
   *   Called at the end of the workflow before the Token documents are created/returned. This callback may return
   *   a falsely value other than undefined to prevent the Tokens from being created/returned.
   * @returns {Promise<TokenDocument[]>} The Token documents that were placed and not rejected by preCreate.
   *   If the dismiss key was pressed, the placement was rejected by `preCommit`, or the game was paused, the user is
   *   not a GM, and the `create` option is true, and empty array is returned.
   * @example Place 3 tokens with random actor.
   * ```js
   * const {count: numTokensToSpawn=3} = await foundry.applications.api.DialogV2.input({
   *  window: {
   *     title: "How many tokens to you want to place?"
   *  },
   *  content: `<input type="number" name="count" min="0" step="1" value="3">`
   * }) ?? {};
   * const actors = game.actors.contents;
   * const tokensToPlace = [];
   * for ( let i = 0; i < numTokensToSpawn; i++ ) {
   *   const actor = actors[Math.floor(Math.random() * actors.length)];
   *   const token = await actor.getTokenDocument({level: canvas.level.id}, {parent: canvas.scene});
   *   tokensToPlace.push(token.toObject());
   * }
   * const placedTokens = await canvas.tokens.placeTokens(tokensToPlace);
   * ```
   */
  async placeTokens(data, {create=true, createOptions={}, allowRotation=true, onMove, onRotate, onChange, preConfirm,
    preSkip, preCommit}={}) {
    if ( onMove && (typeof onMove !== "function") ) throw new Error("The onMove argument must be a function.");
    if ( onRotate && (typeof onRotate !== "function") ) throw new Error("The onRotate argument must be a function.");
    if ( onChange && (typeof onChange !== "function") ) throw new Error("The onChange argument must be a function.");
    if ( preConfirm && (typeof preConfirm !== "function") ) throw new Error("The preConfirm argument must be a function.");
    if ( preSkip && (typeof preSkip !== "function") ) throw new Error("The preSkip argument must be a function.");
    if ( preCommit && (typeof preCommit !== "function") ) throw new Error("The preCommit argument must be a function.");
    if ( !canvas.ready ) throw new Error("The canvas is not ready.");

    // Create preview documents
    const documents = Array.from(data, data => {
      data = foundry.utils.deepClone(data);

      // Multiple previews need a unique PlaceableObject#objectId and #sourceId
      data._id ||= foundry.utils.randomID();
      return new CONFIG.Token.documentClass(data, {parent: canvas.scene});
    });
    if ( !documents.length ) throw new Error("There must be at least one Token to be placed.");
    if ( documents.some(d => !d.isOwner ) ) throw new Error("You must be an owner of the Tokens in order to place them.");

    // Cancel the current placement if there is one
    this._cancelPlacement();
    canvas.regions._cancelPlacement();

    // Cancel the current movement planning if there is one
    this._cancelMovementPlanning();

    // Cancel current drag workflow
    if ( canvas.currentMouseManager ) {
      canvas.currentMouseManager.interactionData.cancelled = true;
      canvas.currentMouseManager.cancel();
    }

    // Non-GMs cannot create a token while the game is paused
    if ( create && game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return [];
    }

    // Activate token layer
    this.activate({tool: "select"});

    // Create preview objects
    const previews = documents.map(document => {
      const preview = new CONFIG.Token.objectClass(document);
      preview._previewType = "creation";
      document._object = preview;
      return preview;
    });

    // Create placement context
    const {promise, resolve, reject} = Promise.withResolvers();
    this._placementContext = {data, previews, placed: [], index: 0, resolve, reject, create,
      createOptions: foundry.utils.deepClone(createOptions), allowRotation, onMove,
      onRotate, onChange, preConfirm, preSkip, preCommit, rotationNotification: false};

    // Register event listeners
    canvas.stage.on("pointermove", this.#placementPointerMoveListener);
    canvas.stage.on("pointerdown", this.#placementPointerDownListener);
    MouseInteractionManager.emulateMoveEvent();

    // Release other tokens and refresh token state
    this.releaseAll();
    this.setAllRenderFlags({refreshState: true});

    return promise;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle dropping of ActiveEffect data onto a Token, creating a new ActiveEffect on the corresponding Actor.
   * @param {DragEvent} event
   * @param {{type: "ActiveEffect"; uuid: string; x: number; y: number}} data
   * @internal
   */
  async _onDropActiveEffect(event, data) {
    if ( !this.active ) return;
    const effect = await ActiveEffect$1.implementation.fromDropData(data);
    if ( !effect ) return;
    const collisionTest = ({t: token}) => token.visible && token.renderable && token.interactive
      && token.hitArea?.contains(data.x - token.x, data.y - token.y);
    const target = Array.from(this.quadtree.getObjects(new PIXI.Rectangle(data.x, data.y, 0, 0), {collisionTest}))
      .sort((a, b) => a._lastSortedIndex - b._lastSortedIndex).at(0);
    const actor = target?.actor;
    if ( !actor ) return;
    await ActiveEffect$1.implementation.create(effect.toObject(), {parent: actor});
  }

  /* -------------------------------------------- */

  /**
   * 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) {
    if ( this._placementContext ) {
      const {previews, index, allowRotation, onRotate, onChange} = this._placementContext;
      if ( !allowRotation ) {
        if ( this._placementContext.rotationNotification ) return;
        ui.notifications.warn("TOKEN.NOTIFICATIONS.PlacedTokenRotationNotAllowed", {localize: true});
        this._placementContext.rotationNotification = true;
        return;
      }
      const preview = previews[index];
      const document = preview.document;
      const count = previews.length;
      const precise = !event.shiftKey;
      const {x, y, elevation, width, height, depth, shape, level, rotation} = document._source;
      const prior = {x, y, elevation, width, height, depth, shape, level, rotation};
      if ( !onRotate || (onRotate({event, preview, document, index, count, precise}) !== false) ) {
        const interval = canvas.grid.isHexagonal ? (precise ? 30 : 60) : (precise ? 15 : 45);
        const delta = interval * Math.sign(event.delta);
        document.updateSource({rotation: (document.rotation + delta).toNearest(interval)});
        preview.renderFlags.set({refreshRotation: true});
      }
      if ( onChange && (!TokenDocument.arePositionsEqual(document._source, prior)
        || (document._source.rotation !== prior.rotation)) ) onChange({preview, document, index, count});
      return;
    }

    if ( canvas.regions._placementContext ) {
      canvas.regions._onMouseWheel(event);
      return;
    }

    if ( this._draggedToken ) return this._draggedToken._onDragMouseWheel(event);

    if ( !this.controlled.length ) return;

    // 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._throttleRotateMany({delta, snap});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDismissKey(event) {
    if ( this._placementContext ) {
      this._cancelPlacement();
      return true;
    }
    if ( canvas.regions._placementContext ) {
      canvas.regions._cancelPlacement();
      return true;
    }
    const consumed = super._onDismissKey(event);
    if ( consumed ) return true;
    if ( this._movementPlanningContext ) {
      this._cancelMovementPlanning();
      return true;
    }
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Called when the placement is moved.
   * @param {PIXI.FederatedEvent} event    The pointer event
   */
  #movePlacement(event) {
    const {previews, index, onMove, onChange} = this._placementContext;
    const preview = previews[index];
    const document = preview.document;
    const count = previews.length;
    if ( !preview.parent ) {
      this.preview.addChild(preview);
      preview.draw();
    }
    const position = event.data.getLocalPosition(this);
    const snap = !event.shiftKey;
    const {x, y, elevation, width, height, depth, shape, level, rotation} = document._source;
    const prior = {x, y, elevation, width, height, depth, shape, level, rotation};
    if ( !onMove || (onMove({event, preview, document, index, count, position, snap}) !== false) ) {
      const pivot = document.getCenterPoint({x: 0, y: 0});
      let changes = {x: position.x - pivot.x, y: position.y - pivot.y};
      if ( snap ) changes = document.getSnappedPosition(changes);
      document.updateSource(changes);
      preview.renderFlags.set({refreshPosition: true});
    }
    if ( onChange && (!TokenDocument.arePositionsEqual(document._source, prior)
      || (document._source.rotation !== prior.rotation)) ) onChange({preview, document, index, count});
  }

  /* -------------------------------------------- */

  /**
   * Confirm the placement.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   */
  async #confirmPlacement(event) {
    const {previews, placed, index, preConfirm} = this._placementContext;
    const preview = previews[index];
    const document = preview.document;
    const count = previews.length;
    if ( preConfirm ) {
      const allowed = preConfirm({event, document, index, count});
      if ( allowed === false ) return;
    }
    placed.push(document);
    this.#nextPlacement(event);
  }

  /* -------------------------------------------- */

  /**
   * Skip the placement.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   */
  #skipPlacement(event) {
    const {previews, index, preSkip} = this._placementContext;
    const preview = previews[index];
    const document = preview.document;
    const count = previews.length;
    if ( preSkip ) {
      const allowed = preSkip({event, document, index, count});
      if ( allowed === false ) return;
    }
    if ( !preview.destroyed ) preview.destroy({children: true});
    this.#nextPlacement(event);
  }

  /* -------------------------------------------- */

  /**
   * Begin next placement or resolve if there's no next token.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   */
  async #nextPlacement(event) {
    const {previews} = this._placementContext;
    let {index} = this._placementContext;

    // If not all tokens have been placed yet, begin placement of the next token
    if ( index < previews.length - 1 ) {
      this._placementContext.index = index = index + 1;
      this.#movePlacement(event);
      return;
    }

    // Otherwise finish placement and create or return the token documents
    const {preCommit, placed, resolve, reject, create, createOptions} = this._placementContext;
    this.#finishPlacement();
    let tokens;
    try {
      let allowed = true;
      if ( preCommit ) {
        allowed = await preCommit(Object.freeze(placed));
        if ( allowed === undefined ) allowed = true;
      }
      if ( !allowed ) tokens = [];
      else if ( create && placed.length ) {
        const scene = placed[0].parent;
        tokens = await scene.createEmbeddedDocuments("Token", placed,
          {controlObject: true, ...createOptions, parent: scene});
      } else {
        tokens = placed;
        for ( const token of tokens ) {
          token._object = null;
          token._destroyed = false;
        }
      }
      resolve(tokens);
    } catch(err) {
      reject(err);
    } finally {
      for ( const preview of previews ) {
        if ( !preview.destroyed ) preview.destroy({children: true});
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Cancel the placement.
   * @internal
   */
  _cancelPlacement() {
    if ( !this._placementContext ) return;
    const {previews, resolve} = this._placementContext;
    for ( const preview of previews ) {
      if ( !preview.destroyed ) preview.destroy({children: true});
    }
    this.#finishPlacement();
    resolve([]);
  }

  /* -------------------------------------------- */

  /**
   * Reset the placement context and unregister all its event listeners.
   */
  #finishPlacement() {
    canvas.stage.off("pointermove", this.#placementPointerMoveListener);
    canvas.stage.off("pointerdown", this.#placementPointerDownListener);
    this._placementContext = null;
    this.setAllRenderFlags({refreshState: true});
  }

  /* -------------------------------------------- */

  /**
   * Cancel movement planning.
   * @internal
   */
  _cancelMovementPlanning() {
    if ( !this._movementPlanningContext ) return;
    const {object, resolve} = this._movementPlanningContext;
    this._movementPlanningContext = null;
    this.setAllRenderFlags({refreshState: true});
    if ( this._draggedToken === object ) {
      const manager = this._draggedToken.mouseInteractionManager;
      manager.interactionData.cancelled = true;
      manager.cancel();
    }
    resolve(null);
  }
}

class VisionMaskFilter extends AbstractBaseMaskFilter {
  /** @override */
  static _createFragmentShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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
    });
  }

  /**
   * Is this filter currently suppressed?
   * @type {boolean}
   */
  suppressed = false;

  /**
   * This filter is enabled if and only if it is not suppressed and `canvas.visibility.visible` is true.
   * This property cannot be set. Set {@link VisionMaskFilter#suppressed} to disable this filter.
   * @override
   */
  get enabled() {
    return !this.suppressed && canvas.visibility.visible;
  }

  set enabled(value) {}
}

/**
 * @import Region from "../placeables/region.mjs";
 * @import {RegionData} from "@common/documents/_types.mjs";
 * @import {Point} from "@common/_types.mjs";
 * @import { DatabaseCreateOperation } from "@common/abstract/_types.mjs";
 * @import TokenLayer from "./tokens.mjs";
 * @import {RegionPlacementOptions} from "./_types.mjs";
 */

/**
 * The Regions Container.
 * @category Canvas
 */
class RegionLayer extends ShapeLayerMixin(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";

  /** @inheritdoc */
  static paletteClass = RegionPalette;

  /* -------------------------------------------- */

  /**
   * 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 graphics used to draw the highlighted shape.
   * @type {PIXI.Graphics}
   */
  #highlight;

  /* -------------------------------------------- */

  /**
   * The highlight meshes of the Regions.
   * @type {PIXI.Container}
   * @internal
   */
  _highlights = this.#initializeHighlights();

  /* -------------------------------------------- */

  /**
   * The shape clipboard.
   * @type {{shape: BaseShapeData|null; cut: boolean}}
   * @internal
   */
  _shapeClipboard = {shape: null, cut: false};

  /* -------------------------------------------- */

  /**
   * The placement context.
   * @type {{
   *   data: RegionData;
   *   layer: RegionLayer|TokenLayer;
   *   regionIndex: number;
   *   regionCount: number;
   *   preview: Region;
   *   shapes: BaseShapeData[];
   *   shape: BaseShapeData;
   *   create: boolean;
   *   createOptions: Partial<Omit<DatabaseCreateOperation, "parent">>;
   *   destroyPreview: boolean;
   *   allowRotation: boolean;
   *   allowEmpty: boolean;
   *   attachToToken: boolean;
   *   onMove: (args: {event: PIXI.FederatedEvent; preview: Region;  document: RegionDocument; shape: BaseShapeData;
   *     index: number; count: number; position: Point; snap: boolean}) => boolean|void;
   *   onRotate: (args: {event: WheelEvent; preview: Region; document: RegionDocument; shape: BaseShapeData;
   *     index: number; count: number; precise: boolean}) => boolean|void;
   *   onChange: (args: {preview: Region; document: RegionDocument; shape: BaseShapeData; index: number;
   *     count: number}) => void;
   *   preConfirm: (args: {event: PIXI.FederatedEvent; document: RegionDocument; shape: BaseShapeData;
   *     index: number; count: number}) => boolean|void;
   *   preSkip: (args: {event: PIXI.FederatedEvent; document: RegionDocument; shape: BaseShapeData;
   *     index: number; count: number}) => boolean|void;
   *   preCommit: (documents: ReadonlyArray<RegionDocument>) => Promise<any|void>|void;
   *   resolve: (document: RegionDocument|null) => void;
   *   reject: (error: Error) => void;
   *   rotationNotification: boolean;
   * }|null}
   * @internal
   */
  _placementContext = null;

  /* -------------------------------------------- */

  /**
   * Handle pointermove events that move the placement.
   * @type {(event: PIXI.FederatedEvent) => void}
   */
  #placementPointerMoveListener = event => {
    canvas._onDragCanvasPan(event);
    this.#movePlacement(event);
  };

  /* -------------------------------------------- */

  /**
   * Handle pointerdown events that confirm or reset the placement.
   * @type {(event: PIXI.FederatedEvent) => void}
   */
  #placementPointerDownListener = event => {
    if ( event.button === 0 ) this.#confirmPlacement(event);
    else this.#skipPlacement(event);
  };

  /* -------------------------------------------- */

  /**
   * Is Measured Template Mode enabled?
   * @type {boolean}
   */
  get templateMode() {
    return this.#templateMode;
  }

  set templateMode(value) {
    if ( this.#templateMode === value ) return;
    this.#templateMode = value;
    this.setAllRenderFlags({refreshState: true});
    if ( this.#templateMode && this.active && ui.controls.tool?.creation ) this.releaseAll();
    this._togglePaletteVisible = !this.#templateMode && !(ui.controls.tool?.creation && this.controlled.length);
    ui.controls.render({reset: true});
  }

  #templateMode = !game.user.isGM;

  /* -------------------------------------------- */

  /**
   * Is the palette toggle visible?
   * @type {boolean}
   * @internal
   */
  _togglePaletteVisible = !this.#templateMode;

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Initialize the highlights container.
   * @returns {PIXI.Container}
   */
  #initializeHighlights() {
    const highlights = new PIXI.Container();
    highlights.filters = game.user.isGM ? [] : [VisionMaskFilter.create()];
    highlights.filterArea = canvas.app.screen;
    highlights.sortableChildren = true;
    highlights.eventMode = "none";
    return highlights;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _deactivate() {
    super._deactivate();
    this.objects.visible = true;
    this._cancelPlacement();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  storeHistory(type, data, options) {
    if ( type === "update" ) {
      for ( const d of data ) delete d.behaviors;
    }

    // Non-GMs cannot create behaviors
    else if ( (type === "delete") && !game.user.isGM ) {
      for ( const d of data ) d.behaviors = [];
    }

    super.storeHistory(type, data, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  copyObjects(options) {
    this._shapeClipboard.shape = null;
    return super.copyObjects(options);
  }

  /* -------------------------------------------- */


  /**
   * Copy (or cut) currently hovered shape.
   * @param {object} [options]             Additional options
   * @param {boolean} [options.cut=false]  Cut instead of copy? Default is false.
   */
  #copyShape({cut=false}={}) {
    this.clipboard.objects = [];
    this._shapeClipboard.shape = this.hover?.hoveredHandle?.controls.shape.clone() ?? null;
    this._shapeClipboard.cut = cut;
    if ( !this._shapeClipboard.shape ) return;
    ui.notifications.info(cut ? "REGION.NOTIFICATIONS.CutShape" : "REGION.NOTIFICATIONS.CopiedShape", {localize: true});
  }

  /* -------------------------------------------- */

  /**
   * Paste the copied/cut shape.
   * @param {Point} position               The destination of the shape
   * @param {object} [options]             Options which modify the paste operation
   * @param {boolean} [options.snap=true]  Snap the resulting objects to the grid. Default is true.
   */
  #pasteShape(position, {snap=true}={}) {
    let {shape, cut} = this._shapeClipboard;
    if ( !shape ) return;
    if ( !this.controlled.length ) {
      ui.notifications.error("REGION.NOTIFICATIONS.PastedShapeWithoutControlledRegion", {localize: true});
      return;
    }
    const region = this.controlled[0];
    const shapes = region.document.toObject().shapes;
    if ( cut ) {
      this._shapeClipboard.shape = null;
      if ( shape.parent === region.document ) shapes.splice(shape._index, 1);
      else {
        const shapes = shape.parent.toObject().shapes;
        shapes.splice(shape._index, 1);
        shape.parent.update({shapes});
      }
    }
    shape = shape.clone({}, {parent: region.document});
    shape.move(position, {snap});
    shapes.push(shape.toObject());
    region.document.update({shapes}, {isPaste: true});
    ui.notifications.info("REGION.NOTIFICATIONS.PastedShape", {localize: true});
  }

  /* -------------------------------------------- */

  /** @override */
  getZIndex() {
    return this.active ? this.options.zIndexActive : this.options.zIndex;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    this.addChild(this._highlights);
    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;

    /** @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});
        }
      }
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {
    this._cancelPlacement();
    this.removeChild(this._highlights);
    return super._tearDown(options);
  }

  /* -------------------------------------------- */

  /**
   * Highlight the shape or clear the highlight.
   * @param {BaseShapeData|null} shape    The shape to highlight, or null to clear the highlight
   * @internal
   */
  _highlightShape(shape) {
    this.#highlight.clear();
    this.#highlight.visible = false;
    if ( !shape ) return;
    const s = canvas.dimensions.uiScale;
    const thickness = CONFIG.Canvas.objectBorderThickness * s;
    const dash = thickness * 2;
    const gap = dash * (5 / 8);
    this.#highlight.visible = true;
    this.#highlight.lineStyle({
      width: thickness,
      color: 0x000000,
      join: PIXI.LINE_JOIN.ROUND,
      shader: new PIXI.smooth.DashLineShader({dash, gap})
    });
    shape.drawShape(this.#highlight);
  }

  /* -------------------------------------------- */

  /** @override */
  static prepareSceneControls() {
    const sc = SceneControls;
    const grid = canvas.grid ?? foundry.documents.BaseScene.defaultGrid;
    return {
      name: "regions",
      order: 7,
      title: "CONTROLS.GroupRegion",
      layer: "regions",
      icon: "fa-regular fa-game-board",
      visible: game.user.can("REGION_CREATE"),
      onChange: (event, active) => {
        if ( active ) canvas.regions.activate();
      },
      onToolChange: (event, tool, active) => {
        canvas.regions.setAllRenderFlags({refreshState: true});
        if ( !active ) return;
        if ( !tool.control ) canvas.regions.releaseAll();
        if ( !canvas.regions.templateMode ) {
          if ( tool.creation && canvas.regions.controlled.length ) {
            canvas.regions._togglePaletteVisible = false;
            if ( "togglePalette" in ui.controls.tools ) ui.controls.render({ reset: true });
          } else {
            canvas.regions._togglePaletteVisible = true;
            if ( !("togglePalette" in ui.controls.tools) ) ui.controls.render({ reset: true });
          }
        }
      },
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.RegionSelect",
          icon: "fa-solid fa-expand",
          interaction: true,
          control: true,
          toolclip: {
            src: "toolclips/tools/region-select.webm",
            heading: "CONTROLS.RegionSelect",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionSelectP"}, "selectAlt", "selectMultiple",
              "move", "rotate", "edit", "delete"])
          }
        },
        templateMode: {
          name: "templateMode",
          order: 2,
          title: "CONTROLS.RegionTemplateMode",
          icon: "fa-solid fa-ruler-combined",
          toggle: true,
          active: !!canvas.regions?.templateMode,
          toolclip: {
            heading: "CONTROLS.RegionTemplateMode",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionTemplateModeP"}])
          },
          onChange: (event, toggled) => {
            canvas.regions.templateMode = toggled;
          }
        },
        rectangle: {
          name: "rectangle",
          order: 3,
          title: "CONTROLS.RegionRectangle",
          icon: "fa-solid icon-fa-rectangle",
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "rectangle", x: 0, y: 0, width: 0, height: 0},
          toolclip: {
            heading: "CONTROLS.RegionRectangle",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw", "drawProportionally",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        circle: {
          name: "circle",
          order: 4,
          title: "CONTROLS.RegionCircle",
          icon: "fa-solid fa-circle",
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "circle", x: 0, y: 0, radius: 0},
          toolclip: {
            heading: "CONTROLS.RegionCircle",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw", "drawProportionally",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        ellipse: {
          name: "ellipse",
          order: 5,
          title: "CONTROLS.RegionEllipse",
          icon: "fa-solid icon-fa-ellipse",
          visible: !canvas.regions?.templateMode,
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "ellipse", x: 0, y: 0, radiusX: 0, radiusY: 0},
          toolclip: {
            heading: "CONTROLS.RegionEllipse",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw", "drawProportionally",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        cone: {
          name: "cone",
          order: 6,
          title: "CONTROLS.RegionCone",
          icon: "fa-solid icon-fa-cone",
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "cone", x: 0, y: 0, radius: 0, angle: 60},
          toolclip: {
            heading: "CONTROLS.RegionCone",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        ring: {
          name: "ring",
          order: 7,
          title: "CONTROLS.RegionRing",
          icon: "fa-regular fa-circle",
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "ring", x: 0, y: 0, radius: 0, innerWidth: 0, outerWidth: grid.size},
          toolclip: {
            heading: "CONTROLS.RegionRing",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        line: {
          name: "line",
          order: 8,
          title: "CONTROLS.RegionLine",
          icon: "fa-solid fa-horizontal-rule",
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "line", x: 0, y: 0, length: 0, width: grid.size},
          toolclip: {
            heading: "CONTROLS.RegionLine",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        emanation: {
          name: "emanation",
          order: 9,
          title: "CONTROLS.RegionEmanation",
          icon: "fa-solid fa-podcast",
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "emanation", base: {type: "token", x: 0, y: 0, width: 1, height: 1, shape:
            grid.isHexagonal ? CONST.TOKEN_SHAPES.ELLIPSE_1 : CONST.TOKEN_SHAPES.RECTANGLE_1}, radius: 0},
          toolclip: {
            heading: "CONTROLS.RegionEmanation",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        polygon: {
          name: "polygon",
          order: 10,
          title: "CONTROLS.RegionPolygon",
          icon: "fa-solid fa-draw-polygon",
          visible: !canvas.regions?.templateMode,
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "polygon", points: [0, 0, 0, 0]},
          toolclip: {
            heading: "CONTROLS.RegionPolygon",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              {heading: "CONTROLS.CommonDraw", content: "CONTROLS.DrawingPolyP"},
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        hole: {
          name: "hole",
          order: 11,
          title: "CONTROLS.RegionHole",
          icon: "fa-duotone fa-object-subtract",
          toggle: true,
          visible: !canvas.regions?.templateMode,
          active: false,
          toolclip: {
            heading: "CONTROLS.RegionHole",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionHoleP"}])
          }
        },
        snap: {
          name: "snap",
          order: 12,
          title: "CONTROLS.CommonForceSnap",
          icon: "fa-solid fa-plus",
          toggle: true,
          visible: !canvas.grid?.isGridless,
          active: canvas.forceSnapVertices,
          onChange: (event, toggled) => canvas.forceSnapVertices = toggled,
          toolclip: {
            heading: "CONTROLS.CommonForceSnap",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionSnap"}, "draw", "drawProportionally"])
          }
        },
        togglePalette: {
          ...this.TOGGLE_PALETTE,
          order: 13,
          visible: canvas.regions?._togglePaletteVisible
        },
        clear: {
          name: "clear",
          order: 14,
          title: "CONTROLS.RegionClear",
          icon: "fa-solid fa-trash",
          onChange: () => canvas.regions.deleteAll(),
          button: true
        }
      },
      activeTool: "select"
    };
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Place a Region at the cursor.
   * The Region can have multiple shapes but must have at least one.
   * Each shape is placed one after the other in the given order.
   * Only one Region can be placed at a time.
   * The placed Region shapes can be rotated with the mouse wheel unless `allowRotation` is false.
   * Left-click confirms the placement of a shape. Right-click skips the placement of a shape.
   * The Region layer is activated unless the Token layer is active.
   * @param {Partial<RegionData>} data          The data of the Region to place
   * @param {RegionPlacementOptions} [options]  Additional options
   * @returns {Promise<RegionDocument|null>}    The Region document that was placed or null if
   *   - the placements of all shapes were skipped unless `allowEmpty` is true,
   *   - the dismiss key was pressed,
   *   - the placement was rejected by `preCommit`,
   *   - the game was paused, the user is not a GM, and the `create` option is true, or
   *   - the Region creation was rejected by preCreate.
   * @example Attach a 10-foot emanation to a token.
   * ```js
   * await canvas.regions.placeRegion({
   *   name: "Aura",
   *   shapes: [{
   *     type: "emanation",
   *     base: {type: "token", x: 0, y: 0, width: 1, height: 1, shape: CONST.TOKEN_SHAPES.RECTANGLE_1},
   *     radius: 10 * canvas.dimensions.distancePixels,
   *     gridBased: true
   *   }],
   *   color: game.user.color,
   *   restriction: {enabled: true},
   *   levels: [canvas.level.id],
   *   highlightMode: "coverage",
   *   displayMeasurements: true,
   *   visibility: CONST.REGION_VISIBILITY.ALWAYS,
   *   ownership: {[game.user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER}
   * }, {attachToToken: true});
   * ```
   * @example Place four 40-foot radius circles.
   * ```js
   * const radius = 40 * canvas.dimensions.distancePixels;
   * const shapes = [];
   * for ( let i = 0; i < 4; i++ ) shapes.push({type: "circle", x: 0, y: 0, radius, gridBased: true});
   * await canvas.regions.placeRegion({
   *   name: "Meteor Swarm",
   *   shapes,
   *   color: game.user.color,
   *   restriction: {enabled: true},
   *   levels: [canvas.level.id],
   *   highlightMode: "coverage",
   *   displayMeasurements: true,
   *   visibility: CONST.REGION_VISIBILITY.ALWAYS,
   *   ownership: {[game.user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER}
   * });
   * ```
   * @example Spawn 10 tokens with random actor and random rotation in a placed circle with 30 grid units radius.
   * ```js
   * ui.notifications.info("Choose the placement for the spawn area.");
   * const spawnArea = await canvas.regions.placeRegion({
   *   name: "Spawn Area",
   *   shapes: [{
   *     type: "circle",
   *     x: 0,
   *     y: 0,
   *     radius: canvas.dimensions.distancePixels * 30
   *   }],
   *   restriction: {enabled: true},
   *   levels: [canvas.level.id]
   * }, {create: false});
   * if ( spawnArea ) {
   *   const {count: numTokensToSpawn=0} = await foundry.applications.api.DialogV2.input({
   *    window: {
   *       title: "How many tokens to you want to spawn?"
   *    },
   *    content: `<input type="number" name="count" min="0" step="1" value="10">`
   *   }) ?? {};
   *   const actors = game.actors.contents;
   *   const tokensToSpawn = [];
   *   for ( let i = 0; i < numTokensToSpawn; i++ ) {
   *     const actor = actors[Math.floor(Math.random() * actors.length)];
   *     const token = await actor.getTokenDocument({
   *       rotation: Math.random() * 360
   *     }, {parent: spawnArea.parent});
   *     tokensToSpawn.push(token);
   *   }
   *   const spawnedTokens = await spawnArea.spawnTokens(tokensToSpawn);
   * }
   * ```
   */
  async placeRegion(data, {create=true, createOptions={}, allowRotation=true, allowEmpty=false, attachToToken=false,
    onMove, onRotate, onChange, preConfirm, preSkip, preCommit, /** @private */ _destroyPreview=true,
    /** @private */ _regionIndex=0, /** @private */ _regionCount=1}={}) {
    if ( onMove && (typeof onMove !== "function") ) throw new Error("The onMove argument must be a function.");
    if ( onRotate && (typeof onRotate !== "function") ) throw new Error("The onRotate argument must be a function.");
    if ( onChange && (typeof onChange !== "function") ) throw new Error("The onChange argument must be a function.");
    if ( preConfirm && (typeof preConfirm !== "function") ) throw new Error("The preConfirm argument must be a function.");
    if ( preSkip && (typeof preSkip !== "function") ) throw new Error("The preSkip argument must be a function.");
    if ( preCommit && (typeof preCommit !== "function") ) throw new Error("The preCommit argument must be a function.");
    if ( !canvas.ready ) throw new Error("The canvas is not ready.");

    // Create preview document
    const document = new CONFIG.Region.documentClass(foundry.utils.deepClone(data), {parent: canvas.scene});
    const shapes = document.shapes.map(s => s.clone());

    if ( !shapes.length ) throw new Error("The Region must have at least one shape.");
    if ( attachToToken ) {
      if ( shapes.length !== 1 ) throw new Error("Only a single Region shape can be attached to a Token.");
      if ( document.attachment.token ) throw new Error("The Region is already attached to a Token.");
    }
    if ( document.attachment.token ) {
      if ( !document._source.levels.equals([document.attachment.token.level]) ) {
        throw new Error("A Region attached to a Token must be included only in the Token's level.");
      }
      if ( document.hidden !== document.attachment.token.hidden ) {
        throw new Error("A Region attached to a Token must have the same hidden state as the Token.");
      }
    }
    if ( document.restriction.enabled && (document.levels.size !== 1) ) {
      throw new Error("A restricted Region must be included only in one Level.");
    }
    if ( create && !document.canUserModify(game.user, "create") ) throw new Error("You do not have the necessary permissions to create this Region.");

    // Cancel the current placement if there is one
    this._cancelPlacement();
    canvas.tokens._cancelPlacement();

    // Cancel the current movement planning if there is one
    canvas.tokens._cancelMovementPlanning();

    // Cancel the current mouse wheel rotation if there is one
    this._cancelMouseWheel();

    // Cancel current drag workflow
    if ( canvas.currentMouseManager ) {
      canvas.currentMouseManager.interactionData.cancelled = true;
      canvas.currentMouseManager.cancel();
    }

    // Non-GMs cannot create a region while the game is paused
    if ( create && game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return null;
    }

    // Activate region or token layer
    const layer = canvas.tokens.active ? canvas.tokens : this;
    layer.activate({tool: "select"});

    // Initialize shapes
    data = document.toObject();
    const shape = shapes[0];
    document.updateSource({shapes: [shape], _shapeConstraints: null});

    // Create preview object
    const preview = new CONFIG.Region.objectClass(document);
    preview._previewType = "creation";
    document._object = preview;

    // Create placement context
    const {promise, resolve, reject} = Promise.withResolvers();
    this._placementContext = {data, layer, preview, regionIndex: _regionIndex, regionCount: _regionCount,
      shapes, shape, create, createOptions: foundry.utils.deepClone(createOptions),
      destroyPreview: _destroyPreview, allowRotation, allowEmpty, attachToToken, onMove, onRotate, onChange,
      preConfirm, preSkip, preCommit, resolve, reject, rotationNotification: false};

    // Register event listeners
    canvas.stage.on("pointermove", this.#placementPointerMoveListener);
    canvas.stage.on("pointerdown", this.#placementPointerDownListener);
    MouseInteractionManager.emulateMoveEvent();

    // Release other regions and refresh token/region state
    this.releaseAll();
    layer.setAllRenderFlags({refreshState: true});

    // Allow interaction with tokens if necessary
    if ( attachToToken && !canvas.tokens.active ) {
      canvas.tokens.interactiveChildren = true;
      canvas.tokens.setAllRenderFlags({refreshState: true});
    }

    return promise;
  }

  /* -------------------------------------------- */

  /**
   * Place one or multiple Regions at the cursor.
   * The Region can have multiple shapes but must have at least one.
   * Each Region is placed one after the other in the given order.
   * Each shape of a Region is placed one after the other in the given order.
   * The placed Region shapes can be rotated with the mouse wheel unless `allowRotation` is false.
   * Left-click confirms the placement of a shape. Right-click skips the placement of a shape.
   * @param {Iterable<Partial<RegionData>>} data The data of the Regions to place
   * @param {RegionPlacementOptions} [options]   Additional options
   * @returns {Promise<RegionDocument[]|null>}   The Region documents that were placed and not rejected by preCreate,
   *   or null if
   *   - the placement was rejected by `preCommit`,
   *   - the dismiss key was pressed, or
   *   - the game was paused, the user is not a GM, and the `create` option is true.
   * @example Place three 20-foot token emanations.
   * ```js
   * const data = [];
   * for ( let i = 0; i < 3; i++ ) data.push({
   *   name: `Emanation (${i + 1})`,
   *   shapes: [{
   *     type: "emanation",
   *     base: {
   *       type: "token",
   *       x: 0,
   *       y: 0,
   *       width: 1,
   *       height: 1,
   *       shape: CONST.TOKEN_SHAPES.ELLIPSE_1
   *     },
   *     radius: 20 * canvas.dimensions.distancePixels,
   *     gridBased: true
   *   }],
   *   restriction: {enabled: true},
   *   levels: [canvas.level.id],
   *   displayMeasurements: true,
   *   visibility: CONST.REGION_VISIBILITY.ALWAYS,
   *   ownership: {[game.user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER}
   * });
   * await canvas.regions.placeRegions(data, {attachToToken: true});
   */
  async placeRegions(data, {create=true, createOptions={}, allowRotation=true, allowEmpty=false, attachToToken=false,
    onMove, onRotate, onChange, preConfirm, preSkip, preCommit}={}) {
    const regionCount = data.length;
    if ( !regionCount ) throw new Error("There must be at least one Region to be placed.");
    if ( preCommit && (typeof preCommit !== "function") ) throw new Error("The preCommit argument must be a function.");
    const documents = [];
    const previews = [];
    try {
      data = Array.from(data, data => {
        data = foundry.utils.deepClone(data);

        // Multiple previews need a unique PlaceableObject#objectId and #sourceId
        data._id ||= foundry.utils.randomID();
        return data;
      });
      createOptions = foundry.utils.deepClone(createOptions);
      for ( const [regionIndex, d] of data.entries() ) {
        const document = await this.placeRegion(d, {create: false, allowEmpty: true, allowRotation, attachToToken,
          onMove, onRotate, onChange, preConfirm, preSkip, _destroyPreview: false, _regionIndex: regionIndex,
          _regionCount: regionCount});
        if ( !document ) return null;
        const preview = document.object;
        if ( document.shapes.length || allowEmpty ) {
          documents.push(document);
          previews.push(preview);
        }
        else if ( !preview.destroyed ) preview.destroy({children: true});
      }
      if ( preCommit ) {
        const allowed = await preCommit(Object.freeze(documents));
        if ( (allowed !== undefined) && !allowed ) return null;
      }
      let regions;
      if ( documents.length ) {
        if ( create ) {
          const scene = documents[0].parent;
          regions = await scene.createEmbeddedDocuments("Region", documents.map(d => d.toObject()),
            {controlObject: true, ...createOptions, parent: scene});
        } else {
          regions = documents;
          for ( const region of regions ) {
            region._object = null;
            region._destroyed = false;
          }
        }
      }
      return regions ?? [];
    } finally {
      for ( const preview of previews ) {
        if ( !preview.destroyed ) preview.destroy({children: true});
      }
    }
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @override */
  _createDragPreviewData(event) {
    let data = {};
    if ( game.user.isGM ) data.name = RegionDocument.implementation.defaultName({parent: canvas.scene});
    else {
      const takenNames = new Set();
      for ( const region of canvas.scene.regions ) {
        if ( region.isOwner ) takenNames.add(region.name);
      }
      const baseName = `${_loc(RegionDocument.implementation.metadata.label)} [${game.user.name}]`;
      let name = baseName;
      let index = 1;
      while ( takenNames.has(name) ) name = `${baseName} (${++index})`;
      data.name = name;
    }
    if ( this.templateMode ) {
      data.color = game.user.color;
      data.levels = [canvas.level.id];
      data.restriction = {enabled: true, type: "move"};
      data.visibility = CONST.REGION_VISIBILITY.ALWAYS;
      data.highlightMode = "coverage";
      data.displayMeasurements = true;
      data.ownership = {[game.user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER};
    }
    else data = foundry.utils.mergeObject(this.paletteCreateData, data, { inplace: false });
    return data;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _updateDragPreview(event) {
    super._updateDragPreview(event);
    event.interactionData.preview.document.updateShapeConstraints();
  }

  /* -------------------------------------------- */

  /** @override */
  _onDeleteKey(event) {
    if ( !this.controlled.length ) return false;
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return true;
    }

    // Delete only the shape if its control handle is hovered
    if ( this.hover?.hoveredHandle ) {
      const shape = this.hover.hoveredHandle.controls.shape;
      const document = shape.parent;
      if ( document.behaviors.size ) document.removeShapeDialog(shape);
      else document.update({shapes: document.shapes.toSpliced(shape._index, 1)});
      return true;
    }

    // Otherwise delete the entire region
    return super._onDeleteKey(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _onCutKey(event) {
    if ( this.hover?.hoveredHandle ) this.#copyShape({cut: true});
    else this.copyObjects({cut: true});
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _onCopyKey(event) {
    if ( this.hover?.hoveredHandle ) this.#copyShape();
    else this.copyObjects();
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _onPasteKey(event) {
    if ( this._shapeClipboard.shape ) {
      this.#pasteShape(canvas.mousePosition, {snap: !event.shiftKey});
      return true;
    }
    if ( this.clipboard.objects.length ) {
      this.pasteObjects(canvas.mousePosition, {hidden: event.altKey, snap: !event.shiftKey});
      return true;
    }
    return true;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onMouseWheel(event) {
    if ( this._placementContext ) {
      const {preview, regionIndex, regionCount, shapes, shape, allowRotation, onRotate,
        onChange} = this._placementContext;
      if ( !allowRotation ) {
        if ( this._placementContext.rotationNotification ) return;
        ui.notifications.warn("REGION.NOTIFICATIONS.PlacedRegionRotationNotAllowed", {localize: true});
        this._placementContext.rotationNotification = true;
        return;
      }
      const shapeIndex = shape._index;
      const shapeCount = shapes.length;
      const document = preview.document;
      const precise = !event.shiftKey;
      if ( !onRotate || (onRotate({event, preview, document, regionIndex, regionCount, shape, shapeIndex, shapeCount,
        precise}) !== false) ) {
        const interval = shape.type === "grid" ? (canvas.grid.isSquare ? 90 : 60) : (precise ? 5 : 15);
        const delta = interval * Math.sign(event.delta);
        shape.rotate((shape.rotation + delta).toNearest(interval) - shape.rotation);
      }
      const diff = document.updateSource({shapes: [...document.shapes.slice(0, -1), shape]});
      if ( foundry.utils.isEmpty(diff) ) return;
      document.updateShapeConstraints();
      preview.renderFlags.set({refreshShapes: true});
      if ( onChange ) onChange({preview, document, regionIndex, regionCount, shape, shapeIndex, shapeCount});
      return;
    }

    super._onMouseWheel(event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDismissKey(event) {
    if ( this._placementContext ) {
      this._cancelPlacement();
      return true;
    }
    return super._onDismissKey(event);
  }

  /* -------------------------------------------- */

  /**
   * Called when the placement is moved.
   * @param {PIXI.FederatedEvent} event    The pointer event
   */
  #movePlacement(event) {
    const {preview, regionIndex, regionCount, shapes, shape, onMove, onChange, attachToToken} = this._placementContext;
    const document = preview.document;
    const shapeIndex = shape._index;
    const shapeCount = shapes.length;
    if ( !preview.parent ) {
      this.preview.addChild(preview);
      preview.draw();
    }
    const position = event.getLocalPosition(this);
    const snap = !event.shiftKey;

    // Invoke a custom onMove handler, potentially preventing default handling
    const preventMove = onMove?.({event, preview, document, regionIndex, regionCount, shape, shapeIndex, shapeCount,
      position, snap}) === false;
    if ( !preventMove ) {
      if ( attachToToken ) this.#attachPlacementToToken(position);
      else shape.move(position, {snap});
    }
    const diff = document.updateSource({shapes: [...document.shapes.slice(0, -1), shape]});
    if ( foundry.utils.isEmpty(diff) ) return;
    document.updateShapeConstraints();
    preview.renderFlags.set({refreshShapes: true});
    if ( onChange ) onChange({preview, document, regionIndex, regionCount, shape, shapeIndex, shapeCount});
  }

  /* -------------------------------------------- */

  /**
   * Called if the `attachToToken` placement option is true on move.
   * @param {Point} position    The mouse position
   */
  #attachPlacementToToken(position) {
    const {preview, data, shape} = this._placementContext;
    const document = preview.document;
    const tokenEmanation = (shape.type === "emanation") && (shape.base.type === "token");
    const hoveredToken = canvas.tokens.hover?.document;
    if ( hoveredToken ) {
      let elevation;
      if ( tokenEmanation ) {
        const source = hoveredToken._source;
        elevation = {
          bottom: Number.isFinite(data.elevation.bottom) ? data.elevation.bottom + source.elevation : null,
          top: Number.isFinite(data.elevation.top) ? data.elevation.top + (source.elevation
            + (source.depth * canvas.grid.distance)) : null
        };
        shape.updateSource({
          base: {
            x: source.x,
            y: source.y,
            width: source.width,
            height: source.height,
            shape: source.shape
          }
        });
      } else {
        const center = hoveredToken.getMovementOrigin(hoveredToken._source);
        elevation = {
          bottom: Number.isFinite(data.elevation.bottom) ? data.elevation.bottom + center.elevation : null,
          top: Number.isFinite(data.elevation.top) ? data.elevation.top + center.elevation : null
        };
        shape.move(center);
      }
      document.updateSource({elevation, levels: [hoveredToken._source.level], hidden: hoveredToken.hidden,
        attachment: {token: hoveredToken.id}});
    } else {
      if ( tokenEmanation ) {
        const original = data.shapes[shape._index].base;
        shape.updateSource({base: {width: original.width, height: original.height, shape: original.shape}});
      }
      shape.move(position, {snap: true});
      document.updateSource({elevation: data.elevation, levels: data.levels, hidden: data.hidden,
        attachment: {token: null}});
    }
  }

  /* -------------------------------------------- */

  /**
   * Confirm the placement.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   */
  async #confirmPlacement(event) {
    const {preview, regionIndex, regionCount, shape, shapes, preConfirm} = this._placementContext;
    const document = preview.document;
    const shapeIndex = shape._index;
    const shapeCount = shapes.length;
    if ( preConfirm ) {
      const allowed = preConfirm({event, document, regionIndex, regionCount, shape, shapeIndex, shapeCount});
      if ( allowed === false ) return;
    }
    this.#nextPlacement(event);
  }

  /* -------------------------------------------- */

  /**
   * Skip the placement.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   */
  #skipPlacement(event) {
    const {preview, regionIndex, regionCount, shapes, shape, preSkip} = this._placementContext;
    const document = preview.document;
    const shapeIndex = shape._index;
    const shapeCount = shapes.length;
    if ( preSkip ) {
      const allowed = preSkip({event, document, regionIndex, regionCount, shape, shapeIndex, shapeCount});
      if ( allowed === false ) return;
    }
    document.updateSource({
      shapes: document.shapes.slice(0, -1),
      _shapeConstraints: document._shapeConstraints?.slice(0, -1) ?? null
    });
    preview.renderFlags.set({redraw: true});
    this.#nextPlacement(event);
  }

  /* -------------------------------------------- */

  /**
   * Begin next placement or resolve if there's no next shape.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   */
  async #nextPlacement(event) {
    const {preview, shapes} = this._placementContext;
    let {shape} = this._placementContext;
    const document = preview.document;

    // If not all shapes have been placed yet, begin placement of the next shape
    if ( shape._index < shapes.length - 1 ) {
      shape = this._placementContext.shape = shapes[shape._index + 1];
      document.updateSource({shapes: [...document.shapes, shape]});
      preview.renderFlags.set({redraw: true});
      this.#movePlacement(event);
      return;
    }

    // Otherwise finish placement and create or return the region document
    const {preCommit, allowEmpty, resolve, reject, create, createOptions, destroyPreview} = this._placementContext;
    this.#finishPlacement();
    let region;
    try {
      if ( document.shapes.length || allowEmpty ) {
        let allowed = true;
        if ( preCommit ) {
          allowed = await preCommit(Object.freeze([document]));
          if ( allowed === undefined ) allowed = true;
        }
        if ( !allowed ) region = null;
        else if ( create ) {
          const scene = document.parent;
          region = await CONFIG.Region.documentClass.create(document.toObject(),
            {controlObject: true, ...createOptions, parent: scene});
          if ( !region ) region = null;
        } else {
          region = document;
          if ( destroyPreview ) {
            region._object = null;
            region._destroyed = false;
          }
        }
      }
      resolve(region);
    } catch(err) {
      reject(err);
    } finally {
      if ( (!region || destroyPreview) && !preview.destroyed ) preview.destroy({children: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Cancel the placement.
   * @internal
   */
  _cancelPlacement() {
    if ( !this._placementContext ) return;
    const {preview, resolve} = this._placementContext;
    if ( !preview.destroyed ) preview.destroy({children: true});
    this.#finishPlacement();
    resolve(null);
  }

  /* -------------------------------------------- */

  /**
   * Reset the placement context and unregister all its event listeners.
   */
  #finishPlacement() {
    canvas.stage.off("pointermove", this.#placementPointerMoveListener);
    canvas.stage.off("pointerdown", this.#placementPointerDownListener);
    const {attachToToken} = this._placementContext;
    this._placementContext.layer.setAllRenderFlags({refreshState: true});
    this._placementContext = null;
    if ( attachToToken && !canvas.tokens.active ) {
      canvas.tokens.interactiveChildren = false;
      canvas.tokens.setAllRenderFlags({refreshState: true});
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _confirmDeleteKey(documents) {
    if ( documents.every(d => !d.behaviors.size) ) return true;
    return super._confirmDeleteKey(documents);
  }
}

/**
 * @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 ShapeLayerMixin(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",
      controllableObjects: true,
      zIndex: 900,
      confirmBeforeCreation: true
    });
  }

  /** @inheritdoc */
  static documentName = "AmbientSound";

  /** @inheritdoc */
  static paletteClass = AmbientSoundPalette;

  /* -------------------------------------------- */

  /** @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();
    this.setAllRenderFlags({refreshField: true});
  }

  /* -------------------------------------------- */

  /**
   * Initialize all AmbientSound sources which are present on this layer
   */
  initializeSources() {
    for ( const sound of this.sources ) sound.initialize();
  }

  /* -------------------------------------------- */

  /**
   * 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.sources.size ) return;
    for ( const source of this.sources ) source.refresh();
    if ( game.audio.locked ) {
      game.audio.pending.push(() => this.refresh(options));
      return;
    }
    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: canvas.level.elevation.base};
    return this._syncPositions([position], {fade: 50});
  }

  /* -------------------------------------------- */

  /**
   * Terminate playback of all ambient audio sources
   */
  stopAll() {
    this.placeables.forEach(s => s.sync(false));
    this.preview.children.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.getListenerPosition());
    if ( !listeners.length && !game.user.isGM ) {
      for ( const token of canvas.tokens.placeables ) {
        if ( token.actor?.isOwner && token.isVisible ) listeners.push(token.document.getListenerPosition());
      }
    }
    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.sources.size || game.audio.locked ) return;
    /** @type {Record<string, Partial<AmbientSoundPlaybackConfig>>} */
    const paths = {};
    for ( const source of this.sources ) {
      const object = source.object;
      const {path, easing, volume, walls} = object.document;
      if ( !path ) continue;
      const {sound} = 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 and surfaces
    if ( walls ) {
      config.muffled = false;
      return;
    }

    // Muffled by walls and surfaces
    const polygonCls = CONFIG.Canvas.polygonBackends.sound;
    const x = polygonCls.testCollision(config.listener, source.origin, {mode: "closest", type: "sound", source});
    config.muffled = (!!x && (x._distance < 1)) // Collided before reaching the source
      || canvas.scene.testSurfaceCollision(config.listener, source.origin, {type: "sound", mode: "any", level: canvas.level.id});
  }

  /* -------------------------------------------- */

  /**
   * 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 and surfaces 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: 6,
      title: "CONTROLS.GroupSound",
      layer: "sounds",
      icon: "fa-solid fa-music",
      visible: game.user.isGM,
      onChange: (_event, active) => {
        if ( active ) canvas.sounds.activate();
      },
      onToolChange: (_event, tool, active) => {
        canvas.sounds.setAllRenderFlags({refreshState: true});
        if ( active && !tool.control ) canvas.sounds.releaseAll();
      },
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.SoundSelect",
          icon: "fa-solid fa-expand",
          interaction: true,
          control: true,
          toolclip: {
            heading: "CONTROLS.SoundSelect",
            items: sc.buildToolclipItems(["selectAlt", "selectMultiple", "move", "edit", "delete"])
          }
        },
        sound: {
          name: "sound",
          order: 2,
          title: "CONTROLS.SoundDraw",
          icon: "fa-solid fa-volume-high",
          interaction: true,
          control: true,
          creation: true,
          toolclip: {
            heading: "CONTROLS.SoundDraw",
            items: sc.buildToolclipItems(["create", "select", "edit", "rotate", "onOff"])
          }
        },
        preview: {
          name: "preview",
          order: 3,
          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: {
            heading: "CONTROLS.SoundPreview",
            items: [{paragraph: "CONTROLS.SoundPreviewP"}]
          }
        },
        togglePalette: { ...this.TOGGLE_PALETTE, order: 4 },
        clear: {
          name: "clear",
          order: 5,
          title: "CONTROLS.SoundClear",
          icon: "fa-solid fa-trash",
          onChange: () => canvas.sounds.deleteAll(),
          button: true
        }
      },
      activeTool: "select"
    };
  }

  /* -------------------------------------------- */
  /*  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);
  }

  /* -------------------------------------------- */

  /** @override */
  _createDragShapeData(event) {
    return {type: "circle", x: 0, y: 0, radius: 0};
  }

  /* -------------------------------------------- */

  /** @override */
  _updateDragPreview(event) {
    const {shape, preview} = event.interactionData;
    const {x, y} = shape;
    const update = {x, y};
    update.radius = shape.radius / canvas.dimensions.distancePixels;
    preview.document.updateSource(update);
    preview.renderFlags.set({refreshTransform: true, refreshField: true});
    preview.initializeSoundSource();
  }

  /* -------------------------------------------- */

  /**
   * 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 = foundry.utils.mergeObject(this.paletteCreateData, {
      path: playlistSound.path,
      volume: playlistSound.volume,
      x: origin.x,
      y: origin.y
    }, { inplace: false });
    soundData.radius ||= canvas.dimensions.distance * 2;
    return this._createPreview(soundData, {top: event.clientY - 20, left: event.clientX + 40});
  }
}

/**
 * @deprecated since v14
 * @ignore
 */
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;
  }

  /* -------------------------------------------- */

  /** @override */
  get placeables() {
    return canvas.scene?.templates.map(document => document.object) || [];
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _getCopyableObjects(options) {
    if ( !game.user.can("TEMPLATE_CREATE") ) return [];
    return super._getCopyableObjects(options);
  }

  /* -------------------------------------------- */

  /** @override */
  activate() {
    canvas.regions.activate();
    return this;
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    this.zIndex = this.getZIndex();

    // The objects remain empty because now only preview Measured Templates are possible
    this.objects = this.addChild(new PIXI.Container());
    this.objects.sortableChildren = true;
    this.objects.visible = false;

    // 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";
  }

  /* -------------------------------------------- */

  /**
   * Register game settings used by the TemplatesLayer
   */
  static registerSettings() {
    /** @deprecated since v14 */
    game.settings.register("core", "gridTemplates", {
      name: "Grid-based Template Shapes",
      scope: "world",
      config: false,
      type: new foundry.data.fields.BooleanField({initial: false})
    });
    /** @deprecated since v14 */
    game.settings.register("core", "coneTemplateType", {
      name: "Cone Template Shape",
      scope: "world",
      config: false,
      type: new foundry.data.fields.StringField({required: true, blank: false, initial: "round", choices: ["round", "flat"]})
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static prepareSceneControls() {
    return {
      name: "templates",
      order: 0,
      title: "Measurement Controls",
      icon: "fa-solid fa-ruler-combined",
      // We hide the scene controls because Measured Templates are deprecated
      visible: false,
      tools: {}
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  _createDragPreviewData(event) {
    let {origin} = event.interactionData;
    if ( !event.shiftKey ) origin = this.getSnappedPoint(origin);
    const tool = game.activeTool;
    const data = {
      user: game.user.id,
      t: tool,
      x: origin.x,
      y: 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") data.angle = defaults.angle;
    else if ( tool === "ray" ) data.width = (defaults.width * canvas.dimensions.distance);
    return data;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftMove(event) {
    const {preview} = event.interactionData;
    if ( !preview || preview._destroyed ) return;

    // Compute the ray
    const origin = {x: preview.document.x, y: preview.document.y};
    let {destination} = event.interactionData;
    if ( !event.shiftKey ) destination = this.getSnappedPoint(destination);
    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.updateSource({direction: Math.normalizeDegrees(Math.toDegrees(ray.angle)), 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 CanvasLayer {
  constructor() {
    super();

    this.eventMode = "passive";

    // 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) || (user.viewedLevel !== canvas.level.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.expanded ) w -= document.getElementById("sidebar-content").getBoundingClientRect().width;
    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
   * @returns {GridHighlight|void}
   */
  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();
  }
}

/**
 * @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 ShapeLayerMixin(PlaceablesLayer) {

  /** @inheritdoc */
  static documentName = "Tile";

  /** @inheritdoc */
  static paletteClass = TilePalette;

  /* -------------------------------------------- */
  /*  Layer Attributes                            */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "tiles",
      zIndex: 300,
      controllableObjects: true,
      rotatableObjects: true,
      confirmBeforeCreation: 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 || [];
  }

  /* -------------------------------------------- */
  /*  Layer Methods                               */
  /* -------------------------------------------- */

  /** @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: 2,
      title: "CONTROLS.GroupTile",
      layer: "tiles",
      icon: "fa-solid fa-cubes",
      visible: game.user.isGM,
      onChange: (event, active) => {
        if ( active ) canvas.tiles.activate();
      },
      onToolChange: (_event, tool, active) => {
        canvas.tiles.setAllRenderFlags({refreshState: true});
        if ( active && !tool.control ) canvas.tiles.releaseAll();
      },
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.TileSelect",
          icon: "fa-solid fa-expand",
          interaction: true,
          control: true,
          toolclip: {
            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",
          creation: true,
          toolclip: {
            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: "texture",
            displayMode: "tiles",
            tileSize: true
          }).render({force: true}),
          toolclip: {
            heading: "CONTROLS.TileBrowser",
            items: sc.buildToolclipItems(["place", "move", "rotate", "hud", "edit", "delete"])
          }
        },
        snap: {
          name: "snap",
          order: 4,
          title: "CONTROLS.CommonForceSnap",
          icon: "fa-solid fa-plus",
          toggle: true,
          visible: !canvas.grid?.isGridless,
          active: canvas.forceSnapVertices,
          onChange: (event, toggled) => canvas.forceSnapVertices = toggled
        },
        togglePalette: { ...this.TOGGLE_PALETTE, order: 6 }
      },
      activeTool: "select"
    };
  }

  /* -------------------------------------------- */

  /** @override */
  _createDragPreviewData(event) {
    return foundry.utils.mergeObject({
      width: 1,
      height: 1,
      sort: Math.max(this.getMaxSort() + 1, 0)
    }, this.paletteCreateData);
  }

  /* -------------------------------------------- */

  /** @override */
  _createDragShapeData(event) {
    return {type: "rectangle", x: 0, y: 0, width: 0, height: 0, anchorX: 0.5, anchorY: 0.5};
  }

  /* -------------------------------------------- */

  /** @override */
  _updateDragPreview(event) {
    const interaction = event.interactionData;
    const {x, y, width, height, rotation} = interaction.shape;
    interaction.preview.document.updateSource({x, y, width, height, rotation});
    interaction.preview.renderFlags.set({refreshTransform: true});
  }

  /* -------------------------------------------- */

  /** @override */
  _updateMouseWheelPreview() {
    const {preview, shape} = this._mouseWheelContext;
    preview.document.updateSource({rotation: shape.rotation});
    preview.renderFlags.set({refreshRotation: true});
  }

  /* -------------------------------------------- */

  /**
   * 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
   * @protected
   */
  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 sort
    data.sort = Math.max(this.getMaxSort() + 1, 0);

    // Determine the final position and snap to grid unless SHIFT is pressed
    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;

    // Merge palette defaults
    const paletteData = this.paletteCreateData;
    foundry.utils.mergeObject(data, paletteData);

    return data;
  }
}

var _module$p = /*#__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$6
});

/**
 * @import VisionMode from "./vision-mode.mjs";
 */

/**
 * @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} refreshLighting          Refresh the rendered appearance of lighting
 * @property {boolean} refreshLightSources      Update the configuration of light sources
 * @property {boolean} refreshOcclusion         Refresh occlusion
 * @property {boolean} refreshOcclusionMask     Refresh occlusion mask
 * @property {boolean} refreshOcclusionStates   Refresh occlusion states
 * @property {boolean} refreshOccludedSurfaces  Refresh occluded surfaces
 * @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
 */


/**
 * @typedef {(typeof VisionMode.LIGHTING_VISIBILITY)[keyof typeof VisionMode.LIGHTING_VISIBILITY]} LightingVisibility
 */

var _types$5 = /*#__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;

  /**
   * Matrix used for fog rendering transformation.
   * @type {PIXI.Matrix}
   */
  #renderTransform = new PIXI.Matrix();

  /**
   * A debounced function to save fog of war exploration once a continuous stream of updates has concluded.
   * @type {Function}
   */
  #debouncedSave = foundry.utils.debounce(this.save.bind(this), 2000);

  /**
   * Debounced handler used to resync fog after the app becomes active again.
   * @type {Function}
   */
  #debouncedResume = foundry.utils.debounce(this.#onApplicationResumed.bind(this), 250);

  /**
   * Socket listener for explicit shared fog updates.
   * @type {Function}
   */
  #shareFogListener = this.#handleShareFog.bind(this);

  /**
   * Document visibilitychange listener.
   * @type {Function}
   */
  #visibilityListener = this.#onVisibilityChange.bind(this);

  /**
   * 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.mode > CONST.FOG_EXPLORATION_MODES.DISABLED;
  }

  /* -------------------------------------------- */

  /**
   * Does the currently viewed Scene is in shared fog exploration?
   * @type {boolean}
   */
  get sharedExploration() {
    return canvas.scene.fog.mode === CONST.FOG_EXPLORATION_MODES.SHARED;
  }

  /* -------------------------------------------- */

  /**
   * 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;
    const sprite = this.#explorationSprite;
    if ( sprite?.destroyed !== false ) return false;
    x = (x - sprite.x) / sprite.width;
    y = (y - sprite.y) / sprite.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 explored = this.#explored;
    const {realWidth, realHeight} = texture.baseTexture;
    const size = this.#extractor.format === PIXI.FORMATS.RED ? 1 : 4;
    const minBufferSize = realWidth * realHeight * size;
    if ( explored.buffer.byteLength < minBufferSize ) {
      explored.buffer = new ArrayBuffer(minBufferSize);
    }
    try {
      const {pixels, width, height, out} = await this.#extractor.extract(
        {texture, compression: TextureExtractor.COMPRESSION_MODES.NONE, out: explored.buffer});

      // A different fog texture was loaded while extraction was pending
      if ( this.#explorationSprite?.texture !== texture ) {
        explored.buffer = out;
        return;
      }

      // No change was detected, but ownership of the output buffer may have changed
      if ( !pixels ) {
        explored.buffer = out;
        return;
      }

      // Swap buffers so the previous pixels buffer can be reused on the next extraction
      explored.buffer = explored.pixels.buffer;
      explored.pixels = pixels;
      explored.width = width;
      explored.height = height;
      explored.offset = size - 1;
      explored.stride = size;
      this.#onExploredChanged();
    }
    finally {
      explored.extracting = false;
    }
  }

  /**
   * A throttled function that extract pixels from the fog texture.
   * @type {() => void}
   */
  #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 a valid FogExploration document for the current canvas context.
   * @param {Partial<FogExplorationData>} [data={}]
   * @returns {FogExploration}
   * @protected
   */
  _createExplorationDocument(data={}) {
    /** @type {typeof FogExploration} */
    const fogExplorationCls = getDocumentClass$1("FogExploration");
    return new fogExplorationCls({
      scene: canvas.scene.id,
      level: canvas.level.id,
      user: game.user.id,
      ...data
    });
  }

  /* -------------------------------------------- */

  /**
   * 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) {
    const {width, height} = this.textureConfiguration;
    const sprite = new SpriteMesh(tex ?? Canvas$1.getRenderTexture({
      clearColor: [0, 0, 0, 1],
      textureConfiguration: this.textureConfiguration
    }), FogSamplerShader);
    sprite.width = width;
    sprite.height = height;
    return sprite;
  }

  /* -------------------------------------------- */

  /**
   * 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();

    // Register socket listener once
    game.socket.off("shareFog", this.#shareFogListener);
    game.socket.on("shareFog", this.#shareFogListener);

    // Register activity listeners once
    document.removeEventListener("visibilitychange", this.#visibilityListener);
    document.addEventListener("visibilitychange", this.#visibilityListener);

    // Load the initial fog texture
    await this.load();
    this.#initialized = true;
  }

  /* -------------------------------------------- */

  /**
   * Handle document visibility changes.
   */
  #onVisibilityChange() {
    if ( document.hidden ) return;
    this.#debouncedResume();
  }

  /* -------------------------------------------- */

  /**
   * Resync shared fog after the app becomes active again.
   */
  async #onApplicationResumed() {
    if ( !this.#initialized ) return;
    if ( !canvas.scene ) return;

    // Only resync when shared exploration is active
    if ( !this.sharedExploration ) return;
    if ( !this.tokenVision || !this.fogExploration ) return;

    try {
      await this.load({preserve: true});
      canvas.perception.initialize();
      for ( const token of canvas.tokens.placeables ) token.initializeVisionSource();
    }
    catch(err) {
      console.error(err);
    }
  }

  /* -------------------------------------------- */

  /**
   * 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.#debouncedSave.cancel();
    this.#debouncedResume.cancel();

    // Unregister socket listener
    game.socket.off("shareFog", this.#shareFogListener);

    // Unregister activity listeners
    document.removeEventListener("visibilitychange", this.#visibilityListener);

    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 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 = -this.#explorationSprite.x;
    this.#renderTransform.ty = -this.#explorationSprite.y;

    // 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 ) this.exploration = this._createExplorationDocument();

    this.#debouncedSave();
  }

  /* -------------------------------------------- */

  /**
   * Load existing fog of war data from local storage and populate the initial exploration sprite.
   * @param {object} [options]
   * @param {boolean} [options.preserve=false]  Preserve current fog until the new one is ready.
   * @returns {Promise<PIXI.Texture|void>}
   */
  async load({preserve=false}={}) {
    return this.#queue.add(this.#load.bind(this), {preserve});
  }

  /* -------------------------------------------- */

  /**
   * Load existing fog of war data from local storage and populate the initial exploration sprite
   * @param {object} [options]
   * @param {boolean} [options.preserve=false]  Preserve current fog until the new one is ready.
   * @returns {Promise<PIXI.Texture|void>}
   */
  async #load({preserve=false}={}) {
    if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Loading saved FogExploration for Scene.");

    // Preserve mode keeps the currently displayed fog until the new one is ready
    if ( !preserve ) this.#deactivate();

    // Take no further action if token vision is not enabled
    if ( !this.tokenVision ) return;

    const scene = canvas.scene;
    const sceneId = scene?.id;
    if ( !sceneId ) return;

    // Union is only required in SHARED exploration mode
    const level = scene._view;
    const requireUnion = this.fogExploration && this.sharedExploration;
    const fogExplorationCls = /** @type {typeof FogExploration} */ getDocumentClass$1("FogExploration");

    // One load operation: server may return multiple docs, load() returns only the requested user's doc
    this.exploration = await fogExplorationCls.load({}, {queryOptions: {sharedFog: requireUnion}});

    const assign = async tex => {
      const sprite = this.#explorationSprite;

      // Preserve mode: swap texture without destroying the exploration container
      if ( preserve && sprite ) {
        const prev = sprite.texture;
        if ( prev !== tex ) {
          sprite.texture = tex;
          prev?.destroy?.(true);
        }
        canvas.visibility.resetExploration();
        canvas.perception.initialize();
        await this.#extractPixels();
        return tex;
      }

      if ( sprite?.texture === tex ) return tex;
      sprite?.destroy(true);

      this.#explorationSprite = this._createExplorationObject(tex);
      canvas.visibility.resetExploration();
      canvas.perception.initialize();
      await this.#extractPixels();
      return tex;
    };

    // If required, players use union of player fogs and GMs use union of player fogs + their private fog
    if ( requireUnion ) {
      const collection = game.collections.get("FogExploration");
      const fogs = collection.filter(f => {
        const fSceneId = f.scene?.id ?? f.scene;
        return (fSceneId === sceneId) && (f.level === level);
      });

      const {texture, updateData} = await this._unionizeSharedExploration(fogs);
      if ( canvas.scene?.id !== sceneId ) return;

      if ( updateData && !foundry.utils.isEmpty(updateData) ) {
        if ( !this.exploration ) this.exploration = this._createExplorationDocument();
        this.exploration.updateSource(updateData);
      }

      await assign(texture);
      return;
    }

    if ( !this.exploration ) {
      await assign(Canvas$1.getRenderTexture({
        clearColor: [0, 0, 0, 1],
        textureConfiguration: this.textureConfiguration
      }));
      return;
    }

    const tex = this.exploration.getTexture();
    if ( !tex ) {
      await assign(Canvas$1.getRenderTexture({
        clearColor: [0, 0, 0, 1],
        textureConfiguration: this.textureConfiguration
      }));
      return;
    }

    if ( !tex.baseTexture.valid ) await new Promise(resolve => tex.once("update", resolve));
    await assign(tex);
  }

  /* -------------------------------------------- */

  /**
   * Unionize exploration inputs for initial scene load.
   * Override this method to change union rules or to return additional exploration data (ex: positions).
   * This method must not perform any persistent DB operations.
   * @param {FogExploration[]} fogs
   * @returns {Promise<{texture: PIXI.RenderTexture, updateData: object|null}>}
   * @protected
   */
  async _unionizeSharedExploration(fogs) {
    const sources = this.#getSharedExplorationDocuments(fogs);
    const texture = await this.#buildSharedBaselineTexture(sources);
    return {texture, updateData: null};
  }

  /* -------------------------------------------- */

  /**
   * Select the FogExploration documents which contribute to shared exploration for the current level.
   * @param {FogExploration[]} fogs
   * @returns {FogExploration[]}
   */
  #getSharedExplorationDocuments(fogs) {
    const level = canvas.scene?._view;
    const docs = Array.isArray(fogs) ? fogs : [];

    // Union is always computed from player docs only
    const sources = docs.filter(f => f?.explored && (f.level === level) && !f.user?.isGM);

    // In SHARED mode, GMs are seeing union(players) + private
    if ( game.user.isGM && this.exploration?.explored && (this.exploration.level === level) ) {
      sources.push(this.exploration);
    }

    return sources;
  }

  /* -------------------------------------------- */

  /**
   * Build a unionized fog of war texture from fog exploration documents.
   * @param {FogExploration[]} fogs
   * @returns {Promise<PIXI.RenderTexture>}
   */
  async #buildSharedBaselineTexture(fogs) {
    const baseline = Canvas$1.getRenderTexture({
      clearColor: [0, 0, 0, 1],
      textureConfiguration: this.textureConfiguration
    });

    const sources = (Array.isArray(fogs) ? fogs : []).map(f => f?.explored).filter(Boolean);
    if ( !sources.length ) return baseline;

    await this.#compositeTextures(baseline, sources, {clear: true});
    return baseline;
  }

  /* -------------------------------------------- */

  /**
   * Handle a shared fog update from another client.
   * @param {object} data
   * @param {string} data.sceneId
   * @param {string} data.level
   * @param {string} data.explored
   * @param {object} [data.positions] optional positional/custom object.
   * @param {string} [data.from]  The sender user id.
   * @returns {Promise<void>}
   */
  async #handleShareFog({sceneId, level, explored, positions, from}={}) {
    if ( from === game.user.id ) return;
    return this.#queue.add(this.#applySharedFog.bind(this, sceneId, level, explored, positions));
  }

  /* -------------------------------------------- */

  /**
   * Apply a remote fog texture by unionizing it with the local exploration texture.
   * @param {string} sceneId
   * @param {string} level
   * @param {string} explored
   * @param {object} [positions] optional positional/custom object.
   * @returns {Promise<void>}
   */
  async #applySharedFog(sceneId, level, explored, positions) {
    if ( !sceneId || (sceneId !== canvas.scene?.id) ) return;
    if ( level !== canvas.scene?._view ) return;
    if ( typeof explored !== "string" || !explored.length ) return;
    if ( !this.tokenVision || !this.fogExploration ) return;

    positions = await this._applySharedExploration(explored, positions);

    if ( !this.exploration ) this.exploration = this._createExplorationDocument();
    if ( positions !== undefined ) this.exploration.updateSource({positions});

    // Persist the union immediately
    this._updated = true;
    this.#debouncedSave({share: false});
    this.#throttleExtractPixels();
  }

  /* -------------------------------------------- */

  /**
   * Apply shared exploration received from another client.
   * Subclasses may override this method to customize how explored texture and positions are merged locally.
   * @param {string} explored
   * @param {object} [positions]
   * @returns {Promise<object>}
   * @protected
   */
  async _applySharedExploration(explored, positions) {
    const rt = await this._createExplorationRenderTexture();
    await this.#compositeTextures(rt, [explored], {clear: false});
    return positions;
  }

  /* -------------------------------------------- */

  /**
   * Create a render texture for the exploration sprite if needed.
   * @returns {Promise<PIXI.RenderTexture>}
   * @protected
   */
  async _createExplorationRenderTexture() {
    const sprite = this.sprite;
    const current = sprite.texture;

    if ( current instanceof PIXI.RenderTexture ) return current;
    if ( current?.baseTexture && !current.baseTexture.valid ) {
      await new Promise(resolve => current.once("update", resolve));
    }

    const rt = Canvas$1.getRenderTexture({
      clearColor: [0, 0, 0, 1],
      textureConfiguration: this.textureConfiguration
    });

    this.#renderTransform.tx = -sprite.x;
    this.#renderTransform.ty = -sprite.y;

    // Copy existing exploration into the new RT
    canvas.app.renderer.render(sprite, {
      renderTexture: rt,
      clear: true,
      transform: this.#renderTransform
    });

    if ( current && (current !== PIXI.Texture.EMPTY) ) current.destroy(true);
    sprite.texture = rt;

    return rt;
  }

  /* -------------------------------------------- */

  /**
   * Composite textures into a render texture using MAX_COLOR.
   * @param {PIXI.RenderTexture} target
   * @param {(string|PIXI.Texture)[]} [sources=[]]
   * @param {object} [options]
   * @param {boolean} [options.clear=false]  Clear before the first draw.
   * @returns {Promise<void>}
   */
  async #compositeTextures(target, sources=[], {clear=false}={}) {
    const dims = canvas.dimensions;

    this.#renderTransform.tx = -dims.sceneX;
    this.#renderTransform.ty = -dims.sceneY;

    const mesh = new SpriteMesh(PIXI.Texture.EMPTY);
    mesh.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    mesh.position.set(dims.sceneX, dims.sceneY);
    mesh.width = this.textureConfiguration.width;
    mesh.height = this.textureConfiguration.height;

    try {
      let firstDraw = true;

      for ( const source of sources ) {
        if ( !source ) continue;

        let tex = null;
        let destroy = false;

        if ( source instanceof PIXI.Texture ) {
          tex = source;
          if ( tex?.baseTexture && !tex.baseTexture.valid ) {
            await new Promise(resolve => tex.once("update", resolve));
          }
        }
        else if ( typeof source === "string" ) {
          const bt = new PIXI.BaseTexture(source, {alphaMode: PIXI.ALPHA_MODES.NPM});
          tex = new PIXI.Texture(bt);
          destroy = true;
          if ( !tex.baseTexture.valid ) await new Promise(resolve => tex.once("update", resolve));
        }
        if ( !tex ) continue;

        mesh.texture = tex;
        canvas.app.renderer.render(mesh, {
          renderTexture: target,
          clear: clear && firstDraw,
          transform: this.#renderTransform
        });

        firstDraw = false;
        if ( destroy ) tex.destroy(true);
      }
    }
    finally {
      mesh.destroy({children: true, texture: false, baseTexture: false});
    }
  }

  /* -------------------------------------------- */

  /**
   * 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.
   * @param {object} [options]
   * @param {boolean} [options.share=false]  Broadcast the fog to other clients for local unionization.
   * @returns {Promise<void>}
   */
  async save({share=false}={}) {
    return this.#queue.add(this.#save.bind(this), {share});
  }

  /* -------------------------------------------- */

  /**
   * Perform a fog of war save operation.
   * @param {object} [options]
   * @param {boolean} [options.share=false]
   * @returns {Promise<void>}
   */
  async #save({share=false}={}) {
    const needsPersist = this._updated;
    if ( !needsPersist && !share ) return;

    // Share-only shortcut: trigger server-side broadcast using the last persisted payload
    const canShareWithoutExtract = share && this.exploration?.id && this.exploration.explored;
    if ( canShareWithoutExtract && !needsPersist ) {
      await this.#updateFogExploration({timestamp: Date.now()}, {share: true});
      return;
    }

    if ( needsPersist ) this._updated = false;

    if ( !this.#extractor ) {
      console.error("FogManager | Browser does not support texture extraction.");
      return;
    }

    if ( !this.exploration ) this.exploration = this._createExplorationDocument();

    const exploration = this.exploration;

    if ( CONFIG.debug.fog.manager ) {
      console.debug("FogManager | Initiate non-blocking extraction of the fog of war progress.");
    }

    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;

    // If we got a new payload, persist it and optionally broadcast it
    let updateData;
    if ( base64Image ) updateData = this._prepareFogUpdateData(base64Image);
    else if ( share ) updateData = {timestamp: Date.now()};
    else {
      if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Fog of war has not changed. Skipping db operation.");
      return;
    }

    await this.#updateFogExploration(updateData, {share});
  }

  /* -------------------------------------------- */

  /**
   * 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();
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * The configured options used for fog base64 extraction.
   * @returns {{type: string, quality: number}}
   * @protected
   */
  _getBase64ExtractionConfiguration() {
    return {
      type: "image/webp",
      quality: 0.8
    };
  }

  /* -------------------------------------------- */

  /**
   * Extract fog data as a base64 string
   * @returns {Promise<string>}
   * @protected
   */
  _extractBase64() {
    return this.#extractor.extract({
      texture: this.#explorationSprite.texture,
      compression: TextureExtractor.COMPRESSION_MODES.BASE64,
      ...this._getBase64ExtractionConfiguration()
    });
  }

  /* -------------------------------------------- */

  /**
   * 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
   * @param {object} [options]
   * @param {boolean} [options.share=false]  Ask the server to share fog to other clients for union.
   * @returns {Promise<void>}
   */
  async #updateFogExploration(updateData, {share=false}={}) {
    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.");

    const operation = {loadFog: false, share};
    if ( !this.exploration.id ) {
      this.exploration.updateSource(updateData);
      this.exploration = await this.exploration.constructor.create(this.exploration.toJSON(), operation);
    }
    else await this.exploration.update(updateData, operation);
  }

  /* -------------------------------------------- */

  /**
   * Deactivate fog of war.
   * Clear all shared containers by unlinking them from their parent.
   * Destroy all stored textures and graphics.
   */
  #deactivate() {
    // Cancel scheduled saves for the previous scene/load cycle
    this.#debouncedSave?.cancel?.();

    // 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;

    // 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, ElevatedPoint} from "@client/_types.mjs";
 * @import {TokenDetectionMode} from "@common/documents/_types.mjs";
 * @import PointVisionSource from "../sources/point-vision-source.mjs";
 * @import Level from "@client/documents/level.mjs";
 * @import {PointSourcePolygonConfig} from "../geometry/_types.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 and surfaces
      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, level, tests}) {
    if ( !mode.enabled ) return false;
    if ( !this._canDetect(visionSource, object, level) ) 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
   * @param {Level} level                      The level the target is in
   * @returns {boolean}                        Can the target object theoretically be detected by this vision source?
   * @protected
   */
  _canDetect(visionSource, target, level) {

    // Cannot see if blinded by darkness
    if ( this.walls && visionSource.blinded.darkness ) 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 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 not constrained by walls and surfaces, we only need to test whether the point is within the vision angle
    if ( !this.walls ) return this._testAngle(visionSource, mode, target, test);

    // If the detection is not constrained by the vision angle unlike the LOS polygon
    if ( !this.angle && (visionSource.data.angle < 360) ) {
      return !this.constructor._testCollision(visionSource, test, {...visionSource.los.config, angle: 360});
    }

    // If the detection is constrained by vision angle
    let hasLOS = test.los.get(visionSource);
    if ( hasLOS === undefined ) {
      hasLOS = !this.constructor._testCollision(visionSource, test, visionSource.los);
      test.los.set(visionSource, hasLOS);
    }
    return hasLOS;
  }

  /* -------------------------------------------- */

  /**
   * Test for LOS collision.
   * For both the segment in the viewed level and in the target level, a ray is cast with the given configuration.
   * @overload
   * @param {PointVisionSource} visionSource                                      The vision source
   * @param {Omit<CanvasVisibilityTest, "los">} test                              The test
   * @param {Partial<Omit<PointSourcePolygonConfig, "source"|"level">>} [config]  The configuration
   * @returns {boolean}  True if there's a collision or the point is not in the vision angle, otherwise false
   * @protected
   */
  /**
   * Test for LOS collision.
   * For the segment of the ray in the viewed level, `los` is tested.
   * For the segment of the ray in the target level, a ray is cast with the configuration of `los`.
   * @overload
   * @param {PointVisionSource} visionSource                  The vision source
   * @param {Pick<CanvasVisibilityTest, "point"|"los">} test  The test
   * @param {PointSourcePolygon} los                          The LOS polygon
   * @returns {boolean}  True if there's a collision or the point is not in the vision angle, otherwise false
   * @protected
   */
  /**
   * Test for LOS collision.
   * @param {PointVisionSource} visionSource
   * @param {Partial<Pick<CanvasVisibilityTest, "point"|"los">>} test
   * @param {Partial<Omit<PointSourcePolygonConfig, "source"|"level">>|PointSourcePolygon} [configOrLos]
   * @returns {boolean}
   * @protected
   */
  static _testCollision(visionSource, test, configOrLos={}) {
    const {origin, level: originLevel} = visionSource;
    const {point: destination, level: destinationLevel} = test;
    const los = configOrLos instanceof PointSourcePolygon ? configOrLos : null;
    const config = los?.config ?? configOrLos;
    const {angle=360, rotation=0, externalRadius=0} = config;
    if ( (angle < 360) && (!los || (externalRadius > 0)) ) {
      const dx = destination.x - origin.x;
      const dy = destination.y - origin.y;
      if ( (dx * dx) + (dy * dy) > (externalRadius * externalRadius) ) {
        if ( angle <= 0 ) return true;
        const aMin = rotation + 90 - (angle / 2);
        const a = Math.toDegrees(Math.atan2(dy, dx));
        if ( (((a - aMin) % 360) + 360) % 360 > angle ) return true;
      }
    }
    const t = DetectionMode.#getIntermediateTValue(visionSource, test);
    if ( los ) {
      const intermediateX = Math.mix(origin.x, destination.x, t);
      const intermediateY = Math.mix(origin.y, destination.y, t);
      if ( !los.contains(intermediateX, intermediateY) ) return true;
      const type = config.type ?? visionSource.constructor.sourceType;
      const options = {type, mode: "any", tMin: 0, tMax: t, level: originLevel};
      if ( canvas.scene.testSurfaceCollision(origin, destination, options) ) return true;
    }
    else if ( DetectionMode.#testCollision(visionSource, destination,
      {...config, tMin: 0, tMax: t, level: originLevel}) ) {
      return true;
    }
    return DetectionMode.#testCollision(visionSource, destination,
      {...config, tMin: t, tMax: 1, level: destinationLevel});
  }

  /* -------------------------------------------- */

  /**
   * Test for collision.
   * @param {PointVisionSource} visionSource    The vision source
   * @param {ElevatedPoint} destination         The destination
   * @param {Partial<Omit<PointSourcePolygonConfig, "source"> & {tMin: number; tMax: number}>} [config]
   *                                            The configuration
   * @returns {boolean}
   */
  static #testCollision(visionSource, destination, config) {
    const type = config.type ?? visionSource.constructor.sourceType;
    const origin = visionSource.origin;
    config = {priority: visionSource.priority, ...config, type, mode: "any", source: visionSource};
    const {tMin, tMax, level} = config;
    if ( canvas.scene.testSurfaceCollision(origin, destination, {type, mode: "any", tMin, tMax, level}) ) return true;
    return CONFIG.Canvas.polygonBackends[type].testCollision(origin, destination, config);
  }

  /* -------------------------------------------- */

  /**
   * Get the intermediate t-value for LOS testing.
   * @param {PointVisionSource} visionSource                  The vision source
   * @param {Pick<CanvasVisibilityTest, "point"|"los">} test  The test
   */
  static #getIntermediateTValue(visionSource, test) {
    const {origin, level: originLevel} = visionSource;
    const {point: destination, level: destinationLevel} = test;
    if ( originLevel === destinationLevel ) return 1;
    const delta = destination.elevation - origin.elevation;

    let to0;
    let to1;
    let td0;
    let td1;
    if ( delta !== 0 ) {

      // Calculate the origin level ray intersection interval
      to0 = (originLevel.elevation.bottom - origin.elevation) / delta;
      to1 = (originLevel.elevation.top - origin.elevation) / delta;
      if ( to0 > to1 ) [to0, to1] = [to1, to0];

      // Calculate the destination level ray intersection interval
      td0 = (destinationLevel.elevation.bottom - origin.elevation) / delta;
      td1 = (destinationLevel.elevation.top - origin.elevation) / delta;
      if ( td0 > td1 ) [td0, td1] = [td1, td0];
    } else {

      // Calculate the origin level ray intersection interval
      to0 = originLevel.elevation.bottom <= origin.elevation ? -Infinity : Infinity;
      to1 = originLevel.elevation.top >= origin.elevation ? Infinity : -Infinity;

      // Calculate the destination level ray intersection interval
      td0 = destinationLevel.elevation.bottom <= origin.elevation ? -Infinity : Infinity;
      td1 = destinationLevel.elevation.top >= origin.elevation ? Infinity : -Infinity;
    }

    // If the ray does not intersect the destination level, we test only in the origin level.
    if ( (td0 > 1) || (td1 < 0) ) return 1;
    // Otherwise the ray intersects the destination level.

    // If the ray does not intersect the origin level, we test only in the destination level.
    if ( (to0 > 1) || (to1 < 0) ) return 0;
    // Otherwise the ray intersects the origin level.

    // Now if the ray exits the destination level before the origin level, we test only in the origin level.
    to1 = Math.min(to1, 1);
    td1 = Math.min(td1, 1);
    if ( to1 > td1 ) return 1;
    // Otherwise the ray exits the origin level first or both at the same time.

    // Now if the ray enters the destination level before the origin level, we test only the destination level.
    to0 = Math.max(to0, 0);
    td0 = Math.max(td0, 0);
    if ( to0 > td0 ) return 0;
    // Otherwise the ray enters the origin level first or both at the same time.

    // We test in the origin level up until the point where the ray has exited the origin level and has entered
    // the destination level, and from that point onward we test in the destination level.
    return Math.max(to1, td0);
  }

  /* -------------------------------------------- */

  /**
   * 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;
    if ( angle <= 0 ) return false;
    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, elevation} = visionSource.data;
    if ( Math.abs(point.elevation - elevation) > range + 1e-8 ) return false;
    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() {
    const cls = this.constructor.name;
    const message = `${cls}#BASIC_MODE_ID is deprecated. Please use ${cls}.BASIC_MODE_ID instead.`;
    foundry.utils.logCompatibilityWarning(message, {since: 13, until: 15, once: true});
    return DetectionMode.BASIC_MODE_ID;
  }
}

/**
 * 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 get defaultUniforms() {
    return {
      outlineColor: [1, 1, 1, 1],
      thickness: [1, 1],
      alphaThreshold: 0.60,
      knockout: true,
      wave: false
    };
  }

  /** @override */
  static _createVertexShader() {
    return `
    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;

  /* -------------------------------------------- */

  /** @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);
  }
}

/**
 * Detection mode that see ALL creatures (no blockers).
 * If not constrained by walls and surfaces, 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 if blinded by darkness
    if ( this.walls && visionSource.blinded.darkness ) return false;

    // 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);
  }
}

/**
 * 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;

  /** @override */
  static get defaultUniforms() {
    return {
      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 ${PIXI.Program.defaultFragmentPrecision} 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;
      }
    }`;
  }

  /** @override */
  static _createVertexShader() {
    return `
    precision ${PIXI.Program.defaultFragmentPrecision} 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);
    }`;
  }

  /** @override */
  static create(uniforms={}) {
    const u = {...this.defaultUniforms, ...uniforms};
    const vertexShader = this._createVertexShader();
    const fragmentShader = this._createFragmentShader(u.quality, u.distance);
    return new this(vertexShader, fragmentShader, u);
  }

  /* -------------------------------------------- */

  /** @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);
  }
}

/**
 * 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 if blinded by darkness
    if ( this.walls && visionSource.blinded.darkness ) return false;

    // 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$o = /*#__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$5
});

/**
 * @import {ElevatedPoint} from "@common/_types.mjs";
 */

/**
 * @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
 */

/**
 * @typedef RegionAnimationState
 * @property {ReadonlyArray<BaseShapeData>} shapes
 * @property {{bottom: number; top: elevation; topInclusive: boolean}} elevation
 * @property {PIXI.Polygon} polygons
 * @property {PolygonTree} polygonTree
 * @property {ClipperPoint[][]} clipperPaths
 * @property {ClipperLib.PolyTree} clipperPolyTree
 * @property {{vertices: Float32Array; indices: Uint16Array|Uint32Array}} triangulation
 * @property {PIXI.Rectangle} bounds
 * @property {number} area
 * @property {(point: ElevatedPoint) => boolean} testPoint
 */

var _types$4 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

var _module$n = /*#__PURE__*/Object.freeze({
  __proto__: null,
  DrawingShapeControls: DrawingShapeControls
});

/**
 * @import AmbientLightDocument from "@client/documents/ambient-light.mjs";
 * @import AmbientLight from "@client/canvas/placeables/ambient-light.mjs";
 * @import LightingLayer from "@client/canvas/layers/lighting.mjs";
 * @import {CircleShapeData, ConeShapeData} from "@common/data/_module.mjs";
 */

/**
 * Controls for a AmbientLight shape.
 * @extends {ShapeControls<AmbientLightDocument, AmbientLight, LightingLayer, CircleShapeData|ConeShapeData>}
 */
class AmbientLightShapeControls extends ShapeControls {

  /** @inheritDoc */
  _onDragStart(event) {
    super._onDragStart(event);
    const {dim, bright} = this.document.config;
    const radius = Math.max(dim, bright);
    event.interactionData.dimRatio = dim / radius;
    event.interactionData.brightRatio = bright / radius;

    // Handle transformation of other controlled sources
    if ( event.interactionData.handle.name === "translate" ) {
      const others = event.interactionData.others = [];
      for ( const object of this.layer.controlled ) {
        if ( object !== this.object ) others.push(ShapeControls._createDragPreview(object));
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _updateDragPreview(event) {
    const {shape, preview, dimRatio, brightRatio} = event.interactionData;
    const {x, y} = shape;
    const radius = shape.radius / canvas.dimensions.distancePixels;
    const dim = dimRatio * radius;
    const bright = brightRatio * radius;
    const data = {x, y, config: {dim, bright}};
    if ( shape.type === "cone" ) {
      data.rotation = shape.rotation - 90;
      data.config.angle = shape.angle;
    }
    preview.document.updateSource(data);
    preview.renderFlags.set({refreshTransform: true, refreshField: true});
    preview.initializeLightSource();

    // Handle translation of other controlled sources
    if ( !event.interactionData.others?.length ) return;
    const dx = preview.document.x - this.document.x;
    const dy = preview.document.y - this.document.y;
    for ( const preview of event.interactionData.others ) {
      const {x, y} = preview._original.document._source;
      preview.document.updateSource({x: x + dx, y: y + dy});
      preview.renderFlags.set({refreshPosition: true, refreshField: true});
      preview.initializeLightSource();
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareDragDropUpdate(event) {
    const {x, y, rotation, config: {dim, bright, angle}} = event.interactionData.preview.document._source;
    return {x, y, rotation, config: {dim, bright, angle}};
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragDrop(event) {

    // Handle translation of multiple controlled sources
    if ( event.interactionData.others?.length ) {
      event.interactionData.clearPreviewContainer = false;
      const {x, y, rotation, config: {dim, bright, angle}} = event.interactionData.preview.document._source;
      const updates = [{_id: this.document.id, x, y, rotation, config: {dim, bright, angle}}];
      for ( const preview of event.interactionData.others ) {
        const object = preview._original;
        const {x, y, rotation, config: {dim, bright, angle}} = preview.document._source;
        updates.push({_id: object.document.id, x, y, rotation, config: {dim, bright, angle}});
      }
      this.document.parent.updateEmbeddedDocuments("AmbientLight", updates).finally(() => {
        this.layer.clearPreviewContainer();
      });
      return;
    }

    return super._onDragDrop(event);
  }
}

var _module$m = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AmbientLightShapeControls: AmbientLightShapeControls
});

/**
 * @import Region from "@client/canvas/placeables/region.mjs";
 * @import RegionDocument from "@client/documents/region.mjs";
 * @import RegionLayer from "@client/canvas/layers/regions.mjs";
 */

/**
 * Controls for a Region shape.
 * @extends {ShapeControls<RegionDocument, Region, RegionLayer>}
 */
class RegionShapeControls extends ShapeControls {

  /** @inheritDoc */
  _updateDragPreview(event) {
    super._updateDragPreview(event);
    event.interactionData.preview.document.updateShapeConstraints();
  }

  /* -------------------------------------------- */

  /** @override */
  _onClick2(event) {
    if ( this.document.isSingleShape ) this.document.sheet.render({force: true, tab: "shapes"});
    else new foundry.applications.apps.ShapeConfig({shape: this.shape}).render({force: true});
  }
}

var _module$l = /*#__PURE__*/Object.freeze({
  __proto__: null,
  RegionGeometry: RegionGeometry,
  RegionMesh: RegionMesh,
  RegionShapeControls: RegionShapeControls
});

/**
 * @import AmbientSoundDocument from "@client/documents/ambient-sound.mjs";
 * @import AmbientSound from "@client/canvas/placeables/ambient-sound.mjs";
 * @import SoundsLayer from "@client/canvas/layers/sounds.mjs";
 * @import {CircleShapeData} from "@common/data/_module.mjs";
 */

/**
 * Controls for a AmbientSound shape.
 * @extends {ShapeControls<AmbientSoundDocument, AmbientSound, SoundsLayer, CircleShapeData>}
 */
class AmbientSoundShapeControls extends ShapeControls {

  /** @inheritDoc */
  _onDragStart(event) {
    super._onDragStart(event);

    // Handle transformation of other controlled sources
    if ( event.interactionData.handle.name === "translate" ) {
      const others = event.interactionData.others = [];
      for ( const object of this.layer.controlled ) {
        if ( object !== this.object ) others.push(ShapeControls._createDragPreview(object));
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _updateDragPreview(event) {
    const {shape, preview} = event.interactionData;
    const {x, y} = shape;
    const radius = shape.radius / canvas.dimensions.distancePixels;
    preview.document.updateSource({x, y, radius});
    preview.renderFlags.set({refreshTransform: true, refreshField: true});
    preview.initializeSoundSource();

    // Handle translation of other controlled sources
    if ( !event.interactionData.others?.length ) return;
    const dx = preview.document.x - this.document.x;
    const dy = preview.document.y - this.document.y;
    for ( const preview of event.interactionData.others ) {
      const object = preview._original;
      const {x, y} = object.document._source;
      preview.document.updateSource({x: x + dx, y: y + dy});
      preview.renderFlags.set({refreshPosition: true, refreshField: true});
      preview.initializeSoundSource();
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareDragDropUpdate(event) {
    const {x, y, radius} = event.interactionData.preview.document._source;
    return {x, y, radius};
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragDrop(event) {

    // Handle translation of multiple controlled sources
    if ( event.interactionData.others?.length ) {
      event.interactionData.clearPreviewContainer = false;
      const {x, y, radius} = event.interactionData.preview.document._source;
      const updates = [{_id: this.document.id, x, y, radius}];
      for ( const preview of event.interactionData.others ) {
        const object = preview._original;
        const {x, y, radius} = preview.document._source;
        updates.push({_id: object.document.id, x, y, radius});
      }
      this.document.parent.updateEmbeddedDocuments("AmbientSound", updates).finally(() => {
        this.layer.clearPreviewContainer();
      });
      return;
    }

    return super._onDragDrop(event);
  }
}

var _module$k = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AmbientSoundShapeControls: AmbientSoundShapeControls
});

/**
 * 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, _options) {
    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(source) {
    const isConstructed = this.initial?.prototype?.constructor === this.initial;
    if ( isConstructed ) return this.initial;
    return super.getInitialValue(source);
  }
}

/* -------------------------------------------- */

/**
 * 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;
  }
}

/**
 * @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, TokenPlannedMovementWaypoint, 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";

  /* -------------------------------------------- */

  /**
   * The waypoint draw functions.
   * @enum {(g: PIXI.Graphics, x: number, y: number, radius: number) => void}
   */
  static #WAYPOINT_DRAW_FUNCTIONS = {
    circle: (g, x, y, radius) => {
      g.drawCircle(x, y, radius);
    },
    square: (g, x, y, radius) => {
      g.drawRect(x - radius, y - radius, radius * 2, radius * 2);
    },
    diamond: (g, x, y, radius) => {
      radius *= Math.SQRT2;
      g.drawPolygon(x + radius, y, x, y + radius, x - radius, y, x, y - radius);
    },
    triangleUp: (g, x, y, radius) => {
      g.drawPolygon(x, y - (2 * radius), x + (radius * Math.SQRT3), y + radius, x - (radius * Math.SQRT3), y + radius);
    },
    triangleDown: (g, x, y, radius) => {
      g.drawPolygon(x, y + (2 * radius), x - (radius * Math.SQRT3), y - radius, x + (radius * Math.SQRT3), y - radius);
    },
    hexagonFlat: (g, x, y, radius) => {
      const sx = radius * Math.SQRT1_3;
      const sy = radius / 2;
      const x0 = x - (2 * sx);
      const x1 = x - sx;
      const x2 = x + sx;
      const x3 = x + (2 * sx);
      const y0 = y - (2 * sy);
      const y1 = y + (2 * sy);
      g.drawPolygon(x0, y, x1, y0, x2, y0, x3, y, x2, y1, x1, y1);
    },
    hexagonPointy: (g, x, y, radius) => {
      const sx = radius / 2;
      const sy = radius * Math.SQRT1_3;
      const y0 = y - (2 * sy);
      const y1 = y - sy;
      const y2 = y + sy;
      const y3 = y + (2 * sy);
      const x0 = x - (2 * sx);
      const x1 = x + (2 * sx);
      g.drawPolygon(x, y0, x1, y1, x1, y2, x, y3, x0, y2, x0, y1);
    },
    octagon: (g, x, y, radius) => {
      const a = radius;
      const b = a * Math.tan(Math.PI / 8);
      g.drawPolygon(x + a, y + b, x + b, y + a, x - b, y + a, x - a, y + b, x - a, y - b, x - b, y - a, x + b, y - a, x + a, y - b);
    }
  };

  /* -------------------------------------------- */

  /**
   * 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);
  }

  /* -------------------------------------------- */

  /**
   * Create the path with all intermediate waypoints.
   * @param {{waypoints: (TokenMeasuredMovementWaypoint|TokenPlannedMovementWaypoint)[];
   *   stage: "passed"|"pending"|"planned"; unreachable: boolean; userId?: string}[]} pathParts
   *   The paths that are to be combined
   * @returns {TokenRulerWaypoint[]}         The combined path
   */
  #createPath(pathParts) {
    const path = [];
    const actionConfigs = CONFIG.Token.movement.actions;
    let index = 0;
    for ( const {waypoints, stage, hidden, unreachable, userId} of pathParts ) {
      for ( let waypoint of waypoints ) {
        if ( waypoint.explicit ) index++;
        const actionConfig = actionConfigs[waypoint.action] ?? actionConfigs.displace;
        waypoint = {...waypoint, actionConfig, index, stage, hidden, unreachable};
        if ( userId ) waypoint.userId = userId;
        waypoint.movementId ??= null;
        waypoint.subpathId ??= null;
        waypoint.center = this.token.document.getCenterPoint(waypoint);
        waypoint.size = this.token.document.getSize(waypoint);
        path.push(waypoint);
      }
    }
    this.#measurePathAndLinkWaypoints(path);
    this._preparePath(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.ray = i > 0 ? new Ray(waypoints[i - 1].center, waypoint.center) : null;
      waypoint.measurement = measurement.waypoints[i];
      waypoint.previous = i > 0 ? waypoints[i - 1] : null;
      waypoint.next = i < waypoints.length - 1 ? waypoints[i + 1] : null;
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare the path.
   * @param {TokenRulerWaypoint[]} path
   * @protected
   */
  _preparePath(path) {}

  /* -------------------------------------------- */

  /**
   * Create the path of waypoints that should be rendered.
   * @param {TokenRulerWaypoint[]} path    The path with all intermediate waypoints
   * @returns {TokenRulerWaypoint[]}
   */
  #createRenderedPath(path) {
    const renderedPath = [];
    const queue = [];
    for ( const waypoint of path ) {
      queue.push(waypoint);
      if ( !this._shouldRenderWaypoint(waypoint) ) continue;
      const accumulatedData = queue.reduce((accumulator, waypoint) => {
        this._accumulateWaypointData(accumulator, waypoint);
        return accumulator;
      }, {});
      queue.length = 0;
      renderedPath.push({...waypoint, ...accumulatedData});
    }
    this.#measurePathAndLinkWaypoints(renderedPath);
    return renderedPath;
  }

  /* -------------------------------------------- */

  /**
   * Should the ruler waypoint be rendered?
   * @param {DeepReadonly<TokenRulerWaypoint>} waypoint
   * @returns {boolean}
   * @protected
   */
  _shouldRenderWaypoint(waypoint) {
    return !waypoint.intermediate;
  }

  /* -------------------------------------------- */

  /**
   * Accumulate waypoint data. Skipped waypoints and the next rendered waypoint are accumulated.
   *
   * The base implementation accumulates the waypoint cost.
   * @param {Partial<TokenRulerWaypoint>} accumulator      The accumulated waypoint data
   * @param {DeepReadonly<TokenRulerWaypoint>} waypoint    A waypoint to be accumulated
   * @protected
   */
  _accumulateWaypointData(accumulator, waypoint) {
    accumulator.cost = (accumulator.cost ?? 0) + waypoint.cost;
  }

  /* -------------------------------------------- */

  /** @override */
  refresh({passedWaypoints, pendingWaypoints, plannedMovement}) {
    const pathParts = [
      {waypoints: passedWaypoints, stage: "passed", hidden: false, unreachable: false},
      {waypoints: pendingWaypoints, stage: "pending", hidden: false, unreachable: false}
    ];
    const path = this.#createPath(pathParts);
    const rulerPaths = [{waypoints: this.#createRenderedPath(path), searching: false, userId: null}];
    const gridHighlightPaths = [];
    if ( !canvas.grid.isGridless ) gridHighlightPaths.push({waypoints: path, searching: false, userId: null});

    // 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 ) {
      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}
      ];
      const path = this.#createPath(pathParts);
      const rulerPathWaypoints = this.#createRenderedPath(path);
      if ( history.length ) {
        const i = rulerPathWaypoints.findIndex(w => w.stage !== "passed");
        if ( i >= 0 ) rulerPathWaypoints.splice(0, i);
        else rulerPathWaypoints.length = 0;
      }
      rulerPaths.push({waypoints: rulerPathWaypoints, searching, userId});
      if ( !canvas.grid.isGridless ) {
        gridHighlightPaths.push({waypoints: path.slice(history.length), searching, userId});
      }
    }

    this.#updateDashLines(rulerPaths);
    this.#drawRulerPaths(rulerPaths);
    this.#drawGridHighlights(gridHighlightPaths);
    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, explicit, next, previous, ray} = waypoint;
    const elevation = (waypoint.elevation - canvas.level.elevation.base).toNearest(0.01);
    if ( !state.initialized ) {

      // If any of the prior waypoints has nonzero elevation, initialize to true
      state.hasElevation = elevation !== 0;
      let w = previous;
      while ( w && !state.hasElevation ) {
        state.hasElevation = (w.elevation - canvas.level.elevation.base).toNearest(0.01) !== 0;
        w = w.previous;
      }

      state.previousElevation = previous ? (previous.elevation - canvas.level.elevation.base).toNearest(0.01)
        : elevation;
      state.initialized = true;
    }
    else state.hasElevation ||= (elevation !== 0);
    if ( !previous ) {
      if ( !state.hasElevation || (waypoint.stage === "planned") ) return;
    } else {
      if ( !explicit && next && waypoint.actionConfig.visualize && next.actionConfig.visualize
        && (waypoint.action === next.action) && (waypoint.unreachable || !waypoint.next.unreachable) ) return;
      if ( (ray.distance === 0) && (waypoint.elevation === previous.elevation) ) return;
    }

    // Prepare data structure
    const context = {
      action: waypoint.actionConfig,
      cssClass: [
        waypoint.hidden ? "secret" : "",
        waypoint.next ? "" : "last",
        waypoint.stage,
        waypoint.unreachable ? "unreachable" : "",
        explicit ? "explicit" : "",
        waypoint.checkpoint ? "checkpoint" : "",
        waypoint.intermediate ? "intermediate" : ""
      ].filterJoin(" "),
      secret: waypoint.hidden,
      units: canvas.grid.units,
      uiScale: canvas.dimensions.uiScale,
      position: {x: waypoint.center.x, y: waypoint.center.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
    context.elevation = {
      total: elevation.signedString(),
      icon: "fa-solid fa-arrows-up-down",
      hidden: !state.hasElevation
    };
    const deltaElevation = (elevation - state.previousElevation).toNearest(0.01);
    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
   */
  #drawRulerPaths(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, shape="circle", color=0xFFFFFF, alpha=1} = this._getWaypointStyle(waypoint);
        if ( radius > 0 ) {
          const drawShape = TokenRuler.#WAYPOINT_DRAW_FUNCTIONS[shape];
          if ( alpha === 1 ) {
            layer3.push(g => {
              g.lineStyle(this.#outlineThickness, this.#outlineColor);
              g.beginFill(color, 1, this.#outlineThickness === 0);
              drawShape(g, to.x, to.y, radius + (this.#outlineThickness / 2));
              g.endFill();
            });
          } else {
            layer3.push(g => {
              g.lineStyle(0);
              g.beginFill(this.#outlineColor, alpha, true);
              drawShape(g, to.x, to.y, radius + this.#outlineThickness);
              g.endFill();
              g.beginFill(color, alpha, true);
              drawShape(g, 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;
   *   shape?: "circle"|"square"|"diamond"|"triangleUp"|"triangleDown"|"hexagonFlat"|"hexagonPointy"|"octagon";
   *   color?: PIXI.ColorSource;
   *   alpha?: number;
   * }}  The shape, 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)
      && (waypoint.unreachable || !waypoint.next.unreachable) ) return {radius: 0};
    const user = game.users.get(waypoint.userId);
    const scale = canvas.dimensions.uiScale;
    return {radius: 6 * scale, shape: "circle", color: user?.color ?? 0x000000,
      alpha: !waypoint.previous || (waypoint.explicit && !waypoint.intermediate) ? 1 : 0.5};
  }

  /* -------------------------------------------- */

  /**
   * 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 {{waypoints: TokenRulerWaypoint[]; searching: boolean; userId: string}[]} 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 waypoints = paths[i].waypoints;

      // Iterate the path in reverse
      for ( let j = waypoints.length - 1; j >= 0; j-- ) {
        const waypoint = waypoints[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<TokenRulerWaypoint>} 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.path = [];
      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$j = /*#__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", "refreshTransform", "refreshTooltip", "refreshElevation"], alias: true},
    refreshState: {propagate: ["refreshVisibility"]},
    refreshVisibility: {},
    refreshTransform: {propagate: ["refreshPosition", "refreshSize"], alias: true},
    refreshPosition: {},
    refreshSize: {},
    refreshTooltip: {},
    refreshElevation: {propagate: ["refreshVisibility"]}
  };

  /* -------------------------------------------- */

  /**
   * 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;
  }

  /* -------------------------------------------- */

  /**
   * A convenient reference for whether the current User is the author of the Note document.
   * @type {boolean}
   */
  get isAuthor() {
    return this.document.isAuthor;
  }

  /* -------------------------------------------- */

  /** @override */
  get isVisible() {
    if ( this.isPreview ) return true;
    if ( this.hasPreview ) return false;
    if ( this.controlled ) return true;
    if ( this.isFilteredOut ) return false;
    const accessTest = this.document.page ?? this.document.entry;
    const access = game.user.isGM || this.isAuthor || (accessTest?.testUserPermission(game.user, "LIMITED")
      ?? ((this.document._source.author === null) || !(this.document.author?.isGM ?? 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.fog.isPointExplored(point) || canvas.visibility.testVisibility(point, {tolerance, object: this});
  }

  /* -------------------------------------------- */

  /** @override */
  get isInteractable() {
    if ( this.isPreview ) return false;
    if ( canvas.tokens.active ) return game.activeTool === "select";
    if ( !this.layer.active ) return false;
    return !!ui.controls.tool?.interaction;
  }

  /* -------------------------------------------- */

  /** @override */
  _overlapsSelection(rectangle) {
    return rectangle.intersects(this.bounds);
  }

  /* -------------------------------------------- */
  /* Rendering
  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    this.controlIcon = this.addChild(this._drawControlIcon());
    await this.controlIcon.draw();
    this.tooltip = this.addChild(this._drawTooltip());
  }

  /* -------------------------------------------- */

  /**
   * Draw the control icon.
   * @returns {ControlIcon}
   * @protected
   */
  _drawControlIcon() {
    return new ControlIcon({texture: this.document.texture.src});
  }

  /* -------------------------------------------- */

  /**
   * 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                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyRenderFlags(flags) {
    super._applyRenderFlags(flags);
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshSize ) this._refreshSize();
    if ( flags.refreshTooltip ) this._refreshTooltip();
    if ( flags.refreshElevation ) this._refreshElevation();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshVisibility() {
    const wasVisible = this.visible;
    super._refreshVisibility();
    if ( wasVisible !== this.visible ) ui.controls._updateNotesIcon();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshState() {
    super._refreshState();
    this.tooltip.visible = this.hover || this.layer.highlightObjects;
    const {locked} = this.document;
    const colors = CONFIG.Canvas.dispositionColors;
    this.controlIcon.icon.tint = this.document.texture.tint;
    this.controlIcon.border.tint = this.controlled || this.hover || this.layer.highlightObjects || this.isPreview
      ? (this.layer.active && locked ? colors.HOSTILE : colors.CONTROLLED) : 0x000000;
  }

  /* -------------------------------------------- */

  /**
   * 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(x, y);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the size of the Note.
   * @protected
   */
  _refreshSize() {
    this.controlIcon.size = this.document.iconSize;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the elevation of the control icon.
   * @protected
   */
  _refreshElevation() {
    this.controlIcon.elevation = this.document.elevation - canvas.level.elevation.base;
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Incremental Refresh
    const positionChanged = ("x" in changed) || ("y" in changed);
    const sizeChanged = "iconSize" in changed;
    const textureChanged = "texture" in changed;
    this.renderFlags.set({
      redraw: textureChanged && ("src" in changed.texture),
      refreshState: ("locked" in changed) || (textureChanged && ("tint" in changed.texture)),
      refreshVisibility: positionChanged || sizeChanged || ["entryId", "pageId", "global"].some(k => k in changed),
      refreshPosition: positionChanged,
      refreshSize: sizeChanged,
      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");
  }

  /* -------------------------------------------- */

  /** @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);
  }
};

/**
 * @import {BaseShapeData} from "@common/data/_module.mjs";
 * @import {GridOffset2D} from "@common/grid/_types.mjs";
 * @import {DeepReadonly, ElevatedPoint, Point} from "@common/_types.mjs";
 * @import {RegionAnimationState} from "./_types.mjs";
 * @import {TokenDocument} from "@client/documents/_module.mjs";
 */

/**
 * 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 ShapeObjectMixin(PlaceableObject) {

  /** @override */
  static embeddedName = "Region";

  /* -------------------------------------------- */

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshShapes"], alias: true},
    refreshState: {propagate: ["refreshVisibility"]},
    refreshVisibility: {},
    refreshShapes: {propagate: ["refreshGeometry"]},
    refreshGeometry: {propagate: ["refreshBorder", "refreshMeasurements"]},
    refreshBorder: {},
    refreshMeasurements: {}
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The geometry of this Region.
   *
   * The value of this property must not be mutated.
   * @type {RegionGeometry}
   */
  get geometry() {
    return this.#geometry;
  }

  #geometry = new RegionGeometry(this);

  /* -------------------------------------------- */

  /** @override */
  get bounds() {
    return this.animationState.bounds.clone(); // PlaceableObject#bounds always returns a new instance
  }

  /* -------------------------------------------- */

  /** @override */
  get center() {
    return this.animationState.bounds.center;
  }

  /* -------------------------------------------- */

  /** @override */
  get isVisible() {
    if ( this.isPreview ) return true;
    if ( this.hasPreview ) return false;
    if ( this.controlled ) return true;
    if ( this.document.hidden && !game.user.isGM ) return false;
    if ( this.isFilteredOut ) return false;
    const isObserver = this.document.testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER);
    const V = CONST.REGION_VISIBILITY;
    if ( this.layer.active ) {
      if ( this.document.locked && (this.document.visibility === V.LAYER_UNLOCKED) ) return false;
      if ( isObserver ) return true;
    }
    switch ( this.document.visibility ) {
      case V.GAMEMASTER: return game.user.isGM;
      case V.OBSERVER: return isObserver;
      case V.ALWAYS: return true;
      default: return false;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  get isInteractable() {
    if ( this.isPreview ) return false;
    if ( this.layer._placementContext ) return false;
    if ( !this.layer.active ) return false;
    if ( !ui.controls.tool?.interaction ) return false;
    if ( this.isAnimating ) return false;
    if ( this.document.locked ) return false;
    if ( !this.document.testUserPermission(game.user, "OBSERVER") ) return false;
    return true;
  }

  /* -------------------------------------------- */

  /**
   * The hitbox of this Region.
   * @type {PIXI.Container}
   */
  #hitbox;

  /* -------------------------------------------- */

  /**
   * The highlight of this Region.
   * @type {RegionMesh}
   */
  #highlight;

  /* -------------------------------------------- */

  /**
   * The border of this Region.
   * @type {PIXI.Graphics}
   */
  #border;

  /* -------------------------------------------- */

  /**
   * The geometry of covered grid spaces.
   * @type {PIXI.Geometry | null}
   */
  #coverageGeometry = null;

  /* -------------------------------------------- */

  /**
   * The covered grid space offsets keys.
   * @type {Set<number>}
   */
  #coverageOffsetKeys = new Set();

  /* -------------------------------------------- */

  /**
   * The shape controls of this Region.
   * @type {PIXI.Container}
   */
  #controls;

  /* -------------------------------------------- */

  /**
   * The animation state of this Region.
   * @type {DeepReadonly<RegionAnimationState>}
   */
  get animationState() {
    return this.#animationState;
  }

  #animationState = Object.freeze(this.#createAnimationState());

  /**
   * Is this Region currently animating?
   * @type {boolean}
   */
  get isAnimating() {
    return !!(this.#animatedShapes || this.#animatedElevation);
  }

  /** @type {BaseShapeData[]|undefined} */
  #animatedShapes;

  /** @type {{origins: Point[]; lastRotation: {angle: number; pivot: Point}}|undefined} */
  #animatedShapeData;

  /** @type {number|undefined} */
  #animatedElevation;

  /** @type {PolygonTree|undefined} */
  #animatedPolygonTree;

  /** @type {ClipperLib.PolyTree|undefined} */
  #animatedClipperPolyTree;

  /* -------------------------------------------- */

  /**
   * Create the animation state of this Region.
   * @returns {RegionAnimationState}
   */
  #createAnimationState() {
    return Object.defineProperties({}, {
      shapes: {
        get: () => this.#animatedShapes ?? this.document.shapes
      },
      elevation: {
        get: () => this.#animatedElevation ?? this.document.elevation
      },
      polygons: {
        get: () => this.#animationState.polygonTree.polygons
      },
      polygonTree: {
        get: () => {
          if ( !this.#animatedShapes ) return this.document.polygonTree;
          return this.#animatedPolygonTree ??= PolygonTree.fromClipperPolyTree(this.#animationState.clipperPolyTree);
        }
      },
      clipperPaths: {
        get: () => this.#animationState.polygonTree.clipperPaths
      },
      clipperPolyTree: {
        get: () => {
          if ( !this.#animatedShapes ) return this.document.clipperPolyTree;
          if ( this.#animatedClipperPolyTree ) return this.#animatedClipperPolyTree;
          const shapes = this.#animationState.shapes;
          const elevation = this.#animationState.elevation;
          const shapeConstraints = this.document._computeShapeConstraints({shapes, elevation});
          return this.#animatedClipperPolyTree = this.document._createClipperPolyTree(shapes, shapeConstraints);
        }
      },
      triangulation: {
        get: () => this.#animationState.polygonTree.triangulation
      },
      bounds: {
        get: () => this.#animationState.polygonTree.bounds
      },
      area: {
        get: () => this.#animationState.polygonTree.area
      },
      testPoint: {
        value: point => {
          return RegionDocument._testElevation(this.animationState.elevation, point.elevation)
            && this.#animationState.polygonTree.testPoint(point);
        }
      }
    });
  }

  /* -------------------------------------------- */

  /** @override */
  getSnappedPosition(position) {
    throw new Error("Region#getSnappedPosition is not supported: RegionDocument does not have a (x, y) position");
  }

  /* -------------------------------------------- */

  /** @override */
  _pasteObject(offset, _options) {
    const shapes = [];
    for ( let shape of this.document.shapes ) {
      shape = shape.clone({}, {parent: canvas.scene});
      shape.move({x: shape.origin.x + offset.x, y: shape.origin.y + offset.y});
      shapes.push(shape._source);
    }

    // Same logic as in PlaceableObject#_pasteObject
    let levels;
    if ( !this.document.levels.size || (this.scene !== canvas.scene) ) levels = [];
    else if ( this.document.levels.has(canvas.level.id) ) levels = [...this.document.levels];
    else levels = [canvas.level.id];
    return {shapes, levels};
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);
    this.#hitbox = this.addChild(this.#drawHitbox());
    this.#highlight = this.layer._highlights.addChild(new RegionMesh(this, HighlightRegionShader));
    this.#highlight.eventMode = "auto";
    this.#highlight.shader.uniforms.hatchThickness = 4 * canvas.dimensions.uiScale;
    this.#highlight.alpha = 0.5;
    this.addChild(this._measurementLines);
    this.#border = this.addChild(new PIXI.Graphics());
    this.#border.eventMode = "none";
    this.#controls = this.addChild(new PIXI.Container());
    await this.#drawShapeControls();
    this.addChild(this._measurementLabels);
    this.cursor = "pointer";
  }

  /* -------------------------------------------- */

  /**
   * Draw the hitbox.
   * @returns {PIXI.Container}
   */
  #drawHitbox() {
    const point = new PIXI.Point();
    const hitbox = new PIXI.Container();
    hitbox.eventMode = "auto";
    hitbox.hitArea = {
      contains: (x, y) => {
        if ( !this.animationState.bounds.contains(x, y) ) return false;
        point.set(x, y);
        if ( this.#coverageOffsetKeys.size ) {
          const offset = canvas.grid.getOffset(point);
          const key = offset.i | (offset.j << 16);
          if ( this.#coverageOffsetKeys.has(key) ) return true;
        }
        return this.animationState.polygonTree.testPoint(point);
      }
    };
    return hitbox;
  }

  /* -------------------------------------------- */

  /**
   * Draw the shape controls.
   * @returns {Promise<void>}
   */
  async #drawShapeControls() {
    for ( const shape of this.document.shapes ) {
      const control = new RegionShapeControls(shape.clone());
      this.#controls.addChild(await control.draw());
    }
    this.renderFlags.set({refreshState: true});
  }

  /* -------------------------------------------- */

  /**
   * Re-draw the shape controls.
   * @internal
   */
  _redrawShapeControls() {
    this.#controls?.removeChildren().forEach(c => c.destroy({children: true}));
    this._partialDraw(() => this.#drawShapeControls());
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _clear() {
    this.#highlight?.destroy({children: true});
    this.#highlight = undefined;
    super._clear();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _destroy(options) {
    this.#highlight?.destroy({children: true});
    super._destroy(options);
  }

  /* -------------------------------------------- */

  /** @override */
  _getMeasuredShapes() {
    if ( this.document.displayMeasurements ) return this.animationState.shapes;
    if ( this.previewType === "creation" ) {
      if ( this.layer._placementContext?.preview === this ) return this.document.shapes;
      return this.document.shapes.slice(-1);
    }
    if ( (this.previewType === "wheel") && (this.layer._mouseWheelContext?.preview === this) ) {
      return [this.document.shapes.at(this.layer._mouseWheelContext.shape._index)];
    }
    if ( (this.previewType === "controls") && this._original.mouseInteractionManager?.isDragging ) {
      return [this.document.shapes.at(this._original.mouseInteractionManager.interactionData.shape._index)];
    }
    return [];
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyRenderFlags(flags) {
    super._applyRenderFlags(flags);
    if ( flags.refreshShapes ) this._refreshShapes();
    if ( flags.refreshGeometry ) this._refreshGeometry();
    if ( flags.refreshBorder ) this._refreshBorder();
    if ( flags.refreshMeasurements ) this._refreshMeasurements();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshVisibility() {
    super._refreshVisibility();
    this.#highlight.visible = this.visible;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshState() {
    super._refreshState();
    this.#highlight.zIndex = this.zIndex;
    const {hidden, locked, color} = this.document;
    this.#highlight.tint = color;
    this.#highlight.shader.uniforms.hatchEnabled = !this.controlled && !this.hover && !this.isPreview;
    const colors = CONFIG.Canvas.dispositionColors;
    const tint = this.controlled || this.isPreview ? colors.CONTROLLED : (locked ? colors.HOSTILE : colors.INACTIVE);
    this.#border.tint = tint;
    const borderOrShapeControlsVisible = this.controlled || this.hover || this.layer.highlightObjects || (this.isPreview
      && !this.layer.templateMode);
    const couldEditShapes = !this.isAnimating && this.layer.active && !!ui.controls.tool?.interaction
      && !!ui.controls.tool?.control && !locked && [null, "dragging", "wheel", "controls"].includes(this.previewType);
    if ( couldEditShapes ) {
      this.#border.visible = false;
      this.#controls.visible = borderOrShapeControlsVisible;
    } else {
      this.#border.visible = borderOrShapeControlsVisible;
      this.#controls.visible = false;
    }
    for ( const controls of this.#controls.children ) {
      controls.handles.visible = this.controlled;
      controls.editable = !locked;
      controls.tint = tint;
      controls.dashed = hidden;
    }
  }

  /* -------------------------------------------- */

  /**
   * Refresh the shapes of the Region.
   * @protected
   */
  _refreshShapes() {
    for ( const [i, control] of this.#controls.children.entries() ) {
      control.shape.updateSource(this.document.shapes[i].toObject());
      control.refresh();
    }
  }

  /* -------------------------------------------- */

  /**
   * Refresh the geometry of the Region.
   * @protected
   */
  _refreshGeometry() {
    this.#geometry?._clearBuffers();
    if ( (this.document.highlightMode !== "coverage") || canvas.grid.isGridless ) {
      this.#coverageGeometry = null;
      this.#highlight.geometry = this.#geometry;
      return;
    }
    const offsets = this._getCoveredGridSpaceOffsets();
    if ( !this.#coverageGeometry ) {
      this.#coverageGeometry = new PIXI.Geometry();
      this.#coverageGeometry.addAttribute("aVertexPosition", new PIXI.Buffer(new Float32Array(), true, false), 2);
      this.#coverageGeometry.addIndex(new PIXI.Buffer(new Uint16Array(), true, true));
    }
    this.#coverageOffsetKeys.clear();
    const n = canvas.grid.isSquare ? 4 : 6;
    const vertices = new Float32Array(offsets.length * n * 2);
    const indices = new (offsets.length > 0xFFFF ? Uint32Array : Uint16Array)(offsets.length * (n - 2) * 3);
    let i = 0;
    let j = 0;
    for ( const offset of offsets ) {
      const key = offset.i | (offset.j << 16);
      this.#coverageOffsetKeys.add(key);
      const k0 = i / 2;
      for ( const {x, y} of canvas.grid.getVertices(offset) ) {
        vertices[i++] = x;
        vertices[i++] = y;
      }
      for ( let dk = 1; dk < n - 1; dk++ ) {
        indices[j++] = k0;
        indices[j++] = k0 + dk;
        indices[j++] = k0 + dk + 1;
      }
    }
    this.#coverageGeometry.buffers[0].update(vertices);
    this.#coverageGeometry.indexBuffer.update(indices);
    this.#highlight.geometry = offsets.length ? this.#coverageGeometry : this.#geometry;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the border of the Region.
   * @protected
   */
  _refreshBorder() {
    foundry.canvas.borders.drawBorder(this.#border, border => this.animationState.polygonTree.drawShape(border),
      {dashed: this.document.hidden});
  }

  /* -------------------------------------------- */

  /**
   * Get the grid space offsets that are covered by this Region.
   * @returns {GridOffset2D[]}
   * @protected
   */
  _getCoveredGridSpaceOffsets() {
    const offsets = [];
    if ( canvas.grid.isGridless ) return [];
    const dx = canvas.grid.sizeX / 2;
    const dy = canvas.grid.sizeY / 2;
    const [i0, j0, i1, j1] = canvas.grid.getOffsetRange(this.animationState.bounds.fit(canvas.dimensions.rect).pad(1));
    for ( let i = i0; i < i1; i++ ) {
      for ( let j = j0; j < j1; j++ ) {
        const offset = {i, j};
        const center = canvas.grid.getCenterPoint(offset);

        // Round the top-left as a token position would be
        center.x = Math.round(center.x - dx) + dx;
        center.y = Math.round(center.y - dy) + dy;

        // A 1x1 token is covered if is center point intersects with a tolerance of 0.75
        const covered = this.animationState.polygonTree.testPoint(center, 0.75);
        if ( covered ) offsets.push(offset);
      }
    }
    return offsets;
  }

  /* -------------------------------------------- */
  /*  Animation                                   */
  /* -------------------------------------------- */

  /**
   * Update the animation state of this Region based on the animation state.
   * @internal
   */
  _onTokenAnimationFrame() {
    const token = this.document.attachment.token;
    if ( !token ) return;
    const wasAnimating = this.isAnimating;
    const dx = token.x - token._source.x;
    const dy = token.y - token._source.y;
    const de = token.elevation - token._source.elevation;
    const dr = Math.normalizeDegrees(token.rotation - token._source.rotation);
    if ( dx || dy || dr ) {
      if ( !this.#animatedShapeData ) {
        this.#animatedShapes = [];
        this.#animatedShapeData = {origins: [], lastRotation: {angle: 0, pivot: {x: 0, y: 0}}};
        for ( const shape of this.document.shapes ) {
          this.#animatedShapes.push(shape.clone());
          this.#animatedShapeData.origins.push(shape.origin);
        }
      }
      const center = dr ? token.getCenterPoint() : undefined;
      const {origins, lastRotation} = this.#animatedShapeData;
      for ( let i = 0; i < this.#animatedShapes.length; i++ ) {
        const shape = this.#animatedShapes[i];

        // TODO optimize this further?
        if ( dx || dy ) {
          if ( lastRotation.angle ) shape.rotate(-lastRotation.angle, {pivot: lastRotation.pivot});
          const origin = origins[i];
          shape.move({x: origin.x + dx, y: origin.y + dy});
          if ( dr ) shape.rotate(dr, {pivot: center});
        } else if ( dr ) {
          if ( (center.x === lastRotation.pivot.x) && (center.y === lastRotation.pivot.y) ) {
            shape.rotate(dr - lastRotation.angle, {pivot: center});
          } else {
            shape.rotate(-lastRotation.angle, {pivot: lastRotation.pivot});
            shape.rotate(dr, {pivot: center});
          }
        }
      }
      lastRotation.angle = dr;
      lastRotation.pivot = center;
    } else {
      this.#animatedShapes = undefined;
      this.#animatedShapeData = undefined;
    }
    this.#animatedPolygonTree = undefined;
    this.#animatedClipperPolyTree = undefined;
    this.#animatedElevation = de ? {bottom: this.document.elevation.bottom + de, top: this.document.elevation.top + de,
      topInclusive: this.document.elevation.topInclusive} : undefined;
    const isAnimating = this.isAnimating;
    this.renderFlags.set({refreshState: isAnimating !== wasAnimating, refreshGeometry: true});
    this.document._handleEvent({name: CONST.REGION_EVENTS.REGION_ANIMATED, data: {}, region: this.document,
      user: game.user});
    this._onAnimationStateChange();
  }

  /* -------------------------------------------- */

  /**
   * Reset the animation state.
   */
  #resetAnimationState() {
    if ( !this.isAnimating ) return;
    this.#animatedShapes = undefined;
    this.#animatedShapeData = undefined;
    this.#animatedElevation = undefined;
    this.#animatedPolygonTree = undefined;
    this.#animatedClipperPolyTree = undefined;
    this.renderFlags.set({refreshState: true, refreshGeometry: true});
    this.document._handleEvent({name: CONST.REGION_EVENTS.REGION_ANIMATED, data: {}, region: this.document,
      user: game.user});
    this._onAnimationStateChange();
  }

  /* -------------------------------------------- */

  /**
   * Called when the animation state of the Region has changed.
   * @protected
   */
  _onAnimationStateChange() {}

  /* -------------------------------------------- */
  /*  Placeable Event Handlers                    */
  /* -------------------------------------------- */

  /** @override */
  _canHUD(user, event) {
    return false; // Regions don't have a HUD
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onHoverIn(event, options={}) {
    if ( options.updateLegend ) this.layer.legend._hoverRegion(this, true);
    return super._onHoverIn(event, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onHoverOut(event, options={}) {
    if ( options.updateLegend ) this.layer.legend._hoverRegion(this, false);
    return super._onHoverOut(event, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onControl(options) {
    super._onControl(options);
    if ( ui.controls.tool?.creation && !this.layer.templateMode ) {
      this.layer._togglePaletteVisible = false;
      if ( "togglePalette" in ui.controls.tools ) ui.controls.render({reset: true});
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRelease(options) {
    super._onRelease(options);
    if ( ui.controls.tool?.creation && !this.layer.templateMode && !this.layer.controlled.length ) {
      this.layer._togglePaletteVisible = true;
      if ( !("togglePalette" in ui.controls.tools) ) ui.controls.render({reset: true});
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _overlapsSelection(rectangle) {
    if ( !rectangle.intersects(this.animationState.bounds) ) return false;
    const x0 = Math.round(rectangle.left * CLIPPER_SCALING_FACTOR);
    const y0 = Math.round(rectangle.top * CLIPPER_SCALING_FACTOR);
    const x1 = Math.round(rectangle.right * CLIPPER_SCALING_FACTOR);
    const y1 = Math.round(rectangle.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.animationState.clipperPaths, ClipperLib.PolyType.ptClip, true);
    clipper.Execute(ClipperLib.ClipType.ctIntersection, solution);
    return solution.length !== 0;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _updateDragPreviews(event) {
    super._updateDragPreviews(event);
    for ( const clone of event.interactionData.clones ) clone.document.updateShapeConstraints();
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    const shapesChanged = "shapes" in changed;
    if ( shapesChanged ) this.#animatedShapeData = undefined;

    if ( "attachment" in changed ) this.#resetAnimationState();

    const redrawShapeControls = shapesChanged && !this.document.shapes.map(s => `${s.type}.${s.base?.type ?? ""}`)
      .equals(this.#controls.children.map(c => `${c.shape.type}.${c.shape.base?.type ?? ""}`));
    if ( redrawShapeControls ) this._redrawShapeControls();

    // Incremental Refresh
    this.renderFlags.set({
      refreshState: ["color", "visibility", "hidden", "locked", "ownership"].some(k => k in changed),
      refreshShapes: shapesChanged,
      refreshGeometry: ("highlightMode" in changed) || ("_shapeConstraints" in changed),
      refreshBorder: "hidden" in changed,
      refreshMeasurements: "displayMeasurements" 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. Use RegionDocument#shapes instead.",
      {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: elevation ?? this.document.elevation.bottom});
  }
};

/**
 * @import {Point} from "@common/_types.mjs";
 */

/**
 * @deprecated since v14
 * @ignore
 */
let MeasuredTemplate$1 = class MeasuredTemplate extends PlaceableObject {
  constructor(document) {
    const msg = "MeasuredTemplate is deprecated because the MeasuredTemplate document has been merged into the functionality of the Region document.";
    foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
    super(document);
  }

  /* -------------------------------------------- */

  /**
   * 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: {propagate: ["refreshVisibility"]},
    refreshVisibility: {},
    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);
  }

  /* -------------------------------------------- */

  /**
   * 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;
    return new ControlIcon({texture: CONFIG.controlIcons.template, size});
  }

  /* -------------------------------------------- */

  /**
   * 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                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyRenderFlags(flags) {
    super._applyRenderFlags(flags);
    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();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshVisibility() {
    super._refreshVisibility();
    const highlightLayer = canvas.interface.grid.getHighlightLayer(this.highlightId);
    highlightLayer.visible = this.visible;

    // Ruler Visibility
    this.ruler.visible = this.visible && this.layer.active;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshState() {
    super._refreshState();

    // 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);
    // FIXME the elevation is not considered in sort order of the highlight layers
    highlightLayer.zIndex = this.document.sort;
    highlightLayer.alpha = alpha;
  }

  /* -------------------------------------------- */

  /**
   * 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) {
    const msg = "MeasuredTemplate.getCircleShape is deprecated. Use CircleShapeData instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});

    // 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) {
    const msg = "MeasuredTemplate.getConeShape is deprecated. Use ConeShapeData instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});

    // 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) {
    const msg = "MeasuredTemplate.getRectShape is deprecated. Use RectangleShapeData instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});

    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 msg = "MeasuredTemplate.getRayShape is deprecated. Use LineShapeData instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});

    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                               */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _canHUD(user, event) {
    return this.layer.active && 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: 16});
    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: 16});
    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: 16});
    return this.isOwner;
  }
};

/**
 * @import DoorControl from "../containers/elements/door-control.mjs";
 * @import Edge from "../geometry/edges/edge.mjs";
 * @import {WallRestrictionType} from "@common/constants.mjs";
 */

/**
 * 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.#priorDoorState = this.document.ds;
  }

  /** @inheritdoc */
  static embeddedName = "Wall";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshLine"], alias: true},
    refreshState: {propagate: ["refreshVisibility", "refreshEndpoints", "refreshHighlight"]},
    refreshVisibility: {},
    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.
   * @type {Edge|null}
   */
  get edge() {
    return this.document.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.isDoor;
  }

  /* -------------------------------------------- */

  /**
   * A boolean for whether the wall contains an open door
   * @returns {boolean}
   */
  get isOpen() {
    return this.document.isOpen;
  }

  /* -------------------------------------------- */

  /**
   * Return the coordinates [x,y] at the midpoint of the wall segment
   * @returns {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.EDGE_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};

    // Same logic as in PlaceableObject#_pasteObject
    let levels;
    if ( !this.document.levels.size || (this.scene !== canvas.scene) ) levels = [];
    else if ( this.document.levels.has(canvas.level.id) ) levels = [...this.document.levels];
    else levels = [canvas.level.id];
    return {c: [a.x, a.y, b.x, b.y], levels};
  }

  /* -------------------------------------------- */

  /**
   * 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();
    this.createDoorControl();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _clear() {
    this.clearDoorControl();
    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.destroyDoorMeshes();
    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                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyRenderFlags(flags) {
    super._applyRenderFlags(flags);
    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]);
  }

  /* -------------------------------------------- */

  /**
   * Given the properties of the wall - decide upon a color to render the wall for display on the WallsLayer
   * @returns {number}
   * @protected
   */
  _getWallColor() {
    const category = this.document.getWallCategory();
    let color = this.document.constructor.CATEGORY_COLORS[category];
    if ( category === "door" ) {
      const ds = this.document.ds || CONST.WALL_DOOR_STATES.CLOSED;
      if ( ds === CONST.WALL_DOOR_STATES.OPEN ) color = 0x66CC66;
      else if ( ds === CONST.WALL_DOOR_STATES.LOCKED ) color = 0xEE4444;
    }
    if ( category === "secret" ) {
      const ds = this.document.ds || CONST.WALL_DOOR_STATES.CLOSED;
      if ( ds === CONST.WALL_DOOR_STATES.OPEN ) color = 0x7C1A9b;
      else if ( ds === CONST.WALL_DOOR_STATES.LOCKED ) color = 0xEE4444;
    }
    return color ?? 0xFFFFBB;
  }

  /* -------------------------------------------- */

  /**
   * Adapt the width that the wall should be rendered based on the grid size.
   * @returns {number}
   */
  static #getLineWidth() {
    return 2 * canvas.dimensions.uiScale;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Re-draw door icons
    const doorChange = ["door", "ds"].some(k => k in changed);
    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();

    // 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 ( ("c" in changed) || ("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
    const dirChanged = "dir" in changed;
    this.renderFlags.set({
      refreshLine: doorChange || dirChanged || ("c" in changed) || CONST.WALL_RESTRICTION_TYPES.some(k => k in changed),
      refreshDirection: dirChanged
    });
  }

  /* -------------------------------------------- */
  /*  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|null}
   */
  createDoorControl() {
    if ( !this.doorControl ) {
      if ( !((this.document.door === CONST.WALL_DOOR_TYPES.DOOR)
        || ((this.document.door === CONST.WALL_DOOR_TYPES.SECRET) && game.user.isGM)) ) return null;
      this.doorControl = new CONFIG.Canvas.doorControlClass(this);
      this.doorControl.draw();
    }
    if ( !this.doorControl.parent ) canvas.controls.doors.addChild(this.doorControl);
    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 ( !super._canControl(user, event) ) 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;
    if ( event instanceof PIXI.FederatedEvent ) {
      const dest = event.getLocalPosition(this.layer);
      this.layer._last = {
        point: WallsLayer.getClosestEndpoint(dest, this)
      };
    }
    return super._onHoverIn(event, options);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onHoverOut(event, options) {
    const mgr = canvas.mouseInteractionManager;
    if ( this.hover && !this.layer._chain && (mgr.state < mgr.states.CLICKED) ) this.layer._last = {point: null};
    return super._onHoverOut(event, options);
  }

  /* -------------------------------------------- */

  /** @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;
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickLeft(event) {
    if ( this.layer._chain ) return false;
    event.stopPropagation();
    if ( this.controlled && !event.altKey ) {
      if ( event.shiftKey ) {
        this.release();
        return false;
      }
      else if ( this.layer.controlled.length > 1 ) return;
    }
    this.control({releaseOthers: !event.shiftKey, chain: event.altKey});
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickLeft2(event) {
    event.stopPropagation();
    this.sheet.render({force: true});
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickRight2(event) {
    event.stopPropagation();
    this.sheet.render({force: true});
  }

  /* -------------------------------------------- */

  /** @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 v14
   * @ignore
   */
  initializeEdge({deleted=false}={}) {
    foundry.utils.logCompatibilityWarning("Wall#initializeEdge has been deprecated. Use WallDocument#initializeEdge instead.",
      {since: 15, until: 16, once: true});
    this.document.initializeEdge({deleted});
  }
};

/**
 * @import {LightSourceData} from "../sources/base-light-source.mjs";
 */

/**
 * 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 ShapeObjectMixin(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;

  /**
   * The shape controls.
   * @type {AmbientLightShapeControls}
   */
  controls;

  /**
   * The tooltip text of this AmbientLight, which contains its elevation.
   * @type {PreciseText}
   */
  tooltip;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static embeddedName = "AmbientLight";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshTransform", "refreshField", "refreshTooltip"], alias: true},
    refreshState: {propagate: ["refreshVisibility"]},
    refreshVisibility: {},
    refreshTransform: {propagate: ["refreshPosition", "refreshRotation", "refreshSize"], alias: true},
    refreshPosition: {propagate: ["refreshMeasurements"]},
    refreshRotation: {propagate: ["refreshMeasurements"]},
    refreshSize: {propagate: ["refreshMeasurements"]},
    refreshField: {},
    refreshTooltip: {},
    refreshMeasurements: {},
    /** @deprecated since v14 */
    refreshElevation: {propagate: ["refreshTooltip"], deprecated: {since: 14, until: 16}, alias: true}
  };

  /* -------------------------------------------- */

  /** @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 this.document.shape.radius;
  }

  /* -------------------------------------------- */

  /**
   * Get the pixel radius of dim light emitted by this light source
   * @type {number}
   */
  get dimRadius() {
    return this.config.dim * canvas.dimensions.distancePixels;
  }

  /* -------------------------------------------- */

  /**
   * Get the pixel radius of bright light emitted by this light source
   * @type {number}
   */
  get brightRadius() {
    return this.config.bright * canvas.dimensions.distancePixels;
  }

  /* -------------------------------------------- */

  /**
   * 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 and lights with zero radius or angle are disabled
    if ( hidden || !this.radius || !config.angle ) return true;

    if ( this.isFilteredOut ) 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();
  }

  /* -------------------------------------------- */

  /** @override */
  get isInteractable() {
    if ( this.isPreview ) return false;
    if ( !this.layer.active ) return false;
    return !!ui.controls.tool?.interaction;
  }

  /* -------------------------------------------- */
  /* Rendering
  /* -------------------------------------------- */

  /** @inheritDoc */
  _destroy(options) {
    this.initializeLightSource({deleted: true});
    this._original?.initializeLightSource();
    super._destroy(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);
    this.field = this.addChild(new PIXI.Graphics());
    this.field.eventMode = "none";
    this.addChild(this._measurementLines);
    this.controls = this.addChild(new AmbientLightShapeControls(this.document.shape.clone()));
    await this.controls.draw();
    this.addChild(this._measurementLabels);
    this.tooltip = this.addChild(this.#drawTooltip());
    this.cursor = "pointer";
    this._original?.initializeLightSource({deleted: true});
    this.initializeLightSource();
  }

  /* -------------------------------------------- */

  /**
   * Draw a text tooltip for the AmbientLight which can be used to display Elevation or something else.
   * @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.pivot.set(0, CONFIG.Canvas.objectBorderThickness * 3 * s);
    tooltip.scale.set(s, s);
    return tooltip;
  }

  /* -------------------------------------------- */

  /** @override */
  _overlapsSelection(rectangle) {
    const {x, y} = this.controls.shape.origin;
    return rectangle.contains(x, y);
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyRenderFlags(flags) {
    super._applyRenderFlags(flags);
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshRotation ) this._refreshRotation();
    if ( flags.refreshSize ) this._refreshSize();
    if ( flags.refreshField ) this._refreshField();
    if ( flags.refreshTooltip ) this._refreshTooltip();
    if ( flags.refreshMeasurements ) this._refreshMeasurements();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the position of the AmbientLight.
   * @protected
   */
  _refreshPosition() {
    const {x, y} = this.document.shape;
    this.controls.shape.updateSource({x, y});
    this.controls.refresh();
    this.tooltip.position.set(x, y);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the rotation of the AmbientLight.
   * @protected
   */
  _refreshRotation() {
    if ( this.controls.shape.type !== "cone" ) return;
    this.controls.shape.updateSource({angle: this.document.config.angle, rotation: this.document.rotation + 90});
    this.controls.refresh();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the size of the AmbientLight.
   * @protected
   */
  _refreshSize() {
    this.controls.shape.updateSource({radius: this.document.shape.radius});
    this.controls.refresh();
  }

  /* -------------------------------------------- */

  /**
   * 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;
    const s = canvas.dimensions.uiScale;
    this.field.lineStyle(2 * s, 0xEEEEEE, 0.4).drawShape(this.lightSource.shape);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the tooltip.
   * @protected
   */
  _refreshTooltip() {
    this.tooltip.text = this._getTooltipText();
    this.tooltip.style = this._getTextStyle();
  }

  /* -------------------------------------------- */

  /**
   * Return the text which should be displayed in the tooltip.
   * @returns {string}
   * @protected
   */
  _getTooltipText() {
    const elevation = (this.document.elevation- canvas.level.elevation.base).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 the tooltip.
   * @returns {PIXI.TextStyle}
   * @protected
   */
  _getTextStyle() {
    const style = CONFIG.canvasTextStyle.clone();
    style.fontSize = 24;
    return style;
  }

  /* -------------------------------------------- */

  /** @override */
  _getMeasuredShapes() {
    return this.isPreview ? [this.controls.shape] : [];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshState() {
    super._refreshState();
    const {hidden, locked} = this.document;
    const colors = CONFIG.Canvas.dispositionColors;
    this.controls.tint = this.controlled || this.isPreview ? colors.CONTROLLED
      : (locked ? colors.HOSTILE : colors.INACTIVE);
    this.controls.border.visible = this.controlled || this.hover || this.layer.highlightObjects || this.isPreview;
    const showNonTranslateHandles = this.controlled && (this.layer.controlled.length === 1) && !locked;
    for ( const handle of this.controls.handles.children ) {
      if ( handle.name !== "translate" ) handle.visible = showNonTranslateHandles;
    }
    this.controls.editable = this.layer.active && !!ui.controls.tool?.interaction
      && !!ui.controls.tool?.control && !locked;
    this.controls.dashed = hidden;
  }

  /* -------------------------------------------- */
  /*  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}={}) {
    if ( this.destroyed || (this.hasPreview && !deleted) ) return;
    const perceptionFlags = {
      initializeVision: false,
      initializeLighting: false,
      refreshLighting: true,
      refreshVision: true
    };
    const affectedLevels = new Set();

    // Handle updates based on the prior source having edges
    if ( this.lightSource?.requiresEdges ) {
      perceptionFlags.initializeLighting = true;
      perceptionFlags.initializeVision ||= this.lightSource.level.isView;
      affectedLevels.add(this.lightSource.level);
    }

    // Handle deletion
    if ( deleted ) this.#destroyLightSource();
    else {

      // Create if missing or recreate if type changed
      const sourceType = this.config.negative ? "darkness" : "light";
      if ( this.lightSource && (this.lightSource.constructor.sourceType !== sourceType) ) this.#destroyLightSource();
      this.lightSource ??= this.#createLightSource();

      // Re-initialize source data and add it to the active collection
      this.lightSource.initialize(this._getLightSourceData());
      this.lightSource.add();

      // Handle updates based on the current source having edges
      if ( this.lightSource.requiresEdges ) {
        perceptionFlags.initializeLighting = true;
        perceptionFlags.initializeVision ||= this.lightSource.level.isView;
        affectedLevels.add(this.lightSource.level);
      }
    }

    // Update perception and rendering
    canvas.perception.update(perceptionFlags);
    if ( this.layer.active ) this.renderFlags.set({refreshField: true});
    for ( const level of affectedLevels ) level.updateRegionShapeConstraints(["light", "darkness", "sight"]);
  }

  /* -------------------------------------------- */

  /**
   * Get the light source data.
   * @returns {LightSourceData}
   * @protected
   */
  _getLightSourceData() {
    const {x, y, elevation, rotation, walls, vision, config, levels} = this.document;
    const level = canvas.inferLevelFromElevation(elevation, {levels});
    return foundry.utils.mergeObject(config.toObject(false), {
      x, y, level: level.id, 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({
      redraw: this.controls.shape.type !== this.document.shape.type,
      refreshState: ("hidden" in changed) || ("locked" in changed),
      refreshPosition: ("x" in changed) || ("y" in changed),
      refreshRotation: ("rotation" in changed) || (("config" in changed) && ("angle" in changed.config)),
      refreshSize: ("config" in changed) && (("dim" in changed.config) || ("bright" in changed.config)),
      refreshTooltip: "elevation" in changed
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    this.initializeLightSource({deleted: true});
    super._onDelete(options, userId);
  }

  /* -------------------------------------------- */

  /** @override */
  _hasShapeChanged(changed) {
    if ( ["x", "y", "rotation"].some(k => k in changed) ) return true;
    if ( !changed.config ) return false;
    return ["dim", "bright", "angle"].some(k => k in changed.config);
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _canHUD(user, event) {
    return this.layer.active && user.isGM; // Allow GMs to single right-click
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _canConfigure(user, event) {
    return false; // Double-right does nothing
  }

  /* -------------------------------------------- */

  /** @override */
  _onControl(options) {
    super._onControl(options);
    for ( const object of this.layer.controlled ) {
      if ( object !== this ) object.renderFlags.set({refreshState: true});
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onRelease(options) {
    super._onRelease(options);
    for ( const object of this.layer.controlled ) object.renderFlags.set({refreshState: true});
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickRight(event) {
    if ( !this.document.locked ) this.document.update({hidden: !this.document.hidden});
    if ( !this._propagateRightClick(event) ) event.stopPropagation();
  }

  /* -------------------------------------------- */

  /** @override */
  _updateDragPreviews(event) {
    const {clones, origin, shape, offset} = event.interactionData;
    const dx = shape.origin.x - (origin.x - offset.x);
    const dy = shape.origin.y - (origin.y - offset.y);
    for ( const clone of clones ) {
      const {x, y} = clone._original.document;
      clone.document.updateSource({x: x + dx, y: y + dy});
      clone.renderFlags.set({refreshPosition: true, refreshField: true});
      clone.initializeLightSource();
    }
  }
};

/**
 * 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 ShapeObjectMixin(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;

  /**
   * The shape controls.
   * @type {AmbientSoundShapeControls}
   */
  controls;

  /**
   * The tooltip text of this AmbientSound, which contains its elevation.
   * @type {PreciseText}
   */
  tooltip;

  /** @inheritdoc */
  static embeddedName = "AmbientSound";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshTransform", "refreshField", "refreshTooltip"], alias: true},
    refreshState: {propagate: ["refreshVisibility"]},
    refreshVisibility: {},
    refreshTransform: {propagate: ["refreshPosition", "refreshSize"], alias: true},
    refreshPosition: {propagate: ["refreshMeasurements"]},
    refreshSize: {propagate: ["refreshMeasurements"]},
    refreshField: {},
    refreshTooltip: {},
    refreshMeasurements: {},
    /** @deprecated since v14 */
    refreshElevation: {propagate: ["refreshTooltip"], deprecated: {since: 14, until: 16}, alias: true}
  };

  /* -------------------------------------------- */

  /**
   * 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
  /* -------------------------------------------- */

  /** @override */
  get isInteractable() {
    if ( this.isPreview ) return false;
    if ( !this.layer.active ) return false;
    return !!ui.controls.tool?.interaction;
  }

  /* -------------------------------------------- */

  /**
   * 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.path || this.document.hidden || !this.document.radius ) return false;
    if ( this.isFilteredOut ) return false;
    return canvas.darknessLevel.between(this.document.darkness.min ?? 0, this.document.darkness.max ?? 1);
  }

  /* -------------------------------------------- */

  /**
   * A convenience accessor for the sound radius in pixels
   * @type {number}
   */
  get radius() {
    return this.document.shape.radius;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @override */
  _overlapsSelection(rectangle) {
    const {x, y} = this.controls.shape.origin;
    return rectangle.contains(x, y);
  }

  /* -------------------------------------------- */

  /**
   * 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;
    }
    super._clear();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);
    this.field = this.addChild(new PIXI.Graphics());
    this.field.eventMode = "none";
    this.addChild(this._measurementLines);
    this.controls = this.addChild(new AmbientSoundShapeControls(this.document.shape.clone()));
    await this.controls.draw();
    this.addChild(this._measurementLabels);
    this.tooltip = this.addChild(this.#drawTooltip());
    this.cursor = "pointer";
    this._original?.initializeSoundSource({deleted: true});
    this.initializeSoundSource();
  }

  /* -------------------------------------------- */

  /**
   * Draw a text tooltip for the AmbientSound which can be used to display Elevation or something else.
   * @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.pivot.set(0, CONFIG.Canvas.objectBorderThickness * 3 * s);
    tooltip.scale.set(s, s);
    return tooltip;
  }

  /* -------------------------------------------- */

  /** @override */
  _destroy(options) {
    this.initializeSoundSource({deleted: true});
    this._original?.initializeSoundSource();
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyRenderFlags(flags) {
    super._applyRenderFlags(flags);
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshRotation ) this._refreshRotation();
    if ( flags.refreshSize ) this._refreshSize();
    if ( flags.refreshField ) this._refreshField();
    if ( flags.refreshTooltip ) this._refreshTooltip();
    if ( flags.refreshMeasurements ) this._refreshMeasurements();
  }

  /* -------------------------------------------- */

  /**
   * 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(2 * s, 0xEEEEEE, 0.4).beginFill(0xAADDFF, 0.15).drawShape(this.source.shape).endFill();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the position of the AmbientSound. Called with the coordinates change.
   * @protected
   */
  _refreshPosition() {
    const {x, y} = this.document.shape;
    this.controls.shape.updateSource({x, y});
    this.controls.refresh();
    this.tooltip.position.set(x, y);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the size of the AmbientSound.
   * @protected
   */
  _refreshSize() {
    this.controls.shape.updateSource({radius: this.document.shape.radius});
    this.controls.refresh();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshState() {
    super._refreshState();
    const {hidden, locked} = this.document;
    const colors = CONFIG.Canvas.dispositionColors;
    this.controls.tint = this.controlled || this.isPreview ? colors.CONTROLLED
      : (locked ? colors.HOSTILE : colors.INACTIVE);
    this.controls.border.visible = this.controlled || this.hover || this.layer.highlightObjects || this.isPreview;
    const showNonTranslateHandles = this.controlled && (this.layer.controlled.length === 1) && !locked;
    for ( const handle of this.controls.handles.children ) {
      if ( handle.name !== "translate" ) handle.visible = showNonTranslateHandles;
    }
    this.controls.editable = this.layer.active && !!ui.controls.tool?.interaction
      && !!ui.controls.tool?.control && !locked;
    this.controls.dashed = hidden;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the tooltip.
   * @protected
   */
  _refreshTooltip() {
    this.tooltip.text = this._getTooltipText();
    this.tooltip.style = this._getTextStyle();
  }

  /* -------------------------------------------- */

  /**
   * Return the text which should be displayed in the tooltip.
   * @returns {string}
   * @protected
   */
  _getTooltipText() {
    const elevation = (this.document.elevation - canvas.level.elevation.base).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 the tooltip.
   * @returns {PIXI.TextStyle}
   * @protected
   */
  _getTextStyle() {
    const style = CONFIG.canvasTextStyle.clone();
    style.fontSize = 24;
    return style;
  }

  /* -------------------------------------------- */

  /** @override */
  _getMeasuredShapes() {
    return this.isPreview ? [this.controls.shape] : [];
  }

  /* -------------------------------------------- */
  /*  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}={}) {
    if ( this.destroyed || (this.hasPreview && !deleted) ) return;

    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() {
    const {x, y, elevation, walls, levels} = this.document;
    const level = canvas.inferLevelFromElevation(elevation, {levels});
    return {
      x, y, level: level.id, elevation, radius: this.radius, walls,
      preview: this.isPreview, 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) || ("locked" in changed) || ("path" in changed) || ("darkness" in changed),
      refreshPosition: ("x" in changed) || ("y" in changed),
      refreshSize: "radius" in changed,
      refreshTooltip: "elevation" in changed
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    this.sound?.stop();
    this.initializeSoundSource({deleted: true});
    super._onDelete(options, userId);
  }

  /* -------------------------------------------- */

  /** @override */
  _hasShapeChanged(changed) {
    return ["x", "y", "radius"].some(k => k in changed);
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _canHUD(user, event) {
    return this.layer.active && user.isGM; // Allow GMs to single right-click
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _canConfigure(user, event) {
    return false; // Double-right does nothing
  }

  /* -------------------------------------------- */

  /** @override */
  _onControl(options) {
    super._onControl(options);
    for ( const object of this.layer.controlled ) {
      if ( object !== this ) object.renderFlags.set({refreshState: true});
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onRelease(options) {
    super._onRelease(options);
    for ( const object of this.layer.controlled ) object.renderFlags.set({refreshState: true});
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickRight(event) {
    if ( !this.document.locked ) this.document.update({hidden: !this.document.hidden});
    if ( !this._propagateRightClick(event) ) event.stopPropagation();
  }

  /* -------------------------------------------- */

  /** @override */
  _updateDragPreviews(event) {
    const {clones, origin, shape, offset} = event.interactionData;
    const dx = shape.origin.x - (origin.x - offset.x);
    const dy = shape.origin.y - (origin.y - offset.y);
    for ( const clone of clones ) {
      const {x, y} = clone._original.document;
      clone.document.updateSource({x: x + dx, y: y + dy});
      clone.renderFlags.set({refreshPosition: true, refreshField: true});
      clone.initializeSoundSource();
    }
  }
};

var _module$i = /*#__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,
  drawings: _module$n,
  lights: _module$m,
  regions: _module$l,
  sounds: _module$k,
  tokens: _module$j,
  types: _types$4
});

/**
 * A configurable particle effect meant to be used in the PrimaryCanvasGroup.
 * You must provide a full configuration object.
 * @deprecated since v14
 * @ignore
 */
class PrimaryParticleEffect extends CanvasTransformMixin(PIXI.Container) {
  constructor(config = {}) {
    super();
    foundry.utils.logCompatibilityWarning("The foundry.canvas.primary.PrimaryParticleEffect class is deprecated in"
      + " favor of the newer foundry.canvas.animation.ParticleGenerator API. Support for this class will be removed in"
      + " Version 16.", {since: 14, until: 16, once: true});
    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$h = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CanvasTransformMixin: CanvasTransformMixin,
  PrimaryCanvasContainer: PrimaryCanvasContainer,
  PrimaryCanvasObjectMixin: PrimaryCanvasObjectMixin,
  PrimaryCanvasParticleContainer: PrimaryCanvasParticleContainer,
  PrimaryGraphics: PrimaryGraphics,
  PrimaryOccludableObjectMixin: PrimaryOccludableObjectMixin,
  PrimaryParticleEffect: PrimaryParticleEffect,
  PrimarySpriteMesh: PrimarySpriteMesh
});

var _types$3 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

/**
 * This filter handles masking and post-processing for visual effects.
 */
class VisualEffectsMaskingFilter extends AbstractBaseMaskFilter {

  /**
   * 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 get defaultUniforms() {
    return {
      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]
    };
  }

  /* -------------------------------------------- */

  /** @override */
  static _createFragmentShader(options) {
    const postProcessModes = options?.postProcessModes ?? [];
    return `
    ${this.fragmentHeader}
    void main() {
      ${this.fragmentCore}
      ${this.fragmentPostProcess(postProcessModes)}
      if ( enableVisionMasking ) finalColor *= vec4(tint, 1.0);
      gl_FragColor = finalColor;
    }
    `;
  }

  /* -------------------------------------------- */

  /** @override */
  static create({postProcessModes, ...uniforms}={}, options) {
    const u = {...this.defaultUniforms, ...uniforms};
    return super.create(u, {...options, postProcessModes});
  }

  /* -------------------------------------------- */

  /**
   * 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;

    // Build both shaders via hooks to keep varyings in sync
    const vert = this.constructor._createVertexShader({postProcessModes});
    const frag = this.constructor._createFragmentShader({postProcessModes});
    this.program = PIXI.Program.from(vert, frag);
  }

  /* -------------------------------------------- */

  /**
   * Remove all post-processing modes and reset some key uniforms.
   */
  reset() {
    this.#postProcessModes = [];

    // Rebuild program using the hook-based pipeline
    const vert = this.constructor._createVertexShader();
    const frag = this.constructor._createFragmentShader();
    this.program = PIXI.Program.from(vert, frag);

    const uniforms = ["tint", "exposure", "contrast", "saturation"];
    const defaultUniforms = this.constructor.defaultUniforms;
    for ( const uniform of uniforms ) {
      this.uniforms[uniform] = 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 ?? ""), "");
  }
}

/**
 * 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 get defaultUniforms() {
    return {
      exploredColor: [1, 1, 1],
      unexploredColor: [0, 0, 0],
      backgroundColor: [0, 0, 0],
      screenDimensions: [1, 1],
      visionTexture: null,
      primaryTexture: null,
      overlayTexture: null,
      overlayMatrix: new PIXI.Matrix(),
      hasOverlayTexture: false
    };
  }

  /** @override */
  static _createVertexShader() {
    return `
    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 _createFragmentShader(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$g = /*#__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$f = /*#__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$e = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AdaptiveFragmentChannelMixin: AdaptiveFragmentChannelMixin,
  BaseShaderMixin: BaseShaderMixin
});

var _module$d = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BLEND_MODES: BLEND_MODES,
  batching: _module$f,
  filters: _module$g,
  mixins: _module$e,
  shaders: _module$u
});

/**
 * @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,
      surfaceExposure: {threshold: canvas.grid.distance}
    });
  }

  /* -------------------------------------------- */
  /*  Edge Management                             */
  /* -------------------------------------------- */

  /** @override */
  _getEdgeCreationOptions() {
    return {
      type: "source",
      object: this.object,
      direction: CONST.EDGE_DIRECTIONS.LEFT,
      darkness: CONST.EDGE_SENSE_TYPES.NORMAL,
      priority: this.data.priority - 0.5 // Ensure that light loses to darkness at the same priority
    };
  }

  /* -------------------------------------------- */
  /*  Visibility Testing                          */
  /* -------------------------------------------- */

  /** @inheritDoc */
  testPoint(point) {

    // Magical light sources are vertically unbounded and aren't affected by surfaces
    if ( this.data.priority > 0 ) {
      const shape = this.shape;
      if ( !shape ) return false;
      return shape.contains(point.x, point.y);
    }

    return super.testPoint(point);
  }

  /* -------------------------------------------- */

  /**
   * 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, level, object}) {
    if ( !(this.data.vision && this._canDetectObject(object, level)) ) return false;
    return tests.some(test => test.level.isView && this.testPoint(test.point));
  }

  /* -------------------------------------------- */

  /**
   * 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
   * @param {Level} level                 The level the target is in
   * @returns {boolean}                   Can the target object theoretically be detected by this vision source?
   * @protected
   */
  _canDetectObject(target, level) {
    const tgt = target?.document;
    const isInvisible = ((tgt instanceof TokenDocument) && tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE));
    return !isInvisible;
  }
}

/**
 * @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, elevation, radius} = this.data;
    const distanceXY = Math.hypot(listener.x - x, listener.y - y);
    const distanceZ = Math.abs(listener.elevation - elevation) * canvas.dimensions.distancePixels;
    if ( (distanceXY === 0) && (distanceZ === 0) ) return 1;
    if ( (distanceXY > radius) || (distanceZ > radius + 1e-8) ) 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
    const dv = 1 - ((1 - (distanceXY / radius)) * (1 - (Math.min(distanceZ, radius) / radius)));
    return (Math.cos(Math.PI * dv) + 1) * 0.5;                             // Cosine easing [0, 1]
  }
}

/**
 * @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 {string} level               The ID of the Level the point source is in
 * @property {boolean} disabled           Whether or not the source is disabled
 */

/**
 * @typedef _RenderedEffectSourceData
 * {@ignore}
 * @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 {BaseEffectSourceData & _RenderedEffectSourceData} RenderedEffectSourceData
 * {@interface}
 */

/**
 * @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
 */

/**
 * @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
 */

/**
 * @typedef _VisionSourceData
 * {@ignore}
 * @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?
 */

/**
 * @typedef {RenderedEffectSourceData & _VisionSourceData} VisionSourceData
 * {@interface}
 */

var _types$2 = /*#__PURE__*/Object.freeze({
  __proto__: null
});

var _module$c = /*#__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,
  types: _types$2
});

/**
 * @import {VFXBasePathPoint, VFXComponentAnimation} from "./_types.mjs";
 */

const PI2 = Math.PI * 2;

/**
 * Interpolate rotation in radians between two angles.
 * @param {number} r1
 * @param {number} r2
 * @param {number} i
 * @returns {number}
 */
function interpolateRotation(r1, r2, i) {
  const diff = Math.abs(r2 - r1);
  if (diff > Math.PI) {
    if (r2 > r1) r1 += PI2;
    else r2 += PI2;
  }
  const value = Math.mix(r1, r2, i);
  return ((value % PI2) + PI2) % PI2;
}

/* -------------------------------------------- */

/**
 * Parse rotation options normalized to radians from shared config objects
 * @param {object} data
 * @param {number} [data.angle]         Initial rotation in degrees
 * @param {number} [data.rotation]      Initial rotation in radians
 * @param {Point} [data.rotateTowards]  Incremental rotation towards a destination position
 * @param {Point} [data.origin]         Rotate from an origin position, needed if using rotateTowards
 * @returns {number}
 */
function parseRotation(data) {
  let rotation = 0;
  if ( typeof data.rotation === "number" ) rotation = data.rotation;
  else if ( typeof data.angle === "number" ) rotation = Math.toRadians(data.angle);
  if ( data.rotateTowards ) {
    const {rotateTowards, origin} = data;
    if (rotateTowards && origin) {
      const dx = rotateTowards.x - origin.x;
      const dy = rotateTowards.y - origin.y;
      rotation += Math.atan2(dy, dx);
    }
  }
  return rotation;
}

/* -------------------------------------------- */

/**
 * Resolves a component animation by name.
 * @param {string} functionName     Name of the animation object in `foundry.vfx.CONFIG.animations`
 * @returns {VFXComponentAnimation} The resolved animations object
 */
function resolveAnimation(functionName) {
  const animation = CONFIG.Canvas.vfx.animations[functionName];
  if ( typeof animation?.animate !== "function" ) {
    throw new Error(`VFX animation "${functionName}" is not defined in CONFIG.Canvas.vfx.animations"`);
  }
  return animation;
}

/* -------------------------------------------- */

/**
 * Resolves an anime.js easing function by name and initializes it with parameters.
 * @param {string} functionName         Name of the easing function
 * @param {any[]} easingParams          Optional positional parameters to initialize the easing function
 * @returns {(time: number) => number}  The resolved easing function
 */
function resolveEasing(functionName, easingParams) {
  const easingFunction = animejs.eases[functionName];
  if ( typeof easingFunction !== "function") {
    throw new Error(`animejs easing function "${functionName}" not found`);
  }
  const isSimple = typeof easingFunction(1) === "number";
  if ( isSimple ) return easingFunction;
  if ( !easingParams ) throw new Error(`animejs easing function "${functionName}" requires provided parameters`);
  easingParams = easingParams ?? [];
  easingParams = Array.isArray(easingParams) ? easingParams : [easingParams];
  return easingFunction(...easingParams);
}

/* -------------------------------------------- */

/**
 * Calculates cubic Hermite basis functions for a given parameter t.
 * Returns an interpolation function that can be reused for multiple properties.
 * @param {number} t  Interpolation parameter
 * @returns {(p0: number, m0: number, p1: number, m1: number) => number} Interpolation function
 */
function hermiteBasis(t) {
  const t2 = t * t;
  const t3 = t2 * t;
  const h00 = (2 * t3) - (3 * t2) + 1;
  const h10 = t3 - (2 * t2) + t;
  const h01 = (-2 * t3) + (3 * t2);
  const h11 = t3 - t2;
  return (p0, m0, p1, m1) => (h00 * p0) + (h10 * m0) + (h01 * p1) + (h11 * m1);
}

/* -------------------------------------------- */

/**
 * Generates points along a cubic Hermite spline segment.
 * @param {VFXBasePathPoint} p0     Start control point
 * @param {Point} m0                Tangent vector at start point
 * @param {VFXBasePathPoint} p1     End control point
 * @param {Point} m1                Tangent vector at end point
 * @param {number} numPoints        Number of points to generate along the segment
 * @param {string[]} [auxiliary]    An array of auxiliary numeric attributes to interpolate
 * @returns {VFXBasePathPoint[]}    Array of points which may be augmented with additional auxiliary attributes
 */
function generateHermiteSegment(p0, m0, p1, m1, numPoints, auxiliary) {
  if ( numPoints < 1 ) return [p0];
  const points = [];
  for (let i=0; i<=numPoints; i++) {
    const t = i / numPoints;
    const interpolate = hermiteBasis(t);
    const x = interpolate(p0.x, m0.x, p1.x, m1.x);
    const y = interpolate(p0.y, m0.y, p1.y, m1.y);
    const point = {x, y, ...interpolateProperties(p0, p1, t, auxiliary)};
    points.push(point);
  }
  return points;
}

/* -------------------------------------------- */

/**
 * Interpolate auxiliary attributes between two points.
 * @param {VFXBasePathPoint} p0     Start control point
 * @param {VFXBasePathPoint} p1     End control point
 * @param {number} t                Interpolation parameter
 * @param {string[]} [auxiliary]    An array of auxiliary numeric attributes to interpolate. These attributes must be
 *                                  present all points. Attributes are either interpolated or carried forward from
 *                                  their last-observed value.
 * @returns {object}                Interpolated auxiliary properties
 */
function interpolateProperties(p0, p1, t, auxiliary=[]) {
  const aux = {};
  for ( const key of auxiliary ) { // Interpolate auxiliary attributes
    const a0 = p0[key];
    const has0 = typeof a0 === "number";
    if ( has0 ) {
      const a1 = p1[key];
      const has1 = typeof a1 === "number";
      if ( has1 ) aux[key] = Math.mix(a0, a1, t);
      else aux[key] = a0;
    }
  }
  return aux;
}


/* -------------------------------------------- */

var vfxUtils = /*#__PURE__*/Object.freeze({
  __proto__: null,
  generateHermiteSegment: generateHermiteSegment,
  interpolateProperties: interpolateProperties,
  interpolateRotation: interpolateRotation,
  parseRotation: parseRotation,
  resolveAnimation: resolveAnimation,
  resolveEasing: resolveEasing
});

/**
 * @import {VFXBasePathPoint, VFXPathPoint} from "./_types.mjs";
 */

/**
 * A class responsible for constructing a path of points used for animation.
 */
class VFXPath {
  /**
   * Construct a VFXPath by providing an array of base point data.
   * @param {VFXBasePathPoint[]} points
   */
  constructor(points) {
    if ( !Array.isArray(points) || (points.length < 2) ) {
      throw new Error("The VFXPath#constructor must be passed an array of points with length of at least 2.");
    }
    this.#distances = new Float32Array(points.length);
    this.#distances[0] = 0;
    this.#pathPoints = new Array(points.length);
    for (let i=0; i<points.length; i++) {
      let {x, y, rotation, elevation=0, sort=0} = points[i];
      const lastPoint = points[i-1] ?? points[i];
      const dx = x - lastPoint.x;
      const dy = y - lastPoint.y;
      const distance = this.#pathLength + Math.hypot(dx, dy);
      this.#distances[i] = distance;
      this.#pathLength = distance;
      if ( (typeof rotation !== "number") && (i > 0) ) rotation = Math.atan2(dy, dx);
      this.#pathPoints[i] = {x, y, rotation: rotation || 0, elevation, sort, distance, index: i};
    }
    this.#pathLength = this.#distances.at(-1) ?? 0;
    this.#pathPoints[0].rotation = this.#pathPoints[1].rotation;
  }

  /**
   * The array of points in the path
   * @type {VFXPathPoint[]}
   */
  get pathPoints() {
    return this.#pathPoints;
  }

  #pathPoints = [];

  /**
   * Discrete path rotation data. Holds distances from the start of the path to each point
   * @type {Float32Array}
   */
  #distances;

  /**
   * Total length of the traveled path across all segments.
   * @type {number}
   */
  #pathLength = 0;

  get pathLength() {
    return this.#pathLength;
  }

  /* -------------------------------------------- */

  /**
   * Get the interpolated point for a value x on [0, 1]
   * @param {number} x        The animation progress on [0, 1]
   * @param {number} index    A known index of the path which was already reached
   * @returns {VFXPathPoint}
   */
  interpolatedPoint(x, index=0) {
    if ( x <= 0 ) return this.#pathPoints[0];
    if ( (x >= 1) || !this.#pathLength ) return this.#pathPoints.at(-1);
    let i;
    for ( i=index; i<this.#pathPoints.length-1; i++ ) {
      if ( x < (this.#pathPoints[i].distance / this.#pathLength) ) break;
    }
    const p0 = this.#pathPoints[i-1];
    const p1 = this.#pathPoints[i];
    const x0 = p0.distance / this.#pathLength;
    const x1 = p1.distance / this.#pathLength;
    const w = (x - x0) / (x1 - x0);
    return {
      x: Math.mix(p0.x, p1.x, w),
      y: Math.mix(p0.y, p1.y, w),
      rotation: interpolateRotation(p0.rotation, p1.rotation, w),
      elevation: p1.elevation,  // Eagerly jump to values of elevation, sort, and sortLayer
      sort: p1.sort,
      sortLayer: p1.sortLayer,
      distance: x * this.#pathLength,
      index: i-1
    };
  }

  /* -------------------------------------------- */

  /**
   * Compute an interpolated point along the path at a given distance.
   * @param {number} distance     The desired distance along the path
   * @returns {VFXPathPoint}      An interpolated point for object position at that distance
   */
  interpolatedPointAtDistance(distance) {
    if ( distance <=0 ) return this.#pathPoints[0];
    if ( !this.#pathLength || (distance > this.#pathLength) ) return this.#pathPoints.at(-1);
    const index = this.#findIndexForDistance(distance);
    const p0 = this.#pathPoints[index];
    const p1 = this.#pathPoints[index + 1];
    if ( !p1 || (p0.distance === distance) ) return p0;
    const ratio = (distance - p0.distance) / (p1.distance - p0.distance);
    return {
      x: Math.mix(p0.x, p1.x, ratio),
      y: Math.mix(p0.y, p1.y, ratio),
      rotation: interpolateRotation(p0.rotation, p1.rotation, ratio),
      elevation: p1.elevation,  // Eagerly jump to values of elevation, sort, and sortLayer
      sort: p1.sort,
      sortLayer: p1.sortLayer,
      distance,
      index
    };
  }

  /* -------------------------------------------- */

  /**
   * Binary search for the index of the closest point in the path that is smaller or equal to the given distance.
   * Trusts that the provided distance is an intermediate value on (0, this.#pathLength).
   * @param {number} distance   A distance along the path that has been traveled
   * @returns {number}          The index of the closest point in the path shorter or equal to the given distance
   */
  #findIndexForDistance(distance) {
    let start = 0;
    let end = this.#distances.length - 1;
    while (start <= end) {
      const mid = (start + end) >> 1;
      if (distance >= this.#distances[mid] && distance < this.#distances[mid + 1]) return mid;
      if (distance < this.#distances[mid]) end = mid - 1;
      else start = mid + 1;
    }
    return this.#distances.length - 1;
  }

  /* -------------------------------------------- */

  /**
   * Get a configured path generator from CONFIG.Canvas.vfx.paths.
   * @param {string} pathName             The named path type
   * @returns {VFXPathGenerator}          The path generator function
   */
  static getPathGenerator(pathName) {
    const pathGenerator = CONFIG.Canvas.vfx.paths[pathName];
    if ( typeof pathGenerator !== "function" ) {
      throw new Error(`VFX path generator function "${pathName}" was not defined in CONFIG.Canvas.vfx.paths`);
    }
    return pathGenerator;
  }

  /* -------------------------------------------- */

  /**
   * Create a VFXPath instance of a certain named path type defined in CONFIG.Canvas.vfx.paths.
   * @param {string} pathName             The named path type to construct
   * @param {VFXBasePathPoint[]} points   Path points to construct
   * @param {object} parameters           Additional parameters used to construct the path
   * @returns {VFXPath}                   The generated path
   */
  static create(pathName, points, parameters={}) {
    const pathGenerator = this.getPathGenerator(pathName);
    return pathGenerator(points, parameters);
  }
}

/**
 * @import { DataField } from "@common/data/fields.mjs";
 * @import { DataFieldOptions, DataFieldContext, DataModelConstructionContext } from "@common/data/_types.mjs"
 * @import { VFXCanvasContainer } from '../canvas/vfx-canvas-container.mjs'
 */

/**
 * Will be extended later to support spritesheet frames and animations
 * @typedef {string} VFXTextureConfig
 **/

/**
 * @template {object} ModelData
 * @extends {foundry.abstract.DataModel<ModelData, DataModelConstructionContext>}
 */
class VFXComponent extends DataModel {

  /**
   * The type of this component. Must be overridden in the subclass.
   * @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}"`
      })
    };
  }

  /* -------------------------------------------- */

  /**
   * A component-specific sub-timeline.
   * @type {animejs.Timeline}
   */
  get timeline() {
    return this.#timeline;
  }

  #timeline;

  /* -------------------------------------------- */

  /**
   * Have the materials for this component been loaded?
   * @type {boolean}
   */
  get loaded() {
    return this.#loaded;
  }

  #loaded = false;

  /* -------------------------------------------- */

  /**
   * Is the animation for this component playing?
   * @returns {boolean}
   */
  get playing() {
    return this.#timeline?.began && !this.#timeline.completed;
  }

  /* -------------------------------------------- */

  /**
   * Asset paths required to be loaded for this component.
   * @type {Set<string>}
   */
  get assetPaths() {
    return this.#assetPaths;
  }

  #assetPaths = new Set();

  /* -------------------------------------------- */

  /**
   * A registry of display objects which are managed by this component.
   * @type {Record<string, PIXI.DisplayObject[]>}
   */
  get managedDisplayObjects() {
    return this.#managedDisplayObjects;
  }

  #managedDisplayObjects = {primary: []};

  /* -------------------------------------------- */
  /*  Component Management Methods                */
  /* -------------------------------------------- */

  /**
   * Adds a DisplayObject to the set of managed primary display objects.
   * Entries in this list will be added to the primary canvas container when the component is attached and
   * removed when the component is destroyed.
   * @template {PIXI.DisplayObject} DisplayObject
   * @param {DisplayObject} object        The object to manage
   * @param {string} [group="primary"]    A canvas group that should contain the object
   * @returns {DisplayObject}
   */
  addManagedDisplayObject(object, group="primary") {
    this.#managedDisplayObjects[group] ||= [];
    this.#managedDisplayObjects[group].push(object);
    return object;
  }

  /* -------------------------------------------- */

  /**
   * Load this component.
   * @returns {Promise<void>}
   */
  async load() {
    if ( this.#loaded ) return;
    await this._load();
    await foundry.canvas.TextureLoader.loader.load(Array.from(this.assetPaths), {displayProgress: false});
    this.#loaded = true;
  }

  /**
   * Perform subclass-specific loading steps to prepare assets for rendering.
   * @protected
   * @returns {Promise<void>}
   */
  async _load() {}

  /* -------------------------------------------- */

  /**
   * Prepare the timeline and create display objects used by this component.
   * Components should override the _draw method to implement subclass-specific steps.
   * @returns {Promise<void>}
   */
  async draw() {
    this.#timeline = animejs.createTimeline({autoplay: false});
    this.#timeline.label("start");
    await this._draw();

    // Ensure an "end" label exists and that the time required to reach it is reserved
    if ( !Object.hasOwn(this.#timeline.labels, "end") ) this.#timeline.label("end");
    const end = this.#timeline.labels.end;
    if ( end > this.#timeline.iterationDuration ) this.#timeline.add({duration: end}, 0);
    this.#timeline.init();
  }

  /**
   * Perform subclass-specific drawing steps to configure the component timeline and create display objects.
   * @protected
   * @returns {Promise<void>}
   */
  async _draw() {}

  /* -------------------------------------------- */

  /**
   * Attach display objects used by this component to the canvas containers that should render them.
   */
  attach() {
    for ( const [group, objects] of Object.entries(this.#managedDisplayObjects) ) {
      for ( const object of objects ) canvas[group].addChild(object);
    }
    this._attach();
  }

  /**
   * Perform subclass-specific attachment steps to customize how display objects are added to the canvas.
   * @protected
   */
  _attach() {}

  /* -------------------------------------------- */

  /**
   * Stop playback of this component and destroy its contents.
   * @returns {Promise<void>}
   */
  async stop() {
    await this._stop();
    this.#timeline?.complete();
    this._destroy();
  }

  /* -------------------------------------------- */

  /**
   * Cancel playback of this component and destroy its contents.
   * @returns {Promise<void>}
   */
  async cancel() {
    await this._stop();
    this.#timeline?.cancel();
    this._destroy();
  }

  /**
   * Perform subclass-specific steps to discontinue component playback.
   * @protected
   * @returns {Promise<void>}
   */
  async _stop() {}

  /**
   * Perform subclass-specific teardown steps to destroy and dispose of component materials.
   */
  _destroy() {
    for ( const [group, objects] of Object.entries(this.#managedDisplayObjects) ) {
      for ( const object of objects ) {
        if ( object.destroyed ) continue;
        object.removeFromParent();
        object.destroy({children: true});
      }
      this.#managedDisplayObjects[group].length = 0;
    }
  }
}

/**
 * A specialized subclass of SchemaField that specifically deals with points.
 * This data structure accepts plain objects with {x, y} structure or PIXI.Point objects.
 */
class VFXPointField extends SchemaField {
  constructor(options={}, context={}) {
    super({
      x: new NumberField({ required: true, nullable: false}),
      y: new NumberField({ required: true, nullable: false})
    },
    {required: true, nullable: false, ...options}, context);
  }

  /* ---------------------------------------- */

  /** @override */
  _cast(value) {
    if (typeof value !== "object" || value === null) {
      return {};
    }
    if (foundry.utils.isPlainObject(value)) return value;
    return {x: value.x, y: value.y};
  }
}

/**
 * @import { DataFieldOptions, DataFieldValidationOptions, DataFieldContext } from "@common/data/_types.mjs"
 */

/**
 * @template ReferenceType
 * @typedef {Object} VFXReference
 * @property {string} reference
 * @property {string} [property]
 **/

/**
 * This specialized data field allows storing a data structure that will be later dynamically resolved.
 * This field can be used for a value type that is a single property.
 * If that property is numeric, the reference field can store a relative delta.
 *
 * @template {string|number|boolean|null} [ValueType=any]
 * @typedef {{reference: string; property?: string }|ValueType} VFXReferenceFieldData
 */
class VFXReferenceField extends DataField {
  /**
   * Construct a VFXReferenceField by providing the inner field schema that it wraps.
   * @param {DataField} valueField
   * @param {DataFieldOptions} options
   * @param {DataFieldContext} context
   */
  constructor(valueField, options, context) {
    super({...valueField.options, ...options}, context);
    this.valueField = valueField;
    valueField.parent = this;
  }

  /* ---------------------------------------- */

  /**
   * The schema of a reference object.
   * @type {SchemaField}
   */
  static referenceField = new SchemaField({
    reference: new StringField({required: true, nullable: false}),
    property: new StringField({required: false, nullable: true}),
    delta: new NumberField({required: false, nullable: false})
  }, {required: true, nullable: false});

  /** @type {DataField} */
  valueField;

  /* ---------------------------------------- */

  /** @override */
  _cleanType(value, options, _state) {
    if ( this.constructor.isReference(value) ) return this.constructor.referenceField.clean(value, options, _state);
    return this.valueField.clean(value, options, _state);
  }

  /* ---------------------------------------- */

  /** @override */
  _validateType(value, options) {
    if ( this.constructor.isReference(value) ) return this.constructor.referenceField.validate(value, options);
    return this.valueField.validate(value, options);
  }

  /* ---------------------------------------- */

  /** @override */
  initialize(value, model, options) {
    // A ReferenceField is initialized as its valueField or undefined
    if ( this.constructor.isReference(value) ) return undefined;
    return this.valueField.initialize(value, model, options);
  }

  /* ---------------------------------------- */

  /**
   * Resolve the value of a VFXReferenceObjectField
   * @param {any} value                         The initial value of the field which may contain a reference
   * @param {Record<string, any>} references    Provided references
   * @returns {ValueType|null|undefined}        The resulting resolved value with references applied, if possible
   */
  resolve(value, references) {
    if ( !this.constructor.isReference(value) ) return this.valueField.clean(value);
    let result = references[value.reference];
    if ( value.property ) result = foundry.utils.getProperty(result, value.property);
    if ( Number.isNumeric(value.delta) && Number.isNumeric(result) ) result += value.delta;
    return this.valueField.clean(result);
  }

  /* ---------------------------------------- */

  /**
   * Test whether a value is a reference.
   * @param {any} value
   * @returns {boolean}
   */
  static isReference(value) {
    try {
      this.referenceField.validate(value, {strict: true});
      return true;
    } catch(err) {
      return false;
    }
  }
}

/**
 * A specialized VFX reference field that accepts either a pre-computed {@link PointSourcePolygon} instance
 * or a serializable configuration object {x, y, type, radius} sufficient to create one.
 *
 * In the serialized path, the field stores a plain config object and automatically computes the polygon
 * during field initialization. In the reference path, the field resolves to a live PointSourcePolygon
 * at runtime, allowing multiple components to share a single pre-computed polygon without redundant
 * computation.
 *
 * In both cases, the initialized value accessed by the component is always a PointSourcePolygon instance
 * (or null/undefined if not configured).
 *
 * @extends VFXReferenceField
 */
class VFXPointSourcePolygonField extends VFXReferenceField {
  constructor(options, context) {
    const schema = new SchemaField({
      type: new StringField({required: true, blank: false, initial: "move", choices: CONST.EDGE_RESTRICTION_TYPES}),
      x: new NumberField({required: true, nullable: false}),
      y: new NumberField({required: true, nullable: false}),
      elevation: new NumberField({required: false, nullable: true, initial: undefined}),
      level: new DocumentIdField({readonly: false, initial: null}),
      radius: new NumberField({required: false, nullable: true, initial: null, positive: true}),
      angle: new AngleField({required: false, initial: undefined}),
      rotation: new AngleField({required: false, initial: undefined})
    }, {required: false, nullable: true, initial: null});
    super(schema, {nullable: true, initial: null, ...options}, context);
  }

  /* -------------------------------------------- */

  /** @override */
  _cleanType(value, options, state) {
    if ( value instanceof PointSourcePolygon ) return value;
    return super._cleanType(value, options, state);
  }

  /* -------------------------------------------- */

  /** @override */
  initialize(value, model, options) {
    if ( value instanceof PointSourcePolygon ) return value;
    if ( this.constructor.isReference(value) ) return undefined;
    if ( !value ) return value;
    const {x, y, elevation, ...config} = value;
    const origin = typeof elevation === "number" ? {x, y, elevation} : {x, y};
    config.level = config.level ? canvas.scene.levels.get(config.level) : canvas.level;
    return CONFIG.Canvas.polygonBackends[config.type].create(origin, config);
  }

  /* -------------------------------------------- */

  /** @override */
  _validateType(value, options) {
    if ( value instanceof PointSourcePolygon ) return;
    return super._validateType(value, options);
  }

  /* -------------------------------------------- */

  /** @override */
  resolve(value, references) {
    if ( !this.constructor.isReference(value) ) return this.valueField.clean(value);
    let result = references[value.reference];
    if ( value.property ) result = foundry.utils.getProperty(result, value.property);
    if ( result instanceof PointSourcePolygon ) return result;
    return this.valueField.clean(result);
  }
}

/**
 * A subclass of VFXReferenceField which is used to target an entire object.
 * This allows applying deltas to multiple properties of that object.
 * @template {object} [ValueType=any]
 * @extends VFXReferenceField<ValueType>
 *
 * @example
 * ```js
 * const dimensions = new VFXReferenceObjectField(new SchemaField({
 *   width: new NumberField(),
 *   height: new NumberField()
 * }));
 * const unresolvedValue = {reference: "target", deltas: {width: 1, height: -1}};
 * const references = {target: tokenDocument}; // Suppose tokenDocument width=4 and tokenDocument height=4
 * const resolvedObject = dimensions.resolve(unresolvedValue, references); // {width: 5, height: 3}
 * ```
 */
class VFXReferenceObjectField extends VFXReferenceField {
  constructor(schema, options, context) {
    super(schema, {
      required: schema.options.required,
      nullable: schema.options.nullable,
      initial: schema.options.initial,
      ...options
    }, context);
  }

  /* ---------------------------------------- */

  /** @override */
  static referenceField = new SchemaField({
    reference: new StringField({ required: true, nullable: false }),
    property: new StringField({ required: false, nullable: true }),
    deltas: new TypedObjectField(new NumberField({nullable: false, required: true}))
  }, { required: true, nullable: false });

  /* ---------------------------------------- */

  /** @override */
  resolve(value, references) {
    if ( !this.constructor.isReference(value) ) return this.valueField.clean(value);
    let target = references[value.reference];
    if ( value.property ) target = foundry.utils.getProperty(target, value.property);
    const result = {};
    for ( const [k, field] of Object.entries(this.valueField.fields) ) {
      let v = target[k];
      if ( Number.isNumeric(value.deltas?.[k]) && Number.isNumeric(v) ) v += value.deltas[k];
      result[k] = field.clean(v);
    }
    return result;
  }
}

/** @import { DataFieldOptions, DataFieldContext } from "@common/data/_types.mjs" */

/**
 * A specialized subclass of VFXReferenceObjectField that specifically deals with points.
 * @extends VFXReferenceObjectField<Point>
 *
 * @example Resolve a relative point into an absolute coordinate.
 * ```js
 * const point = new VFXReferencePointField();
 * const unresolvedValue = {reference: "target", deltas: {x: -50, y: 50}};
 * const references = {target: tokenDocument}; // Suppose tokenDocument.x is 1000 and tokenDocument.y is 2000
 * const resolvedObject = point.resolve(unresolvedValue, references); // {x: 950, y: 2050}
 * ```
 */
class VFXReferencePointField extends VFXReferenceObjectField {
  constructor(options, context={}) {
    super(new VFXPointField(), options, context);
  }
}

var _module$b = /*#__PURE__*/Object.freeze({
  __proto__: null,
  VFXPointField: VFXPointField,
  VFXPointSourcePolygonField: VFXPointSourcePolygonField,
  VFXReferenceField: VFXReferenceField,
  VFXReferenceObjectField: VFXReferenceObjectField,
  VFXReferencePointField: VFXReferencePointField
});

/**
 * @import {ParticleGeneratorConfiguration, ParticleGeneratorRotationOptions} from "../../animation/_types.mjs";
 */

/**
 * @typedef VFXParticleGeneratorData
 * @property {string[]} textures                           Texture paths for particles
 * @property {string} mode                                 Generator mode: "ambient" or "effect" (default "effect")
 * @property {number} count                                Target particle count (default 50)
 * @property {object|null} area                            Spawn area; a point, rect, circle, ring, or line object.
 *                                                         Supports reference resolution (e.g. {reference: "target"}).
 * @property {{min: number, max: number|null}} lifetime    Particle lifetime in ms. If max is null, min is used as a
 *                                                         fixed value. Otherwise, spawns with a random lifetime in
 *                                                         [min, max] (default min 1000).
 * @property {{in: number, out: number}|null} fade         Fade-in and fade-out durations in ms (default null)
 * @property {{min: number, max: number}} alpha            Alpha range [min, max] (default 1)
 * @property {{min: number, max: number}} scale            Scale range [min, max] (default 1)
 * @property {{angle: number, speed: number, x: number, y: number}|null} velocity
 *                                                         Initial particle velocity. Specify either x/y components
 *                                                         or speed (px/s) and angle (degrees). (default null)
 * @property {ParticleGeneratorRotationOptions} [rotation] Rotation configuration for particles.
 * @property {number} blend                                PIXI blend mode (default NORMAL = 0)
 * @property {number} elevation                            Layer elevation for the particle container (default 0)
 * @property {number} sort                                 Sort order within the elevation layer (default 0)
 * @property {number} [duration]                           How long the generator runs in ms before soft-stopping
 *                                                         to let existing particles expire. If absent, runs until
 *                                                         the component is explicitly stopped.
 * @property {number} initial                              Proportion of count to spawn immediately on start [0, 1]
 *                                                         (default 0.25)
 * @property {number} spawnRate                            Maximum particles spawned per second during auto-spawn
 *                                                         (default 300)
 * @property {number} [perFrame=5]                         Deprecated since v14. Migrated to `spawnRate`.
 * @property {ParticleGeneratorConfiguration} config
 * Additional ParticleGenerator configuration passed directly to the constructor. Can include orbit, follow,
 * constraints, drift, blur, clip, anchor, callbacks, and any other {@link ParticleGeneratorConfiguration} fields.
 * When effects are built directly in code, this can also include a custom batchable `shaderClass` that is
 * compatible with SpriteMesh and whose plugin was already registered.
 * Values here override any explicitly-set schema fields of the same name.
 * @property {PointSourcePolygon|{x: number, y: number, type: string, radius: number|null}|null} pointSourceMask
 * An optional mask for clipping particles to wall boundaries. Accepts either a pre-computed PointSourcePolygon
 * (shared via VFXReferenceField resolution) or a serializable config object {x, y, type, radius} from which a polygon
 * is computed at draw time. Supports reference resolution for efficient reuse across multiple components within the
 * same effect.
 */

/**
 * A VFX component that creates and manages a {@link foundry.canvas.animation.ParticleGenerator}.
 * Handles the full lifecycle: loading textures, constructing the generator config, starting and
 * stopping the generator in sync with the VFX timeline.
 *
 * The generator always runs with `manual: false` so particles are spawned automatically. Provide
 * a `duration` to limit how long the generator emits; after that time it soft-stops and lets any
 * in-flight particles expire naturally. If no `duration` is given the generator runs indefinitely
 * until the component is stopped externally.
 *
 * @example A burst of sparks at an impact point
 * ```js
 * const vfxConfig = {
 *   name: "sparkBurst",
 *   components: {
 *     sparks: {
 *       type: "particleGenerator",
 *       textures: ["assets/particles/spark.png"],
 *       area: {reference: "target"},
 *       count: 40,
 *       spawnRate: 90,
 *       duration: 1200,
 *       lifetime: {min: 300, max: 700},
 *       fade: {in: 50, out: 200},
 *       scale: {min: 0.2, max: 0.6},
 *       velocity: {speed: 4, angle: 0},
 *       config: {
 *         constraints: {mode: "none"},
 *         drift: {enabled: true, intensity: 0.4}
 *       }
 *     }
 *   },
 *   timeline: [{component: "sparks"}]
 * };
 * const effect = new foundry.canvas.vfx.VFXEffect(vfxConfig);
 * const target = game.user.targets.first();
 * effect.play({target: target.center});
 * ```
 * @extends {VFXComponent<VFXParticleGeneratorData>}
 */
class VFXParticleGeneratorComponent extends VFXComponent {

  /** @override */
  static TYPE = "particleGenerator";

  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      alpha: new SchemaField({
        max: new AlphaField(),
        min: new AlphaField()
      }),
      area: new VFXReferenceField(new ObjectField({required: false, nullable: true, initial: null})),
      blend: new NumberField({nullable: false, initial: 0}),
      count: new VFXReferenceField(new NumberField({required: true, nullable: true, initial: null, positive: true})),
      duration: new NumberField({nullable: false}),
      elevation: new NumberField({required: true, nullable: false, initial: 0}),
      fade: new SchemaField({
        in: new NumberField({required: true, nullable: false, initial: 0}),
        out: new NumberField({required: true, nullable: false, initial: 0})
      }, {required: false, nullable: true, initial: null}),
      initial: new NumberField({required: true, nullable: false, initial: 0.25, min: 0, max: 1}),
      lifetime: new SchemaField({
        max: new NumberField({initial: null, positive: true}),
        min: new NumberField({required: true, nullable: false, initial: 1000, positive: true})
      }),
      mode: new StringField({required: true, blank: false, initial: "effect",
        choices: ["ambient", "effect"]}),
      config: new ObjectField({required: false}),
      spawnRate: new NumberField({required: true, nullable: false, initial: 300, positive: true}),
      rotation: new SchemaField({
        alignVelocity: new foundry.data.fields.BooleanField(),
        initial: new NumberField({nullable: false, initial: 0}),
        spread: new NumberField({nullable: false, initial: Math.PI}),
        speed: new NumberField({nullable: false, initial: 0})
      }, {required: false}),
      scale: new SchemaField({
        max: new NumberField({required: true, nullable: false, initial: 1, positive: true}),
        min: new NumberField({required: true, nullable: false, initial: 1, positive: true})
      }),
      sort: new NumberField({required: true, nullable: false, initial: 0}),
      textures: new ArrayField(new StringField({required: true, blank: false})),
      velocity: new SchemaField({
        angle: new NumberField({nullable: false}),
        speed: new NumberField({nullable: false}),
        x: new NumberField({nullable: false}),
        y: new NumberField({nullable: false})
      }, {required: false, nullable: true, initial: null}),
      pointSourceMask: new VFXPointSourcePolygonField()
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(data, options) {
    const hasLegacyPerFrame = data.perFrame != null;
    if ( (data.spawnRate == null) && (data.perFrame != null) ) {
      const perFrame = Number(data.perFrame) || 5;
      data.spawnRate = Math.max(0, perFrame) * PIXI.Ticker.targetFPMS * 1000;
    }
    if ( hasLegacyPerFrame && !(data.config && Object.hasOwn(data.config, "particleAnchor")) ) {
      data.config ??= {};
      data.config.particleAnchor = {x: 0.5, y: 0.5};
    }
    delete data.perFrame;
    return super.migrateData(data, options);
  }

  /* -------------------------------------------- */

  /**
   * The active ParticleGenerator instance, populated when playback begins.
   * @type {foundry.canvas.animation.ParticleGenerator|null}
   */
  #generator = null;

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _load() {
    for ( const texture of this.textures ) this.assetPaths.add(texture);
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw() {
    this.timeline.call(() => {
      const textures = this.textures.map(path => foundry.canvas.getTexture(path)).filter(Boolean);
      if ( !textures.length ) {
        console.warn("VFXParticleGeneratorComponent: no valid textures resolved; generator will not start.");
        return;
      }
      const lifetime = (this.lifetime.max !== null) // Normalize lifetime to {min, max} object range
        ? {min: this.lifetime.min, max: this.lifetime.max}
        : this.lifetime.min;
      const hasConfigLegacyPerFrame = !!(this.config && Object.hasOwn(this.config, "perFrame"));
      const hasConfigSpawnRate = !!(this.config && Object.hasOwn(this.config, "spawnRate")
        && (this.config.spawnRate != null));
      const config = {
        alpha: [this.alpha.min, this.alpha.max],
        blend: this.blend,
        count: this.count,
        elevation: this.elevation,
        initial: this.initial,
        lifetime,
        manual: false,
        mode: this.mode,
        spawnRate: this.spawnRate,
        rotation: this.rotation,
        scale: [this.scale.min, this.scale.max],
        sort: this.sort,
        textures,
        ...this.area !== null ? {area: this.area} : {},
        ...this.fade !== null ? {fade: this.fade} : {},
        ...this.velocity !== null ? {velocity: this.velocity} : {},
        ...this.config
      };
      if ( hasConfigLegacyPerFrame && !hasConfigSpawnRate ) delete config.spawnRate;
      // Apply a PointSourcePolygon mask if configured and successfully resolved
      if ( this.pointSourceMask instanceof foundry.canvas.geometry.PointSourcePolygon ) {
        config.mask = this.pointSourceMask;
      } else if ( this.pointSourceMask ) {
        console.warn("VFXParticleGeneratorComponent: pointSourceMask was configured but did not resolve to a"
          + " PointSourcePolygon. No mask applied.", this.pointSourceMask);
      }
      this.#generator = new foundry.canvas.animation.ParticleGenerator(config);
      this.#generator.start();
    }, 0);

    // Finite duration.
    if ( this.duration ) {
      const maxLifetime = this.lifetime.max ?? this.lifetime.min;
      // Soft stop at end of configured duration.
      this.timeline.call(() => this.#generator?.stop({hard: false}), this.duration);
      // Hard stop once all particles have faded out.
      this.timeline.call(() => this.#generator?.stop({hard: true}), this.duration + maxLifetime);
      this.timeline.label("end", this.duration + maxLifetime);
    }

    // Runs indefinitely until cancelled.
    else {
      this.timeline.call(() => this.#generator?.stop({hard: true}), Infinity);
      this.timeline.label("end", Infinity);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async stop() {
    // Finite generators stop as normal since their duration was already extended to cover the fade-out.
    if ( this.duration ) return super.stop();
    // Infinite generators intercept the stop call and perform a soft stop instead. The underlying ParticleGenerator
    // will automatically clean up after all particles have despawned.
    this.#generator?.stop({hard: false});
    this.timeline?.complete();
  }

  /* -------------------------------------------- */

  /** @override */
  async _stop() {
    this.#generator?.stop({hard: true});
  }
}

/**
 * @import {VFXPositionalSoundData} from "../_types.mjs";
 */

/**
 * @typedef _VFXPositionalSoundData
 * @property {number} x             The x coordinate of the sound origin
 * @property {number} y             The y coordinate of the sound origin
 * @property {number} elevation     The elevation of the sound origin
 * @property {number} [angle]       The angle of the sound cone in degrees
 * @property {number} [rotation]    The direction of sound emission in degrees
 * @property {boolean} gmAlways     Whether the GM always hears the sound regardless of position (default true)
 * @property {{type: string, intensity: number}|null} [baseEffect]    Audio effect applied when the sound is not muffled
 * @property {{type: string, intensity: number}|null} [muffledEffect] Audio effect applied when the sound is muffled
 * @property {number} fade          Fade-in duration in milliseconds (default 0)
 * @property {number} [duration]    Component duration in milliseconds; defaults to the sound's natural duration
 * @property {string} channel       Audio channel for playback (default "environment")
 */

/**
 * @typedef {VFXPositionalSoundData & _VFXPositionalSoundData} VFXPositionalSoundComponentData
 */

/**
 * A component for playing positional sound effects that are synchronized with the VFX timeline.
 * This component provides serializable and persisted data storage around the `Sound#playAtPosition` API.
 *
 * @example A thunderclap at a specific location
 * ```js
 * const vfxConfig = {
 *   name: "thunderclap",
 *   components: {
 *     thunder: {
 *       type: "positionalSound",
 *       src: "assets/sounds/thunder.ogg",
 *       x: 3200,
 *       y: 2400,
 *       radius: 120,
 *       volume: 0.8,
 *       channel: "environment"
 *     }
 *   },
 *   timeline: [{component: "thunder"}]
 * };
 * const effect = new foundry.canvas.vfx.VFXEffect(vfxConfig);
 * effect.play();
 * ```
 * @extends {VFXComponent<VFXPositionalSoundComponentData>}
 */
class VFXPositionalSoundComponent extends VFXComponent {

  /** @override */
  static TYPE = "positionalSound";

  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      channel: new StringField({required: true, blank: false, initial: "environment"}),
      duration: new NumberField({nullable: false}),
      easing: new BooleanField({initial: true}),
      elevation: new NumberField({required: true, nullable: false, initial: 0}),
      fade: new NumberField({required: true, nullable: false, initial: 0}),
      gmAlways: new BooleanField({required: false, initial: true}),
      angle: new AngleField({required: false}),
      baseEffect: new SchemaField({
        intensity: new NumberField({required: true, nullable: false, min: 1, max: 10}),
        type: new StringField({required: true, blank: false})
      }, {required: false, nullable: true, initial: null}),
      muffledEffect: new SchemaField({
        intensity: new NumberField({required: true, nullable: false, min: 1, max: 10}),
        type: new StringField({required: true, blank: false})
      }, {required: false, nullable: true, initial: null}),
      radius: new NumberField({required: true, nullable: false, initial: 60, positive: true}),
      rotation: new AngleField({required: false}),
      src: new StringField({required: true, blank: false}),
      volume: new AlphaField(),
      walls: new BooleanField({initial: true}),
      x: new NumberField({required: true, nullable: false}),
      y: new NumberField({required: true, nullable: false})
    };
  }

  /* -------------------------------------------- */

  /**
   * The Sound object used by this component.
   * @type {Sound|null}
   */
  #sound = null;

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _load() {
    const context = game.audio[this.channel];
    this.#sound = new Sound(this.src, {context});
    await this.#sound.load();
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw() {
    if ( this.#sound ) this.timeline.call(() => this.#playSound(), 0);
    const duration = this.duration ?? ((this.#sound?.duration ?? 0) * 1000);
    this.timeline.label("end", duration);
  }

  /* -------------------------------------------- */

  /** @override */
  async _stop() {
    await this.#sound?.stop();
  }

  /* -------------------------------------------- */

  /**
   * Play the positional sound for this component.
   * Failures to play are tolerated with a warning so as not to break the overall animation.
   * @returns {Promise<void>}
   */
  async #playSound() {
    const {elevation, easing, fade, gmAlways, radius, volume, walls, x, y} = this;
    const sourceData = {};
    if ( Number.isNumeric(this.angle) ) sourceData.angle = this.angle;
    if ( Number.isNumeric(this.rotation) ) sourceData.rotation = this.rotation;
    try {
      await this.#sound.playAtPosition({x, y, elevation}, radius, {
        baseEffect: this.baseEffect ?? undefined,
        easing,
        gmAlways,
        muffledEffect: this.muffledEffect ?? undefined,
        playbackOptions: fade ? {fade} : undefined,
        sourceData: Object.keys(sourceData).length ? sourceData : undefined,
        volume,
        walls
      });
    } catch(err) {
      console.warn(err);
    }
  }
}

/**
 * @typedef VFXScrollingTextData
 * @property {{x: number, y: number}} origin   The canvas point where the text originates
 * @property {string} content                  The text string to display
 * @property {number} duration                 The duration of the scrolling effect in milliseconds (default 2000)
 * @property {number} [distance]               The distance in pixels the text travels; defaults to twice the text size
 * @property {number} textAnchor               An anchor point in CONST.TEXT_ANCHOR_POINTS (default CENTER)
 * @property {number} scrollDirection          The direction the text scrolls in CONST.TEXT_ANCHOR_POINTS (default TOP)
 * @property {number} jitter                   Randomization between [0, 1] applied to the initial position (default 0)
 * @property {object} textStyle                Additional PIXI.TextStyle parameters applied to the text
 */

/**
 * A component for displaying scrolling text effects at a canvas location using the
 * `CanvasInterfaceGroup#createScrollingText` API.
 * This can be used for damage numbers, status effects, or other floating text animations.
 *
 * @example Damage number above a token
 * ```js
 * const vfxConfig = {
 *   name: "damageNumber",
 *   components: {
 *     text: {
 *       type: "scrollingText",
 *       origin: {reference: "target"},
 *       content: "-12",
 *       duration: 1500,
 *       scrollDirection: CONST.TEXT_ANCHOR_POINTS.TOP,
 *       textStyle: {fill: "#ff4444", fontSize: 28, fontWeight: "bold"}
 *     }
 *   },
 *   timeline: [{component: "text"}]
 * };
 * const effect = new foundry.canvas.vfx.VFXEffect(vfxConfig);
 * const target = game.user.targets.first();
 * effect.play({target: target.center});
 * ```
 * @extends {VFXComponent<VFXScrollingTextData>}
 */
class VFXScrollingTextComponent extends VFXComponent {

  /** @override */
  static TYPE = "scrollingText";

  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      content: new VFXReferenceField(new StringField({required: true, blank: false})),
      distance: new VFXReferenceField(new NumberField({nullable: false})),
      duration: new VFXReferenceField(new NumberField({required: true, nullable: false, initial: 2000})),
      jitter: new VFXReferenceField(new NumberField({required: true, nullable: false, initial: 0})),
      origin: new VFXReferencePointField({required: true}),
      scrollDirection: new VFXReferenceField(new NumberField({
        required: true,
        nullable: false,
        initial: CONST.TEXT_ANCHOR_POINTS.TOP,
        validate: v => Object.values(CONST.TEXT_ANCHOR_POINTS).includes(v),
        validationError: "must be one of CONST.TEXT_ANCHOR_POINTS values"
      })),
      textAnchor: new VFXReferenceField(new NumberField({
        required: true,
        nullable: false,
        initial: CONST.TEXT_ANCHOR_POINTS.CENTER,
        validate: v => Object.values(CONST.TEXT_ANCHOR_POINTS).includes(v),
        validationError: "must be one of CONST.TEXT_ANCHOR_POINTS values"
      })),
      textStyle: new VFXReferenceField(new ObjectField())
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw() {
    this.timeline.call(() => {
      canvas.interface.createScrollingText(this.origin, this.content, {
        anchor: this.textAnchor,
        direction: this.scrollDirection,
        distance: this.distance,
        duration: this.duration,
        jitter: this.jitter,
        ...this.textStyle
      });
    }, 0);
    this.timeline.label("end", this.duration);
  }
}

/**
 * @typedef VFXShakeData
 * @property {string} target           The canvas property key of the display object to shake (default "stage")
 * @property {number} duration         The active shake duration in milliseconds (default 5000)
 * @property {number} maxDisplacement  The maximum displacement in pixels at the start of the shake (default 35)
 * @property {number} returnSpeed      The return-to-origin interpolation factor per tick in [0, 1] (default 0.1)
 * @property {number|null} seed        An optional seed for a deterministic, reproducible shake pattern (default null)
 * @property {number} smoothness       Shake smoothness in [0, 1]; higher values produce lower-frequency motion (default 0.5)
 */

/**
 * A component that applies a canvas shake effect to a target PIXI display object using the
 * `CanvasShakeEffect` API. Primarily intended for camera shakes (`canvas.stage`) but can also
 * target individual canvas layers or groups.
 *
 * @example A screen shake on impact
 * ```js
 * const vfxConfig = {
 *   name: "impactShake",
 *   components: {
 *     screenShake: {
 *       type: "shake",
 *       target: "stage",
 *       duration: 800,
 *       maxDisplacement: 18,
 *       smoothness: 0.4
 *     }
 *   },
 *   timeline: [{component: "screenShake"}]
 * };
 * const effect = new foundry.canvas.vfx.VFXEffect(vfxConfig);
 * effect.play();
 * ```
 * @extends {VFXComponent<VFXShakeData>}
 */
class VFXShakeComponent extends VFXComponent {

  /** @override */
  static TYPE = "shake";

  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      duration: new NumberField({required: true, nullable: false, initial: 5000, positive: true}),
      maxDisplacement: new NumberField({required: true, nullable: false, initial: 35, positive: true}),
      returnSpeed: new NumberField({required: true, nullable: false, initial: 0.1, min: 0, max: 1}),
      seed: new NumberField({initial: null}),
      smoothness: new NumberField({required: true, nullable: false, initial: 0.5, min: 0, max: 1}),
      target: new VFXReferenceField(new StringField({required: true, blank: false, initial: "stage"}))
    };
  }

  /* -------------------------------------------- */

  /**
   * The active CanvasShakeEffect instance, populated when the shake is triggered.
   * @type {foundry.canvas.animation.CanvasShakeEffect|null}
   */
  #shake = null;

  /* -------------------------------------------- */

  /** @override */
  async _draw() {
    this.timeline.call(() => {
      const target = canvas[this.target];
      if ( !target ) {
        console.warn(`VFXShakeComponent: canvas["${this.target}"] is not a valid shake target.`);
        return;
      }
      this.#shake = new foundry.canvas.animation.CanvasShakeEffect({
        duration: this.duration,
        maxDisplacement: this.maxDisplacement,
        returnSpeed: this.returnSpeed,
        seed: this.seed,
        smoothness: this.smoothness,
        target
      });
      this.#shake.play();
    }, 0);
    this.timeline.label("end", this.duration);
  }

  /* -------------------------------------------- */

  /** @override */
  async _stop() {
    this.#shake?.stop();
  }
}

/**
 * A special subclass of PrimaryCanvasContainer used for the animation of related display objects in VFXEffects.
 * TODO we might not need this in the end and we can just use PrimaryCanvasContainer directly. TBD.
 */
class VFXCanvasContainer extends PrimaryCanvasContainer {
  constructor() {
    super();
    this.on("childAdded", this.#onChildAdded.bind(this));
    this.on("childRemoved", this.#onChildRemoved.bind(this));
  }

  /**
   * A registry of named display objects which belong to this container
   * @type {Record<string, PIXI.DisplayObject>}
   */
  sprites = {};

  /**
   * @param {PIXI.DisplayObject} child
   */
  #onChildAdded(child) {
    if ( child.name ) this.sprites[child.name] = child;
  }

  /**
   * @param {PIXI.Sprite} child
   */
  #onChildRemoved(child) {
    if ( child.name ) delete this.sprites[child.name];
  }
}

/**
 * The duration of time allocated for "pre-impact" lead in animations or sounds.
 * @type {number}
 */
const PRE_IMPACT_MS = 500;

/**
 * An enumeration of animation sound alignment positions.
 * @enum {number}
 */
const SOUND_ALIGNMENT = Object.freeze({
  END_START: 0, // Align the end of the sound to the start of the animation
  START: 1,
  MIDDLE: 2,
  END: 3,
  START_END: 4 // Align the start of the sound to the end of the animation
});

var vfxConstants = /*#__PURE__*/Object.freeze({
  __proto__: null,
  PRE_IMPACT_MS: PRE_IMPACT_MS,
  SOUND_ALIGNMENT: SOUND_ALIGNMENT
});

/**
 * @import {DataSchema} from "@common/abstract/_types.mjs";
 * @import {VFXBasePathPoint, VFXComponentAnimation, VFXComponentData, VFXPositionalSoundData} from "../_types.mjs";
 */

/** @typedef {"charge"|"projectile"|"impact"} VFXSingleAttackStepName */

/**
 * @typedef VFXSingleAttackStep
 * @property {string|null} texture
 * @property {VFXPositionalSoundData|null} sound
 * @property {number} [duration]
 * @property {number} [size]
 * @property {{x: number, y: number}} [scale]
 * @property {{function: string, params: object}[]} [animations]
 */

/**
 * @typedef VFXSingleAttackPathTypeData
 * @property {string} type      The path type registered in CONFIG.Canvas.vfx.paths.
 * @property {object} [params]  Additional parameters passed to the path generator.
 */

/**
 * @typedef _VFXSingleAttackData
 * @property {VFXBasePathPoint[]} path
 * @property {VFXSingleAttackPathTypeData} pathType
 * @property {VFXSingleAttackStep} charge
 * @property {VFXSingleAttackStep & {speed?: number}} projectile
 * @property {VFXSingleAttackStep} impact
 */

/**
 * @typedef {VFXComponentData & _VFXSingleAttackData} VFXSingleAttackData
 */

/**
 * A base class VFX component used for single actor -> target direct attacks.
 *
 * This provides a convenience layer that can include any or all of the following:
 * 1. Pre-attack charge-up sprite animation
 * 2. Pre-attack charge-up audio
 * 3. Attack projectile sprite
 * 4. Attack projectile audio
 * 5. Pre-impact audio
 * 6. Impact sprite animation
 * 7. Impact (or pre-impact) audio
 *
 * @example A projectile with an impact animation
 * ```js
 * const vfxConfig = {
 *   name: "arrowShot",
 *   components: {
 *     arrowShot: {
 *       type: "singleAttack",
 *       path: [{reference: "token", deltas: {sort: 1}}, {reference: "target", deltas: {sort: 1}}],
 *       charge: {
 *         duration: 1000,
 *         animations: [{function: "drawBack"}],
 *         sound: {
 *          src: "assets/sounds/BowAttack1.ogg",
 *          align: 2
 *         }
 *       },
 *       projectile: {
 *        texture: "assets/arrow/arrow-wood.png",
 *        animations: [{function: "followPath"}],
 *        size: 3, // feet
 *        speed: 150 // feet-per-second
 *       },
 *       impact: {
 *         texture: "assets/impact/BloodSplatter1.png",
 *         duration: 2000,
 *         sound: {
 *            src: "assets/sounds/ArrowHit1.wav",
 *            align: 1
 *         }
 *       }
 *     }
 *   },
 *   timeline: [{component: "arrowShot"}]
 * };
 * const effect = new foundry.canvas.vfx.VFXEffect(vfxConfig);
 * const target = game.user.targets.first();
 * effect.play({
 *   token: {..._token.center, elevation: _token.document.elevation, sort: _token.document.sort},
 *   target: {...target.center, elevation: target.document.elevation, sort: target.document.sort}
 * });
 * ```
 * @extends {VFXComponent<VFXSingleAttackData>}
 */
class VFXSingleAttackComponent extends VFXComponent {

  /** @override */
  static TYPE = "singleAttack";

  /**
   * The steps of the single attack sequence.
   * @type {VFXSingleAttackStepName[]}
   */
  static STEPS = ["charge", "projectile", "impact"];

  /**
   * The configured origin point of the path.
   * This is recorded at the beginning of the _draw workflow once references are resolved.
   * @type {VFXBasePathPoint}
   */
  origin;

  /**
   * The configured destination point of the path.
   * This is recorded at the beginning of the _draw workflow once references are resolved.
   * @type {VFXBasePathPoint}
   */
  destination;

  /**
   * The configured VFXPath that the flight of the projectile follows.
   * This is computed at the beginning of the _draw workflow once references are resolved.
   * @type {VFXPath}
   */
  #flightPath;

  /**
   * Animation state used throughout.
   * Populated at the beginning of playback.
   * @type {object}
   */
  #state;

  /**
   * Sound objects that are used as part of this component.
   * @type {Record<VFXSingleAttackStepName, Sound|null>}
   */
  #sounds = this.constructor.STEPS.reduce((obj, s) => {
    obj[s] = null;
    return obj;
  }, {});

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    const SL = foundry.canvas.groups.PrimaryCanvasGroup.SORT_LAYERS;
    return {
      ...super.defineSchema(),
      path: new ArrayField(new VFXReferenceObjectField(new SchemaField({
        x: new NumberField({required: true, nullable: false}),
        y: new NumberField({required: true, nullable: false}),
        elevation: new NumberField({required: true, nullable: false, initial: 0}),
        sort: new NumberField({nullable: false, initial: 0}),
        sortLayer: new NumberField({nullable: false, initial: SL.TOKENS})
      })), {required: true, min: 2}),
      pathType: new SchemaField({
        type: new StringField({required: true, initial: "linear"}),
        params: new ObjectField({required: false})
      }),
      charge: new SchemaField(VFXSingleAttackComponent.#attackStepSchema()),
      projectile: new SchemaField({
        ...VFXSingleAttackComponent.#attackStepSchema(),
        speed: new NumberField({required: false})
      }),
      impact: new SchemaField(VFXSingleAttackComponent.#attackStepSchema())
    };
  }

  /* -------------------------------------------- */

  /**
   * A reused sub-schema definition shared across all three of the charge, projectile, and impact steps.
   * @returns {DataSchema}
   */
  static #attackStepSchema() {
    return {
      texture: new StringField({required: true}),
      duration: new NumberField({required: true, nullable: false, initial: 1000}),
      scale: new VFXReferencePointField({required: false}),
      size: new VFXReferenceField(new NumberField({required: false})),
      animations: new ArrayField(new SchemaField({
        function: new StringField({required: true}),
        params: new VFXReferenceField(new ObjectField())
      })),
      sound: new SchemaField({
        src: new StringField({required: true, blank: false}),
        align: new NumberField({required: true, nullable: false, choices: Object.values(SOUND_ALIGNMENT),
          initial: SOUND_ALIGNMENT.END}),
        volume: new AlphaField(),
        radius: new NumberField({required: true, nullable: false, initial: 60, positive: true}),
        easing: new BooleanField({initial: true}),
        walls: new BooleanField({initial: true})
      }, {nullable: true, initial: null})
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _load() {
    if ( this.charge.texture ) this.assetPaths.add(this.charge.texture);
    if ( this.projectile.texture ) this.assetPaths.add(this.projectile.texture);
    if ( this.impact.texture ) this.assetPaths.add(this.impact.texture);
    const soundPromises = [];
    for ( const step of this.constructor.STEPS ) {
      const sound = this[step].sound;
      if ( sound?.src ) soundPromises.push(foundry.audio.AudioHelper.preloadSound(sound.src).then(s => {
        this.#sounds[step] = s;
      }));
    }
    await Promise.allSettled(soundPromises);
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw() {

    // Initialize positions and duration
    this.origin = this.path[0];
    this.destination = this.path.at(-1);

    // Generate flight path using configured path type
    const pathType = this.pathType.type ?? "linear";
    this.#flightPath = VFXPath.create(pathType, this.path, this.pathType.params);

    // Construct animation state
    this.#state = {
      charge: this.charge,
      projectile: this.projectile,
      impact: this.impact,
      origin: this.origin,
      destination: this.destination,
      flightPath: this.#flightPath
    };

    // Customize projectile speed according to distance
    if ( this.projectile.speed ) {
      const speed = this.projectile.speed * canvas.dimensions.distancePixels;
      this.projectile.duration = this.#flightPath.pathLength * 1000 / speed;
    }

    // Create VFXContainer instances
    if ( this.charge.texture ) {
      this.charge.container = this.addManagedDisplayObject(this.#createContainer(this.charge, this.origin));
    }
    if ( this.projectile.texture ) {
      this.projectile.container = this.addManagedDisplayObject(this.#createContainer(this.projectile, this.origin));
    }
    if ( this.impact.texture ) {
      this.impact.container = this.addManagedDisplayObject(this.#createContainer(this.impact, this.destination));
    }

    // Resolve custom animations that are required and call animation setup functions
    for (const stepName of this.constructor.STEPS) {
      const step = this[stepName];
      for ( const animation of step.animations ) {
        try {
          Object.assign(animation, resolveAnimation(animation.function));
        } catch(err) {
          animation.animate = () => {}; // No-op
          console.warn(`Failed to resolve animation function "${animation.function}" in step "${stepName}".`
            + " This animation component will be skipped.");
        }
        animation.params ||= {};
        animation.setup?.(this.#state, animation.params);
      }
    }

    // Define timeline and animations
    const timings = this._getTimings();
    this._animateCharge(timings);
    this._animateProjectile(timings);
    this._animateImpact(timings);
    this.timeline.label("end", timings.impactEnd);
  }

  /* -------------------------------------------- */

  /**
   * Basic charge animation. It is expected for subclasses to override this to refine the effect.
   * @param {Record<string, number>} timings
   * @protected
   */
  _animateCharge(timings) {
    const c = this.charge;
    const p = this.projectile;
    const fadeMS = Math.min(c.duration / 8, 100);
    this.timeline.label("chargeStart", timings.chargeStart).label("chargeEnd", timings.chargeEnd);

    // Animate charge container
    if ( c.container ) {
      this.timeline.add(c.container, {alpha: {from: 0, to: 1, duration: fadeMS}}, timings.chargeStart)
        .add(c.container, {alpha: {from: 1, to: 0, duration: fadeMS}}, timings.chargeEnd - fadeMS);
    }

    // Animate projectile during charge
    if ( p.container ) {
      this.timeline.set(p.container, {rotation: this.#flightPath.pathPoints[0].rotation}, timings.chargeStart);
    }

    // Add custom charge animations
    for (const {animate, params} of c.animations) {
      const progress = {t: 0};
      this.timeline.add(progress, {
        t: {from: 0, to: 1},
        duration: c.duration,
        ease: params.ease ?? "linear",
        onRender: () => animate(progress.t, this.#state, params)
      }, timings.chargeStart);
    }

    // Animate sound
    if ( this.#sounds.charge ) {
      this.timeline.label("chargeSound", timings.chargeSound)
        .call(() => this.#playSound(this.#sounds.charge, c, this.origin), timings.chargeSound);
    }
  }

  /* -------------------------------------------- */

  /**
   * Basic projectile animation. It is expected for subclasses to override this to refine the effect.
   * @param {Record<string, number>} timings
   * @protected
   */
  _animateProjectile(timings) {
    const p = this.projectile;
    this.timeline.label("projectileStart", timings.projectileStart).label("projectileEnd", timings.projectileEnd);

    // Animate sprite
    if ( p.container ) this.timeline.set(p.container, {alpha: 1, duration: 0}, timings.projectileStart);

    // Add custom projectile animations
    for (const {animate, params} of p.animations) {
      const progress = {t: 0};
      this.timeline.add(progress, {
        t: {from: 0, to: 1},
        duration: p.duration,
        ease: params.ease ?? "linear",
        onRender: () => animate(progress.t, this.#state, params)
      }, timings.projectileStart);
    }

    // Animate sound
    if ( this.#sounds.projectile ) {
      this.timeline.label("projectileSound", timings.projectileSound)
        .call(() => this.#playSound(this.#sounds.projectile, p, this.origin), timings.projectileSound);
    }
  }

  /* -------------------------------------------- */

  /**
   * Basic impact animation. It is expected for subclasses to override this to refine the effect.
   * @param {Record<string, number>} timings
   * @protected
   */
  _animateImpact(timings) {
    const i = this.impact;
    const p = this.projectile;
    const fadeMS = Math.min(i.duration / 8, 100);
    this.timeline.label("impactStart", timings.impactStart).label("impactEnd", timings.impactEnd);

    // Animate impact sprite
    if ( i.container ) {
      this.timeline.add(i.container, {alpha: {from: 0, to: 1, duration: fadeMS}}, timings.impactStart)
        .add(i.container, {alpha: {from: 1, to: 0, duration: fadeMS}}, timings.impactEnd - fadeMS);
    }

    // Animate projectile sprite
    if ( p.container ) {
      const fadeOutMS = Math.min(i.duration / 4, 100);
      this.timeline.add(p.container, {alpha: 0, duration: fadeOutMS}, timings.impactEnd - fadeOutMS);
    }

    // Add custom impact animations
    for (const {animate, params} of i.animations) {
      const progress = {t: 0};
      this.timeline.add(progress, {
        t: {from: 0, to: 1},
        duration: i.duration,
        ease: params.ease ?? "linear",
        onRender: () => animate(progress.t, this.#state, params)
      }, timings.impactStart);
    }

    // Animate sound
    if ( this.#sounds.impact ) {
      this.timeline.label("impactSound", timings.impactSound)
        .call(() => this.#playSound(this.#sounds.impact, i, this.destination), timings.impactSound);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _stop() {
    if ( !this.#state ) return; // We never started

    // Call animation tearDown functions
    for (const stepName of this.constructor.STEPS) {
      const step = this[stepName];
      for ( const {tearDown, params} of step.animations ) tearDown?.(this.#state, params);
    }

    // Stop sound playback
    const soundStops = [];
    for ( const sound of iterateValues(this.#sounds) ) {
      if ( sound?.playing ) soundStops.push(sound.stop());
    }
    await Promise.allSettled(soundStops);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _destroy() {
    super._destroy();
    delete this.charge.container;
    delete this.projectile.container;
    delete this.impact.container;
  }

  /* -------------------------------------------- */

  /**
   * Play a sound effect for the impact.
   * Failures to play a sound are tolerated with a warning such as not to break the overall animation.
   * @param {Sound|null} sound
   * @param {VFXSingleAttackStep} step
   * @param {VFXPathPoint} position
   * @returns {Promise<void>}
   */
  async #playSound(sound, step, position) {
    if ( !sound ) return;
    const {radius, volume, easing, walls} = step.sound;
    try {
      await sound.playAtPosition(position, radius, {volume, easing, walls, gmAlways: true});
    } catch(err) {
      console.warn(err);
    }
  }

  /* -------------------------------------------- */

  /**
   * Create a VFXContainer and included PrimaryCanvasSpriteMesh used for a step of the attack.
   * @param {VFXSingleAttackStep} step
   * @param {VFXPathPoint} point
   * @returns {VFXCanvasContainer}
   */
  #createContainer(step, point) {

    // Create container
    const container = new VFXCanvasContainer();
    container.position.set(point.x, point.y);
    container.elevation = point.elevation;
    container.sort = point.sort;
    container.sortLayer = point.sortLayer;
    container.alpha = 0; // Best practice

    // Create sprite mesh
    const texture = foundry.canvas.getTexture(step.texture);
    if ( !texture ) return container;
    const mesh = new foundry.canvas.primary.PrimarySpriteMesh(texture);
    mesh.name = "mesh";
    mesh.anchor.set(0.5, 0.5);

    // Assign size
    if ( Number.isNumeric(step.size) ) {
      if ( mesh.width >= mesh.height ) {
        mesh.width = step.size * canvas.dimensions.distancePixels;
        mesh.scale.y = mesh.scale.x;
      } else {
        mesh.height = step.size * canvas.dimensions.distancePixels;
        mesh.scale.x = mesh.scale.y;
      }
    }
    else if ( step.scale ) mesh.scale.copyFrom(step.scale);
    container.addChild(mesh);
    return container;
  }

  /* -------------------------------------------- */

  /**
   * Compute timings for each step start, end, and sound.
   * @returns {Record<string, number>}
   * @protected
   */
  _getTimings() {
    const timings = {};
    const sounds = this.#sounds;
    let end = 0;
    for ( const s of this.constructor.STEPS ) {
      const step = this[s];
      const start = timings[`${s}Start`] = end;
      end = timings[`${s}End`] = start + step.duration;
      if ( sounds[s] ) timings[`${s}Sound`] = start + this.#alignSound(sounds[s], step);
    }
    return timings;
  }

  /* -------------------------------------------- */

  /**
   * Identify when a Sound should start playing relative to an animation duration.
   * @param {Sound} sound               The Sound that should play
   * @param {VFXSingleAttackStep} step  The step the sound belongs to
   * @returns {number}                  Milliseconds relative to the start of duration when the sound should play
   */
  #alignSound(sound, step) {
    const soundMS = sound.duration * 1000;
    if ( soundMS >= step.duration ) return 0;
    switch ( step.sound.align ) {
      case SOUND_ALIGNMENT.END_START:
        return -soundMS;
      case SOUND_ALIGNMENT.START:
        return 0;
      case SOUND_ALIGNMENT.MIDDLE:
        return Math.max((step.duration - soundMS) / 2, 0);
      case SOUND_ALIGNMENT.END:
        return Math.max(step.duration - soundMS, 0);
      case SOUND_ALIGNMENT.START_END:
        return step.duration;
      default:
        return 0;
    }
  }
}

/**
 * @import {VFXBasePathPoint, VFXPositionalSoundData} from "../_types.mjs";
 */

/**
 * @typedef VFXSingleImpactData
 * @property {VFXBasePathPoint} position
 * @property {string} texture
 * @property {VFXPositionalSoundData|null} sound
 * @property {number} duration
 * @property {number} [size]
 * @property {{x: number, y: number}} [scale]
 * @property {{function: string, params: object}[]} [animations]
 */

/**
 * A VFX component used to add a single impact effect at a certain location.
 * This component handles basic impacts that can be represented as the combination of a sprite and a sound.
 *
 * @example A blood splatter impact
 * ```js
 * const vfxConfig = {
 *   name: "bloodSplatter",
 *   components: {
 *     splash: {
 *       type: "singleImpact",
 *       position: {reference: "target", deltas: {sort: 1}},
 *       texture: "assets/impact/BloodSplatter1.png",
 *       size: 2,
 *       duration: 2000,
 *       sound: {
 *         src: "assets/sounds/ArrowHit1.wav",
 *         align: 1
 *       }
 *     }
 *   },
 *   timeline: [{component: "splash"}]
 * };
 * const effect = new foundry.canvas.vfx.VFXEffect(vfxConfig);
 * const target = game.user.targets.first();
 * effect.play({
 *   target: {...target.center, elevation: target.document.elevation, sort: target.document.sort}
 * });
 * ```
 * @extends {VFXComponent<VFXSingleImpactData>}
 */
class VFXSingleImpactComponent extends VFXComponent {

  /** @override */
  static TYPE = "singleImpact";

  /** @inheritDoc */
  static defineSchema() {
    const SL = foundry.canvas.groups.PrimaryCanvasGroup.SORT_LAYERS;
    return {
      ...super.defineSchema(),
      position: new VFXReferenceObjectField(new SchemaField({
        x: new NumberField({required: true, nullable: false}),
        y: new NumberField({required: true, nullable: false}),
        elevation: new NumberField({required: true, nullable: false, initial: 0}),
        sort: new NumberField({nullable: false, initial: 0}),
        sortLayer: new NumberField({nullable: false, initial: SL.TOKENS})
      })),
      texture: new StringField({required: true}),
      duration: new NumberField({required: true, nullable: false, initial: 1000}),
      scale: new VFXReferencePointField({required: false}),
      size: new VFXReferenceField(new NumberField({required: false})),
      sound: new SchemaField({
        src: new StringField({required: true, blank: false}),
        align: new NumberField({required: true, nullable: false, choices: Object.values(SOUND_ALIGNMENT),
          initial: SOUND_ALIGNMENT.END}),
        volume: new AlphaField(),
        radius: new NumberField({required: true, nullable: false, initial: 60, positive: true}),
        easing: new BooleanField({initial: true}),
        walls: new BooleanField({initial: true})
      }, {nullable: true, initial: null}),
      animations: new ArrayField(new SchemaField({
        function: new StringField({required: true}),
        params: new VFXReferenceField(new ObjectField({required: false}))
      }))
    };
  }

  /* -------------------------------------------- */

  /**
   * Animation state used throughout.
   * Populated at the beginning of playback.
   * @type {object}
   */
  #state;

  /**
   * The Sound object used by this component.
   * @type {Sound|null}
   */
  #sound = null;

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _load() {
    if ( this.texture ) this.assetPaths.add(this.texture);
    const soundPromises = [];
    if ( this.sound?.src ) soundPromises.push(
      foundry.audio.AudioHelper.preloadSound(this.sound.src).then(s => { this.#sound = s; })
    );
    await Promise.allSettled(soundPromises);
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw() {

    // Create container if a texture is configured
    let container;
    if ( this.texture ) {
      container = this.#createContainer(this.position);
      this.addManagedDisplayObject(container);
    }

    // Construct animation state
    this.#state = {position: this.position, container};

    // Resolve custom animations and call animation setup functions
    for ( const animation of this.animations ) {
      try {
        Object.assign(animation, resolveAnimation(animation.function));
      } catch(err) {
        animation.animate = () => {}; // No-op
        console.warn(`Failed to resolve animation function "${animation.function}" in VFXSingleImpactComponent.`
          + " This animation will be skipped.");
      }
      animation.setup?.(this.#state, animation.params);
    }

    // Animate impact sprite
    const fadeMS = Math.min(this.duration / 8, 100);
    if ( container ) {
      this.timeline
        .add(container, {alpha: {from: 0, to: 1, duration: fadeMS}}, 0)
        .add(container, {alpha: {from: 1, to: 0, duration: fadeMS}}, this.duration - fadeMS);
    }

    // Add custom animations
    for ( const {animate, params} of this.animations ) {
      const progress = {t: 0};
      this.timeline.add(progress, {
        t: {from: 0, to: 1},
        duration: this.duration,
        ease: params.ease ?? "linear",
        onRender: () => animate(progress.t, this.#state, params)
      }, 0);
    }

    // Animate sound
    if ( this.#sound ) {
      const soundOffset = this.#alignSound(this.#sound);
      this.timeline.label("sound", soundOffset)
        .call(() => this.#playSound(this.position), soundOffset);
    }

    this.timeline.label("end", this.duration);
  }

  /* -------------------------------------------- */

  /** @override */
  async _stop() {
    if ( !this.#state ) return; // We never started

    // Call animation tearDown functions
    for ( const {tearDown, params} of this.animations ) tearDown?.(this.#state, params);

    // Stop sound playback
    const soundStops = [];
    if ( this.#sound?.playing ) soundStops.push(this.#sound.stop());
    await Promise.allSettled(soundStops);
  }

  /* -------------------------------------------- */

  /**
   * Play the positional sound for this impact.
   * Failures to play are tolerated with a warning so as not to break the overall animation.
   * @param {VFXBasePathPoint} position
   * @returns {Promise<void>}
   */
  async #playSound(position) {
    const {radius, volume, easing, walls} = this.sound;
    try {
      await this.#sound.playAtPosition(position, radius, {volume, easing, walls, gmAlways: true});
    } catch(err) {
      console.warn(err);
    }
  }

  /* -------------------------------------------- */

  /**
   * Create a VFXCanvasContainer and PrimarySpriteMesh for this impact.
   * @param {VFXBasePathPoint} point
   * @returns {VFXCanvasContainer}
   */
  #createContainer(point) {

    // Create container
    const container = new VFXCanvasContainer();
    container.position.set(point.x, point.y);
    container.elevation = point.elevation;
    container.sort = point.sort;
    container.sortLayer = point.sortLayer;
    container.alpha = 0; // Best practice

    // Create sprite mesh
    const texture = foundry.canvas.getTexture(this.texture);
    if ( !texture ) return container;
    const mesh = new foundry.canvas.primary.PrimarySpriteMesh(texture);
    mesh.name = "mesh";
    mesh.anchor.set(0.5, 0.5);

    // Assign size
    if ( Number.isNumeric(this.size) ) {
      if ( mesh.width >= mesh.height ) {
        mesh.width = this.size * canvas.dimensions.distancePixels;
        mesh.scale.y = mesh.scale.x;
      } else {
        mesh.height = this.size * canvas.dimensions.distancePixels;
        mesh.scale.x = mesh.scale.y;
      }
    }
    else if ( this.scale ) mesh.scale.copyFrom(this.scale);
    container.addChild(mesh);
    return container;
  }

  /* -------------------------------------------- */

  /**
   * Identify when a Sound should start playing relative to the component duration.
   * @param {Sound} sound     The Sound that should play
   * @returns {number}        Milliseconds relative to the component start when the sound should play
   */
  #alignSound(sound) {
    const soundMS = sound.duration * 1000;
    if ( soundMS >= this.duration ) return 0;
    switch ( this.sound.align ) {
      case SOUND_ALIGNMENT.END_START:
        return -soundMS;
      case SOUND_ALIGNMENT.START:
        return 0;
      case SOUND_ALIGNMENT.MIDDLE:
        return Math.max((this.duration - soundMS) / 2, 0);
      case SOUND_ALIGNMENT.END:
        return Math.max(this.duration - soundMS, 0);
      case SOUND_ALIGNMENT.START_END:
        return this.duration;
      default:
        return 0;
    }
  }
}

var components = /*#__PURE__*/Object.freeze({
  __proto__: null,
  VFXParticleGeneratorComponent: VFXParticleGeneratorComponent,
  VFXPositionalSoundComponent: VFXPositionalSoundComponent,
  VFXScrollingTextComponent: VFXScrollingTextComponent,
  VFXShakeComponent: VFXShakeComponent,
  VFXSingleAttackComponent: VFXSingleAttackComponent,
  VFXSingleImpactComponent: VFXSingleImpactComponent
});

const vfxDrawBackAnimation = {
  setup: (state, params) => {
    params.ease = resolveEasing(params.easing ?? "outBack", params.easingParams ?? 0.8);
    if ( !params.object ) {
      const container = state.projectile?.container;
      if ( container ) {
        container.alpha = 1.0;
        params.object = container.sprites.mesh;
      }
    }
  },
  animate: (t, state, params) => {
    const mesh = params.object;
    if (!mesh) return;
    mesh.anchor.x = Math.mix(0.5, 1.25, params.ease(t));
    mesh.alpha = Math.min(t * 4, 1);
  }
};

const vfxFollowPathAnimation = {
  setup: (state, params) => {
    state.lastPathIndex = 0;
    params.ease = resolveEasing(params.easing ?? "linear", params.easingParams);
  },
  animate: (t, state, params) => {
    const target = params.target || state.projectile?.container;
    if (!target) return;
    const w = params.ease(t);
    const point = state.flightPath.interpolatedPoint(w, state.lastPathIndex);
    state.lastPathIndex = point.index;
    target.x = point.x;
    target.y = point.y;
    target.rotation = point.rotation;
    target.elevation = point.elevation;
    target.sort = point.sort;
    target.sprites.mesh.anchor.x = Math.mix(1.25, 0.5, w);
  }
};

const vfxScaleAnimation = {
  setup: (state, params) => {
    state.lastPathIndex = 0;
    params.ease = resolveEasing(params.easing ?? "linear", params.easingParams);
  },
  animate: (t, state, params) => {
    const target = params.target || state.projectile?.container;
    if (!target) return;
    const uniformStart = params.startScale ?? 1;
    const uniformEnd = params.endScale ?? 1;
    const startScaleX = params.startScaleX ?? 1;
    const endScaleX = params.endScaleX ?? startScaleX;
    const startScaleY = params.startScaleY ?? 1;
    const endScaleY = params.endScaleY ?? startScaleY;
    const w = params.ease(t);
    const scale = Math.mix(uniformStart, uniformEnd, w);
    const scaleX = Math.mix(startScaleX, endScaleX, w) * scale;
    const scaleY = Math.mix(startScaleY, endScaleY, w) * scale;
    target.scale.set(scaleX, scaleY);
  }
};

var animations = /*#__PURE__*/Object.freeze({
  __proto__: null,
  drawBack: vfxDrawBackAnimation,
  followPath: vfxFollowPathAnimation,
  scale: vfxScaleAnimation
});

/**
 * @import {VFXBasePathPoint} from "../_types.mjs"
 */

/**
 * Generate an arcing path between provided control points using cubic hermite splines.
 * @param {VFXBasePathPoint[]} waypoints    Explicit waypoints to interpolate
 * @param {object} params                   Spline interpolation parameters
 * @returns {VFXPath}                       A generated arc path
 */
function arcPath(waypoints, params={}) {
  const arcPoints = [];
  for (let i=0; i<waypoints.length-1; i++) {
    const p0 = waypoints[i];
    const p1 = waypoints[i+1];
    const segment = generateArcPoints(p0, p1, params);
    arcPoints.push(...segment.slice(Math.min(i, 1)));
  }
  return new VFXPath(arcPoints);
}

/* -------------------------------------------- */

/**
 * Generate cubic hermite spline points for a pair of control points.
 * @param {VFXBasePathPoint} origin           Starting point of the arc
 * @param {VFXBasePathPoint} destination      Ending point of the arc
 * @param {object} options                    Configuration options
 * @param {number} [options.peakRatio=0.5]      Position along path where arc peaks (0-1)
 * @param {number} [options.peakHeight=0.3]     Height of arc as ratio of path length
 * @param {number} [options.direction=1]        Direction of arc perpendicular to path (1 for "up", -1 for "down")
 * @param {number} [options.numPoints]          Number of points to generate (default: half of distance in pixels)
 * @param {number} [options.tangentScale=1]     Scaling factor for tangent vectors. Determines how curvy the arc is
 * @param {string[]} [options.auxiliary]        An array of auxiliary parameter names
 * @returns {VFXBasePathPoint[]}              Array of path points
 */
function generateArcPoints(origin, destination, {numPoints, peakRatio=0.5, peakHeight=0.3, direction=1,
  tangentScale=1, auxiliary}={}) {

  // Verify straight-line distance
  const dx = destination.x - origin.x;
  const dy = destination.y - origin.y;
  const distance = Math.hypot(dx, dy);
  if (distance === 0) return [origin, destination];

  // Prepare parameters
  auxiliary ||= ["elevation", "sort"];
  numPoints ||= Math.max(10, Math.floor(distance / 2));
  direction = (direction >= 0 ? 1 : -1);
  const perpX = -dy / distance;
  const perpY = dx / distance;

  // Calculate peak point position
  const peakBaseX = origin.x + (dx * peakRatio);
  const peakBaseY = origin.y + (dy * peakRatio);
  const peakOffsetX = perpX * peakHeight * distance * direction * -1;
  const peakOffsetY = perpY * peakHeight * distance * direction * -1;
  const peakX = peakBaseX + peakOffsetX;
  const peakY = peakBaseY + peakOffsetY;

  // Vector at peak is just the scaled source to destination vector
  const tangentPeak = {x: (destination.x - origin.x) * tangentScale, y: (destination.y - origin.y) * tangentScale};
  const peak = {
    x: peakX,
    y: peakY,
    elevation: Math.mix(origin.elevation, destination.elevation, peakRatio),
    sort: Math.mix(origin.sort || 0, destination.sort || 0, peakRatio)
  };

  // Generate the two Hermite segments
  const nRising = Math.floor(numPoints * peakRatio);
  const nFalling = numPoints - nRising;
  const tangentOrigin = {x: 0, y: 0};
  const segment1 = generateHermiteSegment(origin, tangentOrigin, peak, tangentPeak, nRising, auxiliary);
  const segment2 = generateHermiteSegment(peak, tangentPeak, destination, tangentOrigin, nFalling, auxiliary);
  return [...segment1, ...segment2.slice(1)];
}

/**
 * @import {VFXBasePathPoint} from "../_types.mjs"
 */

/**
 * Generate an oscillating weave path between provided control points using cubic hermite splines.
 * @param {VFXBasePathPoint[]} waypoints    Explicit waypoints to interpolate
 * @param {object} params                   Spline interpolation parameters
 * @returns {VFXPath}                       A generated weave path
 */
function weavePath(waypoints, params = {}) {
  const weavePoints = [];
  for (let i=0; i<waypoints.length-1; i++) {
    const p0 = waypoints[i];
    const p1 = waypoints[i + 1];
    const segment = generateWeavePoints(p0, p1, params);
    weavePoints.push(...segment.slice(Math.min(i, 1)));
  }
  return new VFXPath(weavePoints);
}

/* -------------------------------------------- */

/**
 * Generate cubic hermite spline points for a pair of control points.
 * @param {VFXBasePathPoint} origin           Starting point of the arc
 * @param {VFXBasePathPoint} destination      Ending point of the arc
 * @param {object} options                    Configuration options
 * @param {number} [options.arcCount=1]         Number of Hermite arcs (1 = single arc, 2 = up/down pair, etc.)
 * @param {number} [options.amplitude=0.15]     Ratio of the path length that determines the peak displacement
 *                                              of the arcs (1 = full path length)
 * @param {number} [options.tangentScale=1]     Multiplier applied to the Hermite tangents
 * @param {number} [options.direction=1]        Starting direction of the weave (1 = "up", -1 = "down")
 * @param {number} [options.segmentPoints=8]    Points generated per Hermite segment
 * @param {string[]} [options.auxiliary]        An array of auxiliary parameter names
 * @returns {VFXBasePathPoint[]}              Array of path points
 */
function generateWeavePoints(origin, destination, {arcCount = 1, amplitude=0.15, tangentScale=1, direction=1,
  segmentPoints=8, auxiliary}={}) {

  // Verify straight-line distance
  const dx = destination.x - origin.x;
  const dy = destination.y - origin.y;
  const distance = Math.hypot(dx, dy);
  if (distance === 0) return [origin, destination];

  // Prepare parameters
  auxiliary ||= ["elevation", "sort"];
  direction = (direction >= 0 ? 1 : -1);
  const segmentCount = Math.max(1, Math.round(arcCount));
  const resolution = Math.max(3, Math.floor(segmentPoints));
  const perpX = -dy / distance;
  const perpY = dx / distance;
  const baseAmplitude = Math.abs(amplitude) * distance;

  // Create weave points
  const points = [];
  for ( let segmentIndex=0; segmentIndex<segmentCount; segmentIndex++ ) {
    const startT = segmentIndex / segmentCount;
    const endT = (segmentIndex + 1) / segmentCount;
    const p0 = {
      x: origin.x + (dx * startT),
      y: origin.y + (dy * startT),
      ...interpolateProperties(origin, destination, startT, auxiliary)
    };
    const p1 = {
      x: origin.x + (dx * endT),
      y: origin.y + (dy * endT),
      ...interpolateProperties(origin, destination, endT, auxiliary)
    };

    const segmentDx = p1.x - p0.x;
    const segmentDy = p1.y - p0.y;
    const sign = direction * ((segmentIndex % 2 === 0) ? 1 : -1);
    const baseTangent = {x: segmentDx * tangentScale, y: segmentDy * tangentScale};
    const offsetVector = {x: perpX * baseAmplitude * sign * -1, y: perpY * baseAmplitude * sign * -1};
    const startTangent = {x: baseTangent.x + offsetVector.x, y: baseTangent.y + offsetVector.y};
    const endTangent = {x: baseTangent.x - offsetVector.x, y: baseTangent.y - offsetVector.y};

    // Generate weave segment
    const segment = generateHermiteSegment(p0, startTangent, p1, endTangent, resolution, auxiliary);
    points.push(...segment.slice(Math.min(segmentIndex, 1)));
  }
  return points;
}

/**
 * @import {TimerOptions, TimelineOptions, DefaultsParams, Timeline} from "animejs"
 * @import {DataModelConstructionContext} from "@common/abstract/_types.mjs";
 * @import VFXComponent from "./vfx-component.mjs";
 */

/**
 * @typedef VFXEffectData
 * @property {string} name
 * @property {Record<string, object>} components
 * @property {VFXTimelineSequenceEntry[]} timeline
 */

/**
 * @typedef VFXTimelineSequenceEntry
 * @property {string} component             The labeled component in sequence.
 * @property {number|string} [position]     The animejs offset position. Can be a number for absolute timeline values,
 *                                          a label (including optional prefixing to reference other effects),
 *                                          or a relative value (e.g. "+1000" for 1s after the previous effect).
 *                                          It can also be a combination of label and offset (e.g.
 *                                          "effect.impact-=1000" for 1s before the `effect.impact` label)
 */

/**
 * A specialized DataModel subclass used to configure VFXEffects in a way that can be serialized for storage in
 * database or transmission over socket. VFXEffect instances involve animation components which are animated over a
 * configured timeline.
 * Playback for a VFXEffect can only happen once. The intended paradigm to repeat an effect multiple times requires
 * cloning it before each successive playback.
 *
 * @extends {DataModel<VFXEffectData, DataModelConstructionContext>}
 * @mixes VFXEffectData
 */
class VFXEffect extends DataModel {

  /** @override */
  static defineSchema() {
    const components = Object.values(CONFIG.Canvas.vfx.components).reduce((obj, cls) => {
      obj[cls.TYPE] = cls;
      return obj;
    }, {});
    return {
      name: new StringField({required: true, nullable: false, blank: false}),
      components: new TypedObjectField(new TypedSchemaField(components)),
      timeline: new ArrayField(new SchemaField({
        component: new StringField({required: true, nullable: false, blank: false}),
        position: new AnyField({
          required: false,
          nullable: false,
          validate: data => {
            if (data == null || (typeof data === "string" && data !== "") || typeof data === "number") return true;
            throw new Error("Timeline position must be a non-empty string or a number");
          }
        })
      }))
    };
  }

  /* -------------------------------------------- */

  /**
   * Is this VFXEffect currently playing?
   * @type {boolean}
   */
  get playing() {
    return !!this.#animationPromise;
  }

  /**
   * An animejs Timeline instance used to orchestrate playback.
   * @type {animejs.Timeline}
   */
  #animejsTimeline;

  /**
   * An active Promise that resolves once animation of this effect is complete.
   * @type {PromiseWithResolvers<boolean>}
   */
  #animationPromise;

  /**
   * VFXComponents that are included in this animation
   * @type {Record<string, VFXComponent>}
   */
  components = this.components;

  /**
   * Has playback of this effect started?
   * @type {boolean}
   */
  get started() {
    return !!this.#started;
  }

  #started = false;

  /**
   * A VFXEffect may contain references that are resolved at runtime. Such references may only be resolved once per
   * playback lifecycle. Keep track of the unresolved source data for this effect so that the effect can be cloned
   * for reuse with different references.
   * @type {VFXEffectData}
   */
  #unresolvedSource;

  /* -------------------------------------------- */
  /*  Animation Lifecycle                         */
  /* -------------------------------------------- */

  /**
   * Load necessary materials for all animation components.
   * @returns {Promise<void>}
   */
  async load() {
    const promises = [];
    for ( const component of iterateValues(this.components) ) promises.push(component.load());
    await Promise.all(promises);
  }

  /* -------------------------------------------- */

  /**
   * Perform initial drawing of every animation component.
   * @returns {Promise<void>}
   */
  async draw() {
    const promises = [];
    for ( const component of iterateValues(this.components) ) promises.push(component.draw());
    await Promise.all(promises);
    this.#buildTimeline();
  }

  /* -------------------------------------------- */

  /**
   * Add managed display objects for each component to the primary canvas container.
   */
  attach() {
    for ( const component of iterateValues(this.components) ) component.attach();
  }

  /* -------------------------------------------- */

  /** @override */
  clone(data={}, context={}) {
    const source = deepClone(this.#unresolvedSource) ?? this.toObject();
    data = mergeObject(source, data, {insertKeys: false, applyOperators: true, inplace: true});
    return new this.constructor(data, {...context, strict: false});
  }

  /* -------------------------------------------- */

  /**
   * Begin playback of a VFXEffect using provided reference data.
   * @param {Record<string, any>} [references]    A record of references used to resolve model data
   * @returns {Promise<boolean>}                  A Promise which resolves to signal whether playback fully completed
   * @throws {Error}                              An error if playback from this VFXEffect has already started or if
   *                                              playback failed for some reason.
   */
  async play(references={}) {

    // Temporarily gate the VFX framework behind an opt-in flag.
    if ( !CONFIG.Canvas.vfx.enabled ) {
      console.error("The Foundry VTT version 14 VFX framework is not active. You may use this experimental framework"
        + " by enabling it via `CONFIG.Canvas.vfx.enabled`.");
      return;
    }

    // VFXEffect instances can only be played once
    if ( this.#started ) {
      throw new Error("VFXEffect instances may only be played once and may not be reused. To reuse an effect which"
       + " has already been played, first call VFXEffect#clone which resets it to a playable state");
    }
    this.#started = true;
    this.#animationPromise = Promise.withResolvers();

    // Catch errors during playback for safe teardown
    try {
      this.#animejsTimeline = animejs.createTimeline({autoplay: false});

      // Resolve remaining references using provided data
      this.resolveReferences(references);

      // Load component materials, draw display objects, and attach them to the primary canvas group
      await this.load();
      await this.draw();
      this.attach();

      // Schedule cleanup on timeline completion
      this.#animejsTimeline.then(() => this.#finalize(true, {terminateTimeline: false}));

      // Begin playback
      this.#animejsTimeline.play();
    } catch(err) {
      await this.#finalize(false, {terminateTimeline: true});
      throw err;
    }

    // Return the animation promise
    return this.#animationPromise.promise;
  }

  /* -------------------------------------------- */

  /**
   * Stop animation, but treat the animation as successfully completed.
   * @returns {Promise<void>}
   */
  async stop() {
    await this.#finalize(true, {terminateTimeline: true});
  }

  /* -------------------------------------------- */

  /**
   * Cancel animation and treat the animation as unsuccessful.
   * @returns {Promise<void>}
   */
  async cancel() {
    await this.#finalize(false, {terminateTimeline: true});
  }

  /* -------------------------------------------- */

  /**
   * Finalize the animation, either as success or as failure/cancellation.
   * @param {boolean} success
   * @param {object} [options]
   * @param {boolean} [options.terminateTimeline]
   * @returns {Promise<void>}
   */
  async #finalize(success, {terminateTimeline=false}={}) {
    if ( terminateTimeline ) this.#animejsTimeline?.cancel();
    const method = success ? "stop" : "cancel";
    await Promise.allSettled(Object.values(this.components).map(c => c[method]()));
    this.#animationPromise?.resolve(success);
    this.#animationPromise = undefined;
  }

  /* -------------------------------------------- */

  /**
   * Construct and populate the animejs timeline with labels required by each animation component.
   */
  #buildTimeline() {
    const timeline = this.#animejsTimeline;
    timeline.label("start");

    // Set of added component timelines to warn about missing dependencies
    const addedTimelines = new Set();
    let endMS = 0;
    for (const entry of this.timeline) {
      const component = this.components[entry.component];
      if ( !component ) {
        console.error(`VFXEffect: Component ${entry.component} not found`);
        continue;
      }

      // Add labels from the component
      const label = entry.component;
      if (addedTimelines.has(label)) {
        console.warn(`VFXEffect: Timeline entry for ${entry.component} already exists. Each component can only
        have one timeline entry.`);
      }
      addedTimelines.add(label);
      timeline.label(label, entry.position);
      timeline.sync(component.timeline, entry.position);

      // Add scoped component timeline labels to the effect timeline
      const offset = timeline.labels[label];
      const timelineSubLabels = component.timeline.labels;
      for (const [subLabel, subOffset] of Object.entries(timelineSubLabels)) {
        timeline.label(`${label}.${subLabel}`, offset + subOffset);
        endMS = Math.max(endMS, offset + subOffset);
      }
    }
    timeline.label("end", endMS);
  }

  /* -------------------------------------------- */

  /**
   * Resolve all reference fields within this model against provided reference data.
   * This is idempotent: fields that have already been resolved to concrete values are skipped on subsequent calls.
   * This is invoked automatically as part of {@link VFXEffect#play}, after which point all references must be final.
   * It can be called manually before {@link VFXEffect#play} to assume direct control over reference resolution before
   * effect playback begins.
   * @param {Record<string, any>} [references]  A record of references used to resolve model data
   * @throws {DataModelValidationError|Error}   An error if references were unable to resolve
   */
  resolveReferences(references={}) {
    this.#unresolvedSource ??= deepClone(this._source);

    // Resolve into a COMPLETE data tree: references are replaced and every other present value is passed
    // through unchanged. A complete tree is required because updateSource replaces arrays atomically - a
    // partial update carrying only the resolved reference field of an array-of-object element would drop
    // that element's sibling data when the array is replaced. The `resolved` flag preserves the no-op fast
    // path so a tree with no references to resolve does not trigger updateSource (which would re-initialize).
    let resolved = false;
    const resolvedUpdates = this.schema.apply(function(source, _options) {
      if ( !(this instanceof VFXReferenceField) ) return source;
      if ( !VFXReferenceField.isReference(source) ) return source;
      resolved = true;
      return this.resolve(source, references);
    }, this._source, {partial: true});
    if ( !resolved ) return;
    try {
      this.updateSource(resolvedUpdates);
    }
    catch(error) {
      if (error instanceof DataModelValidationError) {
        /** @type {Set<string>} */
        const failedReferenceKeys = new Set();
        const referenceFailures = Object.keys(error.getAllFailures()).flatMap(key => {
          const referenceData = foundry.utils.getProperty(this._source, key);
          const referenceKey = `${referenceData?.reference}|${referenceData?.property}`;
          if (failedReferenceKeys.has(referenceKey)) return [];
          failedReferenceKeys.add(referenceKey);
          return referenceData;
        });
        const failed = referenceFailures.map(ref => JSON.stringify(ref, null, 2)).join("\n");
        let details = "Error playing VFXEffect. Validation failed after resolving references.";
        details += `\nProvided references: ${JSON.stringify(references, null, 2)}`;
        details += `\nThe following references failed to resolve: ${failed}`;
        throw new Error(details, {cause: error});
      }
      throw error;
    }
  }
}

/**
 * @import {VFXComponentAnimation, VFXPathGenerator} from "./canvas/vfx/_types.mjs";
 */

let _vfxActive = false;

/**
 * Activate the animejs for the Canvas ticker.
 * This is called during `Canvas#draw` before the `canvasReady` hook.
 */
function activate() {
  if ( !CONFIG.Canvas.vfx.enabled ) return;
  console.log("%cThe Foundry VTT version 14 EXPERIMENTAL VFX framework is enabled."
    + " Its classes, functions, and configuration are likely to change over coming releases."
    + " The entire VFX module should be treated as non-stable regardless of the stability designation of the release"
    + " build that contains it.", "color: #cc6600");
  _vfxActive = true;
  animejs.engine.useDefaultMainLoop = false;
  const ticker = canvas.app.ticker;
  ticker.add(_onTick, undefined, PIXI.UPDATE_PRIORITY.HIGH);
}

/* -------------------------------------------- */

/**
 * Deactivate the animejs engine for the Canvas ticker.
 * This is called during `Canvas#teardown` before the `canvasTearDown` hook.
 */
function deactivate() {
  if ( !_vfxActive ) return;
  const ticker = canvas.app.ticker;
  ticker.remove(_onTick);
  animejs.engine.useDefaultMainLoop = true;
}

/* -------------------------------------------- */

/**
 * Advance the animejs engine every tick.
 * @param {number} _dt
 */
function _onTick(_dt) {
  animejs.engine.update();
}

/* -------------------------------------------- */
/*  One-Time Configuration                      */
/* -------------------------------------------- */

/**
 * One-time configuration to register components, animations, and paths to CONFIG.Canvas.vfx.
 */
function configure() {
  const cfg = CONFIG.Canvas.vfx;

  // Register components
  for ( const [componentName, component] of Object.entries(components) ) cfg.components[componentName] = component;

  // Register paths
  cfg.paths.linear = (points, _parameters) => new VFXPath(points);
  cfg.paths.arc = arcPath;
  cfg.paths.weave = weavePath;

  // Register animations
  for ( const [animationName, animation] of Object.entries(animations) ) cfg.animations[animationName] = animation;
}

var _module$a = /*#__PURE__*/Object.freeze({
  __proto__: null,
  VFXCanvasContainer: VFXCanvasContainer,
  VFXComponent: VFXComponent,
  VFXEffect: VFXEffect,
  VFXPath: VFXPath,
  activate: activate,
  animations: animations,
  components: components,
  configure: configure,
  constants: vfxConstants,
  deactivate: deactivate,
  fields: _module$b,
  utils: vfxUtils
});

var _module$9 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  TextureCompressor: TextureCompressor
});

/** @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,
  KTX2Parser: KTX2Parser,
  SceneManager: SceneManager,
  TextureExtractor: TextureExtractor,
  TextureLoader: TextureLoader,
  TransitionContainer: TransitionContainer,
  animation: _module$t,
  borders: borders,
  containers: _module$K,
  extensions: _module$s,
  geometry: _module$$,
  getTexture: getTexture,
  groups: _module$r,
  interaction: _module$q,
  layers: _module$p,
  loadTexture: loadTexture,
  perception: _module$o,
  placeables: _module$i,
  primary: _module$h,
  rendering: _module$d,
  sources: _module$c,
  srcExists: srcExists,
  tokens: tokens,
  types: _types$a,
  vfx: _module$a,
  workers: _module$9
});

/**
 * @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 = "([^ (){}[\\]+\\-*/]+)";

  /**
   * The argument portion of a modifier string (e.g., "3" of "min3")
   * @type {string}
   */
  static #MODIFIER_ARG_REGEXP_STRING = String.raw`[^A-z\s()+\-*/]*`;

  /**
   * A regular expression used to separate individual modifiers
   * @type {RegExp}
   */
  static MODIFIER_REGEXP = new RegExp(String.raw`([A-z]+)(${DiceTerm.#MODIFIER_ARG_REGEXP_STRING})`, "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.default || 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 = this.modifiers.map(m => m.toLowerCase());
    this.modifiers = [];

    // Sort modifiers from longest to shortest to ensure that the matching algorithm greedily matches the longest
    // prefixes first.
    const modifierUnion = Object.keys(cls.MODIFIERS).sort((a, b) => b.length - a.length).join("|");
    const pattern = new RegExp(String.raw`(${modifierUnion})${DiceTerm.#MODIFIER_ARG_REGEXP_STRING}`, "gi");

    // Iterate over requested modifiers
    for ( const sequence of requested ) {
      for ( const [modifier, command] of sequence.matchAll(pattern) ) {
        await this._evaluateModifier(command, modifier);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * 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 ( typeof fn === "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];
    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");
    }
    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 5.1.0.
//
// https://peggyjs.org/
(function() {
class peg$SyntaxError extends SyntaxError {
  constructor(message, expected, found, location) {
    super(message);
    this.expected = expected;
    this.found = found;
    this.location = location;
    this.name = "SyntaxError";
  }

  format(sources) {
    let str = "Error: " + this.message;
    if (this.location) {
      let src = null;
      const st = sources.find(s => s.source === this.location.source);
      if (st) {
        src = st.text.split(/\r\n|\n|\r/g);
      }
      const s = this.location.start;
      const offset_s = (this.location.source && (typeof this.location.source.offset === "function"))
        ? this.location.source.offset(s)
        : s;
      const loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column;
      if (src) {
        const e = this.location.end;
        const filler = "".padEnd(offset_s.line.toString().length, " ");
        const line = src[s.line - 1];
        const last = s.line === e.line ? e.column : line.length + 1;
        const hatLen = (last - s.column) || 1;
        str += "\n --> " + loc + "\n"
            + filler + " |\n"
            + offset_s.line + " | " + line + "\n"
            + filler + " | " + "".padEnd(s.column - 1, " ")
            + "".padEnd(hatLen, "^");
      } else {
        str += "\n at " + loc;
      }
    }
    return str;
  }

  static buildMessage(expected, found) {
    function hex(ch) {
      return ch.codePointAt(0).toString(16).toUpperCase();
    }

    const nonPrintable = Object.prototype.hasOwnProperty.call(RegExp.prototype, "unicode")
      ? new RegExp("[\\p{C}\\p{Mn}\\p{Mc}]", "gu")
      : null;
    function unicodeEscape(s) {
      if (nonPrintable) {
        return s.replace(nonPrintable,  ch => "\\u{" + hex(ch) + "}");
      }
      return s;
    }

    function literalEscape(s) {
      return unicodeEscape(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,          ch => "\\x0" + hex(ch))
        .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x"  + hex(ch)));
    }

    function classEscape(s) {
      return unicodeEscape(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,          ch => "\\x0" + hex(ch))
        .replace(/[\x10-\x1F\x7F-\x9F]/g, ch => "\\x"  + hex(ch)));
    }

    const DESCRIBE_EXPECTATION_FNS = {
      literal(expectation) {
        return "\"" + literalEscape(expectation.text) + "\"";
      },

      class(expectation) {
        const escapedParts = expectation.parts.map(
          part => (Array.isArray(part)
            ? classEscape(part[0]) + "-" + classEscape(part[1])
            : classEscape(part))
        );

        return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]" + (expectation.unicode ? "u" : "");
      },

      any() {
        return "any character";
      },

      end() {
        return "end of input";
      },

      other(expectation) {
        return expectation.description;
      },
    };

    function describeExpectation(expectation) {
      return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation);
    }

    function describeExpected(expected) {
      const descriptions = expected.map(describeExpectation);
      descriptions.sort();

      if (descriptions.length > 0) {
        let j = 1;
        for (let i = 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 : {};

  const peg$FAILED = {};
  const peg$source = options.grammarSource;

  const peg$startRuleFunctions = {
    Expression: peg$parseExpression,
  };
  let peg$startRuleFunction = peg$parseExpression;

  const peg$c0 = "(";
  const peg$c1 = ",";
  const peg$c2 = ")";
  const peg$c3 = "{";
  const peg$c4 = "}";
  const peg$c5 = "\u1696";
  const peg$c6 = ".";
  const peg$c7 = "[";
  const peg$c8 = "]";

  const peg$r0 = /^[dD]/;
  const peg$r1 = /^[a-z]/i;
  const peg$r2 = /^[^\u1696]/;
  const peg$r3 = /^[^ (){}[\]$,+\-*%\/]/;
  const peg$r4 = /^[a-z$_]/i;
  const peg$r5 = /^[a-z$_0-9]/i;
  const peg$r6 = /^[^ (){}[\]$+\-*\/,]/;
  const peg$r7 = /^[0-9]/;
  const peg$r8 = /^[%*\/]/;
  const peg$r9 = /^[+\-]/;
  const peg$r10 = /^[^[\]]/;
  const peg$r11 = /^[ ]/;

  const peg$e0 = peg$literalExpectation("(", false);
  const peg$e1 = peg$literalExpectation(",", false);
  const peg$e2 = peg$literalExpectation(")", false);
  const peg$e3 = peg$classExpectation(["d", "D"], false, false, false);
  const peg$e4 = peg$classExpectation([["a", "z"]], false, true, false);
  const peg$e5 = peg$literalExpectation("{", false);
  const peg$e6 = peg$literalExpectation("}", false);
  const peg$e7 = peg$literalExpectation("\u1696", false);
  const peg$e8 = peg$classExpectation(["\u1696"], true, false, false);
  const peg$e9 = peg$classExpectation([" ", "(", ")", "{", "}", "[", "]", "$", ",", "+", "-", "*", "%", "/"], true, false, false);
  const peg$e10 = peg$classExpectation([["a", "z"], "$", "_"], false, true, false);
  const peg$e11 = peg$classExpectation([["a", "z"], "$", "_", ["0", "9"]], false, true, false);
  const peg$e12 = peg$classExpectation([" ", "(", ")", "{", "}", "[", "]", "$", "+", "-", "*", "/", ","], true, false, false);
  const peg$e13 = peg$classExpectation([["0", "9"]], false, false, false);
  const peg$e14 = peg$literalExpectation(".", false);
  const peg$e15 = peg$classExpectation(["%", "*", "/"], false, false, false);
  const peg$e16 = peg$classExpectation(["+", "-"], false, false, false);
  const peg$e17 = peg$literalExpectation("[", false);
  const peg$e18 = peg$classExpectation(["[", "]"], true, false, false);
  const peg$e19 = peg$literalExpectation("]", false);
  const peg$e20 = peg$classExpectation([" "], false, false, false);

  function peg$f0(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);
  }
  function peg$f1(fn, head, tail, flavor) {
    return parser._onFunctionTerm(fn, head, tail, flavor, text(), range());
  }
  function peg$f2(number, faces, modifiers, flavor) {
    return parser._onDiceTerm(number, faces, modifiers, flavor, text(), range());
  }
  function peg$f3(number, flavor) {    return parser._onNumericTerm(number, flavor, range());  }
  function peg$f4(head, tail, modifiers, flavor) {
    return parser._onPoolTerm(head, tail, modifiers, flavor, text(), range());
  }
  function peg$f5(term, flavor) {
    return parser._onParenthetical(term, flavor, text(), range());
  }
  function peg$f6(term, flavor) {    return parser._onStringTerm(term, flavor, range());  }
  function peg$f7() {    return Number(text(), range());  }
  function peg$f8(head, tail) {    return [head, ...tail];  }
  function peg$f9(head, tail) {    return [null, head, ...tail];  }
  let peg$currPos = options.peg$currPos | 0;
  let peg$savedPos = peg$currPos;
  const peg$posDetailsCache = [{ line: 1, column: 1 }];
  let peg$maxFailPos = peg$currPos;
  let peg$maxFailExpected = options.peg$maxFailExpected || [];
  let peg$silentFails = options.peg$silentFails | 0;

  let 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 range() {
    return {
      source: peg$source,
      start: peg$savedPos,
      end: peg$currPos,
    };
  }

  function error(message, location) {
    location = location !== undefined
      ? location
      : peg$computeLocation(peg$savedPos, peg$currPos);

    throw peg$buildSimpleError(message, location);
  }

  function peg$getUnicode(pos = peg$currPos) {
    const cp = input.codePointAt(pos);
    if (cp === undefined) {
      return "";
    }
    return String.fromCodePoint(cp);
  }

  function peg$literalExpectation(text, ignoreCase) {
    return { type: "literal", text, ignoreCase };
  }

  function peg$classExpectation(parts, inverted, ignoreCase, unicode) {
    return { type: "class", parts, inverted, ignoreCase, unicode };
  }

  function peg$endExpectation() {
    return { type: "end" };
  }

  function peg$computePosDetails(pos) {
    let details = peg$posDetailsCache[pos];
    let 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) {
    const startPosDetails = peg$computePosDetails(startPos);
    const endPosDetails = peg$computePosDetails(endPos);

    const 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() {
    let 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() {
    let 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() {
    let 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() {
    let 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() {
    let 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() {
    let 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() {
    let 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() {
    let 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() {
    let 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() {
    let 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() {
    let 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() {
    let 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() {
    let 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() {
    let s0;

    s0 = peg$parseMultiplicativeFirst();
    if (s0 === peg$FAILED) {
      s0 = peg$parseAdditiveOnly();
    }

    return s0;
  }

  function peg$parseMultiplicativeFirst() {
    let 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() {
    let 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() {
    let 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() {
    let 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() {
    let 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_() {
    let 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$e20); }
    }
    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$e20); }
      }
    }
    peg$silentFails--;

    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();

  const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length);
  function peg$throw() {
    if (peg$result !== peg$FAILED && peg$currPos < input.length) {
      peg$fail(peg$endExpectation());
    }

    throw peg$buildStructuredError(
      peg$maxFailExpected,
      peg$maxFailPos < input.length ? peg$getUnicode(peg$maxFailPos) : null,
      peg$maxFailPos < input.length
        ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1)
        : peg$computeLocation(peg$maxFailPos, peg$maxFailPos)
    );
  }
  if (options.peg$library) {
    return /** @type {any} */ ({
      peg$result,
      peg$currPos,
      peg$FAILED,
      peg$maxFailExpected,
      peg$maxFailPos,
      peg$success,
      peg$throw: peg$success ? undefined : peg$throw,
    });
  }
  if (peg$success) {
    return peg$result;
  } else {
    peg$throw();
  }
}
  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.
   * @param {RollParseOffset} offset                                       The position of the matched term in the
   *                                                                       formula string.
   * @returns {DiceRollParseNode}
   * @protected
   */
  _onDiceTerm(number, faces, modifiers, flavor, formula, offset) {
    if ( CONFIG.debug.rollParsing ) {
      console.debug(this.constructor.formatDebug("onDiceTerm", number, faces, modifiers, flavor, formula));
    }
    return { class: "DiceTerm", formula, modifiers, number, faces, offset, evaluated: false, options: { flavor } };
  }

  /* -------------------------------------------- */

  /**
   * Handle a numeric term.
   * @param {number} number           The number.
   * @param {string} flavor           Associated flavor text.
   * @param {RollParseOffset} offset  The position of the matched term in the formula string.
   * @returns {NumericRollParseNode}
   * @protected
   */
  _onNumericTerm(number, flavor, offset) {
    if ( CONFIG.debug.rollParsing ) console.debug(this.constructor.formatDebug("onNumericTerm", number, flavor));
    return {
      class: "NumericTerm", number, offset,
      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.
   * @param {RollParseOffset} offset  The position of the matched term in the formula string.
   * @returns {FunctionRollParseNode}
   * @protected
   */
  _onFunctionTerm(fn, head, tail, flavor, formula, offset) {
    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, offset, 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.
   * @param {RollParseOffset} offset  The position of the matched term in the formula string.
   * @returns {PoolRollParseNode}
   * @protected
   */
  _onPoolTerm(head, tail, modifiers, flavor, formula, offset) {
    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, offset, 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.
   * @param {RollParseOffset} offset  The position of the matched term in the formula string.
   * @returns {ParentheticalRollParseNode}
   * @protected
   */
  _onParenthetical(term, flavor, formula, offset) {
    if ( CONFIG.debug.rollParsing ) {
      console.debug(this.constructor.formatDebug("onParenthetical", term, flavor, formula));
    }
    return { class: "ParentheticalTerm", term, formula, offset, evaluated: false, options: { flavor } };
  }

  /* -------------------------------------------- */

  /**
   * Handle some string that failed to be classified.
   * @param {string} term             The term.
   * @param {string|null} [flavor]    Associated flavor text.
   * @param {RollParseOffset} offset  The position of the matched term in the formula string.
   * @returns {StringParseNode}
   * @protected
   */
  _onStringTerm(term, flavor, offset) {
    return { class: "StringTerm", term, offset, 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(", ")})`;
  }
}

/** @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 Document from "@common/abstract/document.mjs";
 * @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;

  /* -------------------------------------------- */

  /**
   * Apply any art configured for a Document to its source data as it is initialized from a compendium pack.
   * @param {typeof Document} documentClass  The class of the Document being initialized.
   * @param {object} source                  The Document's source data.
   * @param {string} [packId]                The ID of the compendium pack the Document is initialized from.
   * @returns {object}                       The Document's source data.
   */
  applyArt(documentClass, source, packId) {
    const pack = game.packs.get(packId);
    if ( !this.enabled || !source._id || (pack?.documentName !== documentClass.documentName) ) return source;
    const art = this.get(pack.getUuid(source._id));
    if ( !art ) return source;
    const img = art.img ?? art.actor;
    let applied = !!img;
    if ( img ) source.img = img;
    if ( documentClass.documentName === "Actor" ) {
      if ( typeof art.token === "string" ) source.prototypeToken.texture.src = art.token;
      else if ( art.token ) foundry.utils.mergeObject(source.prototypeToken, art.token);
      applied ||= !!art.token;
    }
    if ( applied ) Hooks$1.callAll("applyCompendiumArt", documentClass, source, pack, art);
    return source;
  }

  /* -------------------------------------------- */

  /**
   * 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 = { portraits: true, tokens: true, items: true, ...settings[packageId] };
      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 stored = game.settings.get("core", this.SETTING)?.[packageId];
    const settings = { portraits: true, tokens: true, items: true, ...stored };
    for ( const [packName, documents] of Object.entries(mapping) ) {
      const pack = game.packs.get(packName);
      if ( !pack ) continue;
      for ( const [documentId, info] of Object.entries(documents) ) {
        const entry = fromUuidSync(pack.getUuid(documentId));
        if ( !entry ) continue;
        switch ( pack.documentName ) {
          case "Actor":
            // The actor key is an alias of img, retained for backwards compatibility, and kept populated for consumers
            // which still rely on it.
            info.img ??= info.actor;
            if ( !(settings.portraits || settings.tokens) ) continue;
            if ( settings.portraits ) info.actor = entry.img = info.img;
            else {
              delete info.img;
              delete info.actor;
            }
            if ( !settings.tokens ) delete info.token;
            break;
          case "Item":
            if ( !settings.items || !info.img ) continue;
            entry.img = info.img;
            break;
          default:
            continue;
        }
        if ( credit ) info.credit = credit;
        this.set(entry.uuid, Object.assign(this.get(entry.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. Default: `"image/webp"`.
   * @param {number} [options.quality]      The desired output image quality. Default: `0.8`.
   * @returns {Promise<{thumb: string; width: number; height: number; format: string; quality: number}|null>}
   *   The parsed and converted thumbnail data.
   */
  static async createThumbnail(src, {width, height, tx, ty, center, format="image/webp", quality=0.8}) {
    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});

    // Return the image data
    const result = {thumb, width: reduced.width, height: reduced.height, format, quality};
    /** @deprecated since v14 */
    Object.defineProperties(result, {
      src: {
        get() {
          foundry.utils.logCompatibilityWarning(
            "The src property of the return value of ImageHelper#createThumbnail is deprecated. It is equal to the src argument.",
            {since: 14, until: 16, once: true});
          return src;
        }
      },
      texture: {
        get() {
          foundry.utils.logCompatibilityWarning(
            "The texture property of the return value of ImageHelper#createThumbnail is deprecated without replacement.",
            {since: 14, until: 16, once: true});
          return reduced;
        }
      }
    });
    reduced.destroy(true);
    return result;
  }

  /* -------------------------------------------- */

  /**
   * 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");
    const bounds = object.getBounds();
    width ??= bounds.width;
    height ??= bounds.height;

    // Downscale the object to the desired thumbnail size
    const currentRatio = bounds.width / bounds.height;
    const targetRatio = width / height;
    const s = currentRatio > targetRatio ? (height / bounds.height) : (width / bounds.width);

    // Define a transform matrix
    const transform = PIXI.Matrix.IDENTITY.clone();
    transform.translate(-bounds.x, -bounds.y);
    if ( center ) transform.translate(-bounds.width / 2, -bounds.height / 2);
    else transform.translate(tx, ty);
    transform.scale(s, s);
    if ( center ) transform.translate(width / 2, height / 2);

    // Create and render a texture with the desired dimensions
    const renderTexture = PIXI.RenderTexture.create({
      width,
      height,
      scaleMode: PIXI.SCALE_MODES.LINEAR,
      resolution: 1
    });
    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} [img]           The path to the Document's 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.
 */

/**
 * @import {Document} from "../../common/abstract/_module.mjs";
 */

/**
 * 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}
   */
  #validationFailures = {};

  /**
   * @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: 41,
      match: /Electron\/(\d+)\./,
      message: "ERROR.ElectronVersion"
    },
    Chromium: {
      minimum: 146, // Electron 41
      match: /Chrom(?:e|ium)\/(\d+)\./,
      message: "ERROR.BrowserVersion"
    },
    Firefox: {
      minimum: 138, // Requires Error.isError
      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 = _loc(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);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * 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.
   * @type {{[documentName: string]: {[uuid: string]: {name?: string; error: Error}}}}
   */
  get validationFailures() {
    return this.#validationFailures;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve the tracked usability issues.
   * @type {Record<string, UsabilityIssue>}
   */
  get usabilityIssues() {
    return this.#usabilityIssues;
  }

  /* -------------------------------------------- */

  /**
   * @typedef PackageCompatibilityIssue
   * @property {string[]} error    Error messages.
   * @property {string[]} warning  Warning messages.
   */

  /**
   * Retrieve package compatibility issues.
   * @type {Record<string, PackageCompatibilityIssue>}
   */
  get packageCompatibilityIssues() {
    return game.data.packageWarnings;
  }

  /* -------------------------------------------- */

  /**
   * Track all world document validation failures and failures of their embedded documents.
   * @internal
   */
  _detectDocumentIssues() {
    const trackEmbeddedFailures = document => {
      for ( const [name, field] of Object.entries(document.constructor.hierarchy) ) {
        if ( field instanceof foundry.data.fields.EmbeddedDocumentField ) {
          // Cautiously avoid lazily created documents, currently only ActorDelta
          if ( Object.getOwnPropertyDescriptor(document, name)?.get ) continue;
          const embedded = document[name];
          if ( embedded ) trackEmbeddedFailures(embedded);
        }
        else trackCollectionFailures(document[name]);
      }
    };
    const trackCollectionFailures = collection => {
      const documentName = collection.documentName;
      for ( const id of collection.invalidDocumentIds ) {
        const document = collection.getInvalid(id);
        const failure = document.validationFailures.fields ?? document.validationFailures.joint;
        if ( !failure ) continue;
        const uuid = document.uuid;
        this.#validationFailures[documentName] ??= {};
        this.#validationFailures[documentName][uuid] = {name: document.name, error: failure.asError()};
      }
      for ( const document of collection ) trackEmbeddedFailures(document);
    };
    for ( const documentName of CONST.WORLD_DOCUMENT_TYPES ) {
      const collection = CONFIG[documentName].collection.instance;
      trackCollectionFailures(collection);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle deletion of invalid documents.
   * @param {string} documentName            The Document name
   * @param {string[]} invalidIds            The array of invalid IDs
   * @param {object} context                 The context
   * @param {Document|null} [context.parent] The Documents' parent, if any
   * @param {string|null} [context.pack]     The Documents' compendium pack, if applicable
   * @internal
   */
  _onDeleteInvalid(documentName, invalidIds, {parent, pack}={}) {
    const validationFailures = this.#validationFailures[documentName];
    if ( !validationFailures ) return;
    for ( const id of invalidIds ) {
      const uuid = foundry.utils.buildUuid({id, documentName, parent, pack});
      delete validationFailures[uuid];
    }
    foundry.applications.instances.get("support-details")?.render();
    ui.sidebar?.render();
  }
}

/**
 * @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,   // An ApplicationV2 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`);

    /** @deprecated since v14 */
    if ( ["core.gridTemplates", "core.coneTemplateType"].includes(id) ) {
      const msg = `The setting "${id}" is deprecated without replacement.`;
      foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
    }

    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 ( typeof setting.onChange === "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);
      setting.type.validate(value, {fallback: false, strict: true});
    }

    // 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 WordTree from "@common/utils/string-tree.mjs";
 * @import {StringTreeEntryFilter, StringTreeNode, WordTreeEntry} from "@common/utils/_types.mjs";
 * @import Document from "@common/abstract/document.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>}
     */
    this.trees = {};
    Object.defineProperty(this, "trees", {writable: false, configurable: false});

    /**
     * A reverse-lookup of a document's UUID to its parent nodes in the word tree.
     * @type {Record<string, StringTreeNode[]>}
     */
    this.uuids = {};
    Object.defineProperty(this, "uuids", {writable: false, configurable: false});
  }

  /* -------------------------------------------- */

  /**
   * A unicode-friendly regular-expression pattern matching all non-word characters in a string
   * @type {RegExp}
   */
  static #NON_WORD_CHARACTERS_PATTERN = /[^\p{Alphabetic}\p{Mark}\p{Decimal_Number}\p{Join_Control}]/gu;

  /* -------------------------------------------- */

  /**
   * 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 ( pack.documentClass.metadata.indexed ) this._indexCompendium(pack);
      }
      resolve();
      console.debug(`${CONST.vtt} | Document indexing complete in ${performance.now() - start}ms.`);
    });
  }

  /**
   * Clean a string such as a Document name or lookup query;
   * @param {string} str The string to clean
   * @returns {string} The cleaned string
   */
  #cleanString(str) {
    if ( !str ) return str;
    return foundry.applications.ux.SearchFilter.cleanQuery(str)
      .toLocaleLowerCase(game.i18n.lang)
      .replace(/['`’]/g, "")
      .replaceAll("-", " ")
      .replace(DocumentIndex.#NON_WORD_CHARACTERS_PATTERN, " ");
  }

  /* -------------------------------------------- */

  /**
   * Return entries that match the given string prefix.
   * @param {string} query                      The search prefix or phrase.
   * @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(query, {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 = {};
    const cleanedQuery = this.#cleanString(query);
    const terms = cleanedQuery
      .split(" ")
      .filter(t => (t.length > 1) && !CONFIG.i18n.searchStopWords.has(t.toLocaleLowerCase(game.i18n.lang)));
    for ( const type of types ) {
      const tree = this.trees[type];
      if ( !tree ) {
        results[type] = [];
        continue;
      }
      /** @type {WordTreeEntry[]} */
      const typeResults = [];

      // Track entry matches for each term
      /** @type {Record<string, Set<Document>} */
      const entriesByTerm = {};
      for ( const term of terms ) {
        entriesByTerm[term] = new Set();
        const termResults = tree.lookup(term, {filterEntries, limit: Infinity});
        typeResults.push(...termResults);
        for ( const termResult of termResults ) entriesByTerm[term].add(termResult.entry);
      }

      // Result must prefix-match all terms
      const allTermMatches = typeResults.filter(r => terms.every(t => entriesByTerm[t].has(r.entry)));

      // Dedupe
      const distinctEntries = new Set();
      results[type] = allTermMatches.reduce((finalResults, match) => {
        if ( !distinctEntries.has(match.entry) ) {
          finalResults.push(match);
          distinctEntries.add(match.entry);
        }
        return finalResults;
      }, []);

      // TODO: Add real result scoring before truncating to limit
      results[type].sort((a, b) => {
        const cleanA = this.#cleanString(a.entry.name ?? "");
        const cleanB = this.#cleanString(b.entry.name ?? "");
        return ((cleanB === cleanedQuery) - (cleanA === cleanedQuery))
          || (cleanB.startsWith(cleanedQuery) - cleanA.startsWith(cleanedQuery))
          || (cleanA.length < cleanB.length ? -1 : cleanA.length > cleanB.length ? 1 : 0);
      });
      results[type].length = Math.min(limit, results[type].length);
    }
    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._addLeaves(index, {pack});
    }
    else this._addLeaves(doc);
  }

  /* -------------------------------------------- */

  /**
   * Remove an entry from the index.
   * @param {Document} doc  The document entry.
   */
  removeDocument(doc) {
    const nodes = this.uuids[doc.uuid];
    if ( !nodes ) return;
    for ( const node of nodes ) {
      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 one or more leaf nodes to the word-tree index.
   * @param {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
   */
  _addLeaves(doc, {pack}={}) {
    const entry = {entry: doc, documentName: doc.documentName, uuid: doc.uuid};
    if ( pack ) Object.assign(entry, {documentName: pack.documentName, pack: pack.collection});
    const tree = this.trees[entry.documentName] ??= new foundry.utils.WordTree();
    this.uuids[entry.uuid] = this.#cleanString(doc.name)
      .split(" ")
      .filter(w => (w.length > 1) && !CONFIG.i18n.searchStopWords.has(w))
      .map(w => tree.addLeaf(w, entry));
  }


  /* -------------------------------------------- */

  /**
   * Add a leaf node to the word-tree index.
   * @protected
   * @deprecated
   * @ignore
   */
  _addLeaf(doc, options) {
    const message = "You are using DocumentIndex#_addLeaf: use DocumentIndex#_addLeaves instead.";
    foundry.utils.logCompatibilityWarning(message, {since: 14, until: 16, once: true});
    this._addLeaves(doc, options);
  }

  /* -------------------------------------------- */

  /**
   * 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._addLeaves(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._addLeaves(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._addLeaves(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();
    setInterval(() => this.sync(), GameTime.SYNC_INTERVAL_MS);
  }

  /**
   * The amount of time to delay before re-syncing the official server time.
   * @type {number}
   */
  static SYNC_INTERVAL_MS = 1000 * 30;

  /**
   * How many samples of latency history to retain?
   * @type {number}
   */
  static #PING_HISTORY_LENGTH = 4;

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * 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 = [];

  /**
   * 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;
    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();
    if ( CONFIG.debug.time ) console.debug(`${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
   * @returns {Promise<void>}
   */
  async onUpdateWorldTime(worldTime, options, userId) {
    const dt = worldTime - this.#time.worldTime;
    this.#time.worldTime = worldTime;
    this.#time.components = this.#calendar.timeToComponents(worldTime);
    ActiveEffect$1.registry.refresh("updateWorldTime", {worldTime, dt, options, userId});
    await this.#calendar.onUpdateWorldTime(worldTime, options, userId);
    Hooks$1.callAll("updateWorldTime", worldTime, dt, options, userId);
    if ( CONFIG.debug.time ) console.debug(`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 = {};
  }

  /* -------------------------------------------- */

  /**
   * Valid localization string IDs must match this pattern.
   * @type {RegExp}
   */
  static #STRING_ID_PATTERN = /^[\x21-\x2D\x2F-\x7E]+(?:\.[\x21-\x2D\x2F-\x7E]+)+$/;

  /* -------------------------------------------- */

  /**
   * Cached store of Intl.ListFormat instances.
   * @type {Record<string, Intl.ListFormat>}
   */
  #formatters = {};


  /**
   * A reusable PluralRules instance
   * @type {Intl.PluralRules}
   */
  get pluralRules() {
    return this.#pluralRules ??= new Intl.PluralRules(this.lang);
  }

  /**
   * Cached store of a Intl.PluralRules instance
   * @type {Intl.PluralRules}
   */
  #pluralRules;

  /* -------------------------------------------- */

  /**
   * 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 || {};
        config.typeHints = config.typeHints || {};
        for ( const t of types ) {
          config.typeLabels[t] ??= (t === CONST.BASE_DOCUMENT_TYPE ? "TYPES.Base" : `TYPES.${documentName}.${t}`);
          config.typeHints[t] ??= (game.i18n.has(`TYPES.HINTS.${documentName}.${t}`) ? `TYPES.HINTS.${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});
    Localization.localizeDataModel(foundry.data.BaseShapeData);
    Localization.localizeDataModel(foundry.data.RectangleShapeData);
    Localization.localizeDataModel(foundry.data.CircleShapeData);
    Localization.localizeDataModel(foundry.data.EllipseShapeData);
    Localization.localizeDataModel(foundry.data.ConeShapeData);
    Localization.localizeDataModel(foundry.data.RingShapeData);
    Localization.localizeDataModel(foundry.data.LineShapeData);
    Localization.localizeDataModel(foundry.data.EmanationShapeData);
    Localization.localizeDataModel(foundry.data.PolygonShapeData);
    Localization.localizeDataModel(foundry.data.TokenShapeData);
    Localization.localizeDataModel(foundry.data.GridShapeData);
  }

  /* -------------------------------------------- */

  /**
   * 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 ||= _loc(field.label);
      if ( typeof field?.hint === "string" ) this.hint ||= _loc(field.hint);
      if ( typeof field?.placeholder === "string" ) this.placeholder ||= _loc(field.placeholder);

      // 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";
  }

  /* -------------------------------------------- */

  #formatTemplatePattern = /{[^}]+}/g;

  /**
   * Localize a string by drawing a translation from the available translations dictionary, if available. Variables can
   * be included in the template enclosed in curly braces and will be substituted using those named keys.
   * @param {string} stringId                The string ID to translate
   * @param {Record<string, unknown>} [data] Data for variable formating: values can be anything meaningfully
   *                                         stringifiable.
   * @returns {string}                       The translated string, if a translation was found, or else the inputted
   *                                         stringId
   *
   * @example Localizing a simple string in JavaScript
   * ```js
   * {
   *   "MYMODULE.MYSTRING": "Hello, this is my module!"
   * }
   * _loc("MYMODULE.MYSTRING"); // Hello, this is my module!
   * ```
   * @example Localizing a formatted string in JavaScript
   * ```js
   * {
   *   "MYMODULE.GREETING": "Hello {name}, this is my module!"
   * }
   * _loc("MYMODULE.GREETING" {name: "Andrew"}); // Hello Andrew, this is my module!
   * ```
   *
   * @example Localizing a simple string in Handlebars
   * ```hbs
   * {{localize "MYMODULE.MYSTRING"}} <!-- Hello, this is my module! -->
   *
   * ```
   * @example Localizing a formatted string in Handlebars
   * ```hbs
   * {{localize "MYMODULE.GREETING" name="Andrew"}} <!-- Hello, this is my module! -->
   */
  localize(stringId, data) {
    let translation = foundry.utils.getProperty(this.translations, stringId);
    if ( typeof translation !== "string" ) {
      if ( CONFIG.debug.i18n && Localization.#STRING_ID_PATTERN.test(stringId) ) {
        console.error(`Localization | Key "${stringId}" is not defined.`);
      }
      translation = foundry.utils.getProperty(this._fallback, stringId);
      if ( typeof translation !== "string" ) translation = stringId;
    }
    return data ? translation.replace(this.#formatTemplatePattern, k => data[k.slice(1, -1)]) : translation;
  }

  /* -------------------------------------------- */

  /**
   * 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;
  }
}

Object.defineProperties(Localization.prototype, {format: {value: Localization.prototype.localize}});

/** @module helpers */

var _module$4 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActiveEffectRegistry: ActiveEffectRegistry,
  AsyncWorker: AsyncWorker,
  ClientIssues: ClientIssues,
  ClientSettings: ClientSettings,
  DocumentIndex: DocumentIndex,
  GameTime: GameTime,
  Hooks: Hooks$1,
  Localization: Localization,
  SocketInterface: SocketInterface,
  WorkerManager: WorkerManager,
  interaction: _module$H,
  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");

      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: _loc("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();
    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">${_loc("NUE.FirstLaunchHeader")}</h3>
      <p class="nue">${_loc("NUE.FirstLaunchBody")}</p>
      <p class="nue">${_loc("NUE.FirstLaunchKB")}</p>
      <footer class="nue">${_loc("NUE.FirstLaunchHint")}</footer>
    `, `
      <h3 class="nue">${_loc("NUE.FirstLaunchInvite")}</h3>
      <p class="nue">${_loc("NUE.FirstLaunchInviteBody")}</p>
      <p class="nue">${_loc("NUE.FirstLaunchTroubleshooting")}</p>
      <footer class="nue">${_loc("NUE.FirstLaunchHint")}</footer>
    `];
    const chatData = content.map(c => {
      return {
        whisper: gms,
        speaker: {alias: _loc("COMMON.FoundryVirtualTabletop")},
        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();
    const scene = await Scene$2.implementation.fromImport(json);
    delete sceneData._id;
    scene.updateSource(sceneData);
    return Scene$2.implementation.create(scene, {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$2.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 {io.Socket} socket    The open web-socket which should be used to transact game-state data
   */
  constructor(view, data, socket) {
    // Session Properties
    Object.defineProperties(this, {
      view: {value: view, 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}
    });

    Object.defineProperty(globalThis, "_loc", {value: this.i18n.localize.bind(this.i18n), configurable: false,
      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;

  /**
   * 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
   * @returns {Promise<Game>}         A Promise which resolves to the created Game instance
   */
  static async create(view) {
    const socket = await this.connect();
    const gameData = socket ? await this.getData(socket, view) : {};
    return new this(view, gameData, socket);
  }

  /* -------------------------------------------- */

  /**
   * Establish a live connection to the game server through the socket.io URL
   * @returns {Promise<io.Socket>} A promise which resolves to the connected socket, if successful
   */
  static async connect() {

    /**
     * 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
        cookie: false
      });

      // Confirm successful session creation
      socket.on("session", response => {
        socket.session = response;
        const id = response?.sessionId;
        if ( !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 || (game.view === "quit") ) 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.#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>${_loc(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, configurable: 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

    // Detect document issues
    game.issues._detectDocumentIssues();

    // 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
    this.playlists.initialize();

    // Initialize AV conferencing
    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();

    // Configure VFX Framework
    foundry.canvas.vfx.configure();
  }

  /* -------------------------------------------- */

  /**
   * 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 ) {
        const {speedMultiplier=1} = config;
        config.getAnimationOptions = () => ({speedMultiplier});
      }
      delete config.speedMultiplier;

      if ( config.canSelect === undefined ) config.canSelect = () => true;

      else if ( typeof config.canSelect !== "function" ) {
        const {canSelect} = config.canSelect;
        config.canSelect = () => canSelect;
      }

      if ( config.deriveTerrainDifficulty === undefined ) {
        const {terrainAction} = config;
        if ( terrainAction === null ) config.deriveTerrainDifficulty = () => 1;
        else if ( terrainAction !== undefined ) config.deriveTerrainDifficulty = d => d[terrainAction];
        else config.deriveTerrainDifficulty = null;
      }
      delete config.terrainAction;

      if ( config.getCostFunction === undefined ) {
        const {costMultiplier=1} = config;
        config.getCostFunction = () => cost => cost * costMultiplier;
      }
      delete config.costMultiplier;
    }

    // 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/surfaces");
    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!");
    }

    this.#verifyActiveEffectModels();

    // 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();
      }
    }

    // Initialize Effect Registry
    foundry.documents.ActiveEffect.registry._initialize();

    // 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());

    // Release ActorDelta lazy guard
    foundry.documents.TokenDocument._preventActorDeltaAccess = false;

    // Log document preparation performance
    const dt = performance.now() - t0;
    console.debug(`${CONST.vtt} | Prepared World Documents in ${Math.round(dt)}ms`);
  }

  /* -------------------------------------------- */

  /**
   * Check that all ActiveEffect TypeDataModels include the core fields.
   */
  #verifyActiveEffectModels() {
    for ( const ModelClass of Object.values(CONFIG.ActiveEffect.dataModels) ) {

      // Temporarily soften the break for Prototype 3: extend schema to include changes.
      if ( !ModelClass.schema.fields.changes ) {
        ModelClass.schema.extendFields(foundry.data.ActiveEffectTypeDataModel.defineSchema());
        console.error(`Class ${ModelClass.name} must define a changes field in its schema`);
      }

      // Structurally verify each change DataSchema
      const changesField = ModelClass.schema.fields.changes;
      if ( !(changesField instanceof ArrayField) ) {
        throw new Error(`${ModelClass.name} must have an ArrayField defining changes`);
      }
      const changeSchemaFields = changesField.element instanceof SchemaField
        ? [changesField.element]
        : changesField.element instanceof TypedSchemaField
          ? Object.values(changesField.element.types)
          : null;
      if ( !changeSchemaFields ) {
        throw new Error(`${ModelClass.name} defines an ArrayField for changes,`
          + "but the field's element lacks a schema definition.");
      }
      for ( const schemaField of changeSchemaFields ) {
        const dataSchema = schemaField.fields;
        if ( !(dataSchema.priority instanceof NumberField) ) {
          throw new Error(`Class ${ModelClass.name} must define a numeric priority in its EffectChangeData schema`);
        }
        for ( const key of ["type", "phase"] ) {
          const field = dataSchema[key];
          if ( !(field instanceof StringField) ) {
            throw new Error(`Class ${ModelClass.name} must define a string ${key} in its EffectChangeData schema`);
          }
        }
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * 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.label",
      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.ConfigureLabel",
      label: "PERMISSION.ConfigureLabel",
      hint: "PERMISSION.ConfigureHint",
      icon: "fa-solid fa-shield-keyhole",
      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.Setting",
      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.MenuLabel",
      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.FontConfigL",
      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.CombatConfigL",
      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 visibility mode
    game.settings.register("core", "messageMode", {
      name: "Chat Message Visibility Mode",
      scope: "client",
      config: false,
      type: new foundry.data.fields.StringField({required: true, blank: false, initial: "public"}),
      onChange: () => ui.chat._updateMessageMode()
    });

    /** @deprecated since v14 */
    const rollModeField = new foundry.data.fields.StringField();
    rollModeField.initialize = function() {
      foundry.utils.logCompatibilityWarning("The `core.rollMode` client setting is deprecated in favor of"
        + " `core.messageMode`", {since: 14, until: 16, once: true});
      const mode = game.settings.get("core", "messageMode");
      const legacyModes = {public: "publicroll", gm: "gmroll", blind: "blindroll", self: "selfroll"};
      return legacyModes[mode] || mode;
    };
    game.settings.register("core", "rollMode", {
      name: "Deprecated Roll Mode",
      scope: "client",
      config: false,
      type: rollModeField,
      onChange: value => {
        foundry.utils.logCompatibilityWarning("The `core.rollMode` client setting is deprecated in favor of"
          + " `core.messageMode`", {since: 14, until: 16, once: true});
        game.settings.set("core", "messageMode", foundry.dice.Roll._mapLegacyRollMode(value));
      }
    });

    // 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.Label",
      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
    });

    // Palettes
    Object.values(foundry.applications.sheets.palette).forEach(Palette => {
      if ( !Palette.SETTING_KEY ) return;
      game.settings.register("core", Palette.SETTING_KEY, {
        scope: "client",
        config: false,
        type: Palette.schema,
        onChange: () => ui.controls._updatePresetPips()
      });
    });

    // 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]] = _loc(s[1].label);
          return choices;
        }, {none: _loc("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: new foundry.data.fields.TypedObjectField(new foundry.data.fields.SchemaField({
        coreVersion: new foundry.data.fields.StringField({ required: true, nullable: true, blank: false }),
        importedTime: new foundry.data.fields.NumberField({ required: true, integer: true }),
        moduleVersion: new foundry.data.fields.StringField({ required: true, nullable: true, blank: false }),
        options: new foundry.data.fields.ObjectField(),
        quickstart: new foundry.data.fields.SchemaField({
          postImport: new foundry.data.fields.BooleanField(),
          quickstarted: new foundry.data.fields.BooleanField()
        }),
        systemVersion: new foundry.data.fields.StringField({ required: true, nullable: true, blank: false })
      }), {
        expandKeys: false,
        validateKey: uuid => {
          const { id, type } = foundry.utils.parseUuid(uuid);
          if ( (type !== "Adventure") || !foundry.data.validators.isValidId(id) ) return false;
        }
      }),
      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
   * If the CombatTracker has yet to render, fall back to the first active combat.
   * @type {Combat|null}
   */
  get combat() {
    if ( !game._documentsReady ) return null;
    if ( ui.combat?.rendered ) return ui.combat.viewed;
    return this.combats.find(c => c.isActive) ?? 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|null}
   */
  get activeTool() {
    return ui.controls?.tool?.name ?? null;
  }

  /* -------------------------------------------- */
  /*  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 the game becomes paused, ...
    if ( pause && !wasPaused ) {

      // .. stop movement of tokens
      game.user.movingTokens.forEach(token => token.stopMovement());

      // ... cancel placeable dragging and placement unless GM
      if ( !game.user.isGM ) {
        let cancelledInteraction = false;
        if ( canvas.currentMouseManager?.interactionData.cancelOnPause ) {
          canvas.currentMouseManager.interactionData.cancelled = true;
          canvas.currentMouseManager.cancel();
          cancelledInteraction = true;
        }
        if ( canvas.tokens?._movementPlanningContext ) {
          canvas.tokens._cancelMovementPlanning();
          cancelledInteraction = true;
        }
        if ( canvas.tokens?._placementContext?.create ) {
          canvas.tokens._cancelPlacement();
          cancelledInteraction = true;
        }
        if ( canvas.regions?._placementContext?.create ) {
          canvas.regions._cancelPlacement();
          cancelledInteraction = true;
        }
        if ( cancelledInteraction ) ui.notifications.warn("GAME.PausedWarning", {localize: true});
      }
    }

    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={}, chatBackground, 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");

    // Chat background
    document.getElementById("chat")?.classList.toggle("with-background", chatBackground);

    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();

    // Detached windows.
    for ( const { window: win } of foundry.applications.detached.windows.values() ) {
      foundry.applications.detached.copyAttributes(document.documentElement, win.document.documentElement);
      foundry.applications.detached.copyAttributes(document.body, win.document.body);
    }
  }

  /* -------------------------------------------- */

  /**
   * 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("."));
    }
  }

  /* -------------------------------------------- */

  /**
   * Race the given against for the browser window becoming hidden.
   * @template T
   * @param {Promise<T>} promise  The promise that is raced against the promise of the browser window becoming hidden
   * @returns {Promise<T|void>}   The promise of the race
   */
  async raceWithWindowHidden(promise) {
    if ( window.document.hidden ) return;
    let visibilitychange;
    await Promise.race([promise, new Promise(resolve => {
      visibilitychange = event => {
        if ( window.document.hidden ) resolve();
      };
      window.document.addEventListener("visibilitychange", visibilitychange);
    })]).finally(() => {
      window.document.removeEventListener("visibilitychange", visibilitychange);
    });
  }

  /* -------------------------------------------- */
  /*  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, packageType, packageId }={}) {
    for ( const link of document.querySelectorAll("link") ) {
      const [href] = link.href?.split("?") ?? [];
      if ( href === path ) {
        link.href = `${path}?${Date.now()}`;
        return;
      }
    }
    if ( !packageType ) return;
    const { collection } = foundry.packages.PACKAGE_TYPES[packageType];
    const prefix = `${collection}/${packageId}/`;
    const pathRegex = new RegExp(`@import "(${prefix}[^"?]+)(?:\\?[^"]+)?"`, "g");
    for ( const style of document.querySelectorAll("style") ) {
      if ( !pathRegex.test(style.textContent) ) continue;
      style.textContent = style.textContent.replaceAll(pathRegex, `@import "$1?${Date.now()}"`);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle hot reloading of HTML files, such as Handlebars templates
   * @param {HotReloadData} data          The hot reload data
   */
  #hotReloadHTML(data) {
    let template;
    try {
      template = Handlebars.compile(data.content);
    }
    catch(err) {
      return console.error(err);
    }
    const ids = Handlebars.templateIds[data.path];
    if ( ids?.size ) {
      for ( const id of ids ) {
        if ( id in Handlebars.partials ) Handlebars.registerPartial(id, template);
      }
    }
    const options = { renderContext: "hotReload", renderData: data };
    for ( const appV1 of Object.values(ui.windows) ) appV1.render(false, { ...options });
    for ( const appV2 of foundry.applications.instances.values() ) appV2.render({ ...options });
  }

  /* -------------------------------------------- */

  /**
   * 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);
    const options = { renderContext: "hotReload", renderData: data };
    for ( const appV1 of Object.values(ui.windows) ) appV1.render(false, { ...options });
    for ( const appV2 of foundry.applications.instances.values() ) appV2.render({ ...options });
  }

  /* -------------------------------------------- */

  /**
   * 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.
   * @param {globalThis.Document} document  The document to bind listeners to.
   */
  activateListeners(document) {
    document ??= window.document;

    // 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));

    // Prevent browser page saving
    document.addEventListener("keydown", this.#onPreventPageSave.bind(this));

    // Support mousewheel interaction for range input elements
    document.defaultView.addEventListener("wheel", Game.#handleMouseWheelInputChange.bind(Game), {passive: false});

    // Tooltip rendering
    this.tooltip.activateListeners(document);

    // Document links
    TextEditor$1.implementation.activateListeners(document);

    // 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("resize", this.#onWindowResize.bind(this), { passive: true });
    document.defaultView.addEventListener("blur", this.#onWindowBlur.bind(this));
    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();
  }

  /* -------------------------------------------- */

  /**
   * Disallow utilization of browser save-page features.
   * @param {KeyboardEvent} event
   */
  #onPreventPageSave(event) {
    if ( (event.key === "s") && this.keyboard.isModifierActive("CONTROL") ) 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 = foundry.applications.detached.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 = foundry.utils.getType(target) === "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 event.target.ownerDocument.querySelectorAll("[data-cursor]") ) {
      el.style.cursor = el.dataset.cursor;
      delete el.dataset.cursor;
    }

    const cmm = canvas.currentMouseManager;
    if ( !cmm || event.defaultPrevented ) return;
    cmm.cancel();
  }

  /* -------------------------------------------- */

  /**
   * 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;

    // Close detached windows.
    for ( const { window: win } of foundry.applications.detached.windows.values() ) win.close();

    // Save fog.
    if ( canvas.ready ) {
      canvas.fog.commit();
      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(_loc("APPLICATION.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.activateListeners();

    // Autocomplete listeners
    foundry.applications.ux.Autocomplete.implementation.activateListeners();

    // ProseMirror menu listeners
    ProseMirror.ProseMirrorMenu.activateListeners();
  }

  /* -------------------------------------------- */

  /**
   * 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 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 FormApplication from "./appv1/api/form-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 {EdgeRestrictionType, 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,
 *   TokenMovementWaypoint, 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, Document} from "@common/abstract/_module.mjs";
 * @import {DataField} from "@common/data/fields.mjs";
 * @import {PackageCompendiumData} from "./packages/_types.mjs";
 * @import {Level, Scene, TokenDocument} from "./documents/_module.mjs";
 * @import {PrototypeToken} from "@common/data/data.mjs";
 */

/**
 * Configure which Actor system attributes can be tracked by Tokens.
 * @typedef ActorTrackableAttributes
 * @property {string[]} [bar]    Paths to attributes that have both a value and a max property.
 * @property {string[]} [value]  Paths to attributes that have only a value property.
 */

/**
 * @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 TokenMeasureMovementPathOptions
 * @property {boolean} [preview=false]    Measure a preview path? Default: `false`.
 */

/**
 * @typedef TokenConstrainMovementPathOptions
 * @property {boolean} [preview=false]        Constrain a preview path? Default: `false`.
 * @property {boolean} [ignoreWalls=false]    Ignore walls and surfaces? Default: `false`.
 * @property {boolean} [ignoreCost=false]     Ignore cost? Default: `false`.
 * @property {number} [maxCost]               The maximum cumulative cost.
 * @property {number} [maxDistance]           The maximum cumulative distance.
 * @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 {Omit<TokenMeasureMovementPathOptions, "preview">} [measureOptions]  The measurement options.
 */

/**
 * @typedef TokenCreateTerrainMovementPathOptions
 * @property {boolean} [preview=false]        Constrain a preview path? Default: `false`.
 */

/**
 * @typedef TokenFindMovementPathOptions
 * @property {boolean} [preview=false]        Find a preview path? 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`.
 * @property {Omit<TokenCreateTerrainMovementPathOptions, "preview">} [terrainOptions]  The terrain options.
 * @property {Omit<TokenConstrainMovementPathOptions, "preview"|"measureOptions">} [constrainOptions]
 *   The constrain options.
 * @property {Omit<TokenMeasureMovementPathOptions, "preview">} [measureOptions]  The measure options.
 */

/**
 * @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 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 {Omit<TokenMeasuredMovementWaypoint, "userId"|"movementId"|"subpathId">} TokenPlannedMovementWaypoint
 */

/**
 * @typedef TokenPlannedMovement
 * @property {TokenPlannedMovementWaypoint[]} foundPath             The found path, which goes through all but the
 *                                                                  unreachable waypoints
 * @property {TokenPlannedMovementWaypoint[]} 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 _TokenRulerWaypoint
 * @property {TokenMovementActionConfig} actionConfig       The config of the movement action.
 * @property {string|null} movementId                       The ID of movement, or null if planned movement.
 * @property {string|null} subpathId                        The ID of subpath, which is equal to the movement ID of the
 *                                                          first waypoint in the subpath, 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"|"subpathId"> & _TokenRulerWaypoint} TokenRulerWaypoint
 */

/**
 * @typedef TokenDragContext
 * @property {Token} token
 * @property {Token} clonedToken
 * @property {TokenPosition} origin
 * @property {TokenMovementWaypoint} destination
 * @property {Partial<TokenMovementWaypoint>[]} waypoints
 * @property {TokenMovementWaypoint[]} foundPath
 * @property {TokenMovementWaypoint[]} unreachableWaypoints
 * @property {boolean} hidden
 * @property {boolean} updating
 * @property {TokenFindMovementPathJob} search
 * @property {boolean} searching
 * @property {number} searchId
 */

/**
 * @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} depth                    The depth in grid spaces
 * @property {string} level                    The level ID
 * @property {number} alpha                    The alpha value
 * @property {number} rotation                 The rotation in degrees
 * @property {object} bar1                     Attribute bar 1
 * @property {object} bar2                     Attribute bar 2
 * @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 {number} [speedMultiplier]     The movement speed multiplier to apply to the base movement speed.
 *                                          Default: `1`.
 * @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
 */


/**
 * @typedef TokenPanningOptions
 * @property {string} [transitionType]    The type of the transition animation.
 *                                        Default: `null` (no transition animation).
 * @property {number} [duration]          The duration of the pan or transition animation.
 *                                        Default: `250` for panning or the default duration
 *                                        of the given transition type.
 * @property {number} [speed]             The speed of the panning animation in pixels per second;
 *                                        overrides `duration` if set.
 * @property {string|Function} [easing]   The easing function used for the panning animation.
 *                                        Default: `"easeInOutCosine"`.
 * @property {boolean} [force]            If false, the canvas is not panned to the token if
 *                                        the token is already onscreen. Otherwise the canvas
 *                                        is panned such that the token is in the center of
 *                                        the screen. Default: `false`.
 */

/**
 * @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 TokenMovementActionConfigDescriptor
 * @property {string} label          The label of the movement action.
 * @property {string} [icon]         The FontAwesome icon class.
 * @property {string} [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 {EdgeRestrictionType|null} [walls]  The type of walls and surfaces that block this movement, if any.
 *                                               Default: `"move"`.
 * @property {boolean} [visualize]       Is segment of the movement visualized by the ruler? Default: `true`.
 * @property {number} [speedMultiplier]  The movement speed multiplier. This property is ignored if
 *                                       `getAnimationOptions` is defined.
 * @property {(token: TokenDocument) => Pick<TokenAnimationOptions, "duration"|"movementSpeed"|"easing"
 *   |"ontick">} [getAnimationOptions]   Get the default animation options for this movement action.
 *                                       Default: `() => ({speedMultiplier})`.
 * @property {boolean | (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 {string|null} [terrainAction]  Derive the terrain difficulty from this movement action. If `null`, the
 *   terrain difficulty is always 1. This property is ignored if `deriveTerrainDifficulty` is defined.
 * @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 {number} [costMultiplier]  The cost multiplier. This property is ignored if `getCostFunction` is defined.
 *                                      Default: `1`.
 * @property {(token: TokenDocument, options: TokenMeasureMovementPathOptions)
 *   => TokenMovementActionCostFunction} [getCostFunction]  The cost modification function.
 *                                                          Default: `() => cost => cost * costMultiplier`.
 */

/**
 * @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.
 * @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.
 * @property {boolean} measure       Is the movement measured? The distance, cost, spaces, and diagonals
 *                                   of a segment that is not measured are always 0.
 * @property {EdgeRestrictionType|null} walls  The type of walls that block this movement, if any
 * @property {boolean} visualize     Is segment of the movement visualized by the ruler?
 * @property {(token: TokenDocument) => Pick<TokenAnimationOptions, "duration"|"movementSpeed"|"easing"
 *   |"ontick">} getAnimationOptions Get the default animation options for this movement action.
 * @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.
 * @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.
 */

/**
 * @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`
 * @property {string|null} level  The last-viewed level ID for the scene.
 */

/**
 * @typedef CanvasAnimationPanOptions
 * @property {number} [duration]  The total duration of the animation in milliseconds; used if `speed` is not set.
 *                                Default: `250`.
 * @property {number} [speed]     The speed of animation in pixels per second; overrides `duration` if set.
 * @property {string|Function} [easing]
 *   An easing function passed to {@link foundry.canvas.animation.CanvasAnimation.animate}.
 *   Default: `"easeInOutCosine"`.
 */

/**
 * @typedef CanvasVisibilityTest
 * @property {ElevatedPoint} point                  The target point
 * @property {Level} level                          The target level
 * @property {Map<PointVisionSource, boolean>} los  The LOS test cache
 */

/**
 * @typedef CanvasVisibilityTestConfiguration
 * @property {object|null} object              The target object
 * @property {Level} level                     The target level
 * @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 {string|null} [levelId]           The ID of the scene level 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 {number} [lastActivityTime]       The number of milliseconds since the user's last reported activity.
 * @property {boolean} [idle]                  Whether the user's last activity exceeds an idleness threshold.
 * @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} 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 _RollTableHTMLEmbedConfig
 * @property {boolean} [rollable=false]     Adds a button allowing the table to be rolled directly from its embedded
 *                                          context.
 * @property {boolean} [description=false]  Render the table's description above the table of results. This option
 *                                          takes precedence over the caption option if both are provided.
 * @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
 */

/* ----------------------------------------- */
/*  Compendium Packs                         */
/* ----------------------------------------- */

/**
 * @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.
 * @property {boolean} [dialog=false]           In cases where necessary, prompt the user with a confirmation dialog
 */

/**
 * @typedef ToCompendiumOptions
 * @property {boolean} [clearFlags=false]       Clear the flags object
 * @property {boolean} [clearSource=true]       Clear any prior source information
 * @property {boolean} [clearSort=true]         Clear the currently assigned sort order
 * @property {boolean} [clearFolder=false]      Clear the currently assigned folder
 * @property {boolean} [clearOwnership=true]    Clear document ownership (recursive)
 * @property {boolean} [clearState=true]        Clear fields which store document state (recursive)
 * @property {boolean} [dialog=false]           In cases where necessary, prompt the user with a confirmation dialog
 * @property {boolean} [keepId=false]           Retain the current Document id
 */

/**
 * @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 FormApplication|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
 */

/**
 * @typedef ClipperPoint
 * @property {number} X
 * @property {number} Y
 */

/**
 * @typedef CanvasTearDownOptions
 * @property {Scene|null} [nextScene]  The Scene about to be drawn, or null if the canvas is going blank.
 * @property {Level|null} [nextLevel]  The Level about to be drawn, or null if the canvas is going blank.
 */

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.controls.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);
  }
}

/**
 * @import {ApplicationFormSubmission} from "@client/applications/_types.mjs";
 * @import {AutocompleteCallback} from "@client/applications/ux/_types.mjs";
 */

/**
 * The Join Game setup application.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class JoinGameForm extends HandlebarsApplicationMixin(ApplicationV2) {
  constructor(options) {
    super(options);
    game.users.apps.push(this);
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @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"]
    }
  };

  /* -------------------------------------------- */

  /**
   * The autocomplete instance.
   * @type {Autocomplete}
   */
  #autocomplete;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.#activateAutocomplete();
  }

  /* -------------------------------------------- */

  /** @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 isAdmin = game.data.isAdmin;
    const usersCurrent = game.users.filter(u => u.active).length;
    const usersMax = game.users.size;
    const preselected = isAdmin && (usersCurrent === 0) && (usersMax === 1)
      ? game.users.find(u => u.hasRole(CONST.USER_ROLES.GAMEMASTER))
      : null;
    const context = {
      isAdmin,
      users: game.users,
      world: game.world,
      passwordString: game.data.passwordString,
      usersCurrent,
      usersMax,
      preselected,
      adminUsernameRequired: !!globalThis.ADMIN_USERNAME_REQUIRED
    };

    // 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.username.value = priorElement.username.value;
      const user = game.users.getName(newElement.username.value);
      if ( !user || user.active ) newElement.username.value = "";
      newElement.password.value = priorElement.password.value;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    this.#activateAutocomplete();
  }

  /* -------------------------------------------- */

  #activateAutocomplete() {
    this.#autocomplete = new Autocomplete({onSelect: this.#onAutocompleteSelect.bind(this)});
    this.element.addEventListener("focusin", this.#onAutocompleteRefresh.bind(this));
    this.element.addEventListener("input", this.#onAutocompleteRefresh.bind(this));
    document.body.addEventListener("click", this.#onAutocompleteDismiss.bind(this));
    Autocomplete.activateListeners();
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle dismissing the autocomplete menu.
   * @param {Event} event  The triggering event.
   */
  #onAutocompleteDismiss(event) {
    const {password, username} = document.forms.join.elements;
    if ( ![this.#autocomplete.element, password, username].includes(event.target) ) this.#autocomplete.dismiss();
  }

  /* -------------------------------------------- */

  /**
   * Handle refreshing the autocomplete menu.
   * @param {Event} event  The triggering event.
   */
  #onAutocompleteRefresh(event) {
    const {password, username} = document.forms.join.elements;
    if ( (event.type === "focusin") && (event.target === password) ) return this.#autocomplete.dismiss();
    if ( event.target !== username ) return;
    const users = game.users.map(u => ({identifier: u.id, label: u.name, disabled: u.active}));
    const text = username.value.toLocaleLowerCase(game.i18n.lang);
    const matches = users.filter(u => u.label.toLocaleLowerCase(game.i18n.lang).startsWith(text));
    if ( matches.length ) {
      const {right, top} = username.getBoundingClientRect();
      const position = {top, left: right + 1};
      this.#autocomplete.activate(username, matches, {position});
    }
    else this.#autocomplete.dismiss();
  }

  /* -------------------------------------------- */

  /**
   * Handle selecting an option in the autocomplete.
   * @type {AutocompleteCallback}
   */
  #onAutocompleteSelect(_identifier, label) {
    const {password, username} = document.forms.join.elements;
    username.value = label;
    password.focus();
  }

  /* -------------------------------------------- */

  /**
   * Handle a return-to-setup form submission.
   * @type {ApplicationFormSubmission}
   * @this {JoinGameForm}
   */
  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>${_loc(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);
  }

  /* -------------------------------------------- */

  /**
   * Handle a world-join form submission.
   * @type {ApplicationFormSubmission}
   * @this {JoinGameForm}
   */
  static async #onSubmitLoginForm(event, form, formData) {
    event.preventDefault();
    const submitData = Object.assign(formData.object, {action: "join"});
    if ( !submitData.username ) {
      ui.notifications.error("JOIN.ErrorMustSelectUser", {localize: true});
      return;
    }
    submitData.userId = game.users.find(u => u.name === submitData.username)?.id;
    if ( !submitData.userId ) {
      ui.notifications.error("JOIN.ErrorUserDoesNotExist", {format: {user: submitData.username}});
      return;
    }
    return this.#post(form, submitData);
  }

  /* -------------------------------------------- */

  /**
   * 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 username = postData.username;
    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: username}});
      }
      else {
        ui.notifications.error(foundry.utils.escapeHTML(e));
      }
      form.disabled = false;
      return;
    }

    // Redirect on success
    ui.notifications.clear();
    ui.notifications.success(response.message, {format: {user: username}});
    setTimeout(() => window.location.href = response.redirect, 500 );
  }
}

/**
 * @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 : _loc("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 _loc("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: _loc("PACKAGE.Relationships.Requires"),
            recommends: _loc("PACKAGE.Relationships.Recommends"),
            conflicts: _loc("PACKAGE.Relationships.Conflicts"),
            systems: _loc("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}
        ];
        break;
      }
      case "tabs":
        partContext.tabClasses = "top-tabs";
        break;
    }
    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 => _loc(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: _loc("PACKAGE.Type.systemPl"),
      module: _loc("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 = _loc("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 = _loc("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;
  }
}

/**
 * @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 _loc("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: _loc("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, " ", _loc("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});
  }
}

/**
 * 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 {SystemCompatibility} from "@common/packages/_types.mjs";
 * @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.CREATE_WORLD,
    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 _onFirstRender(context, options) {
    await super._onFirstRender(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
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    // 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.constructor.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.constructor.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.constructor.formatAuthors(w.authors);
      w.system = game.systems.get(w.system);
      w.thumb = this.constructor.getCover(world) || this.constructor.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;
  }

  /* -------------------------------------------- */

  /**
   * 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);
  }

  /* -------------------------------------------- */
  /*  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 "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 = _loc(`SETUP.${html.closest(".tab").dataset.tab.titleCase()}`);
      const search = _loc("SETUP.PackagesNoResultsSearch", {name});
      noResults.innerHTML = `<p>${_loc("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 = _loc(`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({
      label: "SETUP.WorldLaunch",
      icon: "fa-solid fa-circle-play",
      onClick: () => this.#launchWorld(pkg),
      group: "primary"
    });

    // Edit World
    if ( (packageType === "world") && !pkg.locked ) menuItems.push({
      label: "SETUP.WorldEdit",
      icon: "fa-solid fa-edit",
      onClick: () => new WorldConfig({world: pkg}).render({force: true}),
      group: "primary"
    });

    // Edit Module
    if ( (packageType === "module") && !pkg.locked ) menuItems.push({
      label: "PACKAGE.ModuleEdit",
      icon: "fa-solid fa-edit",
      onClick: () => new ModuleConfig({moduleData: pkg.toObject()}).render({force: true}),
      group: "primary"
    });

    // Mark or Unmark Favorite
    menuItems.push({
      label: pkg.favorite ? "PACKAGE.Unfavorite" : "PACKAGE.Favorite",
      icon: pkg.favorite ? "fa-regular fa-star" : "fa-solid fa-star",
      onClick: () => this.#toggleFavorite(pkg),
      group: "primary"
    });

    // Lock or Unlock Package
    menuItems.push({
      label: _loc(pkg.locked ? "PACKAGE.Unlock" : "PACKAGE.Lock", {type: typeLabel}),
      icon: `fa-solid fa-${pkg.locked ? "unlock": "lock"}`,
      onClick: () => this.#toggleLock(pkg),
      group: "primary"
    });

    // Delete Package
    menuItems.push({
      label: packageType === "world" ? "SETUP.WorldDelete" : "SETUP.Uninstall",
      icon: "fa-solid fa-trash",
      onClick: () => game.uninstallPackage(pkg),
      group: "primary"
    });

    if ( !game.data.options.noBackups ) {
      // Taking backups
      menuItems.push({
        label: "SETUP.BACKUPS.TakeBackup",
        icon: "fa-solid fa-floppy-disk",
        onClick: () => game.createBackup(pkg, { dialog: true }),
        group: "backups"
      });

      if ( game.backupsCache?.[pkg.type]?.[pkg.id]?.length ) {
        menuItems.push({
          label: "SETUP.BACKUPS.RestoreLatestBackup",
          icon: "fa-solid fa-arrow-rotate-left",
          onClick: () => game.restoreLatestBackup(pkg, { dialog: true }),
          group: "backups"
        });
      }

      // Managing backups
      menuItems.push({
        label: "SETUP.BACKUPS.ManageBackups",
        icon: "fa-solid fa-floppy-disks",
        onClick: () => 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 ? _loc("SETUP.WorldCoreMigrationRequired", {
        world: foundry.utils.escapeHTML(world.title),
        oldVersion: world.coreVersion,
        newVersion: game.release
      }) : _loc("SETUP.WorldSystemMigrationRequired", {
        oldVersion: world.systemVersion,
        newVersion: system.version
      }),
      system.availability !== CONST.PACKAGE_AVAILABILITY_CODES.VERIFIED
        ? _loc("SETUP.WorldMigrationSystemUnavailable", {
          system: foundry.utils.escapeHTML(system.title),
          systemVersion: system.version
        })
        : "",
      disableModules ? _loc("SETUP.WorldMigrationDisableModules") : "",
      _loc("SETUP.WorldMigrationBackupPrompt")
    ].filterJoin("");

    if ( !game.data.options.noBackups ) {
      content += `
        <label class="checkbox">
          ${_loc("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: "COMMON.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 = _loc(message);
  }

  /* -------------------------------------------- */
  /*  Package Management Operations               */
  /* -------------------------------------------- */

  /**
   * Create a new World.
   */
  #createWorld() {
    if ( !game.systems.size ) return ui.notifications.warn("SETUP.YouMustInstallASystem", {localize: true});
    location.href = foundry.utils.getRoute("/create");
  }

  /* -------------------------------------------- */

  /**
   * 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 = _loc("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 ( Error.isError(response) ) {
      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"
   * @param {string} [options.manifest]       The manifest URL to install from. Defaults to the one the Package is
   *                                          currently tracking.
   * @returns {Promise<foundry.packages.BasePackage>}
   */
  async #installPackageUpdate(pkg, {notify=true, manifest}={}) {
    return game.installPackage({type: pkg.type, id: pkg.id, manifest: 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: _loc("PACKAGE.RESULT.Error"),
            actionClass: "error",
            description: check.error
          });
          console.debug(`Checked ${packageType} ${pkg.id}: error`);
          break;

        // Warning
        case "warning":
          results.push({
            package: pkg,
            action: _loc("PACKAGE.RESULT.Warning"),
            actionClass: "warning",
            description: check.warning
          });
          console.debug(`Checked ${packageType} ${pkg.id}: warning`);
          break;

        // Info
        case "info":
          results.push({
            package: pkg,
            action: _loc("PACKAGE.RESULT.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;

        // 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, manifest: check.manifest});
            results.push({
              package: updated,
              action: _loc("PACKAGE.RESULT.Update"),
              actionClass: "success",
              description: `${pkg.version} ➞ ${updated.version}`
            });
            console.debug(`Checked ${packageType} ${pkg.id}: update success`);
          } catch(err) {
            results.push({
              package: pkg,
              action: _loc("PACKAGE.RESULT.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 listFormatter = game.i18n.getListFormatter({style: "narrow"});
      const incompatibleList = Object.values(incompatible ?? {}).map(({pkg, systemCompatibility}) => {
        return _loc("SETUP.PackageIncompatibleWithSystems", {
          module: `<strong>${foundry.utils.escapeHTML(pkg.title)}</strong>`,
          systems: listFormatter.format(Object.values(systemCompatibility)
            .filter(s => s && !s.compatible)
            .map(({title, version}) => `<strong>${foundry.utils.escapeHTML(title)}</strong> (${version})`))
        });
      });
      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: _loc(`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 "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, {manifest: check.manifest});
        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"|"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 {string} [manifest]                                  The manifest URL to install the Update from, 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;
    }

    // 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,
      codes.REQUIRES_CORE_UPGRADE_UNKNOWN
    ];
    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",
        [codes.REQUIRES_CORE_UPGRADE_UNKNOWN]: "Unknown"
      }[availability];
      checkData.info = _loc(`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 = responseData.manifest;
      return checkData;
    }

    // Packages which are already current
    checkData.state = "current";
    return checkData;
  }

  /* -------------------------------------------- */

  /**
   * 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 listFormatter = game.i18n.getListFormatter({style: "narrow"});
    const items = listFormatter.format(Object.values(compatibility)
      .filter(s => s && !s.compatible)
      .map(({title, version}) => `<li>${foundry.utils.escapeHTML(title)} (${version})</li>`));
    return foundry.applications.api.DialogV2.confirm({
      modal: true,
      rejectClose: false,
      window: {icon: "fa-solid fa-triangle-exclamation", title: "COMMON.AreYouSure"},
      position: {width: 450},
      content: `
        <p>${_loc("SETUP.PackageIncompatibleSystemsPrompt")}</p>
        <ul>${items}</ul>`
    });
  }

  /* -------------------------------------------- */
  /*  Package Preparation Helpers                 */
  /* -------------------------------------------- */

  /**
   * Format the authors of a package into a string of comma-separated links for display.
   * @param {object[]} authors
   * @returns {string}
   */
  static 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(", ");
  }

  /* -------------------------------------------- */

  /**
   * 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|void}    A cover image URL if one exists.
   */
  static getCover(pkg) {
    if ( !pkg ) return;
    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;
  }

  /* -------------------------------------------- */

  /**
   * Prepare data for rendering a view of modules.
   * @returns {object[]}
   */
  static prepareModules() {
    return 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";
      m.titlelc = m.title.toLocaleLowerCase(game.i18n.lang);
      return m;
    }).sort(this.sortPackages);
  }

  /* -------------------------------------------- */

  /**
   * Prepare data for rendering a view of systems.
   * @returns {object[]}
   */
  static prepareSystems() {
    return 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";
      s.titlelc = s.title.toLocaleLowerCase(game.i18n.lang);
      return s;
    }).sort(this.sortPackages);
  }

  /* -------------------------------------------- */

  /**
   * Sort packages by favorite, then alphabetically.
   * @param {ClientPackage} a
   * @param {ClientPackage} b
   * @returns {number}
   */
  static sortPackages(a, b) {
    return (b.favorite - a.favorite) || a.title.localeCompare(b.title, game.i18n.lang);
  }

  /* -------------------------------------------- */
  /*  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 ? _loc(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);
    }
  }
}

/**
 * The World creation setup application.
 * @extends {ApplicationV2}
 * @mixes HandlebarsApplication
 */
class WorldCreate extends HandlebarsApplicationMixin(ApplicationV2) {
  constructor(options) {
    super(options);
    this.#adventures = game.getQuickstartAdventures?.() ?? {};
    const { VERIFIED, UNVERIFIED_GENERATION } = CONST.PACKAGE_AVAILABILITY_CODES;
    this.#systems = game.systems.reduce((obj, s) => {
      if ( (s.availability >= VERIFIED) && (s.availability <= UNVERIFIED_GENERATION) ) obj[s.id] = s;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    id: "world-create",
    tag: "form",
    window: {
      frame: false,
      positioned: false
    },
    actions: {
      cancel: this.#onCancel,
      selectPackage: this.#onSelectPackage
    },
    form: {
      handler: WorldCreate.#onSubmit
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    menu: { template: "templates/setup/create/menu.hbs" },
    modules: {
      template: "templates/setup/create/modules.hbs",
      templates: ["templates/setup/parts/package-tags.hbs"],
      scrollable: [".scrollable"]
    },
    config: { template: "templates/setup/create/config.hbs" },
    footer: { template: "templates/generic/form-footer.hbs" },
    systems: {
      template: "templates/setup/create/systems.hbs",
      templates: ["templates/setup/parts/package-tags.hbs"],
      scrollable: [".scrollable"]
    }
  };

  /* -------------------------------------------- */

  /**
   * The currently selected adventure.
   * @type {string}
   */
  get adventure() {
    return this.#adventure;
  }

  /**
   * Update the adventure selection.
   * @param {string} value  The new adventure value.
   */
  set adventure(value) {
    this.#adventure = value || undefined;
    this.#filterPackageOptions();
    this.selectPackage({ packageId: this.#adventure, type: "module" });
    this.form.elements.adventure.value = value || "";
  }

  #adventure;

  /**
   * Adventures eligible for quick-starting.
   * @type {Record<string, { module: Module, systems: Set<string> }>}
   */
  #adventures;

  /**
   * The currently selected system.
   * @type {string}
   */
  get system() {
    return this.#system;
  }

  /**
   * Update the system selection.
   * @param {string} value  The new system value.
   */
  set system(value) {
    this.#system = value || undefined;
    this.#filterPackageOptions();
    this.selectPackage({ packageId: this.#system, type: "system" });
    this.form.elements.system.value = value || "";
  }

  #system;

  /**
   * Systems available for World creation.
   * @type {Record<string, System>}
   */
  #systems;

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.fields = foundry.packages.World.schema.fields;
    context.themes = Object.entries(CONST.WORLD_JOIN_THEMES).reduce((themes, [key, label]) => {
      themes[key] = _loc(label);
      return themes;
    }, {});
    context.modules = {
      options: [{ label: "", value: "" }],
      field: new foundry.data.fields.StringField({ label: "DOCUMENT.Adventure" }, { name: "adventure" }),
      list: SetupPackages.prepareModules().filter(m => m.id in this.#adventures)
    };
    context.systems = {
      options: [{ label: "", value: "" }],
      field: context.fields.system,
      list: SetupPackages.prepareSystems().filter(s => s.id in this.#systems)
    };
    [context.modules, context.systems].forEach(ctx => {
      for ( let i = 0; i < ctx.list.length; i++ ) {
        const { favorite, id, title } = ctx.list[i];
        const opt = { value: id, label: title };
        if ( favorite && !context.modules[i + 1]?.favorite ) opt.rule = true;
        ctx.options.push(opt);
      }
    });
    context.buttons = [{
      type: "submit",
      cssClass: "bright",
      icon: "fa-solid fa-play",
      label: "SETUP.WorldContinue"
    }];
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("input", this._onInput.bind(this), { passive: true });
  }

  /* -------------------------------------------- */

  /**
   * Adjust package options based on compatibility.
   */
  #filterPackageOptions() {
    const selectedAdventure = this.#adventures[this.#adventure];
    const validAdventures = Object.entries(this.#adventures).reduce((obj, [id, adventure]) => {
      const { systems } = adventure;
      if ( !this.#system || !systems.size || systems.has(this.#system) ) obj[id] = adventure;
      return obj;
    }, {});
    const validSystems = Object.entries(this.#systems).reduce((obj, [id, s]) => {
      const { systems } = selectedAdventure ?? {};
      if ( !systems?.size || systems.has(s.id) ) obj[id] = s;
      return obj;
    }, {});
    for ( const el of this.form.elements.adventure?.querySelectorAll("option") ?? [] ) {
      el.hidden = el.value && !(el.value in validAdventures);
    }
    for ( const el of this.element.querySelectorAll(".package-list .module") ) {
      const { packageId } = el.dataset;
      el.hidden = packageId && !(packageId in validAdventures);
      if ( el.hidden && el.classList.contains("active") ) {
        el.classList.remove("active");
        this.#adventure = undefined;
      }
    }
    for ( const el of this.form.elements.system.querySelectorAll("option") ) {
      el.hidden = el.value && !(el.value in validSystems);
    }
    for ( const el of this.element.querySelectorAll(".package-list .system") ) {
      el.hidden = !(el.dataset.packageId in validSystems);
      if ( el.hidden && el.classList.contains("active") ) {
        el.classList.remove("active");
        this.#system = undefined;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle closing this view and returning to setup.
   * @this {WorldCreate}
   */
  static #onCancel() {
    foundry.applications.api.DialogV2.confirm({
      window: { title: "COMMON.AreYouSure" },
      content: `<p>${_loc("SETUP.WorldCancelConfirm")}</p>`,
      yes: {
        callback: () => location.href = foundry.utils.getRoute("/setup")
      }
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    if ( event.target.name === "background" ) {
      const value = event.target.value;
      // Verify the image exists first before trying to set it by using onload handler.
      const img = new Image();
      const src = URL.parse(value) ? value : foundry.utils.getRoute(event.target.value);
      img.onload = () => document.body.style.setProperty("--background-url", `url('${src}')`);
      img.src = src;
    }
    else if ( event.target.name === "system" ) this.system = event.target.value;
    else if ( event.target.name === "adventure" ) this.adventure = event.target.value;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onRender(context, options);
    const col = this.element.ownerDocument.createElement("div");
    col.classList.add("center", "flexcol");
    const config = this.element.querySelector("[data-application-part=config]");
    const footer = this.element.querySelector("[data-application-part=footer]");
    config.replaceWith(col);
    col.append(config, footer);
  }

  /* -------------------------------------------- */

  /**
   * Handle input events.
   * @param {InputEvent} event  The triggering event.
   * @protected
   */
  _onInput(event) {
    const value = event.target.value;
    if ( event.target.name === "title" ) {
      const slug = value.slugify({ strict: true }) || "world-id";
      document.getElementById("main-header").querySelector("h1").textContent = value;
      this.form.elements["world-id"].placeholder = slug;
    } else if ( event.target.type === "search" ) {
      for ( const li of event.target.closest(".framed").querySelectorAll(".package-list li") ) {
        const { packageId, packageTitle } = li.dataset;
        const match = !value || !packageId || packageId.startsWith(value) || packageTitle?.includes(value);
        li.classList.toggle("nomatch", !match);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle progress updates.
   * @param {Notification} progress  The progress bar.
   * @param {Event} event            The progress event.
   */
  #onProgress(progress, event) {
    let { id, title, pct, message } = event.data;
    message = [message ? _loc(message) : null, title ?? id].filterJoin(" ");
    progress.update({ message, pct: pct / 100 });
  }

  /* -------------------------------------------- */

  /**
   * Handle selecting a package.
   * @this {WorldCreate}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onSelectPackage(event, target) {
    const { packageId, type } = target.dataset;
    if ( type === "system" ) this.system = packageId;
    else {
      this.adventure = packageId;
      const module = game.modules.get(packageId);
      this.form.elements.title.placeholder = module?.title ?? "";
    }
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /**
   * Check dependencies for the given package, and if they are not satisfied, prompt the user to install them.
   * @param {ClientPackage} pkg  The package.
   * @returns {Promise<void>}
   */
  async #installDependencies(pkg) {
    const progress = ui.notifications.info("SETUP.InstallingDependencies", {
      localize: true, permanent: true, progress: true
    });
    const resolved = await game.installDependencies(pkg, {
      onProgress: this.#onProgress.bind(this, progress),
      allowManual: false
    });
    if ( !resolved ) {
      progress.remove();
      throw new Error(_loc("SETUP.WorldCreateDependencyFail"));
    }
    progress.update({ message: "SETUP.InstallDependenciesSuccess", localize: true, pct: 1 });
    progress.element.classList.add("success");
  }

  /* -------------------------------------------- */

  /**
   * Submit a request for world creation.
   * @param {object} worldData  The initial creation data.
   * @returns {Promise<void>}
   */
  async #onCreateWorld(worldData) {
    let error;
    const { ACTIONS } = CONST.SETUP_PACKAGE_PROGRESS;
    const progress = ui.notifications.info("SETUP.WorldCreating", { localize: true, permanent: true, progress: true });
    const receiver = new ProgressReceiver(worldData.id, ACTIONS.CREATE_WORLD, worldData);
    receiver.addEventListener("progress", this.#onProgress.bind(this, progress));
    receiver.addEventListener("error", event => error = event.data.error);
    try { await receiver.listen(); } catch(err) { error = err; }
    if ( error ) {
      progress.remove();
      throw new Error(error.message ?? error);
    } else {
      progress.update({ message: "SETUP.WorldCreateSuccess", localize: true, pct: 1 });
      progress.element.classList.add("success");
    }
    location.href = foundry.utils.getRoute("players");
  }

  /* -------------------------------------------- */

  /**
   * Handle form submission.
   * @this {WorldCreate}
   * @type {ApplicationFormSubmission}
   */
  static #onSubmit(event, form, formData) {
    const submitData = foundry.utils.expandObject(formData.object);
    if ( submitData.adventure ) {
      const module = game.modules.get(submitData.adventure);
      if ( module ) submitData.title ||= module.title;
    }
    submitData.id = submitData["world-id"];
    if ( !submitData.id?.length ) {
      submitData.id = submitData.title.slugify({ strict: true }) || foundry.utils.randomID();
    }

    // Handle next session schedule fields.
    const date = new Date(submitData.nextSession);
    submitData.nextSession = date.isValid() ? date.toISOString() : null;

    submitData.launch = true; // Also launch the world.
    this.#processSubmitData(submitData);
  }

  /* -------------------------------------------- */

  /**
   * Process submitted world data.
   * @param {object} submitData  World creation data.
   * @returns {Promise<void>}
   */
  async #processSubmitData(submitData) {
    this.#updateSubmitState(false, { icon: "fa-solid fa-spinner fa-spin-pulse", label: "SETUP.WorldCreating" });

    // Validate the submission data.
    try {
      new foundry.packages.World(foundry.utils.mergeObject({ coreVersion: game.release.version }, submitData));
      await this.#installDependencies(game.systems.get(submitData.system));
      if ( submitData.adventure ) await this.#installDependencies(game.modules.get(submitData.adventure));
      await this.#onCreateWorld(submitData);
    } catch(err) {
      this.#updateSubmitState(true, { icon: "fa-solid fa-play", label: "SETUP.WorldContinue" });
      if ( err.getFailure?.("title") ) {
        ui.notifications.error("SETUP.WorldCreateNoTitle", { localize: true });
        throw err;
      }
      if ( err.getFailure?.("system") ) {
        ui.notifications.error("SETUP.WorldCreateNoSystem", { localize: true });
        this.#highlightSystems();
        throw err;
      }
      ui.notifications.error(err.message.replace("\n", ". "));
      throw err;
    }
  }

  /* -------------------------------------------- */

  /**
   * Update the form submission state.
   * @param {boolean} enabled         Whether form submission is enabled.
   * @param {object} [options]
   * @param {string} [options.icon]   Update the submit button's icon.
   * @param {string} [options.label]  Update the submit button's label.
   */
  #updateSubmitState(enabled, options={}) {
    const submit = this.form.querySelector("button[type=submit]");
    const icon = submit.querySelector(":scope > i");
    const label = submit.querySelector(":scope > span");
    submit.disabled = !enabled;
    if ( options.icon ) icon.className = options.icon;
    if ( options.label ) label.textContent = _loc(options.label);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Briefly highlight the systems selection panel.
   */
  #highlightSystems() {
    const panel = this.element.querySelector("[data-application-part=systems]");
    if ( !panel ) return;
    const highlight = Tour.highlightElement(panel, { preventInteraction: false });
    setTimeout(() => highlight.remove(), 2000);
  }

  /* -------------------------------------------- */

  /**
   * Programmatically mark a package as selected in the UI.
   * @param {object} [options]
   * @param {string} [options.packageId]        The ID of the package to select.
   * @param {"module"|"system"} [options.type]  The type of package being selected.
   */
  selectPackage({ packageId, type }={}) {
    for ( const el of this.element.querySelectorAll(`.package-list .${type}`) ) {
      el.classList.toggle("active", el.dataset.packageId === packageId);
    }
    if ( type === "system" ) this.#system = packageId;
    else this.#adventure = packageId;
  }
}

/**
 * @import {ServerSettings} from "@common/config.mjs";
 * @import {ApplicationFormSubmission} from "@client/applications/_types.mjs";
 * @import FormDataExtended from "@client/applications/ux/form-data-extended.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>${_loc("SERVER_SETTINGS.Telemetry.request1")}</p>`,
      `<blockquote>${_loc("SERVER_SETTINGS.FIELDS.telemetry.hint")}</blockquote>`,
      `<p>${_loc("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] = _loc(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;
    const source = settings._source;
    return {
      rootId: this.id,
      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>${_loc("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>${_loc("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"
    }
  };

  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    return Object.assign(context, {usernameRequired: !!globalThis.ADMIN_USERNAME_REQUIRED});
  }
}

/**
 * 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, 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 = _loc(`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 = _loc("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: _loc(`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, " ", _loc("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});
  }
}

/**
 * @import {ApplicationClickAction} from "@client/applications/_types.mjs";
 */

/**
 * 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,
      shutDown: SetupMenu.#onClickShutDownServer,
      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: "SETUP.ACTIONS.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: "SETUP.ACTIONS.Configure",
        icon: "fa-solid fa-gears",
        pip: !game.data.options.adminPassword ? {
          type: "warning",
          label: "!"
        } : null
      },
      {
        action: "update",
        tooltip: "SETUP.ACTIONS.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 and shut down
    const canLogOut = !!game.data.options.adminPassword;
    if ( canLogOut ) {
      buttons.push(
        {action: "adminLogout", tooltip: "SETUP.ACTIONS.Logout", icon: "fa-solid fa-door-open"},
        {action: "shutDown", tooltip: "SETUP.ACTIONS.ShutDownServer", icon: "fa-solid fa-power-off"}
      );
    }

    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});
  }

  /* -------------------------------------------- */

  /**
   * Shut down the server.
   * @type {ApplicationClickAction}
   * @this {SetupMenu}
   */
  static async #onClickShutDownServer() {
    const confirmed = await foundry.applications.api.DialogV2.confirm({
      window: {icon: "fa-solid fa-power-off", title: "SETUP.ACTIONS.ShutDownServer"},
      content: _loc("SETUP.ShuttingDown.Confirm")
    });
    if ( !confirmed ) return;
    const response = await fetchWithTimeout(getRoute("quit"), {method: "POST", body: JSON.stringify({defer: true}),
      headers: {"Content-Type": "application/json"}});
    if ( response.ok ) window.location.href = response.url;
    else throw new Error("Unexpected response to shutdown request from server");
  }
}

/**
 * @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 = _loc(`SETUP.Update${this.#release.channel.titleCase()}`);
    const release = this.#release.display.replace(/(\d+) (\d+)$/, `$1 ${channel} $2`);
    return _loc("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: _loc(`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 = _loc(`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 = _loc(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: _loc("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 _loc("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 = _loc(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,
      noUpdateMode: !game.data.coreUpdate.canUpdate
    };
  }

  /* -------------------------------------------- */

  /**
   * 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: _loc("SETUP.CoreVersionHint", {versionDisplay: game.release.display}),
      updateChannel: game.data.options.updateChannel,
      updateChannelHints: Object.entries(updateChannels).reduce((obj, [chan, label]) => {
        obj[chan] = _loc(`${label}Hint`);
        return obj;
      }, {}),
      coreUpdate: game.data.coreUpdate.hasUpdate
        ? _loc("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 = _loc("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 = _loc("SETUP.UpdateNewGeneration");
      } else {
        this.#step = "updateDownload";
        button.disabled = !game.data.coreUpdate.canUpdate;
        if ( button.disabled ) {
          label.textContent = _loc("SETUP.NoUpdateMode.Label");
          button.ariaLabel = _loc("SETUP.NoUpdateMode.Hint");
          button.dataset.tooltip = "";
        } else {
          label.textContent = _loc("SETUP.UpdateButtonDownload", {display: releaseData.display});
        }
      }

      // Render release notes
      if ( releaseData.notes ) {
        ui.updateNotes = new UpdateNotes({release: releaseData});
        ui.updateNotes.render({force: true});
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * 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 = [_loc(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 = _loc(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,
      loginAsUser: UserManagement.#onLoginAsUser,
      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.map((user, idx) => {
        const { _id, name, role } = user;
        return { _id, name, role, passwordWarning: game.data.firstLaunch && (idx === 0) };
      }),
      roles: UserManagement.#getRoleLabels(),
      canConfigurePermissions: game.user?.hasRole("GAMEMASTER") || game.data.firstLaunch,
      userTemplate: this.constructor.USER_TEMPLATE,
      firstLaunch: game.data.firstLaunch,
      passwordString: game.data.passwordString,
      labels: {
        launch: _loc(game.data.firstLaunch ? "USERS.FirstLaunch" : "USERS.Launch")
      }
    };
  }

  /* -------------------------------------------- */

  /**
   * 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]] = _loc(`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>${_loc("COMMON.AreYouSure")}</strong>`,
      `<p>${_loc("USERS.DeleteWarning")}</p>`
    ];
    if ( user.isGM ) lines.push(`<p class="notification warning">${_loc("USERS.DeleteGMWarning")}</p>`);

    // Render a confirmation dialog
    foundry.applications.api.DialogV2.confirm({
      window: {title: _loc("USERS.DeleteName", {name: user.name})},
      content: lines.join("\n"),
      yes: {
        label: "COMMON.Delete",
        icon: "fa-solid fa-trash",
        callback: async () => {
          await user.delete();
          li.remove();
        }
      },
      no: {
        label: "COMMON.Cancel"
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking the "Login As" button for a user row.
   * @this {UserManagement}
   * @param {PointerEvent} event
   * @param {HTMLAnchorElement} target
   */
  static #onLoginAsUser(event, target) {
    const { userId } = target.closest("[data-user-id]").dataset ?? {};
    this.element.dispatchEvent(new CustomEvent("submit", {
      bubbles: true,
      cancelable: true,
      detail: { loginAsUserId: userId }
    }));
  }

  /* -------------------------------------------- */

  /**
   * Handle form submission.
   * @this {UserManagement}
   * @param {SubmitEvent} event
   * @param {HTMLFormElement} form
   * @param {foundry.applications.ux.FormDataExtended} formData
   */
  static async #onSubmitForm(event, form, formData) {
    const loginAsUserId = event.detail?.loginAsUserId || null;
    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) || (game.data.firstLaunch && !data.password) ) {
        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
    try {
      if ( updates.length ) await User$1.updateDocuments(updates, {diff: false});
    }
    catch(err) {
      return this.render();
    }

    // Login as a specific user, or return to the game normally.
    if ( loginAsUserId ) {
      const user = game.users.get(loginAsUserId);
      const joinURL = foundry.utils.getRoute("join");
      let response;
      try {
        response = await foundry.utils.fetchJsonWithTimeout(joinURL, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ action: "loginAs", userId: loginAsUserId })
        });
      } catch(e) {
        if ( e instanceof foundry.utils.HttpError ) {
          ui.notifications.error(e.displayMessage, { format: { user: user?.name } });
        }
        else ui.notifications.error(foundry.utils.escapeHTML(e));
        return;
      }
      ui.notifications.info(response.message, { format: { user: user?.name } });
      return setTimeout(() => window.location.href = response.redirect, 1000);
    }

    ui.notifications.info("USERS.UpdateSuccess", {localize: true});
    return setTimeout(() => window.location.href = foundry.utils.getRoute("game"), 1000);
  }
}

/**
 * An Application that manages the browsing and installation of Packages.
 */
class InstallPackage extends HandlebarsApplicationMixin(ApplicationV2) {
  constructor({packageType, search=null, ...options}={}) {
    super(options);
    this.#packageType = packageType;
    this.#initialSearch = search;
  }

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "install-package",
    classes: ["category-browser"],
    tag: "form",
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-download"
    },
    position: {
      width: 1000,
      height: 780
    },
    actions: {
      filterVisibility: InstallPackage.#onFilterVisibility,
      installPackage: InstallPackage.#onInstallPackage,
      installUrl: InstallPackage.#onInstallUrl,
      setManifestUrl: InstallPackage.#onSetManifestUrl,
      toggleCategory: {handler: InstallPackage.#onToggleCategory, buttons: [0, 2]}
    }
  };

  /** @override */
  static PARTS = {
    sidebar: {
      template: "templates/setup/install-package/sidebar.hbs",
      scrollable: ["nav"]
    },
    main: {
      template: "templates/setup/install-package/main.hbs",
      templates: [
        "templates/setup/install-package/category.hbs",
        "templates/setup/install-package/filters.hbs"
      ]
    },
    footer: {template: "templates/setup/install-package/footer.hbs"}
  };

  /**
   * Configuration for batch scrolling.
   * @type {{MARGIN: number, SIZE: number}}
   */
  static #BATCH_CONFIG = {
    /**
     * The distance in pixels from the end of the list to start rendering the next batch of packages.
     */
    MARGIN: 400,

    /**
     * The number of packages to append each time the end of the list is reached.
     */
    SIZE: 50
  };

  /**
   * The modes offered by the required system filter, in the order they are presented.
   * @type {["installed", "none", "specific"]}
   */
  static #SYSTEM_FILTER_MODES = ["installed", "none", "specific"];

  /**
   * 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 {object[]}
   */
  #packages = [];

  /**
   * The packages which match the active filters, in the order they are listed.
   * @type {object[]}
   */
  #candidates = [];

  /**
   * The number of matching packages in each category, keyed by category ID.
   * @type {Record<string, number>}
   */
  #counts = {};

  /**
   * Package instances, prepared on demand for the packages which are displayed, keyed by package id.
   * @type {Record<string, ClientPackage|null>}
   */
  #models = {};

  /**
   * The number of packages which have been rendered into the list.
   * @type {number}
   */
  #rendered = 0;

  /**
   * Is a batch of packages currently being appended to the list?
   * @type {boolean}
   */
  #appending = false;

  /**
   * Incremented whenever the candidate list is re-filtered, so that a batch prepared against the previous filters can
   * detect that it is stale.
   * @type {number}
   */
  #filterVersion = 0;

  /**
   * An observer which appends the next batch of packages when the end of the list comes into view.
   * @type {IntersectionObserver|null}
   */
  #observer = null;

  /**
   * The list of Tags available
   * @type {object}
   */
  #tags = {};

  /**
   * The type of package being installed, a value in PACKAGE_TYPES
   * @type {PackageType}
   */
  #packageType;

  /**
   * The current filter state.
   * @type {{
   *   categories: Map<string, 1|-1>,
   *   providers: Set<string>,
   *   systems: {mode: "installed"|"none"|"specific", value: Set<string>},
   *   visibility: "all"|"inst"|"unin"
   * }}
   * @protected
   */
  _filterState = {
    categories: InstallPackage.#defaultCategoryState(),
    providers: new Set(),
    systems: {mode: "installed", value: new Set()},
    visibility: "all"
  };

  /**
   * An initial provided search filter value.
   * @type {string|null}
   */
  #initialSearch = null;

  /**
   * The current manifest URL input value.
   * @type {string}
   */
  #manifestURL = "";

  /**
   * Search-filter handling.
   * @type {SearchFilter}
   */
  #search = new SearchFilter({
    inputSelector: "input[type=search]",
    contentSelector: "[data-application-part=main]",
    callback: this._applyFilters.bind(this)
  });

  /** @override */
  get title() {
    return _loc(`SETUP.Install${this.#packageType.titleCase()}`);
  }

  /**
   * Is the package cache warm enough to display packages? Browsing a non-system type additionally requires the system
   * cache, which supplies the titles of the systems that packages require.
   * @returns {boolean}
   */
  get _dataLoaded() {
    if ( game.packageCache[this.#packageType].state <= CACHE_STATES.COLD ) return false;
    return (this.#packageType === "system") || (game.packageCache.system.state > CACHE_STATES.COLD);
  }

  /* -------------------------------------------- */

  /**
   * Perform a text search programmatically.
   * @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) {
    if ( this._dataLoaded && (!this.#packages.length || foundry.utils.isEmpty(this.#tags)) ) {
      const {packages, tags} = InstallPackage.getTaggedPackages(this.#packageType);
      this.#packages = packages;
      this.#tags = tags;
      this.#filterPackages();
    }

    const {visibility} = this._filterState;
    const categories = Object.entries(this.#tags).map(([id, tag]) => {
      const reset = id === "all";
      const value = reset ? 0 : (this._filterState.categories.get(id) ?? 0);
      return {
        id, reset,
        label: tag.label,
        active: reset ? this.#isDefaultCategoryState() : false,
        state: {
          class: value > 0 ? "filter-in" : value < 0 ? "filter-out" : "",
          icon: value > 0 ? "fa-check" : value < 0 ? "fa-xmark" : ""
        },
        count: reset ? this.#candidates.length : (this.#counts[id] ?? 0)
      };
    }).sort((a, b) => this.#sortCategories(a, b));

    return {
      categories,
      rootId: this.id,
      packageType: this.#packageType,
      providersFilter: this.#prepareProvidersFilter(this.#packages),
      systemsFilter: this.#prepareSystemsFilter(this.#packages),
      loading: this._dataLoaded ? null : "SETUP.PackagesLoading",
      manifestURL: this.#manifestURL,
      filters: [
        {id: "inst", active: visibility === "inst", label: _loc("SETUP.PackageVisInst")},
        {id: "unin", active: visibility === "unin", label: _loc("SETUP.PackageVisUnin")},
        {id: "all", active: visibility === "all", label: _loc("SETUP.PackageVisAll")}
      ],
      filtersLabel: _loc(`SETUP.${this.#packageType.titleCase()}Filter`)
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "main": await this._prepareMainContext(context, options); break;
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare main render context.
   * @param {ApplicationRenderContext} context
   * @param {ApplicationRenderOptions} _options
   * @protected
   */
  async _prepareMainContext(context, _options) {
    // The list is rendered one batch at a time, with further batches appended as the end of the list is reached.
    const batch = this.#candidates.slice(0, InstallPackage.#BATCH_CONFIG.SIZE);
    this.#rendered = batch.length;
    context.results = this.#prepareRows(batch);
  }

  /* -------------------------------------------- */

  /**
   * Prepare the control which filters by the package's attributed author or publisher.
   * @param {object[]} results  The prepared package data.
   * @returns {object|null}     Null until the package data has loaded.
   */
  #prepareProvidersFilter(results) {
    if ( !this._dataLoaded ) return null;
    const names = results.reduce((set, p) => {
      if ( p.author ) set.add(p.author);
      return set;
    }, new Set());
    const options = Array.from(names).sort((a, b) => a.localeCompare(b, game.i18n.lang)).map(name => ({value: name}));
    return {
      options,
      field: new SetField(new StringField(), {label: _loc("SETUP.PROVIDER_FILTER.Label")}, {name: "providers"}),
      placeholder: _loc("SETUP.PROVIDER_FILTER.Placeholder"),
      selected: Array.from(this._filterState.providers)
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare the controls which filter by required system. Only the systems which some package actually requires are
   * offered, so that no option can match zero packages. Installed systems come first.
   * @param {object[]} results  The prepared package data.
   * @returns {object|null}     Null when browsing systems, which never require a system.
   */
  #prepareSystemsFilter(results) {
    if ( !this._dataLoaded || (this.#packageType === "system") ) return null;
    const titles = InstallPackage.#getSystemTitles();
    const installed = [];
    const other = [];
    for ( const id of new Set(results.flatMap(p => p.systems)) ) {
      const option = {label: titles.get(id) ?? id, value: id};
      (game.systems.has(id) ? installed : other).push(option);
    }
    const sort = (a, b) => a.label.localeCompare(b.label, game.i18n.lang);
    installed.sort(sort);
    other.sort(sort);
    if ( installed.length && other.length ) installed.at(-1).rule = true;

    const {mode, value} = this._filterState.systems;
    const modes = Object.fromEntries(InstallPackage.#SYSTEM_FILTER_MODES.map(m => {
      return [m, _loc(`SETUP.SYSTEM_FILTER.MODES.${m}`)];
    }));

    return {
      mode,
      fields: {
        mode: new StringField({
          blank: false, choices: modes, label: _loc("SETUP.SYSTEM_FILTER.Label"), required: true
        }, {name: "systems.mode"}),
        value: new SetField(new StringField(), {label: _loc("SETUP.SYSTEM_FILTER.Systems")}, {name: "systems.value"})
      },
      options: [...installed, ...other],
      selected: Array.from(value)
    };
  }

  /* -------------------------------------------- */

  /**
   * Make a possibly long-running request to warm the package cache: a loading message is displayed until it completes.
   * @returns {Promise<void>}
   */
  async _loadCategoryData() {
    this.#clearPackageData();
    await game.warmPackages({type: this.#packageType});
    // The full package index is cached after the above request so asking for systems should just load from the cache.
    if ( this.#packageType !== "system" ) await game.warmPackages({type: "system"});
  }

  /* -------------------------------------------- */

  /** @override */
  _canRender(options) {
    if ( this.#packageType === "world" ) return false;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);

    if ( options.parts.includes("sidebar") ) {
      this.#search.bind(this.element);

      // 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);
      }
    }

    if ( options.parts.includes("footer") ) {
      const manifestInput = this.element.querySelector(`#${this.id}-manifestUrl`);
      manifestInput?.addEventListener("input", event => this.#manifestURL = event.currentTarget.value);
    }

    this.#observeListEnd();
    this.#updateCategoryControls();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _tearDown(options) {
    super._tearDown(options);
    this.#search.unbind();
    this.#observer?.disconnect();
    this.#observer = null;
    this.#clearPackageData();
  }

  /* -------------------------------------------- */

  /**
   * Watch the end of the package list, so that the next batch of packages is appended as it comes into view.
   */
  #observeListEnd() {
    this.#observer?.disconnect();
    const end = this.element.querySelector(".load-more");
    if ( !end ) return;
    this.#observer = new IntersectionObserver(this.#onListEndVisible.bind(this), {
      root: this.element.querySelector(".package-list"),
      rootMargin: `${InstallPackage.#BATCH_CONFIG.MARGIN}px`
    });
    this.#observer.observe(end);
  }

  /* -------------------------------------------- */

  /**
   * Handle the end of the package list coming into view.
   * @param {IntersectionObserverEntry[]} entries  The observed intersections.
   * @returns {Promise<void>}
   */
  async #onListEndVisible(entries) {
    if ( this.#appending || !entries.some(e => e.isIntersecting) ) return;
    this.#appending = true;
    try {
      await this.#appendBatch();
    } finally {
      this.#appending = false;
    }
  }

  /* -------------------------------------------- */

  /**
   * Append the next batch of packages to the rendered list.
   * @returns {Promise<boolean>}  Whether any packages were appended.
   */
  async #appendBatch() {
    const version = this.#filterVersion;
    const batch = this.#candidates.slice(this.#rendered, this.#rendered + InstallPackage.#BATCH_CONFIG.SIZE);
    if ( !batch.length ) return false;
    const html = await foundry.applications.handlebars.renderTemplate(
      "templates/setup/install-package/category.hbs", {entries: this.#prepareRows(batch)});
    const end = this.element?.querySelector(".load-more");
    if ( !end || (version !== this.#filterVersion) ) return false;
    this.#rendered += batch.length;
    end.insertAdjacentHTML("beforebegin", html);
    return true;
  }

  /* -------------------------------------------- */
  /*  Filtering                                   */
  /* -------------------------------------------- */

  /**
   * Apply the visibility, category, system, provider, and text filters to the package list, then re-render it.
   * @protected
   */
  _applyFilters() {
    this.#filterPackages();
    return this.render({parts: ["main"]});
  }

  /* -------------------------------------------- */

  /**
   * Determine which packages match the active filters, and how many matches each category has.
   */
  #filterPackages() {
    this.#filterVersion++;
    const rgx = this.#search.rgx;
    const {categories, providers, systems, visibility} = this._filterState;
    const showAll = visibility === "all";
    const installedOnly = visibility === "inst";
    const installedSystems = new Set(game.systems.keys());
    const included = [];
    const excluded = [];
    for ( const [id, state] of categories ) (state > 0 ? included : excluded).push(id);

    this.#candidates = [];
    this.#counts = {};
    for ( const pkg of this.#packages ) {
      if ( !showAll && (installedOnly !== pkg.installed) ) continue;
      if ( !included.every(id => pkg.tags.includes(id)) ) continue;
      if ( excluded.some(id => pkg.tags.includes(id)) ) continue;
      if ( providers.size && !providers.has(pkg.author) ) continue;
      if ( !this.#matchesSystems(pkg.systems, installedSystems, systems) ) continue;
      if ( rgx && !this.#matchesText(pkg, rgx) ) continue;
      this.#candidates.push(pkg);
      for ( const tag of pkg.tags ) this.#counts[tag] = (this.#counts[tag] ?? 0) + 1;
    }
  }

  /* -------------------------------------------- */

  /**
   * Update the sidebar category counts, tri-state glyphs, and styling.
   */
  #updateCategoryControls() {
    const {categories} = this._filterState;
    for ( const button of this.element.querySelectorAll("nav.tabs button[data-category]") ) {
      const reset = "reset" in button.dataset;
      const id = button.dataset.category;
      const state = reset ? 0 : (categories.get(id) ?? 0);
      const count = reset ? this.#candidates.length : (this.#counts[id] ?? 0);
      button.querySelector(":scope > span[data-count]").innerText = `[${count}]`;
      button.querySelector(":scope > i.status").className = `status fa-solid${
        state > 0 ? " fa-check" : state < 0 ? " fa-xmark" : ""
      }`;
      button.classList.toggle("filter-in", state > 0);
      button.classList.toggle("filter-out", state < 0);
      button.classList.toggle("no-matches", (state >= 0) && (count === 0));
      button.classList.toggle("active", reset && this.#isDefaultCategoryState());
    }
  }

  /* -------------------------------------------- */

  /**
   * Test whether a package matches the active system filter.
   * @param {string[]} requires         The IDs of the systems the package requires.
   * @param {Set<string>} installed     The IDs of the installed systems.
   * @param {object} filter             The active system filter state.
   * @param {string} filter.mode        The filter mode.
   * @param {Set<string>} filter.value  The IDs of the explicitly selected systems.
   * @returns {boolean}
   */
  #matchesSystems(requires, installed, filter) {
    switch ( filter.mode ) {
      case "installed": return !requires.length || requires.some(id => installed.has(id));
      case "specific": return !filter.value.size || requires.some(id => filter.value.has(id));
      default: return true;
    }
  }

  /* -------------------------------------------- */

  /**
   * Test whether a package matches the active text query.
   * @param {object} pkg  The package record.
   * @param {RegExp} rgx  The compiled search expression.
   * @returns {boolean}
   */
  #matchesText(pkg, rgx) {
    if ( SearchFilter.testQuery(rgx, pkg.label) ) return true;
    if ( SearchFilter.testQuery(rgx, pkg.hint) ) return true;
    if ( SearchFilter.testQuery(rgx, pkg.id) ) return true;
    if ( SearchFilter.testQuery(rgx, pkg.author) ) return true;
    return false;
  }

  /* -------------------------------------------- */

  /**
   * The default category filter state, applied on open and restored by the "All Packages" reset. Archived packages
   * are filtered out by default.
   * @returns {Map<string, 1|-1>}
   */
  static #defaultCategoryState() {
    return new Map([["archived", -1]]);
  }

  /* -------------------------------------------- */

  /**
   * Is the current category filter state equal to the default?
   * @returns {boolean}
   */
  #isDefaultCategoryState() {
    const current = this._filterState.categories;
    const defaults = InstallPackage.#defaultCategoryState();
    if ( current.size !== defaults.size ) return false;
    for ( const [id, state] of defaults ) if ( current.get(id) !== state ) return false;
    return true;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    const input = event.target;
    switch ( input.name ) {
      case "providers":
        this._filterState.providers = new Set(input.value);
        break;
      case "systems.mode":
        this._filterState.systems.mode = input.value;
        this.element.querySelector(".form-group.required-systems").hidden = input.value !== "specific";
        break;
      case "systems.value":
        this._filterState.systems.value = new Set(input.value);
        break;
      default: return;
    }
    this._applyFilters();
  }

  /* -------------------------------------------- */

  /**
   * Handle a click event on a category. Left-click advances its tri-state constraint forwards, right-click backwards.
   * @this {InstallPackage}
   * @type {ApplicationClickAction}
   */
  static #onToggleCategory(event, target) {
    if ( "reset" in target.dataset ) this._filterState.categories = InstallPackage.#defaultCategoryState();
    else this.#cycleCategory(target.dataset.category, event.button === 2 ? -1 : 1);
    this._applyFilters();
  }

  /* -------------------------------------------- */

  /**
   * Advance a category through its tri-state cycle: neutral -> filter-in -> filter-out -> neutral.
   * @param {string} id  The category id.
   * @param {1|-1} dir   The direction to cycle.
   */
  #cycleCategory(id, dir) {
    const order = [0, 1, -1];
    const {categories} = this._filterState;
    const next = order[(order.indexOf(categories.get(id) ?? 0) + dir + order.length) % order.length];
    if ( next ) categories.set(id, next);
    else categories.delete(id);
  }

  /* -------------------------------------------- */

  /** @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() ?? "";
    this.#manifestURL = manifestUrl;
    document.getElementById(`${this.id}-manifestUrl`).value = manifestUrl;
  }

  /* -------------------------------------------- */

  /**
   * Handle left-click events to filter to a certain visibility state.
   * @this {InstallPackage}
   * @type {ApplicationClickAction}
   */
  static #onFilterVisibility(event) {
    this._filterState.visibility = event.target.dataset.filter;
    this._applyFilters();
  }

  /* -------------------------------------------- */

  /**
   * 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.#clearPackageData();
    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;
    this.#manifestURL = input.value;
    const url = this.#manifestURL.trim();
    if ( !url) {
      input.disabled = button.disabled = false;
      return;
    }
    this.#clearPackageData();

    // 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 {
      this.#manifestURL = 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 = _loc(`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.parse(url);
    if ( parsedUrl && CONST.PACKAGE_TYPES.some(t => parsedUrl.pathname.endsWith(`/${t}.json`))
      && !parsedUrl.pathname.endsWith(`/${this.#packageType}.json`) ) {
      throw new Error(_loc("SETUP.INSTALL_PACKAGE.ManifestWrongPackageType", {packageType}));
    }

    // Load package manifest and compatibility data
    const packageCls = foundry.packages.PACKAGE_TYPES[this.#packageType];
    let pkg;
    try {
      pkg = await packageCls.fromRemoteManifest(url, {strict: true});
    }
    catch(error) {
      if ( (error instanceof DataModelValidationError) && error.cause?.fields?.type ) {
        throw new Error(_loc("SETUP.INSTALL_PACKAGE.ManifestWrongPackageType", {packageType}));
      }
      else throw error;
    }

    // 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: [
          _loc("SETUP.INSTALL_PACKAGE.ManifestIncompatible.Warning.0", {packageType}),
          _loc("SETUP.INSTALL_PACKAGE.ManifestIncompatible.Warning.1")
        ].map(l => `<p>${l}</p>`).join("\n"),
        no: {default: true}
      });
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Discard the prepared package data, so that the next render rebuilds it.
   */
  #clearPackageData() {
    this.#filterVersion++;
    this.#packages = [];
    this.#candidates = [];
    this.#counts = {};
    this.#models = {};
    this.#rendered = 0;
    this.#tags = {};
  }

  /* -------------------------------------------- */

  /**
   * Organize package data and cache it to the application. Only the fields which identify a package or take part in
   * filtering are prepared here.
   * @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: _loc("SETUP.PackageVisAll"), [type]: true}
    };
    for ( const pack of game.packageCache[type].packages.values() ) {

      const tags = [...pack.tags];
      if ( pack.protected ) {
        if ( pack.owned ) tags.push("owned");
        tags.push("premium");
      }
      else if ( pack.exclusive ) tags.push("exclusive");
      for ( const tag of tags ) unorderedTags[tag] ??= {label: InstallPackage.#getTagLabel(tag), [type]: true};

      packages.push({
        tags,
        author: pack.authors[0]?.name ?? "",
        availability: pack.availability,
        compatibility: pack.compatibility,
        hint: pack.description,
        id: pack.id,
        installed: game[`${type}s`].has(pack.id),
        label: pack.title,
        manifest: pack.manifest,
        owned: pack.owned,
        protected: pack.protected,
        systems: Array.from(pack.relationships.systems).map(s => s.id),
        url: pack.url,
        version: pack.version
      });
    }

    // 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};
  }

  /* -------------------------------------------- */

  /**
   * Prepare the data required to display a set of packages, for those packages which are about to be rendered.
   * @param {object[]} records  Package records
   * @returns {object[]}
   */
  #prepareRows(records) {
    const systemTitles = InstallPackage.#getSystemTitles();
    const CODES = CONST.PACKAGE_AVAILABILITY_CODES;
    return records.map(record => {
      const {availability, compatibility} = record;

      // Packages which have no release for the current core version are listed, but cannot be installed.
      let requiresUpgrade = false;
      switch ( availability ) {
        case CODES.REQUIRES_CORE_UPGRADE_STABLE:
        case CODES.REQUIRES_CORE_UPGRADE_UNSTABLE:
        case CODES.REQUIRES_CORE_UPGRADE_UNKNOWN:
          requiresUpgrade = true;
      }
      const requiresDowngrade = availability === CODES.REQUIRES_CORE_DOWNGRADE;
      const row = {
        ...record,
        button: {label: "", tooltip: ""},
        installable: !requiresUpgrade && !requiresDowngrade,
        systemList: record.systems.join(" "),
        systemTag: InstallPackage.#prepareSystemTag(record.systems, systemTitles),
        tagList: record.tags.join(" "),
        verified: compatibility?.verified
          ? {label: compatibility.verified, type: this.#getPackage(record.id)?.getVersionBadge()?.type}
          : null
      };

      // Possibly modify package installability.
      if ( requiresUpgrade ) {
        row.button.label = _loc("SETUP.PackageRequiresUpgradeLabel");
        row.button.tooltip = _loc("SETUP.PackageRequiresUpgradeHint", {vmin: compatibility.minimum});
      } else if ( requiresDowngrade ) {
        row.button.label = _loc("SETUP.PackageRequiresDowngradeLabel");
        row.button.tooltip = _loc("SETUP.PackageRequiresDowngradeHint", {vmax: compatibility.maximum});
      }
      if ( record.protected && !record.owned && row.installable ) {
        row.installable = false;
        if ( record.owned === null ) {
          row.button.label = _loc("SETUP.PackageOwnershipUnknown");
          row.button.tooltip = _loc("SETUP.PackageOwnershipUnknownHint");
        }
        else row.button.label = _loc("SETUP.PackageNotOwned");
      }
      return row;
    });
  }

  /* -------------------------------------------- */

  /**
   * Wrap a cached package's data in a package instance and cache it.
   * @param {string} id                The package ID.
   * @returns {ClientPackage|null}     The package instance, or null if its data was invalid.
   */
  #getPackage(id) {
    if ( id in this.#models ) return this.#models[id];
    const cls = foundry.packages.PACKAGE_TYPES[this.#packageType];
    try {
      return this.#models[id] = new cls(game.packageCache[this.#packageType].packages.get(id));
    } catch(err) {
      console.warn(`Could not prepare ${this.#packageType} "${id}" for display:`, err);
      return this.#models[id] = null;
    }
  }

  /* -------------------------------------------- */

  /**
   * The label displayed for a package category.
   * @param {string} tag  The category tag
   * @returns {string}
   */
  static #getTagLabel(tag) {
    switch ( tag ) {
      case "exclusive": return _loc("SETUP.ExclusiveContent");
      case "owned": return _loc("SETUP.PackageVisOwned");
      case "premium": return _loc("SETUP.PremiumContent");
    }
    const key = `PACKAGE.TAGS.${tag}`;
    return game.i18n.has(key) ? _loc(key) : tag.split("-").join(" ").titleCase();
  }

  /* -------------------------------------------- */

  /**
   * System titles, keyed by ID.
   * @returns {Map<string, string>}
   */
  static #getSystemTitles() {
    const titles = new Map();
    for ( const system of game.packageCache.system.packages.values() ) titles.set(system.id, system.title);
    return titles;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the tag which reports the systems a package requires.
   * @param {string[]} systems            The ids of the required systems.
   * @param {Map<string, string>} titles  System titles, keyed by ID.
   * @returns {{label: string, tooltip: string|null}|null}
   */
  static #prepareSystemTag(systems, titles) {
    if ( !systems.length ) return null;
    const names = systems.map(id => titles.get(id) ?? id);
    if ( systems.length === 1 ) return {label: names[0], tooltip: null};
    return {
      label: _loc("SETUP.SYSTEM_FILTER.RequiresN", {count: systems.length}),
      tooltip: InstallPackage.listFormatter.format(names)
    };
  }

  /* -------------------------------------------- */

  /**
   * Sorting logic which pins the "all", "owned", "premium", and "exclusive" categories ahead of the alphabetical
   * remainder.
   * @param {{id: string; label: string}} a
   * @param {{id: string; label: string}} b
   * @returns {number}
   */
  #sortCategories(a, b) {
    return ((b.id === "all") - (a.id === "all"))
      || ((b.id === "owned") - (a.id === "owned"))
      || ((b.id === "premium") - (a.id === "premium"))
      || ((b.id === "exclusive") - (a.id === "exclusive"))
      || a.label.localeCompare(b.label, game.i18n.lang);
  }
}

/**
 * @import {ApplicationClickAction} from "@client/applications/_types.mjs";
 */

/**
 * An application finalizing a server shutdown.
 * @extends ApplicationV2
 * @mixes HandlebarsApplication
 */
class ShuttingDown extends HandlebarsApplicationMixin(ApplicationV2) {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    id: "shutting-down",
    window: {
      frame: false,
      positioned: false
    },
    actions: {
      goBack: ShuttingDown.#onGoBack
    }
  };

  /** @override */
  static PARTS = {
    main: {
      id: "shutting-down",
      template: "templates/setup/shutting-down.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    window.setTimeout(async () => {
      const response = await fetchWithTimeout(getRoute("quit"), {method: "POST"});
      if ( !response.ok ) throw new Error(await response.text());
      game.socket.close();
    }, 5_000);
  }

  /* -------------------------------------------- */

  /**
   * Check that the server is back up and go back to the Setup view if so.
   * @type {ApplicationClickAction}
   * @this {ShuttingDown}
   */
  static async #onGoBack(event) {
    event.preventDefault();
    event.stopPropagation();
    const url = getRoute("");
    try {
      await fetchWithTimeout(url, {method: "HEAD"});
      window.location.href = url;
    }
    catch {
      ui.notifications.error("SETUP.ShuttingDown.Unavailable", {localize: true});
    }
  }
}

/**
 * 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
      ? "COMMON.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>${_loc(confirmLabel)}</span>
        </button>
        <button type="button" data-action="cancel" class="no">
          <i class="fa-solid fa-xmark"></i><span>${_loc("COMMON.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"' : ""}>${_loc(this.options.message)}</p>
      ${this.options.packageList ? `
        <p>${_loc("SETUP.BACKUPS.CreateSnapshotPackageList")}</p>
        <ul>
          <li>${_loc("SETUP.BACKUPS.WorldCount", { count: game.worlds.size })}</li>
          <li>${_loc("SETUP.BACKUPS.ModuleCount", { count: game.modules.size })}</li>
          <li>${_loc("SETUP.BACKUPS.SystemCount", { count: game.systems.size })}</li>
        </ul>
      ` : ""}
      ${unchanged ? `
        <p>${_loc(`SETUP.BACKUPS.RestoreSnapshotUnchangedPackages${unchanged === 1 ? "" : "Pl"}`, { count: unchanged })}</p>
      ` : ""}
      <div class="disk-space">
        <em>${_loc("SETUP.BACKUPS.DiskSpace")}:</em>
        <span>
          ${this.#diskSpace ? `
            ${_loc("SETUP.BACKUPS.DiskSpaceRequired", { required: this.#diskSpace.required })}
            &sol;
            ${_loc("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>${_loc("SETUP.BACKUPS.NoteHint")}</p>
        <input class="dark" type="text" autocomplete="off" name="note">
      ` : ""}
      ${this.#confirmCode ? `
        <p>${_loc("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="${_loc("SETUP.ConfirmCodeLabel", { code: this.#confirmCode })}">
      ` : ""}
      ${this.options.confirm ? `<p>${_loc(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"
 * @import {DataField} from "@common/data/fields.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 package data from the web server.
   * @type {{
   *   system: {packages: Map<string, PackageManifestData>, state: CACHE_STATES},
   *   module: {packages: Map<string, PackageManifestData>, state: CACHE_STATES}
   * }}
   */
  packageCache = {
    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", 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"}
    });
    this.settings.register("core", "backupBeforeRestore", {
      scope: "client",
      config: false,
      type: Boolean,
      default: true
    });
  }

  /* -------------------------------------------- */

  /** @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":
      case "quit": req = "getAuthData"; break;
      case "join": req = "getJoinData"; break;
      case "create": req = "getCreateData"; 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 "create":
        return this.#createView();
      case "update":
        return this.#updateView();
      case "quit":
        return this.#quitView();
      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 || this.data.licenseInvalid ) 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.Autocomplete.implementation.activateListeners();
    foundry.applications.ux.ContextMenu.implementation.activateListeners();
    ProseMirror.ProseMirrorMenu.activateListeners();

    // 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 view for creating a new World.
   */
  #createView() {
    if ( !SIGNED_EULA ) location.href = foundry.utils.getRoute("license");
    this.#activateSocketListeners();
    FontConfig._loadFonts();
    this.initializeKeyboard();
    foundry.applications.ux.ContextMenu.implementation.activateListeners();
    foundry.prosemirror.ProseMirrorMenu.activateListeners();
    ui.create = new WorldCreate();
    ui.create.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();
  }

  /* -------------------------------------------- */

  /**
   * The quit view, informing the user that the server is shutting down.
   */
  #quitView() {
    const app = new ShuttingDown();
    app.render({force: true});
  }

  /* -------------------------------------------- */
  /*  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, PackageManifestData>>}
   */
  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
    for ( const p of response ) packages.set(p.id, p);
    this.packageCache[type].packages = packages;
    return packages;
  }

  /* -------------------------------------------- */

  /**
   * Get all adventures eligible for quick-starting.
   * @returns {Record<string, { module: Module, systems: Set<string> }>}
   */
  getQuickstartAdventures() {
    return game.modules.reduce((obj, m) => {
      const { id, packs, quickstart } = m;
      if ( !quickstart ) return obj;
      const descriptor = { module: m };
      if ( foundry.utils.isEmpty(quickstart.adventures) ) descriptor.systems = packs.reduce((set, p) => {
        if ( (p.type === "Adventure") && p.system ) set.add(p.system);
        return set;
      }, new Set());
      else descriptor.systems = new Set(Object.keys(quickstart.adventures));
      obj[id] = descriptor;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * 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?
   * @param {boolean} [options.dependencies=true]  Attempt to resolve and install this package's dependencies.
   * @param {function} [options.onProgress]  A callback to invoke on each progress tick.
   * @returns {Promise<foundry.packages.BasePackage>} A Promise which resolves to the installed package
   */
  async installPackage({type="module", id, manifest, notify=true, force=false, dependencies=true, onProgress}={}) {
    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});
    if ( onProgress ) progress.addEventListener("progress", onProgress);
    let response;
    try {
      response = await progress.listen();
    } catch(err) {
      response = err;
    }

    // Handle failed installation
    if ( Error.isError(response) ) {
      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: _loc(`PACKAGE.Type.${type}`)}});
    }

    // Trigger dependency installation (asynchronously)
    if ( dependencies && 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 behavior. Additional options are
   *                                   forwarded to installPackage.
   * @param {boolean} [options.allowManual=true]  Allow the user to opt-out of automatic dependency installation.
   * @returns {Promise<boolean|null>}  Returns true if all required dependencies are satisfied, false if they are not,
   *                                   or null if the dialog was dismissed.
   */
  async installDependencies(pkg, { allowManual=true, ...installOptions }={}) {
    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 true;

    // 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
      ? _loc("SETUP.PackageHasDependenciesSingular", {title: pkg.title})
      : _loc("SETUP.PackageHasDependenciesPlural", {title: pkg.title, number: data.totalDependencies});
    data.autoInstallLabel = canInstall === 1
      ? _loc("SETUP.PackageDependenciesCouldInstallSingular")
      : _loc("SETUP.PackageDependenciesCouldInstallPlural", {number: canInstall});
    data.manualInstallLabel = cantInstall === 1
      ? _loc("SETUP.PackageDependenciesCouldNotInstallSingular")
      : _loc("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;
    const 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, ...installOptions,
            dependencies: false
          });
          installed++;
        }
        ui.notifications.info("SETUP.PackageDependenciesSuccess", {
          format: {title: pkg.title, number: String(installed)}
        });
        return true;
      },
      default: true
    }];
    if ( allowManual ) buttons.push({
      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}});
          return false;
        }
        return true;
      }
    });
    return foundry.applications.api.DialogV2.wait({
      id: "setup-install-dependencies",
      window: {title: "SETUP.PackageDependenciesTitle"},
      position: {width: 600},
      modal: true,
      content, buttons
    });
  }


  /* -------------------------------------------- */

  /**
   * @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 ) return;
    const existing = dependencyChecks.get(relatedPackage.id);
    if ( existing ) {
      if ( required ) {
        existing.required = true;
        existing.note = _loc("SETUP.RequiredPackageNode");
      }
      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: _loc(required ? "SETUP.RequiredPackageNote" : "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});
      const listedPackage = packages.get(relatedPackage.id);
      // Need to retrieve the manifest otherwise relationships are blank, as the website listing does not populate them.
      const packageCls = foundry.packages.PACKAGE_TYPES[relatedPackage.type];
      dependency = await packageCls.fromRemoteManifest(listedPackage.manifest);
      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 = listedPackage.manifest;
      dependencyCheck.manifest = listedPackage.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;
      const exceedsMaximum = foundry.utils.isNewerVersion(dependencyVersion, maximum, {
        majorOnly: Number.isInteger(Number(maximum))
      });
      return !(maximum && exceedsMaximum);
    }

    // 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 = _loc(`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 = _loc("SETUP.PackageDeleteTitle", {type: typeLabel, title: pkg.title ?? pkg.id});
    const question = _loc("SETUP.PackageDeleteConfirm", {type: typeLabel,
      title: escapeHTML(pkg.title ?? pkg.id)
    });
    let content = `<p>${question}</p>`;
    let confirm;
    if ( pkg.type === "world" ) {
      content += `<p class="notification warning">${_loc("SETUP.WorldDeleteConfirmWarning")}</p>`;
      ({confirm} = await Setup.#confirmCodeDialog({title, content}));
    } else {
      if ( pkg.hasStorage ) content += `<p>${_loc("SETUP.PackageDeletePersistent")}</p>`;
      content += `<p class="notification warning"><span>${_loc("SETUP.PackageDeleteNoUndo")}</span></p>`;
      confirm = await foundry.applications.api.DialogV2.confirm({
        window: {title},
        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(`${_loc("SETUP.UninstallFailure")}: ${escapeHTML(err.message)}`);
      throw err;
    }

    // Finalize the uninstallation
    foundry.packages.PACKAGE_TYPES[pkg.type].uninstall(pkg.id);
    ui.notifications.info(`${typeLabel} ${pkg.id} ${_loc("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<boolean>}        Whether the backup was created.
   */
  async createBackup({ type, id, title }, { note, dialog=false }={}) {
    if ( dialog ) {
      const result = await Setup.#createBackupDialog(title);
      if ( !result.confirm ) return false;
      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: _loc("SETUP.BACKUPS.CreateBackupComplete", {title: escapeHTML(title)}),
      failureMessage: _loc("SETUP.BACKUPS.CreateBackupFailure", {title: escapeHTML(title)})
    });
    progress.addEventListener("progress", ({ data }) => packet = data);
    const response = await progress.listen();
    const success = !Error.isError(response);
    if ( this.backupsCache && success ) {
      this.backupsCache[type] ??= {};
      this.backupsCache[type][id] ??= [];
      this.backupsCache[type][id].unshift(response.backupData);
    }
    this.toggleLock(false);
    return success;
  }

  /* -------------------------------------------- */

  /**
   * 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: _loc("SETUP.BACKUPS.CreateSnapshotComplete"),
      failureMessage: _loc("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: _loc("SETUP.BACKUPS.DeleteBackupTitle", { title }),
        content: `<p>${_loc(`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: _loc("SETUP.BACKUPS.DeleteBackupFailure", {title: escapeHTML(title)}),
      successMessage: _loc(`SETUP.BACKUPS.DeleteBackupComplete${count === 1 ? "" : "Pl"}`,
        {title: 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: _loc("SETUP.BACKUPS.DeleteSnapshotTitle"),
        content: `
          <p>${_loc(`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: _loc("SETUP.BACKUPS.DeleteSnapshotFailure"),
      successMessage: _loc(`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, type, packageId } = backupData;
    let backupFirst = false;
    if ( dialog ) {
      const canBackup = !this.data.options.noBackups;
      const fields = {};
      if ( canBackup ) {
        fields.backupBeforeRestore = new foundry.data.fields.BooleanField({
          hint: "SETUP.BACKUPS.BackupBeforeRestoreHint",
          initial: this.settings.get("core", "backupBeforeRestore"),
          label: "SETUP.BACKUPS.BackupBeforeRestore"
        });
      }
      const warning = `SETUP.BACKUPS.RestoreBackupWarning${canBackup ? "" : "NoBackups"}`;
      const result = await Setup.#confirmCodeDialog({
        fields,
        content: `<p class="notification warning">${_loc(warning)}</p>`,
        title: _loc("SETUP.BACKUPS.RestoreBackupTitle", {title})
      });
      if ( !result.confirm ) return;
      if ( canBackup ) {
        backupFirst = result.fields.backupBeforeRestore;
        await this.settings.set("core", "backupBeforeRestore", backupFirst);
      }
    }

    // Take a backup of the package's current state so that the restoration can itself be rolled back.
    if ( backupFirst ) {
      const pkg = foundry.packages.PACKAGE_TYPES[type].get(packageId);
      const backedUp = pkg && await this.createBackup(pkg, { note: _loc("SETUP.BACKUPS.BackupBeforeRestoreNote") });
      if ( !backedUp ) 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: _loc("SETUP.BACKUPS.RestoreBackupFailure", {title: escapeHTML(title)}),
      successMessage: _loc("SETUP.BACKUPS.RestoreBackupComplete", {
        title: 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: _loc("SETUP.BACKUPS.RestoreSnapshotFailure"),
      successMessage: _loc("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} ${_loc(String(error))}`;
      if ( notify ) ui.notifications.error(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} ${_loc(error)}`);
      Object.assign(err, data);
      if ( notify ) ui.notifications.error(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));
  }

  /* -------------------------------------------- */

  /**
   * @typedef ConfirmCodeResult
   * @property {boolean} confirm  Whether the user chose to proceed and entered the correct code.
   * @property {object} fields    The submitted values of any additional fields, keyed by field name.
   */

  /**
   * 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.
   * @param {Record<string, DataField>} [options.fields]  Additional fields to present below the content, keyed by name.
   * @returns {Promise<ConfirmCodeResult>}
   */
  static async #confirmCodeDialog({title, content, fields}={}) {
    const code = (Math.random() + 1).toString(36).substring(7, 11);
    const groups = Object.entries(fields ?? {}).map(([name, field]) => {
      return field.toFormGroup({ localize: true }, { name, value: field.getInitialValue({}) }).outerHTML;
    });
    content = `
      ${content ?? ""}
      ${groups.join("")}
      <p>${_loc("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="${_loc("SETUP.ConfirmCodeLabel", {code})}" required autofocus>
    `;
    const result = await foundry.applications.api.DialogV2.confirm({
      window: {title},
      position: {width: 480},
      content,
      yes: {
        callback: (event, button) => {
          const confirm = button.form.querySelector("#delete-confirm")?.value;
          if ( confirm !== code ) {
            ui.notifications.error("SETUP.PackageDeleteWorldConfirm", {localize: true});
            return { confirm: false, fields: {} };
          }
          return { confirm: true, fields: new foundry.applications.ux.FormDataExtended(button.form).object };
        }
      },
      no: {
        callback: () => ({ confirm: false, fields: {} })
      }
    });
    return result ?? { confirm: false, fields: {} };
  }

  /* -------------------------------------------- */

  /**
   * @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: _loc("SETUP.BACKUPS.CreateBackup", {title})},
      position: { width: 480 },
      content: `
        <p>${_loc("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,
  ShuttingDown: ShuttingDown,
  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$V,
  applications: _module$B,
  appv1: _module$y,
  audio: _module$v,
  av: _module$w,
  canvas: _module$8,
  config: config,
  data: _module$X,
  dice: _module$6,
  documents: _module$_,
  grid: _module$T,
  helpers: _module$4,
  nue: _module$2,
  packages: _module$W,
  prosemirror: _module$U,
  setup: _module,
  types: _types,
  ui: ui$2,
  utils: _module$11
});

/**
 * 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 {ParticleGeneratorConfiguration} from "./canvas/animation/_types.mjs";
 * @import ParticleEffect from "./canvas/containers/elements/particles/particle-effect.mjs";
 * @import RollResolver from "./applications/dice/roll-resolver.mjs";
 * @import {ActorTrackableAttributes, TokenMovementActionConfig,
 *   TokenMovementActionConfigDescriptor} from "./_types.mjs";
 * @import {ActiveEffectChangeData, ActiveEffectChangeHandler, ActiveEffectChangeRenderer, ActiveEffectDuration,
 *   TokenMovementCostAggregator} from "./documents/_types.mjs";
 * @import ActiveEffectConfig from "./applications/sheets/active-effect-config.mjs";
 * @import ActiveEffectRegistry from "./helpers/active-effect-registry.mjs";
 * @import {EnrichmentOptions, DocumentHTMLEmbedConfig} from "./applications/ux/text-editor.mjs";
 * @import {VFXComponent, VFXComponentAnimation, VFXPathGenerator} from "./canvas/vfx/_module.mjs";
 * @import Color from "@common/utils/color.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();

/**
 * @callback DocumentEmbedHandler
 * @param {Document} doc                                The Document being embedded.
 * @param {HTMLElement|null}  content                   The candidate element for embedding.
 * @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>}  The desired embed result, or null to prevent embedding.
 */

/**
 * Configuration for the Actor document
 */
const Actor = {
  documentClass: Actor$1,
  collection: Actors,
  /** @type {string[]} */
  compendiumIndexFields: [],
  compendiumBanner: "ui/banners/actor-banner.webp",
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  sidebarIcon: "fa-solid fa-user",
  /** @type {Record<string, typeof TypeDataModel>} */
  dataModels: {},
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeHints: {},
  /** @type {Record<string, string>} */
  typeIcons: {},
  /** @type {Record<string, ActorTrackableAttributes>} */
  trackableAttributes: {}
};

/**
 * Configuration for the Adventure document.
 */
const Adventure = {
  documentClass: Adventure$1,
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  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,
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  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>} */
  typeHints: {},
  /** @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,
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  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>} */
  typeHints: {},
  /** @type {Record<string, string>} */
  typeIcons: {},
  batchSize: 100,
  /**
   * Supported chat message visibility modes.
   * @type {Record<string, {label: string, icon: string, handler: (data:Partial<ChatMessageData>)=>void}>}
   */
  modes: {
    /** Out-of-character messages visible to all players. */
    public: {
      label: "CHAT.MODES.public",
      icon: "fa-solid fa-globe"
    },
    /** Messages visible between gamemasters and the sending user. */
    gm: {
      label: "CHAT.MODES.gm",
      icon: "fa-solid fa-user-secret"
    },
    /** Messages visible only to gamemasters and not to the sending user. */
    blind: {
      label: "CHAT.MODES.blind",
      icon: "fa-solid fa-eye-slash"
    },
    /** Messages visible only to the sending user. */
    self: {
      label: "CHAT.MODES.self",
      icon: "fa-solid fa-user"
    },
    /** In-character messages visible to all players. */
    ic: {
      label: "CHAT.MODES.ic",
      icon: "fa-solid fa-hat-wizard"
    }
  }
};

/**
 * Configuration for the Combat document
 */
const Combat = {
  documentClass: Combat$1,
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  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>} */
  typeHints: {},
  /** @type {Record<string, string>} */
  typeIcons: {},
  initiative: {
    formula: null,
    decimals: 2
  },
  fallbackTurnMarker: "canvas/tokens/turn-marker-square-circle-orange.webp",
  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],
  /**
   * 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",
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  sidebarIcon: "fa-solid fa-suitcase",
  /** @type {Record<string, typeof TypeDataModel>} */
  dataModels: {},
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeHints: {},
  /** @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",
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  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",
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  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",
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  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",
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  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$1 = {
  documentClass: Scene$2,
  collection: Scenes,
  /** @type {string[]} */
  compendiumIndexFields: [],
  compendiumBanner: "ui/banners/scene-banner.webp",
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  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.
 */

/**
 * @typedef SceneTransitionDefinition
 * @property {string} id
 * @property {string} label
 * @property {typeof canvas.rendering.filters.AbstractBaseFilter} filterClass
 * @property {string} filterType
 * @property {number} [defaultDuration]
 */


/**
 * 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"
    },
    /**
     * @deprecated since v14
     * @ignore
     */
    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.ANIMATION.Flame",
      animation: PointLightSource.prototype.animateFlickering,
      illuminationShader: FlameIlluminationShader,
      colorationShader: FlameColorationShader
    },
    torch: {
      label: "LIGHT.ANIMATION.Torch",
      animation: PointLightSource.prototype.animateTorch,
      illuminationShader: TorchIlluminationShader,
      colorationShader: TorchColorationShader
    },
    revolving: {
      label: "LIGHT.ANIMATION.Revolving",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: RevolvingColorationShader
    },
    siren: {
      label: "LIGHT.ANIMATION.Siren",
      animation: PointLightSource.prototype.animateTorch,
      illuminationShader: SirenIlluminationShader,
      colorationShader: SirenColorationShader
    },
    pulse: {
      label: "LIGHT.ANIMATION.Pulse",
      animation: PointLightSource.prototype.animatePulse,
      illuminationShader: PulseIlluminationShader,
      colorationShader: PulseColorationShader
    },
    reactivepulse: {
      label: "LIGHT.ANIMATION.ReactivePulse",
      animation: PointLightSource.prototype.animateSoundPulse,
      illuminationShader: PulseIlluminationShader,
      colorationShader: PulseColorationShader
    },
    chroma: {
      label: "LIGHT.ANIMATION.Chroma",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: ChromaColorationShader
    },
    wave: {
      label: "LIGHT.ANIMATION.Wave",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: WaveIlluminationShader,
      colorationShader: WaveColorationShader
    },
    fog: {
      label: "LIGHT.ANIMATION.Fog",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: FogColorationShader
    },
    sunburst: {
      label: "LIGHT.ANIMATION.Sunburst",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: SunburstIlluminationShader,
      colorationShader: SunburstColorationShader
    },
    dome: {
      label: "LIGHT.ANIMATION.LightDome",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: LightDomeColorationShader
    },
    emanation: {
      label: "LIGHT.ANIMATION.Emanation",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: EmanationColorationShader
    },
    hexa: {
      label: "LIGHT.ANIMATION.HexaDome",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: HexaDomeColorationShader
    },
    ghost: {
      label: "LIGHT.ANIMATION.GhostLight",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: GhostLightIlluminationShader,
      colorationShader: GhostLightColorationShader
    },
    energy: {
      label: "LIGHT.ANIMATION.EnergyField",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: EnergyFieldColorationShader
    },
    vortex: {
      label: "LIGHT.ANIMATION.Vortex",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: VortexIlluminationShader,
      colorationShader: VortexColorationShader
    },
    witchwave: {
      label: "LIGHT.ANIMATION.BewitchingWave",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: BewitchingWaveIlluminationShader,
      colorationShader: BewitchingWaveColorationShader
    },
    rainbowswirl: {
      label: "LIGHT.ANIMATION.SwirlingRainbow",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: SwirlingRainbowColorationShader
    },
    radialrainbow: {
      label: "LIGHT.ANIMATION.RadialRainbow",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: RadialRainbowColorationShader
    },
    fairy: {
      label: "LIGHT.ANIMATION.FairyLight",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: FairyLightIlluminationShader,
      colorationShader: FairyLightColorationShader
    },
    grid: {
      label: "LIGHT.ANIMATION.ForceGrid",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: ForceGridColorationShader
    },
    starlight: {
      label: "LIGHT.ANIMATION.StarLight",
      animation: PointLightSource.prototype.animateTime,
      colorationShader: StarLightColorationShader
    },
    smokepatch: {
      label: "LIGHT.ANIMATION.SmokePatch",
      animation: PointLightSource.prototype.animateTime,
      illuminationShader: SmokePatchIlluminationShader,
      colorationShader: SmokePatchColorationShader
    }
  },

  /** @type {DarknessSourceAnimationConfig} */
  darknessAnimations: {
    magicalGloom: {
      label: "LIGHT.ANIMATION.MagicalGloom",
      animation: PointDarknessSource.prototype.animateTime,
      darknessShader: MagicalGloomDarknessShader
    },
    roiling: {
      label: "LIGHT.ANIMATION.RoilingMass",
      animation: PointDarknessSource.prototype.animateTime,
      darknessShader: RoilingDarknessShader
    },
    hole: {
      label: "LIGHT.ANIMATION.BlackHole",
      animation: PointDarknessSource.prototype.animateTime,
      darknessShader: BlackHoleDarknessShader
    },
    denseSmoke: {
      label: "LIGHT.ANIMATION.DenseSmoke",
      animation: PointDarknessSource.prototype.animateTime,
      darknessShader: DenseSmokeDarknessShader
    }
  },

  /** @type {Record<string, SceneTransitionDefinition>} */
  sceneTransitions: {
    fade: {
      id: "fade",
      label: "SCENE.Transition.Types.fade",
      filterClass: TextureTransitionFilter,
      filterType: TextureTransitionFilter.TYPES.FADE,
      defaultDuration: 1500
    },
    swirl: {
      id: "swirl",
      label: "SCENE.Transition.Types.swirl",
      filterClass: TextureTransitionFilter,
      filterType: TextureTransitionFilter.TYPES.SWIRL,
      defaultDuration: 2000
    },
    waterDrop: {
      id: "waterDrop",
      label: "SCENE.Transition.Types.waterDrop",
      filterClass: TextureTransitionFilter,
      filterType: TextureTransitionFilter.TYPES.WATER_DROP,
      defaultDuration: 2000
    },
    morph: {
      id: "morph",
      label: "SCENE.Transition.Types.morph",
      filterClass: TextureTransitionFilter,
      filterType: TextureTransitionFilter.TYPES.MORPH,
      defaultDuration: 2000
    },
    crosshatch: {
      id: "crosshatch",
      label: "SCENE.Transition.Types.crosshatch",
      filterClass: TextureTransitionFilter,
      filterType: TextureTransitionFilter.TYPES.CROSSHATCH,
      defaultDuration: 1500
    },
    wind: {
      id: "wind",
      label: "SCENE.Transition.Types.wind",
      filterClass: TextureTransitionFilter,
      filterType: TextureTransitionFilter.TYPES.WIND,
      defaultDuration: 1500
    },
    waves: {
      id: "waves",
      label: "SCENE.Transition.Types.waves",
      filterClass: TextureTransitionFilter,
      filterType: TextureTransitionFilter.TYPES.WAVES,
      defaultDuration: 1000
    },
    whiteNoise: {
      id: "whiteNoise",
      label: "SCENE.Transition.Types.whiteNoise",
      filterClass: TextureTransitionFilter,
      filterType: TextureTransitionFilter.TYPES.WHITE_NOISE,
      defaultDuration: 1000
    },
    hologram: {
      id: "hologram",
      label: "SCENE.Transition.Types.hologram",
      filterClass: TextureTransitionFilter,
      filterType: TextureTransitionFilter.TYPES.HOLOGRAM,
      defaultDuration: 1000
    },
    hole: {
      id: "hole",
      label: "SCENE.Transition.Types.hole",
      filterClass: TextureTransitionFilter,
      filterType: TextureTransitionFilter.TYPES.HOLE,
      defaultDuration: 2500
    },
    holeSwirl: {
      id: "holeSwirl",
      label: "SCENE.Transition.Types.holeSwirl",
      filterClass: TextureTransitionFilter,
      filterType: TextureTransitionFilter.TYPES.HOLE_SWIRL,
      defaultDuration: 2000
    },
    glitch: {
      id: "glitch",
      label: "SCENE.Transition.Types.glitch",
      filterClass: TextureTransitionFilter,
      filterType: TextureTransitionFilter.TYPES.GLITCH,
      defaultDuration: 1500
    },
    dots: {
      id: "dots",
      label: "SCENE.Transition.Types.dots",
      filterClass: TextureTransitionFilter,
      filterType: TextureTransitionFilter.TYPES.DOTS,
      defaultDuration: 1500
    }
  },

  /**
   * A registry of Scenes which are managed by a specific SceneManager class or instance.
   * @type {Record<string, typeof canvas.SceneManager | 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, VisionMode>}
   */
  get visionModes() {
    // Wrap in self-replacing getters to avoid cyclic dependencies.
    const value = {

      // 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 }
        }
      })
    };
    Object.defineProperty(this, "visionModes", {value, configurable: true, writable: true, enumerable: true});
    return value;
  },

  /* -------------------------------------------- */

  /**
   * The set of DetectionMode definitions which are available to be used for visibility detection.
   * @type {Record<string, canvas.perception.DetectionMode>}
   */
  get detectionModes() {
    const value = {
      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
      })
    };
    Object.defineProperty(this, "detectionModes", {value, configurable: true, writable: true, enumerable: true});
    return value;
  },
  /**
   * @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};
  },

  /**
   * Configuration for the foundry.canvas.vfx module.
   * This configuration is used to register named components, animations, and presets which can be invoked within
   * VFXEffect instances.
   */
  vfx: {

    /**
     * The Foundry VTT version 14 VFX framework is EXPERIMENTAL. Its classes, functions, and configuration are likely
     * to change over coming releases. The entire VFX module should be treated as non-stable regardless of the
     * stability designation of the release build that contains it.
     * You may use this experimental framework by enabling it via `CONFIG.Canvas.vfx.enabled`.
     * @type {boolean}
     */
    enabled: false,

    /**
     * Named VFXComponent subclasses which can be used to orchestrate different types of visual effects.
     * @type {Record<string, typeof VFXComponent>}
     */
    components: {},

    /**
     * Named animations that may be called as part of a component.
     * @type {Record<string, VFXComponentAnimation>}
     */
    animations: {},

    /**
     * Named path generator functions that may be called as part of a component.
     * @type {Record<string, VFXPathGenerator>}
     */
    paths: {}
  }
};

/* -------------------------------------------- */

/**
 * 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 {ParticleGeneratorConfiguration} WeatherParticleConfiguration
 * @property {typeof foundry.canvas.animation.ParticleGenerator} [generatorClass]
 *   An optional ParticleGenerator subclass to instantiate. Defaults to ParticleGenerator.
 */

/**
 * @typedef WeatherEffectConfiguration
 * @property {string} id
 * @property {typeof canvas.rendering.shaders.WeatherShaderEffect} [effectClass]
 * @property {typeof canvas.rendering.shaders.AbstractWeatherShader} [shaderClass]
 * @property {PIXI.BLEND_MODES} [blendMode]
 * @property {object} [config]
 * @property {number} [performanceLevel]
 * @property {WeatherParticleConfiguration[]} [particles]
 */

/** @type {Record<string, WeatherAmbienceConfiguration>} */
const weatherEffects = {
  leaves: {
    id: "leaves",
    label: "WEATHER.AutumnLeaves",
    effects: [{
      id: "leavesParticles",
      particles: [{
        textures: Array.fromRange(6).map(n => `ui/particles/leaf${n + 1}.png`),
        count: 200,
        lifetime: 10000,
        viewPadding: 0.1,
        velocity: {speed: [12, 60], angle: [80, 100]},
        rotation: {speed: [100, 200]},
        alpha: [0.5, 0.9],
        scale: [0.1, 0.4],
        fade: {out: 0.3}
      }]
    }]
  },
  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",
  levels: "icons/svg/levels.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 {number} [order=0]                An integer indicating the order
 * @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 {{[id: string]: StatusEffectConfig}}
 */
const statusEffects = new Proxy([], {
  set(statuses, prop, value) {
    if ( prop === "length" ) {
      const newLength = value;
      for ( let i = newLength; i < statuses.length; i++ ) {
        const statusId = statuses[i]?.id;
        if ( statusId !== undefined ) delete statuses[statusId];
      }
      statuses.length = newLength;
      return true;
    }
    const index = Number(prop);
    if ( Number.isInteger(index) ) {
      const statusId = statuses.at(index)?.id;
      if ( statusId !== undefined ) delete statuses[statusId];
      const status = value;
      statuses[status.id] = status;
      statuses[index] = status;
      return true;
    }
    const statusId = prop;
    const status = value;
    if ( statusId in statuses ) {
      const index = statuses.findIndex(e => e.id === statusId);
      if ( index >= 0 ) statuses[index] = status;
    }
    else statuses.push(status);
    statuses[statusId] = status;
    return true;
  },
  deleteProperty(statuses, prop) {
    let statusId;
    const index = Number(prop);
    if ( Number.isInteger(index) ) {
      statusId = statuses.at(index)?.id;
      delete statuses[index];
    } else {
      statusId = prop;
      statuses.findSplice(e => e.id === statusId);
    }
    if ( statusId !== undefined ) delete statuses[statusId];
    return true;
  },
  ownKeys(statuses) {
    const keys = statuses.map(e => e.id);
    keys.push("length");
    return keys;
  }
});

for ( const effect of [
  {
    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"
  }
]) {
  statusEffects[effect.id] = effect;
}

/**
 * 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: 3,
  /**
   * Stop words used in Document and other textual searches
   */
  searchStopWords: new Set(["an", "and", "of", "or", "the"])
};

/* -------------------------------------------- */
/*  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 that automatically elapse at the end of a Combat turn.
   * @type {number}
   */
  turnTime: 0,

  /**
   * The number of seconds that 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,
    duration: CalendarData.formatDuration,
    ago: CalendarData.formatAgo
  }
};

/* -------------------------------------------- */
/*  Embedded Documents                          */
/* -------------------------------------------- */

/**
 * @typedef ActiveEffectChangeTypeConfig
 * @property {string} label
 * @property {number} defaultPriority
 * @property {ActiveEffectChangeHandler|null} [handler]
 * @property {ActiveEffectChangeRenderer|null} [render]
 */

/**
 * Configuration for the ActiveEffect embedded document type
 */
const ActiveEffect = {
  compendiumBanner: "ui/banners/active-effect-banner.webp",
  documentClass: ActiveEffect$1,
  /** @type {Record<string, typeof data.ActiveEffectTypeDataModel>} */
  dataModels: {base: ActiveEffectTypeDataModel},
  defaultType: "base",
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  /** @type {Record<string, string>} */
  typeLabels: {base: "TYPES.Base"},
  /** @type {Record<string, string>} */
  typeHints: {},
  /** @type {Record<string, string>} */
  typeIcons: {},
  sidebarIcon: "fa-solid fa-person-rays",
  /** @type {Record<string, ActiveEffectChangeTypeConfig>} */
  changeTypes: {},
  /**
   * Additional expiry events registered by packages, with the key of each entry being an identifier and the value a
   * label. Such events must be triggered by calling {@link documents.ActiveEffect.registry.refresh}. If an expiry event
   * happens in tandem with advancing world time, the advancement must be done before notifying the registry of the
   * custom event.
   * @type {Record<string, string>}
   */
  expiryEvents: {},
  /**
   * The action taken by the {@link ActiveEffectRegistry} upon an ActiveEffect's expiration. An update action will
   * set the value of {@link ActiveEffectDuration#expired}.
   * @type {"update"|"delete"|null}
   */
  expiryAction: "update",

  /**
   * Additional change phases registered by packages. Actor#applyActiveEffects must be called by introducing packages
   * at the desired points in data preparation or on certain events.
   * @type {Record<string, {label:string; hint: string}>}
   */
  phases: {}
};

/**
 * 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 {DocumentEmbedHandler[]} */
  embedHandlers: [],
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeHints: {},
  /** @type {Record<string, string>} */
  typeIcons: {}
};

/**
 * Configuration for the TableResult embedded document type
 */
const TableResult = {
  documentClass: TableResult$1,
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: []
};

/**
 * Configuration for the JournalEntryCategory embedded document type.
 */
const JournalEntryCategory = {
  documentClass: JournalEntryCategory$1,
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: []
};

/**
 * Configuration for the JournalEntryPage embedded document type.
 */
const JournalEntryPage = {
  documentClass: JournalEntryPage$1,
  /** @type {Record<string, typeof TypeDataModel>} */
  dataModels: {},
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeHints: {},
  /** @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,
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  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,
  sidebar: {
    applicationClass: AmbientLightTab,
    order: 500
  }
};

/**
 * Configuration for the AmbientSound embedded document type and its representation on the game Canvas
 */
const AmbientSound = {
  documentClass: AmbientSoundDocument,
  objectClass: AmbientSound$1,
  sidebar: {
    applicationClass: AmbientSoundTab,
    order: 600
  }
};

/**
 * Configuration for the Combatant embedded document type within a Combat document
 */
const Combatant = {
  documentClass: Combatant$1,
  /** @type {Record<string, typeof DataModel>} */
  dataModels: {},
  /** @type {DocumentEmbedHandler[]} */
  embedHandlers: [],
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeHints: {},
  /** @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 {DocumentEmbedHandler[]} */
  embedHandlers: [],
  /** @type {Record<string, string>} */
  typeLabels: {},
  /** @type {Record<string, string>} */
  typeHints: {},
  /** @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,
  hudClass: DrawingHUD,
  sidebar: {
    applicationClass: DrawingTab,
    order: 300
  }
};

/**
 * @deprecated since v14
 * @ignore
 */
const MeasuredTemplate = {
  defaults: {
    angle: 53.13,
    width: 1
  },
  documentClass: MeasuredTemplateDocument,
  objectClass: MeasuredTemplate$1,
  sheetClasses: {
    base: {
      "core.MeasuredTemplateConfig": {
        id: "core.MeasuredTemplateConfig",
        label: "Default Measured Template Sheet",
        themes: {
          dark: "SETTINGS.UI.FIELDS.colorScheme.choices.dark",
          light: "SETTINGS.UI.FIELDS.colorScheme.choices.light"
        },
        canBeDefault: true,
        canConfigure: true,
        default: true,
        cls: MeasuredTemplateConfig
      }
    }
  },
  /**
   * @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,
  sidebar: {
    applicationClass: NoteTab,
    order: 800
  }
};

/**
 * Configuration for the Region embedded document type and its representation on the game Canvas
 */
const Region = {
  documentClass: RegionDocument,
  objectClass: Region$1,
  sidebar: {
    applicationClass: RegionTab,
    order: 700
  }
};

/**
 * Configuration for the RegionBehavior embedded document type
 */
const RegionBehavior = {
  documentClass: RegionBehavior$1,
  /** @type {Record<string, typeof data.regionBehaviors.RegionBehaviorType>} */
  dataModels: {
    adjustDarknessLevel: AdjustDarknessLevelRegionBehaviorType,
    applyActiveEffect: ApplyActiveEffectRegionBehaviorType,
    changeLevel: ChangeLevelRegionBehaviorType,
    defineSurface: DefineSurfaceRegionBehaviorType,
    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>} */
  typeHints: {},
  /** @type {Record<string, string>} */
  typeIcons: {
    adjustDarknessLevel: "fa-solid fa-circle-half-stroke",
    applyActiveEffect: "fa-solid fa-person-rays",
    changeLevel: "fa-solid fa-stairs",
    defineSurface: "fa-solid fa-person-arrow-down-to-line",
    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 Level embedded document type and its representation on the game Canvas.
 */
const Level = {
  documentClass: Level$1
};

/**
 * Configuration for the Tile embedded document type and its representation on the game Canvas
 */
const Tile = {
  documentClass: TileDocument,
  objectClass: Tile$1,
  hudClass: TileHUD,
  sidebar: {
    applicationClass: TileTab,
    order: 200
  }
};

/**
 * Configuration for the Token embedded document type and its representation on the game Canvas
 */
const Token = {
  documentClass: TokenDocument,
  objectClass: Token$1,
  prototypeSheetClass: PrototypeTokenConfig,
  hudClass: TokenHUD,
  rulerClass: TokenRuler,
  sidebar: {
    applicationClass: TokenTab,
    order: 100
  },
  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]: TokenMovementActionConfigDescriptor}}
     */
    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,
        speedMultiplier: 0.5
      },
      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,
        speedMultiplier: 0.5,
        terrainAction: "walk",
        costMultiplier: 2
      },
      climb: {
        label: "TOKEN.MOVEMENT.ACTIONS.climb.label",
        icon: "fa-solid fa-person-through-window",
        img: "icons/svg/ladder.svg",
        order: 5,
        speedMultiplier: 0.5,
        terrainAction: "walk",
        costMultiplier: 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),
        costMultiplier: 2
      },
      blink: {
        label: "TOKEN.MOVEMENT.ACTIONS.blink.label",
        icon: "fa-solid fa-person-from-portal",
        img: "icons/svg/teleport.svg",
        order: 7,
        teleport: true,
        speedMultiplier: Infinity,
        terrainAction: null
      },
      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,
        speedMultiplier: Infinity,
        canSelect: false,
        terrainAction: null,
        costMultiplier: 0
      }
    }
  },
  adjectivesPrefix: "TOKEN.Adjectives",

  /**
   * Configuration for token resource bars.
   * @type {{bar1: {colors: {empty: Color, full: Color}}, bar2: {colors: {empty: Color, full: Color}}}}
   */
  barConfig: {
    bar1: {
      colors: {empty: Color.from("#FF0000"), full: Color.from("#7FFF00")}
    },
    bar2: {
      colors: {empty: Color.from("#00007F"), full: Color.from("#7FB2FF")}
    }
  },
  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 {number} thresholdAttenuationMultiplier
 * @property {Record<string, WallDoorSound>} doorSounds
 */
const Wall = {
  documentClass: WallDocument,
  objectClass: Wall$1,
  sidebar: {
    applicationClass: WallTab,
    order: 400
  },
  /**
   * 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"
    },
    jailCreaky: {
      label: "WALL.DoorSounds.JailCreaky",
      close: "sounds/doors/jail/close-creaky.ogg",
      lock: "sounds/doors/jail/lock.ogg",
      open: "sounds/doors/jail/open-creaky.ogg",
      test: "sounds/doors/jail/test.ogg",
      unlock: "sounds/doors/jail/unlock.ogg"
    },
    jailHeavy: {
      label: "WALL.DoorSounds.JailHeavy",
      close: "sounds/doors/jail/close-heavy.ogg",
      lock: "sounds/doors/jail/lock.ogg",
      open: "sounds/doors/jail/open-heavy.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                                */
/* -------------------------------------------- */

/**
 * @typedef TextEditorEngineConfig
 * @property {TextEditorEngineFactory} create   A callback used to instantiate the editor instance.
 * @property {TextEditorEngineRenderer} render  A callback used to generate markup used for the createEditorInput method
 *                                              and {{ editor }} handlebars helper.
 */

/**
 * @callback TextEditorEngineFactory
 * @param {object} options         Construction options.
 * @param {string} initialContent  The editor's initial content.
 * @returns {Promise<any>}
 */

/**
 * @callback TextEditorEngineRenderer
 * @param {object} options  Render options.
 * @param {boolean} [options.editable=true]      Whether the editor is editable.
 * @param {boolean} [options.button=false]       Whether the editor state is toggleable via button.
 * @param {boolean} [options.collaborate=false]  Whether the editor should allow for collaborative editing, if
 *                                               supported.
 * @param {number} [options.height]              Render the editor at a fixed height.
 * @param {string} [options.value]               The editor's initial content.
 * @param {string} [options.name]                The editor's name in the form.
 * @returns {HTMLElement}
 */

/**
 * @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.
 */

/**
 * @typedef ProseMirrorInsert
 * @property {string} action                   A unique identifier.
 * @property {string} title                    The description of the menu item or insert.
 * @property {boolean} [inline]                Whether the insert is inline content, otherwise it is assumed to be
 *                                             block content.
 * @property {string} [html]                   The insert's markup.
 * @property {ProseMirrorInsert[]} [children]  Any child entries.
 */

/**
 * Rich text editing configuration.
 */
const TextEditor = {
  /**
   * Configuration for custom text editor engines.
   * @type {Record<string, TextEditorEngineConfig>}
   */
  engines: {},

  /**
   * A collection of custom enrichers that can be applied to text content, allowing for the matching and handling of
   * custom patterns.
   * @type {TextEditorEnricherConfig[]}
   */
  enrichers: [],

  /**
   * A collection of custom ProseMirror inserts that the user may place in their ProseMirror documents.
   * @type {ProseMirrorInsert[]}
   *
   * @example Inserting a custom readaloud block at the cursor's position.
   * ```js
   * CONFIG.TextEditor.inserts.push({
   *   action: "readaloud",
   *   title: "Readaloud",
   *   html: `
   *     <div class="readaloud">
   *       <strong>Title</strong>
   *       <blockquote>You awake to find yourself in a dark room.</blockquote>
   *     </div>
   *   `
   * });
   * ```
   *
   * @example Wrapping the selection in an inline span with a custom class.
   * ```js
   * CONFIG.TextEditor.inserts.push({
   *   action: "lore-link",
   *   title: "Lore Link",
   *   inline: true,
   *   html: '<span class="lore"><selection></selection></span>'
   * });
   * ```
   *
   * @example Wrapping the selection in a block, with fallback content if there is no selection.
   * ```js
   * CONFIG.TextEditor.inserts.push({
   *   action: "readaloud",
   *   title: "Readaloud",
   *   html: `
   *     <div class="readaloud">
   *       <selection>
   *       <strong>Title</strong>
   *       <blockquote>You awake to find yourself in a dark room.</blockquote>
   *       </selection>
   *     </div>
   *   `
   * })
   * ```
   *
   * @example Provide a group for several inserts.
   * ```js
   * CONFIG.TextEditor.inserts.push({
   *   action: "my-inserts",
   *   title: "My Inserts",
   *   children: [{
   *     action: "lore-link",
   *     title: "Lore Link",
   *     inline: true,
   *     html: '<span class="lore"><selection></selection></span>'
   *   }, {
   *     action: "quest-link",
   *     title: "Quest Link",
   *     inline: true,
   *     html: '<span class="quest"><selection></selection></span>'
   *   }]
   * })
   * ```
   */
  inserts: []
};

/**
 * 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,
  placeables: PlaceableDirectory,
  players: Players,
  playlists: PlaylistDirectory,
  scenes: SceneDirectory,
  settings: Settings,
  tables: RollTableDirectory,
  webrtc: CameraViews
};

/**
 * Overrides for various core UI/UX helpers.
 */
const ux = {
  Autocomplete: Autocomplete,
  ContextMenu: ContextMenu,
  Draggable: Draggable,
  DragDrop: DragDrop,
  FilterMenu: FilterMenu,
  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};

/**
 * @typedef FormulaEditorConfiguration
 * @property {Record<string, FormulaEditorContext>} contexts
 */

/**
 * @typedef FormulaEditorContext
 * @property {Record<string, string>} labels  A map of data paths to human-readable labels.
 */

/**
 * Configure the formula editor.
 * @type {FormulaEditorConfiguration}
 */
const formulaEditor = {
  contexts: {}
};

// 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 v14
 * @ignore
 */
Dice.rollModes = new Proxy(ChatMessage.modes, {
  get(target, prop, receiver) {
    const msg = "CONFIG.Dice.rollModes is deprecated in favor of CONFIG.ChatMessage.modes";
    foundry.utils.logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
    if ( typeof prop === "symbol" ) return Reflect.get(target, prop, receiver);
    prop = foundry.dice.Roll._mapLegacyRollMode(prop);
    const mode = {...Reflect.get(target, prop, receiver)};
    /** @deprecated since v13 */
    Object.defineProperty(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;
    }});
    return mode;
  },
  set(...args) {
    foundry.utils.logCompatibilityWarning("CONFIG.Dice.rollModes is deprecated in favor of CONFIG.ChatMessage.modes",
      {since: 14, until: 16});
    return true; // Don't actually set
  },
  ownKeys() {
    // Return the old keys, deprecation warning will occur when reading `CONFIG.Dice.rollModes` with these legacy keys.
    return ["publicroll", "gmroll", "blindroll", "selfroll"];
  },
  getOwnPropertyDescriptor(target, prop) {
    // Satisfy Object.entries() enumeration
    switch ( prop ) {
      case "publicroll": case "gmroll": case "blindroll": case "selfroll":
        return {enumerable: true, configurable: true};
      default: return Reflect.getOwnPropertyDescriptor(target, prop);
    }
  }
});

/** @deprecated since v14 */
for ( const [documentConfig, documentName, layerName] of [
  [AmbientLight, "AmbientLight", "lighting"],
  [AmbientSound, "AmbientSound", "sounds"],
  [Drawing, "Drawing", "drawings"],
  [MeasuredTemplate, "MeasuredTemplate", "templates"],
  [Note, "Note", "notes"],
  [Region, "Region", "regions"],
  [Tile, "Tile", "tiles"],
  [Token, "Token", "tokens"],
  [Wall, "Wall", "walls"]
]) {
  const msg = `CONFIG.${documentName}.layerClass has been deprecated. Use CONFIG.Canvas.layers.${layerName}.layerClass instead.`;
  Object.defineProperty(documentConfig, "layerClass", {
    get() {
      logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
      return Canvas.layers[layerName].layerClass;
    },
    set(value) {
      logCompatibilityWarning(msg, {since: 14, until: 16, once: true});
      Canvas.layers[layerName].layerClass = value;
    }
  });
}

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,
  Level: Level,
  Macro: Macro,
  MeasuredTemplate: MeasuredTemplate,
  Note: Note,
  Playlist: Playlist,
  PlaylistSound: PlaylistSound,
  Region: Region,
  RegionBehavior: RegionBehavior,
  RollTable: RollTable,
  Scene: Scene$1,
  Setting: Setting,
  TableResult: TableResult,
  TextEditor: TextEditor,
  Tile: Tile,
  Token: Token,
  User: User,
  Wall: Wall,
  WebRTC: WebRTC,
  canvasTextStyle: canvasTextStyle,
  compatibility: compatibility,
  compendium: compendium,
  controlIcons: controlIcons,
  cursors: cursors,
  debug: debug,
  defaultFontFamily: defaultFontFamily,
  fontDefinitions: fontDefinitions,
  formulaEditor: formulaEditor,
  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
 */


/**
 * @import Localization from "./helpers/localization.mjs";
 */

/* ----------------------------------------- */
/*  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, utils} = foundry$1;
const CONFIG$1 = {...globalConfig};

/** @deprecated since v14 */
Object.defineProperty(CONFIG$1, "statusEffects", {
  get() {
    return statusEffects;
  },
  set(value) {
    if ( !Array.isArray(value) ) value = Object.values(value);
    statusEffects.length = 0;
    for ( const effect of value ) statusEffects.push(effect);
  }
});

const prosemirror = { ..._module$U };
Object.defineProperty(prosemirror, "defaultPlugins", {
  get() {
    logCompatibilityWarning("foundry.prosemirror.defaultPlugins is deprecated. "
      + "Please use foundry.applications.ux.ProseMirrorEditor.buildDefaultPlugins() instead.", {since: 14, until: 16});
    return ProseMirrorEditor.buildDefaultPlugins();
  },
  configurable: false,
  enumerable: true
});

Object.assign(globalThis, {
  foundry: {...foundry$1, CONFIG: CONFIG$1, prosemirror},
  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,

  /**
   * A singleton ForcedDeletion operator instance that can be reused.
   * @type {foundry.data.operators.ForcedDeletion}
   */
  _del: new ForcedDeletion(),

  /**
   * A convenience alias of `game#i18n#localize`
   * @type {Localization["localize"]}
   * @see {@link Localization#localize}
   */
  _loc: undefined,

  /**
   * A reference for ForcedReplacement.create that can be easily referenced.
   * @type {foundry.data.operators.ForcedReplacement.create}
   */
  _replace: ForcedReplacement.create,

  /**
   * 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,
  Level: documents.Level,
  Macro: documents.Macro,
  /**
   * @deprecated since v14
   * @ignore
   */
  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,

  // Bundled Libraries
  animejs: animejs$1
});

// Proxy Prototype Token Methods
data.PrototypeToken.prototype.getBarAttribute = documents.TokenDocument.prototype.getBarAttribute;

// Set fragment shader precision to highp
PIXI.Program.defaultVertexPrecision = PIXI.PRECISION.HIGH;
PIXI.Program.defaultFragmentPrecision = PIXI.PRECISION.HIGH;

// PIXI Classes extensions
extendPIXICircle();
extendPIXIPolygon();
extendPIXIRectangle();
extendPIXIGraphics();

// PIXI bug fixes
PIXI.EventBoundary.prototype.allocateEvent = (allocateEvent => function() {
  const event = allocateEvent.apply(this, arguments);
  // https://github.com/pixijs/pixijs/issues/9749 (fixed in v8 but not in v7.4.3)
  event.defaultPrevented = false;
  return event;
})(PIXI.EventBoundary.prototype.allocateEvent);

/* ----------------------------------------- */
/*  Backwards Compatibility                  */
/* ----------------------------------------- */

/** @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",
  /**
   * @deprecated since v14
   * @ignore
   */
  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",

  // 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",
  /**
   * @deprecated since v14
   * @ignore
   */
  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",
  /**
   * @deprecated since v14
   * @ignore
   */
  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.PolygonTree",
  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 () => {
  const Game$1 = Game;

  // 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);
  else if ( CONST$1.GAME_VIEWS.includes(gameView) ) globalThis.game = await Game$1.create(gameView);
  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$V as abstract, _module$B as applications, _module$y as appv1, _module$v as audio, _module$w as av, _module$8 as canvas, config, _module$X as data, _module$6 as dice, _module$_ as documents, _module$T as grid, _module$4 as helpers, _module$2 as nue, _module$W as packages, _module$U as prosemirror, _module as setup, _types as types, ui$2 as ui, _module$11 as utils };
