/**
 * A type of Placeable Object which highlights an area of the grid as covered by some area of effect.
 * @category - Canvas
 * @see {@link MeasuredTemplateDocument}
 * @see {@link TemplateLayer}
 */
class MeasuredTemplate extends PlaceableObject {

  /**
   * The geometry shape used for testing point intersection
   * @type {PIXI.Circle | PIXI.Ellipse | PIXI.Polygon | PIXI.Rectangle | PIXI.RoundedRectangle}
   */
  shape;

  /**
   * The tiling texture used for this template, if any
   * @type {PIXI.Texture}
   */
  texture;

  /**
   * The template graphics
   * @type {PIXI.Graphics}
   */
  template;

  /**
   * The measurement ruler label
   * @type {PreciseText}
   */
  ruler;

  /**
   * Internal property used to configure the control border thickness
   * @type {number}
   * @protected
   */
  _borderThickness = 3;

  /** @inheritdoc */
  static embeddedName = "MeasuredTemplate";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshPosition", "refreshShape", "refreshElevation"], alias: true},
    refreshState: {},
    refreshPosition: {propagate: ["refreshGrid"]},
    refreshShape: {propagate: ["refreshTemplate", "refreshGrid", "refreshText"]},
    refreshTemplate: {},
    refreshGrid: {},
    refreshText: {},
    refreshElevation: {}
  };

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

  /**
   * A convenient reference for whether the current User is the author of the MeasuredTemplate document.
   * @type {boolean}
   */
  get isAuthor() {
    return this.document.isAuthor;
  }

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

  /** @inheritdoc */
  get bounds() {
    const {x, y} = this.document;
    const d = canvas.dimensions;
    const r = this.document.distance * (d.size / d.distance);
    return new PIXI.Rectangle(x-r, y-r, 2*r, 2*r);
  }

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

  /**
   * Is this MeasuredTemplate currently visible on the Canvas?
   * @type {boolean}
   */
  get isVisible() {
    return !this.document.hidden || this.isAuthor || game.user.isGM;
  }

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

  /**
   * A unique identifier which is used to uniquely identify related objects like a template effect or grid highlight.
   * @type {string}
   */
  get highlightId() {
    return this.objectId;
  }

  /* -------------------------------------------- */
  /*  Initial Drawing                             */
  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {

    // Load Fill Texture
    if ( this.document.texture ) {
      this.texture = await loadTexture(this.document.texture, {fallback: "icons/svg/hazard.svg"});
    } else {
      this.texture = null;
    }

    // Template Shape
    this.template = this.addChild(new PIXI.Graphics());

    // Control Icon
    this.controlIcon = this.addChild(this.#createControlIcon());
    await this.controlIcon.draw();

    // Ruler Text
    this.ruler = this.addChild(this.#drawRulerText());

    // Enable highlighting for this template
    canvas.interface.grid.addHighlightLayer(this.highlightId);
  }

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

  /**
   * Draw the ControlIcon for the MeasuredTemplate
   * @returns {ControlIcon}
   */
  #createControlIcon() {
    const size = Math.max(Math.round((canvas.dimensions.size * 0.5) / 20) * 20, 40);
    let icon = new ControlIcon({texture: CONFIG.controlIcons.template, size: size});
    icon.x -= (size * 0.5);
    icon.y -= (size * 0.5);
    return icon;
  }

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

  /**
   * Draw the Text label used for the MeasuredTemplate
   * @returns {PreciseText}
   */
  #drawRulerText() {
    const style = CONFIG.canvasTextStyle.clone();
    style.fontSize = Math.max(Math.round(canvas.dimensions.size * 0.36 * 12) / 12, 36);
    const text = new PreciseText(null, style);
    text.anchor.set(0, 1);
    return text;
  }

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

  /** @override */
  _destroy(options) {
    canvas.interface.grid.destroyHighlightLayer(this.highlightId);
    this.texture?.destroy();
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @override */
  _applyRenderFlags(flags) {
    if ( flags.refreshState ) this._refreshState();
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshShape ) this._refreshShape();
    if ( flags.refreshTemplate ) this._refreshTemplate();
    if ( flags.refreshGrid ) this.highlightGrid();
    if ( flags.refreshText ) this._refreshRulerText();
    if ( flags.refreshElevation ) this._refreshElevation();
  }

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

  /**
   * Refresh the displayed state of the MeasuredTemplate.
   * This refresh occurs when the user interaction state changes.
   * @protected
   */
  _refreshState() {

    // Template Visibility
    const wasVisible = this.visible;
    this.visible = this.isVisible && !this.hasPreview;
    if ( this.visible !== wasVisible ) MouseInteractionManager.emulateMoveEvent();

    // Sort on top of others on hover
    this.zIndex = this.hover ? 1 : 0;

    // Control Icon Visibility
    const isHidden = this.document.hidden;
    this.controlIcon.refresh({
      visible: this.visible && this.layer.active && this.document.isOwner,
      iconColor: isHidden ? 0xFF3300 : 0xFFFFFF,
      borderColor: isHidden ? 0xFF3300 : 0xFF5500,
      borderVisible: this.hover || this.layer.highlightObjects
    });

    // Alpha transparency
    const alpha = isHidden ? 0.5 : 1;
    this.template.alpha = alpha;
    this.ruler.alpha = alpha;
    const highlightLayer = canvas.interface.grid.getHighlightLayer(this.highlightId);
    highlightLayer.visible = this.visible;
    // FIXME the elevation is not considered in sort order of the highlight layers
    highlightLayer.zIndex = this.document.sort;
    highlightLayer.alpha = alpha;
    this.alpha = this._getTargetAlpha();

    // Ruler Visibility
    this.ruler.visible = this.visible && this.layer.active;
  }

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

  /**
   * Refresh the elevation of the control icon.
   * @protected
   */
  _refreshElevation() {
    this.controlIcon.elevation = this.document.elevation;
  }

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

  /** @override */
  _getTargetAlpha() {
    return this.isPreview ? 0.8 : 1.0;
  }

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

  /**
   * Refresh the position of the MeasuredTemplate
   * @protected
   */
  _refreshPosition() {
    const {x, y} = this.document;
    if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
    this.position.set(x, y);
  }

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

  /**
   * Refresh the underlying geometric shape of the MeasuredTemplate.
   * @protected
   */
  _refreshShape() {
    let {x, y, direction, distance} = this.document;

    // Grid type
    if ( game.settings.get("core", "gridTemplates") ) {
      this.ray = new Ray({x, y}, canvas.grid.getTranslatedPoint({x, y}, direction, distance));
    }

    // Euclidean type
    else {
      this.ray = Ray.fromAngle(x, y, Math.toRadians(direction), distance * canvas.dimensions.distancePixels);
    }

    // Get the Template shape
    this.shape = this._computeShape();
  }

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

  /**
   * Compute the geometry for the template using its document data.
   * Subclasses can override this method to take control over how different shapes are rendered.
   * @returns {PIXI.Circle|PIXI.Rectangle|PIXI.Polygon}
   * @protected
   */
  _computeShape() {
    const {t, distance, direction, angle, width} = this.document;
    switch ( t ) {
      case "circle":
        return this.constructor.getCircleShape(distance);
      case "cone":
        return this.constructor.getConeShape(distance, direction, angle);
      case "rect":
        return this.constructor.getRectShape(distance, direction);
      case "ray":
        return this.constructor.getRayShape(distance, direction, width);
    }
  }

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

  /**
   * Refresh the display of the template outline and shape.
   * Subclasses may override this method to take control over how the template is visually rendered.
   * @protected
   */
  _refreshTemplate() {
    const t = this.template.clear();

    // Draw the Template outline
    t.lineStyle(this._borderThickness, this.document.borderColor, 0.75).beginFill(0x000000, 0.0);

    // Fill Color or Texture
    if ( this.texture ) t.beginTextureFill({texture: this.texture});
    else t.beginFill(0x000000, 0.0);

    // Draw the shape
    t.drawShape(this.shape);

    // Draw origin and destination points
    t.lineStyle(this._borderThickness, 0x000000)
      .beginFill(0x000000, 0.5)
      .drawCircle(0, 0, 6)
      .drawCircle(this.ray.dx, this.ray.dy, 6)
      .endFill();
  }

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

  /**
   * Get a Circular area of effect given a radius of effect
   * @param {number} distance    The radius of the circle in grid units
   * @returns {PIXI.Circle|PIXI.Polygon}
   */
  static getCircleShape(distance) {

    // Grid circle
    if ( game.settings.get("core", "gridTemplates") ) {
      return new PIXI.Polygon(canvas.grid.getCircle({x: 0, y: 0}, distance));
    }

    // Euclidean circle
    return new PIXI.Circle(0, 0, distance * canvas.dimensions.distancePixels);
  }

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

  /**
   * Get a Conical area of effect given a direction, angle, and distance
   * @param {number} distance     The radius of the cone in grid units
   * @param {number} direction    The direction of the cone in degrees
   * @param {number} angle        The angle of the cone in degrees
   * @returns {PIXI.Polygon|PIXI.Circle}
   */
  static getConeShape(distance, direction, angle) {

    // Grid cone
    if ( game.settings.get("core", "gridTemplates") ) {
      return new PIXI.Polygon(canvas.grid.getCone({x: 0, y: 0}, distance, direction, angle));
    }

    // Euclidean cone
    if ( (distance <= 0) || (angle <= 0) ) return new PIXI.Polygon();
    distance *= canvas.dimensions.distancePixels;
    const coneType = game.settings.get("core", "coneTemplateType");

    // For round cones - approximate the shape with a ray every 3 degrees
    let angles;
    if ( coneType === "round" ) {
      if ( angle >= 360 ) return new PIXI.Circle(0, 0, distance);
      const da = Math.min(angle, 3);
      angles = Array.fromRange(Math.floor(angle/da)).map(a => (angle/-2) + (a*da)).concat([angle/2]);
    }

    // For flat cones, direct point-to-point
    else {
      angle = Math.min(angle, 179);
      angles = [(angle/-2), (angle/2)];
      distance /= Math.cos(Math.toRadians(angle/2));
    }

    // Get the cone shape as a polygon
    const rays = angles.map(a => Ray.fromAngle(0, 0, Math.toRadians(direction + a), distance));
    const points = rays.reduce((arr, r) => {
      return arr.concat([r.B.x, r.B.y]);
    }, [0, 0]).concat([0, 0]);
    return new PIXI.Polygon(points);
  }

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

  /**
   * Get a Rectangular area of effect given a width and height
   * @param {number} distance     The length of the diagonal in grid units
   * @param {number} direction    The direction of the diagonal in degrees
   * @returns {PIXI.Rectangle}
   */
  static getRectShape(distance, direction) {
    let endpoint;

    // Grid rectangle
    if ( game.settings.get("core", "gridTemplates") ) {
      endpoint = canvas.grid.getTranslatedPoint({x: 0, y: 0}, direction, distance);
    }

    // Euclidean rectangle
    else endpoint = Ray.fromAngle(0, 0, Math.toRadians(direction), distance * canvas.dimensions.distancePixels).B;

    return new PIXI.Rectangle(0, 0, endpoint.x, endpoint.y).normalize();
  }

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

  /**
   * Get a rotated Rectangular area of effect given a width, height, and direction
   * @param {number} distance      The length of the ray in grid units
   * @param {number} direction     The direction of the ray in degrees
   * @param {number} width         The width of the ray in grid units
   * @returns {PIXI.Polygon}
   */
  static getRayShape(distance, direction, width) {
    const d = canvas.dimensions;
    width *= d.distancePixels;
    const p00 = Ray.fromAngle(0, 0, Math.toRadians(direction - 90), width / 2).B;
    const p01 = Ray.fromAngle(0, 0, Math.toRadians(direction + 90), width / 2).B;
    let p10;
    let p11;

    // Grid ray
    if ( game.settings.get("core", "gridTemplates") ) {
      p10 = canvas.grid.getTranslatedPoint(p00, direction, distance);
      p11 = canvas.grid.getTranslatedPoint(p01, direction, distance);
    }

    // Euclidean ray
    else {
      distance *= d.distancePixels;
      direction = Math.toRadians(direction);
      p10 = Ray.fromAngle(p00.x, p00.y, direction, distance).B;
      p11 = Ray.fromAngle(p01.x, p01.y, direction, distance).B;
    }

    return new PIXI.Polygon(p00.x, p00.y, p10.x, p10.y, p11.x, p11.y, p01.x, p01.y);
  }

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

  /**
   * Update the displayed ruler tooltip text
   * @protected
   */
  _refreshRulerText() {
    const {distance, t} = this.document;
    const grid = canvas.grid;
    if ( t === "rect" ) {
      const {A: {x: x0, y: y0}, B: {x: x1, y: y1}} = this.ray;
      const dx = grid.measurePath([{x: x0, y: y0}, {x: x1, y: y0}]).distance;
      const dy = grid.measurePath([{x: x0, y: y0}, {x: x0, y: y1}]).distance;
      const w = Math.round(dx * 10) / 10;
      const h = Math.round(dy * 10) / 10;
      this.ruler.text = `${w}${grid.units} x ${h}${grid.units}`;
    } else {
      const r = Math.round(distance * 10) / 10;
      this.ruler.text = `${r}${grid.units}`;
    }
    this.ruler.position.set(this.ray.dx + 10, this.ray.dy + 5);
  }

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

  /**
   * Highlight the grid squares which should be shown under the area of effect
   */
  highlightGrid() {
    // Clear the existing highlight layer
    canvas.interface.grid.clearHighlightLayer(this.highlightId);

    // Highlight colors
    const border = this.document.borderColor;
    const color = this.document.fillColor;

    // If we are in grid-less mode, highlight the shape directly
    if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) {
      const shape = this._getGridHighlightShape();
      canvas.interface.grid.highlightPosition(this.highlightId, {border, color, shape});
    }

    // Otherwise, highlight specific grid positions
    else {
      const positions = this._getGridHighlightPositions();
      for ( const {x, y} of positions ) {
        canvas.interface.grid.highlightPosition(this.highlightId, {x, y, border, color});
      }
    }
  }

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

  /**
   * Get the shape to highlight on a Scene which uses grid-less mode.
   * @returns {PIXI.Polygon|PIXI.Circle|PIXI.Rectangle}
   * @protected
   */
  _getGridHighlightShape() {
    const shape = this.shape.clone();
    if ( "points" in shape ) {
      shape.points = shape.points.map((p, i) => {
        if ( i % 2 ) return this.y + p;
        else return this.x + p;
      });
    } else {
      shape.x += this.x;
      shape.y += this.y;
    }
    return shape;
  }

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

  /**
   * Get an array of points which define top-left grid spaces to highlight for square or hexagonal grids.
   * @returns {Point[]}
   * @protected
   */
  _getGridHighlightPositions() {
    const grid = canvas.grid;
    const {x: ox, y: oy} = this.document;
    const shape = this.shape;
    const bounds = shape.getBounds();
    bounds.x += ox;
    bounds.y += oy;
    bounds.fit(canvas.dimensions.rect);
    bounds.pad(1);

    // Identify grid space that have their center points covered by the template shape
    const positions = [];
    const [i0, j0, i1, j1] = grid.getOffsetRange(bounds);
    for ( let i = i0; i < i1; i++ ) {
      for ( let j = j0; j < j1; j++ ) {
        const offset = {i, j};
        const {x: cx, y: cy} = grid.getCenterPoint(offset);

        // If the origin of the template is a grid space center, this grid space is highlighted
        let covered = (Math.max(Math.abs(cx - ox), Math.abs(cy - oy)) < 1);
        if ( !covered ) {
          for ( let dx = -0.5; dx <= 0.5; dx += 0.5 ) {
            for ( let dy = -0.5; dy <= 0.5; dy += 0.5 ) {
              if ( shape.contains(cx - ox + dx, cy - oy + dy) ) {
                covered = true;
                break;
              }
            }
          }
        }
        if ( !covered ) continue;
        positions.push(grid.getTopLeftPoint(offset));
      }
    }
    return positions;
  }

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

  /** @override */
  async rotate(angle, snap) {
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return this;
    }
    const direction = this._updateRotation({angle, snap});
    await this.document.update({direction});
    return this;
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Incremental Refresh
    this.renderFlags.set({
      redraw: "texture" in changed,
      refreshState: ("sort" in changed) || ("hidden" in changed),
      refreshPosition: ("x" in changed) || ("y" in changed),
      refreshElevation: "elevation" in changed,
      refreshShape: ["t", "angle", "direction", "distance", "width"].some(k => k in changed),
      refreshTemplate: "borderColor" in changed,
      refreshGrid: ("borderColor" in changed) || ("fillColor" in changed)
    });
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @override */
  _canControl(user, event) {
    if ( !this.layer.active || this.isPreview ) return false;
    return user.isGM || (user === this.document.author);
  }

  /** @inheritdoc */
  _canHUD(user, event) {
    return this.isOwner; // Allow template owners to right-click
  }

  /** @inheritdoc */
  _canConfigure(user, event) {
    return false; // Double-right does nothing
  }

  /** @override */
  _canView(user, event) {
    return this._canControl(user, event);
  }

  /** @inheritdoc */
  _onClickRight(event) {
    this.document.update({hidden: !this.document.hidden});
    if ( !this._propagateRightClick(event) ) event.stopPropagation();
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get borderColor() {
    const msg = "MeasuredTemplate#borderColor has been deprecated. Use MeasuredTemplate#document#borderColor instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.document.borderColor.valueOf();
  }

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


  /**
   * @deprecated since v12
   * @ignore
   */
  get fillColor() {
    const msg = "MeasuredTemplate#fillColor has been deprecated. Use MeasuredTemplate#document#fillColor instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.document.fillColor.valueOf();
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get owner() {
    const msg = "MeasuredTemplate#owner has been deprecated. Use MeasuredTemplate#isOwner instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.isOwner;
  }
}
