<?php

declare(strict_types=1);

namespace Buzz\Client;

use Buzz\Configuration\ParameterBag;
use Buzz\Exception\CallbackException;
use Buzz\Exception\ClientException;
use Buzz\Exception\NetworkException;
use Buzz\Exception\RequestException;
use Buzz\Message\HeaderConverter;
use Buzz\Message\ResponseBuilder;
use Psr\Http\Message\RequestInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Base client class with helpers for working with cURL.
 */
abstract class AbstractCurl extends AbstractClient
{
    private $handles = [];

    private $maxHandles = 5;

    protected function configureOptions(OptionsResolver $resolver): void
    {
        parent::configureOptions($resolver);

        $resolver->setDefault('curl', []);
        $resolver->setAllowedTypes('curl', ['array']);
        $resolver->setDefault('expose_curl_info', false);
        $resolver->setAllowedTypes('expose_curl_info', ['boolean']);
    }

    /**
     * Creates a new cURL resource.
     *
     * @return resource A new cURL resource
     *
     * @throws ClientException If unable to create a cURL resource
     */
    protected function createHandle()
    {
        $curl = $this->handles ? array_pop($this->handles) : curl_init();
        if (false === $curl) {
            throw new ClientException('Unable to create a new cURL handle');
        }

        return $curl;
    }

    /**
     * Release a cUrl resource. This function is from Guzzle.
     *
     * @param resource $curl
     */
    protected function releaseHandle($curl): void
    {
        if (\count($this->handles) >= $this->maxHandles) {
            curl_close($curl);
        } else {
            // Remove all callback functions as they can hold onto references
            // and are not cleaned up by curl_reset. Using curl_setopt_array
            // does not work for some reason, so removing each one
            // individually.
            curl_setopt($curl, CURLOPT_HEADERFUNCTION, null);
            curl_setopt($curl, CURLOPT_READFUNCTION, null);
            curl_setopt($curl, CURLOPT_WRITEFUNCTION, null);
            curl_setopt($curl, CURLOPT_PROGRESSFUNCTION, null);
            curl_reset($curl);

            if (!\in_array($curl, $this->handles)) {
                $this->handles[] = $curl;
            }
        }
    }

