<?php
// =============================================================
// WEBHOOK NOTIFIER — отправка сигналов на внешние trading-сервисы
// =============================================================

require_once __DIR__ . '/config.php';
require_once __DIR__ . '/logger.php';

//
// Webhook — независимый потребитель сигнала, работает параллельно
// с симулятором и executor. Не зависит от них и не влияет на них.
//
// Поддерживаемые сервисы:
//   katoshi  — https://katoshi.ai (Hyperliquid-нативный бот)
//   altrady  — https://altrady.com (поддерживает Hyperliquid)
//   custom   — произвольный URL с JSON-шаблоном и плейсхолдерами
//
// Настройки хранятся на уровне пула/кошелька:
// {
//   "webhooks": [
//     {
//       "enabled": true,
//       "service": "katoshi",
//       "api_key": "xxx",
//       "bot_id": 12345
//     },
//     {
//       "enabled": true,
//       "service": "altrady",
//       "api_key": "xxx",
//       "api_secret": "xxx",
//       "exchange": "HYPE",           // exchange code в Altrady для Hyperliquid
//       "symbol_prefix": "HYPE_USDT_" // префикс символа: HYPE_USDT_BTC
//     },
//     {
//       "enabled": true,
//       "service": "custom",
//       "url": "https://your-service.com/webhook",
//       "method": "POST",             // POST (default) или GET
//       "headers": {                  // опциональные заголовки
//         "Authorization": "Bearer xxx",
//         "X-Secret": "yyy"
//       },
//       "body_template": "{\"action\": \"{{action}}\", \"symbol\": \"{{symbol}}\", \"side\": \"{{side}}\", \"size_usd\": {{size_usd}}, \"price\": {{price}}}"
//     }
//   ]
// }
//
// Плейсхолдеры для custom body_template:
//   {{action}}             → 'open' | 'close' | 'partial_close'
//   {{symbol}}             → 'BTC', 'ETH', ... (внутренняя нормализация, верхний регистр)
//   {{symbol_exchange}}    → точный тикер Hyperliquid ('kPEPE'); для обычных монет = {{symbol}}
//   {{side}}               → 'long' | 'short' (для partial_close — сторона позиции)
//   {{size_usd}}           → размер позиции в USD (на момент действия)
//   {{closed_size_usd}}    → сколько закрыли (только partial_close; иначе 0)
//   {{remaining_size_usd}} → сколько осталось открыто после частичного (иначе 0)
//   {{close_fraction}}     → доля закрытия 0..1 (только partial_close; иначе 0)
//   {{price}}              → цена (для close/partial — цена выхода со слиппеджем)
//   {{leverage}}           → плечо
//   {{is_buy}}             → true | false
//   {{timestamp}}          → unix timestamp
//   {{wallet_id}}          → id кошелька/пула
//   {{wallet_label}}       → название кошелька/пула
//
// action partial_close (включается partial_close_mode='on'):
//   custom  — получает {{closed_size_usd}}/{{remaining_size_usd}}/{{close_fraction}},
//             ваш исполнитель уменьшает позицию на эту долю.
//   katoshi — встречный open_position на closed_size_usd (Hyperliquid односторонний,
//             поэтому это уменьшает позицию, а не открывает хедж).
//   altrady — НЕ поддерживает частичное: сигнал пропускается с предупреждением.
// =============================================================

// Endpoint'ы сервисов (актуальны на июнь 2026, из официальной документации)
define('KATOSHI_SIGNAL_URL', 'https://katoshi.ai/api/v1/signal');
define('ALTRADY_SIGNAL_URL', 'https://api.altrady.com/v2/signal_bot_positions');
define('TELEGRAM_API_BASE',  'https://api.telegram.org');

// =============================================================
// Фильтр для execution_mode=direct
// =============================================================

