<?php
// =============================================================
// HYPER ANALYTICS BOT — CONFIG
// =============================================================
// Секреты и пути к файлам — здесь.
// Список кошельков и торговые настройки (глобальные + per-wallet
// overrides) хранятся в state/wallets.json и редактируются через
// dashboard/wallets.php.
// =============================================================

// Hyperliquid API
define('HL_API_URL', 'https://api.hyperliquid.xyz/info');

// Минимальный нотионал ордера на Hyperliquid (USD). Это ЖЁСТКИЙ минимум самой
// биржи (~$10): любой ордер дешевле отклоняется независимо от наших настроек.
// Не стратегический параметр (крутить «хочу минимум $5» бессмысленно — биржа не
// примет), поэтому это константа, а не поле трейдера. Уровни сетки дешевле этого
// порога поднимаются до него (hl_grid_orders), а не пропускаются. Если биржа
// изменит минимум — правится здесь, в одном месте.
define('HL_MIN_ORDER_NOTIONAL_USD', 10.0);

// =============================================================
// Прямая торговля на Hyperliquid (execution_mode = 'direct')
// =============================================================
// Секреты для прямого исполнения сделок на бирже. Если используешь только
// webhook-ботов (поведение по умолчанию) — оставь как есть, не трогается.
//
// БЕЗОПАСНОСТЬ: используй API agent wallet (approved wallet), а НЕ основной
// приватный ключ. Agent может только торговать, но НЕ может вывести средства,
// поэтому его утечка не приводит к потере депозита. Сгенерировать и одобрить
// агента — через dashboard/approve_agent.php или UI Hyperliquid (More → API).
//
//   HL_ACCOUNT_ADDRESS    — публичный адрес ОСНОВНОГО аккаунта (куда ставятся
//                           копии; для запросов позиций/ордеров)
//   HL_AGENT_PRIVATE_KEY  — приватный ключ агента (0x..., 32 байта)
//   HL_IS_MAINNET         — true = mainnet, false = testnet (обкатка)
define('HL_ACCOUNT_ADDRESS',   getenv('HL_ACCOUNT_ADDRESS')   ?: '');
define('HL_AGENT_PRIVATE_KEY', getenv('HL_AGENT_PRIVATE_KEY') ?: '');
define('HL_IS_MAINNET',        getenv('HL_IS_MAINNET') !== false ? (getenv('HL_IS_MAINNET') !== 'false') : true);

// Retry
// 2 ретрая: при 429 (rate limit) backoff в api_backoff_sleep даёт паузы 1с/2с —
// повторная попытка после паузы обычно проходит, fills не теряются до следующего цикла.
define('API_RETRY_COUNT', 2);
define('API_TIMEOUT_SEC', 5);

// Директория рантайм-состояния (НЕ секреты): lock-файлы, heartbeat, nonce,
// троттлинг логина. Лежит внутри проекта — этих файлов нет смысла прятать.
define('STATE_DIR', __DIR__ . '/state');

// =============================================================
// Приватная директория для ЧУВСТВИТЕЛЬНЫХ файлов
// =============================================================
// wallets.json / pools.json содержат вебхук-ключи (api_key/api_secret) и адреса
// ОТКРЫТЫМ ТЕКСТОМ. Если они лежат внутри web-root (по умолчанию — в state/), то
// на nginx или при отключённом AllowOverride их .htaccess не действует и файлы
// отдаются по HTTP как статика → утечка ключей.
//
// БЕЗОПАСНО: задайте HA_PRIVATE_DIR (env) или PRIVATE_DIR ниже на путь ЗА
// пределами web-root, например /home/USER/hyperanalytics_private. При первом
// запуске существующие state/wallets.json и state/pools.json будут АВТОМАТИЧЕСКИ
// перенесены туда (rename — файл исчезает из web-root). Пусто = state/ (как
// раньше; тогда защита держится только на .htaccess).
define('PRIVATE_DIR', getenv('HA_PRIVATE_DIR') ?: STATE_DIR);

/**
 * Путь к чувствительному файлу в PRIVATE_DIR. Если PRIVATE_DIR отличается от
 * STATE_DIR (оператор вынес его за web-root) и файл ещё лежит в legacy state/ —
 * ПЕРЕНОСИТ его в PRIVATE_DIR при первом обращении (rename убирает файл из
 * web-root), затем возвращает новый путь. Если перенос не удался (права/раздел),
 * возвращает legacy-путь, чтобы не потерять конфиг. При PRIVATE_DIR == STATE_DIR
 * (дефолт и тесты) — no-op, ничего не двигаем.
 */