    /**
     * Prepares a cURL resource to send a request.
     *
     * @param resource $curl
     */
    protected function prepare($curl, RequestInterface $request, ParameterBag $options): ResponseBuilder
    {
        if (\defined('CURLOPT_PROTOCOLS')) {
            curl_setopt($curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
            curl_setopt($curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
        }

        curl_setopt($curl, CURLOPT_HEADER, false);
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, false);
        curl_setopt($curl, CURLOPT_FAILONERROR, false);

        $this->setOptionsFromParameterBag($curl, $options);
        $this->setOptionsFromRequest($curl, $request);

        $responseBuilder = new ResponseBuilder($this->responseFactory);
        curl_setopt($curl, CURLOPT_HEADERFUNCTION, function ($ch, $data) use ($responseBuilder) {
            $str = trim($data);
            if ('' !== $str) {
                if (0 === strpos(strtolower($str), 'http/')) {
                    $responseBuilder->setStatus($str);
                } else {
                    $responseBuilder->addHeader($str);
                }
            }

            return \strlen($data);
        });

        curl_setopt($curl, CURLOPT_WRITEFUNCTION, function ($ch, $data) use ($responseBuilder) {
            return $responseBuilder->writeBody($data);
        });

        // apply additional options
        curl_setopt_array($curl, $options->get('curl'));

        return $responseBuilder;
    }

    /**
     * Sets options on a cURL resource based on a request.
     *
     * @param resource         $curl    A cURL resource
     * @param RequestInterface $request A request object
     */
    private function setOptionsFromRequest($curl, RequestInterface $request): void
    {
        $options = [
            CURLOPT_CUSTOMREQUEST => $request->getMethod(),
            CURLOPT_URL => $request->getUri()->__toString(),
            CURLOPT_HTTPHEADER => HeaderConverter::toBuzzHeaders($request->getHeaders()),
        ];

        if (0 !== $version = $this->getProtocolVersion($request)) {
            $options[CURLOPT_HTTP_VERSION] = $version;
        }

        if ($request->getUri()->getUserInfo()) {
            $options[CURLOPT_USERPWD] = $request->getUri()->getUserInfo();
        }

        switch (strtoupper($request->getMethod())) {
            case 'HEAD':
                $options[CURLOPT_NOBODY] = true;

                break;

            case 'GET':
                $options[CURLOPT_HTTPGET] = true;

                break;

            case 'POST':
            case 'PUT':
            case 'DELETE':
            case 'PATCH':
            case 'OPTIONS':
                $body = $request->getBody();
                $bodySize = $body->getSize();
                if (0 !== $bodySize) {
                    if ($body->isSeekable()) {
                        $body->rewind();
                    }

                    // Message has non empty body.
                    if (null === $bodySize || $bodySize > 1024 * 1024) {
                        // Avoid full loading large or unknown size body into memory
                        $options[CURLOPT_UPLOAD] = true;
                        if (null !== $bodySize) {
                            $options[CURLOPT_INFILESIZE] = $bodySize;
                        }
                        $options[CURLOPT_READFUNCTION] = function ($ch, $fd, $length) use ($body) {
                            return $body->read($length);
                        };
                    } else {
                        // Small body can be loaded into memory
                        $options[CURLOPT_POSTFIELDS] = (string) $body;
                    }
                }
        }

        curl_setopt_array($curl, $options);
    }

    /**
     * @param resource $curl
     */
    private function setOptionsFromParameterBag($curl, ParameterBag $options): void
    {
        if (null !== $proxy = $options->get('proxy')) {
            curl_setopt($curl, CURLOPT_PROXY, $proxy);
        }

        $canFollow = !ini_get('safe_mode') && !ini_get('open_basedir') && $options->get('allow_redirects');
        curl_setopt($curl, CURLOPT_FOLLOWLOCATION, $canFollow);
        curl_setopt($curl, CURLOPT_MAXREDIRS, $canFollow ? $options->get('max_redirects') : 0);
        curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, $options->get('verify') ? 1 : 0);
        curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, $options->get('verify') ? 2 : 0);
        if (0 < $options->get('timeout')) {
            curl_setopt($curl, CURLOPT_TIMEOUT, $options->get('timeout'));
        }
    }

    /**
     * @param resource $curl
     *
     * @throws NetworkException
     * @throws RequestException
     * @throws CallbackException
     */
    protected function parseError(RequestInterface $request, int $errno, $curl): void
    {
        switch ($errno) {
            case CURLE_OK:
                // All OK, create a response object
                break;
            case CURLE_COULDNT_RESOLVE_PROXY:
            case CURLE_COULDNT_RESOLVE_HOST:
            case CURLE_COULDNT_CONNECT:
            case CURLE_OPERATION_TIMEOUTED:
            case CURLE_SSL_CONNECT_ERROR:
                throw new NetworkException($request, curl_error($curl), $errno);
            case CURLE_ABORTED_BY_CALLBACK:
                throw new CallbackException($request, curl_error($curl), $errno);
            default:
                throw new RequestException($request, curl_error($curl), $errno);
        }
    }

    private function getProtocolVersion(RequestInterface $request): int
    {
        switch ($request->getProtocolVersion()) {
            case '1.0':
                return CURL_HTTP_VERSION_1_0;
            case '1.1':
                return CURL_HTTP_VERSION_1_1;
            case '2.0':
                if (\defined('CURL_HTTP_VERSION_2_0')) {
                    return CURL_HTTP_VERSION_2_0;
                }

                throw new \UnexpectedValueException('libcurl 7.33 needed for HTTP 2.0 support');
            default:
                return 0;
        }
    }
}