/**
 * Оставляет только ИНФОРМАЦИОННЫЕ вебхуки (telegram). Используется всеми
 * точками отправки для сущностей в execution_mode=direct: торговые приёмники
 * (katoshi/altrady/custom) там глушатся — иначе сигнал исполнялся бы дважды
 * (биржей и вебхук-ботом), — а уведомления продолжают приходить. Единый фильтр,
 * чтобы executor, watching, риск-свип и пулы не расходились в этом решении.
 */
function webhooks_notify_only(array $webhooks): array
{
    return array_values(array_filter(
        $webhooks,
        fn($wh) => (($wh['service'] ?? '') === 'telegram')
    ));
}

// =============================================================
// Главный диспетчер — вызывается из executor.php
// =============================================================

/**
 * Отправляет сигнал на все включённые webhook-интеграции.
 * Вызывается для каждого сигнала независимо от executor_mode.
 *
 * @param array  $signal      сигнал из engine.php
 * @param array  $webhooks    массив webhook-конфигов из настроек пула/кошелька
 * @param array  $context     ['wallet_id', 'wallet_label', 'size_usd', 'current_price']
 * @param string $action      'open' | 'close'
 */
function dispatch_webhooks(array $signal, array $webhooks, array $context, string $action): void
{
    if (empty($webhooks)) {
        return;
    }

    foreach ($webhooks as $webhook) {
        if (empty($webhook['enabled'])) {
            continue;
        }

        $service = $webhook['service'] ?? 'custom';

        try {
            switch ($service) {
                case 'katoshi':
                    send_katoshi_webhook($signal, $webhook, $context, $action);
                    break;
                case 'altrady':
                    send_altrady_webhook($signal, $webhook, $context, $action);
                    break;
                case 'custom':
                    send_custom_webhook($signal, $webhook, $context, $action);
                    break;
                case 'telegram':
                    send_telegram_webhook($signal, $webhook, $context, $action);
                    break;
                default:
                    bot_log('WEBHOOK: unknown service "' . $service . '", skipping');
            }
        } catch (\Throwable $e) {
            bot_log('WEBHOOK ERROR [' . $service . ']: ' . $e->getMessage());
        }
    }
}

// =============================================================
// Хелперы уведомлений — единая сборка context + dispatch
// =============================================================
//
// До этих хелперов блок «собрать context [wallet_id, wallet_label, size_usd,
// current_price] + dispatch_webhooks» был выписан вручную ~10 раз в трекерах,
// watching и sweep-risk — и легко расходился (а паритет бумаги с вебхуком держится
// именно на нём). Теперь форма context живёт в одном месте.
//
// Идентичность (wallet_id/wallet_label) передаётся уже разрешённой: для кошелька
// это walletMeta, для пула — 'pool_'.$poolId + label. Все хелперы сами проверяют
// пустой список вебхуков, так что вызывающему коду guard не нужен.

/**
 * Open-вебхук: позиция открыта (обычный вход, реверс или отложенный вход).
 * $signal уже несёт сторону НОВОЙ позиции.
 */
function webhook_notify_open(
    array $webhooks, array $signal,
    string $walletId, string $walletLabel,
    float $sizeUsd, ?float $price
): void {
    if (empty($webhooks)) {
        return;
    }
    dispatch_webhooks($signal, $webhooks, [
        'wallet_id'     => $walletId,
        'wallet_label'  => $walletLabel,
        'size_usd'      => $sizeUsd,
        'current_price' => $price,
    ], 'open');
}

/**
 * Close-вебхук: позиция закрыта целиком. Сторона в сигнале принудительно
 * выставляется на сторону ЗАКРЫВАЕМОЙ позиции (как было в array_merge на местах).
 */
function webhook_notify_close(
    array $webhooks, array $signal, string $positionSide,
    string $walletId, string $walletLabel,
    float $sizeUsd, ?float $price
): void {
    if (empty($webhooks)) {
        return;
    }
    $closeSignal = array_merge($signal, ['side' => $positionSide]);
    dispatch_webhooks($closeSignal, $webhooks, [
        'wallet_id'     => $walletId,
        'wallet_label'  => $walletLabel,
        'size_usd'      => $sizeUsd,
        'current_price' => $price,
    ], 'close');
}

