<?php
// =============================================================
// HyperliquidClient — тонкий клиент прямой торговли на Hyperliquid.
// =============================================================
// Портирует из официального Python-SDK только то, что нужно копи-боту:
//   info:     meta, allMids, clearinghouseState, (frontend)OpenOrders
//   exchange: order/bulkOrders, cancel/cancelByCloid, updateLeverage,
//             marketOpen/marketClose, approveAgent
//
// Модуль независим: своя HTTP-обёртка (можно внедрить чужой транспорт
// через конструктор). Подпись — HL\Signing (см. крипто-ядро + golden-тесты).
//
// Расширяемость: новые action добавляются одним приватным методом
// post-экшена; см. updateLeverage как образец.

namespace HL;

require_once __DIR__ . '/Signing.php';

final class HyperliquidClient
{
    public const MAINNET_INFO     = 'https://api.hyperliquid.xyz/info';
    public const MAINNET_EXCHANGE = 'https://api.hyperliquid.xyz/exchange';
    public const TESTNET_INFO     = 'https://api.hyperliquid-testnet.xyz/info';
    public const TESTNET_EXCHANGE = 'https://api.hyperliquid-testnet.xyz/exchange';

    private const DEFAULT_SLIPPAGE = 0.05; // 5% для маркет-ордеров (как в SDK)

    private string $privKey;
    private string $accountAddress;
    private bool $isMainnet;
    private ?string $vaultAddress;
    /** @var callable|null fn(string $url, array $payload): array */
    private $transport;
    private int $timeout;
    // путь к файлу межпроцессного nonce (null = только в пределах процесса)
    private ?string $noncePath;

    // кэш meta
    private ?array $coinToAsset = null;
    private ?array $assetToSzDecimals = null;
    // UPPER(name) → каноническое имя монеты из meta (например 'KPEPE' → 'kPEPE').
    // Бот нормализует символы к верхнему регистру (collector/price_fetcher), а
    // биржа регистрозависима — без этой карты монеты со смешанным регистром
    // (kPEPE, kSHIB, kBONK…) не находились бы ни в meta, ни в allMids.
    private ?array $upperToCoin = null;

    // монотонный nonce: Hyperliquid требует строго возрастающие nonce на
    // аккаунт, а несколько action могут уйти в одну миллисекунду.
    private int $lastNonce = 0;

    /**
     * @param string        $agentPrivKey   приватный ключ агента (0x..., 32 байта)
     * @param string        $accountAddress адрес ОСНОВНОГО аккаунта (для info-запросов)
     * @param bool          $isMainnet      true = mainnet, false = testnet
     * @param string|null   $vaultAddress   адрес vault/субаккаунта (обычно null)
     * @param callable|null $transport      кастомный транспорт fn($url,$payload):?array
     * @param int           $timeout        таймаут HTTP, сек
     * @param string|null   $noncePath      файл для межпроцессного nonce (см. nextNonce)
     */
    public function __construct(
        string $agentPrivKey,
        string $accountAddress,
        bool $isMainnet = true,
        ?string $vaultAddress = null,
        ?callable $transport = null,
        int $timeout = 15,
        ?string $noncePath = null
    ) {
        $this->privKey = $agentPrivKey;
        $this->accountAddress = strtolower($accountAddress);
        $this->isMainnet = $isMainnet;
        $this->vaultAddress = $vaultAddress;
        $this->transport = $transport;
        $this->timeout = $timeout;
        $this->noncePath = $noncePath;
    }

    /**
     * Эффективный адрес аккаунта этого клиента (субаккаунт, если задан, иначе
     * мастер). Это и есть «scope» для info-запросов и для лизы владения символом:
     * на каждом адресе действует своё правило «одна позиция на символ».
     */
    public function account(): string
    {
        return $this->accountAddress;
    }

    private function infoUrl(): string
    {
        return $this->isMainnet ? self::MAINNET_INFO : self::TESTNET_INFO;
    }

    private function exchangeUrl(): string
    {
        return $this->isMainnet ? self::MAINNET_EXCHANGE : self::TESTNET_EXCHANGE;
    }

    // ===================================================== INFO

    /**
     * @return array decoded /info response
     * @throws \RuntimeException при сетевой ошибке/невалидном JSON. Раньше сбой
     *         молча превращался в [] — из-за этого getPosition() при недоступном
     *         API возвращал null («позиции нет»), и закрытие считалось успешным,
     *         хотя биржа не отвечала. Теперь сбой сети явный.
     */
    public function info(array $payload): array
    {
        return $this->httpPost($this->infoUrl(), $payload);
    }

