<?php
// =============================================================
// BOT.PHP — entry point, запускается через cron
// =============================================================
// Вариант A — серверный cron (если работает на хостинге):
//   /usr/local/bin/php /path/to/bot.php
//
// Вариант B — внешний cron через HTTP (cron-job.org и т.п.):
//   GET https://yourdomain.com/bot.php?token=ТВОЙ_CRON_SECRET
//   Без правильного token запрос по HTTP блокируется.
//   CLI-запуск (вариант A) не требует токена.
//
// Обрабатывает ВСЕ включённые кошельки из state/wallets.json
// по очереди, каждый со своим state и настройками.
// =============================================================

require_once __DIR__ . '/config.php';
require_once __DIR__ . '/logger.php';
require_once __DIR__ . '/collector.php';
require_once __DIR__ . '/engine.php';
require_once __DIR__ . '/price_fetcher.php';
require_once __DIR__ . '/storage.php';
require_once __DIR__ . '/balance_tracker.php';
require_once __DIR__ . '/position_tracker.php';
require_once __DIR__ . '/pool_tracker.php';
require_once __DIR__ . '/watching_tracker.php';
require_once __DIR__ . '/webhook_notifier.php';
require_once __DIR__ . '/executor.php';

// --- Проверка дефолтных секретов (защита от деплоя с заводскими значениями) ---
if (defined('CRON_SECRET') && CRON_SECRET === 'change_me_too_please') {
    http_response_code(500);
    die('FATAL: Смените CRON_SECRET в config.php перед использованием бота.');
}
if (defined('DASHBOARD_PASSWORD') && DASHBOARD_PASSWORD === 'change_me_please') {
    http_response_code(500);
    die('FATAL: Смените DASHBOARD_PASSWORD в config.php перед использованием бота.');
}

// --- Auth check for HTTP-triggered cron ----------------------

// php_sapi_name() === 'cli' -> запуск из консоли/серверного cron, токен не нужен.
// Любой другой SAPI (fpm-fcgi и т.п.) -> запрос по HTTP, требуем токен.
if (php_sapi_name() !== 'cli') {
    $token = $_GET['token'] ?? '';
    if (!hash_equals(CRON_SECRET, $token)) {
        http_response_code(403);
        echo "Forbidden\n";
        exit;
    }
    // HTTP-режим (fpm): цикл может идти до CYCLE_COUNT×CYCLE_INTERVAL_SEC (~60с),
    // что дольше дефолтного max_execution_time (30с) и оборвало бы процесс
    // посреди обработки. В CLI лимита нет, поэтому снимаем только под HTTP.
    set_time_limit(0);
    // Внешние cron-сервисы (cron-job.org и т.п.) часто рвут соединение через
    // ~30с, не дожидаясь ответа. Без ignore_user_abort первая же запись в
    // оборванный сокет (echo в execute_mock) убивала бы процесс ПОСРЕДИ цикла —
    // в т.ч. между открытием позиции и постановкой SL/TP в direct-режиме.
    ignore_user_abort(true);
}

// --- Helpers -------------------------------------------------
// Логирование (bot_log / rotate_log_if_needed) живёт в logger.php,
// чтобы dashboard мог писать в лог без подключения точки входа bot.php.

/**
 * Обновляет heartbeat — текущий timestamp.
 * Пишется после каждого ЗАВЕРШЁННОГО цикла; dashboard по нему видит,
 * что бот успевает доводить циклы до конца.
 */
function update_heartbeat(): void
{
    file_put_contents(HEARTBEAT_FILE, (string) time(), LOCK_EX);
}

/**
 * Отмечает сам факт запуска bot.php кроном — до попытки взять lock,
 * поэтому пишется и когда запуск пропущен (предыдущий ещё работает).
 * Dashboard по этой отметке отличает «cron мёртв» от «бот тормозит».
 */
function update_cron_tick(): void
{
    file_put_contents(CRON_TICK_FILE, (string) time(), LOCK_EX);
}

