import {ShapeControlsHandle, PreciseText} from "@client/canvas/containers/_module.mjs";
import MouseInteractionManager from "@client/canvas/interaction/mouse-handler.mjs";
import {CLIPPER_SCALING_FACTOR} from "@common/constants.mjs";

/**
 * @import PlaceableObject from "../placeable-object.mjs";
 * @import {BaseShapeData} from "@common/data/_module.mjs";
 */

/**
 * A mixin for UX shared between PlaceableObjects that have shapes.
 * @param {typeof PlaceableObject} Base
 */
export default function ShapeObjectMixin(Base) {
  return class ShapeObject extends Base {
    /**
     * The measurement lines.
     * @type {PIXI.Graphics}
     * @protected
     */
    _measurementLines;

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

    /**
     * The measurement labels.
     * @type {PIXI.Container}
     * @protected
     */
    _measurementLabels;

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

    /**
     * The solid measurement line style.
     * @type {PIXI.ILineStyleOptions}
     * @protected
     */
    _measurementSolidLineStyle;

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

    /**
     * The dashed measurement line style.
     * @type {PIXI.ILineStyleOptions}
     * @protected
     */
    _measurementDashLineStyle;

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

    /**
     * The controls handle that is currently hovered, if any.
     * @type {ShapeControlsHandle|null}
     */
    get hoveredHandle() {
      return this._hoveredHandle;
    }

    /**
     * @type {ShapeControlsHandle|null}
     * @internal
     */
    _hoveredHandle = null;

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

    /** @override */
    get bounds() {
      if ( !this.document.shapes ) return this.document.shape.bounds.clone(); // PlaceableObject#bounds always returns a new instance
      let bounds;
      const shapes = this.document.shapes;
      if ( shapes.length === 0 ) bounds = new PIXI.Rectangle();
      else {
        bounds = shapes[0].bounds.clone();
        for ( let i = 1; i < shapes.length; i++ ) {
          bounds.enlarge(shapes[i].bounds);
        }
      }
      return bounds;
    }

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

    /** @override */
    get center() {
      if ( !this.document.shapes ) {
        const {x, y} = this.document.shape.center;
        return new PIXI.Point(x, y);
      }
      let x = 0;
      let y = 0;
      let totalArea = 0;
      for ( const shape of this.document.shapes ) {
        x += (shape.center.x * shape.area);
        y += (shape.center.y * shape.area);
        totalArea += shape.area;
      }
      if ( totalArea > 0 ) {
        x /= totalArea;
        y /= totalArea;
      }
      return new PIXI.Point(x, y);
    }

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

    /** @override */
    _getTargetAlpha() {
      return 1;
    }

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

    /** @override */
    _overlapsSelection(rectangle) {
      if ( !rectangle.intersects(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)
      ];
      let shapePaths;
      if ( this.document.shapes ) {
        shapePaths = [];
        for ( const shape of this.document.shapes ) shapePaths.push(...shape.clipperPaths);
      }
      else shapePaths = this.document.shape.clipperPaths;
      const clipper = new ClipperLib.Clipper();
      const solution = [];
      clipper.Clear();
      clipper.AddPath(rectanglePath, ClipperLib.PolyType.ptSubject, true);
      clipper.AddPaths(shapePaths, ClipperLib.PolyType.ptClip, true);
      clipper.Execute(ClipperLib.ClipType.ctIntersection, solution);
      return solution.length !== 0;
    }

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

    /** @inheritDoc */
    _onClickLeft(event) {
      const allowed = super._onClickLeft(event);
      if ( allowed === false ) return false;
      if ( event.target instanceof ShapeControlsHandle ) {
        event.interactionData.handle = event.target;
        event.stopPropagation();
      }
    }

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

    /** @inheritDoc */
    _onClickLeft2(event) {
      if ( event.target instanceof ShapeControlsHandle ) {
        event.interactionData.handle = event.target;
        event.stopPropagation();
        return event.interactionData.handle.controls._onClick2(event);
      }
      return super._onClickLeft2(event);
    }

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

    /** @inheritDoc */
    _canDragLeftStart(user, event, options) {
      const allowed = super._canDragLeftStart(user, event, options);
      if ( !allowed ) return false;
      const handle = event.interactionData.handle;
      if ( handle ) return handle.controls._canDragStart(event, options);
      return true;
    }

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

    /** @inheritDoc */
    _onDragLeftStart(event) {
      const handle = event.interactionData.handle;
      if ( handle ) return handle.controls._onDragStart(event);
      return super._onDragLeftStart(event);
    }

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

    /** @inheritDoc */
    _onDragLeftMove(event) {
      const handle = event.interactionData.handle;
      if ( handle ) return handle.controls._onDragMove(event);
      canvas._onDragCanvasPan(event);
      const {destination, shape, offset} = event.interactionData;
      shape.move({x: destination.x - offset.x, y: destination.y - offset.y}, {snap: !event.shiftKey});
      this._updateDragPreviews(event);
    }

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

    /**
     * Update the drag previews. Called when the shape has changed.
     * @param {PIXI.FederatedEvent} event    The pointer event
     * @protected
     */
    _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);
      if ( this.document.schema.has("shapes") ) {
        for ( const clone of clones ) {
          const shapes = clone._original.document.shapes.map(shape => {
            shape = shape.clone();
            shape.move({x: shape.origin.x + dx, y: shape.origin.y + dy});
            return shape.toObject();
          });
          clone.document.updateSource({shapes});
          clone.renderFlags.set({refreshShapes: true});
        }
      } else if ( this.document.schema.has("shape") ) {
        for ( const clone of clones ) {
          const shape = clone._original.document.shape.clone();
          shape.move({x: shape.origin.x + dx, y: shape.origin.y + dy});
          clone.document.updateSource({shape: shape.toObject()});
          clone.renderFlags.set({refreshShape: true});
        }
      } else if ( this.document.schema.has("x") && this.document.schema.has("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});
        }
      } else {
        throw new Error(`${this.constructor.name} must implement the _updateDragPreviews method.`);
      }
    }

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

    /** @inheritDoc */
    _onDragLeftDrop(event) {
      const handle = event.interactionData.handle;
      if ( handle ) return handle.controls._onDragDrop(event);
      return super._onDragLeftDrop(event);
    }

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

    /** @inheritDoc */
    _onDragLeftCancel(event) {
      const handle = event.interactionData.handle;
      if ( handle ) return handle.controls._onDragCancel(event);
      return super._onDragLeftCancel(event);
    }

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

    /** @inheritDoc */
    _initializeDragLeft(event) {
      const interaction = event.interactionData;
      if ( interaction.handle ) return;
      super._initializeDragLeft(event);
      const {origin} = interaction;
      interaction.shape = this._initializeDragShape(event);
      const {x, y} = interaction.shape.origin;
      interaction.offset = {x: origin.x - x, y: origin.y - y};
    }

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

    /**
     * Initialize the shape for dragging.
     * @param {PIXI.FederatedEvent} event    The pointer event
     * @returns {BaseShapeData}              The shape that is dragged
     */
    _initializeDragShape(event) {
      if ( !this.document.shapes ) return this.document.shape.clone();
      const {origin} = event.interactionData;
      let shape;
      for ( let i = this.document.shapes.length - 1; i >= 0; i-- ) {
        shape = this.document.shapes[i];
        if ( (i === 0) || shape.testPoint(origin) ) break;
      }
      return shape.clone();
    }

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

    /** @override */
    _prepareDragLeftDropUpdates(event) {
      const {clones} = event.interactionData;
      const updates = [];
      if ( this.document.shapes ) {
        for ( const clone of clones ) updates.push({_id: clone._original.id, shapes: clone.document.toObject().shapes});
      } else if ( this.document.schema.has("shape") ) {
        for ( const clone of clones ) updates.push({_id: clone._original.id, shape: clone.document.toObject().shape});
      } else {
        for ( const clone of clones ) {
          const {x, y} = clone.document;
          updates.push({_id: clone._original.id, x, y});
        }
      }
      return updates;
    }

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

    /** @inheritDoc */
    _finalizeDragLeft(event) {
      if ( event.interactionData.handle ) return;
      super._finalizeDragLeft(event);
    }

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

    /** @inheritDoc */
    _draw(options) {
      this._measurementLines = this.addChild(new PIXI.Graphics());
      this._measurementLines.eventMode = "none";
      this._measurementLabels = this.addChild(new PIXI.Container());
      this._measurementLabels.eventMode = "none";
      const thickness = CONFIG.Canvas.objectBorderThickness * canvas.dimensions.uiScale;
      const dash = thickness * 2;
      const gap = dash * (5 / 8);
      this._measurementSolidLineStyle = {
        width: thickness / 2,
        color: 0x000000
      };
      this._measurementDashLineStyle = {
        width: thickness / 2,
        color: 0x000000,
        shader: new PIXI.smooth.DashLineShader({dash, gap})
      };
    }

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

    /**
     * Define a PIXI TextStyle object which is used for the measurement labels.
     * @returns {PIXI.TextStyle}
     * @protected
     */
    _getMeasurementTextStyle() {
      return CONFIG.canvasTextStyle;
    }

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

    /**
     * Get the shape that should be measured.
     * @returns {BaseShapeData[]}
     * @protected
     */
    _getMeasuredShapes() {
      return [];
    }

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

    /**
     * Format a distance that is displayed in a measurement label.
     * @param {number} distance     The distance
     * @returns {string}            The distance label
     * @protected
     */
    _formatMeasuredDistance(distance) {
      const formattedDistance = distance.toNearest(0.01).toLocaleString(game.i18n.lang);
      const units = canvas.grid.units;
      return units ? `${formattedDistance} ${units}` : formattedDistance;
    }

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

    /**
     * Refresh the measurements.
     * @protected
     */
    _refreshMeasurements() {
      this._measurementLines.clear();

      // Get the shapes to draw measurements for
      const shapes = this._getMeasuredShapes();
      if ( !shapes.length ) {
        this._measurementLabels.removeChildren().forEach(c => c.destroy({children: true}));
        return;
      }

      // Draw references lines
      this._measurementLines.lineStyle(this._measurementDashLineStyle);
      for ( const shape of shapes ) shape.drawReferenceLines(this._measurementLines);

      const uiScale = canvas.dimensions.uiScale;
      const thickness = CONFIG.Canvas.objectBorderThickness * uiScale;
      const originRadius = thickness * 2;

      // Collect measurement segments and draw shape outlines and origin
      const measuredSegments = [];
      this._measurementLines.lineStyle(this._measurementSolidLineStyle);
      for ( const shape of shapes ) {

        // Draw shape outline
        shape.drawShape(this._measurementLines);

        // Draw shape origin
        this._measurementLines.drawCircle(shape.origin.x, shape.origin.y, originRadius);

        // Get measurements
        measuredSegments.push(...shape.measuredSegments);
      }

      let usedLabels = 0;
      const createLabel = (text, rotation, winding, anchorX) => {
        let label = this._measurementLabels.children.at(usedLabels++);
        if ( label ) {
          label.text = text;
          label.style = textStyle;
        }
        else label = this._measurementLabels.addChild(new PreciseText(text, textStyle));
        label.scale.set(uiScale, uiScale);
        const flip = !rotation.between(-(Math.PI / 2) - 1e-9, (Math.PI / 2) - 1e-9);
        label.anchor.x = anchorX;
        label.anchor.y = (1 - winding) / 2;
        if ( flip ) {
          label.anchor.x = 1 - label.anchor.x;
          label.anchor.y = 1 - label.anchor.y;
        }
        label.pivot.y = Math.sign(winding) * (flip ? 1 : -1) * thickness;
        label.rotation = rotation + (flip ? Math.PI : 0);
        return label;
      };

      // Create measurement labels and draw measurement lines
      this._measurementLines.lineStyle(this._measurementDashLineStyle);
      const textStyle = this._getMeasurementTextStyle();
      for ( let i = 0; i < measuredSegments.length; i++ ) {
        const {ray, winding, distance, angle} = measuredSegments[i];
        if ( distance >= (0.5 * canvas.dimensions.distance) - 1e-9 ) {

          // Create distance label
          const distanceText = this._formatMeasuredDistance(distance);
          const distanceLabel = createLabel(distanceText, ray.angle, winding, 0.5);
          distanceLabel.position = ray.project(0.5);
          const s = Math.clamp(ray.distance / (2 * canvas.dimensions.size), 0.25, 1);
          distanceLabel.scale.x *= s;
          distanceLabel.scale.y *= s;

          // Create angle label
          if ( (angle !== undefined) && (distance >= (2 * canvas.dimensions.distance) - 1e-9) ) {
            const formattedDistance = angle.toNearest(0.01).toLocaleString(game.i18n.lang);
            const distanceText = `${formattedDistance}°`;
            const angleLabel = createLabel(distanceText, ray.angle, winding, 0);
            angleLabel.position = ray.project(2 * originRadius / ray.distance);
            const s = Math.clamp((ray.distance - (1.5 * canvas.dimensions.size))
              / (2 * canvas.dimensions.size), 0.25, 1);
            angleLabel.scale.x *= s;
            angleLabel.scale.y *= s;
          }
        }

        // Draw measurement line
        if ( winding === 0 ) {
          this._measurementLines.moveTo(ray.A.x, ray.A.y);
          this._measurementLines.lineTo(ray.B.x, ray.B.y);
        }
      }

      // Destroy excess measurement labels
      if ( usedLabels < this._measurementLabels.children.length ) {
        this._measurementLabels.removeChildren(usedLabels).forEach(c => c.destroy({children: true}));
      }
    }

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

    /** @inheritDoc */
    _onUpdate(changed, options, userId) {
      super._onUpdate(changed, options, userId);
      if ( this._hasShapeChanged(changed) ) this.#cancelInteractionWorkflows();
    }

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

    /** @inheritDoc */
    _onDelete(options, userId) {
      this.#cancelInteractionWorkflows();
      super._onDelete(options, userId);
    }

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

    /**
     * Cancel all interaction workflows related to this object.
     */
    #cancelInteractionWorkflows() {

      // Cancel draw workflow if any
      if ( this.mouseInteractionManager?.state > MouseInteractionManager.INTERACTION_STATES.HOVER ) {
        this.mouseInteractionManager.interactionData.cancelled = true;
        this.mouseInteractionManager.cancel();
      }

      // Cancel shape creation workflow if any
      if ( (this._preview?._previewType === "creation")
        && (canvas.mouseInteractionManager.state > MouseInteractionManager.INTERACTION_STATES.HOVER) ) {
        canvas.mouseInteractionManager.interactionData.cancelled = true;
        canvas.mouseInteractionManager.cancel();
      }
    }

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

    /**
     * Has the shape or a shape changed?
     * @param {object} changed  The changes of the update operation
     * @returns {boolean}       True if the shape or a shape has changed
     * @internal
     */
    _hasShapeChanged(changed) {
      if ( this.document.schema.has("shapes") ) return "shapes" in changed;
      if ( this.document.schema.has("shape") ) return "shape" in changed;
      throw new Error(`${this.constructor.name} must implement the _hasShapeChanged method.`);
    }
  };
}
