<?php

declare(strict_types=1);

namespace ComerciaGlobalPayments\AddonPayments\SDK\Api;

use Cascade\Cascade;
use ComerciaGlobalPayments\AddonPayments\SDK\Encryption\Cypher\Aes256Cbc;
use ComerciaGlobalPayments\AddonPayments\SDK\Encryption\Cypher\Aes256Ecb;
use ComerciaGlobalPayments\AddonPayments\SDK\Request\RequestInterface;
use ComerciaGlobalPayments\AddonPayments\SDK\Response\ErrorResponse;
use ComerciaGlobalPayments\AddonPayments\SDK\Utility\NestedArray;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\TransferException;
use Psr\Http\Message\ResponseInterface;
use InvalidArgumentException;
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\ErrorLogHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Processor\PsrLogMessageProcessor;
use Psr\Log\LoggerInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
 * Base class for all API clients implementations.
 *
 * @SuppressWarnings(PHPMD.StaticAccess)
 */
abstract class AbstractApiClient implements ApiClientInterface
{
    public const NAME = 'EPG_API_CLIENT';

    /**
     * @var array[]
     */
    protected $configuration;

    /**
     * @var \GuzzleHttp\ClientInterface
     */
    protected $httpClient;

    /**
     * @var \Psr\Log\LoggerInterface
     */
    protected $logger;

    /**
     * @var \Symfony\Component\Validator\Validator\ValidatorInterface
     */
    protected $validator;

    /**
     * @var \ComerciaGlobalPayments\AddonPayments\SDK\Encryption\JWT\EncoderInterface
     */
    protected $encoder;

    /**
     * AbstractApiClient constructor.
     *
     * @param \Symfony\Component\Validator\Validator\ValidatorInterface|null $validator With annotations enabled
     *
     * @SuppressWarnings(PHPMD.BooleanArgumentFlag)
     */
    public function __construct(
        array $configuration,
        ClientInterface $httpClient = null,
        LoggerInterface $logger = null,
        ValidatorInterface $validator = null
    ) {
        $configuration = NestedArray::mergeDeep($this->getDefaultConfiguration(), $configuration);

        if (empty($configuration['merchantId'])) {
            throw new InvalidArgumentException("Could not create API client instance, 'merchantId' parameter is missing or invalid");
        }
        if (empty($configuration['merchantKey'])) {
            throw new InvalidArgumentException("Could not create API client instance, 'merchantKey' parameter is missing or invalid");
        }
        if (empty($configuration['merchantSecret'])) {
            throw new InvalidArgumentException("Could not create API client instance, 'merchantSecret' parameter is missing or invalid");
        }
        if (!\array_key_exists($configuration['encryption'], self::getAllowedEncryptionMethods())) {
            throw new InvalidArgumentException("Could not create API client instance, 'encryption' parameter is missing or invalid");
        }

        $this->configuration = $configuration;
        $this->httpClient = $httpClient;
        $this->logger = $logger;
        $this->validator = $validator;
    }

    /**
     * @return ResponseInterface
     *
     * @throws \GuzzleHttp\Exception\GuzzleException
     */
    public function sendRequest(
        string $method,
        string $url,
        array $headers = [],
        $payload = null,
        array $options = []
    ) {
        try {
            $method = strtoupper($method);
            $this->getLogger()->debug('Sending {method} request to {url}', [
                'method' => $method,
                'url' => $url,
            ]);

            $options = NestedArray::mergeDeep($options, ['headers' => $headers]);
            if (!empty($payload)) {
                $options['body'] = json_encode($payload);
            }

            /** @var ClientInterface $httpClient */
            $httpClient = $this->getHttpClient();
            // Handle Guzzle 6 compatibility.
            if (method_exists($httpClient, 'createRequest')) {
                $request = $httpClient->createRequest($method, $url, $options);

                return $httpClient->send($request);
            }

            $method = strtolower($method);

            return $httpClient->$method($url, $options);
        } catch (TransferException $e) {
            $this->getLogger()->error('{method} request to {url} failed with {error}, additional response payload {body}', [
                'method' => $method,
                'url' => $url,
                'error' => $e->getMessage(),
                'body' => ($e instanceof RequestException
                    && null !== $e->getResponse()
                    && null !== $e->getResponse()->getBody())
                    ? $e->getResponse()->getBody()->getContents()
                    : 'none',
            ]);
            throw $e;
        }
    }

    public function generateTransactionToken(array $transactionsIds): string
    {
        $transactions = implode(';', $transactionsIds);

        return md5(
            sprintf(
                '%s.%s.%s',
                $this->configuration['merchantId'],
                $transactions,
                $this->configuration['merchantSecret']
            )
        );
    }

    public function getEncryption()
    {
        $class = self::getAllowedEncryptionMethods()[$this->configuration['encryption'] ?? self::DEFAULT_ENCRYPTION];

        return new $class();
    }