    /** Загружает perp-метаданные и строит карты coin→asset / asset→szDecimals. */
    public function loadMeta(bool $force = false): void
    {
        if ($this->coinToAsset !== null && !$force) {
            return;
        }
        $meta = $this->info(['type' => 'meta']);
        $universe = $meta['universe'] ?? [];
        if ($universe === []) {
            // Не кэшируем пустой ответ: иначе один сбой meta «отравил» бы клиент
            // до конца процесса (все nameToAsset кидали бы «неизвестный символ»).
            throw new \RuntimeException('HyperliquidClient: meta без universe — ответ не кэшируется');
        }
        $this->coinToAsset = [];
        $this->assetToSzDecimals = [];
        $this->upperToCoin = [];
        foreach ($universe as $asset => $info) {
            $name = $info['name'] ?? null;
            if ($name === null) {
                continue;
            }
            $this->coinToAsset[$name] = $asset;
            $this->assetToSzDecimals[$asset] = (int) ($info['szDecimals'] ?? 0);
            $this->upperToCoin[strtoupper($name)] = $name;
        }
    }

    /**
     * Каноническое (биржевое) имя монеты по символу в любом регистре.
     * Бот хранит символы в верхнем регистре ('KPEPE'), биржа — в смешанном
     * ('kPEPE'); без приведения такие монеты не находились бы в meta/allMids/
     * позициях и direct-режим молча пропускал бы их (бумага торгует, реал нет).
     * Неизвестный символ возвращается как есть — ошибку даст nameToAsset.
     */
    public function canonicalCoin(string $coin): string
    {
        $this->loadMeta();
        if (isset($this->coinToAsset[$coin])) {
            return $coin;   // уже каноническое имя
        }
        return $this->upperToCoin[strtoupper($coin)] ?? $coin;
    }

    public function nameToAsset(string $coin): int
    {
        $coin = $this->canonicalCoin($coin);   // 'KPEPE' → 'kPEPE' (loadMeta внутри)
        if (!isset($this->coinToAsset[$coin])) {
            throw new \RuntimeException('HyperliquidClient: неизвестный символ ' . $coin);
        }
        return $this->coinToAsset[$coin];
    }

    public function szDecimals(string $coin): int
    {
        $asset = $this->nameToAsset($coin);
        return $this->assetToSzDecimals[$asset] ?? 0;
    }

    /** @return array<string,string> coin → mid price (строки) */
    public function allMids(): array
    {
        return $this->info(['type' => 'allMids']);
    }

    public function userState(?string $address = null): array
    {
        return $this->info(['type' => 'clearinghouseState', 'user' => $address ?? $this->accountAddress]);
    }

    /** Открытые ордера (базовый формат). */
    public function openOrders(?string $address = null): array
    {
        return $this->info(['type' => 'openOrders', 'user' => $address ?? $this->accountAddress]);
    }

    /** Открытые ордера с фронтенд-полями (cloid, isTrigger, triggerPx и пр.). */
    public function frontendOpenOrders(?string $address = null): array
    {
        return $this->info(['type' => 'frontendOpenOrders', 'user' => $address ?? $this->accountAddress]);
    }

    /**
     * Текущая позиция по символу или null. Возвращает нормализованное:
     * ['coin','szi'(float, знак=сторона),'entryPx','positionValue','unrealizedPnl','leverage'].
     */
    public function getPosition(string $coin, ?string $address = null): ?array
    {
        $coin  = $this->canonicalCoin($coin);   // 'KPEPE' → 'kPEPE': coin в позициях биржевой
        $state = $this->userState($address);
        foreach ($state['assetPositions'] ?? [] as $ap) {
            $pos = $ap['position'] ?? [];
            if (($pos['coin'] ?? null) !== $coin) {
                continue;
            }
            $szi = (float) ($pos['szi'] ?? 0);
            if ($szi === 0.0) {
                return null;
            }
            return [
                'coin'           => $coin,
                'szi'            => $szi,
                'side'           => $szi > 0 ? 'long' : 'short',
                'size'           => abs($szi),
                'entryPx'        => isset($pos['entryPx']) ? (float) $pos['entryPx'] : null,
                'positionValue'  => (float) ($pos['positionValue'] ?? 0),
                'unrealizedPnl'  => (float) ($pos['unrealizedPnl'] ?? 0),
                'leverage'       => $pos['leverage'] ?? null,
            ];
        }
        return null;
    }

    // ===================================================== ОКРУГЛЕНИЕ

