<?php

namespace common\modules\orderPayment\WOP\OnlinePayments\Core\Infrastructure\Http;

use common\modules\orderPayment\WOP\OnlinePayments\Core\Infrastructure\Http\DTO\Options;
use common\modules\orderPayment\WOP\OnlinePayments\Core\Infrastructure\Http\Exceptions\HttpCommunicationException;
use common\modules\orderPayment\WOP\OnlinePayments\Core\Infrastructure\Logger\Logger;
use common\modules\orderPayment\WOP\OnlinePayments\Core\Infrastructure\ORM\Exceptions\QueryFilterInvalidParamException;
/**
 * Class CurlHttpClient.
 *
 * @package OnlinePayments\Core\Infrastructure\Http
 */
class CurlHttpClient extends HttpClient
{
    /**
     * Default asynchronous request timeout value in milliseconds.
     */
    public const DEFAULT_ASYNC_REQUEST_TIMEOUT = 1000;
    /**
     * Default synchronous request timeout value in milliseconds.
     */
    public const DEFAULT_REQUEST_TIMEOUT = 900000;
    /**
     * Maximum number of 30x response redirects.
     */
    public const MAX_REDIRECTS = 10;
    /**
     * Config option that indicates whether to switch HTTP and HTTPS protocol.
     */
    public const SWITCH_PROTOCOL = 'SWITCH_PROTOCOL';
    /**
     * Indicates whether to use SSL verification.
     */
    public const SSL_STRICT_MODE = \false;
    /**
     * cURL options for the request.
     *
     * @var array
     */
    protected array $curlOptions;
    /**
     * Indicates whether to let cURL follow location.
     *
     * @var bool
     */
    protected bool $followLocation = \true;
    /**
     * cURL handler.
     *
     * @var resource
     */
    private $curlSession;
    /**
     * Create and send request.
     *
     * @param string $method HTTP method (GET, POST, PUT, DELETE etc.)
     * @param string $url Request URL. Full URL where request should be sent.
     * @param array|null $headers Request headers to send. Key as header name and value as header content. Optional.
     * @param string $body Request payload. String data to send as HTTP request payload. Optional.
     *
     * @return HttpResponse Response object.
     *
     * @throws HttpCommunicationException
     * @throws QueryFilterInvalidParamException
     *      Only in situation when there is no connection or no response.
     */
    protected function sendHttpRequest(string $method, string $url, ?array $headers = [], string $body = ''): HttpResponse
    {
        $this->setCurlFollowLocationFlag();
        $this->setCurlSessionAndCommonRequestParts($method, $url, $headers, $body);
        $this->setCurlSessionOptionsForSynchronousRequest();
        $this->setCurlOptions();
        return $this->executeSynchronousRequest();
    }
    /**
     * Create and send request asynchronously.
     *
     * @param string $method HTTP method (GET, POST, PUT, DELETE etc.)
     * @param string $url Request URL. Full URL where request should be sent.
     * @param array|null $headers [Optional] Request headers to send. Key as header name and value as header content.
     * @param string $body [Optional] Request payload. String data to send as HTTP request payload. Default value for
     *     request body is '' to ensure minimal request data in case of POST, PUT, PATCH methods. This will ensure
     *     that we have the upload progress and enable the async request termination as soon as the upload is finished
     *     without waiting for a response (without downloading a body or relaying on a fixed request timeout).
     *
     * @return void
     *
     * @throws QueryFilterInvalidParamException
     */
    protected function sendHttpRequestAsync(string $method, string $url, ?array $headers = [], string $body = ''): void
    {
        $this->setCurlSessionAndCommonRequestParts($method, $url, $headers, $body);
        $this->setCurlSessionOptionsForAsynchronousRequest();
        $this->setCurlOptions();
        $this->executeAsynchronousRequest();
    }
    /**
     * Executes and returns response for synchronous request.
     *
     * @return HttpResponse A response object.
     *
     * @throws HttpCommunicationException
     */
    protected function executeSynchronousRequest(): HttpResponse
    {
        [$result, $statusCode, $headers] = $this->executeCurlRequest();
        if ($result === \false) {
            $error = curl_errno($this->curlSession) . ' = ' . curl_error($this->curlSession);
            curl_close($this->curlSession);
            throw new HttpCommunicationException('Request ' . $this->curlOptions[\CURLOPT_URL] . ' failed. ERROR: ' . $error);
        }
        curl_close($this->curlSession);
        return new HttpResponse($statusCode, $headers, $result);
    }
    /**
     * Executes asynchronous request.
     *
     * @return void
     */
    protected function executeAsynchronousRequest(): void
    {
        [$result, $statusCode] = $this->executeCurlRequest();
        // 0 status code is set when timeout is reached
        if (!in_array($statusCode, [0, 200], \true) && curl_errno($this->curlSession) !== \CURLE_OPERATION_TIMEOUTED) {
            $curlError = '';
            if (curl_errno($this->curlSession)) {
                $curlError = ' cURL error: ' . curl_errno($this->curlSession) . ' > ' . curl_error($this->curlSession);
            }
            $httpError = $statusCode . ' Message: ' . $result . $curlError;
            Logger::logError('Async process failed. ERROR: ' . $httpError);
        }
        curl_close($this->curlSession);
    }
    /**
     * Sets cURL session and common request parts.
     *
     * @param string $method Request method.
     * @param string $url Request URL.
     * @param array $headers Array of request headers.
     * @param string $body Request body.
     *
     * @return void
     */
    protected function setCurlSessionAndCommonRequestParts(string $method, string $url, array $headers, string $body): void
    {
        $this->initializeCurlSession();
        $this->setCurlSessionOptionsBasedOnMethod($method);
        $this->setCurlSessionUrlHeadersAndBody($method, $url, $headers, $body);
        $this->setCommonOptionsForCurlSession();
    }
    /**
     * Initializes cURL session.
     *
     * @return void
     */
    protected function initializeCurlSession(): void
    {
        // this constant is not defined prior to php 7.0.7
        if (!defined('CURL_REDIR_POST_ALL')) {
            define('CURL_REDIR_POST_ALL', 7);
        }
        $this->curlSession = curl_init();
        $this->curlOptions = [];
    }
    /**
     * Sets cURL session option based on request method.
     *
     * @param string $method Request method.
     *
     * @return void
     */
    protected function setCurlSessionOptionsBasedOnMethod(string $method): void
    {
        if ($method === static::HTTP_METHOD_POST) {
            // follow 30x redirects with POST
            $this->curlOptions[\CURLOPT_POSTREDIR] = \CURL_REDIR_POST_ALL;
        }
        $this->curlOptions[\CURLOPT_CUSTOMREQUEST] = $method;
    }
    /**
     * Sets cURL session URL, headers, and request body.
     *
     * @param string $method Request method.
     * @param string $url Request URL.
     * @param array $headers Array of request headers.
     * @param string $body Request body.
     *
     * @return void
     */
    protected function setCurlSessionUrlHeadersAndBody(string $method, string $url, array $headers, string $body): void
    {
        $this->curlOptions[\CURLOPT_URL] = $this->adjustUrlIfNeeded($url);
        $this->curlOptions[\CURLOPT_HTTPHEADER] = $headers;
        $methodsWithBody = [static::HTTP_METHOD_POST, static::HTTP_METHOD_PUT, static::HTTP_METHOD_PATCH];
        if (in_array($method, $methodsWithBody, \true)) {
            $this->curlOptions[\CURLOPT_POSTFIELDS] = $body;
        }
    }
    /**
     * Sets common options for cURL session.
     *
     * @noinspection CurlSslServerSpoofingInspection
     *
     * @return void
     */
    protected function setCommonOptionsForCurlSession(): void
    {
        $this->curlOptions[\CURLOPT_RETURNTRANSFER] = \true;
        if ($this->followLocation) {
            $this->curlOptions[\CURLOPT_FOLLOWLOCATION] = \true;
            // stop possible endless redirect loop when following 30x redirects.
            $this->curlOptions[\CURLOPT_MAXREDIRS] = static::MAX_REDIRECTS;
        }
        $this->curlOptions[\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V4;
        $this->curlOptions[\CURLOPT_SSL_VERIFYPEER] = static::SSL_STRICT_MODE;
        $this->curlOptions[\CURLOPT_SSL_VERIFYHOST] = static::SSL_STRICT_MODE;
        // Set default user agent, because for some shops if user agent is missing, request will not work.
        $this->curlOptions[\CURLOPT_USERAGENT] = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.186 Safari/537.36';
    }
    /**
     * Sets cURL session options for synchronous request.
     *
     * @return void
     *
     * @throws QueryFilterInvalidParamException
     */
    protected function setCurlSessionOptionsForSynchronousRequest(): void
    {
        $this->curlOptions[\CURLOPT_TIMEOUT_MS] = $this->getConfigService()->getSyncRequestTimeout() ?: static::DEFAULT_REQUEST_TIMEOUT;
    }
    /**
     * Sets cURL session options for asynchronous request.
     *
     * @return void
     *
     * @throws QueryFilterInvalidParamException
     */
    protected function setCurlSessionOptionsForAsynchronousRequest(): void
    {
        // Always ensure the connection is fresh.
        $this->curlOptions[\CURLOPT_FRESH_CONNECT] = \true;
        // Timeout super fast once connected, so it goes into async.
        $asyncRequestTimeout = $this->getConfigService()->getAsyncRequestTimeout();
        $this->curlOptions[\CURLOPT_TIMEOUT_MS] = $asyncRequestTimeout ?: static::DEFAULT_ASYNC_REQUEST_TIMEOUT;
    }
    /**
     * Sets a call options to the cURL instance.
     *
     * @return void
     */
    protected function setCurlOptions(): void
    {
        $this->setCurlSessionOptionsFromConfiguration();
        curl_setopt_array($this->curlSession, $this->curlOptions);
    }
    /**
     * If some configuration options were set in the configuration, use them.
     * This is usually done if the autoconfiguration is used.
     *
     * @return void
     */
    protected function setCurlSessionOptionsFromConfiguration(): void
    {
        $domain = parse_url($this->curlOptions[\CURLOPT_URL], \PHP_URL_HOST);
        $options = $this->getAdditionalOptions($domain);
        foreach ($options as $key => $value) {
            if ($key !== static::SWITCH_PROTOCOL) {
                $this->curlOptions[$key] = $value;
            }
        }
    }
    /**
     * Executes cURL request and returns response and status code.
     *
     * @return array Array with plain response as the first item, status code as the second item and headers as third.
     */
    protected function executeCurlRequest(): array
    {
        $headers = [];
        curl_setopt(
            $this->curlSession,
            \CURLOPT_HEADERFUNCTION,
            // Callback function is called by curl for each header line received
            static function ($curl, $header) use (&$headers) {
                // Set only valid headers
                $headerArray = explode(':', $header, 2);
                if (count($headerArray) >= 2) {
                    $headers[trim($headerArray[0])] = trim($headerArray[1]);
                }
                // Do not use mb_strlen here because curl expects number of bytes to be returned not number of chars
                return strlen($header);
            }
        );
        return [curl_exec($this->curlSession), curl_getinfo($this->curlSession, \CURLINFO_HTTP_CODE), $headers];
    }
    /**
     * Get additional options combinations for request.
     *
     * @param string $method HTTP method (GET, POST, PUT, DELETE etc.)
     * @param string $url Request URL. Full URL where request should be sent.
     *
     * @return array
     *  Array of additional options combinations. Each array item should be an array of Options instances.
     */
    protected function getAutoConfigurationOptionsCombinations(string $method, string $url): array
    {
        /**
         * Combinations to use:
         * CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V6 (default is CURL_IPRESOLVE_V4)
         * CURLOPT_FOLLOWLOCATION => false (default is true)
         * SWITCH_PROTOCOL => This is not a cURL option and is treated differently. Default is false.
         */
        $switchProtocol = new Options(static::SWITCH_PROTOCOL, \true);
        $ipVersion = new Options(\CURLOPT_IPRESOLVE, \CURL_IPRESOLVE_V6);
        if ($this->followLocation) {
            $followLocation = new Options(\CURLOPT_FOLLOWLOCATION, \false);
            return [[$switchProtocol], [$followLocation], [$switchProtocol, $followLocation], [$ipVersion], [$switchProtocol, $ipVersion], [$followLocation, $ipVersion], [$switchProtocol, $followLocation, $ipVersion]];
        }
        return [[$switchProtocol], [$ipVersion], [$switchProtocol, $ipVersion]];
    }
    /**
     * Changes between http and https protocol if needed.
     *
     * @param string $url URL to update.
     *
     * @return string Updated URL.
     */
    protected function adjustUrlIfNeeded(string $url): string
    {
        $domain = parse_url($url, \PHP_URL_HOST);
        $options = $this->getAdditionalOptions($domain);
        if (!empty($options[static::SWITCH_PROTOCOL])) {
            if (mb_strpos($url, 'http:') === 0) {
                $url = str_replace('http:', 'https:', $url);
            } else {
                $url = str_replace('https:', 'http:', $url);
            }
        }
        return $url;
    }
    /**
     * Determines whether to let cURL follow location based on the environment settings.
     *
     * @return void
     */
    protected function setCurlFollowLocationFlag(): void
    {
        // when 'open_basedir' is set, some servers will return curl error upon initializing curl options
        // when setting curl option to follow location
        if (ini_get('open_basedir')) {
            $this->followLocation = \false;
        }
    }
}
