import {GRID_TYPES} from "../constants.mjs";
import Color from "../utils/color.mjs";
import {lineLineIntersection} from "../utils/geometry.mjs";

/**
 * @import {GridConfiguration, GridOffset2D, GridOffset3D, GridCoordinates2D, GridCoordinates3D, GridSnappingBehavior,
 *   GridMeasurePathWaypointData2D, GridMeasurePathWaypointData3D, GridMeasurePathResult, GridMeasurePathCostFunction2D,
 *   GridMeasurePathCostFunction3D} from "./_types.mjs";
 * @import {Point, ElevatedPoint, Rectangle} from "../_types.mjs";
 * @import {GridType, MOVEMENT_DIRECTIONS} from "../constants.mjs";
 */

/**
 * The base grid class.
 * @template [Coordinates2D=GridCoordinates2D]
 * @template [Coordinates3D=GridCoordinates3D]
 * @abstract
 */
export default class BaseGrid {
  /**
   * The base grid constructor.
   * @param {GridConfiguration} config    The grid configuration
   */
  constructor(config) {
    let {size, distance=1, units="", style="solidLines", thickness=1, color, alpha=1} = config;
    if ( size === undefined ) throw new Error(`${this.constructor.name} cannot be constructed without a size`);
    if ( !(size > 0) ) throw new Error("The size must be positive");
    if ( !(distance > 0) ) throw new Error("The distance must be positive");

    // Convert the color to a Color
    if ( color ) color = Color.from(color);
    if ( !color?.valid ) color = new Color(0);

    /**
     * The size of a grid space in pixels.
     * @type {number}
     * @readonly
     */
    this.size = size;

    /**
     * The width of a grid space in pixels.
     * @type {number}
     * @readonly
     */
    this.sizeX = size;

    /**
     * The height of a grid space in pixels.
     * @type {number}
     * @readonly
     */
    this.sizeY = size;

    /**
     * The distance of a grid space in units.
     * @type {number}
     * @readonly
     */
    this.distance = distance;

    /**
     * The distance units used in this grid.
     * @type {string}
     * @readonly
     */
    this.units = units;

    /**
     * The style of the grid.
     * @type {string}
     * @readonly
     */
    this.style = style;

    /**
     * The thickness of the grid.
     * @type {number}
     * @readonly
     */
    this.thickness = thickness;

    /**
     * The color of the grid.
     * @type {Color}
     * @readonly
     */
    this.color = color;

    /**
     * The opacity of the grid.
     * @type {number}
     * @readonly
     */
    this.alpha = alpha;
  }

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

  /**
   * The grid type (see {@link CONST.GRID_TYPES}).
   * @type {GridType}
   * @readonly
   */
  type;

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

  /**
   * Is this a gridless grid?
   * @type {boolean}
   */
  get isGridless() {
    return this.type === GRID_TYPES.GRIDLESS;
  }

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

  /**
   * Is this a square grid?
   * @type {boolean}
   */
  get isSquare() {
    return this.type === GRID_TYPES.SQUARE;
  }

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

  /**
   * Is this a hexagonal grid?
   * @type {boolean}
   */
  get isHexagonal() {
    return (this.type >= GRID_TYPES.HEXODDR) && (this.type <= GRID_TYPES.HEXEVENQ);
  }

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

  /**
   * Calculate the total size of the canvas with padding applied, as well as the top-left coordinates of the inner
   * rectangle that houses the scene.
   * @param {number} sceneWidth         The width of the scene.
   * @param {number} sceneHeight        The height of the scene.
   * @param {number} padding            The percentage of padding.
   * @returns {{width: number, height: number, x: number, y: number, rows: number, columns: number}}
   * @abstract
   */
  calculateDimensions(sceneWidth, sceneHeight, padding) {
    throw new Error("A subclass of the BaseGrid must implement the calculateDimensions method");
  }

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

