<?php
/*
 * This file is autogenerated, DO NOT EDIT
 */

declare(strict_types=1);
        

/**
 * PHP Library for WebToPay provided services.
 * Copyright (C) 2012 http://www.webtopay.com/
 *
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as published by the
 * Free Software Foundation, either version 3 of the License, or (at your
 * option) any later version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 *
 * @package    WebToPay
 * @author     EVP International
 * @license    http://www.gnu.org/licenses/lgpl.html
 * @version    3.0.1
 * @link       http://www.webtopay.com/
 */

/**
 * Contains static methods for most used scenarios.
 */
class WebToPay
{
    /**
     * WebToPay Library version.
     */
    public const VERSION = '3.0.1';

    /**
     * Server URL where all requests should go.
     */
    public const PAY_URL = 'https://bank.paysera.com/pay/';

    /**
     * Server URL where all non-lithuanian language requests should go.
     */
    public const PAYSERA_PAY_URL = 'https://bank.paysera.com/pay/';

    /**
     * Server URL where we can get XML with payment method data.
     */
    public const XML_URL = 'https://www.paysera.com/new/api/paymentMethods/';

    /**
     * SMS answer url.
     *
     * @deprecated
     */
    public const SMS_ANSWER_URL = 'https://bank.paysera.com/psms/respond/';

    /**
     * Builds request data array.
     *
     * This method checks all given data and generates correct request data
     * array or raises WebToPayException on failure.
     *
     * Possible keys:
     * https://developers.paysera.com/en/checkout/integrations/integration-specification
     *
     * @param array<string, mixed> $data Information about current payment request
     *
     * @return array<string, mixed>
     *
     * @throws WebToPayException on data validation error
     */
    public static function buildRequest(array $data): array
    {
        self::checkRequiredParameters($data);

        $password = $data['sign_password'];
        $projectId = $data['projectid'];
        unset($data['sign_password']);
        unset($data['projectid']);

        $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]);
        $requestBuilder = $factory->getRequestBuilder();

        return $requestBuilder->buildRequest($data);
    }

    /**
     * Builds request and redirects user to payment window with generated request data
     *
     * Possible array keys are described here:
     * https://developers.paysera.com/en/checkout/integrations/integration-specification
     *
     * @param  array<string, mixed> $data Information about current payment request.
     * @param  boolean $exit if true, exits after sending Location header; default false
     *
     * @throws WebToPayException on data validation error
     */
    public static function redirectToPayment(array $data, bool $exit = false): void
    {
        self::checkRequiredParameters($data);

        $password = $data['sign_password'];
        $projectId = $data['projectid'];
        unset($data['sign_password']);
        unset($data['projectid']);

        $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]);
        $url = $factory->getRequestBuilder()
            ->buildRequestUrlFromData($data);

        if (WebToPay_Functions::headers_sent()) {
            echo '<script type="text/javascript">window.location = "' . addslashes($url) . '";</script>';
        } else {
            header("Location: $url", true);
        }
        printf(
            'Redirecting to <a href="%s">%s</a>. Please wait.',
            htmlentities($url, ENT_QUOTES, 'UTF-8'),
            htmlentities($url, ENT_QUOTES, 'UTF-8')
        );
        if ($exit) {
            // @codeCoverageIgnoreStart
            exit();
            // @codeCoverageIgnoreEnd
        }
    }

    /**
     * Builds repeat request data array.
     *
     * This method checks all given data and generates correct request data
     * array or raises WebToPayException on failure.
     *
     * Method accepts single parameter $data of array type. All possible array
     * keys are described here:
     * https://developers.paysera.com/en/checkout/integrations/integration-specification
     *
     * @param  array<string, mixed> $data Information about current payment request
     *
     * @return array<string, mixed>
     *
     * @throws WebToPayException on data validation error
     */
    public static function buildRepeatRequest(array $data): array
    {
        if (!isset($data['sign_password']) || !isset($data['projectid']) || !isset($data['orderid'])) {
            throw new WebToPayException('sign_password, projectid or orderid is not provided');
        }
        $password = $data['sign_password'];
        $projectId = $data['projectid'];
        $orderId = $data['orderid'];

        $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]);
        $requestBuilder = $factory->getRequestBuilder();

        return $requestBuilder->buildRepeatRequest($orderId);
    }

    /**
     * Returns payment url. Argument is same as lang parameter in request data
     *
     * @param string $language
     * @return string $url
     */
    public static function getPaymentUrl(string $language = 'LIT'): string
    {
        return (in_array($language, ['lt', 'lit', 'LIT'], true))
            ? self::PAY_URL
            : self::PAYSERA_PAY_URL;
    }

    /**
     * Parses request (query) data and validates its signature.
     *
     * @param array<string, string> $query usually $_GET
     * @param int|null $projectId
     * @param string|null $password
     *
     * @return array<string, string>
     *
     * @throws WebToPayException
     * @throws WebToPay_Exception_Callback
     * @throws WebToPay_Exception_Configuration
     */
    public static function validateAndParseData(array $query, ?int $projectId, ?string $password): array
    {
        $factory = new WebToPay_Factory(['projectId' => $projectId, 'password' => $password]);
        $validator = $factory->getCallbackValidator();

        return $validator->validateAndParseData($query);
    }

    /**
     * Sends SMS answer
     *
     * @param array<string, mixed> $userData
     *
     * @throws WebToPayException
     * @throws WebToPay_Exception_Validation
     *
     * @deprecated
     * @codeCoverageIgnore
     */
    public static function smsAnswer(array $userData): void
    {
        if (!isset($userData['id']) || !isset($userData['msg']) || !isset($userData['sign_password'])) {
            throw new WebToPay_Exception_Validation('id, msg and sign_password are required');
        }

        $smsId = $userData['id'];
        $text = $userData['msg'];
        $password = $userData['sign_password'];
        $logFile = $userData['log'] ?? null;

        try {

            $factory = new WebToPay_Factory(['password' => $password]);
            $factory->getSmsAnswerSender()->sendAnswer($smsId, $text);

            if ($logFile) {
                self::log('OK', 'SMS ANSWER ' . $smsId . ' ' . $text, $logFile);
            }

        } catch (WebToPayException $e) {
            if ($logFile) {
                self::log('ERR', 'SMS ANSWER ' . $e, $logFile);
            }
            throw $e;
        }
    }

    /**
     * Gets available payment methods for project. Gets methods min and max amounts in specified currency.
     *
     * @throws WebToPayException
     * @throws WebToPay_Exception_Configuration
     */
    public static function getPaymentMethodList(
        int $projectId,
        ?float $amount,
        ?string $currency = 'EUR'
    ): WebToPay_PaymentMethodList {
        $factory = new WebToPay_Factory(['projectId' => $projectId]);

        return $factory->getPaymentMethodListProvider()->getPaymentMethodList($amount, $currency);
    }

    /**
     * Logs to file. Just skips logging if file is not writeable
     *
     * @deprecated
     * @codeCoverageIgnore
     */
    protected static function log(string $type, string $msg, string $logfile): void
    {
        $fp = @fopen($logfile, 'a');
        if (!$fp) {
            return;
        }

        $logline = [
            $type,
            $_SERVER['REMOTE_ADDR'] ?? '-',
            date('[Y-m-d H:i:s O]'),
            'v' . self::VERSION . ':',
            $msg,
        ];

        $logline = implode(' ', $logline)."\n";
        fwrite($fp, $logline);
        fclose($fp);

        // clear big log file
        if (filesize($logfile) > 1024 * 1024 * pi()) {
            copy($logfile, $logfile.'.old');
            unlink($logfile);
        }
    }

    /**
     * @param array<string, mixed> $data
     *
     * @throws WebToPayException
     */
    protected static function checkRequiredParameters(array $data): void
    {
        if (!isset($data['sign_password']) || !isset($data['projectid'])) {
            throw new WebToPayException('sign_password or projectid is not provided');
        }
    }
}