/**
 * Partial_close-вебхук: закрыта доля позиции, остаток открыт. Поля доли
 * (closed/remaining/fraction) добавляются к стандартному context.
 */
function webhook_notify_partial_close(
    array $webhooks, array $signal, string $positionSide,
    string $walletId, string $walletLabel,
    float $sizeUsd, float $closedSize, float $remainingSize, float $fraction,
    ?float $price
): void {
    if (empty($webhooks)) {
        return;
    }
    $closeSignal = array_merge($signal, ['side' => $positionSide]);
    dispatch_webhooks($closeSignal, $webhooks, [
        'wallet_id'          => $walletId,
        'wallet_label'       => $walletLabel,
        'size_usd'           => $sizeUsd,
        'closed_size_usd'    => $closedSize,
        'remaining_size_usd' => $remainingSize,
        'close_fraction'     => round($fraction, 6),
        'current_price'      => $price,
    ], 'partial_close');
}

// =============================================================
// Katoshi — https://katoshi.ai
// =============================================================
// Документация: https://katoshi.gitbook.io/katoshi-docs/api/signal-api
//
// Открытие позиции:
// POST https://katoshi.ai/api/v1/signal
// {
//   "action": "open_position",
//   "coin": "BTC",
//   "is_buy": true,            // true = long, false = short
//   "size_usd": 100,           // размер в USD (или size_pct для % от баланса)
//   "api_key": "YOUR_API_KEY",
//   "bot_id": 12345
// }
//
// Закрытие позиции:
// {
//   "action": "close_position",
//   "coin": "BTC",
//   "api_key": "YOUR_API_KEY",
//   "bot_id": 12345
// }
// =============================================================

function send_katoshi_webhook(array $signal, array $cfg, array $context, string $action): void
{
    $apiKey = $cfg['api_key'] ?? '';
    $botId  = $cfg['bot_id'] ?? 0;

    if (!$apiKey || !$botId) {
        bot_log('KATOSHI: missing api_key or bot_id, skipping');
        return;
    }

    // Katoshi — Hyperliquid-нативный бот: coin должен быть ТОЧНЫМ тикером биржи
    // ('kPEPE'), а не нашей верхнерегистровой нормализацией ('KPEPE'). Для обычных
    // монет значения совпадают; fallback на symbol — для сигналов без биржевого
    // имени (ликвидация/sweep, старые watch-записи).
    $coin = $signal['symbol_exchange'] ?? $signal['symbol'];

    if ($action === 'open') {
        $payload = [
            'action'  => 'open_position',
            'coin'    => $coin,
            'is_buy'  => $signal['side'] === 'long',
            'size_usd'=> (float) ($context['size_usd'] ?? 0),
            'api_key' => $apiKey,
            'bot_id'  => (int) $botId,
        ];
    } elseif ($action === 'partial_close') {
        // У Katoshi нет отдельного «частичного закрытия», но Hyperliquid всегда
        // ОДНОСТОРОННИЙ (нет hedge-режима), поэтому встречный market-ордер на
        // closed_size_usd уменьшает позицию ровно на эту долю (а не открывает хедж).
        // is_buy инвертируем относительно стороны позиции: закрытие long = продажа.
        $closedSize = (float) ($context['closed_size_usd'] ?? 0);
        if ($closedSize <= 0) {
            bot_log('KATOSHI: partial_close без closed_size_usd, пропуск');
            return;
        }
        $payload = [
            'action'  => 'open_position',
            'coin'    => $coin,
            'is_buy'  => $signal['side'] === 'short',  // встречный ордер уменьшает позицию
            'size_usd'=> $closedSize,
            'api_key' => $apiKey,
            'bot_id'  => (int) $botId,
        ];
    } else {
        $payload = [
            'action'  => 'close_position',
            'coin'    => $coin,
            'api_key' => $apiKey,
            'bot_id'  => (int) $botId,
        ];
    }

    $result = webhook_http_post(KATOSHI_SIGNAL_URL, $payload);
    log_webhook_result('katoshi', $action, $signal['symbol'], $payload, $result, $context);
}