  /**
   * Returns the offset of the grid space corresponding to the given coordinates.
   * @overload
   * @param {Coordinates2D} coords    The coordinates
   * @returns {GridOffset2D}          The offset
   */
  /**
   * @overload
   * @param {Coordinates3D} coords    The coordinates
   * @returns {GridOffset3D}          The offset
   * @abstract
   */
  getOffset(coords) {
    throw new Error("A subclass of the BaseGrid must implement the getOffset method");
  }

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

  /**
   * Returns the smallest possible range containing the offsets of all grid spaces that intersect the given bounds.
   * If the bounds are empty (nonpositive width or height), then the offset range is empty.
   * @example
   * ```js
   * 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};
   *     // ...
   *   }
   * }
   * ```
   * @param {Rectangle} bounds                                      The bounds
   * @returns {[i0: number, j0: number, i1: number, j1: number]}    The offset range
   * @abstract
   */
  getOffsetRange({x, y, width, height}) {
    throw new Error("A subclass of the BaseGrid must implement the getOffsetRange method");
  }

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

  /**
   * Returns the offsets of the grid spaces adjacent to the one corresponding to the given coordinates.
   * Returns always an empty array in gridless grids.
   * @overload
   * @param {Coordinates2D} coords    The coordinates
   * @returns {GridOffset2D[]}        The adjacent offsets
   */
  /**
   * @overload
   * @param {Coordinates3D} coords    The coordinates
   * @returns {GridOffset3D[]}        The adjacent offsets
   * @abstract
   */
  getAdjacentOffsets(coords) {
    throw new Error("A subclass of the BaseGrid must implement the getAdjacentOffsets method");
  }

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

  /**
   * Returns true if the grid spaces corresponding to the given coordinates are adjacent to each other.
   * In square and hexagonal grids with illegal diagonals the diagonally neighboring grid spaces are not adjacent.
   * Returns always false in gridless grids.
   * @overload
   * @param {Coordinates2D} coords1    The first coordinates
   * @param {Coordinates2D} coords2    The second coordinates
   * @returns {boolean}
   */
  /**
   * @overload
   * @param {Coordinates3D} coords1    The first coordinates
   * @param {Coordinates3D} coords2    The second coordinates
   * @returns {boolean}
   * @abstract
   */
  testAdjacency(coords1, coords2) {
    throw new Error("A subclass of the BaseGrid must implement the testAdjacency method");
  }

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

  /**
   * Returns the offset of the grid space corresponding to the given coordinates
   * shifted by one grid space in the given direction. The k-coordinate is not changed.
   * In square and hexagonal grids with illegal diagonals the offset of the given coordinates is returned
   * if the direction is diagonal.
   * In gridless grids the point is by the grid size.
   * @overload
   * @param {Coordinates2D} coords             The coordinates
   * @param {MOVEMENT_DIRECTIONS} direction    The direction (see {@link CONST.MOVEMENT_DIRECTIONS})
   * @returns {GridOffset2D}                   The offset
   */
  /**
   * @overload
   * @param {Coordinates3D} coords             The coordinates
   * @param {MOVEMENT_DIRECTIONS} direction    The direction (see {@link CONST.MOVEMENT_DIRECTIONS})
   * @returns {GridOffset3D}                   The offset
   * @abstract
   */
  getShiftedOffset(coords, direction) {
    throw new Error("A subclass of the BaseGrid must implement the getShiftedOffset method");
  }

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

  /**
   * Returns the point shifted by the difference between the grid space corresponding to the given coordinates
   * and the shifted grid space in the given direction. The z-coordinate is not changed.
   * In square and hexagonal grids with illegal diagonals the point is not shifted if the direction is diagonal.
   * In gridless grids the point coordinates are shifted by the grid size.
   * @overload
   * @param {Point} point                      The point that is to be shifted
   * @param {MOVEMENT_DIRECTIONS} direction    The direction (see {@link CONST.MOVEMENT_DIRECTIONS})
   * @returns {Point}                          The shifted point
   */
  /**
   * @overload
   * @param {ElevatedPoint} point              The point that is to be shifted
   * @param {MOVEMENT_DIRECTIONS} direction    The direction (see {@link CONST.MOVEMENT_DIRECTIONS})
   * @returns {ElevatedPoint}                  The shifted point
   * @abstract
   */
  getShiftedPoint(point, direction) {
    throw new Error("A subclass of the BaseGrid must implement the getShiftedPoint method");
  }

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

