<?php
/**
 * Elasticsearch PHP Client
 *
 * @link      https://github.com/elastic/elasticsearch-php
 * @copyright Copyright (c) Elasticsearch B.V (https://www.elastic.co)
 * @license   https://opensource.org/licenses/MIT MIT License
 *
 * Licensed to Elasticsearch B.V under one or more agreements.
 * Elasticsearch B.V licenses this file to you under the MIT License.
 * See the LICENSE file in the project root for more information.
 */
declare(strict_types = 1);

namespace Elastic\Elasticsearch;

use Elastic\Elasticsearch\Exception\AuthenticationException;
use Elastic\Elasticsearch\Exception\ConfigException;
use Elastic\Elasticsearch\Exception\HttpClientException;
use Elastic\Elasticsearch\Exception\InvalidArgumentException;
use Elastic\Elasticsearch\Transport\Adapter\AdapterInterface;
use Elastic\Elasticsearch\Transport\Adapter\AdapterOptions;
use Elastic\Elasticsearch\Transport\RequestOptions;
use Elastic\Transport\Exception\NoAsyncClientException;
use Elastic\Transport\NodePool\NodePoolInterface;
use Elastic\Transport\Transport;
use Elastic\Transport\TransportBuilder;
use Http\Client\HttpAsyncClient;
use Psr\Http\Client\ClientInterface;
use Psr\Log\LoggerInterface;
use ReflectionClass;

class ClientBuilder
{
    const DEFAULT_HOST = 'localhost:9200';

    /**
     * PSR-18 client
     */
    private ClientInterface $httpClient;

    /**
     * The HTTP async client
     */
    private HttpAsyncClient $asyncHttpClient;

    /**
     * PSR-3 Logger
     */
    private LoggerInterface $logger;

    /**
     * The NodePool
     */
    private NodePoolInterface $nodePool;

    /**
     * Hosts (elasticsearch nodes)
     */
    private array $hosts;

    /**
     * Elasticsearch API key
     */
    private string $apiKey;

    /**
     * Basic authentication username
     */
    private string $username;

    /**
     * Basic authentication password
     */
    private string $password;

    /**
     * Elastic cloud Id
     */
    private string $cloudId;

    /**
     * Retries
     * 
     * The default value is calculated during the client build
     * and it is equal to the number of hosts
     */
    private int $retries;

    /**
     * SSL certificate 
     * @var array [$cert, $password] $cert is the name of a file containing a PEM formatted certificate,
     *              $password if the certificate requires a password 
     */
    private array $sslCert;

    /**
     * SSL key
     * @var array [$key, $password] $key is the name of a file containing a private SSL key,
     *              $password if the private key requires a password
     */
    private array $sslKey;

    /**
     * SSL verification
     * 
     * Enable or disable the SSL verification (default is true)
     */
    private bool $sslVerification = true;

    /**
     * SSL CA bundle
     */
    private string $sslCA;

    /**
     * Elastic meta header
     * 
     * Enable or disable the x-elastic-client-meta header (default is true)
     */
    private bool $elasticMetaHeader = true;

    /**
     * HTTP client options
     */
    private array $httpClientOptions = [];

    /**
     * Make the constructor final so cannot be overwritten
     */
    final public function __construct()
    {
    }

    /**
     * Create an instance of ClientBuilder
     */
    public static function create(): ClientBuilder
    {
        return new static();
    }

    /**
     * Build a new client from the provided config.  Hash keys
     * should correspond to the method name e.g. ['nodePool']
     * corresponds to setNodePool().
     *
     * Missing keys will use the default for that setting if applicable
     *
     * Unknown keys will throw an exception by default, but this can be silenced
     * by setting `quiet` to true
     *
     * @param  array $config
     * @param  bool $quiet False if unknown settings throw exception, true to silently
     *                     ignore unknown settings
     * @throws ConfigException
     */
    public static function fromConfig(array $config, bool $quiet = false): Client
    {
        $builder = new static;
        foreach ($config as $key => $value) {
            $method = "set$key";
            $reflection = new ReflectionClass($builder);
            if ($reflection->hasMethod($method)) {
                $func = $reflection->getMethod($method);
                if ($func->getNumberOfParameters() > 1) {
                    $builder->$method(...$value);
                } else {
                    $builder->$method($value);
                }
                unset($config[$key]);
            }
        }

        if ($quiet === false && count($config) > 0) {
            $unknown = implode(array_keys($config));
            throw new ConfigException("Unknown parameters provided: $unknown");
        }
        return $builder->build();
    }

