import BaseToken from "@common/documents/token.mjs";
import CanvasDocumentMixin from "./abstract/canvas-document.mjs";
import BaseScene from "@common/documents/scene.mjs";
import {TOKEN_SHAPES, REGION_EVENTS, TOKEN_DISPOSITIONS, REGION_MOVEMENT_SEGMENTS} from "@common/constants.mjs";
import Hooks from "../helpers/hooks.mjs";
import {isValidId} from "@common/data/validators.mjs";
import {deepClone, deepFreeze, diffObject, equals, isEmpty, mergeObject} from "@common/utils/helpers.mjs";
import {DataFieldOperator} from "@common/data/operators.mjs";
import Color from "@common/utils/color.mjs";
import Collection from "@common/utils/collection.mjs";

/**
 * @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
 */
export default 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.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.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.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.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.callAll("planToken", document);
        }
        continue;
      }
      moves.push({document, movement});
      document._onUpdateMovement(movement, operation, user);
      Hooks.callAll("moveToken", document, movement, operation, user);
      if ( (document.movement.id === movement.id) && movement.constrained ) {
        document._onMovementStopped();
        Hooks.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);
  }
}
