/**
 * (type)
 *
 * An unknown type of input.  Must be one of the following:
 * - array: An array with 2 elements.  The first is longitude, the second is latitude.
 * - string: A string in the format `longitude,latitude`
 * - Object (x, y coordinates): An object with `x` and `y` properties.
 *   `x` represents longitude and `y` represents latitude.
 * - Object (lat, lon): An object with latitude and longitude properties.
 *   The properties can be named various things.
 *   For longitude any of the following are valid: `lon`, `lng` or `longitude`
 *   For latitude any of the following are valid: `lat` or `latitude`
 * @typedef {Array|string|Object} lonlat.types.input
 */

/**
 * (type)
 *
 * Standardized lon/lat object.
 * @typedef {Object} lonlat.types.output
 * @property {number} lat
 * @property {number} lon
 */

/**
 * (type)
 *
 * Object with x/y number values.
 * @typedef {Object} lonlat.types.point
 * @property {number} x
 * @property {number} y
 */

/**
 * (exception type)
 *
 * An error that is thrown upon providing invalid coordinates.
 * @typedef {Error} lonlat.types.InvalidCoordinateException
 */

/**
 * Parse an unknown type of input.
 *
 * @module conveyal/lonlat
 * @param {lonlat.types.input} unknown
 * @return {lonlat.types.output}
 * @throws {lonlat.types.InvalidCoordinateException}
 * @example
 * var lonlat = require('@conveyal/lonlat')

 // Object with lon/lat-ish attributes
 var position = lonlat({ lon: 12, lat: 34 })         // { lon: 12, lat: 34 }
 position = lonlat({ lng: 12, lat: 34 })             // { lon: 12, lat: 34 }
 position = lonlat({ longitude: 12, latitude: 34 })  // { lon: 12, lat: 34 }
 position = lonlat({ lng: 12, latitude: 34 })        // { lon: 12, lat: 34 }

 // coordinate array
 position = lonlat([12, 34])   // { lon: 12, lat: 34 }

 // string
 position = lonlat('12,34')   // { lon: 12, lat: 34 }

 // object with x and y attributes
 position = lonlat({ x: 12, y: 34 })   // { lon: 12, lat: 34 }

 // the following will throw errors
 position = lonlat({ lon: 999, lat: 34 })   // Error: Invalid longitude value: 999
 position = lonlat({ lon: 12, lat: 999 })   // Error: Invalid latitude value: 999
 position = lonlat({})                      // Error: Invalid latitude value: undefined
 position = lonlat(null)                    // Error: Value must not be null or undefined
 */

import type { LatLng, LatLngLiteral } from 'leaflet'

export type LonLatOutput = {
  lat: number
  lon: number
}
type Point = {
  x: number
  y: number
}

type LeafletLatLng =
  | {
      longitude: number
      latitude: number
    }
  | { lat: number; lng: number }

export type LonLatInput =
  | LonLatOutput
  | Point
  | GeoJSON.Position
  | GeoJSON.Point
  | LatLng
  | LatLngLiteral
  | { lat: number | string; lng: number | string }
  | { lat: number | string; long: number | string }
  | { latitude: number | string; longitude: number | string }
  | string

export function normalize(unknown: LonLatInput): LonLatOutput {
  if (!unknown) throw new Error('Value must not be null or undefined.')
  if (Array.isArray(unknown)) return fromCoordinates(unknown)
  else if (typeof unknown === 'string') return fromString(unknown)
  else if ('coordinates' in unknown) return fromCoordinates(unknown.coordinates)
  else if (
    'x' in unknown &&
    'y' in unknown &&
    (unknown.x || unknown.x === 0) &&
    (unknown.y || unknown.y === 0)
  ) {
    return fromPoint(unknown)
  }
  return floatize(unknown)
}
export default normalize

/**
 * <b>aliases:</b> fromGeoJSON<br>
 *
 * Tries to parse from an array of coordinates.
 *
 * @memberof conveyal/lonlat
 * @param  {Array} coordinates  An array in the format: [longitude, latitude]
 * @return {lonlat.types.output}
 * @throws {lonlat.types.InvalidCoordinateException}
 * @example
 * var lonlat = require('@conveyal/lonlat')

 var position = lonlat.fromCoordinates([12, 34])   // { lon: 12, lat: 34 }
 position = lonlat.fromGeoJSON([12, 34])           // { lon: 12, lat: 34 }
 */
