<?php

declare(strict_types=1);

/*
 * This file is part of the Geocoder package.
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 * @license    MIT License
 */

namespace Geocoder\Provider\GoogleMaps;

use Geocoder\Collection;
use Geocoder\Exception\InvalidCredentials;
use Geocoder\Exception\InvalidServerResponse;
use Geocoder\Exception\QuotaExceeded;
use Geocoder\Exception\UnsupportedOperation;
use Geocoder\Http\Provider\AbstractHttpProvider;
use Geocoder\Model\AddressBuilder;
use Geocoder\Model\AddressCollection;
use Geocoder\Provider\GoogleMaps\Model\GoogleAddress;
use Geocoder\Provider\Provider;
use Geocoder\Query\GeocodeQuery;
use Geocoder\Query\ReverseQuery;
use Psr\Http\Client\ClientInterface;

/**
 * @author William Durand <william.durand1@gmail.com>
 */
final class GoogleMaps extends AbstractHttpProvider implements Provider
{
    /**
     * @var string
     */
    public const GEOCODE_ENDPOINT_URL_SSL = 'https://maps.googleapis.com/maps/api/geocode/json?address=%s';

    /**
     * @var string
     */
    public const REVERSE_ENDPOINT_URL_SSL = 'https://maps.googleapis.com/maps/api/geocode/json?latlng=%F,%F';

    /**
     * @var string|null
     */
    private $region;

    /**
     * @var string|null
     */
    private $apiKey;

    /**
     * @var string|null
     */
    private $clientId;

    /**
     * @var string|null
     */
    private $privateKey;

    /**
     * @var string|null
     */
    private $channel;

    /**
     * Google Maps for Business
     * https://developers.google.com/maps/documentation/business/
     * Maps for Business is no longer accepting new signups.
     *
     * @param ClientInterface $client     An HTTP adapter
     * @param string          $clientId   Your Client ID
     * @param string          $privateKey Your Private Key (optional)
     * @param string          $region     Region biasing (optional)
     * @param string          $apiKey     Google Geocoding API key (optional)
     * @param string          $channel    Google Channel parameter (optional)
     *
     * @return GoogleMaps
     */
    public static function business(
        ClientInterface $client,
        string $clientId,
        ?string $privateKey = null,
        ?string $region = null,
        ?string $apiKey = null,
        ?string $channel = null,
    ) {
        $provider = new self($client, $region, $apiKey);
        $provider->clientId = $clientId;
        $provider->privateKey = $privateKey;
        $provider->channel = $channel;

        return $provider;
    }

    /**
     * @param ClientInterface $client An HTTP adapter
     * @param string          $region Region biasing (optional)
     * @param string          $apiKey Google Geocoding API key (optional)
     */
    public function __construct(ClientInterface $client, ?string $region = null, ?string $apiKey = null)
    {
        parent::__construct($client);

        $this->region = $region;
        $this->apiKey = $apiKey;
    }

    public function geocodeQuery(GeocodeQuery $query): Collection
    {
        // Google API returns invalid data if IP address given
        // This API doesn't handle IPs
        if (filter_var($query->getText(), FILTER_VALIDATE_IP)) {
            throw new UnsupportedOperation('The GoogleMaps provider does not support IP addresses, only street addresses.');
        }

        $url = sprintf(self::GEOCODE_ENDPOINT_URL_SSL, rawurlencode($query->getText()));
        if (null !== $bounds = $query->getBounds()) {
            $url .= sprintf(
                '&bounds=%s,%s|%s,%s',
                $bounds->getSouth(),
                $bounds->getWest(),
                $bounds->getNorth(),
                $bounds->getEast()
            );
        }

        if (null !== $components = $query->getData('components')) {
            $serializedComponents = is_string($components) ? $components : $this->serializeComponents($components);
            $url .= sprintf('&components=%s', urlencode($serializedComponents));
        }

        return $this->fetchUrl($url, $query->getLocale(), $query->getLimit(), $query->getData('region', $this->region));
    }

    public function reverseQuery(ReverseQuery $query): Collection
    {
        $coordinate = $query->getCoordinates();
        $url = sprintf(self::REVERSE_ENDPOINT_URL_SSL, $coordinate->getLatitude(), $coordinate->getLongitude());

        if (null !== $locationType = $query->getData('location_type')) {
            $url .= '&location_type='.urlencode($locationType);
        }

        if (null !== $resultType = $query->getData('result_type')) {
            $url .= '&result_type='.urlencode($resultType);
        }

        return $this->fetchUrl($url, $query->getLocale(), $query->getLimit(), $query->getData('region', $this->region));
    }

