/*******************************
    GeoUtils Class Module       
*******************************/
import { Convert } from './convert';
import {
  getDistance,
  getPathLength,
  getCenter,
  computeDestinationPoint,
  isPointInPolygon,
  getGreatCircleBearing
} from 'geolib';
import { SKPosition, Position } from '../types';

export type Extent = [number, number, number, number]; // coords [swlon,swlat,nelon,nelat] of a bounding box

export class GeoUtils {
  private static readonly R = 6_371_000; // mean earth radius in metres

  /**
   * Rhumb-line destination: given a start [lon,lat], bearing (radians),
   * and distance (metres), return the destination [lon,lat].
   */
  static rhumbDestination(
    origin: Position,
    bearingRad: number,
    distMeters: number
  ): Position {
    const R = GeoUtils.R;
    const δ = distMeters / R;
    const φ1 = (origin[1] * Math.PI) / 180;
    const λ1 = (origin[0] * Math.PI) / 180;
    const θ = bearingRad;

    const Δφ = δ * Math.cos(θ);
    let φ2 = φ1 + Δφ;
    let δ_eff = δ;

    // Clamp to ±89° to avoid the Mercator/pole singularity (log(tan(π/2)) → ∞).
    // Scale the effective distance proportionally so that tan(θ)·Δψ — the rhumb
    // bearing identity — produces the correct longitude offset and the line points
    // in the right direction on the Mercator map.
    const MAX_φ = (89 * Math.PI) / 180;
    if (φ2 > MAX_φ && φ1 < MAX_φ) {
      δ_eff = (δ * (MAX_φ - φ1)) / Δφ;
      φ2 = MAX_φ;
    } else if (φ2 < -MAX_φ && φ1 > -MAX_φ) {
      δ_eff = (δ * (-MAX_φ - φ1)) / Δφ;
      φ2 = -MAX_φ;
    }

    const Δψ = Math.log(
      Math.tan(Math.PI / 4 + φ2 / 2) / Math.tan(Math.PI / 4 + φ1 / 2)
    );
    const q = Math.abs(Δψ) > 1e-12 ? (φ2 - φ1) / Δψ : Math.cos(φ1);
    const Δλ = (δ_eff * Math.sin(θ)) / q;

    return [((λ1 + Δλ) * 180) / Math.PI, (φ2 * 180) / Math.PI];
  }

  /**
   * Great Circle desitnation coordinate
   * @param src - origin position
   * @param bearing - angle in radians
   * @param distance - distance in meters
   * @returns
   */
  static destCoordinate(
    src: Position,
    bearing: number,
    distance: number
  ): Position {
    const pt = computeDestinationPoint(
      src,
      distance,
      Convert.radiansToDegrees(bearing)
    );
    return [pt.longitude, pt.latitude];
  }

  /** Calculate the great circle distance between two points in metres **/
  static distanceTo(srcpt: Position, destpt: Position) {
    return getDistance(srcpt, destpt);
  }

  /** Calculate the length of an array of points in metres **/
  static routeLength(points: Position[]) {
    return getPathLength(points);
  }

  /** Calculate distance bearing between each point in an array
   * @param points Array of points
   * @param vessel Vessel position
   * @returns Array of objects (same length as points) containing bearing & distance.
   */
  static routeLegs(
    points: Position[],
    vessel?: Position
  ): { bearing: number; distance: number }[] {
    if (points.length < 2) {
      return [];
    }
    const legs = [];
    if (vessel) {
      legs.push({
        bearing: getGreatCircleBearing(vessel, points[0]),
        distance: GeoUtils.distanceTo(vessel, points[0])
      });
    } else {
      legs.push({
        bearing: 0,
        distance: 0
      });
    }
    for (let i = 1; i < points.length; ++i) {
      const l = {
        bearing: getGreatCircleBearing(points[i - 1], points[i]),
        distance: GeoUtils.distanceTo(points[i - 1], points[i])
      };
      legs.push(l);
    }
    return legs;
  }