/**
 * Base exception class for all exceptions in this library
 */
class WebToPayException extends Exception
{
    /**
     * Missing field.
     */
    public const E_MISSING = 1;

    /**
     * Invalid field value.
     */
    public const E_INVALID = 2;

    /**
     * Max length exceeded.
     */
    public const E_MAXLEN = 3;

    /**
     * Regexp for field value doesn't match.
     */
    public const E_REGEXP = 4;

    /**
     * Missing or invalid user given parameters.
     */
    public const E_USER_PARAMS = 5;

    /**
     * Logging errors
     */
    public const E_LOG = 6;

    /**
     * SMS answer errors
     */
    public const E_SMS_ANSWER = 7;

    /**
     * Macro answer errors
     */
    public const E_STATUS = 8;

    /**
     * Library errors - if this happens, bug-report should be sent; also you can check for newer version
     */
    public const E_LIBRARY = 9;

    /**
     * Errors in remote service - it returns some invalid data
     */
    public const E_SERVICE = 10;

    /**
     * Deprecated usage errors
     */
    public const E_DEPRECATED_USAGE = 11;

    protected ?string $fieldName = null;

    /**
     * Sets field which failed
     */
    public function setField(?string $fieldName): void
    {
        $this->fieldName = $fieldName;
    }

    /**
     * Gets field which failed
     */
    public function getField(): ?string
    {
        return $this->fieldName;
    }
}


/**
 * Class to hold information about payment method
 */
class WebToPay_PaymentMethod
{
    /**
     * Assigned key for this payment method
     */
    protected string $key;

    protected ?int $minAmount;

    protected ?int $maxAmount;

    protected ?string $currency;

    /**
     * Logo url list by language. Usually logo is same for all languages, but exceptions exist
     *
     * @var array<string, string>
     */
    protected array $logoList;

    /**
     * Title list by language
     *
     * @var array<string, string>
     */
    protected array $titleTranslations;

    /**
     * Default language to use for titles
     */
    protected string $defaultLanguage;

    protected bool $isIban;

    protected ?string $baseCurrency;

    /**
     * Constructs object
     *
     * @param string $key
     * @param integer|null $minAmount
     * @param integer|null $maxAmount
     * @param string|null $currency
     * @param array<string, string> $logoList
     * @param array<string, string> $titleTranslations
     * @param string $defaultLanguage
     * @param bool $isIban
     * @param string|null $baseCurrency
     */
    public function __construct(
        string $key,
        ?int $minAmount,
        ?int $maxAmount,
        ?string $currency,
        array $logoList = [],
        array $titleTranslations = [],
        string $defaultLanguage = 'lt',
        bool $isIban = false,
        ?string $baseCurrency = null
    ) {
        $this->key = $key;
        $this->minAmount = $minAmount;
        $this->maxAmount = $maxAmount;
        $this->currency = $currency;
        $this->logoList = $logoList;
        $this->titleTranslations = $titleTranslations;
        $this->setDefaultLanguage($defaultLanguage);
        $this->setIsIban($isIban);
        $this->setBaseCurrency($baseCurrency);
    }

    /**
     * Sets default language for titles.
     * Returns itself for fluent interface
     */
    public function setDefaultLanguage(string $language): WebToPay_PaymentMethod
    {
        $this->defaultLanguage = $language;

        return $this;
    }

    /**
     * Gets default language for titles
     */
    public function getDefaultLanguage(): string
    {
        return $this->defaultLanguage;
    }

    /**
     * Get assigned payment method key
     */
    public function getKey(): string
    {
        return $this->key;
    }

    /**
     * Gets logo url for this payment method. Uses specified language or default one.
     * If logotype is not found for specified language, null is returned.
     */
    public function getLogoUrl(?string $languageCode = null): ?string
    {
        if ($languageCode !== null && isset($this->logoList[$languageCode])) {
            return $this->logoList[$languageCode];
        } elseif (isset($this->logoList[$this->defaultLanguage])) {
            return $this->logoList[$this->defaultLanguage];
        } else {
            return null;
        }
    }

    /**
     * Gets title for this payment method. Uses specified language or default one.
     */
    public function getTitle(?string $languageCode = null): string
    {
        if ($languageCode !== null && isset($this->titleTranslations[$languageCode])) {
            return $this->titleTranslations[$languageCode];
        } elseif (isset($this->titleTranslations[$this->defaultLanguage])) {
            return $this->titleTranslations[$this->defaultLanguage];
        } else {
            return $this->key;
        }
    }

    /**
     * Checks if this payment method can be used for specified amount.
     * Throws exception if currency checked is not the one, for which payment method list was downloaded.
     *
     * @throws WebToPayException
     */
    public function isAvailableForAmount(int $amount, string $currency): bool
    {
        if ($this->currency !== $currency) {
            throw new WebToPayException(
                'Currencies does not match. You have to get payment types for the currency you are checking. Given currency: '
                    . $currency . ', available currency: ' . $this->currency
            );
        }

        return (
            ($this->minAmount === null || $amount >= $this->minAmount)
            && ($this->maxAmount === null || $amount <= $this->maxAmount)
        );
    }

    /**
     * Returns min amount for this payment method. If no min amount is specified, returns empty string.
     */
    public function getMinAmountAsString(): string
    {
        return $this->minAmount === null ? '' : ($this->minAmount . ' ' . $this->currency);
    }

    /**
     * Returns max amount for this payment method. If no max amount is specified, returns empty string.
     */
    public function getMaxAmountAsString(): string
    {
        return $this->maxAmount === null ? '' : ($this->maxAmount . ' ' . $this->currency);
    }

    /**
     * Set if this method returns IBAN number after payment
     */
    public function setIsIban(bool $isIban): void
    {
        $this->isIban = $isIban;
    }

    /**
     * Get if this method returns IBAN number after payment
     */
    public function isIban(): bool
    {
        return $this->isIban;
    }

    /**
     * Setter of BaseCurrency
     */
    public function setBaseCurrency(?string $baseCurrency): void
    {
        $this->baseCurrency = $baseCurrency;
    }

    /**
     * Getter of BaseCurrency
     */
    public function getBaseCurrency(): ?string
    {
        return $this->baseCurrency;
    }
}


/**
 * Utility class
 */
class WebToPay_Util
{
    public const GCM_CIPHER = 'aes-256-gcm';
    public const GCM_AUTH_KEY_LENGTH = 16;

    /**
     * Decodes url-safe-base64 encoded string
     * Url-safe-base64 is same as base64, but + is replaced to - and / to _
     */
    public function decodeSafeUrlBase64(string $encodedText): string
    {
        return (string) base64_decode(strtr($encodedText, '-_', '+/'), true);
    }

    /**
     * Encodes string to url-safe-base64
     * Url-safe-base64 is same as base64, but + is replaced to - and / to _
     */
    public function encodeSafeUrlBase64(string $text): string
    {
        return strtr(base64_encode($text), '+/', '-_');
    }

