<?php

/**
 * Library that allows to communicate with B1 API.
 * @author Serj Ivanov
 */
class B1
{

    /**
     * Version.
     */
    const VERSION = '2.2.72';
    /**
     * Platform.
     */
    const PLATFORM = 'woocommerce';
    /**
     * Max retries after 429
     */
    const MAX_RETRIES = 5;
    /**
     * Initial delay (ms)
     */
    const RETRY_DELAY_MS = 500;
    /**
     * Max delay (ms)
     */
    const MAX_RETRY_DELAY_MS = 30000;
    /**
     * Server scheme
     */
    private $scheme = 'https';
    /**
     * Server host
     */
    private $host = 'site.pro/My-Accounting';
    /**
     * API base path.
     */
    private $basePath = '/api/';
    /**
     * @var string API key. Required for all requests to Site.pro.
     */
    private $apiKey;
    /**
     * @var string Private key. Required for all requests to Site.pro.
     */
    private $privateKey;
    /**
     * @var int Request timeout.
     */
    private $timeout = 30;
    /**
     * @var array Attributes that are allowed to be set.
     */
    private $configKeys = ['scheme', 'host', 'apiKey', 'privateKey', 'timeout'];

    public function __construct($config)
    {
        foreach ($this->configKeys as $key) {
            if (isset($config[$key])) {
                $this->$key = $config[$key];
            }
        }
        if (empty($this->apiKey)) {
            throw new B1Exception('API key is not provided', $config);// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
        }
        if (empty($this->privateKey)) {
            throw new B1Exception('Private key is not provided', $config);// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
        }
    }

    /**
     * @param string $path Path in Site.pro system.
     * @param array $data Data to send to Site.pro.
     * @param array $headers Headers that need to be incorporated in the request.
     * @return B1Response
     * @throws B1Exception
     */
    public function request($path, $data = [], $headers = [])
    {
        $content = json_encode($data);
        return $this->executeRequest($path, $content ?? "[]", $headers);
    }

    /**
     * @param string $path Path in Site.pro system.
     * @param string $fileContent File content.
     * @param array $headers Headers that need to be incorporated in the request.
     * @return B1Response
     * @throws B1Exception
     */
    public function upload($path, $fileContent, $headers = [])
    {
        if (!isset($headers['Id'])) {
            throw new B1Exception("'Id' is not specified in the headers array");// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
        }
        if (!isset($headers['File-Name'])) {
            throw new B1Exception("'File-Name' is not specified in the headers array");// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
        }
        if (!isset($headers['Content-Type'])) {
            throw new B1Exception("'Content-Type' is not specified in the headers array");// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
        }
        return $this->executeRequest($path, $fileContent, $headers);
    }

    /**
     * Populates the headers array with required lines.
     * @param array $headers Headers that need to be incorporated in the request.
     * @param string $content
     * @throws B1Exception
     */
    private function populateRequestHeaders(&$headers, $content)
    {
        $headers['Api-Key'] = $this->apiKey;
        $headers['Content-Type'] = 'application/json; charset=utf-8';
        $headers['Content-Length'] = strlen($content);
        $headers['Time'] = time();
        $headers['Version'] = B1::VERSION;
        $headers['Platform'] = B1::PLATFORM;
        ksort($headers);
    }

    /**
     * Generates the request signature.
     * @param array $headers Headers.
     * @param string $content Content.
     */
    private function signRequest(&$headers, $content)
    {
        $data = '';
        foreach ($headers as $name => $value) {
            $data .= strtolower($name) . $value;
        }
        $data .= $content;
        $headers['Signature'] = hash_hmac('sha512', $data, $this->privateKey);
    }

    /**
     * Builds the request headers.
     * @param array $headers Headers.
     * @return array
     */
    private function buildRequestHeaders(&$headers)
    {
        $headersToSend = [];
        foreach ($headers as $name => $value) {
            if (!in_array($name, ['Content-Type', 'Content-Length'])) {
                $headersToSend['B1-' . $name] = $value;
            } else {
                $headersToSend[$name] = $value;
            }
        }
        return $headersToSend;
    }

