import CachedContainer from "../../containers/advanced/cached-container.mjs";

/**
 * @import {Point, ElevatedPoint, DeepReadonly} from "@common/_types.mjs";
 * @import {PolygonTree} from "@client/data/polygon-tree.mjs";
 * @import {RegionSurface} from "@client/documents/_types.mjs";
 */

/**
 * The occlusion mask which contains radial occlusion and vision occlusion from tokens.
 * Red channel: Fade occlusion.
 * Green channel: Radial occlusion.
 * Blue channel: Vision occlusion.
 * Alpha channel: Surface occlusion.
 * @category Canvas
 */
export default class CanvasOcclusionMask extends CachedContainer {
  constructor(...args) {
    super(...args);
    this.#createOcclusion();
    canvas.registerMouseMoveHandler(this.#onMouseMove, foundry.canvas.Canvas.MOUSE_MOVE_HANDLER_PRIORITIES.LOW, this);
  }

  /** @override */
  static textureConfiguration = {
    scaleMode: PIXI.SCALE_MODES.NEAREST,
    format: PIXI.FORMATS.RGBA,
    multisample: PIXI.MSAA_QUALITY.NONE
  };

  /**
   * Graphics in which token radial and vision occlusion shapes are drawn.
   * @type {PIXI.LegacyGraphics}
   */
  tokens;

  /**
   * Graphics in which surface occlusion shapes are drawn.
   * @type {PIXI.LegacyGraphics}
   */
  surfaces;

  /**
   * The occludable tokens.
   * @type {Token[]}
   */
  #tokens = [];

  /** @override */
  clearColor = [0, 1, 1, 1];

  /** @override */
  autoRender = false;

  /**
   * The set of currently occluded canvas objects.
   * @type {Set<PrimaryCanvasObject>}
   */
  get occluded() {
    return this.#occluded;
  }

  #occluded = new Set();

  /**
   * The occluded surfaces.
   * @type {ReadonlySet<DeepReadonly<RegionSurface>>}
   */
  get occludedSurfaces() {
    return this.#occludedSurfaces;
  }

  #occludedSurfaces = new Set();

  /**
   * The set of hovered surface keys.
   * @type {Set<string>}
   */
  #hoveredSurfaces = new Set();

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

  /**
   * Is vision occlusion active?
   * @type {boolean}
   */
  get vision() {
    return this.#vision;
  }

  /**
   * @type {boolean}
   */
  #vision = false;

  /**
   * The elevations of the elevation-to-depth mapping.
   * Supported are up to 254 unique elevations.
   * @type {Float64Array}
   */
  #elevations = new Float64Array([-Infinity]);

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

  /**
   * Initialize the depth mask with the roofs container and token graphics.
   */
  #createOcclusion() {
    this.alphaMode = PIXI.ALPHA_MODES.NO_PREMULTIPLIED_ALPHA;
    this.tokens = this.addChild(new PIXI.LegacyGraphics());
    this.tokens.blendMode = PIXI.BLEND_MODES.MIN_ALL;
    this.surfaces = this.addChild(new PIXI.LegacyGraphics());
    this.surfaces.blendMode = PIXI.BLEND_MODES.MIN_ALL;
    this.surfaces.mask = new PIXI.MaskData();
    this.surfaces.mask.colorMask = PIXI.COLOR_MASK_BITS.ALPHA;
  }

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

  /**
   * Clear the occlusion mask.
   * @override
   */
  clear() {
    this.tokens.clear();
    this.surfaces.clear();
    this.#tokens = [];
    this.#occluded.clear();
    this.#occludedSurfaces.clear();
    this.#hoveredSurfaces.clear();
    this.#vision = false;
    this.#elevations = new Float64Array([-Infinity]);
    return this;
  }

  /* -------------------------------------------- */
  /*  Occlusion Management                        */
  /* -------------------------------------------- */

  /**
   * Map an elevation to a value in the range (0, 1] with 8-bit precision.
   * The radial and vision shapes are drawn with these values into the render texture.
   * @param {number} elevation    The elevation in distance units
   * @returns {number}            The value for this elevation in the range (0, 1] with 8-bit precision
   */
  mapElevation(elevation) {

    // Finds the interval containing `elevation` and maps it to the corresponding value.
    //     [     -∞, E[0]   ]  ->  1 / 255
    //     (   E[0], E[1]   ]  ->  2 / 255
    //     (   E[1], E[2]   ]  ->  3 / 255
    //     ...
    //     ( E[n-2], E[n-1] ]  ->  (n - 1) / 255
    //     ( E[n-1], ∞      ]  ->  255 / 255
    const E = this.#elevations;
    let i = 0;
    let j = E.length - 1;
    if ( elevation > E[j] ) return 1;
    while ( i < j ) {
      const k = (i + j) >> 1;
      const e = E[k];
      if ( e >= elevation ) j = k;
      else i = k + 1;
    }
    return (i + 1) / 255;
  }

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

