import * as Util from '@dra2020/util';

// For incremental poly interface
export interface TickOptions
{
  maxLeafCount?: number;
  maxDepth?: number;
  tickStep?: number;
}

export const DefaultTickOptions = { maxLeafCount: 256, maxDepth: 20, tickStep: 1 };


// Internal utilities

export const EARTH_RADIUS = 6371000; // Radius of earth in meters

export interface PolyOptions
{
  noLatitudeCorrection?: boolean;
}
const DefaultOptions: PolyOptions = { };

// Return geographic polygon area in meters^2
function polySimpleArea(p: any, options?: PolyOptions): number
{
  if (options === undefined) options = DefaultOptions;

  let p1: any;
  let p2: any;
  let p3: any;
  let dx: number;
  let l: number;
  let m: number;
  let u: number;
  let i: number;
  let total: number = 0;
  let n: number = p.length;
  if (n > 2)
  {
      for (i = 0; i < n; i++)
      {
          if (i === n - 2)
          {
              l = n - 2;
              m = n - 1;
              u = 0;
          }
          else if (i === n - 1)
          {
              l = n - 1;
              m = 0;
              u = 1;
          }
          else {
              l = i;
              m = i + 1;
              u = i + 2;
          }
          p1 = p[l];
          p2 = p[m];
          p3 = p[u];
          dx = (Util.deg2rad(p3[0]) - Util.deg2rad(p1[0]));
          dx *= Math.sin(Util.deg2rad(p2[1]));
          total += dx;
      }
      total *= EARTH_RADIUS * EARTH_RADIUS / 2;
  }
  return Math.abs(total);
}

// Allow bare polygon coordinates array or GeoJSON feature.
// Normalize to multipolygon points array.

export function polyNormalize(poly: any, options?: PolyOptions): any
{
  if (options === undefined) options = DefaultOptions;

  // Convert a GeoJSON polygon or multipolygon to the raw list of points
  if (poly && poly.geometry && poly.geometry.coordinates)
    poly = poly.geometry.coordinates;

  // This is really an invalid specification but used internally to represent null polygon
  if (poly && poly.length == 0) return null;

  if (Util.depthof(poly) == 4) poly = [ poly ];
  return poly;
}

// Area of geographic polygon
export function polyArea(poly: any, options?: PolyOptions): number
{
  if (options === undefined) options = DefaultOptions;

  poly = polyNormalize(poly, options);
  let a: number = 0;

  // MultiPolygon is a set of polygons
  for (let i: number = 0; poly && i < poly.length; i++)
  {
    // Single polygon is exterior ring with interior holes. Holes are subtracted.
    let p = poly[i];
    for (let j: number = 0; j < p.length; j++)
    {
      let sp = p[j];
      a += polySimpleArea(sp, options) * (j == 0 ? 1 : -1);
    }
  }

  return a;
}

// Return distance in meters given two lat/lon points
function haversine(x1: number, y1: number, x2: number, y2: number, options: PolyOptions): number
{
    let dLat = Util.deg2rad(y2 - y1);
    let dLon = Util.deg2rad(x2 - x1);
    let c: number;

    // Short circuit for using simple cartesian algorithm instead of haversine
    if (options.noLatitudeCorrection)
      c = Math.sqrt((dLat*dLat)+(dLon*dLon));
    else
    {
      let lat1 = Util.deg2rad(y1);
      let lat2 = Util.deg2rad(y2);

      let a = Math.sin(dLat/2) * Math.sin(dLat/2) +
              Math.sin(dLon/2) * Math.sin(dLon/2) * Math.cos(lat1) * Math.cos(lat2)
      c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
    }

    return EARTH_RADIUS * c;
}

// Perimeter of geographic polygon in meters
export function polyPerimeter(poly: any, options?: PolyOptions): number
{
  if (options === undefined) options = DefaultOptions;

  poly = polyNormalize(poly, options);

  let perimeter: number = 0;
  for (let i: number = 0; poly && i < poly.length; i++)
  {
    // Ignore holes so only look at first polyline
    let p: any = poly[i][0];
    for (let j: number = 0; j < p.length - 1; j++)
      perimeter += haversine(p[j][0], p[j][1], p[j+1][0], p[j+1][1], options);

    if (p.length > 2 && (p[0][0] != p[p.length-1][0] || p[0][1] != p[p.length-1][1]))
      perimeter += haversine(p[0][0], p[0][1], p[p.length-1][0], p[p.length-1][1], options);
  }

  return perimeter;
}

class Point {
	public constructor(
		public x: number,
		public y: number) {}
}