    /** Округление цены: 5 значащих цифр, затем (6 - szDecimals) знаков (perp). */
    public function roundPx(string $coin, float $px): float
    {
        $sz = $this->szDecimals($coin);
        // Целочисленные цены Hyperliquid принимает всегда — сверх правила 5
        // значащих цифр (важно для дорогих активов, напр. BTC > 100000). Не режем
        // их %.5g, иначе 123456 превратилось бы в 123460.
        $intPx = round($px);
        if (abs($intPx - $px) < 1e-9) {
            return $intPx;
        }
        $sig = (float) sprintf('%.5g', $px);
        return round($sig, 6 - $sz);
    }

    /** Округление размера до szDecimals. */
    public function roundSz(string $coin, float $sz): float
    {
        return round($sz, $this->szDecimals($coin));
    }

    /** Цена для маркет-ордера (агрессивный лимит с проскальзыванием). */
    public function slippagePrice(string $coin, bool $isBuy, float $slippage, ?float $px = null): float
    {
        if ($px === null) {
            $coin = $this->canonicalCoin($coin);   // ключи allMids — биржевые ('kPEPE')
            $mids = $this->allMids();
            if (!isset($mids[$coin])) {
                throw new \RuntimeException('slippagePrice: нет mid для ' . $coin);
            }
            $px = (float) $mids[$coin];
        }
        $px *= $isBuy ? (1 + $slippage) : (1 - $slippage);
        return $this->roundPx($coin, $px);
    }

    // ===================================================== EXCHANGE

    /**
     * Один ордер. order_type: ['limit'=>['tif'=>'Gtc'|'Ioc'|'Alo']] |
     *   ['trigger'=>['triggerPx'=>float,'isMarket'=>bool,'tpsl'=>'tp'|'sl']]
     */
    public function order(
        string $coin,
        bool $isBuy,
        float $sz,
        float $limitPx,
        array $orderType,
        bool $reduceOnly = false,
        ?string $cloid = null
    ): array {
        $req = [
            'coin' => $coin, 'is_buy' => $isBuy, 'sz' => $sz, 'limit_px' => $limitPx,
            'order_type' => $orderType, 'reduce_only' => $reduceOnly,
        ];
        if ($cloid !== null) {
            $req['cloid'] = $cloid;
        }
        return $this->bulkOrders([$req]);
    }

    /**
     * Пачка ордеров одним подписанным action (сетка/брекет = один вызов).
     * grouping: 'na' | 'normalTpsl' | 'positionTpsl'.
     * @param array[] $orderRequests
     */
    public function bulkOrders(array $orderRequests, string $grouping = 'na'): array
    {
        $wires = [];
        foreach ($orderRequests as $req) {
            $wires[] = Signing::orderRequestToWire($req, $this->nameToAsset($req['coin']));
        }
        $action = Signing::orderWiresToAction($wires, null, $grouping);
        return $this->postAction($action);
    }

    /** Маркет-открытие (агрессивный IoC-лимит). */
    public function marketOpen(
        string $coin,
        bool $isBuy,
        float $sz,
        ?float $px = null,
        float $slippage = self::DEFAULT_SLIPPAGE,
        ?string $cloid = null
    ): array {
        $px = $this->slippagePrice($coin, $isBuy, $slippage, $px);
        return $this->order($coin, $isBuy, $sz, $px, ['limit' => ['tif' => 'Ioc']], false, $cloid);
    }

    /** Маркет-закрытие текущей позиции (reduce-only IoC). */
    public function marketClose(
        string $coin,
        ?float $sz = null,
        ?float $px = null,
        float $slippage = self::DEFAULT_SLIPPAGE,
        ?string $cloid = null
    ): ?array {
        $pos = $this->getPosition($coin);
        if ($pos === null) {
            return null;
        }
        $isBuy = $pos['szi'] < 0;       // закрываем шорт покупкой и наоборот
        $size = $sz ?? $pos['size'];
        $px = $this->slippagePrice($coin, $isBuy, $slippage, $px);
        return $this->order($coin, $isBuy, $size, $px, ['limit' => ['tif' => 'Ioc']], true, $cloid);
    }

    public function cancel(string $coin, int $oid): array
    {
        return $this->bulkCancel([['coin' => $coin, 'oid' => $oid]]);
    }

    /** @param array[] $cancels [['coin'=>..,'oid'=>int], ...] */
    public function bulkCancel(array $cancels): array
    {
        $action = [
            'type' => 'cancel',
            'cancels' => array_map(fn($c) => [
                'a' => $this->nameToAsset($c['coin']),
                'o' => (int) $c['oid'],
            ], $cancels),
        ];
        return $this->postAction($action);
    }