// =============================================================
// Altrady — https://altrady.com
// =============================================================
// Документация: https://support.altrady.com/en/article/webhook-signals-open-...
//
// Открытие позиции:
// POST https://api.altrady.com/v2/signal_bot_positions
// {
//   "api_key": "YOUR_API_KEY",
//   "api_secret": "YOUR_API_SECRET",
//   "action": "open",
//   "exchange": "HYPE",           // Hyperliquid exchange code в Altrady
//   "symbol": "HYPE_USDT_BTC",    // формат: {exchange}_{quote}_{base}
//   "side": "long"
// }
//
// Закрытие позиции (или разворот через reverse):
// {
//   "api_key": "YOUR_API_KEY",
//   "api_secret": "YOUR_API_SECRET",
//   "action": "close",             // или "reverse" + "side" для разворота
//   "exchange": "HYPE",
//   "symbol": "HYPE_USDT_BTC"
// }
// =============================================================

function send_altrady_webhook(array $signal, array $cfg, array $context, string $action): void
{
    $apiKey    = $cfg['api_key'] ?? '';
    $apiSecret = $cfg['api_secret'] ?? '';
    $exchange  = $cfg['exchange'] ?? 'HYPE';
    $prefix    = $cfg['symbol_prefix'] ?? ($exchange . '_USDT_');

    if (!$apiKey || !$apiSecret) {
        bot_log('ALTRADY: missing api_key or api_secret, skipping');
        return;
    }

    // Altrady символ: HYPE_USDT_BTC (exchange_quote_base)
    $altradySymbol = $prefix . strtoupper($signal['symbol']);

    if ($action === 'partial_close') {
        // Altrady signal bot не умеет частичное/уменьшение позиции — только
        // open/close/reverse целиком. Слать close было бы НЕВЕРНО (схлопнул бы
        // позицию полностью, рассинхрон с симулятором). Пропускаем и предупреждаем:
        // для partial_close_mode='on' используйте custom или katoshi.
        bot_log('ALTRADY: partial_close НЕ поддерживается (только полный close). '
            . 'Для частичного закрытия используйте custom/katoshi. Пропуск ' . $signal['symbol']);
        return;
    }

    if ($action === 'open') {
        $payload = [
            'api_key'    => $apiKey,
            'api_secret' => $apiSecret,
            'action'     => 'open',
            'exchange'   => $exchange,
            'symbol'     => $altradySymbol,
            'side'       => $signal['side'],  // 'long' | 'short'
        ];
    } else {
        // При закрытии используем 'close' — закрывает позицию в том же направлении.
        // Если нужен разворот (close + open в другую сторону), используйте 'reverse'.
        $payload = [
            'api_key'    => $apiKey,
            'api_secret' => $apiSecret,
            'action'     => 'close',
            'exchange'   => $exchange,
            'symbol'     => $altradySymbol,
        ];
    }

    $result = webhook_http_post(ALTRADY_SIGNAL_URL, $payload);
    log_webhook_result('altrady', $action, $signal['symbol'], $payload, $result, $context);
}

// =============================================================
// Telegram — уведомление сообщением (не торговый сигнал)
// =============================================================
// Информационный потребитель: шлёт человекочитаемое сообщение о действии
// бота в чат/канал через Bot API (api.telegram.org/bot{token}/sendMessage).
// Обычно включается флагом telegram_notify (Настройки/кошелёк) — тогда
// resolve_wallet_settings добавляет этот элемент в webhooks синтетически;
// но его можно задать и руками в webhooks (в т.ч. на пуле в pools.json).
// =============================================================