    /**
     * Makes the call to the API.
     * @param string $path Path in Site.pro system.
     * @param string $requestContent Request content.
     * @param array $headers Request headers.
     * @param int $attempt Current retry attempt (internal use).
     * @return B1Response
     * @throws B1Exception
     */
    private function executeRequest($path, $requestContent, $headers, $attempt = 1)
    {
        $this->populateRequestHeaders($headers, $requestContent);
        $this->signRequest($headers, $requestContent);
        $headers = $this->buildRequestHeaders($headers);
        $args = [
            'method'      => 'POST',
            'data_format' => 'body',
            'headers' => $headers,
            'body'    => $requestContent,
            'timeout' => $this->timeout,
            'transport'   => 'curl',
        ];

        $url = "{$this->scheme}://{$this->host}{$this->basePath}{$path}";
        $wpResponse = wp_remote_post($url, $args);
        $responseCode      = wp_remote_retrieve_response_code($wpResponse);
        $responseBody    = wp_remote_retrieve_body($wpResponse);
        $responseHeaders = wp_remote_retrieve_headers($wpResponse);


        $debug = [
            'request' => [
                'headers' => $headers,
                'content' => $requestContent,
            ],
            'response' => [
                'headers' => $responseHeaders,
                'content' => $responseBody,
            ],
        ];

        $debugToLog = [
            'request' => [
                'host' => "{$this->host}",
                'url' => "{$this->basePath}{$path}",
                'headers' => $headers,
                'content' => json_decode($requestContent),
            ],
            'response' => [
                'headers' => (array)$responseHeaders,
                'content' => json_decode($responseBody),
            ],
        ];

        if ( is_wp_error($wpResponse) ) {
            B1_Accounting_Logger::save(false, $debugToLog);
            throw new B1Exception($wpResponse->get_error_message(), $debug);// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
        } else {
            $rawHeaders = '';
            foreach ( $responseHeaders as $name => $values ) {
                $values = (array) $values;
                foreach ( $values as $v ) {
                    $rawHeaders .= "{$name}: {$v}\r\n";
                }
            }
            $response = new B1Response($rawHeaders, $responseBody, $responseCode);
            B1_Accounting_Logger::save($responseCode == 200, $debugToLog);

            // === 429 (Too Many Requests) ===
            if ($responseCode === 429) {
                if ($attempt <= self::MAX_RETRIES) {
                    $retryAfter = $this->getRetryAfterDelay($responseHeaders);

                    if (class_exists('B1_Accounting_Helper') && method_exists('B1_Accounting_Helper', 'debug')) {
                        B1_Accounting_Helper::debug(sprintf(
                            'Rate limited (429) on %s. Retry %d/%d after %d sec.',
                            $path,
                            $attempt,
                            self::MAX_RETRIES,
                            $retryAfter
                        ));
                    }

                    sleep($retryAfter);
                    return $this->executeRequest($path, $requestContent, $headers, $attempt + 1);
                } else {
                    throw new B1Exception(
                        "Rate limit exceeded after " . self::MAX_RETRIES . " retries. Last response: " . $responseBody,
                        $debug
                    );
                }
            }
            // === End 429 ===

            switch ($responseCode) {
                case 200:
                    return $response;
                case 400:
                    throw new B1ValidationException($response,"Site.pro API data validation failure.",  $debug);// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
                case 404:
                    throw new B1ResourceNotFoundException($response,"Site.pro API resource not found.",  $debug);// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
                case 409:
                    throw new B1DuplicateException($response,"Object already exists in the Site.pro system.",  $debug);// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
                case 480:
                    throw new B1PartialCompletionException($response,"Partial completion in the Site.pro system.",  $debug);// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
                case 500:
                    throw new B1InternalErrorException($response,"Site.pro API internal error.",  $debug);// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
                case 503:
                    throw new B1ServiceUnavailableException($response,"Site.pro API is currently unavailable.",  $debug);// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
                default:
                    throw new B1Exception("Site.pro API fatal error.", $debug);// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
            }

        }
    }