    /**
     * Decrypts string with aes-256-gcm algorithm
     */
    public function decryptGCM(string $stringToDecrypt, string $key): ?string
    {
        $ivLength = (int) openssl_cipher_iv_length(self::GCM_CIPHER);
        $iv = substr($stringToDecrypt, 0, $ivLength);
        $ciphertext = substr($stringToDecrypt, $ivLength, -self::GCM_AUTH_KEY_LENGTH);
        $tag = substr($stringToDecrypt, -self::GCM_AUTH_KEY_LENGTH);

        $decryptedText = openssl_decrypt(
            $ciphertext,
            self::GCM_CIPHER,
            $key,
            OPENSSL_RAW_DATA,
            $iv,
            $tag
        );

        return $decryptedText === false ? null : $decryptedText;
    }

    /**
     * Parses HTTP query to array
     *
     * @param string $query
     *
     * @return array<int|string, mixed>
     */
    public function parseHttpQuery(string $query): array
    {
        $params = [];
        parse_str($query, $params);

        return $params;
    }
}


/**
 * Raised on validation error in passed data when building the request
 */
class WebToPay_Exception_Validation extends WebToPayException
{
    public function __construct(
        string $message,
        int $code = 0,
        ?string $field = null,
        ?Exception $previousException = null
    ) {
        parent::__construct($message, $code, $previousException);
        if ($field) {
            $this->setField($field);
        }
    }
}


/**
 * Raised on error in callback
 */
class WebToPay_Exception_Callback extends WebToPayException
{
}


/**
 * Raised if configuration is incorrect
 */
class WebToPay_Exception_Configuration extends WebToPayException
{
}


/**
 * The class is used for manipulating with behavior of functions in the global namespace.
 * It is used for testing purposes. No payload.
 *
 * @codeCoverageIgnore
 */
class WebToPay_Functions
{
    public static function function_exists(string $functionName): bool
    {
        return \function_exists($functionName);
    }

    public static function headers_sent(): bool
    {
        return \headers_sent();
    }
}


/**
 * Payment method configuration for some country
 */
class WebToPay_PaymentMethodCountry
{
    protected string $countryCode;

    /**
     * Holds available payment types for this country
     *
     * @var WebToPay_PaymentMethodGroup[]
     */
    protected array $groups;

    /**
     * Default language for titles
     */
    protected string $defaultLanguage;

    /**
     * Translations array for this country. Holds associative array of country title by language codes.
     *
     * @var array<string, string>
     */
    protected array $titleTranslations;

    /**
     * Constructs object
     *
     * @param string $countryCode
     * @param array<string, string> $titleTranslations
     * @param string $defaultLanguage
     */
    public function __construct(string $countryCode, array $titleTranslations, string $defaultLanguage = 'lt')
    {
        $this->countryCode = $countryCode;
        $this->defaultLanguage = $defaultLanguage;
        $this->titleTranslations = $titleTranslations;
        $this->groups = [];
    }

    /**
     * Sets default language for titles.
     * Returns itself for fluent interface
     */
    public function setDefaultLanguage(string $language): WebToPay_PaymentMethodCountry
    {
        $this->defaultLanguage = $language;
        foreach ($this->groups as $group) {
            $group->setDefaultLanguage($language);
        }

        return $this;
    }

    /**
     * Gets title of the group. Tries to get title in specified language. If it is not found or if language is not
     * specified, uses default language, given to constructor.
     */
    public function getTitle(?string $languageCode = null): string
    {
        if ($languageCode !== null && isset($this->titleTranslations[$languageCode])) {
            return $this->titleTranslations[$languageCode];
        } elseif (isset($this->titleTranslations[$this->defaultLanguage])) {
            return $this->titleTranslations[$this->defaultLanguage];
        } else {
            return $this->countryCode;
        }
    }

    /**
     * Gets default language for titles
     */
    public function getDefaultLanguage(): string
    {
        return $this->defaultLanguage;
    }

    /**
     * Gets country code
     */
    public function getCode(): string
    {
        return $this->countryCode;
    }

    /**
     * Adds new group to payment methods for this country.
     * If some other group was registered earlier with same key, overwrites it.
     * Returns given group
     */
    public function addGroup(WebToPay_PaymentMethodGroup $group): WebToPay_PaymentMethodGroup
    {
        return $this->groups[$group->getKey()] = $group;
    }

    /**
     * Gets group object with specified group key. If no group with such key is found, returns null.
     */
    public function getGroup(string $groupKey): ?WebToPay_PaymentMethodGroup
    {
        return $this->groups[$groupKey] ?? null;
    }

    /**
     * Returns payment method groups registered for this country.
     *
     * @return WebToPay_PaymentMethodGroup[]
     */
    public function getGroups(): array
    {
        return $this->groups;
    }

    /**
     * Gets payment methods in all groups
     *
     * @return WebToPay_PaymentMethod[]
     */
    public function getPaymentMethods(): array
    {
        $paymentMethods = [];
        foreach ($this->groups as $group) {
            $paymentMethods = array_merge($paymentMethods, $group->getPaymentMethods());
        }

        return $paymentMethods;
    }

    /**
     * Returns new country instance with only those payment methods, which are available for provided amount.
     */
    public function filterForAmount(int $amount, string $currency): WebToPay_PaymentMethodCountry
    {
        $country = new WebToPay_PaymentMethodCountry($this->countryCode, $this->titleTranslations, $this->defaultLanguage);
        foreach ($this->getGroups() as $group) {
            $group = $group->filterForAmount($amount, $currency);
            if (!$group->isEmpty()) {
                $country->addGroup($group);
            }
        }

        return $country;
    }

    /**
     * Returns new country instance with only those payment methods, which are returns or not iban number after payment
     */
    public function filterForIban(bool $isIban = true): WebToPay_PaymentMethodCountry
    {
        $country = new WebToPay_PaymentMethodCountry(
            $this->countryCode,
            $this->titleTranslations,
            $this->defaultLanguage
        );

        foreach ($this->getGroups() as $group) {
            $group = $group->filterForIban($isIban);
            if (!$group->isEmpty()) {
                $country->addGroup($group);
            }
        }

        return $country;
    }

    /**
     * Returns whether this country has no groups
     */
    public function isEmpty(): bool
    {
        return count($this->groups) === 0;
    }

    /**
     * Loads groups from given XML node
     */
    public function fromXmlNode(SimpleXMLElement $countryNode): void
    {
        foreach ($countryNode->payment_group as $groupNode) {
            $key = (string) $groupNode->attributes()->key;
            $titleTranslations = [];
            foreach ($groupNode->title as $titleNode) {
                $titleTranslations[(string) $titleNode->attributes()->language] = (string) $titleNode;
            }
            $this->addGroup($this->createGroup($key, $titleTranslations))->fromXmlNode($groupNode);
        }
    }

    /**
     * Method to create new group instances. Overwrite if you have to use some other group subtype.
     *
     * @param string $groupKey
     * @param array<string, string> $translations
     *
     * @return WebToPay_PaymentMethodGroup
     */
    protected function createGroup(string $groupKey, array $translations = []): WebToPay_PaymentMethodGroup
    {
        return new WebToPay_PaymentMethodGroup($groupKey, $translations, $this->defaultLanguage);
    }
}


/**
 * Builds and signs requests
 */