/**
 * Раз в сутки удаляет из БД сигналы старше signals_retention_days
 * (Настройки → Система; 0 = выключено, хранить всё). Вызывается в конце
 * run() — ПОСЛЕ торговых циклов, чтобы очистка не задерживала обработку
 * fills. Метка последнего запуска пишется ДО удаления: если DELETE упадёт,
 * повтор будет через сутки, а не на каждом cron-тике.
 */
function prune_old_signals_daily(): void
{
    $days = (int) (load_wallets_config()['global_settings']['signals_retention_days'] ?? 0);
    if ($days <= 0) {
        return;
    }

    $last = is_file(SIGNALS_PRUNE_TICK_FILE)
        ? (int) trim((string) file_get_contents(SIGNALS_PRUNE_TICK_FILE))
        : 0;
    if (time() - $last < 86400) {
        return;
    }
    file_put_contents(SIGNALS_PRUNE_TICK_FILE, (string) time(), LOCK_EX);

    try {
        $deleted = storage_prune_old_signals($days);
        bot_log("SIGNALS PRUNE: deleted $deleted signal(s) older than $days day(s)");
    } catch (\Throwable $e) {
        bot_log('SIGNALS PRUNE ERROR: ' . $e->getMessage());
    }
}

// --- Main pipeline ---------------------------------------------

/**
 * Обрабатывает одного трейдера внутри пула:
 * fetch fills → engine signals → track_pool_position для каждого сигнала.
 * Executor (mock/live) не вызывается для пул-трейдеров на этом этапе.
 *
 * Engine-state трейдера адресуется парой ('pool_trader', "{poolId}:{traderId}").
 */
function run_pool_trader(array $trader, array $pool, string $poolId, array $globalSettings): void
{
    $traderId    = $trader['id'];
    $traderLabel = $trader['label'] ?? $traderId;
    $address     = $trader['address'];
    $poolLabel   = $pool['label'] ?? $poolId;

    // Эффективные настройки трейдера пула — тот же резолвер слоёв, что у
    // кошельков: глобальные дефолты → пул → трейдер (см. resolve_pool_settings).
    // Раньше здесь мержились только allowed_symbols/cooldown_seconds, а плечо в
    // сигнал попадало ГЛОБАЛЬНОЕ — вебхук пула сообщал приёмнику не то плечо,
    // на котором пул реально торгует. Теперь signal['leverage'] совпадает с
    // resolve_trader_leverage, по которому открывается бумажная позиция.
    $settings = resolve_pool_settings($pool, $trader, $globalSettings);

    $entityType = 'pool_trader';
    $entityId   = $poolId . ':' . $traderId;

    bot_log("--- POOL [$poolLabel] trader=[$traderLabel] address=$address");

    $state     = load_state($entityType, $entityId);
    $sinceTsMs = $state['last_seen_ts'] ?? null;
    $failed    = false;
    $fills     = fetch_trader_fills($address, $sinceTsMs, $failed);

    // Сетевой сбой ≠ «сделок нет»: watermark не трогаем, fills доберутся
    // следующим циклом (иначе пустой ответ сбоя инициализировал бы watermark).
    if ($failed) {
        bot_log("POOL [$poolLabel] [$traderLabel] SKIP: fetch failed, watermark untouched");
        return;
    }

    bot_log("POOL [$poolLabel] [$traderLabel] Fetched fills: " . count($fills));

    // prepare → execute → commit (watermark фиксируется после исполнения).
    // State НЕ передаём загруженным: prepare перечитает его из БД сама — так
    // сброс из дашборда между load_state и commit не будет перезаписан старым
    // watermark. Пустые fills тоже прогоняем: первый запуск кошелька без
    // истории инициализирует watermark «на сейчас» (см. process_fills_prepare).
    $prepared = process_fills_prepare($fills, $settings, $entityType, $entityId);
    $signals  = $prepared['signals'];

    bot_log("POOL [$poolLabel] [$traderLabel] Signals: " . count($signals));

    $poolDirect = ($pool['execution_mode'] ?? 'webhook') === 'direct';
    foreach ($signals as $signal) {
        $detail = null;
        $status = track_pool_position($signal, $poolId, $pool, $trader, $detail);
        if (!$poolDirect) {
            continue;
        }
        // Прямое исполнение пула: реальный ордер уходит, только когда бумага реально
        // действовала как владелец (opened/closed/реверс). 'watching'/'ignored_busy'
        // на биржу не транслируем (ownership и просадка решаются на бумажном слое).
        if (pool_status_forwards_to_exchange($status)) {
            // Паритет: бумага закрыла БЕЗ реверс-ноги (не хватило маржи) →
            // на биржу уходит чистый close, иначе биржа открыла бы ногу,
            // которой нет на бумаге.
            $fwdSignal = $signal;
            if ($status === 'closed' && empty($detail['reversed'])) {
                $fwdSignal['action'] = 'close';
            }
            execute_pool_real_order($fwdSignal, $pool, $trader, $poolId);
        } elseif ($status === 'closed_partial') {
            // Частичное закрытие бумаги → reduce-only ордер на ту же долю реальной
            // позиции (одностороннний неттинг Hyperliquid уменьшает позицию, не хеджит).
            execute_pool_partial_close($signal, $pool, $trader, $poolId, (float) ($detail['fraction'] ?? 0));
        } elseif ($status === 'ignored_no_position' && ($signal['action'] ?? 'auto') === 'close') {
            // Бумага уже flat (например, после внутрисделочной ликвидации), а на
            // бирже мог остаться реальный остаток под нашей лизой — закрываем его.
            // Гарды hl_execute_signal защищают чужие/ничейные позиции.
            execute_pool_close_remainder($signal, $pool, $trader, $poolId);
        }
    }

    commit_state($entityType, $entityId, $prepared['state']);
}

