<?php
declare(strict_types=1);

namespace PayPo\Payment\Model;

use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Serialize\Serializer\Json;
use Magento\Framework\UrlInterface;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Model\Order;
use PayPo\Payment\Helper\Data;
use PayPo\Payment\Logger\Logger as PayPoLogger;

/**
 * Client for the PayPo merchant REST API.
 */
class PayPoApi
{
    public const STATUS_COMPLETED = 'COMPLETED';
    public const STATUS_CANCELED = 'CANCELED';

    /**
     * Seconds allowed for the whole request and for connecting.
     */
    private const REQUEST_TIMEOUT = 30;
    private const CONNECT_TIMEOUT = 10;

    /**
     * @var string
     */
    private string $apiUrl = '';

    /**
     * @var string
     */
    private string $clientId = '';

    /**
     * @var string
     */
    private string $clientSecret = '';

    /**
     * @var array{0: string, 1: int}
     */
    private array $productMode = ['CORE', 4];

    /**
     * Access token together with the credentials it was issued for, so that a
     * token from one store scope is never reused for another one.
     *
     * @var string|null
     */
    private ?string $token = null;

    /**
     * @var string|null
     */
    private ?string $tokenOwner = null;

    /**
     * @param UrlInterface $urlBuilder
     * @param Data $helper
     * @param PayPoLogger $paypoLogger
     * @param Json $json
     */
    public function __construct(
        private readonly UrlInterface $urlBuilder,
        private readonly Data $helper,
        private readonly PayPoLogger $paypoLogger,
        private readonly Json $json
    ) {
    }

    /**
     * Register the order in PayPo and obtain the customer redirect url.
     *
     * @param OrderInterface|Order $order
     * @param float $amount Amount to authorize, in the order currency
     * @return array Decoded PayPo response, contains at least transactionId and redirectUrl
     * @throws LocalizedException
     */
    public function initOrder($order, $amount): array
    {
        $storeId = $order->getStoreId();
        $this->setup($storeId);

        $billing = $order->getBillingAddress();
        $shipping = $order->getShippingAddress() ?: $billing;

        if ($billing === null) {
            throw new LocalizedException(__('PayPo requires a billing address on the order.'));
        }

        $postData = [
            'merchantId' => $this->clientId,
            'order' => [
                'referenceId' => (string)$order->getIncrementId(),
                'amount' => (int)round((float)$amount * 100),
                'billingAddress' => $this->formatAddress($billing),
                'shippingAddress' => $this->formatAddress($shipping),
                'shipment' => 0,
            ],
            'customer' => [
                'name' => $billing->getFirstname(),
                'surname' => $billing->getLastname(),
                'email' => $order->getCustomerEmail(),
                'phone' => preg_replace('/\D+/', '', (string)$billing->getTelephone()),
            ],
            'configuration' => [
                'returnUrl' => $this->urlBuilder->getUrl('paypo/checkout/success', ['_scope' => $storeId]),
                'notifyUrl' => $this->urlBuilder->getUrl('paypo/checkout/process', ['_scope' => $storeId]),
                'cancelUrl' => $this->urlBuilder->getUrl('paypo/checkout/cancel', ['_scope' => $storeId]),
                'product' => [
                    'productType' => $this->productMode[0],
                    'installmentCount' => (int)$this->productMode[1],
                ],
            ],
        ];

        $response = $this->request('POST', '/transactions', $postData, true);

        if (!isset($response['transactionId'], $response['redirectUrl'])) {
            $this->paypoLogger->addError(
                sprintf('initOrder: unexpected response %s', $this->encode($response))
            );

            throw new LocalizedException(
                __('We were unable to process your payment. Please contact Shop Support for assistance.')
            );
        }

        return $response;
    }

    /**
     * Update the status of an existing PayPo transaction.
     *
     * @param string $id PayPo transaction id
     * @param string|null $status Defaults to COMPLETED
     * @param int|string|null $storeId
     * @return array
     * @throws LocalizedException
     */
    public function changeStatus(string $id, ?string $status = null, int|string|null $storeId = null): array
    {
        $this->setup($storeId);

        return $this->request(
            'PATCH',
            '/transactions/' . rawurlencode($id),
            ['status' => $status ?? self::STATUS_COMPLETED],
            true
        );
    }

    /**
     * Refund a PayPo transaction, fully or partially.
     *
     * @param string $id PayPo transaction id
     * @param int $amount Amount in the minor currency unit (grosze)
     * @param int|string|null $storeId
     * @return array
     * @throws LocalizedException
     */
    public function refund(string $id, int $amount = 0, int|string|null $storeId = null): array
    {
        $this->setup($storeId);

        return $this->request(
            'POST',
            '/transactions/' . rawurlencode($id) . '/refunds',
            ['amount' => $amount],
            true
        );
    }

