import BaseScene from "@common/documents/scene.mjs";
import ClientDocumentMixin from "./abstract/client-document.mjs";
import BaseGrid from "@common/grid/base.mjs";
import GridlessGrid from "@common/grid/gridless.mjs";
import HexagonalGrid from "@common/grid/hexagonal.mjs";
import SquareGrid from "@common/grid/square.mjs";
import Edge from "@client/canvas/geometry/edges/edge.mjs";

/**
 * @import {CanvasViewPosition, DeepReadonly, ElevatedPoint} from "@client/_types.mjs";
 * @import {SceneDimensions, SceneViewOptions, RegionSurface, TokenData, TokenMovementInstruction, TokenMovementOptions,
 *   TokenMovementWaypoint, TokenResizingInstruction} from "./_types.mjs";
 * @import TokenDocument from "./token.mjs";
 * @import {BaseShapeData} from "@common/data/_module.mjs";
 * @import {EdgeRestrictionType, WallRestrictionType} from "@common/constants.mjs";
 * @import Region from "@client/canvas/placeables/region.mjs";
 * @import RegionDocument from "./region.mjs";
 * @import User from "./user.mjs";
 * @import SceneManager from "@client/canvas/scene-manager.mjs";
 * @import {DatabaseCreateOperation, DatabaseUpdateOperation} from "@common/abstract/_types.mjs";
 * @import {LevelTexture, SceneData} from "@common/documents/_types.mjs";
 * @import Level from "./level.mjs";
 */

/**
 * The client-side Scene document which extends the common BaseScene model.
 * @extends BaseScene
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.collections.Scenes}: The world-level collection of Scene documents
 * @see {@link foundry.applications.sheets.SceneConfig}: The Scene configuration application
 */
export default class Scene extends ClientDocumentMixin(BaseScene) {

  /**
   * Track the viewed position of each scene (while in memory only, not persisted)
   * When switching back to a previously viewed scene, we can automatically pan to the previous position.
   * @type {CanvasViewPosition}
   * @internal
   */
  _viewPosition = {};

  /**
   * Track whether the scene is the active view and which level is viewed
   * @type {string|null}
   * @internal
   */
  _view = null;

  /**
   * The grid instance.
   * @type {BaseGrid}
   */
  grid = this.grid; // Workaround for subclass property instantiation issue.

  /**
   * The gridless version of the grid instance.
   * @type {GridlessGrid}
   */
  gridlessGrid = this.gridlessGrid; // Workaround for subclass property instantiation issue.

  /**
   * Determine the canvas dimensions this Scene would occupy, if rendered
   * @type {SceneDimensions}
   */
  dimensions = this.dimensions; // Workaround for subclass property instantiation issue.

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

  /**
   * The restriction types that are pending to be updated.
   * @type {Set<string>}
   */
  #pendingRegionRestrictionTypes = new Set();

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

  /**
   * The regions that are pending to have their shape constraints updated.
   * @type {Set<Region>}
   */
  #regionShapeConstraintsToUpdate = new Set();

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

  /**
   * A throttled function that updates the shape constraints of all Regions the current User is designated for
   * for all pending restriction types.
   * @type {() => void}
   */
  #throttleUpdateRegionShapeConstraints = foundry.utils.throttle(this.#updateRegionShapeConstraints.bind(this), 250);

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

  /**
   * The surfaces in this Scene.
   * @type {DeepReadonly<RegionSurface[]>|undefined}
   */
  #surfaces;

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

  /**
   * The cache of surfaces in this Scene.
   * @type {Record<string, DeepReadonly<RegionSurface[]>>|undefined}
   */
  #surfacesCache;

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

  /**
   * Have the edges of this Scene been initialized already?
   *
   * The property becomes true we moment {@link Scene#initializeEdges} is called.
   * @type {boolean}
   */
  get initializedEdges() {
    return this.#initializedEdges;
  }

  #initializedEdges = false;

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

  /**
   * Edges representing the outer boundaries of the game canvas.
   * @type {Edge[]}
   */
  #outerBoundaryEdges = [];

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

  /**
   * Edges representing the inner boundaries of the scene rectangle.
   * @type {Edge[]}
   */
  #innerBoundaryEdges = [];

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

  /**
   * The levels that are available to this User. By default GMs and scenes without token vision can access all levels;
   * players can only access levels where they have OBSERVER of a Token.
   * A SceneManager may override this via {@link SceneManager#_getAvailableLevels}.
   * @type {Set<Level>}
   */
  get availableLevels() {
    const manager = canvas.scene === this ? canvas.manager : foundry.canvas.Canvas.getSceneManager(this);
    return this._getAvailableLevels({ manager });
  }

  /**
   * @type {Set<Level>|null}
   * @protected
   */
  _availableLevels = null;

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

  /**
   * Provide a thumbnail image path used to represent this document.
   * @type {string|null}
   */
  get thumbnail() {
    return this.thumb;
  }

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

  /**
   * A convenience accessor for whether the Scene is currently viewed
   * @type {boolean}
   */
  get isView() {
    return !!this._view;
  }

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

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