export function fromCoordinates(coordinates: GeoJSON.Position): LonLatOutput {
  return floatize({ lat: coordinates[1], lon: coordinates[0] })
}
export { fromCoordinates as fromGeoJSON }

/**
 * <b>aliases:</b> fromLeaflet<br>
 *
 * Tries to parse from an object.
 *
 * @param  {Object} lonlat  An object with a `lon`, `lng` or `longitude` attribute and a `lat` or `latitude` attribute
 * @return {lonlat.types.output}
 * @throws {lonlat.types.InvalidCoordinateException}
 * @example
 * var lonlat = require('@conveyal/lonlat')

 var position = lonlat.fromLatlng({ longitude: 12, latitude: 34 })   // { lon: 12, lat: 34 }
 position = lonlat.fromLeaflet({ lng: 12, lat: 34 })                 // { lon: 12, lat: 34 }
 */
export function fromLatlng(lonlat: LeafletLatLng): LonLatOutput {
  return floatize(lonlat)
}
export { fromLatlng as fromLeaflet }

/**
 * Tries to parse from an object.
 *
 * @memberof conveyal/lonlat
 * @param  {Object} point  An object with a `x` attribute representing `longitude`
 *                         and a `y` attribute representing `latitude`
 * @return {lonlat.types.output}
 * @throws {lonlat.types.InvalidCoordinateException}
 * @example
 * var lonlat = require('@conveyal/lonlat')

 var position = lonlat.fromPoint({ x: 12, y: 34 })   // { lon: 12, lat: 34 }
 */
export function fromPoint(point: Point): LonLatOutput {
  return floatize({ lat: point.y, lon: point.x })
}

/**
 * <b>aliases:</b> fromLonFirstString<br>
 *
 * Tries to parse from a string where the longitude appears before the latitude.
 *
 * @memberof conveyal/lonlat
 * @param  {string} str          A string in the format: `longitude,latitude`
 * @return {lonlat.types.output}
 * @throws {lonlat.types.InvalidCoordinateException}
 * @example
 * var lonlat = require('@conveyal/lonlat')

 var position = lonlat.fromString('12,34')          // { lon: 12, lat: 34 }
 var position = lonlat.fromLonFirstString('12,34')  // { lon: 12, lat: 34 }
 */
export function fromString(str: string): LonLatOutput {
  const arr: Array<string> = str.split(',')
  return floatize({ lat: arr[1], lon: arr[0] })
}
export { fromString as fromLonFirstString }

/**
 * Tries to parse from a string where the latitude appears before the longitude.
 *
 * @memberof conveyal/lonlat
 * @param  {string} str           A string in the format: `latitude,longitude`
 * @return {lonlat.types.output}
 * @throws {lonlat.types.InvalidCoordinateException}
 * @example
 * var lonlat = require('@conveyal/lonlat')

 var position = lonlat.fromLatFirstString('12,34') // { lon: 34, lat: 12 }
 */
export function fromLatFirstString(str: string): LonLatOutput {
  const arr: Array<string> = str.split(',')
  return floatize({ lat: arr[0], lon: arr[1] })
}

/**
 * Determine if two inputs are equal to each other
 *
 * @param  {lonlat.types.input}  lonlat1
 * @param  {lonlat.types.input}  lonlat2
 * @param  {number} [epsilon=0] The maximum acceptable deviation to be considered equal.
 * @return {boolean}
 * @throws {lonlat.types.InvalidCoordinateException}
 * @example
 * var lonlat = require('@conveyal/lonlat')

 var isEqual = lonlat.isEqual('12,34', [12, 34])   // true
 */
export function isEqual(
  lonlat1: LonLatInput,
  lonlat2: LonLatInput,
  epsilon?: number
): boolean {
  lonlat1 = normalize(lonlat1)
  lonlat2 = normalize(lonlat2)
  epsilon = epsilon || 0
  return (
    Math.abs(lonlat1.lat - lonlat2.lat) <= epsilon &&
    Math.abs(lonlat1.lon - lonlat2.lon) <= epsilon
  )
}

/**
 * @param  {lonlat.types.input} input
 * @param  {number} [fixed=5] The number of decimal places to round to.
 * @return {string}           A string with in the format `longitude,latitude` rounded to
 *                            the number of decimal places as specified by `fixed`
 * @throws {lonlat.types.InvalidCoordinateException}
 * @example
 * var lonlat = require('@conveyal/lonlat')

 var pretty = lonlat.print('12.345678,34')   // '12.34568, 34.00000'
 */