  /**
   * Returns the top-left point of the grid space bounds corresponding to the given coordinates.
   * If given a point, the top-left point of the grid space bounds that contains it is returned.
   * The top-left point lies in the plane of the bottom face of the 3D grid space.
   * In gridless grids a point with the same coordinates as the given point is returned.
   * @overload
   * @param {Coordinates2D} coords    The coordinates
   * @returns {Point}                 The top-left point
   */
  /**
   * @overload
   * @param {Coordinates3D} coords    The coordinates
   * @returns {ElevatedPoint}         The top-left point
   * @abstract
   */
  getTopLeftPoint(coords) {
    throw new Error("A subclass of the BaseGrid must implement the getTopLeftPoint method");
  }

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

  /**
   * Returns the center point of the grid space corresponding to the given coordinates.
   * If given a point, the center point of the grid space that contains it is returned.
   * The center point lies in the plane of the bottom face of the 3D grid space.
   * In gridless grids a point with the same coordinates as the given point is returned.
   * @overload
   * @param {Coordinates2D} coords    The coordinates
   * @returns {Point}                 The center point
   */
  /**
   * @overload
   * @param {Coordinates3D} coords    The coordinates
   * @returns {ElevatedPoint}         The center point
   * @abstract
   */
  getCenterPoint(coords) {
    throw new Error("A subclass of the BaseGrid must implement the getCenterPoint method");
  }

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

  /**
   * Returns the points of the grid space shape relative to the center point.
   * The points are returned in the same order as in {@link BaseGrid#getVertices}.
   * In gridless grids an empty array is returned.
   * @returns {Point[]}    The points of the polygon
   * @abstract
   */
  getShape() {
    throw new Error("A subclass of the BaseGrid must implement the getShape method");
  }

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

  /**
   * Returns the vertices of the grid space corresponding to the given coordinates.
   * The vertices are returned ordered in positive orientation with the first vertex
   * being the top-left vertex in square grids, the top vertex in row-oriented
   * hexagonal grids, and the left vertex in column-oriented hexagonal grids.
   * In gridless grids an empty array is returned.
   * @param {Coordinates2D} coords      The coordinates
   * @returns {Point[]}                 The vertices
   * @abstract
   */
  getVertices(coords) {
    throw new Error("A subclass of the BaseGrid must implement the getVertices method");
  }

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

  /**
   * Snaps the given point to the grid.
   * In square and hexagonal grids the z-coordinate of the point is rounded to the nearest multiple of the grid size.
   * In gridless grids a point with the same coordinates as the given point is returned regardless of the
   * snapping behavior.
   * @overload
   * @param {Point} point                      The point that is to be snapped
   * @param {GridSnappingBehavior} behavior    The snapping behavior
   * @returns {Point}                          The snapped point
   */
  /**
   * @overload
   * @param {ElevatedPoint} point              The point that is to be snapped
   * @param {GridSnappingBehavior} behavior    The snapping behavior
   * @returns {ElevatedPoint}                  The snapped point
   * @abstract
   */
  getSnappedPoint(point, behavior) {
    throw new Error("A subclass of the BaseGrid must implement the getSnappedPoint method");
  }

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