    /**
     * Calculates delay in seconds for retry after receiving 429 response
     * @param array|object $responseHeaders
     * @return int Delay in seconds
     */

    private function getRetryAfterDelay($responseHeaders)
    {
        $retryAfter = null;
        $headersArray = is_object($responseHeaders) ? (array)$responseHeaders : $responseHeaders;

        if (isset($headersArray['retry-after'])) {
            $value = $headersArray['retry-after'];
            $value = is_array($value) ? reset($value) : $value;

            if (is_numeric($value)) {
                $retryAfter = (int)$value;
            } else {
                $timestamp = strtotime($value);
                if ($timestamp !== false) {
                    $retryAfter = max(1, $timestamp - time());
                }
            }
        }

        if ($retryAfter === null || $retryAfter < 1) {
            $attempt = 1;
            $retryAfter = min(
                self::MAX_RETRY_DELAY_MS / 1000,
                (self::RETRY_DELAY_MS * pow(2, $attempt - 1)) / 1000
            );
        }

        return (int)ceil($retryAfter);
    }

}

class B1Response
{

    private $headers;
    private $content;
    private $code;

    public function __construct($headers, $content, $code)
    {
        foreach (explode("\r\n", $headers) as $i => $line) {
            if (strpos($line, ': ') !== false) {
                list($key, $value) = explode(': ', $line);
                $this->headers[strtolower($key)] = $value;
            }
        }
        if ($this->isJson()) {
            $this->content = json_decode($content, true);
        } else {
            $this->content = $content;
        }
        $this->code = $code;
    }

    public function getContent()
    {
        return $this->content;
    }

    public function getHeaders()
    {
        return $this->headers;
    }

    public function getFileName()
    {
        return isset($this->headers['file-name']) ? $this->headers['file-name'] : null;
    }

    public function getContentType()
    {
        return isset($this->headers['content-type']) ? $this->headers['content-type'] : null;
    }

    private function isJson()
    {
        return isset($this->headers['content-type']) && strpos($this->headers['content-type'], 'application/json') !== false;
    }

    /**
     * @return int HTTP status code
     */
    public function getCode()
    {
        return $this->code;
    }

}

/**
 * Base exception class for all exceptions in this library
 */
class B1Exception extends Exception
{

    /**
     * @var array
     */
    protected $extraData;

    /**
     * @param string $message
     * @param array $extraData
     * @param int $code
     * @param Throwable|null $previous
     */
    public function __construct($message = "", $extraData = [], $code = 0, Throwable $previous = null)
    {
        $this->extraData = $extraData;
        parent::__construct($message, $code, $previous);
    }

    public function getExtraData()
    {
        return $this->extraData;
    }

}

class B1ClientException extends B1Exception
{

    /**
     * @var B1Response
     */
    protected $response;

    /**
     * @param B1Response $response
     * @param string $message
     * @param array $extraData
     * @param int $code
     * @param Throwable|null $previous
     */
    public function __construct($response, $message = "", $extraData = [], $code = 0, Throwable $previous = null)
    {
        $this->response = $response;
        parent::__construct($message, $extraData, $code, $previous);
    }

    public function getResponse()
    {
        return $this->response;
    }

}

class B1ValidationException extends B1ClientException
{

}

class B1ResourceNotFoundException extends B1ClientException
{

}

class B1DuplicateException extends B1ClientException
{

}

class B1InternalErrorException extends B1ClientException
{

}

class B1ServiceUnavailableException extends B1ClientException
{

}

class B1PartialCompletionException extends B1ClientException
{

}