    /**
     * Maintain a list of BaseShapeData instances that are placed in this Scene.
     * @type {IterableWeakSet<BaseShapeData>}
     * @internal
     */
    Object.defineProperty(this, "_dependentShapes", {value: new foundry.utils.IterableWeakSet()});
  }

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

  /** @inheritDoc */
  _updateCommit(copy, diff, options, state) {
    super._updateCommit(copy, diff, options, state);
    if ( "grid" in diff ) {
      for ( const region of this.regions ) region._onGridChange(diff.grid);
      for ( const shape of this._dependentShapes ) shape._onGridChange(diff.grid);
    }
    if ( "regions" in diff ) {
      for ( const region of this.regions ) region._clearPolygonTree();
    }
  }

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

  /**
   * Pull the specified users to this Scene.
   * @param {Iterable<User|string>} users     The User documents or IDs.
   * @param {SceneViewOptions} [viewOptions]  The view options.
   * @example Pull all users to the viewed scene.
   * ```js
   * canvas.scene.pullUsers(game.users);
   * ```
   */
  pullUsers(users, viewOptions={}) {
    if ( !game.user.isGM ) throw new Error("You must be a GM to pull players to a scene");
    for ( let user of users ) {
      if ( typeof user === "string" ) user = game.users.get(user);
      if ( !user?.active ) continue;
      game.socket.emit("pullToScene", this.id, user.id, viewOptions);
    }
  }

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

  /**
   * Set this Scene as currently active.
   * @param {object} [options]  Additional options
   * @param {SceneViewOptions} [options.viewOptions]  The view options
   * @param {boolean} [options.pullUsers]  Pull all Users to this Scene if it is already active?
   * @param {Partial<Omit<SceneData, "active">>} [options.updateData]                   Additional update data
   * @param {Partial<Omit<DatabaseUpdateOperation, "updates">>} [options.updateOptions] The update operation options
   * @returns {Promise<this>}  A Promise which resolves to this Scene once it has been successfully activated
   */
  async activate({viewOptions={}, pullUsers=false, updateData, updateOptions}={}) {
    if ( !game.user.isGM ) throw new Error("You must be a GM to activate a scene");
    if ( !this.active ) pullUsers = false;
    updateData = {...updateData, active: true};
    updateOptions = {...updateOptions, viewOptions};
    await this.update(updateData, updateOptions);
    if ( pullUsers ) this.pullUsers(game.users, viewOptions);
    return this;
  }

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

  /**
   * Set this scene as the current view
   * @param {SceneViewOptions} [options]   The view options
   * @returns {Promise<Scene>}
   */
  async view(options={}) {

    // Do not switch if the loader is still running
    if ( canvas.loading ) {
      return ui.notifications.warn("You cannot switch Scenes until resources finish loading for your current view.");
    }

    // Notify the user in no-canvas mode
    if ( game.settings.get("core", "noCanvas") ) {
      ui.notifications.info("INFO.SceneViewCanvasDisabled", {format: {name: this.navName ? this.navName : this.name}});
    }

    // Re-draw the canvas if the view is different
    const sceneChanged = canvas.scene?.id !== this.id;
    const levelChanged = (options.level !== undefined) && (options.level !== canvas.level?.id);
    if ( canvas.initialized && canvas.scene && sceneChanged ) {
      delete canvas.scene.apps[ui.placeables.id];
      ui.placeables._onDeactivate();
    }
    if ( canvas.initialized && (sceneChanged || levelChanged) ) {
      console.log(`Foundry VTT | Viewing Scene ${this.name}`);
      canvas._viewOptions = options;
      await canvas.draw(this);
      canvas.scene?.render();
      this.render();
    }
    if ( levelChanged ) ui.placeablesPalette?.render();

    // Render apps for the collection
    this.collection.render();
    ui.combat.render({ combat: null });
    return this;
  }

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

  /**
   * Unview this Scene, if it is the viewed Scene, clearing the game canvas.
   */
  async unview() {
    if ( !this._view ) return;
    delete canvas.scene.apps[ui.placeables.id];
    ui.placeables._onDeactivate();

    // Re-draw the canvas with a blank scene
    await canvas.draw(null);

    // Render apps for the collection
    this.collection.render();
    ui.combat.render({ combat: null });
    return this;
  }

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

  /** @override */
  clone(createData={}, options={}) {
    createData.active = false;
    createData.navigation = false;
    if ( !foundry.data.validators.isBase64Data(createData.thumb) ) delete createData.thumb;
    if ( !options.save ) return super.clone(createData, options);
    return this.createThumbnail()
      .then(data => {
        createData.thumb = data.thumb;
        return super.clone(createData, options);
      })
      .catch(err => {
        if ( err.cause?.thumbUploadDenied ) {
          ui.notifications.warn("SCENE.GenerateThumbUploadDenied", {localize: true});
          createData.thumb = this.thumbnail; // Reusing the existing thumb image if any exist
        }
        return super.clone(createData, options);
      });
  }

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

  /** @override */
  reset() {
    this._initialize({sceneReset: true});
  }

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

  /** @inheritDoc */
  toObject(source=true) {
    const object = super.toObject(source);
    if ( !source && this.grid.isHexagonal && this.flags.core?.legacyHex ) {
      object.grid.size = Math.round(this.grid.size * (2 * Math.SQRT1_3));
    }
    return object;
  }

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

  /** @inheritDoc */
  prepareBaseData() {
    Scene.#prepareGrids(this);
    this.dimensions = this.getDimensions();
    this.playlistSound = this.playlist ? this.playlist.sounds.get(this._source.playlistSound) : null;
  }

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

  /** @inheritDoc */
  prepareEmbeddedDocuments() {
    super.prepareEmbeddedDocuments();
    this._availableLevels = null;

    // Derived properties of all levels
    const levelsSorted = this.levels.contents.sort((a, b) => a.sort - b.sort);
    const levelsReverseSorted = levelsSorted.toReversed();
    Object.defineProperties(this.levels, {
      sorted: {
        value: levelsSorted,
        configurable: true,
        writable: false
      },
      reverseSorted: {
        value: levelsReverseSorted,
        configurable: true,
        writable: false
      }
    });
    for ( const [i, level] of this.levels.sorted.entries() ) {
      Object.defineProperty(level, "index", {value: i, configurable: true, enumerable: true, writable: false});
    }
  }

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

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();

    Object.defineProperty(this.fog, "overlay", {
      get: () => this.firstLevel?.fog?.src,
      configurable: true,
      enumerable: false
    });
  }

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

  /**
   * Prepare the grid instances from the grid config of this scene if it doesn't exist yet.
   * @param {Scene} scene
   */
  static #prepareGrids(scene) {
    const grid = scene.grid;
    if ( grid instanceof BaseGrid ) return;

    const T = CONST.GRID_TYPES;
    const type = grid.type;
    const config = {
      size: grid.size,
      distance: grid.distance,
      units: grid.units,
      style: grid.style,
      thickness: grid.thickness,
      color: grid.color,
      alpha: grid.alpha
    };

    // Gridless grid
    scene.gridlessGrid = new GridlessGrid(config);
    if ( type === T.GRIDLESS ) scene.grid = scene.gridlessGrid;

    // Square grid
    else if ( type === T.SQUARE ) {
      config.diagonals = game.settings.get("core", "gridDiagonals");
      scene.grid = new SquareGrid(config);
    }

    // Hexagonal grid
    else if ( type.between(T.HEXODDR, T.HEXEVENQ) ) {
      config.columns = (type === T.HEXODDQ) || (type === T.HEXEVENQ);
      config.even = (type === T.HEXEVENR) || (type === T.HEXEVENQ);
      config.diagonals = game.settings.get("core", "gridDiagonals");
      if ( scene.flags.core?.legacyHex ) config.size *= (Math.SQRT3 / 2);
      scene.grid = new HexagonalGrid(config);
    }

    else throw new Error("Invalid grid type");
  }

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

  /**
   * The levels that are available to this User in ascending order. By default GMs and scenes without token vision
   * can access all levels; players can only access levels where they have OBSERVER of a Token.
   * @param {object} [options]
   * @param {SceneManager|null} [options.manager]    A SceneManager whose {@link SceneManager#_getAvailableLevels}
   *                                                 override should be applied to the base set.
   * @returns {Set<Level>}
   * @protected
   */
  _getAvailableLevels({manager=null}={}) {
    if ( !this._availableLevels ) {
      if ( game.user.isGM || !this.tokenVision ) this._availableLevels = new Set(this.levels.sorted);
      else {
        this._availableLevels = new Set(this.tokens.reduce((arr, t) => {
          if ( !t.testUserPermission(game.user, "OBSERVER") ) return arr;
          const level = this.levels.get(t._source.level);
          if ( level ) arr.push(level);
          return arr;
        }, []).sort((a, b) => a.index - b.index));
      }
    }
    const managerLevels = manager?._getAvailableLevels(this._availableLevels);
    return managerLevels instanceof Set ? managerLevels : this._availableLevels;
  }

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

  /**
   * Get the Canvas dimensions which would be used to display this Scene.
   * Apply padding to enlarge the playable space and round to the nearest 2x grid size to ensure symmetry.
   * The rounding accomplishes that the padding buffer around the map always contains whole grid spaces.
   * @returns {SceneDimensions}
   */
  getDimensions() {

    // Get Scene data
    const grid = this.grid;
    const sceneWidth = this.width;
    const sceneHeight = this.height;

    // Compute the correct grid sizing
    let dimensions;
    if ( grid.isHexagonal && this.flags.core?.legacyHex ) {
      const legacySize = Math.round(grid.size * (2 * Math.SQRT1_3));
      dimensions = HexagonalGrid._calculatePreV10Dimensions(grid.columns, legacySize,
        sceneWidth, sceneHeight, this.padding);
    } else {
      dimensions = grid.calculateDimensions(sceneWidth, sceneHeight, this.padding);
    }
    const {width, height} = dimensions;
    const sceneX = dimensions.x - this.shiftX;
    const sceneY = dimensions.y - this.shiftY;

    // Define Scene dimensions
    return {
      width, height, size: grid.size,
      rect: new PIXI.Rectangle(0, 0, width, height),
      sceneX, sceneY, sceneWidth, sceneHeight,
      sceneRect: new PIXI.Rectangle(sceneX, sceneY, sceneWidth, sceneHeight),
      distance: grid.distance,
      distancePixels: grid.size / grid.distance,
      ratio: sceneWidth / sceneHeight,
      maxR: Math.hypot(width, height),
      rows: dimensions.rows,
      columns: dimensions.columns
    };
  }

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

  /** @inheritDoc */
  _onClickDocumentLink(event) {
    if ( this.journal ) return this.journal._onClickDocumentLink(event);
    return super._onClickDocumentLink(event);
  }

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

  /**
   * Clear the movement history of all Tokens within this Scene.
   * @returns {Promise<void>}
   */
  async clearMovementHistories() {
    const updates = this.tokens.reduce((arr, t) => {
      if ( t._source._movementHistory.length !== 0 ) arr.push({_id: t.id});
      return arr;
    }, []);
    await this.updateEmbeddedDocuments("Token", updates, {diff: false, noHook: true, _clearMovementHistory: true});
  }

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

  /**
   * For the given Tokens in this Scene identify the Regions that each Token is contained in and update the regions of
   * each Token accordingly.
   *
   * This function doesn't need to be called by the systems/modules unless
   * {@link foundry.documents.TokenDocument#testInsideRegion} is overridden and non-Token properties other than
   * `Scene#grid.type` and `Scene#grid.size` change that are used in the override of
   * {@link foundry.documents.TokenDocument#testInsideRegion}.
   * @param {Iterable<TokenDocument>} [tokens]  The Tokens whoses regions should be updates: if not provided, all Tokens
   *                                            will be updated.
   * @returns {Promise<TokenDocument[]>}        The array of Tokens whose regions changed
   */
  async updateTokenRegions(tokens=this.tokens) {
    const updates = [];
    for ( const token of tokens ) {
      if ( token.parent !== this ) throw new Error("Token must be in this Scene");
      updates.push({_id: token.id, _regions: token._identifyRegions()});
    }
    return this.updateEmbeddedDocuments("Token", updates, {noHook: true});
  }

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

  /**
   * Update the shape constraints of all Regions the current User is designated for
   * (for the given restriction types).
   * @param {Iterable<EdgeRestrictionType>} [types]    The types to update. Default: all.
   */
  updateRegionShapeConstraints(types=CONST.EDGE_RESTRICTION_TYPES) {
    if ( !this.persisted ) throw new Error("A nonpersisted Document cannot be updated.");
    let added = false;
    for ( const type of types ) {
      this.#pendingRegionRestrictionTypes.add(type);
      added = true;
    }
    if ( added ) this.#throttleUpdateRegionShapeConstraints();
  }

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

  /**
   * Update the shape constraints of the given Regions if the current User is designated for it.
   * @param {Region} region
   * @internal
   */
  _updateRegionShapeConstraints(region) {
    this.#regionShapeConstraintsToUpdate.add(region);
    this.#throttleUpdateRegionShapeConstraints();
  }

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

  /**
   * Update the shape constraints of all Regions the current User is designated for
   * for the pending restriction types.
   */
  #updateRegionShapeConstraints() {
    canvas.app.ticker.addOnce(() => {
      const updates = [];
      const options = {noHook: true, _updateShapeConstraints: {}};
      for ( const region of this.regions ) {
        if ( !region.restriction.enabled || (!this.#pendingRegionRestrictionTypes.has(region.restriction.type)
          && !this.#regionShapeConstraintsToUpdate.has(region)) ) continue;
        const designatedUser = game.users.getDesignatedUser(u => u.active && region.canUserModify(u, "update")
          && (u.viewedScene === this.id) && region.includedInLevel(u.viewedLevel))
          ?? game.users.getDesignatedUser(u => u.active && region.canUserModify(u, "update"));
        if ( game.user !== designatedUser ) continue;
        updates.push({_id: region.id, _shapeConstraints: region._computeShapeConstraints()});
        options._updateShapeConstraints[region.id] = true;
      }
      this.#pendingRegionRestrictionTypes.clear();
      this.#regionShapeConstraintsToUpdate.clear();
      this.updateEmbeddedDocuments("Region", updates, options);
    }, undefined, PIXI.UPDATE_PRIORITY.PERCEPTION - 1);
  }

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

  /**
   * Move/resize multiple Tokens.
   * @param {{[id: string]: TokenMovementInstruction|TokenResizingInstruction}} instructions
   *   The movement/resizing instructions.
   * @param {Partial<Omit<DatabaseUpdateOperation, "updates"> & Omit<TokenMovementOptions, "id">} [options]
   *   Parameters of the update and movement operation.
   * @returns {Promise<{[id: string]: boolean}>} A Promise that resolves once all movement instructions are finished.
   *   The resolved value is an object with token IDs as keys and booleans as values that indicate whether the movement
   *   the token with the corresponding ID was completed (`true`) or stopped/prevented (`false`).
   * @see {@link foundry.documents.TokenDocument#move}
   * @see {@link foundry.documents.TokenDocument#resize}
   * @example
   * ```js
   * const results = await scene.moveTokens({
   *    // Moving the token to new position including additional token data
   *   "cGYT0rR0YbtFkhzT": {
   *     destination: {x: 100, y: 200, rotation: 45, texture: {tint: "#ff0000"}},
   *     showRuler: false, // This overrides `options.showRuler`
   *   },
   *   // Moving the token to along a path with multiple waypoints
   *   "wBFpJuZuleEtVNw1": {
   *     waypoints: [
   *       {x: 100, y: 200}, // Move to the position (100, 200)
   *       {elevation: 5, explicit: true}, // Move to elevation 5 indicating that the user placed this waypoint
   *       {x: 500, y: 500, checkpoint: true}, // Move to (500, 500): the movement can be stopped/paused here
   *       {width: 2, height: 2, depth: 2}, // Change size
   *       {x: 1000, action: "swim"}, // Swim to (1000, 500)
   *       {x: 0, y: 0, snapped: true}, // Move to (0, 0) indicating that (0, 0) is a snapped position for the token
   *       {elevation: 10} // Move to elevation 10 (the last waypoint is always a checkpoint automatically)
   *     ],
   *     autoRotate: true,
   *     constrainOptions: {ignoreWalls: true, ignoreCost: true} // Allow the token to move through walls, surfaces, and
   *                                                             // impassable terrain
   *   },
   *   // Resizing the token including additional token data
   *   "VupAIbzpX6SHqtaH": {
   *     dimensions: {width: 3, height: 3, depth: 3, rotation: 45, texture: {tint: "#ff0000"}}
   *   }
   * }, {
   *   showRuler: true // This applies to all instructions that do not define `showRuler`
   * })
   * if ( results["cGYT0rR0YbtFkhzT"] ) {
   *   // The movement of Token [cGYT0rR0YbtFkhzT] was completed: it arrived at the destination
   * } else {
   *   // The movement of Token [cGYT0rR0YbtFkhzT] was stopped or prevented
   * }
   * if ( results["wBFpJuZuleEtVNw1"] ) {
   *   // The movement of Token [wBFpJuZuleEtVNw1] was completed: it arrived at the destination
   * } else {
   *   // The movement of Token [wBFpJuZuleEtVNw1] was stopped or prevented
   * }
   * if ( results["VupAIbzpX6SHqtaH"] ) {
   *   // The resizing of Token [VupAIbzpX6SHqtaH] was completed
   * } else {
   *   // The resizing of Token [VupAIbzpX6SHqtaH] was prevented
   * }
   * ```
   */
  async moveTokens(instructions, options) {
    const toUpdate = [];
    const movement = {};
    for ( const [tokenId, instruction] of Object.entries(instructions) ) {
      if ( instruction.dimensions ) {
        const {dimensions, id, method, split, planned} = instruction;
        let {x, y, elevation, width, height, depth, shape, level, action, snapped, explicit, checkpoint,
          ...updateData} = dimensions;
        const token = this.tokens.get(tokenId, {strict: true});
        width ??= token._source.width;
        height ??= token._source.height;
        depth ??= token._source.depth;
        shape ??= token._source.shape;
        level ??= token._source.level;
        const center = token.getCenterPoint(token._source);
        const pivot = token.getCenterPoint({x: 0, y: 0, elevation: 0, width, height, shape});
        x = Math.round(center.x - pivot.x);
        y = Math.round(center.y - pivot.y);
        toUpdate.push({...updateData, _id: tokenId});
        movement[tokenId] = {waypoints: [{x, y, elevation, width, height, depth, shape, level, action, snapped,
          explicit, checkpoint}], id, method, split, planned, autoRotate: false, showRuler: false, constrainOptions:
          {ignoreWalls: true, ignoreCost: true}};
      } else {
        let {waypoints, id, method, split, planned, autoRotate, showRuler, constrainOptions, terrainOptions,
          measureOptions} = instruction;
        let updateData;
        if ( !waypoints ) {
          const {x, y, elevation, width, height, depth, shape, level, action, snapped, explicit, checkpoint,
            ...remaining} = instruction.destination;
          waypoints = [{x, y, elevation, width, height, depth, shape, level, action, snapped, explicit, checkpoint}];
          updateData = remaining;
        }
        toUpdate.push({...updateData, _id: tokenId});
        movement[tokenId] = {waypoints, id, method, split, planned, autoRotate, showRuler, constrainOptions,
          terrainOptions, measureOptions};
      }
    }
    const args = {result: {}};
    const updated = await this.updateEmbeddedDocuments("Token", toUpdate, {...options, movement, _movementArguments: args,
      _returnMovementPromise: true});
    const updatedById = updated.reduce((obj, token) => {
      obj[token.id] = token;
      return obj;
    }, {});
    const promises = [];
    const results = {};
    for ( const id of Object.keys(instructions) ) {
      const updatedToken = updatedById[id];
      const initialMovementId = args.result[id];
      if ( !initialMovementId || !updatedToken ) results[id] = false;
      else {
        const promise = updatedToken._returnedMovementPromises.get(initialMovementId);
        updatedToken._returnedMovementPromises.delete(initialMovementId);
        promises.push(promise.then(result => {
          results[id] = result;
        }));
      }
    }
    await Promise.all(promises);
    return results;
  }

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

  /**
   * Invalidate cached surface data.
   * @internal
   */
  _invalidateSurfaces() {
    this.#surfaces = undefined;
    this.#surfacesCache = undefined;
  }

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

  /**
   * Update surfaces if invalidated.
   */
  #updateSurfaces() {
    if ( this.#surfaces ) return;
    this.#surfaces = [];
    this.#surfacesCache = {};
    const attrs = ["light", "darkness", "move", "sight", "sound", "occlusion", "exposure", "culling"];
    for ( const region of this.regions ) {
      if ( region.hidden ) continue;
      const bottomSurface = {key: `${region.id},${region.elevation.bottom}`, region, elevation: region.elevation.bottom};
      const topSurface = {key: `${region.id},${region.elevation.top}`, region, elevation: region.elevation.top};
      for ( const a of attrs ) bottomSurface[a] = topSurface[a] = false;
      const flat = region.elevation.bottom === region.elevation.top;
      for ( const behavior of region.behaviors ) {
        if ( behavior.disabled || (behavior.type !== "defineSurface") ) continue;
        const placement = behavior.system.placement;
        if ( (placement === "bottom") || (placement === "both") || flat ) {
          for ( const a of attrs ) bottomSurface[a] ||= behavior.system[a];
        }
        if ( (placement === "top") || (placement === "both") || flat ) {
          for ( const a of attrs ) topSurface[a] ||= behavior.system[a];
        }
      }
      if ( attrs.some(a => bottomSurface[a]) ) this.#surfaces.push(bottomSurface);
      if ( !flat && attrs.some(a => topSurface[a]) ) this.#surfaces.push(topSurface);
    }
    this.#surfaces.sort((a, b) => a.elevation - b.elevation);
    Object.freeze(this.#surfaces);
  }

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

  /**
   * Get all surfaces or surfaces matching the filter, ordered by elevation in ascending order.
   * @param {object} [options]                    Additional options
   * @param {EdgeRestrictionType} [options.type]  Only return surfaces that restrict this type
   * @param {Level|string} [options.level]        Only return surfaces that are included in this Level
   * @param {boolean} [options.occlusion]   Only return surfaces that have this value as {@link RegionSurface#occlusion}
   * @param {boolean} [options.exposure]    Only return surfaces that have this value as {@link RegionSurface#exposure}
   * @param {boolean} [options.culling]     Only return surfaces that have this value as {@link RegionSurface#culling}
   * @returns {DeepReadonly<RegionSurface[]>}
   */
  getSurfaces({type, level, occlusion, exposure, culling}={}) {
    this.#updateSurfaces();
    const key = `${type || ""},${!level || (typeof level === "string") ? level || "" : level.id},`
      + `${occlusion === true ? "+" : occlusion === false ? "-" : ""},`
      + `${exposure === true ? "+" : exposure === false ? "-" : ""},`
      + `${culling === true ? "+" : culling === false ? "-" : ""}`;
    return this.#surfacesCache[key] ??= Object.freeze(this.#surfaces.filter(surface => {
      if ( (level !== undefined) && !surface.region.includedInLevel(level) ) return false;
      if ( (type !== undefined) && !surface[type] ) return false;
      if ( (occlusion !== undefined) && (surface.occlusion !== occlusion) ) return false;
      if ( (exposure !== undefined) && (surface.exposure !== exposure) ) return false;
      if ( (culling !== undefined) && (surface.culling !== culling) ) return false;
      return true;
    }));
  }

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

  /**
   * Test for surface collision for a movement between two points.
   * @param {ElevatedPoint} origin                 The origin.
   * @param {ElevatedPoint} destination            The destination.
   * @param {object} config                        Configuration.
   * @param {EdgeRestrictionType} [config.type]    The restriction type. Default: `"move"`.
   * @param {"any"|"all"|"closest"} [config.mode]  The collision mode. Default: `"any"`.
   * @param {"below"|"above"} [config.side]        The side of the surface that counts as colliding
   *                                               when the ray originates on the surface. Default: `"below"`.
   *                                               - `"below"`: Treats the surface as solid in the negative z-direction.
   *                                                 Rays originating on the surface will collide if they point downward
   *                                                 (z < 0) and will not collide if they point upward.
   *                                               - `"above"`: Treats the surface as solid in the positive z-direction.
   *                                                 Rays originating on the surface will collide if they point upward
   *                                                 (z > 0) and will not collide if they point downward.
   * @param {number} [config.tMin]                 Intersections of the ray and a surface with t-value less than
   *                                               `tMin` are not considered collisions. Default: `0`.
   * @param {number} [config.tMax]                 Intersections of the ray and a surface with t-value greater than
   *                                               `tMax` are not considered collisions. Default: `1`.
   * @param {Level|string} config.level            The Level or Level ID to test collision in.
   * @returns {boolean|ElevatedPoint|ElevatedPoint[]|null}  The collision result depends on the mode of the test:
   *                                                 - `"any"`: Returns a boolean for whether any collision occurred.
   *                                                 - `"all"`: Returns a sorted array of `ElevatedPoint` instances.
   *                                                 - `"closest"`: Returns an `ElevatedPoint` instance or null.
   */
  testSurfaceCollision(origin, destination, {type="move", mode="any", side="below", tMin=0, tMax=1, level}) {
    if ( (origin.elevation === destination.elevation) || (tMin >= tMax) ) return mode === "any" ? false : (mode === "all" ? [] : null);

    const collisions = mode !== "any" ? [] : undefined;
    const down = origin.elevation > destination.elevation;
    if ( down ) [tMin, tMax] = [1 - tMax, 1 - tMin];
    const [lower, upper] = down ? [destination, origin] : [origin, destination];
    const deltaX = upper.x - lower.x;
    const deltaY = upper.y - lower.y;
    const bounds = new PIXI.Rectangle(
      lower.x + (deltaX * tMin),
      lower.y + (deltaY * tMin),
      deltaX * (tMax - tMin),
      deltaY * (tMax - tMin)
    ).normalize().pad(1);
    for ( const {region, elevation} of this.getSurfaces({type, level}) ) {
      if ( side === "above" ) {
        if ( elevation < lower.elevation ) continue;
        if ( elevation >= upper.elevation ) break;
      } else {
        if ( elevation <= lower.elevation ) continue;
        if ( elevation > upper.elevation ) break;
      }
      if ( !region.bounds.intersects(bounds) ) continue;
      const t = (elevation - lower.elevation) / (upper.elevation - lower.elevation);
      if ( (t < tMin) || (t > tMax) ) continue;
      const x = Math.mix(lower.x, upper.x, t);
      const y = Math.mix(lower.y, upper.y, t);
      const position = {x, y, elevation};
      if ( region.polygonTree.testPoint(position, 0.75) ) {
        if ( !collisions ) return true; // Mode is "any"
        collisions.push(position);
      }
    }

    if ( mode === "any" ) return false;

    if ( mode === "all" ) {
      return collisions.sort(down ? (a, b) => b.elevation - a.elevation : (a, b) => a.elevation - b.elevation);
    }

    // Mode is "closest"
    if ( !collisions.length ) return null;
    let collision = collisions[0];
    if ( down ) {
      for ( const c of collisions ) {
        if ( c.elevation > collision.elevation ) collision = c;
      }
    } else {
      for ( const c of collisions ) {
        if ( c.elevation < collision.elevation ) collision = c;
      }
    }
    return collision;
  }

  /* -------------------------------------------- */
  /*  Scene Levels                                */
  /* -------------------------------------------- */

  /**
   * Cycle the currently viewed Level for this Scene.
   * @param {-1|1} direction
   * @returns {Promise<void>}
   */
  async cycleLevel(direction) {
    direction = Math.sign(direction);
    if ( direction === 0 ) return;
    const availableLevels = Array.from(this.availableLevels);
    const currentIndex = availableLevels.findIndex(l => l.id === this._view) ?? 0;
    const nextIndex = Math.clamp(currentIndex + direction, 0, availableLevels.length - 1);
    const nextLevel = availableLevels[nextIndex];
    if ( !nextLevel ) return;
    await this.view({level: nextLevel.id});
  }

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

  /**
   * Get textures that should be used for the currently active level.
   * @returns {(LevelTexture & {level: Level; name: string; elevation: number; sort: number; zIndex: number;
   *   isBackground: boolean; isUpper: boolean})[]}
   * @internal
   */
  _configureLevelTextures() {
    if ( !this._view ) return [];
    const viewedLevel = this.levels.get(this._view);
    const textures = [];
    for ( const level of this.levels.sorted ) {
      const {isView, isVisible} = level;
      let {background, foreground} = level;
      background ??= {};
      foreground ??= {};
      if ( isView || (background.src && isVisible) ) {
        const isUpper = !isView && (level.elevation.bottom > viewedLevel.elevation.bottom);
        textures.push({level, name: "background", elevation: level.elevation.bottom, sort: 0, zIndex: 0, isBackground: true, isUpper, ...background});
      }
      if ( isView || (foreground.src && isVisible) ) {
        const isUpper = !isView && (level.elevation.top > viewedLevel.elevation.bottom);
        textures.push({level, name: "foreground", elevation: level.elevation.top, sort: 0, zIndex: 0, isBackground: false, isUpper, ...foreground});
      }
    }
    return textures;
  }

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

  /**
   * Reset the edges of this Scene.
   * @internal
   */
  _resetEdges() {
    if ( !this.#initializedEdges ) return;
    this.#initializedEdges = false;
    for ( const level of this.levels ) level.edges.clear();
    if ( this.isView ) this.initializeEdges();
  }

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

  /**
   * Initialize the edges of this Scene unless they already have been inititalized.
   */
  initializeEdges() {
    if ( this.#initializedEdges ) return;
    this.#initializedEdges = true;
    this.#initializeBoundaryEdges();
    for ( const wall of this.walls ) wall.initializeEdge();
    Hooks.callAll("initializeEdges", this);
  }

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

  /**
   * Initialize the boundary edges for this Scene.
   */
  #initializeBoundaryEdges() {

    // Delete existing boundary edges
    for ( const edge of this.#outerBoundaryEdges ) {
      for ( const level of this.levels ) level.edges.delete(edge.id);
    }
    for ( const edge of this.#innerBoundaryEdges ) {
      for ( const level of this.levels ) level.edges.delete(edge.id);
    }

    // Create boundary edges
    const d = this.dimensions;
    const createBoundaryEdges = (type, r) => {
      const top = new Edge({x: r.x, y: r.y}, {x: r.right, y: r.y}, {id: `${type}.top`, object: this, type});
      const right = new Edge({x: r.right, y: r.y}, {x: r.right, y: r.bottom}, {id: `${type}.right`, object: this, type});
      const bottom = new Edge({x: r.right, y: r.bottom}, {x: r.x, y: r.bottom}, {id: `${type}.bottom`, object: this, type});
      const left = new Edge({x: r.x, y: r.bottom}, {x: r.x, y: r.y}, {id: `${type}.left`, object: this, type});
      return [top, right, bottom, left];
    };
    this.#outerBoundaryEdges = createBoundaryEdges("outerBounds", d.rect);
    this.#innerBoundaryEdges = createBoundaryEdges("innerBounds", d.sceneRect);

    // Add new boundary edges
    for ( const edge of this.#outerBoundaryEdges ) {
      for ( const level of this.levels ) level.edges.set(edge.id, edge);
    }
    for ( const edge of this.#innerBoundaryEdges ) {
      for ( const level of this.levels ) level.edges.set(edge.id, edge);
    }
  }

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

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;

    if ( !this.levels.size && !data.levels?.length ) {
      const { background, backgroundColor, foreground, foregroundElevation, fog } = data;
      const level = {
        _id: this.constructor.metadata.defaultLevelId,
        name: foundry.documents.Level.defaultName({ parent: this })
      };
      const changes = { levels: [level], initialLevel: level._id };
      if ( foundry.utils.isPlainObject(background) || backgroundColor ) level.background = {};
      if ( backgroundColor ) level.background.color = backgroundColor;
      if ( foundry.utils.isPlainObject(background) ) {
        level.background.src = background.src;
        if ( ("offsetX" in background) && !("shiftX" in data) ) changes.shiftX = background.offsetX;
        if ( ("offsetY" in background) && !("shiftY" in data) ) changes.shiftY = background.offsetY;
      }
      if ( foreground ) level.foreground = { src: foreground };
      if ( foregroundElevation ) level.elevation = { top: foregroundElevation };
      if ( fog?.overlay ) level.fog = { src: fog.overlay };
      this.updateSource(changes);
    }

    // Create a base64 thumbnail for the scene
    if ( !("thumb" in data) && canvas.ready && this.initialLevel.background?.src ) {
      try {
        const t = await this.createThumbnail();
        this.updateSource({thumb: t.thumb});
      }
      catch(err) {
        if ( err.cause?.thumbUploadDenied ) {
          ui.notifications.warn("SCENE.GenerateThumbUploadDenied", {localize: true});
        }
      }
    }
  }

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

  /** @inheritDoc */
  static async _preCreateOperation(documents, operation, user) {
    if ( game.scenes.active ) operation.priorActiveScene = game.scenes.active.id;
    else if ( documents.every(s => !s.active) ) {
      const candidate = documents.find((s, i) => !("active" in operation.data[i]));
      candidate?.updateSource({ active: true }); // Set a scene as active if none currently are.
    }
  }

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

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);

    // Register the ActiveEffects of any pre-included Token Actors
    for ( const token of this.tokens ) {
      const syntheticActor = token.delta?.syntheticActor;
      if ( syntheticActor ) foundry.documents.ActiveEffect.registry.addFromParent(syntheticActor);
    }

    // Trigger Region Behavior status events
    const user = game.users.get(userId);
    for ( const region of this.regions ) {
      region._handleEvent({name: CONST.REGION_EVENTS.BEHAVIOR_ACTIVATED, data: {}, region, user});
      /** @deprecated since v13 */
      region._handleEvent({name: "behaviorStatus", data: {active: true}, region, user});
    }

    // Activate the new Scene
    if ( data.active === true ) {
      this._onActivate(true, options);
      if ( game.userId === userId ) game.playlists._onChangeScene(this);
    }
  }

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

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    const allowed = await super._preUpdate(changed, options, user);
    if ( allowed === false ) return false;

    // Record the prior active scene for later use
    if ( "active" in changed ) options.priorActiveScene = game.scenes.active?.id;

    // Handle darkness level lock special case
    if ( changed.environment?.darknessLevel !== undefined ) {
      const darknessLocked = this.environment.darknessLock && (changed.environment.darknessLock !== false);
      if ( darknessLocked ) delete changed.environment.darknessLevel;
    }

    if ( "thumb" in changed ) {
      options.thumb ??= [];
      options.thumb.push(this.id);
    }

    // If the canvas size has changed, translate the placeable objects
    if ( options.autoReposition ) {
      try {
        changed = this.#repositionObjects(changed);
      }
      catch(err) {
        delete changed.width;
        delete changed.height;
        delete changed.padding;
        delete changed.background;
        return ui.notifications.error(err.message);
      }
    }

    if ( Array.isArray(changed.levels) || !this.levels.size ) return;
    const levelDelta = { _id: this.firstLevel.id };
    for ( const [oldKey, newKey] of BaseScene._LEVELS_PROPERTY_MAP ) {
      const value = foundry.utils.getProperty(changed, oldKey);
      if ( value !== undefined ) foundry.utils.setProperty(levelDelta, newKey, value);
    }
  }

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

  /**
   * Handle repositioning of placed objects when the Scene dimensions change
   * @param {object} sceneUpdateData
   * @returns {object}
   */
  #repositionObjects(sceneUpdateData) {
    const translationScaleX = "width" in sceneUpdateData ? (sceneUpdateData.width / this.width) : 1;
    const translationScaleY = "height" in sceneUpdateData ? (sceneUpdateData.height / this.height) : 1;
    const averageTranslationScale = (translationScaleX + translationScaleY) / 2;

    // If the padding is larger than before, we need to add to it. If it's smaller, we need to subtract from it.
    const originalDimensions = this.getDimensions();
    const updatedScene = this.clone();
    updatedScene.updateSource(sceneUpdateData);
    const newDimensions = updatedScene.getDimensions();
    const paddingOffsetX = "padding" in sceneUpdateData ? ((newDimensions.width - originalDimensions.width) / 2) : 0;
    const paddingOffsetY = "padding" in sceneUpdateData ? ((newDimensions.height - originalDimensions.height) / 2) : 0;

    // Adjust for shift
    const shiftX = sceneUpdateData.shiftX !== undefined ? (this.shiftX - sceneUpdateData.shiftY) : 0;
    const shiftY = sceneUpdateData.shiftY !== undefined ? (this.shiftY - sceneUpdateData.shiftY) : 0;

    // If not gridless and grid size is not already being updated, adjust the grid size, ensuring the minimum
    if ( (this.grid.type !== CONST.GRID_TYPES.GRIDLESS) && !foundry.utils.hasProperty(sceneUpdateData, "grid.size") ) {
      const gridSize = Math.round(this._source.grid.size * averageTranslationScale);
      if ( gridSize < CONST.GRID_MIN_SIZE ) throw new Error(_loc("SCENE.GridSizeError"));
      foundry.utils.setProperty(sceneUpdateData, "grid.size", gridSize);
    }

    const adjustPoint = (x, y, applyOffset=true) => {
      return {
        x: Math.round((x * translationScaleX) + (applyOffset ? paddingOffsetX + shiftX : 0) ),
        y: Math.round((y * translationScaleY) + (applyOffset ? paddingOffsetY + shiftY : 0) )
      };
    };

    // Placeables that have just a Position
    for ( const collection of ["tokens", "lights", "sounds"] ) {
      sceneUpdateData[collection] = this[collection].map(p => {
        const {x, y} = adjustPoint(p.x, p.y);
        return {_id: p.id, x, y, _movementHistory: null};
      });
    }

    // Placeables that have a Position and a Size
    for ( const collection of ["tiles"] ) {
      sceneUpdateData[collection] = this[collection].map(p => {
        const {x, y} = adjustPoint(p.x, p.y);
        const width = Math.round(p.width * translationScaleX);
        const height = Math.round(p.height * translationScaleY);
        return {_id: p.id, x, y, width, height};
      });
    }

    // Notes have both a position and an icon size
    sceneUpdateData.notes = this.notes.map(p => {
      const {x, y} = adjustPoint(p.x, p.y);
      const iconSize = Math.max(32, Math.round(p.iconSize * averageTranslationScale));
      const fontSize = Math.clamp(Math.round(p.fontSize * averageTranslationScale), 8, 128);
      return {_id: p.id, x, y, iconSize, fontSize};
    });

    // Drawings possibly have relative shape points
    sceneUpdateData.drawings = this.drawings.map(p => {
      const {x, y} = adjustPoint(p.x, p.y);
      const width = Math.round(p.shape.width * translationScaleX);
      const height = Math.round(p.shape.height * translationScaleY);
      const points = [];
      if ( p.shape.points ) {
        for ( let i = 0; i < p.shape.points.length; i += 2 ) {
          const {x, y} = adjustPoint(p.shape.points[i], p.shape.points[i+1], false);
          points.push(x);
          points.push(y);
        }
      }
      return {_id: p.id, x, y, "shape.width": width, "shape.height": height, "shape.points": points};
    });

    // Walls are two points
    sceneUpdateData.walls = this.walls.map(w => {
      const c = w.c;
      const p1 = adjustPoint(c[0], c[1]);
      const p2 = adjustPoint(c[2], c[3]);
      return {_id: w.id, c: [p1.x, p1.y, p2.x, p2.y]};
    });

    // Region shapes
    sceneUpdateData.regions = this.regions.map(r => {
      return {_id: r.id, shapes: r.shapes.map(s => {
        s = s.clone();
        s.move(adjustPoint(s.origin.x, s.origin.y));
        return s.toObject();
      }), _shapeConstraints: null};
    });

    return sceneUpdateData;
  }

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

  /** @inheritDoc */
  static async _onUpdateOperation(documents, operation, user) {
    await super._onUpdateOperation(documents, operation, user);

    // Only activate or deactivate a single Scene per operation
    let nowActive;
    let nowInactive;
    for ( const change of operation.updates ) {
      if ( change.active === true ) {
        nowActive = documents.find(d => d._id === change._id);
        break;
      } else if ( (change.active === false) && (change._id === operation.priorActiveScene) ) {
        nowInactive = documents.find(d => d._id === change._id);
      }
    }
    if ( nowActive ) nowActive._onActivate(true, operation);
    else nowInactive?._onActivate(false, operation);
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    if ( !("thumb" in changed) && (options.thumb ?? []).includes(this.id) ) changed.thumb = this.thumb;
    super._onUpdate(changed, options, userId);
    const changedKeys = new Set(Object.keys(foundry.utils.flattenObject(changed)).filter(k => k !== "_id"));
    const embeddedCollections = Object.values(this.constructor.metadata.embedded);

    // Reinitialize edges when required.
    // This reinitialization is only needed because https://github.com/foundryvtt/foundryvtt/issues/5241
    // has not been implemented. We proactively uninitialize edges for any modified embedded
    // collections so that systems/modules which attach edges to placeable objects do not
    // need to handle this edge uninitialization logic themselves.
    if ( embeddedCollections.some(k => k in changed) ) this._resetEdges();

    // Changes to the scene's dimensions and grid may change the geometry of regions or containment of tokens
    this.#onUpdateRegions(changed, options, userId);

    // Change audio
    const audioChange = changed.active || (this.active && ["playlist", "playlistSound"].some(k => k in changed));
    if ( audioChange && (game.userId === userId) ) game.playlists._onChangeScene(this);

    // If the Thumbnail was updated, bust the image cache
    if ( ("thumb" in changed) && this.thumb ) {
      this.thumb = `${this.thumb.split("?")[0]}?${Date.now()}`;
    }

    // If the scene is already active, maybe re-draw the canvas
    if ( canvas.scene === this ) {

      // New initial view position
      const initializeCanvasPosition = ["grid.size", "initial.x", "initial.y", "initial.scale", "width", "height",
        "padding"].some(k => changedKeys.has(k));
      if ( initializeCanvasPosition ) this._viewPosition = { level: this._viewPosition.level };

      // Fully redraw the canvas
      const redraw = [
        "width", "height", "padding", "shiftX", "shiftY",         // Scene Dimensions
        "grid.type", "grid.size", "grid.distance", "grid.units",  // Grid Configuration
        ...embeddedCollections,                                   // Embedded Collections
        "weather"                                                 // Ambience
      ];
      if ( redraw.some(k => changedKeys.has(k)) ) return canvas.draw();

      // Update grid mesh
      if ( "grid" in changed ) canvas.interface.grid.initializeMesh(this.grid);

      // Modify vision conditions
      const fogModeChanged = changedKeys.has("fog.mode");
      const perceptionAttrs = ["globalLight", "tokenVision"];
      if ( perceptionAttrs.some(k => changedKeys.has(k)) ) canvas.perception.initialize();
      if ( ("tokenVision" in changed) && !fogModeChanged ) {
        for ( const token of canvas.tokens.placeables ) token.initializeVisionSource();
      }

      // Reload fog when the exploration mode changes.
      if ( fogModeChanged ) {
        ui.notifications.info("SCENE.FogModeChangeMessage", {localize: true});
        canvas.fog.load({preserve: true}).then(() => {
          canvas.perception.initialize();
          for ( const token of canvas.tokens.placeables ) token.initializeVisionSource();
        });
      }

      // Progress darkness level
      if ( changedKeys.has("environment.darknessLevel") && options.animateDarkness ) {
        return canvas.effects.animateDarkness(changed.environment.darknessLevel, {
          duration: typeof options.animateDarkness === "number" ? options.animateDarkness : undefined
        });
      }

      // Initialize the color manager with the new darkness level and/or scene background color
      if ( ("environment" in changed)
        || ["backgroundColor", "fog.colors.unexplored", "fog.colors.explored"].some(k => changedKeys.has(k)) ) {
        canvas.environment.initialize();
      }

      // Re-initialize canvas position
      if ( initializeCanvasPosition ) canvas.initializeCanvasPosition();

      /**
       * @type {SceneConfig}
       */
      const sheet = this.sheet;
      if ( changedKeys.has("environment.darknessLock") ) {
        // Reset scene controls to omit darkness level transition buttons
        ui.controls.render({reset: true});
        // Live preview if the sheet is rendered (force all)
        if ( sheet?.rendered ) sheet._previewScene("environment.darknessLock", {force: true});
      }
    }
  }

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

  /**
   * Handle Region-specific _onUpdate behavior
   * @param {object} changed   The changes to the scene.
   * @param {object} options  The update operation options.
   * @param {string} userId    The user that performed the update.
   */
  #onUpdateRegions(changed, options, userId) {

    // Update Region shape constraints if necessary
    if ( ["width", "height", "padding", "lights", "regions", "tokens", "walls"].some(k => k in changed) ) {
      this.updateRegionShapeConstraints();
    }

    const gridTypeOrSizeChanged = ("grid" in changed) && (("type" in changed.grid) || ("size" in changed.grid));
    if ( !gridTypeOrSizeChanged ) return;

    // Update region containment of tokens
    if ( game.user.isActiveGM ) {
      this.updateRegionShapeConstraints();
      this.updateTokenRegions();
    }

    // Emit boundary changed events
    const user = game.users.get(userId);
    for ( const region of this.regions ) {
      const affectedByGrid = region.shapes.some(shape => shape.isAffectedByGrid);
      if ( !affectedByGrid ) continue;
      region._handleEvent({
        name: CONST.REGION_EVENTS.REGION_BOUNDARY,
        data: {},
        region,
        user
      });
    }
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    this.unview();
    for ( const token of this.tokens ) {
      token.baseActor?._unregisterDependentScene(this);
    }

    // Change audio state
    if ( this.active && (game.userId === userId) ) {
      game.playlists._onChangeScene(null);
    }

    // Trigger Region Behavior status events
    const user = game.users.get(userId);
    for ( const region of this.regions ) {
      region._handleEvent({name: CONST.REGION_EVENTS.BEHAVIOR_DEACTIVATED, data: {}, region, user});
      /** @deprecated since v13 */
      region._handleEvent({name: "behaviorStatus", data: {active: false}, region, user});
    }
  }

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

  /**
   * Handle Scene activation workflow if the active state is changed to true.
   * @param {boolean} active                                             Is the scene now active?
   * @param {DatabaseCreateOperation|DatabaseUpdateOperation} operation  The database operation
   * @protected
   */
  _onActivate(active, operation) {
    if ( active ) this.view(operation.viewOptions);
    else this.unview();
  }

  /* -------------------------------------------- */
  /*  Descendant Document Operations              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _preCreateDescendantDocuments(parent, collection, data, options, userId) {
    super._preCreateDescendantDocuments(parent, collection, data, options, userId);

    // Record layer history for child embedded documents
    if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) {
      const layer = canvas.getCollectionLayer(collection);
      layer?.storeHistory("create", data.map(d => ({_id: d._id})), options);
    }
  }

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

  /** @inheritDoc */
  _preUpdateDescendantDocuments(parent, collection, changes, options, userId) {
    if ( collection === "tokens" ) foundry.documents.TokenDocument._addTeleportAndForcedShims(options);
    super._preUpdateDescendantDocuments(parent, collection, changes, options, userId);
    if ( parent !== this ) return;
    const documentCollection = this.getEmbeddedCollection(collection);

    // Record layer history for child embedded documents
    if ( (userId === game.userId) && this.isView && !options.isUndo ) {
      const originals = [];
      for ( const change of changes ) {
        const doc = documentCollection.get(change._id);
        const source = doc.toObject();
        const original = foundry.utils.filterObject(source, change);
        if ( "flags" in change ) { // Special handling of flag additions
          original.flags ??= {};
          for ( const flag in change.flags ) {
            if ( !(flag in original.flags) ) original.flags[flag] = _del;
          }
        }
        originals.push(original);
      }
      const layer = canvas.getCollectionLayer(collection);
      layer?.storeHistory("update", originals, options);
    }
  }

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

  /** @inheritDoc */
  _preDeleteDescendantDocuments(parent, collection, ids, options, userId) {
    super._preDeleteDescendantDocuments(parent, collection, ids, options, userId);

    // Record layer history for child embedded documents
    if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) {
      const documentCollection = this.getEmbeddedCollection(collection);
      const originals = ids.reduce((data, id) => {
        const doc = documentCollection.get(id);
        if ( doc ) data.push(doc.toObject());
        return data;
      }, []);
      const layer = canvas.getCollectionLayer(collection);
      layer?.storeHistory("delete", originals, options);
    }
  }

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

  /** @inheritDoc */
  _onCreateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    super._onCreateDescendantDocuments(parent, collection, documents, changes, options, userId);
    if ( (parent === this) && (collection === "levels") ) {
      this.#handleDescendantLevelChanges("create", documents, changes);
    }
  }

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

  /** @inheritDoc */
  _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
    if ( (parent === this) && documents.some(doc => doc.object?.hasActiveHUD) ) {
      canvas.getCollectionLayer(collection).hud.render();
    }
    if ( (parent === this) && (collection === "levels") ) {
      this.#handleDescendantLevelChanges("update", documents, changes);
    }
  }

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

  /** @inheritDoc */
  _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
    super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
    if ( (parent === this) && (collection === "levels") ) this.#handleDescendantLevelChanges("delete", documents);
  }

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

  /**
   * When a Level changes as an embedded document operation, determine what actions we need to take for the Scene.
   * @param {DatabaseAction} operation
   * @param {Level[]} documents
   * @param {object[]} [changes]
   */
  #handleDescendantLevelChanges(operation, documents, changes) {

    // Determine whether it is necessary to redraw the canvas
    let redraw = false;
    switch ( operation ) {
      case "create":
        redraw = (this.levels.size - changes.length) <= 0; // There were no levels previously
        break;
      case "update":
        for ( const change of changes ) {
          const level = this.levels.get(change._id);
          if ( !level.isVisible ) continue;
          const redrawKeys = ["background", "elevation", "foreground", "fog", "textures", "visibility"];
          redraw = redrawKeys.some(k => k in change);
          if ( redraw ) break;
        }
        break;
      case "delete":
        redraw = documents.some(level => level.isVisible);
        break;
    }
    if ( redraw ) {
      canvas.draw();
      return;
    }

    // Re-render Scene Navigation
    ui.nav.render();
  }

  /* -------------------------------------------- */
  /*  Importing and Exporting                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  toCompendium(pack, options={}) {
    const data = super.toCompendium(pack, options);
    if ( options.clearState !== false ) delete data.fog.reset;
    if ( options.clearSort !== false ) {
      data.navigation = false;
      delete data.navOrder;
    }
    return data;
  }

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

  /**
   * Create a 300px by 100px thumbnail image for this scene background
   * @param {object} [options]              Options which modify thumbnail creation
   * @param {string|Level} [options.level]  The Level to generated the thumbnail for. Defaults to the initial level.
   * @param {number} [options.width=300]    The desired thumbnail width. Default is 300px
   * @param {number} [options.height=100]   The desired thumbnail height. Default is 100px;
   * @param {string} [options.format="image/webp"]  Which image format should be used? image/png, image/jpeg,
   *                                                or image/webp. Default is image/webp.
   * @param {number} [options.quality=0.8]  What compression quality should be used for jpeg or webp, between 0 and 1.
   *                                        Default is 0.8.
   * @returns {Promise<{thumb: string; width: number; height: number; format: string; quality: number}>}
   *   The created thumbnail data.
   */
  async createThumbnail(options={}) {
    if ( game.settings.get("core", "noCanvas") ) throw new Error(_loc("SCENE.GenerateThumbNoCanvas"));
    if ( !game.user.can("FILES_UPLOAD") ) {
      throw new Error(_loc("SCENE.GenerateThumbUploadDenied"), {cause: {thumbUploadDenied: true}});
    }
    let {level, width=300, height=100, format="image/webp", quality=0.8} = options;
    if ( typeof level === "string" ) level = this.levels.get(level);
    else level ??= this.initialLevel;
    if ( level?.parent !== this ) throw new Error("The Level doesn't belong to this Scene");

    // Clone the scene and simulate view of the level
    const scene = this.clone();
    scene._view = level.id; // Simulate view
    level = scene.levels.get(level.id);

    /** @deprecated since v14 */
    let img;
    if ( "img" in options ) {
      foundry.utils.logCompatibilityWarning("The img parameter of Scene#createThumbnail is deprecated. "
        + "Instead create a clone of the Scene with the necessary changes to the Level textures and "
        + "then use Scene#createThumbnail on the cloned Scene.", {since: 14, until: 16, once: true});
      img = options.img ?? undefined; // Be sure to turn null to undefined
    }
    const newBackgroundTexture = img !== undefined ? await foundry.canvas.loadTexture(img) : null;
    if ( newBackgroundTexture ) {
      scene.updateSource({width: newBackgroundTexture.width, height: newBackgroundTexture.height});
      level.updateSource({background: {src: img}});
    }

    // Get all visible tiles
    const tiles = scene.tiles.filter(t => t.texture.src && !t.hidden && t.viewed);

    // Load required textures to create the thumbnail
    const toLoad = tiles.map(t => t.texture.src);
    for ( const lt of scene._configureLevelTextures() ) toLoad.push(lt.src);
    await foundry.canvas.TextureLoader.loader.load(toLoad, {message: "SCENE.GenerateThumbProgress", format: {name: this.name}});

    // Create a container and enforce the size
    const sceneRect = scene.dimensions.sceneRect;
    const baseContainer = new PIXI.Container();
    const sceneRectMask = baseContainer.addChild(new PIXI.LegacyGraphics());
    sceneRectMask.beginFill().drawShape(sceneRect).endFill();
    baseContainer.mask = sceneRectMask;
    const sceneRectFrame = baseContainer.addChild(new PIXI.Sprite(PIXI.Texture.EMPTY));
    sceneRectFrame.x = sceneRect.x;
    sceneRectFrame.y = sceneRect.y;
    sceneRectFrame.width = sceneRect.width;
    sceneRectFrame.height = sceneRect.height;

    // Simulate the way a sprite is drawn
    const drawMesh = ({x, y, rotation, width, height, elevation, sort, sortLayer, zIndex,
      src, tint, anchorX, anchorY, offsetX, offsetY, fit, scaleX, scaleY}) => {
      const texture = foundry.canvas.getTexture(src);
      if ( !texture ) return;
      const mesh = new PIXI.Sprite(texture);
      mesh.anchor.set(anchorX, anchorY);
      mesh.position.set(x + offsetX, y + offsetY);
      foundry.canvas.primary.PrimarySpriteMesh.prototype.resize.call(mesh, width, height, {fit, scaleX, scaleY});
      mesh.angle = rotation;
      mesh.elevation = elevation;
      mesh.sort = sort;
      mesh.sortLayer = sortLayer;
      mesh.zIndex = zIndex;
      if ( tint ) mesh.tint = tint;
      return mesh;
    };

    // Draw Level texture meshes
    for ( const lt of scene._configureLevelTextures() ) {
      const {center: {x, y}, width, height} = scene.dimensions.sceneRect;
      const sortLayer = foundry.canvas.groups.PrimaryCanvasGroup.SORT_LAYERS.SCENE;
      const sprite = drawMesh({...lt, ...lt.level.textures, x, y, width, height, sortLayer, zIndex: -Infinity});
      if ( sprite ) baseContainer.addChild(sprite);
    }

    // Draw Tile meshes
    for ( const tile of tiles ) {
      const {x, y, rotation, width, height, elevation, sort} = tile;
      const {src, tint, anchorX, anchorY, fit, scaleX, scaleY} = tile.texture;
      const sortLayer = foundry.canvas.groups.PrimaryCanvasGroup.SORT_LAYERS.TILES;
      const sprite = await drawMesh({x, y, rotation, width, height, elevation, sort, sortLayer, zIndex: 0, src, tint,
        anchorX, anchorY, offsetX: 0, offsetY: 0, fit, scaleX, scaleY});
      if ( sprite ) baseContainer.addChild(sprite);
    }

    // Sort meshes
    baseContainer.children.sort(foundry.canvas.groups.PrimaryCanvasGroup._compareObjects);

    // Render the container to a thumbnail
    const stage = new PIXI.Container();
    stage.addChild(baseContainer);
    return foundry.helpers.media.ImageHelper.createThumbnail(stage, {width, height, format, quality});
  }

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

  /** @inheritDoc */
  static getCollectionName(name) {
    /** @deprecated since v14 */
    if ( name === "MeasuredTemplate" ) {
      foundry.utils.logCompatibilityWarning("Scene#templates is deprecated without replacement.",
        {since: 14, until: 16, once: true});
      return "templates";
    }
    return super.getCollectionName(name);
  }

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

  /** @inheritDoc */
  getEmbeddedCollection(embeddedName) {
    /** @deprecated since v14 */
    if ( embeddedName === "MeasuredTemplate" ) return this.templates;
    return super.getEmbeddedCollection(embeddedName);
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  get templates() {
    foundry.utils.logCompatibilityWarning("Scene#templates is deprecated because the MeasuredTemplate document has been merged into the functionality of the Region document.",
      {since: 14, until: 16, once: true});
    const {MeasuredTemplateDocument, RegionDocument} = foundry.documents;
    const sourceArray = [];
    for ( const regionData of this.regions._source ) {
      if ( !foundry.utils.getProperty(regionData, "flags.core.MeasuredTemplate") ) continue;
      const region = RegionDocument.implementation.fromSource(regionData, {parent: this});
      const template = MeasuredTemplateDocument._fromRegion(region);
      sourceArray.push(template.toObject());
    }
    const templates = new foundry.abstract.EmbeddedCollection("templates", this, sourceArray);
    Object.defineProperties(templates, {
      documentClass: {value: MeasuredTemplateDocument.implementation, writable: false},
      documentsByType: {value: {base: [MeasuredTemplateDocument.implementation]}}
    });
    templates.initialize();
    return templates;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  get background() {
    foundry.utils.logCompatibilityWarning("Scene#background is deprecated. Use Level#background and Level#textures instead.",
      {since: 14, until: 16, once: true});
    const background = {};
    const firstLevel = this.firstLevel ?? {};
    for ( const [oldKey, newKey] of BaseScene._LEVELS_PROPERTY_MAP ) {
      if ( !oldKey.startsWith("background.") ) continue;
      const key = oldKey.slice(11);
      foundry.utils.setProperty(background, key, foundry.utils.getProperty(firstLevel, newKey));
    }
    background.offsetX = this.shiftX;
    background.offsetY = this.shiftY;
    return background;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  get backgroundColor() {
    foundry.utils.logCompatibilityWarning("Scene#backgroundColor is deprecated. Use Level#background#color instead.", {
      since: 14, until: 16
    });
    return this.firstLevel?.background?.color;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  get foreground() {
    foundry.utils.logCompatibilityWarning("Scene#foreground is deprecated. Use Level#foreground.src instead.",
      {since: 14, until: 16, once: true});
    return this.firstLevel?.foreground?.src;
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  get foregroundElevation() {
    foundry.utils.logCompatibilityWarning("Scene#foregroundElevation is deprecated. Use Level#elevation.top instead.",
      {since: 14, until: 16, once: true});
    return this.firstLevel?.elevation.top;
  }
}