  /**
   * Measure a shortest, direct path through the given waypoints.
   * @template {{[K in "i"|"j"|"k"|"q"|"r"|"s"|"x"|"y"|"elevation"|"cost"]: never}} [SegmentData={}]
   * @overload
   * @param {(Coordinates2D & Partial<GridMeasurePathWaypointData2D> & SegmentData)[]} waypoints
   *   The waypoints the path must pass through
   * @param {object} [options]                                             Additional measurement options
   * @param {GridMeasurePathCostFunction2D<SegmentData>} [options.cost]    The function that returns the cost
   *   for a given move between grid spaces (default is the distance travelled along the direct path)
   * @returns {GridMeasurePathResult}    The measurements a shortest, direct path through the given waypoints
   */
  /**
   * @overload
   * @param {(Coordinates3D & Partial<GridMeasurePathWaypointData3D> & SegmentData)[]} waypoints
   *   The waypoints the path must pass through
   * @param {object} [options]                                             Additional measurement options
   * @param {GridMeasurePathCostFunction3D<SegmentData>} [options.cost]    The function that returns the cost
   *   for a given move between grid spaces (default is the distance travelled along the direct path)
   * @returns {GridMeasurePathResult}    The measurements a shortest, direct path through the given waypoints
   */
  measurePath(waypoints, options={}) {
    const result = {
      waypoints: [],
      segments: [],
      distance: 0,
      cost: 0,
      spaces: 0,
      diagonals: 0,
      euclidean: 0
    };
    if ( waypoints.length === 0 ) return result;

    // Create result waypoints and segments
    let from = {backward: null, forward: null, distance: 0, cost: 0, spaces: 0, diagonals: 0, euclidean: 0};
    result.waypoints.push(from);
    for ( let i = 1; i < waypoints.length; i++ ) {
      const to = {backward: null, forward: null, distance: 0, cost: 0, spaces: 0, diagonals: 0, euclidean: 0};
      const segment = {from, to, distance: 0, cost: 0, spaces: 0, diagonals: 0, euclidean: 0};
      from.forward = to.backward = segment;
      result.waypoints.push(to);
      result.segments.push(segment);
      from = to;
    }

    // Measure segments
    this._measurePath(waypoints, options, result);

    // Accumulate segment measurements
    for ( let i = 1; i < waypoints.length; i++ ) {
      const waypoint = result.waypoints[i];
      const segment = waypoint.backward;

      // Accumulate measurements
      result.distance += segment.distance;
      result.cost += segment.cost;
      result.spaces += segment.spaces;
      result.diagonals += segment.diagonals;
      result.euclidean += segment.euclidean;

      // Set waypoint measurements
      waypoint.distance = result.distance;
      waypoint.cost = result.cost;
      waypoint.spaces = result.spaces;
      waypoint.diagonals = result.diagonals;
      waypoint.euclidean = result.euclidean;
    }
    return result;
  }

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

  /**
   * Measures the path and writes the segments measurements into the result.
   * The waypoint measurements are filled in by {@link BaseGrid#measurePath}.
   * Called by {@link BaseGrid#measurePath}.
   * @template {{[K in "i"|"j"|"k"|"q"|"r"|"s"|"x"|"y"|"elevation"|"cost"|"measure"]: never}} SegmentData
   * @overload
   * @param {(Coordinates2D & Partial<GridMeasurePathWaypointData2D> & SegmentData)[]} waypoints
   *   The waypoints the path must pass through
   * @param {object} [options]                                             Additional measurement options
   * @param {GridMeasurePathCostFunction2D<SegmentData>} [options.cost]    The function that returns the cost
   *   for a given move between grid spaces (default is the distance travelled)
   * @param {GridMeasurePathResult} result    The measurement result that the measurements need to be written to
   */
  /**
   * @overload
   * @param {(Coordinates3D & Partial<GridMeasurePathWaypointData3D> & SegmentData)[]} waypoints
   *   The waypoints the path must pass through
   * @param {object} [options]                                             Additional measurement options
   * @param {GridMeasurePathCostFunction3D<SegmentData>} [options.cost]    The function that returns the cost
   *   for a given move between grid spaces (default is the distance travelled)
   * @param {GridMeasurePathResult} result    The measurement result that the measurements need to be written to
   * @protected
   * @abstract
   */
  _measurePath(waypoints, options, result) {
    throw new Error("A subclass of the BaseGrid must implement the _measurePath method");
  }

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

  /**
   * Returns the sequence of grid offsets of a shortest, direct path passing through the given waypoints.
   * @overload
   * @param {Coordinates2D[]} waypoints    The waypoints the path must pass through
   * @returns {GridOffset2D[]}             The sequence of grid offsets of a shortest, direct path
   */
  /**
   * @overload
   * @param {Coordinates3D[]} waypoints    The waypoints the path must pass through
   * @returns {GridOffset3D[]}             The sequence of grid offsets of a shortest, direct path
   * @abstract
   */
  getDirectPath(waypoints) {
    throw new Error("A subclass of the BaseGrid must implement the getDirectPath method");
  }

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