function send_telegram_webhook(array $signal, array $cfg, array $context, string $action): void
{
    $token  = trim((string) ($cfg['bot_token'] ?? ''));
    $chatId = trim((string) ($cfg['chat_id'] ?? ''));

    if ($token === '' || $chatId === '') {
        bot_log('TELEGRAM: missing bot_token or chat_id, skipping');
        return;
    }

    $text = telegram_format_signal_text($signal, $context, $action);

    $result = webhook_http_post(TELEGRAM_API_BASE . '/bot' . $token . '/sendMessage', [
        'chat_id'                  => $chatId,
        'text'                     => $text,
        'disable_web_page_preview' => true,
    ]);
    log_webhook_result('telegram', $action, $signal['symbol'], ['chat_id' => $chatId], $result, $context);
}

/**
 * Человекочитаемый текст уведомления. Чистая функция (тестируется).
 */
function telegram_format_signal_text(array $signal, array $context, string $action): string
{
    $side     = strtoupper((string) ($signal['side'] ?? ''));
    $symbol   = (string) ($signal['symbol_exchange'] ?? $signal['symbol'] ?? '?');
    $label    = (string) ($context['wallet_label'] ?? $context['wallet_id'] ?? '?');
    $sizeUsd  = (float) ($context['size_usd'] ?? 0);
    $price    = (float) ($context['current_price'] ?? 0);
    $leverage = (int) ($signal['leverage'] ?? 0);

    switch ($action) {
        case 'open':          $head = '🟢 ОТКРЫТИЕ';           break;
        case 'close':         $head = '🔴 ЗАКРЫТИЕ';           break;
        case 'partial_close': $head = '🟠 ЧАСТИЧНОЕ ЗАКРЫТИЕ'; break;
        default:              $head = 'ℹ️ ' . strtoupper($action);
    }

    $lines   = [trim("$head $side $symbol")];
    $lines[] = 'Трейдер: ' . $label;

    if ($sizeUsd > 0) {
        $lines[] = 'Размер: $' . number_format($sizeUsd, 2, '.', ' ')
            . ($leverage > 0 ? " × {$leverage}x" : '');
    }
    if ($action === 'partial_close') {
        $closed    = (float) ($context['closed_size_usd'] ?? 0);
        $remaining = (float) ($context['remaining_size_usd'] ?? 0);
        $fraction  = (float) ($context['close_fraction'] ?? 0);
        $lines[] = sprintf('Закрыто: $%s (%.0f%%), осталось: $%s',
            number_format($closed, 2, '.', ' '), $fraction * 100,
            number_format($remaining, 2, '.', ' '));
    }
    if ($price > 0) {
        $lines[] = 'Цена: ' . rtrim(rtrim(number_format($price, 6, '.', ' '), '0'), '.');
    }

    return implode("\n", $lines);
}

// =============================================================
// Custom webhook — произвольный URL + JSON-шаблон
// =============================================================
// Плейсхолдеры в body_template заменяются реальными значениями сигнала.
// Пример шаблона:
// {
//   "action": "{{action}}",
//   "symbol": "{{symbol}}",
//   "side": "{{side}}",
//   "size_usd": {{size_usd}},
//   "price": {{price}},
//   "is_buy": {{is_buy}},
//   "leverage": {{leverage}},
//   "timestamp": {{timestamp}}
// }
// =============================================================