    public function getName(): string
    {
        return 'google_maps';
    }

    /**
     * @return string query with extra params
     */
    private function buildQuery(string $url, ?string $locale = null, ?string $region = null): string
    {
        if (null === $this->apiKey && null === $this->clientId) {
            throw new InvalidCredentials('You must provide an API key. Keyless access was removed in June, 2016');
        }

        if (null !== $locale) {
            $url = sprintf('%s&language=%s', $url, $locale);
        }

        if (null !== $region) {
            $url = sprintf('%s&region=%s', $url, $region);
        }

        if (null !== $this->apiKey) {
            $url = sprintf('%s&key=%s', $url, $this->apiKey);
        }

        if (null !== $this->clientId) {
            $url = sprintf('%s&client=%s', $url, $this->clientId);

            if (null !== $this->channel) {
                $url = sprintf('%s&channel=%s', $url, $this->channel);
            }

            if (null !== $this->privateKey) {
                $url = $this->signQuery($url);
            }
        }

        return $url;
    }

    /**
     * @throws InvalidServerResponse
     * @throws InvalidCredentials
     */
    private function fetchUrl(string $url, ?string $locale = null, int $limit = 1, ?string $region = null): AddressCollection
    {
        $url = $this->buildQuery($url, $locale, $region);
        $content = $this->getUrlContents($url);
        $json = $this->validateResponse($url, $content);

        // no result
        if (!isset($json->results) || !count($json->results) || 'OK' !== $json->status) {
            return new AddressCollection([]);
        }

        $results = [];
        foreach ($json->results as $result) {
            $builder = new AddressBuilder($this->getName());
            $this->parseCoordinates($builder, $result);

            // set official Google place id
            if (isset($result->place_id)) {
                $builder->setValue('id', $result->place_id);
            }

            // update address components
            foreach ($result->address_components as $component) {
                foreach ($component->types as $type) {
                    $this->updateAddressComponent($builder, $type, $component);
                }
            }

            /** @var GoogleAddress $address */
            $address = $builder->build(GoogleAddress::class);
            $address = $address->withId($builder->getValue('id'));
            if (isset($result->geometry->location_type)) {
                $address = $address->withLocationType($result->geometry->location_type);
            }
            if (isset($result->types)) {
                $address = $address->withResultType($result->types);
            }
            if (isset($result->formatted_address)) {
                $address = $address->withFormattedAddress($result->formatted_address);
            }

            $results[] = $address
                ->withStreetAddress($builder->getValue('street_address'))
                ->withIntersection($builder->getValue('intersection'))
                ->withPolitical($builder->getValue('political'))
                ->withColloquialArea($builder->getValue('colloquial_area'))
                ->withWard($builder->getValue('ward'))
                ->withNeighborhood($builder->getValue('neighborhood'))
                ->withPremise($builder->getValue('premise'))
                ->withSubpremise($builder->getValue('subpremise'))
                ->withNaturalFeature($builder->getValue('natural_feature'))
                ->withAirport($builder->getValue('airport'))
                ->withPark($builder->getValue('park'))
                ->withPointOfInterest($builder->getValue('point_of_interest'))
                ->withEstablishment($builder->getValue('establishment'))
                ->withSubLocalityLevels($builder->getValue('subLocalityLevel', []))
                ->withPostalCodeSuffix($builder->getValue('postal_code_suffix'))
                ->withPartialMatch($result->partial_match ?? false);

            if (count($results) >= $limit) {
                break;
            }
        }

        return new AddressCollection($results);
    }

    /**
     * Update current resultSet with given key/value.
     *
     * @param string $type   Component type
     * @param object $values The component values
     */
    private function updateAddressComponent(AddressBuilder $builder, string $type, $values): void
    {
        switch ($type) {
            case 'postal_code':
                $builder->setPostalCode($values->long_name);

                break;

            case 'locality':
            case 'postal_town':
                $builder->setLocality($values->long_name);

                break;

            case 'administrative_area_level_1':
            case 'administrative_area_level_2':
            case 'administrative_area_level_3':
            case 'administrative_area_level_4':
            case 'administrative_area_level_5':
                $builder->addAdminLevel(intval(substr($type, -1)), $values->long_name, $values->short_name);

                break;

            case 'sublocality_level_1':
            case 'sublocality_level_2':
            case 'sublocality_level_3':
            case 'sublocality_level_4':
            case 'sublocality_level_5':
                $subLocalityLevel = $builder->getValue('subLocalityLevel', []);
                $subLocalityLevel[] = [
                    'level' => intval(substr($type, -1)),
                    'name' => $values->long_name,
                    'code' => $values->short_name,
                ];
                $builder->setValue('subLocalityLevel', $subLocalityLevel);

                break;

            case 'country':
                $builder->setCountry($values->long_name);
                $builder->setCountryCode($values->short_name);

                break;

            case 'street_number':
                $builder->setStreetNumber($values->long_name);

                break;

            case 'route':
                $builder->setStreetName($values->long_name);

                break;

            case 'sublocality':
                $builder->setSubLocality($values->long_name);

                break;

            case 'street_address':
            case 'intersection':
            case 'political':
            case 'colloquial_area':
            case 'ward':
            case 'neighborhood':
            case 'premise':
            case 'subpremise':
            case 'natural_feature':
            case 'airport':
            case 'park':
            case 'point_of_interest':
            case 'establishment':
            case 'postal_code_suffix':
                $builder->setValue($type, $values->long_name);

                break;

            default:
        }
    }