  /**
   * Get the point translated in a direction by a distance.
   * The z-coordinate is not changed.
   * @overload
   * @param {Point} point            The point that is to be translated
   * @param {number} direction       The angle of direction in degrees
   * @param {number} distance        The distance in grid units
   * @returns {Point}                The translated point
   */
  /**
   * @overload
   * @param {ElevatedPoint} point    The point that is to be translated
   * @param {number} direction       The angle of direction in degrees
   * @param {number} distance        The distance in grid units
   * @returns {ElevatedPoint}        The translated point
   * @abstract
   */
  getTranslatedPoint(point, direction, distance) {
    throw new Error("A subclass of the BaseGrid must implement the getTranslatedPoint method");
  }

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

  /**
   * Get the rectangle polygon given the width and height in grid units for this grid.
   * The points of the polygon are returned ordered in positive orientation.
   * @param {Point} origin        The origin point of the rectangle.
   * @param {number} width        The width in grid units.
   * @param {number} height       The height in grid units.
   * @param {Point} anchor        The anchor.
   * @param {number} rotation     The rotation in degrees.
   * @returns {Point[]}           The points of the rectangle polygon.
   */
  getRectangle(origin, width, height, anchor, rotation) {
    if ( (width <= 0) || (height <= 0) ) return [];
    const b = this.getTranslatedPoint(origin, rotation, width);
    const d = this.getTranslatedPoint(origin, rotation + 90, height);
    const dx = (anchor.x * (b.x - origin.x)) + (anchor.y * (d.x - origin.x));
    const dy = (anchor.x * (b.y - origin.y)) + (anchor.y * (d.y - origin.y));
    b.x -= dx;
    b.y -= dy;
    d.x -= dx;
    d.y -= dy;
    const a = {x: origin.x - dx, y: origin.y - dy};
    const c = {x: d.x + b.x - a.x, y: d.y + b.y - a.y};
    return [a, b, c, d];
  }

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

  /**
   * Get the line polygon given the length and width in grid units for this grid.
   * The points of the polygon are returned ordered in positive orientation.
   * @param {Point} origin        The origin point of the line.
   * @param {number} length       The length in grid units.
   * @param {number} width        The width in grid units.
   * @param {number} direction    The direction in degrees.
   * @returns {Point[]}           The points of the line polygon.
   */
  getLine(origin, length, width, direction) {
    if ( (length <= 0) || (width <= 0) ) return [];
    const destination = this.getTranslatedPoint(origin, direction, length);
    let {x: dx, y: dy} = this.getTranslatedPoint({x: 0, y: 0}, direction + 90, width);
    dx /= 2;
    dy /= 2;
    const a = {x: origin.x - dx, y: origin.y - dy};
    const b = {x: destination.x - dx, y: destination.y - dy};
    const c = {x: destination.x + dx, y: destination.y + dy};
    const d = {x: origin.x + dx, y: origin.y + dy};
    return [a, b, c, d];
  }

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

  /**
   * Get the circle polygon given the radius in grid units for this grid.
   * The points of the polygon are returned ordered in positive orientation.
   * In gridless grids an approximation of the true circle with a deviation of less than 0.25 pixels is returned.
   * @param {Point} center      The center point of the circle.
   * @param {number} radius     The radius in grid units.
   * @returns {Point[]}         The points of the circle polygon.
   * @abstract
   */
  getCircle(center, radius) {
    throw new Error("A subclass of the BaseGrid must implement the getCircle method");
  }

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

