import SpriteMesh from "../containers/elements/sprite-mesh.mjs";
import TextureExtractor from "../texture-extractor.mjs";
import Canvas from "../board.mjs";
import FogSamplerShader from "../rendering/shaders/samplers/fog-of-war.mjs";
import EventEmitterMixin from "@common/utils/event-emitter.mjs";
import {getDocumentClass} from "../../utils/helpers.mjs";

/**
 * @import Semaphore from "@common/utils/semaphore.mjs";
 * @import {CanvasVisibilityTextureConfiguration, FogExplorationData, Point} from "../../_types.mjs";
 * @import FogExploration from "../../documents/fog-exploration.mjs";
 */

/**
 * A fog of war management class which is the singleton canvas.fog instance.
 * @category Canvas
 * @see {EventEmitterMixin}
 */
export default class FogManager extends EventEmitterMixin() {
  /** @override */
  static emittedEvents = Object.freeze(["explored"]);

  /**
   * The FogExploration document which applies to this canvas view
   * @type {FogExploration|null}
   */
  exploration = null;

  /**
   * A status flag for whether the layer initialization workflow has succeeded
   * @type {boolean}
   */
  #initialized = false;

  /**
   * Track whether we have pending fog updates which have not yet been saved to the database
   * @type {boolean}
   * @internal
   */
  _updated = false;

  /**
   * Texture extractor
   * @type {TextureExtractor}
   */
  get extractor() {
    return this.#extractor;
  }

  #extractor;

  /**
   * Matrix used for fog rendering transformation.
   * @type {PIXI.Matrix}
   */
  #renderTransform = new PIXI.Matrix();

  /**
   * A debounced function to save fog of war exploration once a continuous stream of updates has concluded.
   * @type {Function}
   */
  #debouncedSave = foundry.utils.debounce(this.save.bind(this), 2000);

  /**
   * Debounced handler used to resync fog after the app becomes active again.
   * @type {Function}
   */
  #debouncedResume = foundry.utils.debounce(this.#onApplicationResumed.bind(this), 250);

  /**
   * Socket listener for explicit shared fog updates.
   * @type {Function}
   */
  #shareFogListener = this.#handleShareFog.bind(this);

  /**
   * Document visibilitychange listener.
   * @type {Function}
   */
  #visibilityListener = this.#onVisibilityChange.bind(this);

  /**
   * Handling of the concurrency for fog loading, saving and reset.
   * @type {Semaphore}
   */
  #queue = new foundry.utils.Semaphore();