function private_data_path(string $filename): string
{
    $target = rtrim(PRIVATE_DIR, "/\\") . '/' . $filename;
    $legacy = rtrim(STATE_DIR,   "/\\") . '/' . $filename;

    if ($target === $legacy) {
        return $target;   // приватная директория не вынесена — переносить некуда
    }
    if (!is_file($target) && is_file($legacy)) {
        $dir = dirname($target);
        if (!is_dir($dir)) {
            @mkdir($dir, 0700, true);
        }
        if (@rename($legacy, $target)) {
            @chmod($target, 0600);
        } else {
            // Не смогли перенести — читаем из legacy, чтобы не потерять данные.
            return is_file($target) ? $target : $legacy;
        }
    }
    return $target;
}

// Пути к общим файлам
define('WALLETS_FILE',        PRIVATE_DIR . '/wallets.json');
define('HEARTBEAT_FILE',      __DIR__ . '/state/heartbeat.txt');
// Отметка «cron дёрнул bot.php» — пишется при КАЖДОМ запуске, включая те, что
// пропущены из-за lock. Позволяет dashboard отличать «cron не запускается»
// от «cron работает, но циклы бота не завершаются» (см. check_cron_health).
define('CRON_TICK_FILE',      __DIR__ . '/state/cron_tick.txt');
// Отметка последней автоочистки старых сигналов из БД. Очистка идёт раз в
// сутки в конце run() (см. prune_old_signals_daily в bot.php), метка — по
// образцу heartbeat/cron_tick, чтобы не проверять БД на каждом запуске.
define('SIGNALS_PRUNE_TICK_FILE', __DIR__ . '/state/signals_prune.txt');
define('LOG_FILE',            __DIR__ . '/logs/bot.log');
define('LOG_MAX_BYTES',       5 * 1024 * 1024);  // 5 MB — при достижении файл ротируется
define('LOG_ROTATE_KEEP',     3);                 // хранить bot.log.1, .2, .3
define('DB_FILE',             __DIR__ . '/logs/bot.db');            // SQLite — основное хранилище

// Lock-файл против параллельных запусков bot.php (защита от двойного cron)
define('LOCK_FILE', __DIR__ . '/state/bot.lock');

// Dashboard
// Задаётся через env HA_DASHBOARD_PASSWORD (предпочтительно — секрет не живёт
// в git-отслеживаемом файле, как и HL_* выше) или правкой значения ниже.
// ОБЯЗАТЕЛЬНО смени перед деплоем: с заводским значением bot.php и dashboard
// отказываются работать.
define('DASHBOARD_PASSWORD', getenv('HA_DASHBOARD_PASSWORD') ?: '12222bbb');

// Cron через HTTP (cron-job.org и т.п.)
// bot.php проверяет ?token=... в URL — без правильного токена выполнение блокируется.
// Задаётся через env HA_CRON_SECRET или правкой значения ниже (сгенерируй:
// openssl rand -hex 32). ОБЯЗАТЕЛЬНО смени перед деплоем.
define('CRON_SECRET', getenv('HA_CRON_SECRET') ?: 'hyper');

// --- Цикличный запуск внутри одного cron-тика -------------------
//
// Cron запускается раз в минуту, но бот может делать N итераций
// с паузой CYCLE_INTERVAL_SEC между ними.
//
// Примеры:
//   CYCLE_COUNT=1, CYCLE_INTERVAL_SEC=60  → стандартный режим (одна итерация)
//   CYCLE_COUNT=4, CYCLE_INTERVAL_SEC=15  → 4 итерации по 15 сек = ~60 сек
//   CYCLE_COUNT=2, CYCLE_INTERVAL_SEC=30  → 2 итерации по 30 сек = ~60 сек
//
// Итого время работы: CYCLE_COUNT × CYCLE_INTERVAL_SEC ≤ 60 сек
// (иначе процесс выйдет за минуту и следующий cron не запустится вовремя)
define('CYCLE_COUNT',        4);   // количество итераций за один запуск cron
define('CYCLE_INTERVAL_SEC', 15);  // пауза между итерациями в секундах

// Бюджет времени одного запуска. Если циклы затянулись (медленный API, DNS)
// и суммарное время вышло за бюджет — оставшиеся циклы пропускаются, чтобы
// запуск освободил lock до прихода следующего cron-тика и запуски не
// наслаивались друг на друга. Чуть меньше минуты — оставляем запас на старт.
define('RUN_TIME_BUDGET_SEC', 55);