export class Circle {
	public constructor(
		public x: number,
		public y: number,
		public r: number) {}
}

//
// Returns the smallest circle that encloses the given polygon.
// Runs in expected O(n) time, randomized.
// Note: If 0 points are given, null is returned.
// If 1 point is given, a circle of radius 0 is returned.
//

export function polyToCircle(poly: any, options?: PolyOptions): Circle|null
{
  if (options === undefined) options = DefaultOptions;

  return makeCircle(polyToExteriorPoints(poly));
}

//
// Returns the circle whose perimeter is equal to the perimeter of the bounding perimeter
// of the polygon. Use binary search to find an approximation.
//

export function polyToPolsbyPopperCircle(poly: any, options?: PolyOptions): Circle|null
{
  if (options === undefined) options = DefaultOptions;

  let c = polyToCircle(poly, options);
  if (c == null) return c;
  let p: number = polyPerimeter(poly, options);
  c.r = (p / (2 * Math.PI)) / 111139;
  return c;
}

function makeCircle(points: Array<Point>): Circle|null
{
  if (points == null) return null;

	// Clone list to preserve the caller's data, do Durstenfeld shuffle
	let shuffled: Array<Point> = points.slice();
	for (let i = points.length - 1; i >= 0; i--) {
		let j = Math.floor(Math.random() * (i + 1));
		j = Math.max(Math.min(j, i), 0);
		const temp: Point = shuffled[i];
		shuffled[i] = shuffled[j];
		shuffled[j] = temp;
	}

	// Progressively add points to circle or recompute circle
	let c: Circle|null = null;
	shuffled.forEach((p: Point, i: number) => {
		if (c === null || !isInCircle(c, p))
			c = makeCircleOnePoint(shuffled.slice(0, i + 1), p);
	});
	return c;
}

// One boundary point known
function makeCircleOnePoint(points: Array<Point>, p: Point): Circle
{
	let c: Circle = new Circle(p.x, p.y, 0);
	points.forEach((q: Point, i: number) => {
		if (!isInCircle(c, q)) {
			if (c.r == 0)
				c = makeDiameter(p, q);
			else
				c = makeCircleTwoPoints(points.slice(0, i + 1), p, q);
		}
	});
	return c;
}

// Two boundary points known
function makeCircleTwoPoints(points: Array<Point>, p: Point, q: Point): Circle
{
	const circ: Circle = makeDiameter(p, q);
	let left : Circle|null = null;
	let right: Circle|null = null;

	// For each point not in the two-point circle
	for (const r of points) {
		if (isInCircle(circ, r))
			continue;

		// Form a circumcircle and classify it on left or right side
		const cross: number = crossProduct(p.x, p.y, q.x, q.y, r.x, r.y);
		const c: Circle|null = makeCircumcircle(p, q, r);
		if (c === null)
			continue;
		else if (cross > 0 && (left === null || crossProduct(p.x, p.y, q.x, q.y, c.x, c.y) > crossProduct(p.x, p.y, q.x, q.y, left.x, left.y)))
			left = c;
		else if (cross < 0 && (right === null || crossProduct(p.x, p.y, q.x, q.y, c.x, c.y) < crossProduct(p.x, p.y, q.x, q.y, right.x, right.y)))
			right = c;
	}

	// Select which circle to return
	if (left === null && right === null)
		return circ;
	else if (left === null && right !== null)
		return right;
	else if (left !== null && right === null)
		return left;
	else if (left !== null && right !== null)
		return left.r <= right.r ? left : right;
	else
		throw "Assertion error";
}

function makeDiameter(a: Point, b: Point): Circle
{
	const cx: number = (a.x + b.x) / 2;
	const cy: number = (a.y + b.y) / 2;
	const r0: number = Util.distance(cx, cy, a.x, a.y);
	const r1: number = Util.distance(cx, cy, b.x, b.y);
	return new Circle(cx, cy, Math.max(r0, r1));
}

function makeCircumcircle(a: Point, b: Point, c: Point): Circle|null
{
	// Mathematical algorithm from Wikipedia: Circumscribed circle
	const ox: number = (Math.min(a.x, b.x, c.x) + Math.max(a.x, b.x, c.x)) / 2;
	const oy: number = (Math.min(a.y, b.y, c.y) + Math.max(a.y, b.y, c.y)) / 2;
	const ax: number = a.x - ox;  const ay: number = a.y - oy;
	const bx: number = b.x - ox;  const by: number = b.y - oy;
	const cx: number = c.x - ox;  const cy: number = c.y - oy;
	const d: number = (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) * 2;
	if (d == 0)
		return null;
	const x: number = ox + ((ax*ax + ay*ay) * (by - cy) + (bx*bx + by*by) * (cy - ay) + (cx*cx + cy*cy) * (ay - by)) / d;
	const y: number = oy + ((ax*ax + ay*ay) * (cx - bx) + (bx*bx + by*by) * (ax - cx) + (cx*cx + cy*cy) * (bx - ax)) / d;
	const ra: number = Util.distance(x, y, a.x, a.y);
	const rb: number = Util.distance(x, y, b.x, b.y);
	const rc: number = Util.distance(x, y, c.x, c.y);
	return new Circle(x, y, Math.max(ra, rb, rc));
}

