import PlaceableObject from "./placeable-object.mjs";
import ShapeObjectMixin from "./mixins/shapes.mjs";
import PreciseText from "../containers/elements/precise-text.mjs";
import PrimaryCanvasGroup from "../groups/primary.mjs";
import {ShapeData} from "@common/data/data.mjs";
import {loadTexture} from "../loader.mjs";
import DrawingShapeControls from "./drawings/shape-controls.mjs";
import { CLIPPER_SCALING_FACTOR } from "@common/constants.mjs";

/**
 * The Drawing object is an implementation of the PlaceableObject container.
 * Each Drawing is a placeable object in the DrawingsLayer.
 * @category Canvas
 * @see {@link foundry.documents.DrawingDocument}
 * @see {@link foundry.canvas.layers.DrawingsLayer}
 */
export default class Drawing extends ShapeObjectMixin(PlaceableObject) {

  /**
   * The texture that is used to fill this Drawing, if any.
   * @type {PIXI.Texture|null}
   */
  texture = null;

  /**
   * The border frame and resizing handles for the drawing.
   * @type {PIXI.Container}
   */
  frame;

  /**
   * A text label that may be displayed as part of the interface layer for the Drawing.
   * @type {PreciseText|null}
   */
  text = null;

  /**
   * The drawing shape which is rendered as a PIXI.Graphics in the interface or a PrimaryGraphics in the Primary Group.
   * @type {PrimaryGraphics|PIXI.Graphics}
   */
  shape;