    public function isLive(): bool
    {
        return $this->configuration['live'] ?? false;
    }

    public static function getAllowedEncryptionMethods(): array
    {
        // @todo Use some discovery pattern on the future.
        return [
            Aes256Cbc::NAME => Aes256Cbc::class,
            Aes256Ecb::NAME => Aes256Ecb::class,
        ];
    }

    public function getEndpoint(string $operation): string
    {
        $endpoints = static::getEndpoints($this->isLive());
        if (!\array_key_exists($operation, $endpoints)) {
            $operations = array_keys($endpoints);
            $message = sprintf(
                "Invalid operation '%s', allowed operations are '%s'",
                $operation,
                implode("', '", $operations)
            );
            throw new InvalidArgumentException($message);
        }

        return $endpoints[$operation];
    }

    public function validate(RequestInterface $request): ConstraintViolationListInterface
    {
        return $this->getValidator()->validate($request);
    }

    public function getDefaultConfiguration(): array
    {
        return [
            'encryption' => self::DEFAULT_ENCRYPTION,
            'live' => self::DEFAULT_LIVE_MODE,
            'validate' => false,
            'guzzle' => [],
            'logger' => [
                'version' => 1,
                'formatters' => [
                    'spaced' => [
                        'class' => LineFormatter::class,
                        'format' => "%datetime% %channel%.%level_name%  %message%\n",
                        'include_stacktraces' => true,
                    ],
                ],
                'handlers' => [
                    'trace_log' => [
                        'class' => StreamHandler::class,
                        'level' => 'DEBUG',
                        'formatter' => 'spaced',
                        'stream' => '/tmp/epg.log',
                    ],
                    'error_log' => [
                        'class' => ErrorLogHandler::class,
                        'level' => 'ERROR',
                        'formatter' => 'spaced',
                    ],
                ],
                'processors' => [
                    'ps3' => [
                        'class' => PsrLogMessageProcessor::class,
                    ],
                ],
            ],
        ];
    }

    protected function getHttpClient(): ClientInterface
    {
        if (null === $this->httpClient) {
            $this->httpClient = new Client($this->configuration['guzzle'] ?? []);
        }

        return $this->httpClient;
    }

    protected function getLogger(): LoggerInterface
    {
        if (null === $this->logger) {
            $loggerConfig = $this->configuration['logger'];
            // Override any loggers configured and register all handlers and processors.
            $loggerConfig['loggers'] = [
                static::NAME => [
                    'handlers' => array_keys($loggerConfig['handlers']),
                    'processors' => array_keys($loggerConfig['processors']),
                ],
            ];
            Cascade::loadConfigFromArray($loggerConfig);

            $this->logger = Cascade::getLogger(static::NAME);
        }

        return $this->logger;
    }

    protected function getValidator(): ValidatorInterface
    {
        if (null === $this->validator) {
            $this->validator = Validation::createValidatorBuilder()
                ->enableAnnotationMapping()
                ->getValidator();
        }

        return $this->validator;
    }

    /**
     * Log violations for a given operation.
     *
     * @param string                                                        $name       The API name
     * @param string                                                        $operation  The operation being executed
     * @param \Symfony\Component\Validator\ConstraintViolationListInterface $violations The request model violatons
     */
    protected function logOperationViolations(
        string $name,
        string $operation,
        ConstraintViolationListInterface $violations
    ): void {
        $this->getLogger()->error(
            'Operation {api}/{op} request is invalid',
            [
                'api' => $name,
                'op' => $operation,
            ]
        );
        /** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */
        foreach ($violations as $violation) {
            $this->getLogger()->debug(
                '{api}/{op}/{property}: {message}',
                [
                    'api' => $name,
                    'op' => $operation,
                    'property' => $violation->getPropertyPath(),
                    'message' => $violation->getMessage(),
                ]
            );
        }
    }

    /**
     * Execute request validation and throw exception on failure.
     */
    protected function doValidate(RequestInterface $request, string $operation): void
    {
        if (false === $this->configuration['validate']) {
            return;
        }

        $violations = $this->validate($request);
        if (0 < \count($violations)) {
            $this->logOperationViolations(
                static::NAME,
                $operation,
                $violations
            );
            throw new InvalidArgumentException('Operation canceled, invalid request supplied');
        }
    }

    /**
     * Handle guzzle exceptions and wrap them as responses.
     */
    protected function handleApiException(\Exception $exception): ErrorResponse
    {
        // No need to log errors here, the ::sendRequest method already
        // logged them, wrap up the error on a response and return it.
        $response = new ErrorResponse();
        $response->setReason($exception->getMessage());

        if (!$exception instanceof RequestException) {
            return $response;
        }

        if (!$exception->hasResponse()) {
            return $response;
        }

        $response->setHttpResponse($exception->getResponse());

        return $response;
    }
}