  /**
   * Get the cone polygon given the radius in grid units and the angle in degrees for this grid.
   * The points of the polygon are returned ordered in positive orientation.
   * In gridless grids an approximation of the true cone with a deviation of less than 0.25 pixels is returned.
   * If the angle less than 360 and the cone not empty, the first point of the polygon is the origin.
   * @param {Point} origin        The origin point of the cone
   * @param {number} radius       The radius in grid units
   * @param {number} direction    The direction in degrees
   * @param {number} angle        The angle in degrees
   * @returns {Point[]}           The points of the cone polygon
   */
  getCone(origin, radius, direction, angle) {
    if ( (radius <= 0) || (angle <= 0) ) return [];
    const circle = this.getCircle(origin, radius);
    if ( angle >= 360 ) return circle;
    const n = circle.length;
    const aMin = Math.normalizeRadians(Math.toRadians(direction - (angle / 2)));
    const aMax = aMin + Math.toRadians(angle);
    const pMin = {x: origin.x + (Math.cos(aMin) * this.size), y: origin.y + (Math.sin(aMin) * this.size)};
    const pMax = {x: origin.x + (Math.cos(aMax) * this.size), y: origin.y + (Math.sin(aMax) * this.size)};
    const angles = circle.map(p => {
      const a = Math.atan2(p.y - origin.y, p.x - origin.x);
      return a >= aMin ? a : a + (2 * Math.PI);
    });
    const points = [{x: origin.x, y: origin.y}];
    for ( let i = 0, c0 = circle[n - 1], a0 = angles[n - 1]; i < n; i++ ) {
      let c1 = circle[i];
      let a1 = angles[i];
      if ( a0 > a1 ) {
        const {x: x1, y: y1} = lineLineIntersection(c0, c1, origin, pMin);
        points.push({x: x1, y: y1});
        while ( a1 < aMax ) {
          points.push(c1);
          i = (i + 1) % n;
          c0 = c1;
          c1 = circle[i];
          a0 = a1;
          a1 = angles[i];
          if ( a0 > a1 ) break;
        }
        const {x: x2, y: y2} = lineLineIntersection(c0, c1, origin, pMax);
        points.push({x: x2, y: y2});
        break;
      }
      c0 = c1;
      a0 = a1;
    }
    return points;
  }

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

  /**
   * Get the ellipse polygon given the radius in grid units for this grid.
   * The points of the polygon are returned ordered in positive orientation.
   * In gridless grids an approximation of the true ellipse with a deviation of less than 0.25 pixels is returned.
   * @param {Point} center      The center point of the ellipse.
   * @param {number} radiusX    The x-radius in grid units.
   * @param {number} radiusY    The y-radius in grid units.
   * @param {number} rotation   The rotation in degrees.
   * @returns {Point[]}         The points of the ellipse polygon.
   * @abstract
   */
  getEllipse(center, radiusX, radiusY, rotation) {
    if ( radiusX === radiusY ) return this.getCircle(center, radiusX);
    if ( (radiusX <= 0) || (radiusY <= 0) ) return [];
    const ratio = (radiusY / radiusX) - 1;
    const a = Math.toRadians(rotation);
    const cos = Math.cos(a);
    const sin = Math.sin(a);
    const points = [];
    for ( const point1 of this.getCircle(center, radiusX) ) {
      const dx = point1.x - center.x;
      const dy = point1.y - center.y;
      const t = ((cos * dy) - (sin * dx)) * ratio;
      points.push({
        x: point1.x - (t * sin),
        y: point1.y + (t * cos)
      });
    }
    return points;
  }

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

  /**
   * Get the ring polygon given the radius and width in grid units for this grid.
   * The points of the polygons are returned ordered in positive orientation.
   * In gridless grids an approximation of the true ring with a deviation of less than 0.25 pixels is returned.
   * @param {Point} center                                      The center point of the ring.
   * @param {number} radius                                     The radius in grid units.
   * @param {number} innerWidth                                 The inner width in grid units.
   * @param {number} outerWidth                                 The outer width in grid units.
   * @returns {[innerCircle: Point[], outerCircle: Point[]]}    The inner and outer circles of the ring polygon.
   */
  getRing(center, radius, innerWidth, outerWidth) {
    const innerCircle = this.getCircle(center, radius - innerWidth);
    const outerCircle = this.getCircle(center, radius + outerWidth);
    return [innerCircle, outerCircle];
  }
}