function send_custom_webhook(array $signal, array $cfg, array $context, string $action): void
{
    $url      = $cfg['url'] ?? '';
    $template = $cfg['body_template'] ?? '';
    $method   = strtoupper($cfg['method'] ?? 'POST');
    $headers  = $cfg['headers'] ?? [];

    if (!$url) {
        bot_log('CUSTOM WEBHOOK: missing url, skipping');
        return;
    }

    // SSRF-защита: только внешние http/https адреса; резолвим хост и проверяем
    // КАЖДЫЙ итоговый IP. Проверенные IP пиниваем в curl (CURLOPT_RESOLVE) —
    // без этого curl резолвил бы DNS заново и проверка от rebinding бесполезна.
    if (!webhook_url_is_safe($url, $reason, $pinnedIps)) {
        bot_log('CUSTOM WEBHOOK: недопустимый URL (SSRF-защита: ' . $reason . '), skipping: ' . $url);
        return;
    }

    $isBuy    = $signal['side'] === 'long';
    $sizeUsd  = (float) ($context['size_usd'] ?? 0);
    $price    = (float) ($context['current_price'] ?? 0);

    // Поля частичного закрытия (0 для open/close — схема стабильна для шаблона).
    $closedSizeUsd    = (float) ($context['closed_size_usd']    ?? 0);
    $remainingSizeUsd = (float) ($context['remaining_size_usd'] ?? 0);
    $closeFraction    = (float) ($context['close_fraction']     ?? 0);

    // Значения для замены плейсхолдеров.
    // ВАЖНО: '{{symbol_exchange}}' стоит РАНЬШЕ '{{symbol}}' — str_replace идёт по
    // порядку ключей, и замена '{{symbol}}' первой исказила бы длинный плейсхолдер.
    $replacements = [
        '{{action}}'             => $action,            // open | close | partial_close
        '{{symbol_exchange}}'    => $signal['symbol_exchange'] ?? $signal['symbol'], // точный тикер биржи ('kPEPE')
        '{{symbol}}'             => $signal['symbol'],
        '{{side}}'               => $signal['side'],
        '{{size_usd}}'           => $sizeUsd,
        '{{closed_size_usd}}'    => $closedSizeUsd,     // сколько закрыли (partial_close)
        '{{remaining_size_usd}}' => $remainingSizeUsd,  // сколько осталось открыто
        '{{close_fraction}}'     => $closeFraction,     // доля закрытия 0..1
        '{{price}}'              => $price,
        '{{is_buy}}'             => $isBuy ? 'true' : 'false',
        '{{leverage}}'           => (int) $signal['leverage'],
        '{{timestamp}}'          => time(),
        '{{wallet_id}}'          => $context['wallet_id'] ?? '',
        '{{wallet_label}}'       => $context['wallet_label'] ?? '',
    ];

    // Если шаблон задан — используем его, иначе стандартный JSON
    if ($template) {
        $body = str_replace(
            array_keys($replacements),
            array_values($replacements),
            $template
        );
    } else {
        // Стандартный payload если шаблон не задан
        $body = json_encode([
            'action'             => $action,
            'symbol'             => $signal['symbol'],
            'side'               => $signal['side'],
            'size_usd'           => $sizeUsd,
            'closed_size_usd'    => $closedSizeUsd,
            'remaining_size_usd' => $remainingSizeUsd,
            'close_fraction'     => $closeFraction,
            'price'              => $price,
            'is_buy'             => $isBuy,
            'leverage'           => (int) $signal['leverage'],
            'timestamp'          => time(),
            'wallet_id'          => $context['wallet_id'] ?? '',
            'wallet_label'       => $context['wallet_label'] ?? '',
        ]);
    }

    // Валидируем что получился корректный JSON (только для template)
    if ($template) {
        $decoded = json_decode($body, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            bot_log('CUSTOM WEBHOOK: invalid JSON after template substitution: ' . json_last_error_msg());
            bot_log('CUSTOM WEBHOOK body: ' . $body);
            return;
        }
    }

    $extraHeaders = [];
    foreach ($headers as $key => $value) {
        $extraHeaders[] = $key . ': ' . $value;
    }

    $result = webhook_http_request($url, $body, $method, $extraHeaders, $pinnedIps);
    log_webhook_result('custom', $action, $signal['symbol'], ['url' => $url, 'body' => $body], $result, $context);
}

// =============================================================
// SSRF-защита
// =============================================================