class WebToPay_RequestBuilder
{
    private const REQUEST_SPECS = [
        ['orderid', 40, true, ''],
        ['accepturl', 255, true, ''],
        ['cancelurl', 255, true, ''],
        ['callbackurl', 255, true, ''],
        ['lang', 3, false, '/^[a-z]{3}$/i'],
        ['amount', 11, false, '/^\d+$/'],
        ['currency', 3, false, '/^[a-z]{3}$/i'],
        ['payment', 20, false, ''],
        ['country', 2, false, '/^[a-z_]{2}$/i'],
        ['paytext', 255, false, ''],
        ['p_firstname', 255, false, ''],
        ['p_lastname', 255, false, ''],
        ['p_email', 255, false, ''],
        ['p_street', 255, false, ''],
        ['p_city', 255, false, ''],
        ['p_state', 255, false, ''],
        ['p_zip', 20, false, ''],
        ['p_countrycode', 2, false, '/^[a-z]{2}$/i'],
        ['test', 1, false, '/^[01]$/'],
        ['time_limit', 19, false, '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/'],
    ];

    protected string $projectPassword;

    protected WebToPay_Util $util;

    protected int $projectId;

    protected WebToPay_UrlBuilder $urlBuilder;

    /**
     * Constructs object
     */
    public function __construct(
        int $projectId,
        string $projectPassword,
        WebToPay_Util $util,
        WebToPay_UrlBuilder $urlBuilder
    ) {
        $this->projectId = $projectId;
        $this->projectPassword = $projectPassword;
        $this->util = $util;
        $this->urlBuilder = $urlBuilder;
    }

    /**
     * Builds request data array.
     *
     * This method checks all given data and generates correct request data
     * array or raises WebToPayException on failure.
     *
     * @param array<string, mixed> $data information about current payment request
     *
     * @return array<string, mixed>
     *
     * @throws WebToPayException
     */
    public function buildRequest(array $data): array
    {
        $this->validateRequest($data);
        $data['version'] = WebToPay::VERSION;
        $data['projectid'] = $this->projectId;
        unset($data['repeat_request']);

        return $this->createRequest($data);
    }

    /**
     * Builds the full request url (including the protocol and the domain)
     *
     * @param array<string, mixed> $data
     * @return string
     * @throws WebToPayException
     */
    public function buildRequestUrlFromData(array $data): string
    {
        $request = $this->buildRequest($data);

        return $this->urlBuilder->buildForRequest($request);
    }

    /**
     * Builds repeat request data array.
     *
     * This method checks all given data and generates correct request data
     * array or raises WebToPayException on failure.
     *
     * @param int $orderId order id of repeated request
     *
     * @return array<string, mixed>
     *
     * @throws WebToPayException
     */
    public function buildRepeatRequest(int $orderId): array
    {
        $data['orderid'] = $orderId;
        $data['version'] = WebToPay::VERSION;
        $data['projectid'] = $this->projectId;
        $data['repeat_request'] = '1';

        return $this->createRequest($data);
    }

    /**
     * Builds the full request url for a repeated request (including the protocol and the domain)
     *
     * @throws WebToPayException
     */
    public function buildRepeatRequestUrlFromOrderId(int $orderId): string
    {
        $request = $this->buildRepeatRequest($orderId);

        return $this->urlBuilder->buildForRequest($request);
    }

    /**
     * Checks data to be valid by passed specification
     *
     * @param array<string, mixed> $data
     *
     * @throws WebToPay_Exception_Validation
     */
    protected function validateRequest(array $data): void
    {
        foreach (self::REQUEST_SPECS as $spec) {
            [$name, $maxlen, $required, $regexp] = $spec;

            if ($required && empty($data[$name])) {
                throw new WebToPay_Exception_Validation(
                    sprintf("'%s' is required but missing.", $name),
                    WebToPayException::E_MISSING,
                    $name
                );
            }

            if (!empty($data[$name])) {
                if (strlen((string) $data[$name]) > $maxlen) {
                    throw new WebToPay_Exception_Validation(sprintf(
                        "'%s' value is too long (%d), %d characters allowed.",
                        $name,
                        strlen((string) $data[$name]),
                        $maxlen
                    ), WebToPayException::E_MAXLEN, $name);
                }

                if ($regexp !== ''  && !preg_match($regexp, (string) $data[$name])) {
                    throw new WebToPay_Exception_Validation(
                        sprintf("'%s' value '%s' is invalid.", $name, $data[$name]),
                        WebToPayException::E_REGEXP,
                        $name
                    );
                }
            }
        }
    }

    /**
     * Makes request data array from parameters, also generates signature
     *
     * @param array<string, mixed> $request
     *
     * @return array<string, mixed>
     */
    protected function createRequest(array $request): array
    {
        $data = $this->util->encodeSafeUrlBase64(http_build_query($request, '', '&'));

        return [
            'data' => $data,
            'sign' => md5($data . $this->projectPassword),
        ];
    }
}


/**
 * Simple web client
 */
class WebToPay_WebClient
{
    /**
     * Gets page contents by specified URI. Adds query data if provided to the URI
     * Ignores status code of the response and header fields
     *
     * @param string $uri
     * @param array<string, mixed> $queryData
     *
     * @return string
     * @throws WebToPayException
     */
    public function get(string $uri, array $queryData = []): string
    {
        if (count($queryData) > 0) {
            $uri .= strpos($uri, '?') === false ? '?' : '&';
            $uri .= http_build_query($queryData, '', '&');
        }
        $url = parse_url($uri);
        if ('https' === ($url['scheme'] ?? '')) {
            $host = 'ssl://' . ($url['host'] ?? '');
            $port = 443;
        } else {
            $host = $url['host'] ?? '';
            $port = 80;
        }

        $fp = $this->openSocket($host, $port, $errno, $errstr, 30);
        if (!$fp) {
            throw new WebToPayException(sprintf('Cannot connect to %s', $uri), WebToPayException::E_INVALID);
        }

        if(isset($url['query'])) {
            $data = ($url['path'] ?? '') . '?' . $url['query'];
        } else {
            $data = ($url['path'] ?? '');
        }

        $out = "GET " . $data . " HTTP/1.0\r\n";
        $out .= "Host: " . ($url['host'] ?? '') . "\r\n";
        $out .= "Connection: Close\r\n\r\n";

        $content = $this->getContentFromSocket($fp, $out);

        // Separate header and content
        [$header, $content] = explode("\r\n\r\n", $content, 2);

        return trim($content);
    }

    /**
     * @param string $host
     * @param int $port
     * @param int $errno
     * @param string $errstr
     * @param float $timeout
     * @return false|resource
     */
    protected function openSocket(string $host, int $port, &$errno, &$errstr, float $timeout = 30)
    {
        return fsockopen($host, $port, $errno, $errstr, $timeout);
    }

    /**
     * @param resource $fp
     * @param string $out
     *
     * @return string
     */
    protected function getContentFromSocket($fp, string $out): string
    {
        fwrite($fp, $out);
        $content = (string) stream_get_contents($fp);
        fclose($fp);

        return $content;
    }
}


/**
 * Sends answer to SMS payment if it was not provided with response to callback
 */
class WebToPay_SmsAnswerSender
{
    protected string $password;

    protected WebToPay_WebClient $webClient;

    protected WebToPay_UrlBuilder $urlBuilder;

    /**
     * Constructs object
     */
    public function __construct(string $password, WebToPay_WebClient $webClient, WebToPay_UrlBuilder $urlBuilder)
    {
        $this->password = $password;
        $this->webClient = $webClient;
        $this->urlBuilder = $urlBuilder;
    }