// --- Батч-обработка кошельков -----------------------------------
//
// При большом числе кошельков (40+) обрабатываем их батчами:
//   WALLET_BATCH_SIZE    — сколько кошельков обрабатывать за один цикл
//   При 40 кошельках и BATCH_SIZE=10 каждый кошелёк получает опрос
//   раз в 4 цикла (раз в минуту при CYCLE_COUNT=4).
//
// Бюджет rate limit Hyperliquid: ~1200 веса/мин с IP, userFillsByTime стоит 20.
// BATCH_SIZE × CYCLE_COUNT × 20 должно оставаться заметно ниже 1200:
// 10 × 4 × 20 = 800/мин — с запасом на allMids, equity, ретраи и dashboard.
// Без батча (0) при 40+ кошельках бюджет превышается кратно → массовые 429.
// Риск-свип, watching и реконсиляция НЕ батчуются — идут по всем кошелькам
// каждый цикл (им хватает кэша allMids, вес 2 на цикл).
//
// 0 = обрабатывать все кошельки каждый цикл (только для малого числа кошельков)
define('WALLET_BATCH_SIZE',  10);  // 0 = все; >0 = N кошельков за цикл (по last_sync ASC)

// =============================================================
// Дефолты издержек — ЕДИНЫЙ источник литералов.
// =============================================================
// Используются и в GLOBAL_SETTINGS_DEFAULTS ниже (для кошельков значения
// приходят уже смерженными через resolve_wallet_settings), и как бэкстоп в
// рантайм-путях (трекеры/пулы/watching) через resolve_cost_settings() —
// чтобы числа 0.035 / 0 не были раскиданы по коду и не разъезжались.
define('COST_DEFAULT_FEE_PCT',      0.035);  // % комиссии за сторону (taker)
define('COST_DEFAULT_SLIPPAGE_PCT', 0.0);    // % исполнительного слиппеджа за сторону
define('COST_DEFAULT_FUNDING_PCT',  0.0);    // funding %/час на notional