/**
 * Обрабатывает один пул: проходит по всем включённым трейдерам пула.
 */
function run_pool(array $pool, array $globalSettings): void
{
    $poolId    = $pool['id'];
    $poolLabel = $pool['label'] ?? $poolId;

    if (empty($pool['traders'])) {
        bot_log("POOL [$poolLabel] No traders configured, skipping.");
        return;
    }

    $enabledTraders = array_filter($pool['traders'], fn($t) => !empty($t['enabled']) && !empty($t['address']));

    if (empty($enabledTraders)) {
        bot_log("POOL [$poolLabel] No enabled traders, skipping.");
        return;
    }

    bot_log("=== POOL [$poolLabel] traders=" . count($enabledTraders) . ' ===');

    foreach ($enabledTraders as $trader) {
        // Изолируем сбой одного трейдера, чтобы он не валил обработку остальных в пуле.
        try {
            run_pool_trader($trader, $pool, $poolId, $globalSettings);
        } catch (\Throwable $e) {
            bot_log(sprintf('POOL [%s] trader [%s] ERROR: %s',
                $poolLabel, $trader['label'] ?? $trader['id'] ?? '?', $e->getMessage()));
        }
    }
}

/**
 * Обрабатывает пачку кошельков за один батч-запрос к API.
 * Для кошельков без watermark (первый запуск) делает отдельные запросы.
 */
function run_wallets_batch(array $wallets, array $globalSettings): void
{
    if (empty($wallets)) {
        return;
    }

    // Собираем данные для батч-запроса. Engine-state здесь читается ТОЛЬКО ради
    // watermark для запроса: обработка перечитает его заново под wallet-lock
    // (см. run_wallet_with_fills) — иначе сброс из дашборда в окне между этим
    // чтением и commit молча перезаписывался бы старым watermark.
    $batchInput = [];

    foreach ($wallets as $wallet) {
        $walletId  = $wallet['id'];
        $state     = load_state('wallet', $walletId);

        $batchInput[$walletId] = [
            'address'   => $wallet['address'],
            'sinceTsMs' => $state['last_seen_ts'] ?? null,
        ];
    }

    // Параллельный (curl_multi) набор fills сразу для всех кошельков группы
    bot_log('FETCH: requesting fills for ' . count($batchInput) . ' wallet(s) in parallel');
    $failedIds  = [];
    $batchFills = fetch_traders_fills_batch($batchInput, $failedIds);
    $failedSet  = array_flip($failedIds);

    // Обрабатываем каждый кошелёк с его fills.
    // Изолируем сбой одного кошелька — он не должен валить остальные в батче.
    foreach ($wallets as $wallet) {
        $walletId = $wallet['id'];
        $fills    = $batchFills[$walletId] ?? [];
        try {
            run_wallet_with_fills($wallet, $globalSettings, $fills, isset($failedSet[$walletId]));
        } catch (\Throwable $e) {
            bot_log(sprintf('[%s] ERROR: %s', $wallet['label'] ?? $walletId, $e->getMessage()));
        }
    }
}