    public function setHttpClient(ClientInterface $httpClient): ClientBuilder
    {
        $this->httpClient = $httpClient;
        return $this;
    }

    public function setAsyncHttpClient(HttpAsyncClient $asyncHttpClient): ClientBuilder
    {
        $this->asyncHttpClient = $asyncHttpClient;
        return $this;
    }

    /**
     * Set the PSR-3 Logger
     */
    public function setLogger(LoggerInterface $logger): ClientBuilder
    {
        $this->logger = $logger;
        return $this;
    }

    /**
     * Set the NodePool
     */
    public function setNodePool(NodePoolInterface $nodePool): ClientBuilder
    {
        $this->nodePool = $nodePool;
        return $this;
    }

    /**
     * Set the hosts (nodes)
     */
    public function setHosts(array $hosts): ClientBuilder
    {
        $this->hosts = $hosts;
        return $this;
    }

    /**
     * Set the ApiKey
     * If the id is not specified we store the ApiKey otherwise
     * we store as Base64(id:ApiKey)
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html
     */
    public function setApiKey(string $apiKey, ?string $id = null): ClientBuilder
    {
        if (empty($id)) {
            $this->apiKey = $apiKey;
        } else {
            $this->apiKey = base64_encode($id . ':' . $apiKey);
        }
        return $this;
    }

    /**
     * Set the Basic Authentication
     */
    public function setBasicAuthentication(string $username, string $password): ClientBuilder
    {
        $this->username = $username;
        $this->password = $password;
        return $this;
    }

    public function setElasticCloudId(string $cloudId)
    {
        $this->cloudId = $cloudId;
        return $this;
    }

    /**
     * Set number or retries
     * 
     * @param int $retries
     */
    public function setRetries(int $retries): ClientBuilder
    {
        if ($retries < 0) {
            throw new InvalidArgumentException('The retries number must be >= 0');
        }
        $this->retries = $retries;
        return $this;
    }

    /**
     * Set SSL certificate
     * 
     * @param string $cert The name of a file containing a PEM formatted certificate
     * @param string $password if the certificate requires a password
     */
    public function setSSLCert(string $cert, ?string $password = null): ClientBuilder
    {
        $this->sslCert = [$cert, $password];
        return $this;
    }

    /**
     * Set the Certificate Authority (CA) bundle 
     * 
     * @param string $cert The name of a file containing a PEM formatted certificate
     */
    public function setCABundle(string $cert): ClientBuilder
    {
        $this->sslCA = $cert;
        return $this;
    }

    /**
     * Set SSL key
     * 
     * @param string $key The name of a file containing a private SSL key
     * @param string $password if the private key requires a password
     */
    public function setSSLKey(string $key, ?string $password = null): ClientBuilder
    {
        $this->sslKey = [$key, $password];
        return $this;
    }

    /**
     * Enable or disable the SSL verification 
     */
    public function setSSLVerification(bool $value = true): ClientBuilder
    {
        $this->sslVerification = $value;
        return $this;
    }

    /**
     * Enable or disable the x-elastic-client-meta header
     */
    public function setElasticMetaHeader(bool $value = true): ClientBuilder
    {
        $this->elasticMetaHeader = $value;
        return $this;
    }

    public function setHttpClientOptions(array $options): ClientBuilder
    {
        $this->httpClientOptions = $options;
        return $this;
    }

