<?php

declare(strict_types=1);

namespace MaxMind\WebService;

use Composer\CaBundle\CaBundle;
use MaxMind\Exception\AuthenticationException;
use MaxMind\Exception\HttpException;
use MaxMind\Exception\InsufficientFundsException;
use MaxMind\Exception\InvalidInputException;
use MaxMind\Exception\InvalidRequestException;
use MaxMind\Exception\IpAddressNotFoundException;
use MaxMind\Exception\PermissionRequiredException;
use MaxMind\Exception\WebServiceException;
use MaxMind\WebService\Http\RequestFactory;

/**
 * This class is not intended to be used directly by an end-user of a
 * MaxMind web service. Please use the appropriate client API for the service
 * that you are using.
 *
 * @internal
 */
class Client
{
    public const VERSION = '0.2.0';

    private readonly ?string $caBundle;
    private readonly ?float $connectTimeout;
    private readonly string $host;
    private readonly bool $useHttps;
    private readonly RequestFactory $httpRequestFactory;
    private readonly string $licenseKey;
    private readonly ?string $proxy;
    private readonly ?float $timeout;
    private readonly string $userAgentPrefix;
    private readonly int $accountId;

    /**
     * @param int                  $accountId  your MaxMind account ID
     * @param string               $licenseKey your MaxMind license key
     * @param array<string, mixed> $options    an array of options. Possible keys:
     *                                         * `host` - The host to use when connecting to the web service.
     *                                         * `useHttps` - Set to false to disable HTTPS.
     *                                         * `userAgent` - The prefix of the User-Agent to use in the request.
     *                                         * `caBundle` - The bundle of CA root certificates to use in the request.
     *                                         * `connectTimeout` - The connect timeout to use for the request.
     *                                         * `timeout` - The timeout to use for the request.
     *                                         * `proxy` - The HTTP proxy to use. May include a schema, port,
     *                                         username, and password, e.g., `http://username:password@127.0.0.1:10`.
     */
    public function __construct(
        int $accountId,
        string $licenseKey,
        array $options = []
    ) {
        $this->accountId = $accountId;
        $this->licenseKey = $licenseKey;

        $this->httpRequestFactory = $options['httpRequestFactory'] ?? new RequestFactory();
        $this->host = $options['host'] ?? 'api.maxmind.com';
        $this->useHttps = $options['useHttps'] ?? true;
        $this->userAgentPrefix = isset($options['userAgent']) ? $options['userAgent'] . ' ' : '';
        $this->caBundle = $options['caBundle'] ?? $this->getCaBundle();
        $this->connectTimeout = $options['connectTimeout'] ?? null;
        $this->timeout = $options['timeout'] ?? null;
        $this->proxy = $options['proxy'] ?? null;
    }

    /**
     * @param string       $service name of the service querying
     * @param string       $path    the URI path to use
     * @param array<mixed> $input   the data to be posted as JSON
     *
     * @throws InvalidInputException      when the request has missing or invalid
     *                                    data
     * @throws AuthenticationException    when there is an issue authenticating the
     *                                    request
     * @throws InsufficientFundsException when your account is out of funds
     * @throws InvalidRequestException    when the request is invalid for some
     *                                    other reason, e.g., invalid JSON in the POST.
     * @throws HttpException              when an unexpected HTTP error occurs
     * @throws WebServiceException        when some other error occurs. This also
     *                                    serves as the base class for the above exceptions.
     *
     * @return array<mixed>|null The decoded content of a successful response
     */
    public function post(string $service, string $path, array $input): ?array
    {
        $requestBody = json_encode($input);
        if ($requestBody === false) {
            throw new InvalidInputException(
                'Error encoding input as JSON: '
                . $this->jsonErrorDescription()
            );
        }

        $request = $this->createRequest(
            $path,
            ['Content-Type: application/json']
        );

        [$statusCode, $contentType, $responseBody] = $request->post($requestBody);

        return $this->handleResponse(
            $statusCode,
            $contentType,
            $responseBody,
            $service,
            $path
        );
    }

    /**
     * @return array<mixed>|null
     */
    public function get(string $service, string $path): ?array
    {
        $request = $this->createRequest(
            $path
        );

        [$statusCode, $contentType, $responseBody] = $request->get();

        return $this->handleResponse(
            $statusCode,
            $contentType,
            $responseBody,
            $service,
            $path
        );
    }

    private function userAgent(): string
    {
        $curlVersion = curl_version();
        if ($curlVersion === false) {
            throw new \RuntimeException('curl_version() returned false');
        }

        return $this->userAgentPrefix . 'MaxMind-WS-API/' . self::VERSION . ' PHP/' . \PHP_VERSION
           . ' curl/' . $curlVersion['version'];
    }