// =============================================================
// Глобальные дефолты для торговых настроек.
// Каждый кошелёк может переопределить любой из этих параметров —
// если в его записи в wallets.json параметр не задан (null/отсутствует),
// используется значение отсюда.
// =============================================================
define('GLOBAL_SETTINGS_DEFAULTS', [
    'allowed_symbols'   => [],      // [] = разрешены все символы
    'scale_factor'      => 0.1,
    'default_leverage'  => 3,
    'symbol_leverage'   => [],      // ['BTC' => 5, 'ETH' => 2]
    'cooldown_seconds'  => 30,
    // Минимальный интервал между опросами fills ЭТОГО кошелька в минутах.
    // 0 = опрашивать каждый цикл (как раньше). Для дневных/свинг-трейдеров,
    // держащих сделку днями, ставь 3–10: кошелёк перестаёт занимать слоты
    // батча и тратить вес API, освобождая бюджет для скальперов.
    // Риск-свип/ликвидация/watching НЕ зависят от этого интервала — они
    // идут по всем кошелькам каждый цикл по кэшированному allMids.
    'poll_interval_minutes' => 0,
    'executor_mode'     => 'mock',  // 'mock' | 'live'
    'executor_url'      => 'http://your-executor-service/order',

    // --- Способ исполнения сделок (PRD §3) -------------------
    // 'webhook' (по умолчанию) — существующее поведение: сигнал уходит на
    //   webhook-ботов (katoshi/altrady/custom) и/или executor_url. Ничего
    //   не меняется для текущих пользователей.
    // 'direct'  — сделки отправляются НАПРЯМУЮ на Hyperliquid (PHP SDK,
    //   hyperliquid/HyperliquidClient). Требует HL_* секретов в config.php.
    // Настраивается независимо для каждого трейдера (per-wallet override).
    'execution_mode'    => 'webhook',
    // Режим маржи для прямых ордеров: 'cross' | 'isolated'.
    // Дефолт isolated: совпадает с изолированной моделью ликвидации симулятора
    // (каждая позиция рискует только своей маржой) — бумага и биржа согласованы.
    'hl_margin_mode'    => 'isolated',
    // Субаккаунт Hyperliquid для прямых ордеров этой сущности (адрес 0x...).
    // Пусто = торговать на ОСНОВНОМ аккаунте (HL_ACCOUNT_ADDRESS). Если задать
    // каждой direct-сущности свой субаккаунт, правило «одна позиция на символ на
    // аккаунт» действует ОТДЕЛЬНО для каждой — несколько сущностей могут держать
    // один символ независимо (устраняет расхождение бумага/биржа на общем счёте).
    // Подписывает тот же агент мастер-аккаунта; субаккаунт должен принадлежать ему.
    'hl_subaccount'     => '',
    // Управлять ли на МАСТЕР-аккаунте реальными позициями, которые НЕ принадлежат
    // боту по лизе (открыты вручную/другим ботом/остались после краха). По
    // умолчанию false: на общем мастер-аккаунте close/reverse НЕ трогают такую
    // «ничейную» позицию — иначе бот закрыл бы то, что не открывал. true = прежнее
    // поведение (управлять любой позицией символа). На субаккаунтах не действует:
    // там сущность одна, «ничейная» позиция — почти всегда её же остаток, её закрываем.
    'hl_manage_foreign_positions' => false,

    // --- Stop Loss (PRD §6), только для execution_mode=direct ----
    //   enabled — вкл/выкл отдельно для трейдера
    //   type:  'pnl_pct'   — закрыть при убытке value% от маржи (с учётом плеча)
    //          'price_pct' — закрыть при движении цены против входа на value%
    //          'price'     — закрыть при достижении абсолютной цены value
    //   value — число (смысл зависит от type)
    'stop_loss'   => ['enabled' => false, 'type' => 'pnl_pct', 'value' => 50],

    // --- Take Profit (PRD §7), только для execution_mode=direct --
    //   те же type, но value — прибыль/целевая цена
    'take_profit' => ['enabled' => false, 'type' => 'pnl_pct', 'value' => 80],

    // --- Сеточная торговля (PRD §8–9), только для direct ---------
    //   enabled       — вкл/выкл независимо от SL/TP
    //   levels        — кол-во дополнительных ордеров добора
    //   step_pct      — шаг между ордерами в % цены (против входа)
    //   size_factor   — множитель размера доп.ордера относительно основного
    //   distribution  — 'equal' (каждый = main*size_factor) |
    //                   'multiplicative' (i-й = main*size_factor^i, мартингейл)
    'grid' => [
        'enabled'      => false,
        'levels'       => 3,
        'step_pct'     => 1.0,
        'size_factor'  => 1.0,
        'distribution' => 'equal',
    ],

    // --- Симулятор копитрейдинга -----------------------------
    'virtual_balance'   => 1000,    // стартовый баланс виртуального счёта (USDC)
    'sizing_mode'       => 'fixed', // 'fixed' | 'percent_balance' | 'proportional'
    'sizing_fixed_usd'  => 100,     // используется если sizing_mode = 'fixed'
    'sizing_percent'    => 10,      // используется если sizing_mode = 'percent_balance' (10 = 10%)

    // --- Лимиты объёма НАШЕЙ позиции (НОТИОНАЛ = маржа × наше плечо) ------
    // Применяются ПОСЛЕДНИМИ, поверх любого sizing_mode: результат сайзинга
    // меньше минимума — поднимается до минимума, больше максимума — срезается.
    // Если задан и min и max и min > max — последним применяется max (жёсткий
    // потолок риска побеждает). Если свободного баланса не хватает даже на
    // минимум — сделка ПРОПУСКАЕТСЯ (insufficient_margin), а не открывается
    // мельче: биржа всё равно отклонила бы ордер ниже своего минимума (~$10).
    // Лимит задаёт размер ОСНОВНОГО ордера; ордера сетки (grid) размерятся
    // от него своими множителями и лимитом не капятся, НО каждый уровень сетки
    // не опускается ниже биржевого минимума (HL_MIN_ORDER_NOTIONAL_USD, ~$10):
    // суб-минимальный уровень поднимается до порога, иначе биржа отклонила бы его
    // молча. 0 = выкл.
    'position_min_usd'  => 0,
    'position_max_usd'  => 0,
    'fee_pct_per_side'  => COST_DEFAULT_FEE_PCT,   // % за вход и за выход отдельно (taker по умолчанию)

    // --- Реалистичность издержек (по умолчанию 0 = поведение как раньше) -----
    // Исполнительный слиппедж/спред: цена входа/выхода ухудшается на эту %
    // за каждую сторону (копи-ордер — тейкер, пересекает спред + маркет-импакт).
    // 0 = mid→mid как раньше. Реалистичные значения для майоров HL: ~0.01–0.03.
    'slippage_pct_per_side'   => COST_DEFAULT_SLIPPAGE_PCT,
    // Оценка funding в %/час на notional (size_usd*leverage). При ставке > 0
    // long платит, short получает (типичный знак perp). Начисляется за время
    // удержания позиции и реализуется при закрытии. 0 = funding не учитывается.
    // Для ориентира: типичный funding ~0.01%/8ч ≈ 0.00125%/час.
    'funding_rate_hourly_pct' => COST_DEFAULT_FUNDING_PCT,

    // --- Частичное закрытие и паритет с реальной торговлей ---
    // 'off' (по умолчанию) — любой close трейдера = ПОЛНОЕ закрытие нашей позиции,
    //   и вебхук шлёт полный close. Бумага 1:1 совпадает с любым приёмником сигналов.
    // 'on'  — close трейдера меньше открытого = частичное закрытие нашей позиции,
    //   и вебхук шлёт partial_close. Включать только если приёмник умеет частичное
    //   (custom — да; katoshi — да, встречным ордером; altrady — НЕТ).
    //   В direct-режиме частичное закрытие миррорится всегда: reduce-only ордер на ту
    //   же долю реальной позиции (одностороннний неттинг Hyperliquid уменьшает позицию,
    //   не хеджит) — см. hl_partial_close_position.
    'partial_close_mode' => 'off',

    // --- Вебхуки кошельков (katoshi/altrady/custom) -----------
    // Формат элементов — как у пулов (см. webhook_notifier.php). UI для кошельков
    // нет — задаётся руками в wallets.json (global_settings или settings кошелька).
    // Ключ ОБЯЗАН присутствовать в дефолтах: resolve_wallet_settings принимает
    // только известные ключи, без него per-wallet 'webhooks' молча отбрасывался.
    'webhooks' => [],

    // --- Уведомления в Telegram --------------------------------
    // Бот-токен и chat_id задаются глобально (Настройки); включение —
    // глобально или на кошелёк (override telegram_notify). При включении
    // сигналы кошелька (open/close/partial/ликвидация/watching-вход)
    // дублируются сообщением в Telegram — синтетическим webhook-элементом
    // service=telegram (см. resolve_wallet_settings).
    'telegram_notify'    => false,
    'telegram_bot_token' => '',   // токен бота из @BotFather
    'telegram_chat_id'   => '',   // id чата/канала (например, свой user id)

    // --- Хранение истории сигналов ----------------------------
    // Сигналы старше N дней удаляются из БД автоматически (раз в сутки,
    // см. prune_old_signals_daily в bot.php). 0 = хранить вечно (автоочистка
    // выключена). Настройка ГЛОБАЛЬНАЯ (Настройки → Система) — per-wallet
    // override смысла не имеет. Закрытые сделки (trades_closed) НЕ ротируются:
    // это история для аналитики и скоринга трейдеров.
    'signals_retention_days' => 30,

    // --- Отложенный вход по просадке трейдера ----------------
    'copy_after_drawdown_enabled' => false, // включить режим ожидания просадки
    'copy_after_drawdown_pct'     => 5,     // порог просадки трейдера в % (UPNL <= -N%)
    // TTL отложенной watch-записи в часах: если просадка так и не наступила за это
    // время — watch снимается (страховка от «протухших» записей, напр. при пропуске
    // close-сигнала коллектором). 0 = без срока (наблюдаем, пока трейдер не закроется).
    'copy_after_drawdown_ttl_hours' => 0,
]);

