<?php

namespace idoit\Component\Location;

use InvalidArgumentException;
use JsonSerializable;

/**
 * Coordinate class
 *
 * @author Antoine Corcy <contact@sbin.dk>
 * @author Kevin Mauel <kmauel@i-doit.com>
 *
 * @see    https://github.com/thephpleague/geotools
 */
class Coordinate implements JsonSerializable
{
    /**
     * The latitude of the coordinate.
     *
     * @var float
     */
    protected $latitude;

    /**
     * The longitude of the coordinate.
     *
     * @var float
     */
    protected $longitude;

    /**
     * The precision to use to compare big numbers
     *
     * @var integer
     */
    private $precision = 8;

    /**
     * Set the latitude and the longitude of the coordinates into an selected ellipsoid.
     *
     * @param array|string $coordinates The coordinates.
     *
     * @throws InvalidArgumentException
     */
    public function __construct($coordinates)
    {
        if (is_array($coordinates) && 2 === count($coordinates)) {
            $this->setLatitude($coordinates[0]);
            $this->setLongitude($coordinates[1]);
        } elseif (is_string($coordinates)) {
            $this->setFromString($coordinates);
        } else {
            throw new InvalidArgumentException('It should be a string, an array !');
        }
    }

    /**
     * Normalizes a latitude to the (-90, 90) range.
     * Latitudes below -90.0 or above 90.0 degrees are capped, not wrapped.
     *
     * @param float $latitude The latitude to normalize
     *
     * @return float
     */
    public function normalizeLatitude($latitude): float
    {
        return (double)max(-90, min(90, $latitude));
    }

    /**
     * Normalizes a longitude to the (-180, 180) range.
     * Longitudes below -180.0 or abode 180.0 degrees are wrapped.
     *
     * @param float $longitude The longitude to normalize
     *
     * @return float
     */
    public function normalizeLongitude($longitude): float
    {
        if (180 === (double)$longitude % 360) {
            return 180.0;
        }

        $mod = fmod((double)$longitude, 360);
        $longitude = $mod < -180 ? $mod + 360 : ($mod > 180 ? $mod - 360 : $mod);

        return (double)$longitude;
    }

    /**
     * Set the latitude.
     *
     * @param float $latitude
     */
    public function setLatitude($latitude)
    {
        $this->latitude = $this->normalizeLatitude($latitude);
    }

    /**
     * Get the latitude.
     *
     * @return float
     */
    public function getLatitude(): float
    {
        return $this->latitude;
    }

    /**
     * Set the longitude.
     *
     * @param float $longitude
     */
    public function setLongitude($longitude)
    {
        $this->longitude = $this->normalizeLongitude($longitude);
    }

    /**
     * Get the longitude.
     *
     * @return float
     */
    public function getLongitude(): float
    {
        return $this->longitude;
    }

    /**
     * Creates a valid and acceptable geographic coordinates.
     *
     * @param string $coordinates
     *
     * @throws InvalidArgumentException
     */
    public function setFromString($coordinates)
    {
        if (!is_string($coordinates)) {
            throw new InvalidArgumentException('The given coordinates should be a string !');
        }

        try {
            $inDecimalDegree = $this->toDecimalDegrees($coordinates);
            $this->setLatitude($inDecimalDegree[0]);
            $this->setLongitude($inDecimalDegree[1]);
        } catch (InvalidArgumentException $e) {
            throw $e;
        }
    }

    /**
     * @return integer
     */
    public function getPrecision(): int
    {
        return $this->precision;
    }

    /**
     * @param  integer $precision
     *
     * @return $this
     */
    public function setPrecision($precision): static
    {
        $this->precision = $precision;

        return $this;
    }

    /**
     * Converts a valid and acceptable geographic coordinates to decimal degrees coordinate.
     *
     * @param string $coordinates A valid and acceptable geographic coordinates.
     *
     * @return array An array of coordinate in decimal degree.
     *
     * @throws InvalidArgumentException
     *
     * @see http://en.wikipedia.org/wiki/Geographic_coordinate_conversion
     */
    private function toDecimalDegrees($coordinates): array
    {
        // 40.446195, -79.948862
        if (preg_match('/(\-?[0-9]{1,2}\.?\d*)[, ] ?(\-?[0-9]{1,3}\.?\d*)$/', $coordinates, $match)) {
            return [$match[1], $match[2]];
        }

        // 40° 26.7717, -79° 56.93172
        if (preg_match('/(\-?[0-9]{1,2})\D+([0-9]{1,2}\.?\d*)[, ] ?(\-?[0-9]{1,3})\D+([0-9]{1,2}\.?\d*)$/i', $coordinates, $match)) {
            return [
                $match[1] < 0 ? $match[1] - $match[2] / 60 : $match[1] + $match[2] / 60,
                $match[3] < 0 ? $match[3] - $match[4] / 60 : $match[3] + $match[4] / 60
            ];
        }

        // 40.446195N 79.948862W
        if (preg_match('/([0-9]{1,2}\.?\d*)\D*([ns]{1})[, ] ?([0-9]{1,3}\.?\d*)\D*([we]{1})$/i', $coordinates, $match)) {
            return [
                'N' === strtoupper($match[2]) ? $match[1] : -$match[1],
                'E' === strtoupper($match[4]) ? $match[3] : -$match[3]
            ];
        }

        // 40°26.7717S 79°56.93172E
        // 25°59.86′N,21°09.81′W
        if (preg_match('/([0-9]{1,2})\D+([0-9]{1,2}\.?\d*)\D*([ns]{1})[, ] ?([0-9]{1,3})\D+([0-9]{1,2}\.?\d*)\D*([we]{1})$/i', $coordinates, $match)) {
            $latitude = $match[1] + $match[2] / 60;
            $longitude = $match[4] + $match[5] / 60;

            return [
                'N' === strtoupper($match[3]) ? $latitude : -$latitude,
                'E' === strtoupper($match[6]) ? $longitude : -$longitude
            ];
        }

        // 40:26:46N, 079:56:55W
        // 40:26:46.302N 079:56:55.903W
        // 40°26′47″N 079°58′36″W
        // 40d 26′ 47″ N 079d 58′ 36″ W
        if (preg_match(
            '/([0-9]{1,2})\D+([0-9]{1,2})\D+([0-9]{1,2}\.?\d*)\D*([ns]{1})[, ] ?([0-9]{1,3})\D+([0-9]{1,2})\D+([0-9]{1,2}\.?\d*)\D*([we]{1})$/i',
            $coordinates,
            $match
        )) {
            $latitude = $match[1] + ($match[2] * 60 + $match[3]) / 3600;
            $longitude = $match[5] + ($match[6] * 60 + $match[7]) / 3600;

            return [
                'N' === strtoupper($match[4]) ? $latitude : -$latitude,
                'E' === strtoupper($match[8]) ? $longitude : -$longitude
            ];
        }

        throw new InvalidArgumentException('It should be a valid and acceptable ways to write geographic coordinates !');
    }

    /**
     * {@inheritDoc}
     */
    public function jsonSerialize(): mixed
    {
        return [$this->latitude, $this->longitude];
    }

    /**
     * Returns a boolean determining coordinates equality
     *
     * @param  Coordinate $coordinate
     *
     * @return boolean
     */
    public function isEqual(Coordinate $coordinate): bool
    {
        return bccomp($this->latitude, $coordinate->getLatitude(), $this->getPrecision()) === 0 &&
            bccomp($this->longitude, $coordinate->getLongitude(), $this->getPrecision()) === 0;
    }
}