    /**
     * Sends answer by sms ID get from callback. Answer can be sent only if it was not provided
     * when responding to the callback
     *
     * @throws WebToPayException
     *
     * @codeCoverageIgnore
     */
    public function sendAnswer(int $smsId, string $text): void
    {
        $content = $this->webClient->get($this->urlBuilder->buildForSmsAnswer(), [
            'id' => $smsId,
            'msg' => $text,
            'transaction' => md5($this->password . '|' . $smsId),
        ]);

        if (strpos($content, 'OK') !== 0) {
            throw new WebToPayException(
                sprintf('Error: %s', $content),
                WebToPayException::E_SMS_ANSWER
            );
        }
    }
}


/**
 * Class with all information about available payment methods for some project, optionally filtered by some amount.
 */
class WebToPay_PaymentMethodList
{
    /**
     * Holds available payment countries
     *
     * @var WebToPay_PaymentMethodCountry[]
     */
    protected array $countries;

    /**
     * Default language for titles
     */
    protected string $defaultLanguage;

    /**
     * Project ID, to which this method list is valid
     */
    protected int $projectId;

    /**
     * Currency for min and max amounts in this list
     */
    protected string $currency;

    /**
     * If this list is filtered for some amount, this field defines it
     */
    protected ?int $amount;

    /**
     * Constructs object
     *
     * @param int $projectId
     * @param string $currency currency for min and max amounts in this list
     * @param string $defaultLanguage
     * @param int|null $amount null if this list is not filtered by amount
     */
    public function __construct(int $projectId, string $currency, string $defaultLanguage = 'lt', ?int $amount = null)
    {
        $this->projectId = $projectId;
        $this->countries = [];
        $this->defaultLanguage = $defaultLanguage;
        $this->currency = $currency;
        $this->amount = $amount;
    }

    /**
     * Sets default language for titles.
     * Returns itself for fluent interface
     */
    public function setDefaultLanguage(string $language): WebToPay_PaymentMethodList
    {
        $this->defaultLanguage = $language;
        foreach ($this->countries as $country) {
            $country->setDefaultLanguage($language);
        }

        return $this;
    }

    /**
     * Gets default language for titles
     */
    public function getDefaultLanguage(): string
    {
        return $this->defaultLanguage;
    }

    /**
     * Gets project ID for this payment method list
     */
    public function getProjectId(): int
    {
        return $this->projectId;
    }

    /**
     * Gets currency for min and max amounts in this list
     */
    public function getCurrency(): string
    {
        return $this->currency;
    }

    /**
     * Gets whether this list is already filtered for some amount
     */
    public function isFiltered(): bool
    {
        return $this->amount !== null;
    }

    /**
     * Returns available countries
     *
     * @return WebToPay_PaymentMethodCountry[]
     */
    public function getCountries(): array
    {
        return $this->countries;
    }

    /**
     * Adds new country to payment methods. If some other country with same code was registered earlier, overwrites it.
     * Returns added country instance
     */
    public function addCountry(WebToPay_PaymentMethodCountry $country): WebToPay_PaymentMethodCountry
    {
        return $this->countries[$country->getCode()] = $country;
    }

    /**
     * Gets country object with specified country code. If no country with such country code is found, returns null.
     */
    public function getCountry(string $countryCode): ?WebToPay_PaymentMethodCountry
    {
        return $this->countries[$countryCode] ?? null;
    }

    /**
     * Returns new payment method list instance with only those payment methods, which are available for provided
     * amount.
     * Returns itself, if list is already filtered and filter amount matches the given one.
     *
     * @throws WebToPayException    if this list is already filtered and not for provided amount
     */
    public function filterForAmount(int $amount, string $currency): WebToPay_PaymentMethodList
    {
        if ($currency !== $this->currency) {
            throw new WebToPayException(
                'Currencies do not match. Given currency: '
                . $currency
                . ', currency in list: '
                . $this->currency
            );
        }
        if ($this->isFiltered()) {
            if ($this->amount === $amount) {
                return $this;
            } else {
                throw new WebToPayException('This list is already filtered, use unfiltered list instead');
            }
        } else {
            $list = new WebToPay_PaymentMethodList($this->projectId, $currency, $this->defaultLanguage, $amount);
            foreach ($this->getCountries() as $country) {
                $country = $country->filterForAmount($amount, $currency);
                if (!$country->isEmpty()) {
                    $list->addCountry($country);
                }
            }

            return $list;
        }
    }

    /**
     * Loads countries from given XML node
     */
    public function fromXmlNode(SimpleXMLElement $xmlNode): void
    {
        foreach ($xmlNode->country as $countryNode) {
            $titleTranslations = [];
            foreach ($countryNode->title as $titleNode) {
                $titleTranslations[(string)$titleNode->attributes()->language] = (string)$titleNode;
            }
            $this->addCountry($this->createCountry((string)$countryNode->attributes()->code, $titleTranslations))
                ->fromXmlNode($countryNode);
        }
    }

    /**
     * Method to create new country instances. Overwrite if you have to use some other country subtype.
     *
     * @param string $countryCode
     * @param array<string, string> $titleTranslations
     *
     * @return WebToPay_PaymentMethodCountry
     */
    protected function createCountry(string $countryCode, array $titleTranslations = []): WebToPay_PaymentMethodCountry
    {
        return new WebToPay_PaymentMethodCountry($countryCode, $titleTranslations, $this->defaultLanguage);
    }
}


/**
 * Sign checker which checks SS1 signature. SS1 does not depend on SSL functions
 */
class WebToPay_Sign_SS1SignChecker implements WebToPay_Sign_SignCheckerInterface
{
    protected string $projectPassword;

    /**
     * Constructs object
     */
    public function __construct(string $projectPassword)
    {
        $this->projectPassword = $projectPassword;
    }

    /**
     * Check for SS1, which is not depend on openssl functions.
     *
     * @param array<string, mixed> $request
     *
     * @return bool
     *
     * @throws WebToPay_Exception_Callback
     */
    public function checkSign(array $request): bool
    {
        if (!isset($request['data']) || !isset($request['ss1'])) {
            throw new WebToPay_Exception_Callback('Not enough parameters in callback. Possible version mismatch');
        }

        return md5($request['data'] . $this->projectPassword) === $request['ss1'];
    }
}


/**
 * Checks SS2 signature. Depends on SSL functions
 */
class WebToPay_Sign_SS2SignChecker implements WebToPay_Sign_SignCheckerInterface
{
    protected string $publicKey;

    protected WebToPay_Util $util;

    /**
     * Constructs object
     */
    public function __construct(string $publicKey, WebToPay_Util $util)
    {
        $this->publicKey = $publicKey;
        $this->util = $util;
    }

    /**
     * Checks signature
     *
     * @param array<string, mixed> $request
     *
     * @return bool
     *
     * @throws WebToPay_Exception_Callback
     */
    public function checkSign(array $request): bool
    {
        if (!isset($request['data']) || !isset($request['ss2'])) {
            throw new WebToPay_Exception_Callback('Not enough parameters in callback. Possible version mismatch');
        }

        $ss2 = $this->util->decodeSafeUrlBase64($request['ss2']);
        $ok = openssl_verify($request['data'], $ss2, $this->publicKey);

        return $ok === 1;
    }
}


