// noinspection JSPrimitiveTypeWrapperUsage
/**
 * The visibility Layer which implements dynamic vision, lighting, and fog of war
 * This layer uses an event-driven workflow to perform the minimal required calculation in response to changes.
 * @see {@link PointSource}
 *
 * ### Hook Events
 * - {@link hookEvents.visibilityRefresh}
 *
 * @category - Canvas
 */
class CanvasVisibility extends CanvasLayer {
  /**
   * The currently revealed vision.
   * @type {CanvasVisionContainer}
   */
  vision;

  /**
   * The exploration container which tracks exploration progress.
   * @type {PIXI.Container}
   */
  explored;

  /**
   * The optional visibility overlay sprite that should be drawn instead of the unexplored color in the fog of war.
   * @type {PIXI.Sprite}
   */
  visibilityOverlay;

  /**
   * The graphics used to render cached light sources.
   * @type {PIXI.LegacyGraphics}
   */
  #cachedLights = new PIXI.LegacyGraphics();

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

  /**
   * Dimensions of the visibility overlay texture and base texture used for tiling texture into the visibility filter.
   * @type {number[]}
   */
  #visibilityOverlayDimensions;

  /**
   * The active vision source data object
   * @type {{source: VisionSource|null, activeLightingOptions: object}}
   */
  visionModeData = {
    source: undefined,
    activeLightingOptions: {}
  };

  /**
   * Define whether each lighting layer is enabled, required, or disabled by this vision mode.
   * The value for each lighting channel is a number in LIGHTING_VISIBILITY
   * @type {{illumination: number, background: number, coloration: number,
   * darkness: number, any: boolean}}
   */
  lightingVisibility = {
    background: VisionMode.LIGHTING_VISIBILITY.ENABLED,
    illumination: VisionMode.LIGHTING_VISIBILITY.ENABLED,
    coloration: VisionMode.LIGHTING_VISIBILITY.ENABLED,
    darkness: VisionMode.LIGHTING_VISIBILITY.ENABLED,
    any: true
  };

  /**
   * The map with the active cached light source IDs as keys and their update IDs as values.
   * @type {Map<string, number>}
   */
  #cachedLightSourceStates = new Map();

  /**
   * The maximum allowable visibility texture size.
   * @type {number}
   */
  static #MAXIMUM_VISIBILITY_TEXTURE_SIZE = 4096;

  /* -------------------------------------------- */
  /*  Canvas Visibility Properties                */
  /* -------------------------------------------- */

  /**
   * A status flag for whether the layer initialization workflow has succeeded.
   * @type {boolean}
   */
  get initialized() {
    return this.#initialized;
  }

  #initialized = false;

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

  /**
   * Indicates whether containment filtering is required when rendering vision into a texture.
   * @type {boolean}
   * @internal
   */
  get needsContainment() {
    return this.#needsContainment;
  }

  #needsContainment = false;

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

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

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

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

  /** @private */
  #textureConfiguration;

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

  /**
   * Optional overrides for exploration sprite dimensions.
   * @type {FogTextureConfiguration}
   */
  set explorationRect(rect) {
    this.#explorationRect = rect;
  }

  /** @private */
  #explorationRect;

  /* -------------------------------------------- */
  /*  Layer Initialization                        */
  /* -------------------------------------------- */

  /**
   * Initialize all Token vision sources which are present on this layer
   */
  initializeSources() {
    canvas.effects.toggleMaskingFilters(false); // Deactivate vision masking before destroying textures
    for ( const source of canvas.effects.visionSources ) source.initialize();
    Hooks.callAll("initializeVisionSources", canvas.effects.visionSources);
  }

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

  /**
   * Initialize the vision mode.
   */
  initializeVisionMode() {
    this.visionModeData.source = this.#getSingleVisionSource();
    this.#configureLightingVisibility();
    this.#updateLightingPostProcessing();
    this.#updateTintPostProcessing();
    Hooks.callAll("initializeVisionMode", this);
  }

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