// =============================================================
// WALLETS — список отслеживаемых кошельков
// =============================================================
// Структура wallets.json:
// {
//   "global_settings": { ...GLOBAL_SETTINGS_DEFAULTS overrides... },
//   "available_tags": ["scalper", "btc-only", "swing"],   // глобальный список тегов
//   "wallets": [
//     {
//       "id": "w1",
//       "label": "Трейдер 1",
//       "address": "0x...",
//       "enabled": true,
//       "status": "WATCH",    // WATCH | TEST | ACTIVE | ARCHIVE
//       "rating": 0,            // 0-5 звёзд
//       "tags": [],             // подмножество available_tags
//       "notes": "",            // свободный текст
//       "source": "",           // откуда узнал о кошельке
//       "archive_reason": "",   // причина архивации
//       "settings": { ... per-wallet overrides, любой ключ может быть null ... }
//     }
//   ]
// }

/**
 * Загружает полный конфиг кошельков. Возвращает структуру с
 * 'global_settings' (объединено с GLOBAL_SETTINGS_DEFAULTS) и 'wallets'.
 */
function load_wallets_config(): array
{
    $default = ['global_settings' => GLOBAL_SETTINGS_DEFAULTS, 'wallets' => []];

    $file = private_data_path('wallets.json');
    if (!file_exists($file)) {
        return $default;
    }

    $data = json_decode(file_get_contents($file), true);
    if (!is_array($data)) {
        return $default;
    }

    $globalSettings = array_merge(
        GLOBAL_SETTINGS_DEFAULTS,
        is_array($data['global_settings'] ?? null) ? $data['global_settings'] : []
    );

    $wallets      = is_array($data['wallets'] ?? null) ? $data['wallets'] : [];
    $availableTags = is_array($data['available_tags'] ?? null) ? $data['available_tags'] : [];

    return [
        'global_settings' => $globalSettings,
        'wallets'         => $wallets,
        'available_tags'  => $availableTags,
    ];
}