/**
 * Interface for sign checker
 */
interface WebToPay_Sign_SignCheckerInterface
{
    /**
     * Checks whether request is signed properly
     *
     * @param array<string, mixed> $request
     *
     * @return boolean
     */
    public function checkSign(array $request): bool;
}


/**
 * Loads data about payment methods and constructs payment method list object from that data
 * You need SimpleXML support to use this feature
 */
class WebToPay_PaymentMethodListProvider
{
    protected int $projectId;

    protected WebToPay_WebClient $webClient;

    /**
     * Holds constructed method lists by currency
     *
     * @var WebToPay_PaymentMethodList[]
     */
    protected array $methodListCache = [];

    /**
     * Builds various request URLs
     */
    protected WebToPay_UrlBuilder $urlBuilder;

    /**
     * Constructs object
     *
     * @throws WebToPayException if SimpleXML is not available
     */
    public function __construct(
        int $projectId,
        WebToPay_WebClient $webClient,
        WebToPay_UrlBuilder $urlBuilder
    ) {
        $this->projectId = $projectId;
        $this->webClient = $webClient;
        $this->urlBuilder = $urlBuilder;

        if (!WebToPay_Functions::function_exists('simplexml_load_string')) {
            throw new WebToPayException('You have to install libxml to use payment methods API');
        }
    }

    /**
     * Gets payment method list for specified currency
     *
     * @throws WebToPayException
     */
    public function getPaymentMethodList(?float $amount, ?string $currency): WebToPay_PaymentMethodList
    {
        if (!isset($this->methodListCache[$currency])) {
            $xmlAsString = $this->webClient->get(
                $this->urlBuilder->buildForPaymentsMethodList($this->projectId, (string) $amount, $currency)
            );
            $useInternalErrors = libxml_use_internal_errors(false);
            $rootNode = simplexml_load_string($xmlAsString);
            libxml_clear_errors();
            libxml_use_internal_errors($useInternalErrors);
            if (!$rootNode) {
                throw new WebToPayException('Unable to load XML from remote server');
            }
            $methodList = new WebToPay_PaymentMethodList($this->projectId, $currency);
            $methodList->fromXmlNode($rootNode);
            $this->methodListCache[$currency] = $methodList;
        }

        return $this->methodListCache[$currency];
    }
}


/**
 * Creates objects. Also caches to avoid creating several instances of same objects
 */
class WebToPay_Factory
{
    public const ENV_PRODUCTION = 'production';
    public const ENV_SANDBOX = 'sandbox';

    /**
     * @var array<string, mixed>
     */
    protected static array $defaultConfiguration = [
        'routes' => [
            self::ENV_PRODUCTION => [
                'publicKey'           => 'https://www.paysera.com/download/public.key',
                'payment'             => 'https://bank.paysera.com/pay/',
                'paymentMethodList'   => 'https://www.paysera.com/new/api/paymentMethods/',
                'smsAnswer'           => 'https://bank.paysera.com/psms/respond/',
            ],
            self::ENV_SANDBOX => [
                'publicKey'         => 'https://sandbox.paysera.com/download/public.key',
                'payment'           => 'https://sandbox.paysera.com/pay/',
                'paymentMethodList' => 'https://sandbox.paysera.com/new/api/paymentMethods/',
                'smsAnswer'         => 'https://sandbox.paysera.com/psms/respond/',
            ],
        ],
    ];

    protected string $environment;

    /**
     * @var array<string, mixed>
     */
    protected array $configuration;

    protected ?WebToPay_WebClient $webClient = null;

    protected ?WebToPay_CallbackValidator $callbackValidator = null;

    protected ?WebToPay_RequestBuilder $requestBuilder = null;

    protected ?WebToPay_Sign_SignCheckerInterface $signer = null;

    protected ?WebToPay_SmsAnswerSender $smsAnswerSender = null;

    protected ?WebToPay_PaymentMethodListProvider $paymentMethodListProvider = null;

    protected ?WebToPay_Util $util = null;

    protected ?WebToPay_UrlBuilder $urlBuilder = null;

    /**
     * Constructs object.
     * Configuration keys: projectId, password
     * They are required only when some object being created needs them,
     *     if they are not found at that moment - exception is thrown
     *
     * @param array<string, mixed> $configuration
     */
    public function __construct(array $configuration = [])
    {
        $this->configuration = array_merge(self::$defaultConfiguration, $configuration);
        $this->environment = self::ENV_PRODUCTION;
    }

    /**
     * If passed true the factory will use sandbox when constructing URLs
     */
    public function useSandbox(bool $enableSandbox): self
    {
        if ($enableSandbox) {
            $this->environment = self::ENV_SANDBOX;
        } else {
            $this->environment = self::ENV_PRODUCTION;
        }

        return $this;
    }

    /**
     * Creates or gets callback validator instance
     *
     * @throws WebToPayException
     * @throws WebToPay_Exception_Configuration
     */
    public function getCallbackValidator(): WebToPay_CallbackValidator
    {
        if ($this->callbackValidator === null) {
            if (!isset($this->configuration['projectId'])) {
                throw new WebToPay_Exception_Configuration('You have to provide project ID');
            }

            $this->callbackValidator = new WebToPay_CallbackValidator(
                (int) $this->configuration['projectId'],
                $this->getSigner(),
                $this->getUtil(),
                $this->configuration['password'] ?? null
            );
        }

        return $this->callbackValidator;
    }

    /**
     * Creates or gets request builder instance
     *
     * @throws WebToPay_Exception_Configuration
     */
    public function getRequestBuilder(): WebToPay_RequestBuilder
    {
        if ($this->requestBuilder === null) {
            if (!isset($this->configuration['password'])) {
                throw new WebToPay_Exception_Configuration('You have to provide project password to sign request');
            }
            if (!isset($this->configuration['projectId'])) {
                throw new WebToPay_Exception_Configuration('You have to provide project ID');
            }
            $this->requestBuilder = new WebToPay_RequestBuilder(
                (int) $this->configuration['projectId'],
                $this->configuration['password'],
                $this->getUtil(),
                $this->getUrlBuilder()
            );
        }

        return $this->requestBuilder;
    }

    public function getUrlBuilder(): WebToPay_UrlBuilder
    {
        if ($this->urlBuilder === null || $this->urlBuilder->getEnvironment() !== $this->environment) {
            $this->urlBuilder = new WebToPay_UrlBuilder(
                $this->configuration,
                $this->environment
            );
        }

        return $this->urlBuilder;
    }

    /**
     * Creates or gets SMS answer sender instance
     *
     * @throws WebToPay_Exception_Configuration
     */
    public function getSmsAnswerSender(): WebToPay_SmsAnswerSender
    {
        if ($this->smsAnswerSender === null) {
            if (!isset($this->configuration['password'])) {
                throw new WebToPay_Exception_Configuration('You have to provide project password');
            }
            $this->smsAnswerSender = new WebToPay_SmsAnswerSender(
                $this->configuration['password'],
                $this->getWebClient(),
                $this->getUrlBuilder()
            );
        }

        return $this->smsAnswerSender;
    }