    /**
     * Load the API credentials of the given store scope.
     *
     * @param int|string|null $storeId
     * @return void
     */
    public function setup(int|string|null $storeId = null): void
    {
        $prefix = $this->helper->isSandbox($storeId) ? 'sandbox' : 'live';

        $this->apiUrl = rtrim((string)$this->helper->getConfig($prefix . '_api_url', $storeId), '/');
        $this->clientId = (string)$this->helper->getConfig($prefix . '_api_key', $storeId);
        $this->clientSecret = (string)$this->helper->getConfig($prefix . '_api_secret', $storeId);
        $this->productMode = $this->helper->getProductMode($storeId);

        // Drop a token issued for different credentials, e.g. another store view.
        if ($this->tokenOwner !== $this->credentialsFingerprint()) {
            $this->token = null;
        }
    }

    /**
     * Map a Magento order address onto the PayPo address structure.
     *
     * @param \Magento\Sales\Api\Data\OrderAddressInterface $address
     * @return array
     */
    private function formatAddress($address): array
    {
        return [
            'street' => implode(' ', (array)$address->getStreet()),
            'zip' => (string)$address->getPostcode(),
            'city' => (string)$address->getCity(),
            'county' => (string)($address->getRegion() ?? ''),
            'country' => (string)$address->getCountryId(),
        ];
    }

    /**
     * Current OAuth token, requested on first use.
     *
     * @return string
     * @throws LocalizedException
     */
    private function getToken(): string
    {
        if ($this->token === null) {
            $this->token = $this->generateToken();
            $this->tokenOwner = $this->credentialsFingerprint();
        }

        return $this->token;
    }

    /**
     * Exchange the client credentials for an access token.
     *
     * @return string
     * @throws LocalizedException
     */
    private function generateToken(): string
    {
        $response = $this->request(
            'POST',
            '/oauth/tokens',
            http_build_query([
                'grant_type' => 'client_credentials',
                'client_id' => $this->clientId,
                'client_secret' => $this->clientSecret,
            ]),
            false,
            ['Content-Type: application/x-www-form-urlencoded']
        );

        if (empty($response['access_token'])) {
            // The request body is deliberately not logged, it carries the API secret.
            $this->paypoLogger->addError('generateToken: PayPo returned no access_token');

            throw new LocalizedException(__('Empty PayPo access_token'));
        }

        return (string)$response['access_token'];
    }

    /**
     * Send a request to the PayPo API and decode the response.
     *
     * @param string $method HTTP verb
     * @param string $path Path appended to the configured API url
     * @param array|string|null $payload Array is sent as JSON, string is sent as-is
     * @param bool $authorize Whether the OAuth token should be attached
     * @param array $headers Extra headers, replacing the JSON defaults
     * @return array
     * @throws LocalizedException
     */
    private function request(
        string $method,
        string $path,
        array|string|null $payload = null,
        bool $authorize = true,
        array $headers = []
    ): array {
        if ($this->apiUrl === '') {
            throw new LocalizedException(__('PayPo API url is not configured.'));
        }

        $body = is_array($payload) ? $this->encode($payload) : $payload;
        $requestHeaders = $headers ?: ['Content-Type: application/json'];
        $requestHeaders[] = 'Accept: application/json';

        if ($authorize) {
            $requestHeaders[] = 'Authorization: Bearer ' . $this->getToken();
        }

        $curl = curl_init();
        curl_setopt_array($curl, [
            CURLOPT_URL => $this->apiUrl . $path,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_NOBODY => false,
            CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
            CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
            CURLOPT_HTTPHEADER => $requestHeaders,
            CURLOPT_POSTFIELDS => $body ?? '',
        ]);

        $rawResponse = curl_exec($curl);
        $error = curl_error($curl);
        $httpCode = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        curl_close($curl);

        if ($rawResponse === false) {
            $this->paypoLogger->addError(sprintf('%s %s: transport error %s', $method, $path, $error));

            throw new LocalizedException(__('Could not connect to PayPo. Please try again later.'));
        }

        $decoded = $this->decode((string)$rawResponse);

        if ($httpCode >= 400) {
            $this->paypoLogger->addError(
                sprintf('%s %s: HTTP %d, response %s', $method, $path, $httpCode, (string)$rawResponse)
            );

            throw new LocalizedException(__('PayPo rejected the request (HTTP %1).', $httpCode));
        }

        return $decoded;
    }

    /**
     * Decode a JSON response, tolerating an empty body.
     *
     * @param string $rawResponse
     * @return array
     */
    private function decode(string $rawResponse): array
    {
        if (trim($rawResponse) === '') {
            return [];
        }

        try {
            $decoded = $this->json->unserialize($rawResponse);
        } catch (\InvalidArgumentException $e) {
            return [];
        }

        return is_array($decoded) ? $decoded : [];
    }

    /**
     * @param array $data
     * @return string
     */
    private function encode(array $data): string
    {
        return (string)json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }

    /**
     * Identifier of the credential set the cached token belongs to.
     *
     * @return string
     */
    private function credentialsFingerprint(): string
    {
        return sha1($this->apiUrl . '|' . $this->clientId);
    }
}