/**
 * Обрабатывает один кошелёк с уже полученными fills.
 * Принимает уже полученные fills (из батч-запроса), сам их не запрашивает.
 *
 * @param bool $fetchFailed true = fills пусты из-за СЕТЕВОГО сбоя (а не «сделок
 *   нет»): watermark и wallet_sync не трогаем, кошелёк сохраняет приоритет
 *   опроса и будет добран следующим циклом.
 */
function run_wallet_with_fills(array $wallet, array $globalSettings, array $fills, bool $fetchFailed = false): void
{
    $walletId = $wallet['id'];
    $address  = $wallet['address'];
    $label    = $wallet['label'] ?? $walletId;

    if ($fetchFailed) {
        bot_log("[{$label}] SKIP: fetch failed, watermark/sync untouched");
        return;
    }

    $lockFp = fopen(wallet_lock_file($walletId), 'c');
    if (!$lockFp) {
        bot_log("[{$label}] ERROR: cannot open wallet lock file");
        return;
    }
    if (!flock($lockFp, LOCK_EX | LOCK_NB)) {
        bot_log("[{$label}] SKIP: wallet already being processed (lock held)");
        fclose($lockFp);
        return;
    }

    try {
        $settings   = resolve_wallet_settings($wallet, $globalSettings);
        $walletMeta = ['id' => $walletId, 'label' => $label, 'address' => $address];

        bot_log("--- [$label] mode={$settings['executor_mode']} wallet=$address");
        bot_log('[' . $label . '] Fetched fills: ' . count($fills));

        // Адрес трейдера (нужен для proportional sizing) передаётся явно через
        // walletMeta → resolve_position_size_usd в трекере, а не протаскивается
        // через мутацию $settings. Engine-state prepare перечитывает из БД уже
        // ПОД lock (защита от гонки со сбросом из дашборда). Пустые fills тоже
        // прогоняются: кошелёк без единой сделки в истории инициализирует
        // watermark «на сейчас» и перестаёт запрашивать полный userFills.
        $prepared = process_fills_prepare($fills, $settings, 'wallet', $walletId);
        $signals  = $prepared['signals'];

        bot_log("[$label] Signals to execute: " . count($signals));

        execute_signals($signals, $settings, $walletMeta, 'wallet', $walletId);

        commit_state('wallet', $walletId, $prepared['state']);
        storage_update_wallet_sync($walletId);

    } finally {
        flock($lockFp, LOCK_UN);
        fclose($lockFp);
    }
}

/**
 * Одна итерация бота: watching → fills → execute.
 * Вызывается из run() в цикле CYCLE_COUNT раз.
 * Кэш цен сбрасывается перед каждым вызовом — каждая итерация
 * получает свежий allMids.
 */