  /** Returns the index of the closest point that is forward of vessel position
   * @param points Array of points
   * @param vessel Vessel position (degrees)
   * @param heading Vessel heading (degrees)
   * @returns Index of the point in the array that is cloosest to and forward of vessel or -1 if no point matches criteria
   */
  static closestForwardPoint(
    points: Position[],
    vessel: Position,
    heading: number
  ): number {
    if (points.length < 2 || !vessel) {
      return -1;
    }
    const closest: {
      index: number;
      distance: number;
    } = { index: -1, distance: -1 };

    for (let i = 0; i < points.length; ++i) {
      const bearing = getGreatCircleBearing(vessel, points[i]);
      const a = Angle.difference(heading, bearing);
      // if forward of vessel
      if (a > -90 && a < 90) {
        const distance = GeoUtils.distanceTo(vessel, points[i]);
        if (closest.distance === -1 || distance < closest.distance) {
          closest.distance = distance;
          closest.index = i;
        }
      }
    }
    return closest.index;
  }

  /** Calculate the centre of polygon **/
  static centreOfPolygon(coords: Array<Position>): Position {
    const c = getCenter(coords) as SKPosition;
    return [c.longitude, c.latitude];
  }

  /** DateLine Crossing:
   * @returns true if point is in the zone for dateline transition
   * @param zoneValue lower end of 180 to xx range within which Longitude must fall for retun value to be true
   * @param position Coordinate to test
   **/
  static inDLCrossingZone(coord: Position, zoneValue = 170) {
    return Math.abs(coord[0]) >= zoneValue ? true : false;
  }

  // returns true if point is inside the supplied extent
  static inBounds(point: Position, extent: Extent) {
    return isPointInPolygon(point, [
      [extent[0], extent[1]],
      [extent[0], extent[3]],
      [extent[2], extent[3]],
      [extent[2], extent[1]],
      [extent[0], extent[1]]
    ]);
  }

  // returns mapified extent centered at point with boundary radius meters from center
  static calcMapifiedExtent(point: Position, radius: number): Extent {
    const latScale = 111111; // 1 deg of lat in meters
    const lonScale = latScale * Math.cos(Convert.degreesToRadians(point[1]));
    const ext: Extent = [0, 0, 0, 0];

    // latitude bounds
    ext[1] = point[1] + (0 - Math.abs(radius)) / latScale;
    ext[3] = point[1] + Math.abs(radius) / latScale;
    // longitude bounds
    ext[0] = point[0] + (0 - Math.abs(radius)) / lonScale;
    ext[2] = point[0] + Math.abs(radius) / lonScale;
    return ext;
  }

  // ensure -180<coords<180
  static normaliseCoords(coords: Position); // Point
  static normaliseCoords(coords: Array<Position>); //LineString
  static normaliseCoords(coords: Array<Array<Position>>); // MultiLineString
  static normaliseCoords(coords) {
    if (!Array.isArray(coords)) {
      return [0, 0];
    }
    if (typeof coords[0] === 'number') {
      if (coords[0] > 180) {
        while (coords[0] > 180) {
          coords[0] = coords[0] - 360;
        }
      } else if (coords[0] < -180) {
        while (coords[0] < -180) {
          coords[0] = 360 + coords[0];
        }
      }
      return coords;
    } else if (Array.isArray(coords[0])) {
      coords.forEach((c) => (c = this.normaliseCoords(c)));
      return coords;
    }
  }
}

export class Angle {
  /** difference between two angles (in degrees)
   * @param h: angle 1 (in degrees)
   * @param b: angle 2 (in degrees)
   * @returns angle (-ive = port)
   */
  static difference(h: number, b: number): number {
    const d = 360 - b;
    const hd = h + d;
    const a = Angle.normalise(hd);
    return a < 180 ? 0 - a : 360 - a;
  }

  /** Add two angles (in degrees)
   * @param h: angle 1 (in degrees)
   * @param b: angle 2 (in degrees)
   * @returns sum angle
   */
  static add(h: number, b: number): number {
    return Angle.normalise(h + b);
  }

  /** Normalises angle to a value between 0 & 360 degrees
   * @param a: angle (in degrees)
   * @returns value between 0-360
   */
  static normalise(a: number): number {
    return a < 0 ? a + 360 : a >= 360 ? a - 360 : a;
  }
}