export function print(input: LonLatInput, fixed?: number): string {
  const ll: LonLatOutput = normalize(input)
  return ll.lon.toFixed(fixed || 5) + ', ' + ll.lat.toFixed(fixed || 5)
}

/**
 * <b>aliases:</b> toGeoJSON<br>
 *
 * Translates to a coordinate array.
 *
 * @param {lonlat.types.input} input
 * @return {Array}      An array in the format [longitude, latitude]
 * @throws {lonlat.types.InvalidCoordinateException}
 * @example
 * var lonlat = require('@conveyal/lonlat')

 var coords = lonlat.toCoordinates('12,34')   // [12, 34]
 */
export function toCoordinates(input: LonLatInput): [number, number] {
  const ll: LonLatOutput = normalize(input)
  return [ll.lon, ll.lat]
}
export { toCoordinates as toGeoJSON }
/**
 * Translates to {@link http://leafletjs.com/reference-1.0.3.html#latlng|Leaflet LatLng} object.
 * This function requires Leaflet to be installed as a global variable `L` in the window environment.
 *
 * @param {lonlat.types.input} input
 * @return {Object}      A Leaflet LatLng object
 * @throws {lonlat.types.InvalidCoordinateException}
 * @example
 * var lonlat = require('@conveyal/lonlat')

 var position = lonlat.toLeaflet({ lat: 12, long: 34 })   // Leaflet LatLng object
 */
export function toLeaflet(input: LonLatInput): LatLng {
  if (!window.L) throw new Error('Leaflet not found.')
  const ll: LonLatOutput = normalize(input)
  return window.L.latLng(ll.lat, ll.lon)
}

/**
 * Translates to point Object.
 *
 * @param {lonlat.types.input} input
 * @return {Object}      An object with `x` and `y` attributes representing latitude and longitude respectively
 * @throws {lonlat.types.InvalidCoordinateException}
 * @example
 * var lonlat = require('@conveyal/lonlat')

 var point = lonlat.toPoint('12,34')   // { x: 12, y: 34 }
 */
export function toPoint(input: LonLatInput): Point {
  const ll: LonLatOutput = normalize(input)
  return { x: ll.lon, y: ll.lat }
}

/**
 * <b>aliases:</b> toLonFirstString<br>
 *
 * Translates to coordinate string where the longitude appears before latitude.
 *
 * @param {lonlat.types.input} input
 * @return {string}     A string in the format 'longitude,latitude'
 * @throws {lonlat.types.InvalidCoordinateException}
 * @example
 * var lonlat = require('@conveyal/lonlat')

 var str = lonlat.toString({ lat: 12, longitude: 34 })          // '34,12'
 var str = lonlat.toLonFirstString({ lat: 12, longitude: 34 })  // '34,12'
 */
export function toString(input: LonLatInput): string {
  const ll: LonLatOutput = normalize(input)
  return ll.lon + ',' + ll.lat
}
export { toString as toLonFirstString }

/**
 * Translates to coordinate string where the latitude appears before longitude.
 *
 * @param {lonlat.types.input} input
 * @return {string}     A string in the format 'longitude,latitude'
 * @throws {lonlat.types.InvalidCoordinateException}
 * @example
 * var lonlat = require('@conveyal/lonlat')

 var str = lonlat.toLatFirstString({ lat: 12, longitude: 34 })  // '12,34'
 */
export function toLatFirstString(input: LonLatInput): string {
  const ll: LonLatOutput = normalize(input)
  return ll.lat + ',' + ll.lon
}

/**
 * Pixel conversions and constants taken from
 * https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Implementations
 */

/**
 * Pixels per tile.
 */
export const PIXELS_PER_TILE = 256

// 2^z represents the tile number. Scale that by the number of pixels in each tile.
function zScale(z: number): number {
  return Math.pow(2, z) * PIXELS_PER_TILE
}

// Converts from degrees to radians
function toRadians(degrees: number): number {
  return (degrees * Math.PI) / 180
}

// Converts from radians to degrees.
function toDegrees(radians: number): number {
  return (radians * 180) / Math.PI
}

/**
 * Convert a longitude to it's pixel value given a `zoom` level.
 *
 * @param {number} longitude
 * @param {number} zoom
 * @return {number} pixel
 * @example
 * var xPixel = lonlat.longitudeToPixel(-70, 9) //= 40049.77777777778
 */