  /**
   * The explored data.
   * @type {{
   *   pixels: Uint8ClampedArray,
   *   width: number,
   *   height: number,
   *   offset: number,
   *   stride: number,
   *   buffer: ArrayBuffer,
   *   extracting: boolean
   * }}
   */
  #explored = {
    pixels: new Uint8ClampedArray(0),
    width: 0,
    height: 0,
    offset: 0,
    stride: 1,
    buffer: new ArrayBuffer(0),
    extracting: false
  };

  /* -------------------------------------------- */
  /*  Fog Manager Properties                      */
  /* -------------------------------------------- */

  /**
   * The exploration SpriteMesh which holds the fog exploration texture.
   * @type {SpriteMesh}
   */
  get sprite() {
    return this.#explorationSprite || (this.#explorationSprite = this._createExplorationObject());
  }

  #explorationSprite;

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

  /**
   * The configured options used for the saved fog-of-war texture.
   * @type {CanvasVisibilityTextureConfiguration}
   */
  get textureConfiguration() {
    return canvas.visibility.textureConfiguration;
  }

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

  /**
   * Does the currently viewed Scene support Token field of vision?
   * @type {boolean}
   */
  get tokenVision() {
    return canvas.scene.tokenVision;
  }

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

  /**
   * Does the currently viewed Scene support fog of war exploration?
   * @type {boolean}
   */
  get fogExploration() {
    return canvas.scene.fog.mode > CONST.FOG_EXPLORATION_MODES.DISABLED;
  }

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

  /**
   * Does the currently viewed Scene is in shared fog exploration?
   * @type {boolean}
   */
  get sharedExploration() {
    return canvas.scene.fog.mode === CONST.FOG_EXPLORATION_MODES.SHARED;
  }

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

  /**
   * Is this position explored?
   * @param {Point} position      The position to be tested
   * @returns {boolean}           Is this position explored?
   */
  isPointExplored({x, y}) {
    if ( !this.#initialized ) return false;
    if ( !this.tokenVision ) return true;
    const sprite = this.#explorationSprite;
    if ( sprite?.destroyed !== false ) return false;
    x = (x - sprite.x) / sprite.width;
    y = (y - sprite.y) / sprite.height;
    if ( (x < 0) || (x >= 1) || (y < 0) || (y >= 1) ) return false;
    const {pixels, width, height, offset, stride} = this.#explored;
    const x1 = (x * width) | 0;
    const x0 = x1 > 0 ? x1 - 1 : 0;
    const x2 = x1 < width ? x1 + 2 : width;
    const y1 = (y * height) | 0;
    const y0 = y1 > 0 ? y1 - 1 : 0;
    const y2 = y1 < height ? y1 + 2 : height;
    for ( let y = y0; y < y2; y++ ) {
      const k = y * width;
      for ( let x = x0; x < x2; x++ ) {
        if ( pixels[((k + x) * stride) + offset] !== 0 ) return true;
      }
    }
    return false;
  }

  /* -------------------------------------------- */
  /*  Fog of War Management                       */
  /* -------------------------------------------- */

  /**
   * Extract the pixels from the fog texture.
   * @returns {Promise<void>}
   */
  async #extractPixels() {
    if ( !this.#extractor ) return;
    const texture = this.#explorationSprite?.texture;
    if ( !texture?.valid ) return;
    if ( this.#explored.extracting ) {
      this.#throttleExtractPixels();
      return;
    }
    this.#explored.extracting = true;
    const explored = this.#explored;
    const {realWidth, realHeight} = texture.baseTexture;
    const size = this.#extractor.format === PIXI.FORMATS.RED ? 1 : 4;
    const minBufferSize = realWidth * realHeight * size;
    if ( explored.buffer.byteLength < minBufferSize ) {
      explored.buffer = new ArrayBuffer(minBufferSize);
    }
    try {
      const {pixels, width, height, out} = await this.#extractor.extract(
        {texture, compression: TextureExtractor.COMPRESSION_MODES.NONE, out: explored.buffer});

      // A different fog texture was loaded while extraction was pending
      if ( this.#explorationSprite?.texture !== texture ) {
        explored.buffer = out;
        return;
      }

      // No change was detected, but ownership of the output buffer may have changed
      if ( !pixels ) {
        explored.buffer = out;
        return;
      }

      // Swap buffers so the previous pixels buffer can be reused on the next extraction
      explored.buffer = explored.pixels.buffer;
      explored.pixels = pixels;
      explored.width = width;
      explored.height = height;
      explored.offset = size - 1;
      explored.stride = size;
      this.#onExploredChanged();
    }
    finally {
      explored.extracting = false;
    }
  }

  /**
   * A throttled function that extract pixels from the fog texture.
   * @type {() => void}
   */
  #throttleExtractPixels = foundry.utils.throttle(this.#extractPixels.bind(this), 500);

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

  /**
   * Called when the explored data changed.
   */
  #onExploredChanged() {
    if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Explored area changed.");
    this.dispatchEvent(new Event("explored"));
  }

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

  /**
   * Create a valid FogExploration document for the current canvas context.
   * @param {Partial<FogExplorationData>} [data={}]
   * @returns {FogExploration}
   * @protected
   */
  _createExplorationDocument(data={}) {
    /** @type {typeof FogExploration} */
    const fogExplorationCls = getDocumentClass("FogExploration");
    return new fogExplorationCls({
      scene: canvas.scene.id,
      level: canvas.level.id,
      user: game.user.id,
      ...data
    });
  }

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

  /**
   * Create the exploration display object with or without a provided texture.
   * @param {PIXI.Texture|PIXI.RenderTexture} [tex] Optional exploration texture.
   * @returns {SpriteMesh}
   * @internal
   */
  _createExplorationObject(tex) {
    const {width, height} = this.textureConfiguration;
    const sprite = new SpriteMesh(tex ?? Canvas.getRenderTexture({
      clearColor: [0, 0, 0, 1],
      textureConfiguration: this.textureConfiguration
    }), FogSamplerShader);
    sprite.width = width;
    sprite.height = height;
    return sprite;
  }

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

  /**
   * Initialize fog of war - resetting it when switching scenes or re-drawing the canvas
   * @returns {Promise<void>}
   */
  async initialize() {
    this.#initialized = false;

    // Create a TextureExtractor instance
    if ( this.#extractor === undefined ) {
      try {
        this.#extractor = new TextureExtractor(canvas.app.renderer, {
          callerName: "FogExtractor",
          controlHash: true,
          format: this.textureConfiguration?.format ?? PIXI.FORMATS.RED
        });
        this.#extractor.debug = CONFIG.debug.fog.extractor;
      } catch(e) {
        this.#extractor = null;
        console.error(e);
      }
    }
    this.#extractor?.reset();

    // Register socket listener once
    game.socket.off("shareFog", this.#shareFogListener);
    game.socket.on("shareFog", this.#shareFogListener);

    // Register activity listeners once
    document.removeEventListener("visibilitychange", this.#visibilityListener);
    document.addEventListener("visibilitychange", this.#visibilityListener);

    // Load the initial fog texture
    await this.load();
    this.#initialized = true;
  }

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

  /**
   * Handle document visibility changes.
   */
  #onVisibilityChange() {
    if ( document.hidden ) return;
    this.#debouncedResume();
  }

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

  /**
   * Resync shared fog after the app becomes active again.
   */
  async #onApplicationResumed() {
    if ( !this.#initialized ) return;
    if ( !canvas.scene ) return;

    // Only resync when shared exploration is active
    if ( !this.sharedExploration ) return;
    if ( !this.tokenVision || !this.fogExploration ) return;

    try {
      await this.load({preserve: true});
      canvas.perception.initialize();
      for ( const token of canvas.tokens.placeables ) token.initializeVisionSource();
    }
    catch(err) {
      console.error(err);
    }
  }

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

  /**
   * Clear the fog and reinitialize properties (commit and save in non reset mode)
   * @returns {Promise<void>}
   */
  async clear() {
    // Save any pending exploration
    try {
      await this.save();
    } catch(e) {
      ui.notifications.error("Failed to save fog exploration");
      console.error(e);
    }

    // Deactivate current fog exploration
    this.#initialized = false;
    this.#deactivate();
  }

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

  /**
   * Destroy this FogManager.
   */
  destroy() {
    this.exploration = null;
    this.#initialized = false;
    this.#debouncedSave.cancel();
    this.#debouncedResume.cancel();

    // Unregister socket listener
    game.socket.off("shareFog", this.#shareFogListener);

    // Unregister activity listeners
    document.removeEventListener("visibilitychange", this.#visibilityListener);

    this.#extractor?.destroy();
    this.#extractor = undefined;
    this._updated = false;
    if ( this.#explorationSprite && !this.#explorationSprite.destroyed ) this.#explorationSprite.destroy(true);
    this.#explorationSprite = undefined;
  }

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

  /**
   * Once a new Fog of War location is explored, composite the explored container with the current staging sprite.
   * Once the number of refresh is > to the commit threshold, save the fog texture to the database.
   */
  commit() {
    const vision = canvas.visibility.vision;
    if ( !vision?.children.length || !this.fogExploration || !this.tokenVision ) return;
    if ( !this.#explorationSprite?.texture.valid ) return;

    // Get a staging texture or clear and render into the sprite if its texture is a RT
    // and render the entire fog container to it
    const isRenderTex = this.#explorationSprite.texture instanceof PIXI.RenderTexture;
    const tex = isRenderTex ? this.#explorationSprite.texture : Canvas.getRenderTexture({
      clearColor: [0, 0, 0, 1],
      textureConfiguration: this.textureConfiguration
    });
    this.#renderTransform.tx = -this.#explorationSprite.x;
    this.#renderTransform.ty = -this.#explorationSprite.y;

    // Render the currently revealed vision (preview excluded) to the texture
    vision.containmentFilter.enabled = canvas.visibility.needsContainment;
    vision.light.preview.visible = false;
    vision.light.mask.preview.visible = false;
    vision.sight.preview.visible = false;
    canvas.app.renderer.render(isRenderTex ? vision : this.#explorationSprite, {
      renderTexture: tex,
      clear: false,
      transform: this.#renderTransform
    });
    vision.light.preview.visible = true;
    vision.light.mask.preview.visible = true;
    vision.sight.preview.visible = true;
    vision.containmentFilter.enabled = false;

    if ( !isRenderTex ) this.#explorationSprite.texture.destroy(true);
    this.#explorationSprite.texture = tex;

    this._updated = true;
    this.#throttleExtractPixels();

    if ( !this.exploration ) this.exploration = this._createExplorationDocument();

    this.#debouncedSave();
  }

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

  /**
   * Load existing fog of war data from local storage and populate the initial exploration sprite.
   * @param {object} [options]
   * @param {boolean} [options.preserve=false]  Preserve current fog until the new one is ready.
   * @returns {Promise<PIXI.Texture|void>}
   */
  async load({preserve=false}={}) {
    return this.#queue.add(this.#load.bind(this), {preserve});
  }

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

  /**
   * Load existing fog of war data from local storage and populate the initial exploration sprite
   * @param {object} [options]
   * @param {boolean} [options.preserve=false]  Preserve current fog until the new one is ready.
   * @returns {Promise<PIXI.Texture|void>}
   */
  async #load({preserve=false}={}) {
    if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Loading saved FogExploration for Scene.");

    // Preserve mode keeps the currently displayed fog until the new one is ready
    if ( !preserve ) this.#deactivate();

    // Take no further action if token vision is not enabled
    if ( !this.tokenVision ) return;

    const scene = canvas.scene;
    const sceneId = scene?.id;
    if ( !sceneId ) return;

    // Union is only required in SHARED exploration mode
    const level = scene._view;
    const requireUnion = this.fogExploration && this.sharedExploration;
    const fogExplorationCls = /** @type {typeof FogExploration} */ getDocumentClass("FogExploration");

    // One load operation: server may return multiple docs, load() returns only the requested user's doc
    this.exploration = await fogExplorationCls.load({}, {queryOptions: {sharedFog: requireUnion}});

    const assign = async tex => {
      const sprite = this.#explorationSprite;

      // Preserve mode: swap texture without destroying the exploration container
      if ( preserve && sprite ) {
        const prev = sprite.texture;
        if ( prev !== tex ) {
          sprite.texture = tex;
          prev?.destroy?.(true);
        }
        canvas.visibility.resetExploration();
        canvas.perception.initialize();
        await this.#extractPixels();
        return tex;
      }

      if ( sprite?.texture === tex ) return tex;
      sprite?.destroy(true);

      this.#explorationSprite = this._createExplorationObject(tex);
      canvas.visibility.resetExploration();
      canvas.perception.initialize();
      await this.#extractPixels();
      return tex;
    };

    // If required, players use union of player fogs and GMs use union of player fogs + their private fog
    if ( requireUnion ) {
      const collection = game.collections.get("FogExploration");
      const fogs = collection.filter(f => {
        const fSceneId = f.scene?.id ?? f.scene;
        return (fSceneId === sceneId) && (f.level === level);
      });

      const {texture, updateData} = await this._unionizeSharedExploration(fogs);
      if ( canvas.scene?.id !== sceneId ) return;

      if ( updateData && !foundry.utils.isEmpty(updateData) ) {
        if ( !this.exploration ) this.exploration = this._createExplorationDocument();
        this.exploration.updateSource(updateData);
      }

      await assign(texture);
      return;
    }

    if ( !this.exploration ) {
      await assign(Canvas.getRenderTexture({
        clearColor: [0, 0, 0, 1],
        textureConfiguration: this.textureConfiguration
      }));
      return;
    }

    const tex = this.exploration.getTexture();
    if ( !tex ) {
      await assign(Canvas.getRenderTexture({
        clearColor: [0, 0, 0, 1],
        textureConfiguration: this.textureConfiguration
      }));
      return;
    }

    if ( !tex.baseTexture.valid ) await new Promise(resolve => tex.once("update", resolve));
    await assign(tex);
  }

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

  /**
   * Unionize exploration inputs for initial scene load.
   * Override this method to change union rules or to return additional exploration data (ex: positions).
   * This method must not perform any persistent DB operations.
   * @param {FogExploration[]} fogs
   * @returns {Promise<{texture: PIXI.RenderTexture, updateData: object|null}>}
   * @protected
   */
  async _unionizeSharedExploration(fogs) {
    const sources = this.#getSharedExplorationDocuments(fogs);
    const texture = await this.#buildSharedBaselineTexture(sources);
    return {texture, updateData: null};
  }

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

  /**
   * Select the FogExploration documents which contribute to shared exploration for the current level.
   * @param {FogExploration[]} fogs
   * @returns {FogExploration[]}
   */
  #getSharedExplorationDocuments(fogs) {
    const level = canvas.scene?._view;
    const docs = Array.isArray(fogs) ? fogs : [];

    // Union is always computed from player docs only
    const sources = docs.filter(f => f?.explored && (f.level === level) && !f.user?.isGM);

    // In SHARED mode, GMs are seeing union(players) + private
    if ( game.user.isGM && this.exploration?.explored && (this.exploration.level === level) ) {
      sources.push(this.exploration);
    }

    return sources;
  }

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

  /**
   * Build a unionized fog of war texture from fog exploration documents.
   * @param {FogExploration[]} fogs
   * @returns {Promise<PIXI.RenderTexture>}
   */
  async #buildSharedBaselineTexture(fogs) {
    const baseline = Canvas.getRenderTexture({
      clearColor: [0, 0, 0, 1],
      textureConfiguration: this.textureConfiguration
    });

    const sources = (Array.isArray(fogs) ? fogs : []).map(f => f?.explored).filter(Boolean);
    if ( !sources.length ) return baseline;

    await this.#compositeTextures(baseline, sources, {clear: true});
    return baseline;
  }

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

  /**
   * Handle a shared fog update from another client.
   * @param {object} data
   * @param {string} data.sceneId
   * @param {string} data.level
   * @param {string} data.explored
   * @param {object} [data.positions] optional positional/custom object.
   * @param {string} [data.from]  The sender user id.
   * @returns {Promise<void>}
   */
  async #handleShareFog({sceneId, level, explored, positions, from}={}) {
    if ( from === game.user.id ) return;
    return this.#queue.add(this.#applySharedFog.bind(this, sceneId, level, explored, positions));
  }

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

  /**
   * Apply a remote fog texture by unionizing it with the local exploration texture.
   * @param {string} sceneId
   * @param {string} level
   * @param {string} explored
   * @param {object} [positions] optional positional/custom object.
   * @returns {Promise<void>}
   */
  async #applySharedFog(sceneId, level, explored, positions) {
    if ( !sceneId || (sceneId !== canvas.scene?.id) ) return;
    if ( level !== canvas.scene?._view ) return;
    if ( typeof explored !== "string" || !explored.length ) return;
    if ( !this.tokenVision || !this.fogExploration ) return;

    positions = await this._applySharedExploration(explored, positions);

    if ( !this.exploration ) this.exploration = this._createExplorationDocument();
    if ( positions !== undefined ) this.exploration.updateSource({positions});

    // Persist the union immediately
    this._updated = true;
    this.#debouncedSave({share: false});
    this.#throttleExtractPixels();
  }

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

  /**
   * Apply shared exploration received from another client.
   * Subclasses may override this method to customize how explored texture and positions are merged locally.
   * @param {string} explored
   * @param {object} [positions]
   * @returns {Promise<object>}
   * @protected
   */
  async _applySharedExploration(explored, positions) {
    const rt = await this._createExplorationRenderTexture();
    await this.#compositeTextures(rt, [explored], {clear: false});
    return positions;
  }

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

  /**
   * Create a render texture for the exploration sprite if needed.
   * @returns {Promise<PIXI.RenderTexture>}
   * @protected
   */
  async _createExplorationRenderTexture() {
    const sprite = this.sprite;
    const current = sprite.texture;

    if ( current instanceof PIXI.RenderTexture ) return current;
    if ( current?.baseTexture && !current.baseTexture.valid ) {
      await new Promise(resolve => current.once("update", resolve));
    }

    const rt = Canvas.getRenderTexture({
      clearColor: [0, 0, 0, 1],
      textureConfiguration: this.textureConfiguration
    });

    this.#renderTransform.tx = -sprite.x;
    this.#renderTransform.ty = -sprite.y;

    // Copy existing exploration into the new RT
    canvas.app.renderer.render(sprite, {
      renderTexture: rt,
      clear: true,
      transform: this.#renderTransform
    });

    if ( current && (current !== PIXI.Texture.EMPTY) ) current.destroy(true);
    sprite.texture = rt;

    return rt;
  }

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

  /**
   * Composite textures into a render texture using MAX_COLOR.
   * @param {PIXI.RenderTexture} target
   * @param {(string|PIXI.Texture)[]} [sources=[]]
   * @param {object} [options]
   * @param {boolean} [options.clear=false]  Clear before the first draw.
   * @returns {Promise<void>}
   */
  async #compositeTextures(target, sources=[], {clear=false}={}) {
    const dims = canvas.dimensions;

    this.#renderTransform.tx = -dims.sceneX;
    this.#renderTransform.ty = -dims.sceneY;

    const mesh = new SpriteMesh(PIXI.Texture.EMPTY);
    mesh.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    mesh.position.set(dims.sceneX, dims.sceneY);
    mesh.width = this.textureConfiguration.width;
    mesh.height = this.textureConfiguration.height;

    try {
      let firstDraw = true;

      for ( const source of sources ) {
        if ( !source ) continue;

        let tex = null;
        let destroy = false;

        if ( source instanceof PIXI.Texture ) {
          tex = source;
          if ( tex?.baseTexture && !tex.baseTexture.valid ) {
            await new Promise(resolve => tex.once("update", resolve));
          }
        }
        else if ( typeof source === "string" ) {
          const bt = new PIXI.BaseTexture(source, {alphaMode: PIXI.ALPHA_MODES.NPM});
          tex = new PIXI.Texture(bt);
          destroy = true;
          if ( !tex.baseTexture.valid ) await new Promise(resolve => tex.once("update", resolve));
        }
        if ( !tex ) continue;

        mesh.texture = tex;
        canvas.app.renderer.render(mesh, {
          renderTexture: target,
          clear: clear && firstDraw,
          transform: this.#renderTransform
        });

        firstDraw = false;
        if ( destroy ) tex.destroy(true);
      }
    }
    finally {
      mesh.destroy({children: true, texture: false, baseTexture: false});
    }
  }

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

  /**
   * Dispatch a request to reset the fog of war exploration status for all users within this Scene.
   * Once the server has deleted existing FogExploration documents, the _onReset handler will re-draw the canvas.
   * @returns {Promise<void>}
   */
  async reset() {
    if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Resetting fog of war exploration for Scene.");
    game.socket.emit("resetFog", canvas.scene.id);
  }

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

  /**
   * Request a fog of war save operation.
   * Note: if a save operation is pending, we're waiting for its conclusion.
   * @param {object} [options]
   * @param {boolean} [options.share=false]  Broadcast the fog to other clients for local unionization.
   * @returns {Promise<void>}
   */
  async save({share=false}={}) {
    return this.#queue.add(this.#save.bind(this), {share});
  }

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

  /**
   * Perform a fog of war save operation.
   * @param {object} [options]
   * @param {boolean} [options.share=false]
   * @returns {Promise<void>}
   */
  async #save({share=false}={}) {
    const needsPersist = this._updated;
    if ( !needsPersist && !share ) return;

    // Share-only shortcut: trigger server-side broadcast using the last persisted payload
    const canShareWithoutExtract = share && this.exploration?.id && this.exploration.explored;
    if ( canShareWithoutExtract && !needsPersist ) {
      await this.#updateFogExploration({timestamp: Date.now()}, {share: true});
      return;
    }

    if ( needsPersist ) this._updated = false;

    if ( !this.#extractor ) {
      console.error("FogManager | Browser does not support texture extraction.");
      return;
    }

    if ( !this.exploration ) this.exploration = this._createExplorationDocument();

    const exploration = this.exploration;

    if ( CONFIG.debug.fog.manager ) {
      console.debug("FogManager | Initiate non-blocking extraction of the fog of war progress.");
    }

    this.#extractor.debug = CONFIG.debug.fog.extractor;
    const base64Image = await this._extractBase64();

    // If the exploration changed, the fog was reloaded while the pixels were extracted
    if ( this.exploration !== exploration ) return;

    // If we got a new payload, persist it and optionally broadcast it
    let updateData;
    if ( base64Image ) updateData = this._prepareFogUpdateData(base64Image);
    else if ( share ) updateData = {timestamp: Date.now()};
    else {
      if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Fog of war has not changed. Skipping db operation.");
      return;
    }

    await this.#updateFogExploration(updateData, {share});
  }

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

  /**
   * Synchronize one user's version of the Fog of War for this scene to other users.
   * Note: This API is experimental and may be removed in later versions *without deprecation*. It is intended for
   * one-time corrections of users' fog explorations, and should not be used for real-time synchronization of fog
   * exploration.
   * @param {User} from        The user whose Fog of War to use as the source of truth.
   * @param {User[]} [to]      A list of users that should have their Fog of War synced. If none are specified then all
   *                           users will be synced.
   * @returns {Promise<void>}  A promise that resolves when synchronization has been completed.
   */
  async sync(from, to) {
    if ( !game.user.isGM ) throw new Error("Only GMs may synchronize Fog of War.");
    if ( !(from instanceof foundry.documents.User) ) throw new Error("Required User to sync from.");
    to = to?.map(u => {
      if ( !(u instanceof foundry.documents.User) ) throw new Error("Required User to sync to.");
      return u.id;
    });
    return new Promise((resolve, reject) => {
      game.socket.emit("syncFog", canvas.scene.id, from.id, { to }, result => {
        if ( result?.error ) reject(new Error(result.error));
        else resolve();
      });
    });
  }

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

  /**
   * The configured options used for fog base64 extraction.
   * @returns {{type: string, quality: number}}
   * @protected
   */
  _getBase64ExtractionConfiguration() {
    return {
      type: "image/webp",
      quality: 0.8
    };
  }

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

  /**
   * Extract fog data as a base64 string
   * @returns {Promise<string>}
   * @protected
   */
  _extractBase64() {
    return this.#extractor.extract({
      texture: this.#explorationSprite.texture,
      compression: TextureExtractor.COMPRESSION_MODES.BASE64,
      ...this._getBase64ExtractionConfiguration()
    });
  }

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

  /**
   * Prepare the data that will be used to update the FogExploration document.
   * @param {string} base64Image              The extracted base64 image data
   * @returns {Partial<FogExplorationData>}   Exploration data to update
   * @protected
   */
  _prepareFogUpdateData(base64Image) {
    return {explored: base64Image, timestamp: Date.now()};
  }

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

  /**
   * Update the fog exploration document with provided data.
   * @param {object} updateData
   * @param {object} [options]
   * @param {boolean} [options.share=false]  Ask the server to share fog to other clients for union.
   * @returns {Promise<void>}
   */
  async #updateFogExploration(updateData, {share=false}={}) {
    if ( !game.scenes.has(canvas.scene?.id) ) return;
    if ( !this.exploration ) return;
    if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Saving fog of war progress into exploration document.");

    const operation = {loadFog: false, share};
    if ( !this.exploration.id ) {
      this.exploration.updateSource(updateData);
      this.exploration = await this.exploration.constructor.create(this.exploration.toJSON(), operation);
    }
    else await this.exploration.update(updateData, operation);
  }

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

  /**
   * Deactivate fog of war.
   * Clear all shared containers by unlinking them from their parent.
   * Destroy all stored textures and graphics.
   */
  #deactivate() {
    // Cancel scheduled saves for the previous scene/load cycle
    this.#debouncedSave?.cancel?.();

    // Remove the current exploration document
    this.exploration = null;
    this.#extractor?.reset();

    // Destroy current exploration texture and provide a new one with transparency
    if ( this.#explorationSprite && !this.#explorationSprite.destroyed ) this.#explorationSprite.destroy(true);
    this.#explorationSprite = undefined;

    this._updated = false;

    // Reset explored data
    if ( this.#explored.pixels.length !== 0 ) {
      this.#explored.pixels = new Uint8ClampedArray(0);
      this.#explored.width = 0;
      this.#explored.height = 0;
      this.#explored.offset = 0;
      this.#explored.stride = 1;
      this.#explored.buffer = new ArrayBuffer(0);
      this.#explored.extracting = false;
      this.#onExploredChanged();
    }
  }

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

  /**
   * If fog of war data is reset from the server, deactivate the current fog and initialize the exploration.
   * @returns {Promise<void>}
   * @internal
   */
  async _handleReset() {
    return this.#queue.add(this.#handleReset.bind(this));
  }

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

  /**
   * If fog of war data is reset from the server, deactivate the current fog and initialize the exploration.
   * @returns {Promise<void>}
   */
  async #handleReset() {
    ui.notifications.info("Fog of War exploration progress was reset for this Scene");

    // Remove the current exploration document
    this.#deactivate();

    // Reset exploration in the visibility layer
    canvas.visibility.resetExploration();

    // Refresh perception
    canvas.perception.initialize();
  }
}
