<?php
// Cryptanio 1.0.0 62 / public API v1
declare(strict_types=1);

// Клиент публичного Cryptanio API /v1. Версия файла ставится сборкой сайта.
final class CryptanioError extends RuntimeException
{
    public function __construct(
        public readonly int $status,
        public readonly string $apiCode,
        string $message,
        public readonly string $retryAfter = '',
    ) {
        parent::__construct($message);
    }
}

final class Cryptanio
{
    private const MAX_JSON_BYTES = 8 * 1024 * 1024;
    private readonly string $origin;

    public function __construct(
        private readonly string $apiKey = '',
        string $baseOrigin = 'https://api.cryptanio.com',
        private readonly float $timeoutSeconds = 30,
    ) {
        $url = parse_url($baseOrigin);
        if ($url === false || empty($url['host']) || isset($url['user']) || isset($url['pass']) || isset($url['query']) || isset($url['fragment']) || !in_array($url['path'] ?? '', ['', '/'], true)) {
            throw new CryptanioError(0, 'invalid_origin', 'baseOrigin must be an origin without credentials, path, query or fragment');
        }
        $host = strtolower(trim($url['host'], '[]'));
        $loopback = $host === 'localhost' || $host === '::1' || (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false && explode('.', $host)[0] === '127');
        if (($url['scheme'] ?? '') !== 'https' && !(($url['scheme'] ?? '') === 'http' && $loopback)) {
            throw new CryptanioError(0, 'invalid_origin', 'HTTPS is required outside loopback');
        }
        if (preg_match('/[\x00-\x20\x7f]/', $apiKey)) {
            throw new CryptanioError(0, 'invalid_key', 'API key must not contain whitespace or control characters');
        }
        if (!is_finite($timeoutSeconds) || $timeoutSeconds <= 0 || $timeoutSeconds > 300) {
            throw new CryptanioError(0, 'invalid_timeout', 'timeout must be greater than zero and at most 300 seconds');
        }
        if (!extension_loaded('curl')) {
            throw new CryptanioError(0, 'missing_dependency', 'The PHP cURL extension is required');
        }
        $this->origin = rtrim($baseOrigin, '/');
    }

    private function failure(int $status, string $code, string $message, string $retryAfter = ''): CryptanioError
    {
        $redact = fn(string $value): string => $this->apiKey === '' ? $value : str_replace($this->apiKey, '[REDACTED]', $value);
        return new CryptanioError($status, $redact($code), $redact($message), $redact($retryAfter));
    }