    /**
     * Creates or gets payment list provider instance
     *
     * @throws WebToPayException
     * @throws WebToPay_Exception_Configuration
     */
    public function getPaymentMethodListProvider(): WebToPay_PaymentMethodListProvider
    {
        if ($this->paymentMethodListProvider === null) {
            if (!isset($this->configuration['projectId'])) {
                throw new WebToPay_Exception_Configuration('You have to provide project ID');
            }
            $this->paymentMethodListProvider = new WebToPay_PaymentMethodListProvider(
                (int) $this->configuration['projectId'],
                $this->getWebClient(),
                $this->getUrlBuilder()
            );
        }

        return $this->paymentMethodListProvider;
    }

    /**
     * Creates or gets signer instance. Chooses SS2 signer if openssl functions are available, SS1 in other case
     *
     * @throws WebToPay_Exception_Configuration
     * @throws WebToPayException
     */
    protected function getSigner(): WebToPay_Sign_SignCheckerInterface
    {
        if ($this->signer === null) {
            if (WebToPay_Functions::function_exists('openssl_pkey_get_public')) {
                $webClient = $this->getWebClient();
                $publicKey = $webClient->get($this->getUrlBuilder()->buildForPublicKey());
                if (!$publicKey) {
                    throw new WebToPayException('Cannot download public key from WebToPay website');
                }
                $this->signer = new WebToPay_Sign_SS2SignChecker($publicKey, $this->getUtil());
            } else {
                if (!isset($this->configuration['password'])) {
                    throw new WebToPay_Exception_Configuration(
                        'You have to provide project password if OpenSSL is unavailable'
                    );
                }
                $this->signer = new WebToPay_Sign_SS1SignChecker($this->configuration['password']);
            }
        }

        return $this->signer;
    }

    /**
     * Creates or gets web client instance
     */
    protected function getWebClient(): WebToPay_WebClient
    {
        if ($this->webClient === null) {
            $this->webClient = new WebToPay_WebClient();
        }

        return $this->webClient;
    }

    /**
     * Creates or gets util instance
     *
     * @throws WebToPay_Exception_Configuration
     */
    protected function getUtil(): WebToPay_Util
    {
        if ($this->util === null) {
            $this->util = new WebToPay_Util();
        }

        return $this->util;
    }
}


/**
 * Used to build a complete request URL.
 *
 * Class WebToPay_UrlBuilder
 */
class WebToPay_UrlBuilder
{
    public const PLACEHOLDER_KEY = '[domain]';

    /**
     * @var array<string, mixed>
     */
    protected array $configuration;

    protected string $environment;

    /**
     * @var array<string, string>
     */
    protected array $environmentSettings;

    /**
     * @param array<string, mixed> $configuration
     * @param string $environment
     */
    public function __construct(array $configuration, string $environment)
    {
        $this->configuration = $configuration;
        $this->environment = $environment;
        $this->environmentSettings = $this->configuration['routes'][$this->environment];
    }

    public function getEnvironment(): string
    {
        return $this->environment;
    }

    /**
     * Builds a complete request URL based on the provided parameters
     *
     * @param array<string, mixed> $request
     *
     * @return string
     */
    public function buildForRequest(array $request): string
    {
        return $this->createUrlFromRequestAndLanguage($request);
    }

    /**
     * Builds a complete URL for payment list API
     */
    public function buildForPaymentsMethodList(int $projectId, ?string $amount, ?string $currency): string
    {
        $route = $this->environmentSettings['paymentMethodList'];

        return $route . $projectId . '/currency:' . $currency . '/amount:' . $amount;
    }

    /**
     * Builds a complete URL for Sms Answer
     *
     * @codeCoverageIgnore
     */
    public function buildForSmsAnswer(): string
    {
        return $this->environmentSettings['smsAnswer'];
    }

    /**
     * Build the URL to the public key
     */
    public function buildForPublicKey(): string
    {
        return $this->environmentSettings['publicKey'];
    }

    /**
     * Creates a URL from the request and data provided.
     *
     * @param array<string, mixed> $request
     *
     * @return string
     */
    protected function createUrlFromRequestAndLanguage(array $request): string
    {
        $url = $this->getPaymentUrl() . '?' . http_build_query($request, '', '&');

        return preg_replace('/[\r\n]+/is', '', $url) ?? '';
    }

    /**
     * Returns payment URL. Argument is same as lang parameter in request data
     *
     * @return string
     */
    public function getPaymentUrl(): string
    {
        return $this->environmentSettings['payment'];
    }
}


/**
 * Wrapper class to group payment methods. Each country can have several payment method groups, each of them
 * have one or more payment methods.
 */
class WebToPay_PaymentMethodGroup
{
    /**
     * Some unique (in the scope of country) key for this group
     */
    protected string $groupKey;

    /**
     * Translations array for this group. Holds associative array of group title by country codes.
     *
     * @var array<string, string>
     */
    protected array $translations;

    /**
     * Holds actual payment methods
     *
     * @var WebToPay_PaymentMethod[]
     */
    protected array $paymentMethods;

    /**
     * Default language for titles
     */
    protected string $defaultLanguage;

    /**
     * Constructs object
     *
     * @param string $groupKey
     * @param array<string, string> $translations
     * @param string $defaultLanguage
     */
    public function __construct(string $groupKey, array $translations = [], string $defaultLanguage = 'lt')
    {
        $this->groupKey = $groupKey;
        $this->translations = $translations;
        $this->defaultLanguage = $defaultLanguage;
        $this->paymentMethods = [];
    }

    /**
     * Sets default language for titles.
     * Returns itself for fluent interface
     */
    public function setDefaultLanguage(string $language): WebToPay_PaymentMethodGroup
    {
        $this->defaultLanguage = $language;
        foreach ($this->paymentMethods as $paymentMethod) {
            $paymentMethod->setDefaultLanguage($language);
        }

        return $this;
    }

    /**
     * Gets default language for titles
     */
    public function getDefaultLanguage(): string
    {
        return $this->defaultLanguage;
    }

    /**
     * Gets title of the group. Tries to get title in specified language. If it is not found or if language is not
     * specified, uses default language, given to constructor.
     */
    public function getTitle(?string $languageCode = null): string
    {
        if ($languageCode !== null && isset($this->translations[$languageCode])) {
            return $this->translations[$languageCode];
        } elseif (isset($this->translations[$this->defaultLanguage])) {
            return $this->translations[$this->defaultLanguage];
        } else {
            return $this->groupKey;
        }
    }

    /**
     * Returns group key
     */
    public function getKey(): string
    {
        return $this->groupKey;
    }

    /**
     * Returns available payment methods for this group
     *
     * @return WebToPay_PaymentMethod[]
     */
    public function getPaymentMethods(): array
    {
        return $this->paymentMethods;
    }


    /**
     * Adds new payment method for this group.
     * If some other payment method with specified key was registered earlier, overwrites it.
     * Returns given payment method
     *
     * @param WebToPay_PaymentMethod $paymentMethod
     *
     * @return WebToPay_PaymentMethod
     */
    public function addPaymentMethod(WebToPay_PaymentMethod $paymentMethod): WebToPay_PaymentMethod
    {
        return $this->paymentMethods[$paymentMethod->getKey()] = $paymentMethod;
    }

    /**
     * Gets payment method object with key. If no payment method with such key is found, returns null.
     */
    public function getPaymentMethod(string $key): ?WebToPay_PaymentMethod
    {
        return $this->paymentMethods[$key] ?? null;
    }