export function longitudeToPixel(longitude: number, zoom: number): number {
  return ((longitude + 180) / 360) * zScale(zoom)
}

/**
 * Convert a latitude to it's pixel value given a `zoom` level.
 *
 * @param {number} latitude
 * @param {number} zoom
 * @return {number} pixel
 * @example
 * var yPixel = lonlat.latitudeToPixel(40, 9) //= 49621.12736343896
 */
export function latitudeToPixel(latitude: number, zoom: number): number {
  const latRad: number = toRadians(latitude)
  return (
    ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) *
    zScale(zoom)
  )
}

/**
 * Maximum Latitude for valid Mercator projection conversion.
 */
const MAX_LAT = toDegrees(Math.atan(Math.sinh(Math.PI)))

/**
 * Convert a coordinate to a pixel.
 *
 * @param {lonlat.types.input} input
 * @param {number} zoom
 * @return {Object} An object with `x` and `y` attributes representing pixel coordinates
 * @throws {lonlat.types.InvalidCoordinateException}
 * @throws {Error} If latitude is above or below `MAX_LAT`
 * @throws {Error} If `zoom` is undefined.
 * @example
 * var pixel = lonlat.toPixel({lon: -70, lat: 40}, 9) //= {x: 40049.77777777778, y:49621.12736343896}
 */
export function toPixel(input: LonLatInput, zoom: number): Point {
  const ll: LonLatOutput = normalize(input)
  if (ll.lat > MAX_LAT || ll.lat < -MAX_LAT) {
    throw new Error(
      'Pixel conversion only works between ' +
        MAX_LAT +
        'N and -' +
        MAX_LAT +
        'S'
    )
  }

  return {
    x: longitudeToPixel(ll.lon, zoom),
    y: latitudeToPixel(ll.lat, zoom)
  }
}

/**
 * Convert a pixel to it's longitude value given a zoom level.
 *
 * @param {number} x
 * @param {number} zoom
 * @return {number} longitude
 * @example
 * var lon = lonlat.pixelToLongitude(40000, 9) //= -70.13671875
 */
export function pixelToLongitude(x: number, zoom: number): number {
  return (x / zScale(zoom)) * 360 - 180
}

/**
 * Convert a pixel to it's latitude value given a zoom level.
 *
 * @param {number} y
 * @param {number} zoom
 * @return {number} latitude
 * @example
 * var lat = lonlat.pixelToLatitude(50000, 9) //= 39.1982053488948
 */
export function pixelToLatitude(y: number, zoom: number): number {
  const latRad: number = Math.atan(
    Math.sinh(Math.PI * (1 - (2 * y) / zScale(zoom)))
  )
  return toDegrees(latRad)
}

/**
 * From pixel.
 *
 * @param {lonlat.types.point} pixel
 * @param {number} zoom
 * @return {lonlat.types.output}
 * @example
 * var ll = lonlat.fromPixel({x: 40000, y: 50000}, 9) //= {lon: -70.13671875, lat: 39.1982053488948}
 */
export function fromPixel(pixel: Point, zoom: number): LonLatOutput {
  return {
    lat: pixelToLatitude(pixel.y, zoom),
    lon: pixelToLongitude(pixel.x, zoom)
  }
}

// Can the various ways in which lat/long pairs can be expressed to this
// method be expressed as a type?
// eslint-disable-next-line complexity
function floatize(lonlat: Record<string, unknown>): LonLatOutput {
  const lon = parseFloatWithAlternates([
    lonlat.lon,
    lonlat.lng,
    lonlat.longitude
  ])
  const lat = parseFloatWithAlternates([lonlat.lat, lonlat.latitude])
  if ((!lon || lon > 180 || lon < -180) && lon !== 0) {
    throw new Error(
      'Invalid longitude value: ' +
        (lonlat.lon || lonlat.lng || lonlat.longitude)
    )
  }
  if ((!lat || lat > 90 || lat < -90) && lat !== 0) {
    throw new Error(
      'Invalid latitude value: ' + (lonlat.lat || lonlat.latitude)
    )
  }
  return { lat, lon }
}

function parseFloatWithAlternates(alternates: Array<unknown>): number | null {
  if (Array.isArray(alternates) && alternates.length > 0) {
    const num = parseFloat(alternates[0])
    if (isNaN(num)) {
      return parseFloatWithAlternates(alternates.slice(1))
    } else {
      return num
    }
  }
  return null
}