/**
 * Проверяет, что IP не из приватного/зарезервированного диапазона.
 * FILTER_FLAG_NO_RES_RANGE покрывает в т.ч. 127.0.0.0/8, 0.0.0.0/8,
 * 169.254.0.0/16 (link-local, сюда же cloud-metadata 169.254.169.254)
 * и IPv6 ::1; NO_PRIV_RANGE — 10/8, 172.16/12, 192.168/16, fc00::/7.
 */
function webhook_ip_is_public(string $ip): bool
{
    return filter_var(
        $ip,
        FILTER_VALIDATE_IP,
        FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
    ) !== false;
}

/**
 * Безопасен ли URL для исходящего запроса (анти-SSRF).
 * Требования: схема http/https, есть хост, и ВСЕ резолвящиеся IP (A + AAAA)
 * — публичные. Резолв хоста защищает от DNS-rebinding, когда внешне валидное
 * имя указывает на 127.0.0.1 / приватную сеть / metadata-эндпоинт.
 * Если хост не резолвится — считаем небезопасным (fail-closed).
 *
 * @param string $url
 * @param string|null $reason  причина отказа (out-параметр для лога)
 * @param array|null  $resolvedIps  out-параметр: проверенные публичные IP
 *                    хоста — их нужно запинить в curl (CURLOPT_RESOLVE), иначе
 *                    curl резолвит DNS заново и проверка от rebinding бесполезна.
 */
function webhook_url_is_safe(string $url, ?string &$reason = null, ?array &$resolvedIps = null): bool
{
    $resolvedIps = [];
    $parsed = parse_url($url);
    $scheme = strtolower($parsed['scheme'] ?? '');
    $host   = $parsed['host'] ?? '';

    if (!in_array($scheme, ['http', 'https'], true)) {
        $reason = 'схема не http/https';
        return false;
    }
    if ($host === '') {
        $reason = 'пустой хост';
        return false;
    }

    // Собираем кандидатов-IP: либо хост уже IP-литерал, либо резолвим A/AAAA.
    $ips = [];
    if (filter_var($host, FILTER_VALIDATE_IP)) {
        $ips[] = $host;
    } else {
        $a = @gethostbynamel($host);
        if (is_array($a)) {
            $ips = array_merge($ips, $a);
        }
        foreach (@dns_get_record($host, DNS_AAAA) ?: [] as $rec) {
            if (!empty($rec['ipv6'])) {
                $ips[] = $rec['ipv6'];
            }
        }
    }

    if (empty($ips)) {
        $reason = 'хост не резолвится';
        return false;
    }

    foreach ($ips as $ip) {
        if (!webhook_ip_is_public($ip)) {
            $reason = 'приватный/зарезервированный IP ' . $ip;
            return false;
        }
    }

    $resolvedIps = array_values(array_unique($ips));
    return true;
}

// =============================================================
// HTTP helpers
// =============================================================

/**
 * Отправляет POST-запрос с JSON-payload.
 * Возвращает ['http_code' => int, 'response' => string, 'error' => string]
 */
function webhook_http_post(string $url, array $payload): array
{
    return webhook_http_request($url, json_encode($payload), 'POST');
}

/**
 * Выполняет HTTP-запрос (POST или GET) с JSON-телом.
 *
 * @param array $pinnedIps  проверенные публичные IP хоста (из webhook_url_is_safe).
 *   Если заданы — запиниваем их через CURLOPT_RESOLVE, чтобы curl НЕ резолвил
 *   DNS повторно. Иначе анти-SSRF проверка бесполезна: между проверкой и
 *   запросом DNS с коротким TTL мог бы вернуть приватный IP (rebinding).
 * @param int   $timeoutSec  таймаут запроса (по умолчанию 10; executor передаёт
 *   API_TIMEOUT_SEC, переиспользуя этот же хелпер вместо своего curl-блока).
 */