    /**
     * Build and returns the Client object
     */
    public function build(): Client
    {
        // Transport builder
        $builder = TransportBuilder::create();

        // Set the default hosts if empty
        if (empty($this->hosts)) {
            $this->hosts = [self::DEFAULT_HOST];
        }
        $builder->setHosts($this->hosts);

        // Logger
        if (!empty($this->logger)) {    
            $builder->setLogger($this->logger);
        }

        // Http client
        if (!empty($this->httpClient)) {
            $builder->setClient($this->httpClient);
        }
        // Set HTTP client options
        $builder->setClient(
            $this->setOptions($builder->getClient(), $this->getConfig(), $this->httpClientOptions)
        );

        // Cloud id
        if (!empty($this->cloudId)) {
            $builder->setCloudId($this->cloudId);
        }

        // Node Pool
        if (!empty($this->nodePool)) {
            $builder->setNodePool($this->nodePool);
        }

        $transport = $builder->build();
        
        // The default retries is equal to the number of hosts
        if (empty($this->retries)) {
            $this->retries = count($this->hosts);
        }
        $transport->setRetries($this->retries);

        // Async client
        if (!empty($this->asyncHttpClient)) {
            $transport->setAsyncClient($this->asyncHttpClient);
        }
        
        // Basic authentication
        if (!empty($this->username) && !empty($this->password)) {
            $transport->setUserInfo($this->username, $this->password);
        }

        // API key
        if (!empty($this->apiKey)) {
            if (!empty($this->username)) {
                throw new AuthenticationException('You cannot use APIKey and Basic Authentication together');
            }
            $transport->setHeader('Authorization', sprintf("ApiKey %s", $this->apiKey));
        }

        /**
         * Elastic cloud or serverless optimized with gzip
         * @see https://github.com/elastic/elasticsearch-php/issues/1241 omit for Symfony HTTP Client    
         */
        if ((!empty($this->cloudId) || $this->isCloud($this->hosts) || $this->isServerless($this->hosts)) && !$this->isSymfonyHttpClient($transport)) {
            $transport->setHeader('Accept-Encoding', 'gzip');
        }

        $client = new Client($transport, $transport->getLogger());
        // Enable or disable the x-elastic-client-meta header
        $client->setElasticMetaHeader($this->elasticMetaHeader);

        if ($this->isServerless($this->hosts)) {
            $client->setServerless(true);
        }
        return $client;
    }

    /**
     * Check if the hosts contains an Elastic Cloud url
     */
    protected function isCloud(array $hosts): bool
    {
        if (empty($hosts) || count($hosts)>1) {
            return false;
        }
        $url = $hosts[0];
        // Elastic Cloud gcp
        if (preg_match('/\.cloud\.es\.io/i', $url)) {
            return true;
        }
        // Elastic Cloud aws or azure
        if (preg_match('/\.elastic-cloud\.com/i', $url)) {
            return true;
        }
        return false;
    }

     /**
     * Check if the hosts contains an Elastic Serverless url 
     */
    protected function isServerless(array $hosts): bool
    {
        if (empty($hosts) || count($hosts)>1) {
            return false;
        }
        $url = $hosts[0];
        if (preg_match('/\.elastic\.cloud/i', $url)) {
            return true;
        }
        return false;
    }

    /**
     * Returns true if the transport HTTP client is Symfony
     */
    protected function isSymfonyHttpClient(Transport $transport): bool
    {
        if (false !== strpos(get_class($transport->getClient()), 'Symfony\Component\HttpClient')) {
            return true;
        }
        try {
            if (false !== strpos(get_class($transport->getAsyncClient()), 'Symfony\Component\HttpClient')) {
                return true;
            }
        } catch (NoAsyncClientException $e) {
            return false;
        }
        return false;
    }

    /**
     * Returns the configuration to be used in the HTTP client
     */
    protected function getConfig(): array
    {
        $config = [];
        if (!empty($this->sslCert)) {
            $config[RequestOptions::SSL_CERT] = $this->sslCert;
        }
        if (!empty($this->sslKey)) {
            $config[RequestOptions::SSL_KEY] = $this->sslKey;
        }
        if (!$this->sslVerification) {
            $config[RequestOptions::SSL_VERIFY] = false;
        }
        if (!empty($this->sslCA)) {
            $config[RequestOptions::SSL_CA] = $this->sslCA;
        }
        return $config;
    }

    /**
     * Set the configuration for the specific HTTP client using an adapter
     */
    protected function setOptions(ClientInterface $client, array $config, array $clientOptions = []): ClientInterface
    {
        if (empty($config) && empty($clientOptions)) {
            return $client;
        }
        $class = get_class($client);
        if (!isset(AdapterOptions::HTTP_ADAPTERS[$class])) {
            throw new HttpClientException(sprintf(
                "The HTTP client %s is not supported for custom options",
                $class
            ));
        }
        $adapterClass = AdapterOptions::HTTP_ADAPTERS[$class];
        if (!class_exists($adapterClass) || !in_array(AdapterInterface::class, class_implements($adapterClass))) {
            throw new HttpClientException(sprintf(
                "The class %s does not exists or does not implement %s",
                $adapterClass,
                AdapterInterface::class
            ));
        }
        $adapter = new $adapterClass;
        return $adapter->setConfig($client, $config, $clientOptions);
    }
}