// =============================================================
// БЭКАПЫ КОНФИГОВ — страховка от потери списка трейдеров
// =============================================================
// wallets.json и pools.json лежат в state/ и НЕ версионируются гитом: адреса
// трейдеров, ключи вебхуков и субаккаунты не место в репозитории. Обратная
// сторона — их нечем восстановить, если файл потеряется при выкладке (деплой
// «снести и залить», чистый clone, ошибка rsync) или его перезапишет пустым.
// В БД адресов нет: там только wallet_id и label, восстановить копирование по
// ним невозможно.
//
// Поэтому перед КАЖДОЙ записью кладём копию текущего файла в state/backups/.
// Копия делается только когда файл существует и непустой, так что первая
// установка мусора не плодит.

define('CONFIG_BACKUPS_KEEP', 15);

/** Директория бэкапов (внутри приватной, значит под теми же запретами). */
function config_backups_dir(): string
{
    return dirname(private_data_path('x')) . '/backups';
}

/**
 * Копирует текущую версию конфига в бэкап и подчищает старые.
 * Ошибки бэкапа НИКОГДА не должны мешать основной записи — молча пропускаем.
 */
function backup_private_file(string $filename): void
{
    $src = private_data_path($filename);
    if (!is_file($src) || filesize($src) === 0) {
        return;   // нечего бэкапить (первая установка)
    }

    $dir = config_backups_dir();
    if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) {
        return;
    }

    $base = pathinfo($filename, PATHINFO_FILENAME);

    // Одинаковое содержимое подряд не дублируем: сохранение настроек не должно
    // вытеснять из ротации бэкап с реальными трейдерами.
    $existing = config_backups_list($base);
    if (!empty($existing) && @md5_file($existing[0]['path']) === @md5_file($src)) {
        return;
    }

    // Имя должно быть уникальным ВНУТРИ секунды: сохранение конфига и запись
    // из другого обработчика легко попадают в одну и ту же секунду, и бэкап с
    // живыми трейдерами затирался бы бэкапом уже испорченной версии.
    $stamp = date('Ymd-His');
    $dest  = $dir . '/' . $base . '-' . $stamp . '.json';
    for ($i = 1; file_exists($dest) && $i < 1000; $i++) {
        $dest = $dir . '/' . $base . '-' . $stamp . '-' . $i . '.json';
    }

    if (file_exists($dest) || !@copy($src, $dest)) {
        return;
    }
    @chmod($dest, 0600);

    // Ротация: держим последние CONFIG_BACKUPS_KEEP штук
    $all = config_backups_list($base);
    foreach (array_slice($all, CONFIG_BACKUPS_KEEP) as $old) {
        @unlink($old['path']);
    }
}

/**
 * Список бэкапов конфига, новые первыми.
 * @return array<int, array{path:string, name:string, time:int, size:int, wallets:int}>
 */
function config_backups_list(string $base): array
{
    $dir = config_backups_dir();
    if (!is_dir($dir)) {
        return [];
    }

    $out = [];
    foreach ((array) glob($dir . '/' . $base . '-*.json') as $path) {
        if (!is_file($path)) {
            continue;
        }
        // Сколько записей внутри — чтобы в UI было видно, какой бэкап полезный,
        // а какой сохранён уже после потери.
        $data  = json_decode((string) @file_get_contents($path), true);
        $items = is_array($data['wallets'] ?? null) ? $data['wallets']
               : (is_array($data['pools'] ?? null) ? $data['pools'] : []);
        $out[] = [
            'path'    => $path,
            'name'    => basename($path),
            'time'    => (int) @filemtime($path),
            'size'    => (int) @filesize($path),
            'wallets' => count($items),
        ];
    }

    usort($out, fn($a, $b) => $b['time'] <=> $a['time']);
    return $out;
}