function webhook_http_request(string $url, string $body, string $method = 'POST', array $extraHeaders = [], array $pinnedIps = [], int $timeoutSec = 10): array
{
    $ch = curl_init($url);

    $headers = array_merge(['Content-Type: application/json'], $extraHeaders);

    if ($method === 'POST') {
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
    } else {
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
        if ($body) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
        }
    }

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => $timeoutSec,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_SSL_VERIFYPEER => true,
        // Не ходим по редиректам: 30x на приватный адрес обошёл бы SSRF-проверку.
        CURLOPT_FOLLOWLOCATION => false,
        // Разрешаем только http/https (без file://, gopher:// и т.п.).
        CURLOPT_PROTOCOLS      => CURLPROTO_HTTP | CURLPROTO_HTTPS,
        CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
    ]);

    // Пиннинг проверенных IP: curl подключится к ним, а не будет резолвить заново.
    if (!empty($pinnedIps)) {
        $parsed = parse_url($url);
        $host   = $parsed['host'] ?? '';
        $port   = $parsed['port'] ?? (strtolower($parsed['scheme'] ?? '') === 'http' ? 80 : 443);
        if ($host !== '') {
            curl_setopt($ch, CURLOPT_RESOLVE, [
                $host . ':' . $port . ':' . implode(',', $pinnedIps),
            ]);
        }
    }

    $response  = curl_exec($ch);
    $httpCode  = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $curlError = curl_error($ch);
    curl_close($ch);

    return [
        'http_code' => $httpCode,
        'response'  => $response ?: '',
        'error'     => $curlError,
    ];
}

// =============================================================
// Логирование
// =============================================================

/**
 * Логирует результат отправки webhook в logs/webhooks.log
 */
function log_webhook_result(string $service, string $action, string $symbol, array $payload, array $result, array $context): void
{
    $status = $result['error']
        ? 'ERROR:' . $result['error']
        : 'HTTP:' . $result['http_code'];

    $success = !$result['error'] && in_array($result['http_code'], [200, 201, 204], true);

    $line = sprintf(
        '[%s] WEBHOOK %s | service=%s action=%s symbol=%s status=%s wallet=%s',
        date('Y-m-d H:i:s'),
        $success ? 'OK' : 'FAIL',
        $service,
        $action,
        $symbol,
        $status,
        $context['wallet_label'] ?? $context['wallet_id'] ?? '?'
    );

    // Пишем в отдельный лог чтобы не засорять bot.log
    $logFile = defined('LOGS_DIR') ? LOGS_DIR . '/webhooks.log' : __DIR__ . '/logs/webhooks.log';
    file_put_contents($logFile, $line . PHP_EOL, FILE_APPEND | LOCK_EX);

    if ($success) {
        bot_log('WEBHOOK OK: ' . $service . ' ' . $action . ' ' . $symbol);
    } else {
        bot_log('WEBHOOK FAIL: ' . $service . ' ' . $action . ' ' . $symbol . ' | ' . $status);
        if ($result['response']) {
            bot_log('WEBHOOK response: ' . substr($result['response'], 0, 300));
        }
    }
}

// =============================================================
// Вспомогательные функции для настроек
// =============================================================

/**
 * Возвращает дефолтную конфигурацию пустого webhook-элемента.
 * Используется при создании нового webhook через dashboard.
 */
function webhook_default_config(string $service = 'katoshi'): array
{
    $base = [
        'enabled' => false,
        'service' => $service,
    ];

    switch ($service) {
        case 'katoshi':
            return array_merge($base, [
                'api_key' => '',
                'bot_id'  => 0,
            ]);
        case 'altrady':
            return array_merge($base, [
                'api_key'       => '',
                'api_secret'    => '',
                'exchange'      => 'HYPE',
                'symbol_prefix' => 'HYPE_USDT_',
            ]);
        case 'telegram':
            return array_merge($base, [
                'bot_token' => '',
                'chat_id'   => '',
            ]);
        case 'custom':
        default:
            return array_merge($base, [
                'url'           => '',
                'method'        => 'POST',
                'headers'       => [],
                'body_template' => '',
            ]);
    }
}