function run_bot_cycle(int $cycleNum): void
{
    bot_log("--- CYCLE $cycleNum START ---");

    // Сбрасываем кэши цен и equity — каждая итерация должна видеть актуальные данные
    fetch_reset_price_cache();
    fetch_reset_trader_equity_cache();

    $config         = load_wallets_config();
    $globalSettings = $config['global_settings'];
    $wallets        = $config['wallets'];
    $poolsConfig    = load_pools_config();
    $pools          = $poolsConfig['pools'] ?? [];

    // --- Шаг 1: проверяем watching-позиции (drawdown entry) ---
    foreach ($wallets as $wallet) {
        if (empty($wallet['enabled'])) continue;
        $settings = resolve_wallet_settings($wallet, $globalSettings);
        if (empty($settings['copy_after_drawdown_enabled'])) continue;
        // Адрес трейдера (для proportional sizing при срабатывании отложенного
        // входа) передаётся явно через walletMeta → resolve_position_size_usd,
        // а не протаскивается через мутацию $settings.
        $walletMeta = [
            'id'      => $wallet['id'],
            'label'   => $wallet['label'] ?? $wallet['id'],
            'address' => $wallet['address'],
        ];
        // onTrigger: при срабатывании отложенного входа реальный ордер (direct/
        // executor_url) уходит ровно в момент входа — паритет реала с симуляцией.
        check_wallet_watching(
            $wallet['id'], $settings, $walletMeta,
            fn(array $sig, array $set, array $meta) => execute_real_order($sig, $set, $meta)
        );
    }

    foreach ($pools as $pool) {
        if (empty($pool['enabled'])) continue;
        $hasDrawdownTraders = !empty(array_filter(
            $pool['traders'] ?? [],
            fn($t) => !empty($t['enabled']) && !empty($t['copy_after_drawdown_enabled'])
        ));
        if (!$hasDrawdownTraders) continue;
        // onTrigger: при срабатывании отложенного входа реальный ордер пула (direct)
        // уходит ровно в момент входа — паритет реала с симуляцией.
        check_pool_watching(
            $pool['id'], $pool, $pool['traders'] ?? [],
            fn(array $sig, array $pl, array $tr) => execute_pool_real_order($sig, $pl, $tr, $pl['id'])
        );
    }

    // --- Шаг 1b: реконсиляция direct-слоя (позиции, закрытые на бирже вне бота) ---
    // SL/TP исполняется НА БИРЖЕ без участия бота. Без сверки после срабатывания
    // стопа GTC-ордера сетки оставались бы в книге (доливались бы и открывали
    // новую позицию без защиты), а бумага считала бы позицию открытой. Сверяем
    // лизы сущностей с реальными позициями и закрываем расхождение.
    foreach ($wallets as $wallet) {
        if (empty($wallet['enabled']) || empty($wallet['id'])) continue;
        $settings = resolve_wallet_settings($wallet, $globalSettings);
        if (($settings['execution_mode'] ?? 'webhook') !== 'direct') continue;
        $meta = ['id' => $wallet['id'], 'label' => $wallet['label'] ?? $wallet['id']];
        try {
            hl_reconcile_entity_leases(
                'wallet', $wallet['id'], $settings, $meta['label'],
                fn(string $symbol) => close_external_position(
                    'wallet', $wallet['id'], $symbol,
                    resolve_cost_settings($settings), $settings, $meta
                )
            );
        } catch (\Throwable $e) {
            bot_log(sprintf('[%s] RECONCILE ERROR: %s', $meta['label'], $e->getMessage()));
        }
    }
    foreach ($pools as $pool) {
        if (empty($pool['enabled']) || empty($pool['id'])) continue;
        if (($pool['execution_mode'] ?? 'webhook') !== 'direct') continue;
        $poolLabel = $pool['label'] ?? $pool['id'];
        try {
            hl_reconcile_entity_leases(
                'pool', $pool['id'],
                ['hl_subaccount' => $pool['hl_subaccount'] ?? ''], $poolLabel,
                fn(string $symbol) => close_external_pool_position($pool['id'], $pool, $symbol)
            );
        } catch (\Throwable $e) {
            bot_log(sprintf('POOL [%s] RECONCILE ERROR: %s', $poolLabel, $e->getMessage()));
        }
    }

    // --- Шаг 2: обычная обработка fills ---
    if (empty($wallets)) {
        bot_log('No wallets configured.');
        bot_log("--- CYCLE $cycleNum END ---");
        return;
    }

    // Отбираем enabled кошельки
    $enabledWallets = array_filter(
        $wallets,
        fn($w) => !empty($w['enabled']) && !empty($w['address']) && !empty($w['id'])
    );

    if (empty($enabledWallets)) {
        bot_log("Cycle $cycleNum: no enabled wallets");
    } else {
        // Планировщик опроса: у каждого кошелька свой минимальный интервал между
        // опросами fills (poll_interval_minutes; 0 = каждый цикл). Кошельки, чей
        // интервал ещё не истёк, не занимают слоты батча и не тратят вес API.
        // Из «созревших» берём WALLET_BATCH_SIZE самых давних по last_sync (0 = все).
        $pollIntervals = [];
        foreach ($enabledWallets as $w) {
            $s = resolve_wallet_settings($w, $globalSettings);
            $pollIntervals[$w['id']] =
                (int) round(max(0, (float) ($s['poll_interval_minutes'] ?? 0)) * 60);
        }

        $batchSize = max(0, (int) WALLET_BATCH_SIZE);
        $allIds    = array_column(array_values($enabledWallets), 'id');
        $dueIds    = storage_get_wallets_by_sync_priority($allIds, $batchSize, $pollIntervals);
        $dueSet    = array_flip($dueIds);
        $dueWallets = array_filter($enabledWallets, fn($w) => isset($dueSet[$w['id']]));

        if (count($dueWallets) < count($allIds)) {
            bot_log(sprintf(
                'Cycle %d: polling %d/%d wallets (batch=%d, остальные ждут свой интервал опроса)',
                $cycleNum, count($dueWallets), count($allIds), $batchSize
            ));
        }

        if (empty($dueWallets)) {
            bot_log("Cycle $cycleNum: no wallets due for polling");
        } else {
            run_wallets_batch(array_values($dueWallets), $globalSettings);
            bot_log("Cycle $cycleNum: processed " . count($dueWallets) . " wallet(s)");
        }
    }

    // --- Шаг 3: пулы ---
    $enabledPoolCount = 0;
    foreach ($pools as $pool) {
        if (empty($pool['enabled']) || empty($pool['id'])) continue;
        $enabledPoolCount++;
        run_pool($pool, $globalSettings);
    }

    if (!empty($pools)) {
        bot_log("Cycle $cycleNum: processed $enabledPoolCount pool(s)");
    }

    // --- Шаг 4: риск открытых позиций (внутрисделочная ликвидация + MTM-просадка) ---
    // Проходит по ВСЕМ включённым кошелькам/пулам каждый цикл, независимо от батча
    // fills: ликвидация не должна ждать «очереди» кошелька. Закрывает позиции, чей
    // нереализованный убыток достиг маржи, и обновляет mark-to-market просадку.
    foreach ($wallets as $wallet) {
        if (empty($wallet['enabled']) || empty($wallet['address']) || empty($wallet['id'])) continue;
        try {
            $settings   = resolve_wallet_settings($wallet, $globalSettings);
            $walletMeta = ['id' => $wallet['id'], 'label' => $wallet['label'] ?? $wallet['id']];

            // direct-кошелёк: бумажная ликвидация зеркалится на биржу тем же
            // тиком (бумага решила «позиция мертва» → реальный двойник закрыт),
            // иначе бумага и биржа расходились бы до следующего сигнала трейдера.
            $onLiquidated = null;
            if (($settings['execution_mode'] ?? 'webhook') === 'direct') {
                $walletId = $wallet['id'];
                $onLiquidated = function (string $symbol, array $pos) use ($settings, $walletMeta, $walletId) {
                    $signal = [
                        'symbol'          => $symbol,
                        'symbol_exchange' => $pos['symbol_exchange'] ?? $symbol,
                        'side'            => $pos['side'],
                        'action'          => 'close',
                        'leverage'        => (int) $pos['leverage'],
                    ];
                    $status = hl_execute_signal($signal, $settings, $walletMeta, 'wallet', $walletId);
                    // 'ignored' = реальной позиции уже нет / она чужая — не расхождение.
                    if (in_array($status, ['error', 'no_client'], true)) {
                        bot_log(sprintf('[%s] DIRECT: ликвидация %s НЕ отражена на бирже (status=%s) — проверьте позицию вручную',
                            $walletMeta['label'], $symbol, $status));
                    }
                };
            }

            sweep_wallet_risk('wallet', $wallet['id'], $settings, $walletMeta, $onLiquidated);
        } catch (\Throwable $e) {
            bot_log(sprintf('[%s] RISK SWEEP ERROR: %s', $wallet['label'] ?? $wallet['id'], $e->getMessage()));
        }
    }
    foreach ($pools as $pool) {
        if (empty($pool['enabled']) || empty($pool['id'])) continue;
        try {
            sweep_pool_risk($pool['id'], $pool);
        } catch (\Throwable $e) {
            bot_log(sprintf('POOL [%s] RISK SWEEP ERROR: %s', $pool['label'] ?? $pool['id'], $e->getMessage()));
        }
    }

    bot_log("--- CYCLE $cycleNum END ---");
}