  /**
   * Update the occludable tokens.
   * @internal
   */
  _updateOccludableTokens() {
    this.#tokens = canvas.tokens._getOccludableTokens();
  }

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

  /**
   * Draw occlusion shapes to the occlusion mask.
   * Fade occlusion draws to the red channel with varying intensity from [0, 1] based on elevation.
   * Radial occlusion draws to the green channel with varying intensity from [0, 1] based on elevation.
   * Vision occlusion draws to the blue channel with varying intensity from [0, 1] based on elevation.
   * Surface occlusion draws to the alpha channel with varying intensity from [0, 1] based on elevation.
   * @internal
   */
  _updateOcclusionMask() {
    this.#vision = false;
    this.tokens.clear();
    this.surfaces.clear();
    const elevations = [];
    const queue = [];
    for ( const token of this.#tokens ) queue.push({object: token, elevation: token.document.elevation});
    for ( const surface of this.occludedSurfaces ) {
      queue.push({object: surface, elevation: Math.nextDown(surface.elevation)});
    }
    queue.sort((a, b) => a.elevation - b.elevation);
    for ( const {object, elevation} of queue) {
      if ( elevation !== elevations.at(-1) ) elevations.push(elevation);
      const occlusionElevation = Math.min(elevations.length, 254);

      // Token
      if ( object instanceof foundry.canvas.placeables.Token ) {

        // Draw vision occlusion
        if ( object.vision?.active ) {
          this.#vision = true;
          this.tokens.beginFill(0xFFFF00 | occlusionElevation).drawShape(object.vision.los).endFill();
        }

        // Draw radial occlusion (and radial into the vision channel if this token doesn't have vision)
        const origin = object.center;
        const occlusionRadius = Math.max(object.externalRadius, object.getLightRadius(
          object.document.occludable.radius));
        this.tokens.beginFill(0xFF0000 | (occlusionElevation << 8) | (object.vision?.active ? 0xFF
          : occlusionElevation)).drawCircle(origin.x, origin.y, occlusionRadius).endFill();
      }

      // Surface
      else {
        this.surfaces.beginFill(0xFFFFFF, occlusionElevation / 255);
        object.region.polygonTree.drawShape(this.surfaces);
        this.surfaces.endFill();
      }
    }
    if ( !elevations.length ) elevations.push(-Infinity);
    else elevations.length = Math.min(elevations.length, 254);
    this.#elevations = new Float64Array(elevations);
    this.renderDirty = true;
  }

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

  /**
   * Update the current occlusion status of all PCOs.
   * @internal
   */
  _updateOccludedObjects() {
    const occluded = this._identifyOccludedObjects(this.#tokens);
    for ( const pco of canvas.primary.objects ) {
      const isOccludable = pco.isOccludable;
      if ( (isOccludable === undefined) || (!isOccludable && !pco.occluded) ) continue;
      pco.debounceSetOcclusion(occluded.has(pco));
    }
  }

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

  /**
   * Determine the set of objects which should be currently occluded by a Token.
   * @param {Token[]} tokens                   The occludable Tokens
   * @returns {Set<PrimaryCanvasObjectMixin>}  The PCO objects which should be currently occluded
   * @protected
   */
  _identifyOccludedObjects(tokens) {
    this.#occluded.clear();
    for ( const token of tokens ) {
      // Get the occludable primary canvas objects (PCO) according to the token bounds
      const matchingPCO = canvas.primary.quadtree.getObjects(token.bounds);
      for ( const pco of matchingPCO ) {
        // Don't bother re-testing a PCO or an object which is not occludable
        if ( !pco.isOccludable || this.#occluded.has(pco) ) continue;
        if ( pco.testOcclusion(token) ) this.#occluded.add(pco);
      }
    }
    return this.#occluded;
  }

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

  /**
   * Determine the occluded surfaces.
   * @param {object} flags  The perception render flags that are processed
   * @internal
   */
  _updateOccludedSurfaces(flags) {

    // No need to remember to check for occluded surface changes if we already initialize light and vision sources or
    // there's no surfaces with exposure in the viewed level
    const priorOccludedSurfaces = !(flags.initializeLightSources && flags.initializeVision) && canvas.scene.getSurfaces(
      {level: canvas.level.id, exposure: true}).length ? this.#occludedSurfaces.map(s => s.key) : undefined;

    this.#occludedSurfaces.clear();

    const occludableSurfaces = canvas.scene.getSurfaces({level: canvas.level.id, occlusion: true});
    if ( occludableSurfaces.length ) {

      // If the token layer is not active or the "highlight all" key is pressed, surfaces in the foreground are occluded
      if ( !canvas.tokens.active || canvas.tokens.highlightObjects ) {
        for ( let i = occludableSurfaces.length - 1; i >= 0; i-- ) {
          const surface = occludableSurfaces[i];
          if ( surface.elevation < canvas.level.elevation.top ) break;
          this.#occludedSurfaces.add(surface);
        }
      }

      // Hovered surfaces are occluded
      if ( this.#hoveredSurfaces.size ) {
        for ( const surface of occludableSurfaces ) {
          if ( this.#hoveredSurfaces.has(surface.key) ) this.#occludedSurfaces.add(surface);
        }
      }

      // Determine the surfaces that are occluded by tokens
      const candidateSurfaces = [];
      for ( const token of this.#tokens ) {
        const tokenElevation = token.document.elevation;
        let tokenBounds;
        for ( let i = occludableSurfaces.length - 1; i >= 0; i-- ) {
          const surface = occludableSurfaces[i];
          if ( tokenElevation >= surface.elevation ) break;
          tokenBounds ??= token.bounds;
          if ( !tokenBounds.intersects(surface.region.bounds) ) continue;
          candidateSurfaces.push(surface);
        }
        if ( !candidateSurfaces.length ) continue;
        for ( const point of token.document.getOcclusionTestPoints() ) {
          for ( const surface of CanvasOcclusionMask.#getOccludedSurfaces(point, tokenElevation, candidateSurfaces) ) {
            this.#occludedSurfaces.add(surface);
          }
        }
        candidateSurfaces.length = 0;
      }
    }

    // If there's a change in occluded surfaces, we need to initialize all light and vision sources
    if ( priorOccludedSurfaces?.equals(this.#occludedSurfaces.map(s => s.key)) === false ) {
      flags.initializeLightSources = flags.refreshLighting = flags.initializeVision = flags.refreshVision = true;
    }
  }

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

  /**
   * Get all surfaces that are occluded given the point and elevation.
   * @param {Point} point                 The point that is tested for occlusion.
   * @param {number} elevation            The elevation of the point.
   * @param {RegionSurface[]} [surfaces]  The occludable surfaces, order by elevation in ascending order.
   * @yields {RegionSurface}              The occluded surfaces.
   */
  static *#getOccludedSurfaces(point, elevation, surfaces) {

    // Iterate over the surfaces in ascending order of elevation
    let lowest;
    for ( const surface of surfaces ?? canvas.scene.getSurfaces({level: canvas.level.id, occlusion: true}) ) {

      // Surfaces at or below the point cannot occlude the point
      if ( surface.elevation <= elevation ) continue;
      const region = surface.region;

      // Surfaces whose elevation range does not include the point must be the lowest surface to be occluded
      const enveloped = region.elevation.bottom <= elevation;
      if ( !enveloped && (surface.elevation > lowest?.elevation) ) continue;

      // For a ceiling surface to be occluded, the point must be contained within the region's elevation range
      const ceiling = surface.elevation > region.elevation.bottom;
      const occluded = (enveloped || !ceiling) && region.polygonTree.testPoint(point);
      if ( !occluded ) continue;

      lowest ??= surface;
      yield surface;
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle mousemove events.
   * @param {PIXI.Point} mousePosition   The current mouse position
   */
  #onMouseMove(mousePosition) {
    const hoveredSurfaces = [];

    // Only visible or explored mouse positions can trigger hovering
    if ( canvas.mousePositionVisible || canvas.mousePositionExplored ) {

      // Get elevation of hovered placeable
      let mouseElevation = canvas.level.elevation.base;
      const hoveredPlaceable = canvas.activeLayer?.hover;
      if ( hoveredPlaceable ) {
        const {Drawing, Tile, Token, Region} = foundry.canvas.placeables;
        if ( (hoveredPlaceable instanceof Token) || (hoveredPlaceable instanceof Tile) ) {
          const mesh = hoveredPlaceable.mesh;
          if ( mesh ) mouseElevation = mesh.elevation;
        } else if ( hoveredPlaceable instanceof Drawing ) {
          const shape = hoveredPlaceable.shape;
          if ( shape ) mouseElevation = shape.elevation;
        } else if ( hoveredPlaceable instanceof Region ) {
          mouseElevation = hoveredPlaceable.document.elevation.top;
        } else if ( hoveredPlaceable.document.schema.has("elevation") ) {
          mouseElevation = hoveredPlaceable.document.elevation;
        }
      }

      // Determine all hovered surfaces. Only surfaces with occlusion can be hovered.
      for ( const surface of CanvasOcclusionMask.#getOccludedSurfaces(mousePosition, mouseElevation) ) {
        hoveredSurfaces.push(surface);
      }
    }

    // If the hovered surfaces changed, refresh the occlusion states of surfaces
    if ( (hoveredSurfaces.length !== this.#hoveredSurfaces.size)
      || !hoveredSurfaces.every(surface => this.#hoveredSurfaces.has(surface.key)) ) {
      canvas.perception.update({refreshOccludedSurfaces: true});
    }
    this.#hoveredSurfaces.clear();
    for ( const surface of hoveredSurfaces ) this.#hoveredSurfaces.add(surface.key);
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  updateOcclusion() {
    foundry.utils.logCompatibilityWarning("CanvasOcclusionMask#updateOcclusion is deprecated. Use canvas.perception.update({refreshOcclusion: true}) instead.",
      {since: 14, until: 16, once: true});
    canvas.perception.update({refreshOcclusion: true});
  }
}