    /**
     * Returns new group instance with only those payment methods, which are available for provided amount.
     *
     * @throws WebToPayException
     */
    public function filterForAmount(int $amount, string $currency): WebToPay_PaymentMethodGroup
    {
        $group = new WebToPay_PaymentMethodGroup($this->groupKey, $this->translations, $this->defaultLanguage);
        foreach ($this->getPaymentMethods() as $paymentMethod) {
            if ($paymentMethod->isAvailableForAmount($amount, $currency)) {
                $group->addPaymentMethod($paymentMethod);
            }
        }

        return $group;
    }

    /**
     * Returns new country instance with only those payment methods, which are returns or not iban number after payment
     */
    public function filterForIban(bool $isIban = true): WebToPay_PaymentMethodGroup
    {
        $group = new WebToPay_PaymentMethodGroup($this->groupKey, $this->translations, $this->defaultLanguage);
        foreach ($this->getPaymentMethods() as $paymentMethod) {
            if ($paymentMethod->isIban() == $isIban) {
                $group->addPaymentMethod($paymentMethod);
            }
        }

        return $group;
    }

    /**
     * Returns whether this group has no payment methods
     *
     * @return bool
     */
    public function isEmpty(): bool
    {
        return count($this->paymentMethods) === 0;
    }

    /**
     * Loads payment methods from given XML node
     */
    public function fromXmlNode(SimpleXMLElement $groupNode): void
    {
        foreach ($groupNode->payment_type as $paymentTypeNode) {
            $key = (string)$paymentTypeNode->attributes()->key;
            $titleTranslations = [];
            foreach ($paymentTypeNode->title as $titleNode) {
                $titleTranslations[(string)$titleNode->attributes()->language] = (string)$titleNode;
            }
            $logoTranslations = [];
            foreach ($paymentTypeNode->logo_url as $logoNode) {
                if ((string)$logoNode !== '') {
                    $logoTranslations[(string)$logoNode->attributes()->language] = (string)$logoNode;
                }
            }
            $minAmount = null;
            $maxAmount = null;
            $currency = null;
            $isIban = false;
            $baseCurrency = null;
            if (isset($paymentTypeNode->min)) {
                $minAmount = (int)$paymentTypeNode->min->attributes()->amount;
                $currency = (string)$paymentTypeNode->min->attributes()->currency;
            }
            if (isset($paymentTypeNode->max)) {
                $maxAmount = (int)$paymentTypeNode->max->attributes()->amount;
                $currency = (string)$paymentTypeNode->max->attributes()->currency;
            }

            if (isset($paymentTypeNode->is_iban)) {
                /*
                 * There are ONLY two ways to fetch value from a node of the SimpleXMLElement class:
                 * - use the `current` function: current($paymentTypeNode->is_iban);
                 * - implicitly use the `__toString()` magic method casting the node to a string.
                 * We chose the 2nd one
                 *
                 * FYI: the expression `(bool) $paymentTypeNode->is_iban` ALWAYS returns `true`
                 */
                $isIban = (string) $paymentTypeNode->is_iban === "1";
            }
            if (isset($paymentTypeNode->base_currency)) {
                $baseCurrency = (string)$paymentTypeNode->base_currency;
            }
            $this->addPaymentMethod($this->createPaymentMethod(
                $key,
                $minAmount,
                $maxAmount,
                $currency,
                $logoTranslations,
                $titleTranslations,
                $isIban,
                $baseCurrency
            ));
        }
    }

    /**
     * Method to create new payment method instances. Overwrite if you have to use some other subclass.
     *
     * @param string $key
     * @param int|null $minAmount
     * @param int|null $maxAmount
     * @param string|null $currency
     * @param array<string, string> $logoList
     * @param array<string, string> $titleTranslations
     * @param bool $isIban
     * @param mixed $baseCurrency
     *
     * @return WebToPay_PaymentMethod
     */
    protected function createPaymentMethod(
        string $key,
        ?int $minAmount,
        ?int $maxAmount,
        ?string $currency,
        array $logoList = [],
        array $titleTranslations = [],
        bool $isIban = false,
        $baseCurrency = null
    ): WebToPay_PaymentMethod {
        return new WebToPay_PaymentMethod(
            $key,
            $minAmount,
            $maxAmount,
            $currency,
            $logoList,
            $titleTranslations,
            $this->defaultLanguage,
            $isIban,
            $baseCurrency
        );
    }
}


/**
 * Parses and validates callbacks
 */
class WebToPay_CallbackValidator
{
    protected WebToPay_Sign_SignCheckerInterface $signer;

    protected WebToPay_Util $util;

    protected int $projectId;

    protected ?string $password;

    /**
     * Constructs object
     *
     * @param integer $projectId
     * @param WebToPay_Sign_SignCheckerInterface $signer
     * @param WebToPay_Util $util
     * @param string|null $password
     */
    public function __construct(
        int $projectId,
        WebToPay_Sign_SignCheckerInterface $signer,
        WebToPay_Util $util,
        ?string $password = null
    ) {
        $this->signer = $signer;
        $this->util = $util;
        $this->projectId = $projectId;
        $this->password = $password;
    }

    /**
     * Parses callback parameters from query parameters and checks if sign is correct.
     * Request has parameter "data", which is signed and holds all callback parameters
     *
     * @param array<string, string> $requestData
     *
     * @return array<string, string> Parsed callback parameters
     *
     * @throws WebToPayException
     * @throws WebToPay_Exception_Callback
     */
    public function validateAndParseData(array $requestData): array
    {
        if (!isset($requestData['data'])) {
            throw new WebToPay_Exception_Callback('"data" parameter not found');
        }

        $data = $requestData['data'];

        if (isset($requestData['ss1']) || isset($requestData['ss2'])) {
            if (!$this->signer->checkSign($requestData)) {
                throw new WebToPay_Exception_Callback('Invalid sign parameters, check $_GET length limit');
            }

            $queryString = $this->util->decodeSafeUrlBase64($data);
        } else {
            if (null === $this->password) {
                throw new WebToPay_Exception_Configuration('You have to provide project password');
            }

            $queryString = $this->util->decryptGCM(
                $this->util->decodeSafeUrlBase64($data),
                $this->password
            );

            if (null === $queryString) {
                throw new WebToPay_Exception_Callback('Callback data decryption failed');
            }
        }
        $request = $this->util->parseHttpQuery($queryString);

        if (!isset($request['projectid'])) {
            throw new WebToPay_Exception_Callback(
                'Project ID not provided in callback',
                WebToPayException::E_INVALID
            );
        }

        if ((string) $request['projectid'] !== (string) $this->projectId) {
            throw new WebToPay_Exception_Callback(
                sprintf('Bad projectid: %s, should be: %s', $request['projectid'], $this->projectId),
                WebToPayException::E_INVALID
            );
        }

        if (!isset($request['type']) || !in_array($request['type'], ['micro', 'macro'], true)) {
            $micro = (
                isset($request['to'])
                && isset($request['from'])
                && isset($request['sms'])
            );
            $request['type'] = $micro ? 'micro' : 'macro';
        }

        return $request;
    }

    /**
     * Checks data to have all the same parameters provided in expected array
     *
     * @param array<string, string> $data
     * @param array<string, string> $expected
     *
     * @throws WebToPayException
     */
    public function checkExpectedFields(array $data, array $expected): void
    {
        foreach ($expected as $key => $value) {
            $passedValue = $data[$key] ?? null;
            // there should be non-strict comparison here
            if ($passedValue != $value) {
                throw new WebToPayException(
                    sprintf('Field %s is not as expected (expected %s, got %s)', $key, $value, $passedValue)
                );
            }
        }
    }
}