/**
 * Восстанавливает конфиг из бэкапа. Текущая версия сначала уходит в бэкап,
 * поэтому восстановление обратимо. $backupName — только имя файла (без путей).
 */
function config_restore_backup(string $filename, string $backupName): bool
{
    $base = pathinfo($filename, PATHINFO_FILENAME);
    // Никаких путей извне: имя должно совпасть с одним из реально найденных.
    $match = null;
    foreach (config_backups_list($base) as $b) {
        if ($b['name'] === $backupName) {
            $match = $b;
            break;
        }
    }
    if ($match === null) {
        return false;
    }

    // Содержимое читаем ДО страховочного бэкапа: иначе он может оказаться
    // тем же файлом (одна секунда, одно имя) и мы восстановим сами себя.
    $content = (string) @file_get_contents($match['path']);
    $data    = json_decode($content, true);
    if (!is_array($data)) {
        return false;   // битый бэкап — не затираем рабочий файл
    }

    backup_private_file($filename);   // текущая версия тоже сохраняется

    $dest = private_data_path($filename);
    $tmp  = $dest . '.tmp.' . getmypid();
    if (@file_put_contents($tmp, $content) === false) {
        @unlink($tmp);
        return false;
    }
    if (!@rename($tmp, $dest)) {
        @unlink($tmp);
        return false;
    }
    @chmod($dest, 0600);
    return true;
}

/**
 * Сохраняет полный конфиг кошельков (atomic rename).
 */