    /**
     * Sign a URL with a given crypto key
     * Note that this URL must be properly URL-encoded
     * src: http://gmaps-samples.googlecode.com/svn/trunk/urlsigning/UrlSigner.php-source.
     *
     * @param string $query Query to be signed
     *
     * @return string $query query with signature appended
     */
    private function signQuery(string $query): string
    {
        $url = parse_url($query);

        $urlPartToSign = $url['path'].'?'.$url['query'];

        // Decode the private key into its binary format
        $decodedKey = base64_decode(str_replace(['-', '_'], ['+', '/'], $this->privateKey));

        // Create a signature using the private key and the URL-encoded
        // string using HMAC SHA1. This signature will be binary.
        $signature = hash_hmac('sha1', $urlPartToSign, $decodedKey, true);

        $encodedSignature = str_replace(['+', '/'], ['-', '_'], base64_encode($signature));

        return sprintf('%s&signature=%s', $query, $encodedSignature);
    }

    /**
     * Serialize the component query parameter.
     *
     * @param array<string, string> $components
     */
    private function serializeComponents(array $components): string
    {
        return implode('|', array_map(function ($name, $value) {
            return sprintf('%s:%s', $name, $value);
        }, array_keys($components), $components));
    }

    /**
     * Decode the response content and validate it to make sure it does not have any errors.
     *
     * @param string $content
     *
     * @return \Stdclass result form json_decode()
     *
     * @throws InvalidCredentials
     * @throws InvalidServerResponse
     * @throws QuotaExceeded
     */
    private function validateResponse(string $url, $content)
    {
        // Throw exception if invalid clientID and/or privateKey used with GoogleMapsBusinessProvider
        if (false !== strpos($content, "Provided 'signature' is not valid for the provided client ID")) {
            throw new InvalidCredentials(sprintf('Invalid client ID / API Key %s', $url));
        }

        $json = json_decode($content);

        // API error
        if (!isset($json)) {
            throw InvalidServerResponse::create($url);
        }

        if ('REQUEST_DENIED' === $json->status && 'The provided API key is invalid.' === $json->error_message) {
            throw new InvalidCredentials(sprintf('API key is invalid %s', $url));
        }

        if ('REQUEST_DENIED' === $json->status) {
            throw new InvalidServerResponse(sprintf('API access denied. Request: %s - Message: %s', $url, $json->error_message));
        }

        // you are over your quota
        if ('OVER_QUERY_LIMIT' === $json->status) {
            throw new QuotaExceeded(sprintf('Daily quota exceeded %s', $url));
        }

        return $json;
    }

    /**
     * Parse coordinates and bounds.
     *
     * @param \Stdclass $result
     */
    private function parseCoordinates(AddressBuilder $builder, $result): void
    {
        $coordinates = $result->geometry->location;
        $builder->setCoordinates($coordinates->lat, $coordinates->lng);

        if (isset($result->geometry->bounds)) {
            $builder->setBounds(
                $result->geometry->bounds->southwest->lat,
                $result->geometry->bounds->southwest->lng,
                $result->geometry->bounds->northeast->lat,
                $result->geometry->bounds->northeast->lng
            );
        } elseif (isset($result->geometry->viewport)) {
            $builder->setBounds(
                $result->geometry->viewport->southwest->lat,
                $result->geometry->viewport->southwest->lng,
                $result->geometry->viewport->northeast->lat,
                $result->geometry->viewport->northeast->lng
            );
        } elseif ('ROOFTOP' === $result->geometry->location_type) {
            // Fake bounds
            $builder->setBounds(
                $coordinates->lat,
                $coordinates->lng,
                $coordinates->lat,
                $coordinates->lng
            );
        }
    }
}