    /**
     * @param array<string> $headers
     */
    private function createRequest(string $path, array $headers = []): Http\Request
    {
        $headers = [
            ...$headers,
            'Authorization: Basic '
            . base64_encode($this->accountId . ':' . $this->licenseKey),
            'Accept: application/json',
        ];

        return $this->httpRequestFactory->request(
            $this->urlFor($path),
            [
                'caBundle' => $this->caBundle,
                'connectTimeout' => $this->connectTimeout,
                'headers' => $headers,
                'proxy' => $this->proxy,
                'timeout' => $this->timeout,
                'userAgent' => $this->userAgent(),
            ]
        );
    }

    /**
     * @param int         $statusCode   the HTTP status code of the response
     * @param string|null $contentType  the Content-Type of the response
     * @param string|null $responseBody the response body
     * @param string      $service      the name of the service
     * @param string      $path         the path used in the request
     *
     * @throws AuthenticationException    when there is an issue authenticating the
     *                                    request
     * @throws InsufficientFundsException when your account is out of funds
     * @throws InvalidRequestException    when the request is invalid for some
     *                                    other reason, e.g., invalid JSON in the POST.
     * @throws HttpException              when an unexpected HTTP error occurs
     * @throws WebServiceException        when some other error occurs. This also
     *                                    serves as the base class for the above exceptions
     *
     * @return array<mixed>|null The decoded content of a successful response
     */
    private function handleResponse(
        int $statusCode,
        ?string $contentType,
        ?string $responseBody,
        string $service,
        string $path
    ): ?array {
        if ($statusCode >= 400 && $statusCode <= 499) {
            $this->handle4xx($statusCode, $contentType, $responseBody, $service, $path);
        } elseif ($statusCode >= 500) {
            $this->handle5xx($statusCode, $service, $path);
        } elseif ($statusCode !== 200 && $statusCode !== 204) {
            $this->handleUnexpectedStatus($statusCode, $service, $path);
        }

        return $this->handleSuccess($statusCode, $responseBody, $service);
    }

    /**
     * @return string describing the JSON error
     */
    private function jsonErrorDescription(): string
    {
        $errno = json_last_error();

        switch ($errno) {
            case \JSON_ERROR_DEPTH:
                return 'The maximum stack depth has been exceeded.';

            case \JSON_ERROR_STATE_MISMATCH:
                return 'Invalid or malformed JSON.';

            case \JSON_ERROR_CTRL_CHAR:
                return 'Control character error.';

            case \JSON_ERROR_SYNTAX:
                return 'Syntax error.';

            case \JSON_ERROR_UTF8:
                return 'Malformed UTF-8 characters.';

            default:
                return "Other JSON error ($errno).";
        }
    }

    /**
     * @param string $path the path to use in the URL
     *
     * @return string the constructed URL
     */
    private function urlFor(string $path): string
    {
        return ($this->useHttps ? 'https://' : 'http://') . $this->host . $path;
    }

    /**
     * @param int         $statusCode  the HTTP status code
     * @param string|null $contentType the response content-type
     * @param string|null $body        the response body
     * @param string      $service     the service name
     * @param string      $path        the path used in the request
     *
     * @throws AuthenticationException
     * @throws HttpException
     * @throws InsufficientFundsException
     * @throws InvalidRequestException
     */
    private function handle4xx(
        int $statusCode,
        ?string $contentType,
        ?string $body,
        string $service,
        string $path
    ): void {
        if ($body === null || $body === '') {
            throw new HttpException(
                "Received a $statusCode error for $service with no body",
                $statusCode,
                $this->urlFor($path)
            );
        }
        if ($contentType === null || !str_contains($contentType, 'json')) {
            throw new HttpException(
                "Received a $statusCode error for $service with "
                . 'the following body: ' . $body,
                $statusCode,
                $this->urlFor($path)
            );
        }

        $message = json_decode($body, true);
        if ($message === null) {
            throw new HttpException(
                "Received a $statusCode error for $service but could "
                . 'not decode the response as JSON: '
                . $this->jsonErrorDescription() . ' Body: ' . $body,
                $statusCode,
                $this->urlFor($path)
            );
        }

        if (!isset($message['code']) || !isset($message['error'])) {
            throw new HttpException(
                'Error response contains JSON but it does not '
                . 'specify code or error keys: ' . $body,
                $statusCode,
                $this->urlFor($path)
            );
        }

        $this->handleWebServiceError(
            $message['error'],
            $message['code'],
            $statusCode,
            $path
        );
    }