function save_wallets_config(array $config): bool
{
    backup_private_file('wallets.json');

    $file = private_data_path('wallets.json');
    $dir  = dirname($file);
    if (!is_dir($dir)) {
        mkdir($dir, 0755, true);
    }

    $globalSettings = array_merge(
        GLOBAL_SETTINGS_DEFAULTS,
        is_array($config['global_settings'] ?? null) ? $config['global_settings'] : []
    );

    $merged = [
        'global_settings' => $globalSettings,
        'available_tags'  => array_values(array_unique($config['available_tags'] ?? [])),
        'wallets'         => $config['wallets'] ?? [],
    ];

    // Атомарная запись через временный файл + rename().
    // rename() на том же разделе атомарна на уровне ОС — bot.php никогда
    // не получит частично записанный файл и json_decode() не вернёт null.
    $tmp = $file . '.tmp.' . getmypid();
    $ok  = file_put_contents($tmp, json_encode($merged, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) !== false;

    if (!$ok) {
        @unlink($tmp);
        return false;
    }

    if (!rename($tmp, $file)) {
        @unlink($tmp);
        return false;
    }
    @chmod($file, 0600);

    return true;
}

/**
 * =============================================================
 * ЕДИНЫЙ РЕЗОЛВЕР НАСТРОЕК — одно правило наследования на всё приложение
 * =============================================================
 *
 * $base   — пространство имён и значения по умолчанию (GLOBAL_SETTINGS_DEFAULTS
 *           или уже смерженные глобальные настройки). Ключ, которого нет в базе,
 *           из слоёв игнорируется: защита от опечаток в руками правленом JSON.
 * $layers — слои от САМОГО ОБЩЕГО к САМОМУ ЧАСТНОМУ; каждый следующий
 *           перебивает предыдущий. Кошелёк: [settings кошелька].
 *           Пул: [слой пула, слой трейдера].
 *
 * Правило наследования ОДНО: null = «не задано, взять из предыдущего слоя».
 * Пустой массив — ЗНАЧЕНИЕ, а не «не задано» (allowed_symbols: [] = все символы).
 *
 * $emptyStringInherits — приравнивать ли '' к null. Для кошельков false:
 * форма уже конвертирует пустое поле в null, а '' может быть осмысленным
 * значением (hl_subaccount: '' = торговать на мастер-аккаунте). Для пулов
 * true: их поля приходят прямо из формы, где пустое поле = «как у пула/глобально».
 */
function resolve_settings(array $base, array $layers, bool $emptyStringInherits = false): array
{
    $effective = $base;

    foreach ($layers as $layer) {
        if (!is_array($layer)) {
            continue;
        }
        foreach ($layer as $key => $value) {
            if (!array_key_exists($key, $effective)) {
                continue;   // ключ вне пространства имён базы — игнорируем
            }
            if ($value === null) {
                continue;   // не задано — наследуем
            }
            if ($emptyStringInherits && $value === '') {
                continue;
            }
            $effective[$key] = $value;
        }
    }

    return $effective;
}

/**
 * Telegram: включённый флаг + заданные глобально креды превращаются в
 * синтетический webhook-элемент. Так уведомления получают ВСЕ точки отправки
 * (open/close/partial, ликвидация, watching-вход) без изменения каждого
 * call-site — они уже рассылают по $settings['webhooks'].
 *
 * Общий постпроцессор резолвера: применяется и к кошелькам, и к пулам.
 */
function apply_telegram_webhook(array $effective): array
{
    if (empty($effective['telegram_notify'])
        || trim((string) ($effective['telegram_bot_token'] ?? '')) === ''
        || trim((string) ($effective['telegram_chat_id'] ?? '')) === ''
    ) {
        return $effective;
    }

    $webhooks = is_array($effective['webhooks'] ?? null) ? $effective['webhooks'] : [];
    foreach ($webhooks as $wh) {
        if (($wh['service'] ?? '') === 'telegram') {
            return $effective;   // задан руками в webhooks — не дублируем
        }
    }

    $webhooks[] = [
        'enabled'   => true,
        'service'   => 'telegram',
        'bot_token' => trim((string) $effective['telegram_bot_token']),
        'chat_id'   => trim((string) $effective['telegram_chat_id']),
    ];
    $effective['webhooks'] = $webhooks;

    return $effective;
}

/**
 * Глобальные настройки с кэшем по mtime/size файла. Нужен слоям, которые
 * резолвятся вне основного цикла бота (пулы читают глобальные дефолты, но
 * получают на вход только свой $pool/$trader). Кэш инвалидируется сам при
 * сохранении настроек из дашборда — ключ включает время и размер файла.
 */
function global_settings(): array
{
    static $cache = null;
    static $cacheKey = null;

    $file = private_data_path('wallets.json');
    $key  = is_file($file)
        ? ((string) @filemtime($file) . ':' . (string) @filesize($file))
        : 'none';

    if ($cache === null || $cacheKey !== $key) {
        $cache    = load_wallets_config()['global_settings'];
        $cacheKey = $key;
    }

    return $cache;
}

/**
 * Резолвит эффективные настройки для конкретного кошелька:
 * global_settings + per-wallet overrides (non-null значения).
 */
function resolve_wallet_settings(array $wallet, array $globalSettings): array
{
    $effective = resolve_settings(
        $globalSettings,
        [is_array($wallet['settings'] ?? null) ? $wallet['settings'] : []]
    );

    return apply_telegram_webhook($effective);
}

/**
 * Резолвит издержки (fee/slippage/funding) из произвольного источника настроек
 * с едиными дефолтами (COST_DEFAULT_*). Источником может быть как эффективный
 * $settings кошелька (значения уже смержены), так и сырой массив пула из
 * pools.json (который НЕ мержится с GLOBAL_SETTINGS_DEFAULTS) — поэтому дефолты
 * применяются здесь, в одном месте для обоих.
 */
function resolve_cost_settings(array $src): array
{
    return [
        'fee_pct_per_side'        => (float) ($src['fee_pct_per_side']        ?? COST_DEFAULT_FEE_PCT),
        'slippage_pct_per_side'   => (float) ($src['slippage_pct_per_side']   ?? COST_DEFAULT_SLIPPAGE_PCT),
        'funding_rate_hourly_pct' => (float) ($src['funding_rate_hourly_pct'] ?? COST_DEFAULT_FUNDING_PCT),
    ];
}

/**
 * Путь к per-wallet lock-файлу.
 * Это РЕАЛЬНЫЙ файл (flock против параллельной обработки одного кошелька),
 * в отличие от состояния, которое целиком живёт в SQLite и адресуется
 * парой (entity_type, entity_id) напрямую — без промежуточных «путей».
 */
function wallet_lock_file(string $walletId): string
{
    return STATE_DIR . '/wallet_' . $walletId . '.lock';
}

/**
 * Генерирует короткий уникальный id для нового кошелька.
 */
function generate_wallet_id(array $existingWallets): string
{
    $existing = array_column($existingWallets, 'id');
    $i = 1;
    do {
        $id = 'w' . $i;
        $i++;
    } while (in_array($id, $existing, true));

    return $id;
}