  /**
   * An internal timestamp for the previous freehand draw time, to limit sampling.
   * @type {number}
   */
  #drawTime = 0;

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

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

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshTransform", "refreshText", "refreshElevation"], alias: true},
    refreshState: {propagate: ["refreshVisibility"]},
    refreshVisibility: {},
    refreshTransform: {propagate: ["refreshPosition", "refreshRotation", "refreshSize"], alias: true},
    refreshPosition: {},
    refreshRotation: {},
    refreshSize: {propagate: ["refreshPosition", "refreshShape", "refreshText"]},
    refreshShape: {},
    refreshText: {},
    refreshElevation: {},
    /** @deprecated since v14 */
    refreshFrame: {deprecated: {since: 14, until: 16}, alias: true}
  };

  /**
   * The rate at which points are sampled (in milliseconds) during a freehand drawing workflow
   * @type {number}
   */
  static FREEHAND_SAMPLE_RATE = 75;

  /**
   * A convenience reference to the possible shape types.
   * @enum {string}
   */
  static SHAPE_TYPES = ShapeData.TYPES;

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

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

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

  /** @override */
  get bounds() {
    return this.document._shape.bounds.clone(); // PlaceableObject#bounds always returns a new instance
  }

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

  /** @override */
  get center() {
    const {x, y, shape} = this.document;
    return new PIXI.Point(x + (shape.width / 2), y + (shape.height / 2));
  }

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

  /**
   * A Boolean flag for whether the Drawing utilizes a tiled texture background?
   * @type {boolean}
   */
  get isTiled() {
    return this.document.fillType === CONST.DRAWING_FILL_TYPES.PATTERN;
  }

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

  /**
   * A Boolean flag for whether the Drawing is a Polygon type (either linear or freehand)?
   * @type {boolean}
   */
  get isPolygon() {
    return this.type === Drawing.SHAPE_TYPES.POLYGON;
  }

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

  /**
   * Does the Drawing have text that is displayed?
   * @type {boolean}
   */
  get hasText() {
    return ((this._pendingText !== undefined) || !!this.document.text) && (this.document.fontSize > 0);
  }

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

  /**
   * The shape type that this Drawing represents. A value in Drawing.SHAPE_TYPES.
   * @see {@link Drawing.SHAPE_TYPES}
   * @type {string}
   */
  get type() {
    return this.document.shape.type;
  }

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

  /**
   * The shape controls of this Drawing.
   * @type {PIXI.Container}
   */
  controls;

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


  /**
   * The pending text.
   * @type {string}
   * @internal
   */
  _pendingText;

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

  /**
   * The registered keydown listener.
   * @type {Function|null}
   * @internal
   */
  _onkeydown = null;

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

  /**
   * Delete the Drawing if the text is empty once text editing ends?
   * @type {boolean}
   */
  #deleteIfEmptyText = false;

  /* -------------------------------------------- */
  /*  Initial Rendering                           */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _destroy(options) {
    this.#removeDrawing(this);
    this.texture?.destroy();
  }

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

  /** @inheritDoc */
  _clear() {
    if ( this.shape ) this.shape.visible = false;
    this.text?.destroy({children: true});
    this.text = null;
    super._clear();
  }

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

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);

    // Load the background texture, if one is defined
    const texture = this.document.texture;
    if ( this._original ) this.texture = this._original.texture?.clone() ?? null;
    else this.texture = texture ? await loadTexture(texture, {fallback: "icons/svg/hazard.svg"}) : null;

    // Create the drawing container in the primary group or in the interface group
    this.shape = this.#addDrawing();
    this.shape.visible = true;

    // Draw hitbox
    this.frame = this.addChild(this.#drawFrame());

    // Draw controls
    this.controls = this.addChild(new DrawingShapeControls(this.document._shape.clone()));
    await this.controls.draw();

    // Drawing text
    this.text = this.hasText ? this.shape.addChild(this.#drawText()) : null;

    // Set cursor
    this.cursor = "pointer";
  }

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

  /**
   * Add a drawing object according to interface configuration.
   * @returns {PIXI.Graphics|PrimaryGraphics}
   */
  #addDrawing() {
    const targetGroup = this.document.interface ? canvas.interface : canvas.primary;
    const removeGroup = this.document.interface ? canvas.primary : canvas.interface;
    removeGroup.removeDrawing(this);
    return targetGroup.addDrawing(this);
  }

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

  /**
   * Remove a drawing object.
   */
  #removeDrawing() {
    canvas.interface.removeDrawing(this);
    canvas.primary.removeDrawing(this);
  }

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

  /**
   * Create elements for the Drawing hitbox
   * @returns {PIXI.Container}
   */
  #drawFrame() {
    const point = new PIXI.Point();
    const frame = new PIXI.Container();
    frame.eventMode = "auto";
    frame.hitArea = {
      contains: (x, y) => {
        const {_shape: shape, fillType, fillAlpha} = this.document;
        const isOpen = (shape.type === "polygon") && !(fillType && fillAlpha);
        return !isOpen && shape.testPoint(point.set(x, y));
      }
    };
    return frame;
  }

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

  /**
   * Create a PreciseText element to be displayed as part of this drawing.
   * @returns {PreciseText}
   */
  #drawText() {
    const text = new PreciseText(this.document.text || "", this._getTextStyle());
    text.eventMode = "none";
    return text;
  }

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

  /**
   * Get the line style used for drawing the shape of this Drawing.
   * @returns {object}    The line style options (`PIXI.ILineStyleOptions`).
   * @protected
   */
  _getLineStyle() {
    const {strokeWidth, strokeColor, strokeAlpha} = this.document;
    return {width: strokeWidth, color: strokeColor, alpha: strokeAlpha};
  }

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

  /**
   * Get the fill style used for drawing the shape of this Drawing.
   * @returns {object}    The fill style options (`PIXI.IFillStyleOptions`).
   * @protected
   */
  _getFillStyle() {
    const {fillType, fillColor, fillAlpha} = this.document;
    const style = {color: fillColor, alpha: fillAlpha};
    if ( (fillType === CONST.DRAWING_FILL_TYPES.PATTERN) && this.texture?.valid ) style.texture = this.texture;
    else if ( !fillType ) style.alpha = 0;
    return style;
  }

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

  /**
   * Prepare the text style used to instantiate a PIXI.Text or PreciseText instance for this Drawing document.
   * @returns {PIXI.TextStyle}
   * @protected
   */
  _getTextStyle() {
    const {fontSize, fontFamily, textColor, shape} = this.document;
    const stroke = Math.max(Math.round(fontSize / 32), 2);
    return PreciseText.getTextStyle({
      fontFamily: fontFamily || CONFIG.defaultFontFamily,
      fontSize: fontSize,
      fill: textColor,
      strokeThickness: stroke,
      dropShadowBlur: Math.max(Math.round(fontSize / 16), 2),
      align: "center",
      wordWrap: true,
      wordWrapWidth: shape.width ?? 0,
      padding: stroke * 4
    });
  }

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

  /** @inheritDoc */
  clone() {
    const c = super.clone();
    c._pendingText = this._pendingText;
    return c;
  }

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

  /** @inheritDoc */
  _applyRenderFlags(flags) {
    super._applyRenderFlags(flags);
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshRotation ) this._refreshRotation();
    if ( flags.refreshShape ) this._refreshShape();
    if ( flags.refreshText ) this._refreshText();
    if ( flags.refreshElevation ) this._refreshElevation();
  }

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

  /**
   * Refresh the position.
   * @protected
   */
  _refreshPosition() {
    const {x, y, shape: {width, height}} = this.document;
    if ( this.controls.shape.type === "polygon" ) this.controls.shape.updateSource(this.document._shape.toObject());
    else this.controls.shape.updateSource({x: x + (width / 2), y: y + (height / 2)});
    this.controls.refresh();
    this.shape.position.set(x + (width / 2), y + (height / 2));
    this.shape.pivot.set(width / 2, height / 2);
    if ( !this.text ) return;
    this.text.position.set(width / 2, height / 2);
    this.text.anchor.set(0.5, 0.5);
  }

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

  /**
   * Refresh the rotation.
   * @protected
   */
  _refreshRotation() {
    const {rotation} = this.document;
    if ( this.controls.shape.type === "polygon" ) this.controls.shape.updateSource(this.document._shape.toObject());
    else this.controls.shape.updateSource({rotation});
    this.controls.refresh();
    this.shape.angle = rotation;
  }

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

  /** @inheritDoc */
  _refreshVisibility() {
    super._refreshVisibility();
    this.shape.visible = this.visible;
  }

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

  /** @inheritDoc */
  _refreshState() {
    super._refreshState();
    const {hidden, locked, sort} = this.document;
    const colors = CONFIG.Canvas.dispositionColors;
    this.controls.tint = this.controlled || this.isPreview ? colors.CONTROLLED
      : (locked ? colors.HOSTILE : colors.INACTIVE);
    this.controls.visible = this.controlled || this.hover || this.layer.highlightObjects || ["dragging", "wheel", "controls"].includes(this.previewType);
    this.controls.handles.visible = this.layer.active && (game.activeTool !== "text") && this.controlled && !locked;
    this.controls.editable = this.layer.active && !!ui.controls.tool?.interaction
      && !!ui.controls.tool?.control && !locked;
    this.controls.dashed = hidden;
    this.shape.zIndex = this.zIndex;
    this.shape.sort = sort;
    this.shape.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.DRAWINGS;
    const isDragging = ["dragging", "creation"].includes(this.previewType);
    this.shape.alpha = this.alpha * (isDragging ? 0.8 : 1) * (hidden ? 0.5 : 1);
    this.shape.hidden = hidden;
    if ( !this.text ) return;
    this.text.alpha = this.document.textAlpha;
  }

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

  /**
   * Clear and then draw the shape.
   * @protected
   */
  _refreshShape() {
    this.controls.shape.updateSource(this.document._shape.toObject());
    this.controls.refresh();
    this.shape.clear();
    this.shape.lineStyle(this._getLineStyle());
    this.shape.beginTextureFill(this._getFillStyle());
    const lineWidth = this.shape.line.width;
    const shape = this.document.shape;
    switch ( shape.type ) {
      case Drawing.SHAPE_TYPES.RECTANGLE:
        this.shape.drawRect(
          lineWidth / 2,
          lineWidth / 2,
          Math.max(shape.width - lineWidth, 0),
          Math.max(shape.height - lineWidth, 0)
        );
        break;
      case Drawing.SHAPE_TYPES.ELLIPSE:
        this.shape.drawEllipse(
          shape.width / 2,
          shape.height / 2,
          Math.max(shape.width - lineWidth, 0) / 2,
          Math.max(shape.height - lineWidth, 0) / 2
        );
        break;
      case Drawing.SHAPE_TYPES.POLYGON: {
        const isClosed = this.document.fillType || (shape.points.slice(0, 2).equals(shape.points.slice(-2)));
        if ( isClosed ) this.shape.drawSmoothedPolygon(shape.points, this.document.bezierFactor * 2);
        else this.shape.drawSmoothedPath(shape.points, this.document.bezierFactor * 2);
        break;
      }
    }
    this.shape.endFill();
    this.shape.line.reset();
  }

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

  /**
   * Update sorting of this Drawing relative to other PrimaryCanvasGroup siblings.
   * Called when the elevation or sort order for the Drawing changes.
   * @protected
   */
  _refreshElevation() {
    this.shape.elevation = this.document.elevation;
  }

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

  /**
   * Refresh the content and appearance of text.
   * @protected
   */
  _refreshText() {
    if ( !this.text ) return;
    const {text, textAlpha} = this.document;
    this.text.text = this._pendingText ?? text ?? "";
    this.text.alpha = textAlpha;
    this.text.style = this._getTextStyle();
  }

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

  /** @inheritDoc */
  _onControl(options) {
    super._onControl(options);
    this.enableTextEditing(options);
  }

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

  /** @inheritDoc */
  _onRelease(options) {
    super._onRelease(options);
    if ( this._onkeydown ) {
      document.removeEventListener("keydown", this._onkeydown);
      this._onkeydown = null;
    }
    if ( canvas.scene.drawings.has(this.id) ) {
      if ( (this._pendingText === "") && this.#deleteIfEmptyText ) {
        if ( this._preview?.previewType !== "config" ) this.document.delete();
      } else if ( this._pendingText !== undefined ) {    // Submit pending text
        this.#deleteIfEmptyText = false;
        this.document.update({text: this._pendingText}).then(() => {
          this._pendingText = undefined;
          this.renderFlags.set({redraw: this.hasText === !this.text, refreshText: true});
        });
      }
    }
  }

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

  /** @override */
  _overlapsSelection(rectangle) {
    if ( !rectangle.overlaps(this.bounds) ) return false;
    const x0 = Math.round(rectangle.left * CLIPPER_SCALING_FACTOR);
    const y0 = Math.round(rectangle.top * CLIPPER_SCALING_FACTOR);
    const x1 = Math.round(rectangle.right * CLIPPER_SCALING_FACTOR);
    const y1 = Math.round(rectangle.bottom * CLIPPER_SCALING_FACTOR);
    if ( (x0 === x1) || (y0 === y1) ) return false;
    const rectanglePath = [
      new ClipperLib.IntPoint(x0, y0),
      new ClipperLib.IntPoint(x1, y0),
      new ClipperLib.IntPoint(x1, y1),
      new ClipperLib.IntPoint(x0, y1)
    ];
    const {_shape: shape, fillType, fillAlpha} = this.document;
    const isOpen = (shape.type === "polygon") && !(fillType && fillAlpha);
    const clipper = new ClipperLib.Clipper();
    const solution = isOpen ? new ClipperLib.PolyTree() : [];
    clipper.Clear();
    clipper.AddPaths(shape.clipperPaths, ClipperLib.PolyType.ptSubject, !isOpen);
    clipper.AddPath(rectanglePath, ClipperLib.PolyType.ptClip, true);
    clipper.Execute(ClipperLib.ClipType.ctIntersection, solution);
    return (Array.isArray(solution) ? solution.length : solution.ChildCount()) !== 0;
  }

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

  /**
   * Enable text editing for this drawing.
   * @param {object} [options]
   */
  enableTextEditing(options={}) {
    if ( this._onkeydown ) return;
    if ( (game.activeTool === "text") || options.forceTextEditing ) {
      this._pendingText = this.document.text || "";
      this._onkeydown = this.#onDrawingTextKeydown.bind(this);
      document.addEventListener("keydown", this._onkeydown);
      if ( options.isNew ) this.#deleteIfEmptyText = true;
      this.renderFlags.set({refreshPosition: !this.text, refreshText: true});
      this.text ??= this.shape?.addChild(this.#drawText()) ?? null;
    }
  }

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

  /**
   * Handle text entry in an active text tool
   * @param {KeyboardEvent} event
   */
  #onDrawingTextKeydown(event) {

    // Ignore events when an input is focused, or when ALT or CTRL modifiers are applied
    if ( event.altKey || event.ctrlKey || event.metaKey ) return;
    if ( game.keyboard.hasFocus ) return;

    // Track refresh or conclusion conditions
    let conclude = false;
    let refresh = false;

    // Enter (submit) or Escape (cancel)
    if ( ["Escape", "Enter"].includes(event.key) ) {
      conclude = true;
    }

    // Deleting a character
    else if ( event.key === "Backspace" ) {
      this._pendingText = this._pendingText.slice(0, -1);
      refresh = true;
    }

    // Typing text (any single char)
    else if ( /^.$/.test(event.key) ) {
      this._pendingText += event.key;
      refresh = true;
    }

    // Stop propagation if the event was handled
    if ( refresh || conclude ) {
      event.preventDefault();
      event.stopPropagation();
    }

    // Conclude the workflow
    if ( conclude ) {
      this.release();
    }

    // Refresh the display
    else if ( refresh ) {
      this.renderFlags.set({refreshText: true});
    }
  }

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

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

    // Update pending text
    if ( ("text" in changed) && (this._pendingText !== undefined) ) this._pendingText = this.document.text || "";

    // Sort the interface drawings container if necessary
    if ( this.shape?.parent && (("elevation" in changed) || ("sort" in changed)) ) this.shape.parent.sortDirty = true;

    // Refresh the Tile
    this.renderFlags.set({
      redraw: ("interface" in changed) || ("texture" in changed) || (("text" in changed) && (this.hasText === !this.text))
        || (this.controls.shape.type !== this.document._shape.type),
      refreshState: ("sort" in changed) || ("hidden" in changed) || ("locked" in changed) || ("author" in changed),
      refreshPosition: ("x" in changed) || ("y" in changed),
      refreshRotation: "rotation" in changed,
      refreshSize: ("shape" in changed) && (("width" in changed.shape) || ("height" in changed.shape)),
      refreshElevation: "elevation" in changed,
      refreshShape: ["shape", "bezierFactor", "strokeWidth", "strokeColor", "strokeAlpha",
        "fillType", "fillColor", "fillAlpha"].some(k => k in changed),
      refreshText: ["text", "fontFamily", "fontSize", "textColor", "textAlpha"].some(k => k in changed)
    });
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( this._onkeydown ) document.removeEventListener("keydown", this._onkeydown);
  }

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

  /** @override */
  _hasShapeChanged(changed) {
    return ["x", "y", "rotation", "shape"].some(k => k in changed);
  }

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

  /** @override */
  _initializeDragShape(event) {
    return this.document._shape.clone();
  }

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

  /** @override */
  _updateDragPreviews(event) {
    const {clones, origin, shape, offset} = event.interactionData;
    const dx = shape.origin.x - (origin.x - offset.x);
    const dy = shape.origin.y - (origin.y - offset.y);
    for ( const clone of clones ) {
      const {x, y} = clone._original.document;
      clone.document.updateSource({x: x + dx, y: y + dy});
      clone.renderFlags.set({refreshPosition: true});
    }
  }

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

  /** @override */
  _prepareDragLeftDropUpdates(event) {
    const {clones} = event.interactionData;
    const updates = [];
    for ( const clone of clones ) {
      const {x, y} = clone.document;
      updates.push({_id: clone._original.id, x, y});
    }
    return updates;
  }

  /* -------------------------------------------- */
  /*  Resize Handling                             */
  /* -------------------------------------------- */

  /**
   * Get a vectorized rescaling transformation for drawing data and dimensions passed in parameter
   * @param {Object} original     The original drawing data
   * @param {number} dx           The pixel distance dragged in the horizontal direction
   * @param {number} dy           The pixel distance dragged in the vertical direction
   * @returns {object}            The adjusted shape data
   */
  static rescaleDimensions(original, dx, dy) {
    let {type, points, width, height} = original.shape;
    width += dx;
    height += dy;
    points = points || [];

    // Rescale polygon points
    if ( type === Drawing.SHAPE_TYPES.POLYGON ) {
      const scaleX = 1 + (original.shape.width > 0 ? dx / original.shape.width : 0);
      const scaleY = 1 + (original.shape.height > 0 ? dy / original.shape.height : 0);
      points = points.map((p, i) => p * (i % 2 ? scaleY : scaleX));
    }

    // Normalize the shape
    return this.normalizeShape({
      x: original.x,
      y: original.y,
      shape: {width: Math.round(width), height: Math.round(height), points}
    });
  }

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

  /**
   * Adjust the location, dimensions, and points of the Drawing before committing the change.
   * @param {object} data   The DrawingData pending update
   * @returns {object}      The adjusted data
   */
  static normalizeShape(data) {

    // Adjust shapes with an explicit points array
    const rawPoints = data.shape.points;
    if ( rawPoints?.length ) {

      // Organize raw points and de-dupe any points which repeated in sequence
      const xs = [];
      const ys = [];
      for ( let i=1; i<rawPoints.length; i+=2 ) {
        const x0 = rawPoints[i-3];
        const y0 = rawPoints[i-2];
        const x1 = rawPoints[i-1];
        const y1 = rawPoints[i];
        if ( (x1 === x0) && (y1 === y0) ) {
          continue;
        }
        xs.push(x1);
        ys.push(y1);
      }

      // Determine minimal and maximal points
      const minX = Math.min(...xs);
      const maxX = Math.max(...xs);
      const minY = Math.min(...ys);
      const maxY = Math.max(...ys);

      // Normalize points relative to minX and minY
      const points = [];
      for ( let i=0; i<xs.length; i++ ) {
        points.push(xs[i] - minX, ys[i] - minY);
      }

      // Update data
      data.x += minX;
      data.y += minY;
      data.shape.width = maxX - minX;
      data.shape.height = maxY - minY;
      data.shape.points = points;
    }

    // Adjust rectangles
    else {
      const normalized = new PIXI.Rectangle(data.x, data.y, data.shape.width, data.shape.height).normalize();
      data.x = normalized.x;
      data.y = normalized.y;
      data.shape.width = normalized.width;
      data.shape.height = normalized.height;
    }
    return data;
  }
}