    public function cancelByCloid(string $coin, string $cloid): array
    {
        return $this->bulkCancelByCloid([['coin' => $coin, 'cloid' => $cloid]]);
    }

    /** @param array[] $cancels [['coin'=>..,'cloid'=>'0x..'], ...] */
    public function bulkCancelByCloid(array $cancels): array
    {
        $action = [
            'type' => 'cancelByCloid',
            'cancels' => array_map(fn($c) => [
                'asset' => $this->nameToAsset($c['coin']),
                'cloid' => $c['cloid'],
            ], $cancels),
        ];
        return $this->postAction($action);
    }

    public function updateLeverage(string $coin, int $leverage, bool $isCross = true): array
    {
        $action = [
            'type'     => 'updateLeverage',
            'asset'    => $this->nameToAsset($coin),
            'isCross'  => $isCross,
            'leverage' => $leverage,
        ];
        return $this->postAction($action);
    }

    // ===================================================== ПОДПИСЬ + POST

    private function postAction(array $action): array
    {
        $nonce = $this->nextNonce();
        $sig = Signing::signL1Action($this->privKey, $action, $this->vaultAddress, $nonce, null, $this->isMainnet);
        $payload = [
            'action'    => $action,
            'nonce'     => $nonce,
            'signature' => $sig,
        ];
        if ($this->vaultAddress !== null) {
            $payload['vaultAddress'] = $this->vaultAddress;
        }
        return $this->httpPost($this->exchangeUrl(), $payload);
    }

    /**
     * Строго возрастающий nonce. Hyperliquid требует, чтобы nonce на аккаунт не
     * повторялся и шёл вверх; несколько action могут уйти в одну миллисекунду.
     * Если задан noncePath — счётчик хранится в файле под flock, что держит
     * монотонность и МЕЖДУ процессами (бот + дашборд на тот же аккаунт), а не
     * только в пределах одного процесса. Без пути — прежнее поведение в памяти.
     */
    private function nextNonce(): int
    {
        $now = Signing::getTimestampMs();
        if ($this->noncePath === null) {
            $nonce = $now > $this->lastNonce ? $now : $this->lastNonce + 1;
            $this->lastNonce = $nonce;
            return $nonce;
        }
        $fp = @fopen($this->noncePath, 'c+');
        if ($fp === false) {
            // файл недоступен — деградируем до внутрипроцессного счётчика
            $nonce = $now > $this->lastNonce ? $now : $this->lastNonce + 1;
            $this->lastNonce = $nonce;
            return $nonce;
        }
        flock($fp, LOCK_EX);
        $stored = (int) stream_get_contents($fp);
        $nonce  = $now > $stored ? $now : $stored + 1;
        rewind($fp);
        ftruncate($fp, 0);
        fwrite($fp, (string) $nonce);
        fflush($fp);
        flock($fp, LOCK_UN);
        fclose($fp);
        $this->lastNonce = $nonce;
        return $nonce;
    }

    /** Генерирует случайный cloid (16 байт) для тегирования наших ордеров. */
    public static function newCloid(): string
    {
        return '0x' . bin2hex(random_bytes(16));
    }

    // ===================================================== HTTP

    /**
     * POST JSON → decoded array. Сетевая ошибка/невалидный JSON = исключение,
     * НЕ null: молчаливый сбой здесь маскировал бы неисполненные закрытия и
     * «пустые» позиции у вызывающего кода (hl_executor).
     */
    private function httpPost(string $url, array $payload): array
    {
        if ($this->transport !== null) {
            $res = ($this->transport)($url, $payload);
            if (!is_array($res)) {
                throw new \RuntimeException('HyperliquidClient: транспорт не вернул ответ для ' . $url);
            }
            return $res;
        }
        $body = json_encode($payload, JSON_UNESCAPED_SLASHES);
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $body,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => $this->timeout,
            CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
        ]);
        $resp = curl_exec($ch);
        $err  = curl_error($ch);
        curl_close($ch);
        if ($err || $resp === false) {
            throw new \RuntimeException('HyperliquidClient: сетевая ошибка ' . $url . ': ' . ($err !== '' ? $err : 'пустой ответ'));
        }
        $decoded = json_decode($resp, true);
        if (!is_array($decoded)) {
            throw new \RuntimeException('HyperliquidClient: невалидный JSON от ' . $url);
        }
        return $decoded;
    }
}