    /**
     * @param string $message    the error message from the web service
     * @param string $code       the error code from the web service
     * @param int    $statusCode the HTTP status code
     * @param string $path       the path used in the request
     *
     * @throws AuthenticationException
     * @throws InvalidRequestException
     * @throws InsufficientFundsException
     */
    private function handleWebServiceError(
        string $message,
        string $code,
        int $statusCode,
        string $path
    ): void {
        switch ($code) {
            case 'IP_ADDRESS_NOT_FOUND':
            case 'IP_ADDRESS_RESERVED':
                throw new IpAddressNotFoundException(
                    $message,
                    $code,
                    $statusCode,
                    $this->urlFor($path)
                );

            case 'ACCOUNT_ID_REQUIRED':
            case 'ACCOUNT_ID_UNKNOWN':
            case 'AUTHORIZATION_INVALID':
            case 'LICENSE_KEY_REQUIRED':
            case 'USER_ID_REQUIRED':
            case 'USER_ID_UNKNOWN':
                throw new AuthenticationException(
                    $message,
                    $code,
                    $statusCode,
                    $this->urlFor($path)
                );

            case 'OUT_OF_QUERIES':
            case 'INSUFFICIENT_FUNDS':
                throw new InsufficientFundsException(
                    $message,
                    $code,
                    $statusCode,
                    $this->urlFor($path)
                );

            case 'PERMISSION_REQUIRED':
                throw new PermissionRequiredException(
                    $message,
                    $code,
                    $statusCode,
                    $this->urlFor($path)
                );

            default:
                throw new InvalidRequestException(
                    $message,
                    $code,
                    $statusCode,
                    $this->urlFor($path)
                );
        }
    }

    /**
     * @param int    $statusCode the HTTP status code
     * @param string $service    the service name
     * @param string $path       the URI path used in the request
     *
     * @throws HttpException
     */
    private function handle5xx(int $statusCode, string $service, string $path): void
    {
        throw new HttpException(
            "Received a server error ($statusCode) for $service",
            $statusCode,
            $this->urlFor($path)
        );
    }

    /**
     * @param int    $statusCode the HTTP status code
     * @param string $service    the service name
     * @param string $path       the URI path used in the request
     *
     * @throws HttpException
     */
    private function handleUnexpectedStatus(int $statusCode, string $service, string $path): void
    {
        throw new HttpException(
            'Received an unexpected HTTP status '
            . "($statusCode) for $service",
            $statusCode,
            $this->urlFor($path)
        );
    }

    /**
     * @param int         $statusCode the HTTP status code
     * @param string|null $body       the successful request body
     * @param string      $service    the service name
     *
     * @throws WebServiceException if a response body is included but not
     *                             expected, or is not expected but not
     *                             included, or is expected and included
     *                             but cannot be decoded as JSON
     *
     * @return array<mixed>|null the decoded request body
     */
    private function handleSuccess(int $statusCode, ?string $body, string $service): ?array
    {
        // A 204 should have no response body
        if ($statusCode === 204) {
            if ($body !== null && $body !== '') {
                throw new WebServiceException(
                    "Received a 204 response for $service along with an "
                    . "unexpected HTTP body: $body"
                );
            }

            return null;
        }

        // A 200 should have a valid JSON body
        if ($body === null || $body === '') {
            throw new WebServiceException(
                "Received a 200 response for $service but did not "
                . 'receive a HTTP body.'
            );
        }

        $decodedContent = json_decode($body, true);
        if ($decodedContent === null) {
            throw new WebServiceException(
                "Received a 200 response for $service but could "
                . 'not decode the response as JSON: '
                . $this->jsonErrorDescription() . ' Body: ' . $body
            );
        }

        return $decodedContent;
    }

    private function getCaBundle(): ?string
    {
        $curlVersion = curl_version();
        if ($curlVersion === false) {
            throw new \RuntimeException('curl_version() returned false');
        }

        // On OS X, when the SSL version is "SecureTransport", the system's
        // keychain will be used.
        if ($curlVersion['ssl_version'] === 'SecureTransport') {
            return null;
        }
        $cert = CaBundle::getSystemCaRootBundlePath();

        // Check if the cert is inside a phar. If so, we need to copy the cert
        // to a temp file so that curl can see it.
        if (str_starts_with($cert, 'phar://')) {
            $tempDir = sys_get_temp_dir();
            $newCert = tempnam($tempDir, 'geoip2-');
            if ($newCert === false) {
                throw new \RuntimeException(
                    "Unable to create temporary file in $tempDir"
                );
            }
            if (!copy($cert, $newCert)) {
                throw new \RuntimeException(
                    "Could not copy $cert to $newCert: "
                    . var_export(error_get_last(), true)
                );
            }

            // We use a shutdown function rather than the destructor as the
            // destructor isn't called on a fatal error such as an uncaught
            // exception.
            register_shutdown_function(
                function () use ($newCert) {
                    unlink($newCert);
                }
            );
            $cert = $newCert;
        }
        if (!file_exists($cert)) {
            throw new \RuntimeException("CA cert does not exist at $cert");
        }

        return $cert;
    }
}