/**
 * Точка входа. Берёт лок, крутит CYCLE_COUNT итераций
 * с паузой CYCLE_INTERVAL_SEC между ними.
 */
function run(): void
{
    // Факт запуска фиксируем всегда — даже если ниже уйдём по SKIP RUN.
    update_cron_tick();

    // --- Защита от параллельных запусков ---
    $lockFp = fopen(LOCK_FILE, 'c');
    if (!$lockFp) {
        bot_log('ERROR: cannot open lock file: ' . LOCK_FILE);
        return;
    }

    if (!flock($lockFp, LOCK_EX | LOCK_NB)) {
        bot_log('SKIP RUN: another instance is already running (lock held)');
        fclose($lockFp);
        return;
    }

    try {
        $cycles   = max(1, (int) CYCLE_COUNT);
        $interval = max(1, (int) CYCLE_INTERVAL_SEC);

        // Валидация: суммарное время не должно превышать 60 сек,
        // иначе следующий cron-тик налезет на текущий процесс.
        if ($cycles * $interval > 60) {
            bot_log(sprintf(
                'WARNING: CYCLE_COUNT(%d) × CYCLE_INTERVAL_SEC(%d) = %ds > 60s. ' .
                'Next cron tick may overlap. Reduce CYCLE_COUNT or CYCLE_INTERVAL_SEC in config.php.',
                $cycles, $interval, $cycles * $interval
            ));
        }

        bot_log("=== RUN START (cycles=$cycles interval={$interval}s) ===");
        update_heartbeat();

        $runStart = microtime(true);

        for ($i = 1; $i <= $cycles; $i++) {
            $cycleStart = microtime(true);

            run_bot_cycle($i);

            // Обновляем heartbeat после каждой итерации
            update_heartbeat();

            if ($i >= $cycles) {
                break;
            }

            // Бюджет времени запуска: если циклы затянулись (медленный API,
            // DNS-проблемы) — не начинаем новые, освобождаем lock до прихода
            // следующего cron-тика, чтобы запуски не наслаивались.
            $runElapsed = microtime(true) - $runStart;
            if ($runElapsed >= RUN_TIME_BUDGET_SEC) {
                bot_log(sprintf(
                    'RUN over time budget (%.1fs >= %ds) — skipping remaining %d cycle(s)',
                    $runElapsed, RUN_TIME_BUDGET_SEC, $cycles - $i
                ));
                break;
            }

            // Пауза до следующей итерации
            $elapsed = microtime(true) - $cycleStart;
            $sleep   = $interval - $elapsed;

            if ($sleep > 0.5) {
                bot_log(sprintf('Sleeping %.1fs before cycle %d...', $sleep, $i + 1));
                // usleep для точности — не теряем дробные секунды
                usleep((int) ($sleep * 1_000_000));
            } else {
                bot_log(sprintf('Cycle %d took %.1fs (>= interval), skipping sleep', $i, $elapsed));
            }
        }

        // Ежесуточная уборка старых сигналов — после торговых циклов.
        prune_old_signals_daily();

        bot_log('=== RUN END ===');
    } finally {
        flock($lockFp, LOCK_UN);
        fclose($lockFp);
    }
}

run();