/* Simple mathematical functions */

const MULTIPLICATIVE_EPSILON: number = 1 + 1e-14;

function isInCircle(c: Circle|null, p: Point): boolean
{
	return c !== null && Util.distance(p.x, p.y, c.x, c.y) <= c.r * MULTIPLICATIVE_EPSILON;
}

// Returns twice the signed area of the triangle defined by (x0, y0), (x1, y1), (x2, y2).
function crossProduct(x0: number, y0: number, x1: number, y1: number, x2: number, y2: number): number
{
	return (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0);
}

// cache x,y circle offsets for one quadrant indexed by number of segments
let circleOffsets: any = {};

function getCircleOffsets(n: number): any
{
  if (circleOffsets[n] === undefined)
  {
    let a: number[] = [];
    let incr = (Math.PI / 2) / n;
    let theta: number = 0;
    let i: number, j: number;

    // Compute NE quadrant
    for (i = 0; i < n; i++, theta += incr)
    {
      a.push(Math.sin(theta));
      a.push(Math.cos(theta));
    }
    // Add top of circle
    a.push(0);
    a.push(1);
    // Now flip X and replicate to NW quadrant
    for (i = 0; i < n; i++)
    {
      j = (n-i)*2;
      a.push(- a[j-1]);
      a.push(a[j-2]);
    }
    // Now flip X and Y and replicate to SW quadrant
    for (i = 0; i < n; i++)
    {
      j = (n-i)*2;
      a.push(- a[j-1]);
      a.push(- a[j-2]);
    }
    // Add bottom of circle
    a.push(0);
    a.push(-1);
    // Now flip Y and replicate to SE quadrant
    for (i = 0; i < n; i++)
    {
      j = (n-i)*2;
      a.push(a[j-1]);
      a.push(- a[j-2]);
    }
    // Duplicate final point per GeoJSON spec
    a.push(a[0]);
    a.push(a[1]);

    circleOffsets[n] = a;
  }

  return circleOffsets[n];
}

// Note that this is essentially an inversion of polyToCircle which computes a mathematical
// circle given the point values, ignoring latitude correction. This inversion also
// ignores that correction which means when plotted on a 2D map looks circular but the edge of
// the circle is not a fixed distance (in physical units, e.g. meters) from the center.
//

export function polyFromCircle(c: Circle, nSegments?: number, options?: PolyOptions): any
{
  if (options === undefined) options = DefaultOptions;

  if (c === null || c.r == 0) return null;
  if (!nSegments || nSegments < 8) nSegments = 8;
  let poly: any = [];

  let offsets: any = getCircleOffsets(nSegments);
  const n: number = offsets.length;

  for (let i: number = 0; i < n; i += 2)
    poly.push([ c.x + (c.r * offsets[i]), c.y + (c.r * offsets[i+1]) ]);

  // return multi-polygon
  return [ [ poly ] ];
}

// isLeft(): tests if a point is Left|On|Right of an infinite line.
//    Input:  three points P0, P1, and P2
//    Return: >0 for P2 left of the line through P0 and P1
//            =0 for P2 on the line
//            <0 for P2 right of the line

function sortPointX(a: Point, b: Point): number { return a.x - b.x; }
function sortPointY(a: Point, b: Point): number { return a.y - b.y; }
function sortPoint(a: Point, b: Point): number { return a.x === b.x ? a.y - b.y : a.x - b.x; }
function isLeft(p0: Point, p1: Point, p2: Point): number { return (p1.x - p0.x) * (p2.y - p0.y) - (p2.x - p0.x) * (p1.y - p0.y); }

function pointsToPoly(points: Point[]): any
{
  let p: any = [];

  for (let i: number = 0; i < points.length; i++)
    p.push( [ points[i].x, points[i].y ] );
  
  return [ p ];
}

