// math.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {cutPolylineByGrid, cutPolygonByGrid} from './cut-by-grid';
import {getPointAtIndex, push} from './utils';
import type {Polygon} from './cut-by-grid';
import type {NumericArray} from '@math.gl/core';

// https://en.wikipedia.org/wiki/Web_Mercator_projection
const DEFAULT_MAX_LATITUDE = 85.051129;

/** https://user-images.githubusercontent.com/2059298/78465769-938b7a00-76ae-11ea-9b95-1f4c26425ab9.png */
export function cutPolylineByMercatorBounds(
  positions: Readonly<NumericArray>,
  options?: {
    size?: number;
    startIndex?: number;
    endIndex?: number;
    normalize?: boolean;
  }
): number[][] {
  const {size = 2, startIndex = 0, endIndex = positions.length, normalize = true} = options || {};

  // Remap longitudes so that each segment takes the shorter path
  const newPositions = positions.slice(startIndex, endIndex);
  wrapLongitudesForShortestPath(newPositions, size, 0, endIndex - startIndex);

  const parts = cutPolylineByGrid(newPositions, {
    size,
    broken: true,
    gridResolution: 360,
    gridOffset: [-180, -180]
  }) as number[][];

  if (normalize) {
    // Each part is guaranteed to be in a single copy of the world
    // Map longitudes back to [-180, 180]
    for (const part of parts) {
      shiftLongitudesIntoRange(part, size);
    }
  }
  return parts;
}

/** https://user-images.githubusercontent.com/2059298/78465770-94241080-76ae-11ea-809a-6a8534dac1d9.png */
export function cutPolygonByMercatorBounds(
  positions: Readonly<NumericArray>,
  holeIndices: Readonly<NumericArray> | null = null,
  options?: {
    size?: number;
    normalize?: boolean;
    maxLatitude?: number;
    edgeTypes?: boolean;
  }
): Polygon[] {
  const {size = 2, normalize = true, edgeTypes = false} = options || {};
  holeIndices = holeIndices || [];
  const newPositions: number[] = [];
  const newHoleIndices: number[] = [];
  let srcStartIndex = 0;
  let targetIndex = 0;

  for (let ringIndex = 0; ringIndex <= holeIndices.length; ringIndex++) {
    // srcStartIndex/srcEndIndex define the ring in the original positions
    const srcEndIndex = holeIndices[ringIndex] || positions.length;
    // targetStartIndex/targetIndex define the ring in newPositions
    const targetStartIndex = targetIndex;

    // In case the ring contains a pole (e.g. Antarctica), we'll have to insert vertices
    // The insertion point is defined by the vertex closest to the pole
    // Split the the ring by the insertion point when copying to newPositions
    const splitIndex = findSplitIndex(positions, size, srcStartIndex, srcEndIndex);
    for (let i = splitIndex; i < srcEndIndex; i++) {
      newPositions[targetIndex++] = positions[i];
    }
    for (let i = srcStartIndex; i < splitIndex; i++) {
      newPositions[targetIndex++] = positions[i];
    }

    // Remap longitudes so that each segment takes the shorter path
    wrapLongitudesForShortestPath(newPositions, size, targetStartIndex, targetIndex);

    // Handle the case when the ring contains a pole
    insertPoleVertices(newPositions, size, targetStartIndex, targetIndex, options?.maxLatitude);

    srcStartIndex = srcEndIndex;
    newHoleIndices[ringIndex] = targetIndex;
  }
  newHoleIndices.pop();

  const parts = cutPolygonByGrid(newPositions, newHoleIndices, {
    size,
    gridResolution: 360,
    gridOffset: [-180, -180],
    edgeTypes
  });

  if (normalize) {
    // Each part is guaranteed to be in a single copy of the world
    // Map longitudes back to [-180, 180]
    for (const part of parts) {
      // @ts-expect-error (mutates readonly array) May mutate newPositions, which is created by us
      shiftLongitudesIntoRange(part.positions, size);
    }
  }
  return parts;
}

/* Helpers */

// See comments for insertPoleVertices
function findSplitIndex(
  positions: Readonly<NumericArray>,
  size: number,
  startIndex: number,
  endIndex: number
): number {
  let maxLat = -1;
  let pointIndex = -1;
  for (let i = startIndex + 1; i < endIndex; i += size) {
    const lat = Math.abs(positions[i]);
    if (lat > maxLat) {
      maxLat = lat;
      pointIndex = i - 1;
    }
  }
  return pointIndex;
}

// https://user-images.githubusercontent.com/2059298/78857483-5987e400-79de-11ea-98fc-0631287a8431.png
//
// If the polygon contains a pole, to tesselate it correctly, we need to insert the edge
// of map into the polygon. This requires adding two vertices that represent the pole, by
// drawing a perpendicular line to the Mercator map edge from a selected vertex on the ring.
//
// We select the insertion position carefully so that the inserted line segments do not
// intersect with the ring itself. This is ensured by findSplitIndex, which returns the
// vertex closest to the pole.
function insertPoleVertices(
  positions: number[],
  size: number,
  startIndex: number,
  endIndex: number,
  maxLatitude: number = DEFAULT_MAX_LATITUDE
): void {
  // Check if the ring contains a pole
  const firstLng = positions[startIndex];
  const lastLng = positions[endIndex - size];
  if (Math.abs(firstLng - lastLng) > 180) {
    // The ring does not make a round trip
    // Add the nearest pole to the vertices so that the polygon tesselates correctly
    const p = getPointAtIndex(positions, 0, size, startIndex);
    // Copy the first vertex to the world of the last vertex
    p[0] += Math.round((lastLng - firstLng) / 360) * 360;
    push(positions, p);
    // Project the copied vertex to the edge of the map
    p[1] = Math.sign(p[1]) * maxLatitude;
    push(positions, p);
    // Project the first vertex to the edge of the map
    p[0] = firstLng;
    push(positions, p);
  }
}

function wrapLongitudesForShortestPath(
  positions: NumericArray,
  size: number,
  startIndex: number,
  endIndex: number
): void {
  let prevLng: number = positions[0];
  let lng: number;
  for (let i = startIndex; i < endIndex; i += size) {
    lng = positions[i];
    const delta = lng - prevLng;
    if (delta > 180 || delta < -180) {
      lng -= Math.round(delta / 360) * 360;
    }
    positions[i] = prevLng = lng;
  }
}

function shiftLongitudesIntoRange(positions: NumericArray, size: number): void {
  let refLng: number;
  const pointCount = positions.length / size;

  // Find a longitude that is not on the edge of a world
  // Which we will use to determine which world copy it is
  for (let i = 0; i < pointCount; i++) {
    refLng = positions[i * size];
    if ((refLng + 180) % 360 !== 0) {
      break;
    }
  }

  const delta = -Math.round(refLng / 360) * 360;
  if (delta === 0) {
    return;
  }
  for (let i = 0; i < pointCount; i++) {
    positions[i * size] += delta;
  }
}