  /**
   * Identify whether there is one singular vision source active (excluding previews).
   * @returns {VisionSource|null}                         A singular source, or null
   */
  #getSingleVisionSource() {
    return canvas.effects.visionSources.filter(s => s.active).sort((a, b) =>
      (a.isPreview - b.isPreview)
      || (a.isBlinded - b.isBlinded)
      || (b.visionMode.perceivesLight - a.visionMode.perceivesLight)
    ).at(0) ?? null;
  }

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

  /**
   * Configure the visibility of individual lighting channels based on the currently active vision source(s).
   */
  #configureLightingVisibility() {
    const vs = this.visionModeData.source;
    const vm = vs?.visionMode;
    const lv = this.lightingVisibility;
    const lvs = VisionMode.LIGHTING_VISIBILITY;
    Object.assign(lv, {
      background: CanvasVisibility.#requireBackgroundShader(vm),
      illumination: vm?.lighting.illumination.visibility ?? lvs.ENABLED,
      coloration: vm?.lighting.coloration.visibility ?? lvs.ENABLED,
      darkness: vm?.lighting.darkness.visibility ?? lvs.ENABLED
    });
    lv.any = (lv.background + lv.illumination + lv.coloration + lv.darkness) > VisionMode.LIGHTING_VISIBILITY.DISABLED;
  }

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

  /**
   * Update the lighting according to vision mode options.
   */
  #updateLightingPostProcessing() {
    // Check whether lighting configuration has changed
    const lightingOptions = this.visionModeData.source?.visionMode.lighting || {};
    const diffOpt = foundry.utils.diffObject(this.visionModeData.activeLightingOptions, lightingOptions);
    this.visionModeData.activeLightingOptions = lightingOptions;
    if ( foundry.utils.isEmpty(lightingOptions) ) canvas.effects.resetPostProcessingFilters();
    if ( foundry.utils.isEmpty(diffOpt) ) return;

    // Update post-processing filters and refresh lighting
    const modes = CONFIG.Canvas.visualEffectsMaskingFilter.FILTER_MODES;
    canvas.effects.resetPostProcessingFilters();
    for ( const layer of ["background", "illumination", "coloration"] ) {
      if ( layer in lightingOptions ) {
        const options = lightingOptions[layer];
        const filterMode = modes[layer.toUpperCase()];
        canvas.effects.activatePostProcessingFilters(filterMode, options.postProcessingModes, options.uniforms);
      }
    }
  }

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

  /**
   * Refresh the tint of the post processing filters.
   */
  #updateTintPostProcessing() {
    // Update tint
    const activeOptions = this.visionModeData.activeLightingOptions;
    const singleSource = this.visionModeData.source;
    const color = singleSource?.visionModeOverrides.colorRGB;
    for ( const f of canvas.effects.visualEffectsMaskingFilters ) {
      const defaultTint = f.constructor.defaultUniforms.tint;
      const tintedLayer = activeOptions[f.uniforms.mode]?.uniforms?.tint;
      f.uniforms.tint = tintedLayer ? (color ?? (tintedLayer ?? defaultTint)) : defaultTint;
    }
  }

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

  /**
   * Give the visibility requirement of the lighting background shader.
   * @param {VisionMode} visionMode             The single Vision Mode active at the moment (if any).
   * @returns {VisionMode.LIGHTING_VISIBILITY}
   */
  static #requireBackgroundShader(visionMode) {
    // Do we need to force lighting background shader? Force when :
    // - Multiple vision modes are active with a mix of preferred and non preferred visions
    // - Or when some have background shader required
    const lvs = VisionMode.LIGHTING_VISIBILITY;
    let preferred = false;
    let nonPreferred = false;
    for ( const vs of canvas.effects.visionSources ) {
      if ( !vs.active ) continue;
      const vm = vs.visionMode;
      if ( vm.lighting.background.visibility === lvs.REQUIRED ) return lvs.REQUIRED;
      if ( vm.vision.preferred ) preferred = true;
      else nonPreferred = true;
    }
    if ( preferred && nonPreferred ) return lvs.REQUIRED;
    return visionMode?.lighting.background.visibility ?? lvs.ENABLED;
  }

  /* -------------------------------------------- */
  /*  Layer Rendering                             */
  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    this.#configureVisibilityTexture();

    // Initialize fog
    await canvas.fog.initialize();

    // Create the vision container and attach it to the CanvasVisionMask cached container
    this.vision = this.#createVision();
    canvas.masks.vision.attachVision(this.vision);
    this.#cacheLights(true);

    // Exploration container
    this.explored = this.addChild(this.#createExploration());

    // Loading the fog overlay
    await this.#drawVisibilityOverlay();

    // Apply the visibility filter with a normal blend
    this.filter = CONFIG.Canvas.visibilityFilter.create({
      unexploredColor: canvas.colors.fogUnexplored.rgb,
      exploredColor: canvas.colors.fogExplored.rgb,
      backgroundColor: canvas.colors.background.rgb,
      visionTexture: canvas.masks.vision.renderTexture,
      primaryTexture: canvas.primary.renderTexture,
      overlayTexture: this.visibilityOverlay?.texture ?? null,
      dimensions: this.#visibilityOverlayDimensions,
      hasOverlayTexture: !!this.visibilityOverlay?.texture.valid
    }, canvas.visibilityOptions);
    this.filter.blendMode = PIXI.BLEND_MODES.NORMAL;
    this.filters = [this.filter];
    this.filterArea = canvas.app.screen;

    // Add the visibility filter to the canvas blur filter list
    canvas.addBlurFilter(this.filter);
    this.visible = false;
    this.#initialized = true;
  }

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

  /**
   * Create the exploration container with its exploration sprite.
   * @returns {PIXI.Container}   The newly created exploration container.
   */
  #createExploration() {
    const dims = canvas.dimensions;
    const explored = new PIXI.Container();
    const explorationSprite = explored.addChild(canvas.fog.sprite);
    const exr = this.#explorationRect;

    // Check if custom exploration dimensions are required
    if ( exr ) {
      explorationSprite.position.set(exr.x, exr.y);
      explorationSprite.width = exr.width;
      explorationSprite.height = exr.height;
    }

    // Otherwise, use the standard behavior
    else {
      explorationSprite.position.set(dims.sceneX, dims.sceneY);
      explorationSprite.width = this.#textureConfiguration.width;
      explorationSprite.height = this.#textureConfiguration.height;
    }
    return explored;
  }

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

  /**
   * Create the vision container and all its children.
   * @returns {PIXI.Container} The created vision container.
   */
  #createVision() {
    const dims = canvas.dimensions;
    const vision = new PIXI.Container();

    // Adding a void filter necessary when commiting fog on a texture for dynamic illumination
    vision.containmentFilter = VoidFilter.create();
    vision.containmentFilter.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    vision.containmentFilter.enabled = false; // Disabled by default, used only when writing on textures
    vision.filters = [vision.containmentFilter];

    // Areas visible because of light sources and light perception
    vision.light = vision.addChild(new PIXI.Container());

    // The global light container, which hold darkness level meshes for dynamic illumination
    vision.light.global = vision.light.addChild(new PIXI.Container());
    vision.light.global.source = vision.light.global.addChild(new PIXI.LegacyGraphics());
    vision.light.global.meshes = vision.light.global.addChild(new PIXI.Container());
    vision.light.global.source.blendMode = PIXI.BLEND_MODES.MAX_COLOR;

    // The light sources
    vision.light.sources = vision.light.addChild(new PIXI.LegacyGraphics());
    vision.light.sources.blendMode = PIXI.BLEND_MODES.MAX_COLOR;

    // Preview container, which is not cached
    vision.light.preview = vision.light.addChild(new PIXI.LegacyGraphics());
    vision.light.preview.blendMode = PIXI.BLEND_MODES.MAX_COLOR;

    // The cached light to avoid too many geometry drawings
    vision.light.cached = vision.light.addChild(new SpriteMesh(Canvas.getRenderTexture({
      textureConfiguration: this.textureConfiguration
    })));
    vision.light.cached.position.set(dims.sceneX, dims.sceneY);
    vision.light.cached.blendMode = PIXI.BLEND_MODES.MAX_COLOR;

    // The masked area
    vision.light.mask = vision.light.addChild(new PIXI.LegacyGraphics());
    vision.light.mask.preview = vision.light.mask.addChild(new PIXI.LegacyGraphics());

    // Areas visible because of FOV of vision sources
    vision.sight = vision.addChild(new PIXI.LegacyGraphics());
    vision.sight.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    vision.sight.preview = vision.sight.addChild(new PIXI.LegacyGraphics());
    vision.sight.preview.blendMode = PIXI.BLEND_MODES.MAX_COLOR;

    // Eraser for darkness sources
    vision.darkness = vision.addChild(new PIXI.LegacyGraphics());
    vision.darkness.blendMode = PIXI.BLEND_MODES.ERASE;

    /** @deprecated since v12 */
    Object.defineProperty(vision, "base", {
      get() {
        const msg = "CanvasVisibility#vision#base is deprecated in favor of CanvasVisibility#vision#light#preview.";
        foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
        return this.fov.preview;
      }
    });
    /** @deprecated since v12 */
    Object.defineProperty(vision, "fov", {
      get() {
        const msg = "CanvasVisibility#vision#fov is deprecated in favor of CanvasVisibility#vision#light.";
        foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
        return this.light;
      }
    });
    /** @deprecated since v12 */
    Object.defineProperty(vision, "los", {
      get() {
        const msg = "CanvasVisibility#vision#los is deprecated in favor of CanvasVisibility#vision#light#mask.";
        foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
        return this.light.mask;
      }
    });
    /** @deprecated since v12 */
    Object.defineProperty(vision.light, "lights", {
      get: () => {
        const msg = "CanvasVisibility#vision#fov#lights is deprecated without replacement.";
        foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
        return this.#cachedLights;
      }
    });
    /** @deprecated since v12 */
    Object.defineProperty(vision.light, "lightsSprite", {
      get() {
        const msg = "CanvasVisibility#vision#fov#lightsSprite is deprecated in favor of CanvasVisibility#vision#light#cached.";
        foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
        return this.cached;
      }
    });
    /** @deprecated since v12 */
    Object.defineProperty(vision.light, "tokens", {
      get() {
        const msg = "CanvasVisibility#vision#tokens is deprecated in favor of CanvasVisibility#vision#light.";
        foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
        return this;
      }
    });
    return vision;
  }

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

  /** @inheritDoc */
  async _tearDown(options) {
    canvas.masks.vision.detachVision();
    this.#cachedLightSourceStates.clear();
    await canvas.fog.clear();

    // Performs deep cleaning of the detached vision container
    this.vision.destroy({children: true, texture: true, baseTexture: true});
    this.vision = undefined;

    canvas.effects.visionSources.clear();
    this.#initialized = false;
    return super._tearDown(options);
  }

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

  /**
   * Update the display of the sight layer.
   * Organize sources into rendering queues and draw lighting containers for each source
   */
  refresh() {
    if ( !this.initialized ) return;

    // Refresh visibility
    if ( this.tokenVision ) {
      this.refreshVisibility();
      this.visible = canvas.effects.visionSources.some(s => s.active) || !game.user.isGM;
    }
    else this.visible = false;

    // Update visibility of objects
    this.restrictVisibility();
  }

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

  /**
   * Update vision (and fog if necessary)
   */
  refreshVisibility() {
    canvas.masks.vision.renderDirty = true;
    if ( !this.vision ) return;
    const vision = this.vision;

    // Begin fills
    const fillColor = 0xFF0000;
    this.#cachedLights.beginFill(fillColor);
    vision.light.sources.clear().beginFill(fillColor);
    vision.light.preview.clear().beginFill(fillColor);
    vision.light.global.source.clear().beginFill(fillColor);
    vision.light.mask.clear().beginFill();
    vision.light.mask.preview.clear().beginFill();
    vision.sight.clear().beginFill(fillColor);
    vision.sight.preview.clear().beginFill(fillColor);
    vision.darkness.clear().beginFill(fillColor);

    // Checking if the lights cache needs a full redraw
    const redrawCache = this.#checkCachedLightSources();
    if ( redrawCache ) this.#cachedLightSourceStates.clear();

    // A flag to know if the lights cache render texture need to be refreshed
    let refreshCache = redrawCache;

    // A flag to know if fog need to be refreshed.
    let commitFog = false;

    // Iterating over each active light source
    for ( const [sourceId, lightSource] of canvas.effects.lightSources.entries() ) {
      // Ignoring inactive sources or global light (which is rendered using the global light mesh)
      if ( !lightSource.hasActiveLayer || (lightSource instanceof foundry.canvas.sources.GlobalLightSource) ) continue;

      // Is the light source providing vision?
      if ( lightSource.data.vision ) {
        if ( lightSource.isPreview ) vision.light.mask.preview.drawShape(lightSource.shape);
        else {
          vision.light.mask.drawShape(lightSource.shape);
          commitFog = true;
        }
      }

      // Update the cached state. Skip if already cached.
      const isCached = this.#shouldCacheLight(lightSource);
      if ( isCached ) {
        if ( this.#cachedLightSourceStates.has(sourceId) ) continue;
        this.#cachedLightSourceStates.set(sourceId, lightSource.updateId);
        refreshCache = true;
      }

      // Draw the light source
      if ( isCached ) this.#cachedLights.drawShape(lightSource.shape);
      else if ( lightSource.isPreview ) vision.light.preview.drawShape(lightSource.shape);
      else vision.light.sources.drawShape(lightSource.shape);
    }

    // Refresh the light source cache if necessary.
    // Note: With a full redraw, we need to refresh the texture cache, even if no elements are present
    if ( refreshCache ) this.#cacheLights(redrawCache);

    // Refresh global/dynamic illumination with global source and illumination meshes
    this.#refreshDynamicIllumination();

    // Iterating over each active vision source
    for ( const visionSource of canvas.effects.visionSources ) {
      if ( !visionSource.hasActiveLayer ) continue;
      const blinded = visionSource.isBlinded;

      // Draw vision FOV
      if ( (visionSource.radius > 0) && !blinded && !visionSource.isPreview ) {
        vision.sight.drawShape(visionSource.shape);
        commitFog = true;
      }
      else vision.sight.preview.drawShape(visionSource.shape);

      // Draw light perception
      if ( (visionSource.lightRadius > 0) && !blinded && !visionSource.isPreview ) {
        vision.light.mask.drawShape(visionSource.light);
        commitFog = true;
      }
      else vision.light.mask.preview.drawShape(visionSource.light);
    }

    // Call visibility refresh hook
    Hooks.callAll("visibilityRefresh", this);

    // End fills
    vision.light.sources.endFill();
    vision.light.preview.endFill();
    vision.light.global.source.endFill();
    vision.light.mask.endFill();
    vision.light.mask.preview.endFill();
    vision.sight.endFill();
    vision.sight.preview.endFill();
    vision.darkness.endFill();

    // Update fog of war texture (if fow is activated)
    if ( commitFog ) canvas.fog.commit();
  }

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

  /**
   * Reset the exploration container with the fog sprite
   */
  resetExploration() {
    if ( !this.explored ) return;
    this.explored.destroy();
    this.explored = this.addChild(this.#createExploration());
  }

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

  /**
   * Refresh the dynamic illumination with darkness level meshes and global light.
   * Tell if a fence filter is needed when vision is rendered into a texture.
   */
  #refreshDynamicIllumination() {
    // Reset filter containment
    this.#needsContainment = false;

    // Setting global light source container visibility
    const globalLightSource = canvas.environment.globalLightSource;
    const v = this.vision.light.global.visible = globalLightSource.active;
    if ( !v ) return;
    const {min, max} = globalLightSource.data.darkness;

    // Draw the global source if necessary
    const darknessLevel = canvas.environment.darknessLevel;
    if ( (darknessLevel >= min) && (darknessLevel <= max) ) {
      this.vision.light.global.source.drawShape(globalLightSource.shape);
    }

    // Then draw dynamic illumination meshes
    const illuminationMeshes = this.vision.light.global.meshes.children;
    for ( const mesh of illuminationMeshes ) {
      const darknessLevel = mesh.shader.darknessLevel;
      if ( (darknessLevel < min) || (darknessLevel > max)) {
        mesh.blendMode = PIXI.BLEND_MODES.ERASE;
        this.#needsContainment = true;
      }
      else mesh.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    }
  }

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

  /**
   * Returns true if the light source should be cached.
   * @param {LightSource} lightSource    The light source
   * @returns {boolean}
   */
  #shouldCacheLight(lightSource) {
    return !(lightSource.object instanceof Token) && !lightSource.isPreview;
  }

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

  /**
   * Check if the cached light sources need to be fully redrawn.
   * @returns {boolean}    True if a full redraw is necessary.
   */
  #checkCachedLightSources() {
    for ( const [sourceId, updateId] of this.#cachedLightSourceStates ) {
      const lightSource = canvas.effects.lightSources.get(sourceId);
      if ( !lightSource || !lightSource.active || !this.#shouldCacheLight(lightSource)
        || (updateId !== lightSource.updateId) ) return true;
    }
    return false;
  }

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

  /**
   * Render `this.#cachedLights` into `this.vision.light.cached.texture`.
   * Note: A full cache redraw needs the texture to be cleared.
   * @param {boolean} clearTexture       If the texture need to be cleared before rendering.
   */
  #cacheLights(clearTexture) {
    const dims = canvas.dimensions;
    this.#renderTransform.tx = -dims.sceneX;
    this.#renderTransform.ty = -dims.sceneY;
    this.#cachedLights.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    canvas.app.renderer.render(this.#cachedLights, {
      renderTexture: this.vision.light.cached.texture,
      clear: clearTexture,
      transform: this.#renderTransform
    });
    this.#cachedLights.clear();
  }

  /* -------------------------------------------- */
  /*  Visibility Testing                          */
  /* -------------------------------------------- */

  /**
   * Restrict the visibility of certain canvas assets (like Tokens or DoorControls) based on the visibility polygon
   * These assets should only be displayed if they are visible given the current player's field of view
   */
  restrictVisibility() {
    // Activate or deactivate visual effects vision masking
    canvas.effects.toggleMaskingFilters(this.visible);

    // Tokens & Notes
    const flags = {refreshVisibility: true};
    for ( const token of canvas.tokens.placeables ) token.renderFlags.set(flags);
    for ( const note of canvas.notes.placeables ) note.renderFlags.set(flags);

    // Door Icons
    for ( const door of canvas.controls.doors.children ) door.visible = door.isVisible;

    Hooks.callAll("sightRefresh", this);
  }

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

  /**
   * @typedef {Object} CanvasVisibilityTestConfig
   * @property {object|null} object              The target object
   * @property {CanvasVisibilityTest[]} tests    An array of visibility tests
   */

  /**
   * @typedef {Object} CanvasVisibilityTest
   * @property {Point} point
   * @property {number} elevation
   * @property {Map<VisionSource, boolean>} los
   */

  /**
   * Test whether a target point on the Canvas is visible based on the current vision and LOS polygons.
   * @param {Point} point                     The point in space to test, an object with coordinates x and y.
   * @param {object} [options]                Additional options which modify visibility testing.
   * @param {number} [options.tolerance=2]    A numeric radial offset which allows for a non-exact match.
   *                                          For example, if tolerance is 2 then the test will pass if the point
   *                                          is within 2px of a vision polygon.
   * @param {object|null} [options.object]    An optional reference to the object whose visibility is being tested
   * @returns {boolean}                       Whether the point is currently visible.
   */
  testVisibility(point, options={}) {

    // If no vision sources are present, the visibility is dependant of the type of user
    if ( !canvas.effects.visionSources.some(s => s.active) ) return game.user.isGM;

    // Prepare an array of test points depending on the requested tolerance
    const object = options.object ?? null;
    const config = this._createVisibilityTestConfig(point, options);

    // First test basic detection for light sources which specifically provide vision
    for ( const lightSource of canvas.effects.lightSources ) {
      if ( !lightSource.data.vision || !lightSource.active ) continue;
      const result = lightSource.testVisibility(config);
      if ( result === true ) return true;
    }

    // Get scene rect to test that some points are not detected into the padding
    const sr = canvas.dimensions.sceneRect;
    const inBuffer = !sr.contains(point.x, point.y);

    // Skip sources that are not both inside the scene or both inside the buffer
    const activeVisionSources = canvas.effects.visionSources.filter(s => s.active
      && (inBuffer !== sr.contains(s.x, s.y)));
    const modes = CONFIG.Canvas.detectionModes;

    // Second test Basic Sight and Light Perception tests for vision sources
    for ( const visionSource of activeVisionSources ) {
      if ( visionSource.isBlinded ) continue;
      const token = visionSource.object.document;
      const basicMode = token.detectionModes.find(m => m.id === "basicSight");
      if ( basicMode ) {
        const result = modes.basicSight.testVisibility(visionSource, basicMode, config);
        if ( result === true ) return true;
      }
      const lightMode = token.detectionModes.find(m => m.id === "lightPerception");
      if ( lightMode ) {
        const result = modes.lightPerception.testVisibility(visionSource, lightMode, config);
        if ( result === true ) return true;
      }
    }

    // Special detection modes can only detect tokens
    if ( !(object instanceof Token) ) return false;

    // Lastly test special detection modes for vision sources
    for ( const visionSource of activeVisionSources ) {
      const token = visionSource.object.document;
      for ( const mode of token.detectionModes ) {
        if ( (mode.id === "basicSight") || (mode.id === "lightPerception") ) continue;
        const dm = modes[mode.id];
        const result = dm?.testVisibility(visionSource, mode, config);
        if ( result === true ) {
          object.detectionFilter = dm.constructor.getDetectionFilter();
          return true;
        }
      }
    }
    return false;
  }

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

  /**
   * Create the visibility test config.
   * @param {Point} point                     The point in space to test, an object with coordinates x and y.
   * @param {object} [options]                Additional options which modify visibility testing.
   * @param {number} [options.tolerance=2]    A numeric radial offset which allows for a non-exact match.
   *                                          For example, if tolerance is 2 then the test will pass if the point
   *                                          is within 2px of a vision polygon.
   * @param {object|null} [options.object]    An optional reference to the object whose visibility is being tested
   * @returns {CanvasVisibilityTestConfig}
   * @internal
   */
  _createVisibilityTestConfig(point, {tolerance=2, object=null}={}) {
    const t = tolerance;
    const offsets = t > 0 ? [[0, 0], [-t, -t], [-t, t], [t, t], [t, -t], [-t, 0], [t, 0], [0, -t], [0, t]] : [[0, 0]];
    const elevation = object instanceof Token ? object.document.elevation : 0;
    return {
      object,
      tests: offsets.map(o => ({
        point: {x: point.x + o[0], y: point.y + o[1]},
        elevation,
        los: new Map()
      }))
    };
  }

  /* -------------------------------------------- */
  /*  Visibility Overlay and Texture management   */
  /* -------------------------------------------- */

  /**
   * Load the scene fog overlay if provided and attach the fog overlay sprite to this layer.
   */
  async #drawVisibilityOverlay() {
    this.visibilityOverlay = undefined;
    this.#visibilityOverlayDimensions = [];
    const overlaySrc = canvas.sceneTextures.fogOverlay ?? canvas.scene.fog.overlay;
    const overlayTexture = overlaySrc instanceof PIXI.Texture ? overlaySrc : getTexture(overlaySrc);
    if ( !overlayTexture ) return;

    // Creating the sprite and updating its base texture with repeating wrap mode
    const fo = this.visibilityOverlay = new PIXI.Sprite(overlayTexture);

    // Set dimensions and position according to overlay <-> scene foreground dimensions
    const bkg = canvas.primary.background;
    const baseTex = overlayTexture.baseTexture;
    if ( bkg && ((fo.width !== bkg.width) || (fo.height !== bkg.height)) ) {
      // Set to the size of the scene dimensions
      fo.width = canvas.scene.dimensions.width;
      fo.height = canvas.scene.dimensions.height;
      fo.position.set(0, 0);
      // Activate repeat wrap mode for this base texture (to allow tiling)
      baseTex.wrapMode = PIXI.WRAP_MODES.REPEAT;
    }
    else {
      // Set the same position and size as the scene primary background
      fo.width = bkg.width;
      fo.height = bkg.height;
      fo.position.set(bkg.x, bkg.y);
    }

    // The overlay is added to this canvas container to update its transforms only
    fo.renderable = false;
    this.addChild(this.visibilityOverlay);

    // Manage video playback
    const video = game.video.getVideoSource(overlayTexture);
    if ( video ) {
      const playOptions = {volume: 0};
      game.video.play(video, playOptions);
    }

    // Passing overlay and base texture width and height for shader tiling calculations
    this.#visibilityOverlayDimensions = [fo.width, fo.height, baseTex.width, baseTex.height];
  }

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

  /**
   * @typedef {object} VisibilityTextureConfiguration
   * @property {number} resolution
   * @property {number} width
   * @property {number} height
   * @property {number} mipmap
   * @property {number} scaleMode
   * @property {number} multisample
   */

  /**
   * Configure the fog texture will all required options.
   * Choose an adaptive fog rendering resolution which downscales the saved fog textures for larger dimension Scenes.
   * It is important that the width and height of the fog texture is evenly divisible by the downscaling resolution.
   * @returns {VisibilityTextureConfiguration}
   * @private
   */
  #configureVisibilityTexture() {
    const dims = canvas.dimensions;
    let width = dims.sceneWidth;
    let height = dims.sceneHeight;
    const maxSize = CanvasVisibility.#MAXIMUM_VISIBILITY_TEXTURE_SIZE;

    // Adapt the fog texture resolution relative to some maximum size, and ensure that multiplying the scene dimensions
    // by the resolution results in an integer number in order to avoid fog drift.
    let resolution = 1.0;
    if ( (width >= height) && (width > maxSize) ) {
      resolution = maxSize / width;
      height = Math.ceil(height * resolution) / resolution;
    } else if ( height > maxSize ) {
      resolution = maxSize / height;
      width = Math.ceil(width * resolution) / resolution;
    }

    // Determine the fog texture options
    return this.#textureConfiguration = {
      resolution,
      width,
      height,
      mipmap: PIXI.MIPMAP_MODES.OFF,
      multisample: PIXI.MSAA_QUALITY.NONE,
      scaleMode: PIXI.SCALE_MODES.LINEAR,
      alphaMode: PIXI.ALPHA_MODES.NPM,
      format: PIXI.FORMATS.RED
    };
  }

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

  /**
   * @deprecated since v11
   * @ignore
   */
  get fogOverlay() {
    const msg = "fogOverlay is deprecated in favor of visibilityOverlay";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return this.visibilityOverlay;
  }
}