    private function request(string $method, string $path, array $query = [], ?array $body = null, string $idempotencyKey = '', bool $public = false, ?callable $onChunk = null): ?array
    {
        if (!$public && $this->apiKey === '') {
            throw $this->failure(0, 'missing_key', 'An API key is required');
        }
        $payload = null;
        if ($body !== null) {
            if ($body !== [] && array_is_list($body)) {
                throw $this->failure(0, 'invalid_body', 'Request body must be a JSON object');
            }
            try {
                $payload = json_encode((object)$body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            } catch (JsonException $e) {
                throw $this->failure(0, 'invalid_body', 'Request body cannot be encoded as JSON');
            }
        }
        $url = $this->origin . $path . ($query === [] ? '' : '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986));
        $headers = ['Accept: ' . ($onChunk === null ? 'application/json' : 'text/event-stream')];
        if (!$public) { $headers[] = 'Authorization: Bearer ' . $this->apiKey; }
        if ($payload !== null) { $headers[] = 'Content-Type: application/json'; }
        if ($idempotencyKey !== '') { $headers[] = 'Idempotency-Key: ' . $idempotencyKey; }
        $handle = curl_init($url);
        if ($handle === false) { throw $this->failure(0, 'request_failed', 'Request could not be created'); }
        $status = 0;
        $retryAfter = '';
        $contentType = '';
        $data = '';
        $tooLarge = false;
        $stopped = false;
        $callbackError = null;
        try {
            curl_setopt_array($handle, [
                CURLOPT_CUSTOMREQUEST => $method,
                CURLOPT_HTTPHEADER => $headers,
                CURLOPT_FOLLOWLOCATION => false,
                CURLOPT_MAXREDIRS => 0,
                CURLOPT_TIMEOUT_MS => max(1, (int)ceil($this->timeoutSeconds * 1000)),
                CURLOPT_CONNECTTIMEOUT_MS => max(1, (int)ceil($this->timeoutSeconds * 1000)),
                CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
                CURLOPT_HEADERFUNCTION => function ($curl, string $line) use (&$status, &$retryAfter, &$contentType): int {
                    if (preg_match('#^HTTP/\S+\s+(\d+)#', $line, $match)) {
                        $status = (int)$match[1]; $retryAfter = ''; $contentType = '';
                    } elseif (stripos($line, 'Retry-After:') === 0) {
                        $retryAfter = trim(substr($line, 12));
                    } elseif (stripos($line, 'Content-Type:') === 0) {
                        $contentType = strtolower(trim(substr($line, 13)));
                    }
                    return strlen($line);
                },
                CURLOPT_WRITEFUNCTION => function ($curl, string $chunk) use ($onChunk, &$status, &$contentType, &$data, &$tooLarge, &$stopped, &$callbackError): int {
                    if ($onChunk !== null && $status >= 200 && $status < 300 && str_starts_with($contentType, 'text/event-stream')) {
                        try {
                            if ($onChunk($chunk) === false) { $stopped = true; return 0; }
                        } catch (Throwable $e) { $callbackError = $e; return 0; }
                        return strlen($chunk);
                    }
                    if (strlen($data) + strlen($chunk) > self::MAX_JSON_BYTES) { $tooLarge = true; return 0; }
                    $data .= $chunk;
                    return strlen($chunk);
                },
            ]);
            if ($payload !== null) { curl_setopt($handle, CURLOPT_POSTFIELDS, $payload); }
            $ok = curl_exec($handle);
            if ($callbackError !== null) { throw $callbackError; }
            if ($stopped) { return null; }
            if ($tooLarge) { throw $this->failure($status, 'response_too_large', 'JSON response exceeds 8 MiB'); }
            if ($ok === false) {
                $code = curl_errno($handle) === CURLE_OPERATION_TIMEDOUT ? 'timeout' : 'request_failed';
                throw $this->failure(0, $code, 'Request failed; the outcome of a write may be unknown');
            }
            if ($status < 200 || $status >= 300) {
                $error = json_decode($data, true);
                $code = is_string($error['error']['code'] ?? null) ? $error['error']['code'] : 'http_error';
                $message = is_string($error['error']['message'] ?? null) ? $error['error']['message'] : 'The server returned HTTP ' . $status;
                throw $this->failure($status, $code, $message, $retryAfter);
            }
            if ($onChunk !== null) {
                if (!str_starts_with($contentType, 'text/event-stream')) { throw $this->failure($status, 'invalid_response', 'Expected text/event-stream'); }
                return null;
            }
            if ($status === 204 && $data === '') { return null; }
            try {
                $result = json_decode($data, true, 512, JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING);
                if (!is_array($result) || !str_starts_with(ltrim($data), '{')) { throw new JsonException('Expected object'); }
            } catch (JsonException $e) { throw $this->failure($status, 'invalid_response', 'Expected a JSON object'); }
            return $result;
        } finally {
            // PHP 8+ освобождает соединение вместе с объектом CurlHandle.
            unset($handle);
        }
    }

    private function id(string $id): string
    {
        if (trim($id) === '' || $id === '.' || $id === '..') { throw $this->failure(0, 'invalid_id', 'Resource ID must be a nonempty string other than a dot segment'); }
        return rawurlencode($id);
    }

    private function filters(array $query, array $allowed): array
    {
        foreach ($query as $name => $value) {
            if (!in_array($name, $allowed, true)) { throw $this->failure(0, 'invalid_filter', 'Unsupported list filter'); }
            if ($value === null) { unset($query[$name]); continue; }
            if ($name === 'limit') {
                if (!is_int($value) || $value < 1 || $value > 200) { throw $this->failure(0, 'invalid_filter', 'limit must be an integer from 1 to 200'); }
            } elseif (!is_string($value)) { throw $this->failure(0, 'invalid_filter', 'Filter values other than limit must be strings'); }
        }
        return $query;
    }

    public function createWatch(array $body): ?array { return $this->request('POST', '/v1/watches', body: $body); }
    public function listWatches(array $query = []): ?array { return $this->request('GET', '/v1/watches', $this->filters($query, ['project', 'chain', 'limit', 'before'])); }
    public function getWatch(string $id): ?array { return $this->request('GET', '/v1/watches/' . $this->id($id)); }
    public function deleteWatch(string $id): ?array { return $this->request('DELETE', '/v1/watches/' . $this->id($id)); }
    public function listEvents(array $query = []): ?array { return $this->request('GET', '/v1/events', $this->filters($query, ['project', 'watch_id', 'chain', 'type', 'address', 'tx_id', 'limit', 'before'])); }
    public function listPayments(array $query = []): ?array { return $this->request('GET', '/v1/payments', $this->filters($query, ['project', 'watch_id', 'chain', 'type', 'address', 'tx_id', 'limit', 'before'])); }
    public function createInvoice(array $body, string $idempotencyKey): ?array
    {
        if (!preg_match('/\A[A-Za-z0-9_-]{16,80}\z/', $idempotencyKey)) { throw $this->failure(0, 'invalid_idempotency_key', 'Idempotency key must contain 16–80 ASCII letters, digits, dashes or underscores'); }
        return $this->request('POST', '/v1/invoices', body: $body, idempotencyKey: $idempotencyKey);
    }
    public function listInvoices(array $query = []): ?array { return $this->request('GET', '/v1/invoices', $this->filters($query, ['project', 'chain', 'status', 'limit', 'before'])); }
    public function getInvoice(string $id): ?array { return $this->request('GET', '/v1/invoices/' . $this->id($id)); }
    public function cancelInvoice(string $id): ?array { return $this->request('POST', '/v1/invoices/' . $this->id($id) . '/cancel'); }
    public function createEndpoint(array $body): ?array { return $this->request('POST', '/v1/endpoints', body: $body); }
    public function listEndpoints(): ?array { return $this->request('GET', '/v1/endpoints'); }
    public function deleteEndpoint(string $id): ?array { return $this->request('DELETE', '/v1/endpoints/' . $this->id($id)); }
    public function getHealth(): ?array { return $this->request('GET', '/v1/health', public: true); }
    public function listChains(): ?array { return $this->request('GET', '/v1/chains', public: true); }

    // Callback получает сырые SSE bytes (границы chunks не равны границам событий).
    // false прерывает поток без ошибки; finally закрывает cURL и при исключении.
    public function streamEvents(callable $onChunk): void { $this->request('GET', '/v1/stream', onChunk: $onChunk); }
}