function polyToExteriorPoints(poly: any): any
{
  poly = polyNormalize(poly);
  if (poly == null) return null;

  let points: Point[] = [];
  for (let i: number = 0; poly && i < poly.length; i++)
  {
    let p: any = poly[i][0];  // Only need exterior points, so grab exterior ring and ignore holes
    for (let j: number = 0; j < p.length; j++)
      points.push(new Point(p[j][0], p[j][1]));
  }

  return points;
}

//
// polyConvexHull(): A.M. Andrew's monotone chain 2D convex hull algorithm
//

export function polyConvexHull(poly: any, options?: PolyOptions): any
{
  if (options === undefined) options = DefaultOptions;

  // Normalize input
  let points = polyToExteriorPoints(poly);
  if (points == null) return null;

  points.sort(sortPoint);

  let H: Point[] = [];

  // the output array H[] will be used as the stack
  let n: number = points.length;
  let i: number;
  let bot: number = 0;
  let top: number = -1; // indices for bottom and top of the stack

  // Get the indices of points with min x-coord and min|max y-coord
  let minmin: number = 0, minmax: number;

  let xmin = points[0].x;
  for (i = 1; i < n; i++)
    if (points[i].x != xmin)
      break;

  minmax = i - 1;
  if (minmax == n - 1) // degenerate case: all x-coords == xmin
  {
    H[++top] = points[minmin];
    if (points[minmax].y != points[minmin].y) // a nontrivial segment
        H[++top] = points[minmax];
    H[++top] = points[minmin]; // add polygon endpoint
    return pointsToPoly(H);
  }

  // Get the indices of points with max x-coord and min|max y-coord
  let maxmin: number, maxmax: number = n - 1;
  let xmax: number = points[n - 1].x;
  for (i = n - 2; i >= 0; i--)
    if (points[i].x != xmax)
      break;
  maxmin = i + 1;

  // Compute the lower hull on the stack H
  H[++top] = points[minmin]; // push minmin point onto stack
  i = minmax;
  while (++i <= maxmin)
  {
    // the lower line joins points[minmin] with points[maxmin]
    if (isLeft(points[minmin], points[maxmin], points[i]) >= 0 && i < maxmin)
      continue; // ignore points[i] above or on the lower line

    while (top > 0) // there are at least 2 points on the stack
    {
      // test if points[i] is left of the line at the stack top
      if (isLeft(H[top - 1], H[top], points[i]) > 0)
        break; // points[i] is a new hull vertex
      else
        top--; // pop top point off stack
    }

    H[++top] = points[i]; // push points[i] onto stack
  }

  // Next, compute the upper hull on the stack H above the bottom hull
  if (maxmax != maxmin) // if distinct xmax points
    H[++top] = points[maxmax]; // push maxmax point onto stack

  bot = top; // the bottom point of the upper hull stack
  i = maxmin;
  while (--i >= minmax)
  {
    // the upper line joins points[maxmax] with points[minmax]
    if (isLeft(points[maxmax], points[minmax], points[i]) >= 0 && i > minmax)
      continue; // ignore points[i] below or on the upper line

    while (top > bot) // at least 2 points on the upper stack
    {
      // test if points[i] is left of the line at the stack top
      if (isLeft(H[top - 1], H[top], points[i]) > 0)
        break;  // points[i] is a new hull vertex
      else
        top--; // pop top point off stack
    }

    if (points[i].x == H[0].x && points[i].y == H[0].y)
      return pointsToPoly(H); // special case (mgomes)

    H[++top] = points[i]; // push points[i] onto stack
  }

  if (minmax != minmin)
    H[++top] = points[minmin]; // push joining endpoint onto stack

  return pointsToPoly(H);
}

//
// Compactness
//
// Measures of compactness compare district shapes to various ideally compact benchmarks,
// such as circles. All else equal, more compact districts are better.
// 
// States may provide shapefiles for districts directly. Alternatively, shapes for districts may be derived
// using VTD (or block) shapes in Census Tiger/Line files and VTD- (or block-) -to-district assignment files.
// 
// There are four popular measures of compactness. They either focus on how dispersed or how indented a shapes are.
//
// These first two measures are the most important:
// 
// Reock is the primary measure of the dispersion of district shapes, calculated as “the area of the district
// to the area of the minimum spanning circle that can enclose the district.”
//
//    R = A / A(Minimum Bounding Circle)
//    R = A / (π * (D / 2)2)
//
//    R = 4A / πD2
//
// where A is the area of the district and D is the diameter of the minimum bounding circle.
//
// Polsby-Popper is the primary measure of the indendentation of district shapes, calculated as the
// “the ratio of the area of the district to the area of a circle whose circumference is equal to
// the perimeter of the district.”
//
//    PP = A / A(C)
//
// where C is that circle. In other words:
//
//    P = 2πRc and A(C) = π (P / 2π)2
//
// where P is the perimeter of the district and Rc is the radius of the circle.
//
// Hence, the measure simplifies to:
//
//    PP = 4π * (A / P2)
// 
// I propose that we use these two, normalize them, and weight equally to determine our compactness value.
// 
// These second two measures may be used to complement the primary ones above:
// 
// Convex Hull is a secondary measure of the dispersion of district shapes, calculated as
// “the ratio of the district area to the area of the minimum convex bounding polygon (also known as a convex hull)
// enclosing the district.”
//
//    CH = A / A(Convex Hull)
// 
// where a convex hull is the minimum perimeter that encloses all points in a shape, basically the shortest
// unstretched rubber band that fits around the shape.
//
// Schwartzberg is a secondary measure of the degree of indentation of district shapes, calculated as
// “the ratio of the perimeter of the district to the circumference of a circle whose area is equal to the area of the district.”
//
//    S = 1 / (P / C)
//
// where P is the perimeter of the district and C is the circumference of the circle. The radius of the circle is:
//
//    Rc = SQRT(A / π)
//
// So, the circumference of the circle is:
//
//    C = 2πRc or C = 2π * SQRT(A / π)
//
// Hence:
//
//    S = 1 (P / 2π * SQRT(A / π))
//
//    S = (2π * SQRT(A / π)) / P
//
// All these measures produce values between 0 and 1, with 0 being the least compact and 1 being the most compact.
// Sometimes these values are multiplied by 100 to give values between 0 and 100.
// 
// For each measure, the compactness of a set of Congressional districts is the average of that measure for all the districts.
//

export interface CompactnessDescription
{
  value: number,
  reock: number,
  polsby_popper: number,
  convex_hull: number,
  schwartzberg: number,
}

export function polyCompactness(poly: any, options?: PolyOptions): CompactnessDescription
{
  if (options === undefined) options = DefaultOptions;

  poly = polyNormalize(poly, options);

  let area: number = polyArea(poly, options);
  let perimeter: number = polyPerimeter(poly, options);
  let circle: Circle = polyToCircle(poly, options);
  let circleArea: number = polyArea(polyFromCircle(circle), options);
  let ppcircleArea: number = polyArea(polyFromCircle(polyToPolsbyPopperCircle(poly)), options);
  let hullArea: number = polyArea(polyConvexHull(poly), options);

  let result: CompactnessDescription = { value: 0, reock: 0, polsby_popper: 0, convex_hull: 0, schwartzberg: 0 };
  if (area == 0 || circle == null || circle.r == 0) return result;

  result.reock = area / circleArea;
  result.polsby_popper = area / ppcircleArea;
  result.convex_hull = area / hullArea;
  result.schwartzberg = ((2 * Math.PI) * Math.sqrt(area / Math.PI)) / perimeter;

  // Weight Reock and Polsby-Popper equally, normalize in 0-100 range
  result.value = Math.trunc(((result.reock + result.polsby_popper) / 2) * 100);
  return result;
}

export interface PolyDescription
{
  npoly: number,
  nhole: number,
  npoint: number
}

const EmptyPolyDescription = { npoly: 0, nhole: 0, npoint: 0 };

function polySimpleDescribe(poly: any, d?: PolyDescription): PolyDescription
{
  if (d === undefined)
    d = Util.shallowCopy(EmptyPolyDescription);

  d.npoint += npoints(poly);
  d.npoly++;
  d.nhole = poly.length - 1;

  return d;
}

export function polyDescribe(poly: any, options?: PolyOptions): PolyDescription
{
  if (options === undefined) options = DefaultOptions;

  // Support getting passed either GeoJSON feature or simply arrays of coordinates
  poly = polyNormalize(poly, options);

  let d = Util.shallowCopy(EmptyPolyDescription);
  let depth = Util.depthof(poly);

  // Multipolygon
  if (depth == 5)
  {
    for (let i: number = 0; i < poly.length; i++)
      polySimpleDescribe(poly[i], d);
  }

  // Polygon
  else if (depth == 4)
    polySimpleDescribe(poly, d);

  // Support null/undefined but other inputs are programming errors
  else if (poly != null && poly !== undefined)
    throw ('polyDescribe: argument not a polygon coordinate array');

  return d;
}

export function npoints(poly: any): number
{
  if (Array.isArray(poly))
  {
    if (poly.length == 2 && typeof poly[0] === 'number')
      return 1;
    else
    {
      let n: number = 0;
      for (let i: number = 0; i < poly.length; i++)
        n += npoints(poly[i]);
      return n;
    }
  }
  else
    return 0;
}
