<?php
// =============================================================
// DASHBOARD SMOKE TESTS — логика dashboard без HTTP/HTML
// =============================================================
// Покрывает data.php (агрегация), dashboard/lib/forms.php (парсеры) и
// handler'ы (wallets/settings/pools) против ВРЕМЕННЫХ БД и конфигов.
// Запуск:  php tests/dashboard_test.php   (ненулевой код = есть падения)
// =============================================================

error_reporting(E_ALL & ~E_DEPRECATED);

// Изолируем ВСЁ состояние во временную папку ДО подключения config.php
// (define() не переопределяет уже заданную константу).
$tmp = sys_get_temp_dir() . '/hyperanalytics_dash_' . getmypid();
@mkdir($tmp, 0777, true);
@mkdir($tmp . '/logs', 0777, true);
@mkdir($tmp . '/state', 0777, true);
define('DB_FILE',        $tmp . '/logs/bot.db');
define('LOG_FILE',       $tmp . '/logs/bot.log');
define('WALLETS_FILE',   $tmp . '/state/wallets.json');
define('STATE_DIR',      $tmp . '/state');
define('HEARTBEAT_FILE', $tmp . '/state/heartbeat.txt');
define('CRON_TICK_FILE', $tmp . '/state/cron_tick.txt');
@unlink(DB_FILE);

$root = dirname(__DIR__);
$prevLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_WARNING);
require_once $root . '/config.php';
error_reporting($prevLevel);
require_once $root . '/storage.php';
require_once $root . '/dashboard/data.php';
require_once $root . '/dashboard/lib/forms.php';
require_once $root . '/dashboard/lib/settings_schema.php';
require_once $root . '/dashboard/lib/settings_handler.php';
require_once $root . '/dashboard/lib/wallets_handler.php';
require_once $root . '/dashboard/lib/pools_handler.php';
require_once $root . '/dashboard/lib/xlsx.php';
require_once $root . '/dashboard/lib/traders_xlsx.php';

// --- мини-фреймворк ассертов ---------------------------------
$GLOBALS['__pass'] = 0;
$GLOBALS['__fail'] = 0;
function section(string $s): void { echo "\n# $s\n"; }
function ok(bool $cond, string $name): void {
    if ($cond) { $GLOBALS['__pass']++; echo "  \033[32mPASS\033[0m $name\n"; }
    else       { $GLOBALS['__fail']++; echo "  \033[31mFAIL\033[0m $name\n"; }
}
function eqv($got, $want, string $name): void {
    ok($got === $want, $name . ($got === $want ? '' : ' (got ' . var_export($got, true) . ' want ' . var_export($want, true) . ')'));
}
function approx(float $got, float $want, string $name, float $eps = 0.0001): void {
    ok(abs($got - $want) < $eps, $name . (abs($got - $want) < $eps ? '' : " (got $got want $want)"));
}
const ADDR_A = '0x' . 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const ADDR_B = '0x' . 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';

// =============================================================
// data.php — агрегация
// =============================================================
section('data.php: calculate_summary / winrate / profit factor');
$trades = [
    ['gross_pnl' => 10, 'net_pnl' => 8,  'pnl_pct' => 5,  'entry_fee' => 1, 'exit_fee' => 1, 'funding' => 0, 'close_time' => '2026-01-01 10:00:00', 'open_time' => '2026-01-01 09:00:00'],
    ['gross_pnl' => -4, 'net_pnl' => -6, 'pnl_pct' => -3, 'entry_fee' => 1, 'exit_fee' => 1, 'funding' => 0, 'close_time' => '2026-01-02 10:00:00', 'open_time' => '2026-01-02 08:00:00'],
];
$sum = calculate_summary($trades);
eqv($sum['total_trades'], 2, 'total_trades=2');
approx($sum['total_gross_pnl'], 6.0, 'total_gross_pnl=6');
approx($sum['total_net_pnl'], 2.0, 'total_net_pnl=2');
eqv($sum['wins'], 1, 'wins=1 (по net>0)');
approx($sum['win_rate'], 50.0, 'winrate=50%');
approx((float) $sum['profit_factor'], 2.5, 'profit_factor=gross 10/4=2.5');

section('data.php: пустой набор / нулевые делители');
$empty = calculate_summary([]);
eqv($empty['total_trades'], 0, 'empty trades=0');
eqv($empty['win_rate'], 0, 'empty winrate=0');
eqv($empty['profit_factor'], 0.0, 'empty PF=0.0 (нет прибыли)');

section('data.php: profit_factor = null когда нет убытков');
$onlyWins = calculate_summary([['gross_pnl' => 5, 'net_pnl' => 4, 'pnl_pct' => 2, 'close_time' => '2026-01-01 10:00:00', 'open_time' => '2026-01-01 09:00:00']]);
ok($onlyWins['profit_factor'] === null, 'PF=null при нулевом валовом убытке');

section('data.php: group_pnl_by_period / cumulative_pnl');
$byDay = group_pnl_by_period($trades, 'day');
eqv(count($byDay), 2, 'два дня в группировке');
approx($byDay[0]['pnl_usd'], 8.0, 'день1 net=8');
$cum = cumulative_pnl($trades);
approx($cum[1]['pnl_usd'], 2.0, 'накопительно net=2 после 2 сделок');
eqv($cum[1]['trades'], 2, 'накопительно trades=2');

section('data.php: avg holding / slippage');
eqv(calculate_average_holding_time($trades), 5400, 'avg holding = (3600+7200)/2 = 5400s');
$slipTrades = [['entry_slippage_pct' => 0.1, 'exit_slippage_pct' => 0.3], ['entry_slippage_pct' => '', 'exit_slippage_pct' => 0.2]];
approx(calculate_average_slippage($slipTrades), 0.2, 'avg slippage = (0.1+0.3+0.2)/3');

section('data.php: normalize_trade_row (legacy)');
$legacy = normalize_trade_row(['pnl_usd' => 7, 'pnl_pct' => 3, 'size' => 100]);
approx((float) $legacy['net_pnl'], 7.0, 'legacy net_pnl = pnl_usd');
approx((float) $legacy['entry_fee'], 0.0, 'legacy entry_fee=0');

section('data.php: calculate_wallet_score grade');
ok(calculate_wallet_score([])['score'] === null, 'мало сделок → score null');
$manyTrades = [];
for ($i = 0; $i < 12; $i++) {
    $manyTrades[] = ['gross_pnl' => 5, 'net_pnl' => 4, 'pnl_pct' => 2, 'close_time' => '2026-01-' . sprintf('%02d', $i + 1) . ' 12:00:00', 'open_time' => '2026-01-' . sprintf('%02d', $i + 1) . ' 06:00:00'];
}
$score = calculate_wallet_score($manyTrades);
ok($score['score'] !== null && in_array($score['grade'], ['A', 'B', 'C', 'D', 'F'], true), 'грейд присвоен для 12 прибыльных сделок');

section('data.php: check_cron_health');
file_put_contents(HEARTBEAT_FILE, (string) time());
ok(check_cron_health(420)['ok'] === true, 'свежий heartbeat → ok');
ok(check_cron_health(420)['cron_ok'] === null, 'нет отметки тика → cron_ok=null');
file_put_contents(HEARTBEAT_FILE, (string) (time() - 1000));
ok(check_cron_health(420)['ok'] === false, 'старый heartbeat → не ok');
file_put_contents(CRON_TICK_FILE, (string) time());
$health = check_cron_health(420);
ok($health['cron_ok'] === true && $health['ok'] === false, 'свежий тик + старый heartbeat → cron жив, бот не завершает циклы');
file_put_contents(CRON_TICK_FILE, (string) (time() - 1000));
ok(check_cron_health(420)['cron_ok'] === false, 'старый тик → cron_ok=false');
@unlink(CRON_TICK_FILE);

// =============================================================
// forms.php — парсеры
// =============================================================
section('forms: parse_symbols_field / parse_leverage_field');
eqv(parse_symbols_field('btc, eth  sol'), ['BTC', 'ETH', 'SOL'], 'символы → upper, split по запятой/пробелу');
eqv(parse_symbols_field('   '), [], 'пусто → []');
eqv(parse_leverage_field('BTC=5, eth=2, bad'), ['BTC' => 5, 'ETH' => 2], 'leverage map, мусор отброшен');

section('forms: dashboard_executor_url_is_valid (SSRF UI-guard)');
ok(dashboard_executor_url_is_valid('https://example.com/x') === true, 'внешний https → ok');
ok(dashboard_executor_url_is_valid('http://example.com') === true, 'внешний http → ok');
ok(dashboard_executor_url_is_valid('http://localhost') === false, 'localhost → отказ');
ok(dashboard_executor_url_is_valid('http://127.0.0.1/x') === false, '127.0.0.1 → отказ');
ok(dashboard_executor_url_is_valid('http://10.1.2.3') === false, '10/8 → отказ');
ok(dashboard_executor_url_is_valid('http://192.168.0.5') === false, '192.168 → отказ');
ok(dashboard_executor_url_is_valid('ftp://example.com') === false, 'не http/https → отказ');

section('forms: parse_wallet_overrides — пустое = null');
$ov = parse_wallet_overrides([], 'x_');
ok($ov['allowed_symbols'] === null, 'allowed_symbols=null без чекбокса');
ok($ov['scale_factor'] === null, 'scale_factor=null когда пусто');
ok($ov['stop_loss'] === null && $ov['take_profit'] === null && $ov['grid'] === null, 'SL/TP/grid=null без чекбоксов');

section('forms: parse_wallet_overrides — заданные значения');
$ov2 = parse_wallet_overrides([
    'x_override_symbols' => '1', 'x_allowed_symbols' => 'btc, eth',
    'x_scale_factor' => '0.2', 'x_default_leverage' => '5',
    'x_executor_url' => 'http://localhost/x',   // приватный → должен обнулиться
    'x_sizing_mode' => 'percent_balance',
    'x_override_sl' => '1', 'x_sl_enabled' => '1', 'x_sl_type' => 'price_pct', 'x_sl_value' => '3',
    'x_override_grid' => '1', 'x_grid_enabled' => '1', 'x_grid_levels' => '4', 'x_grid_distribution' => 'multiplicative',
], 'x_');
eqv($ov2['allowed_symbols'], ['BTC', 'ETH'], 'override allowed_symbols');
approx((float) $ov2['scale_factor'], 0.2, 'override scale_factor');
eqv($ov2['default_leverage'], 5, 'override default_leverage');
ok($ov2['executor_url'] === null, 'приватный executor_url отброшен → null');
eqv($ov2['sizing_mode'], 'percent_balance', 'override sizing_mode');
eqv($ov2['stop_loss'], ['enabled' => true, 'type' => 'price_pct', 'value' => 3.0], 'SL-блок');
eqv($ov2['grid']['distribution'], 'multiplicative', 'grid distribution мартингейл');
eqv($ov2['grid']['levels'], 4, 'grid levels=4');

section('forms: parse_wallet_overrides — частота опроса и telegram');
$ov3 = parse_wallet_overrides([], 'x_');
ok($ov3['poll_interval_minutes'] === null, 'poll_interval=null когда пусто (наследовать глобальное)');
ok($ov3['telegram_notify'] === null, 'telegram_notify=null когда не выбрано');
$ov4 = parse_wallet_overrides([
    'x_poll_interval_minutes' => '3',
    'x_telegram_notify'       => '1',
], 'x_');
approx((float) $ov4['poll_interval_minutes'], 3.0, 'poll_interval=3 мин распарсен');
ok($ov4['telegram_notify'] === true, 'telegram_notify=true распарсен');
$ov5 = parse_wallet_overrides(['x_poll_interval_minutes' => '-5', 'x_telegram_notify' => '0'], 'x_');
approx((float) $ov5['poll_interval_minutes'], 0.0, 'отрицательный интервал клампится в 0');
ok($ov5['telegram_notify'] === false, 'telegram_notify=false (явное выключение)');

section('forms: parse_webhooks_from_post');
$wh = parse_webhooks_from_post([
    'wh_0_service' => 'katoshi', 'wh_0_enabled' => '1', 'wh_0_api_key' => 'k', 'wh_0_bot_id' => '42',
    'wh_1_service' => 'custom', 'wh_1_url' => 'https://x.io/h', 'wh_1_method' => 'GET',
    'wh_1_headers_raw' => "Authorization: Bearer z\nX-A: b",
    'wh_2_service' => 'telegram', 'wh_2_enabled' => '1', 'wh_2_bot_token' => '9:T', 'wh_2_chat_id' => '-100',
], 'wh_');
eqv(count($wh), 3, 'три webhook собрано');
eqv($wh[0]['service'], 'katoshi', 'wh0 katoshi');
eqv($wh[0]['bot_id'], 42, 'wh0 bot_id int');
eqv($wh[1]['method'], 'GET', 'wh1 method GET');
eqv($wh[1]['headers'], ['Authorization' => 'Bearer z', 'X-A' => 'b'], 'wh1 заголовки распарсены');
eqv($wh[2]['service'], 'telegram', 'wh2 telegram');
eqv($wh[2]['bot_token'], '9:T', 'wh2 bot_token');
eqv($wh[2]['chat_id'], '-100', 'wh2 chat_id');

section('forms: parse_grid_config / parse_protective_config (общие для кошелька и пула)');
$g = parse_grid_config(['grid_enabled' => '1', 'grid_levels' => '5', 'grid_step_pct' => '2', 'grid_size_factor' => '1.5', 'grid_distribution' => 'multiplicative'], '');
eqv($g, ['enabled' => true, 'levels' => 5, 'step_pct' => 2.0, 'size_factor' => 1.5, 'distribution' => 'multiplicative'], 'grid-конфиг распарсен');
eqv(parse_grid_config(['grid_distribution' => 'bogus'], '')['distribution'], 'equal', 'неизвестное распределение → equal');
ok(parse_grid_config([], '')['enabled'] === false, 'без grid_enabled → выкл');
$slc = parse_protective_config(['sl_enabled' => '1', 'sl_type' => 'price', 'sl_value' => '12'], '', 'sl');
eqv($slc, ['enabled' => true, 'type' => 'price', 'value' => 12.0], 'SL-конфиг распарсен');
eqv(parse_protective_config(['tp_type' => 'weird'], '', 'tp')['type'], 'pnl_pct', 'неизвестный тип → pnl_pct');

section('forms: fresh balance helpers');
$fb = dashboard_fresh_wallet_balance(500.0);
approx($fb['balance'], 500.0, 'wallet fresh balance=start');
ok(array_key_exists('total_funding', $fb), 'wallet fresh содержит total_funding');

// =============================================================
// settings_handler
// =============================================================
section('settings: dashboard_build_global_settings — клампы и валидация');
$gs = dashboard_build_global_settings([
    'allowed_symbols' => 'btc eth', 'default_leverage' => '0',
    'sizing_percent' => '200', 'sizing_mode' => 'weird',
    'partial_close_mode' => 'on', 'executor_mode' => 'live',
]);
eqv($gs['allowed_symbols'], ['BTC', 'ETH'], 'global allowed_symbols');
eqv($gs['default_leverage'], 1, 'default_leverage clamp ≥1');
approx($gs['sizing_percent'], 100.0, 'sizing_percent clamp ≤100');
eqv($gs['sizing_mode'], 'fixed', 'невалидный sizing_mode → fixed');
eqv($gs['partial_close_mode'], 'on', 'partial_close_mode=on');
eqv($gs['executor_mode'], 'live', 'executor_mode=live');

section('settings: dashboard_build_global_settings — опрос и telegram');
$gs2 = dashboard_build_global_settings([
    'poll_interval_minutes' => '2.5',
    'telegram_notify'       => '1',
    'telegram_bot_token'    => ' 123:ABC ',
    'telegram_chat_id'      => ' 42 ',
]);
approx($gs2['poll_interval_minutes'], 2.5, 'poll_interval_minutes=2.5');
ok($gs2['telegram_notify'] === true, 'telegram_notify=true');
eqv($gs2['telegram_bot_token'], '123:ABC', 'токен обрезан от пробелов');
eqv($gs2['telegram_chat_id'], '42', 'chat_id обрезан от пробелов');
$gs3 = dashboard_build_global_settings([]);
approx($gs3['poll_interval_minutes'], 0.0, 'дефолт: опрос каждый цикл');
ok($gs3['telegram_notify'] === false, 'дефолт: telegram выключен');

section('settings: handle save_global персистит конфиг');
$cfg = load_wallets_config();
$res = dashboard_handle_settings_post(['action' => 'save_global', 'scale_factor' => '0.25', 'default_leverage' => '7'], $cfg);
eqv($res['messageType'], 'success', 'save_global success');
$reload = load_wallets_config();
approx((float) $reload['global_settings']['scale_factor'], 0.25, 'scale_factor сохранён');
eqv($reload['global_settings']['default_leverage'], 7, 'default_leverage сохранён');

section('settings: handle save_global отвергает приватный executor_url');
$res = dashboard_handle_settings_post(['action' => 'save_global', 'executor_url' => 'http://127.0.0.1/x'], load_wallets_config());
eqv($res['messageType'], 'error', 'приватный executor_url → error');

section('settings: save_global СОХРАНЯЕТ ключи, которых нет в форме (execution_mode/webhooks)');
// Оператор задал в wallets.json ключи вне формы настроек — сохранение формы
// не должно молча откатывать их к заводским дефолтам.
$cfg = load_wallets_config();
$cfg['global_settings']['execution_mode'] = 'direct';
$cfg['global_settings']['webhooks'] = [['enabled' => true, 'service' => 'katoshi', 'api_key' => 'K', 'bot_id' => 1]];
save_wallets_config($cfg);
$res = dashboard_handle_settings_post(['action' => 'save_global', 'scale_factor' => '0.5'], load_wallets_config());
eqv($res['messageType'], 'success', 'save_global success');
$reload = load_wallets_config();
eqv($reload['global_settings']['execution_mode'], 'direct', 'execution_mode не откатился к дефолту');
eqv($reload['global_settings']['webhooks'][0]['service'] ?? null, 'katoshi', 'глобальные webhooks не потеряны');
approx((float) $reload['global_settings']['scale_factor'], 0.5, 'поле формы при этом обновлено');
// Возвращаем безопасные значения, чтобы не влиять на следующие секции.
$cfg = load_wallets_config();
$cfg['global_settings']['execution_mode'] = 'webhook';
$cfg['global_settings']['webhooks'] = [];
save_wallets_config($cfg);

section('config: per-wallet webhooks проходят через resolve_wallet_settings');
// 'webhooks' обязан быть в GLOBAL_SETTINGS_DEFAULTS: resolve_wallet_settings
// принимает только известные ключи, без него override молча отбрасывался.
$wWh = ['id' => 'wx', 'settings' => ['webhooks' => [['enabled' => true, 'service' => 'custom', 'url' => 'https://x.io/h']]]];
$effWh = resolve_wallet_settings($wWh, GLOBAL_SETTINGS_DEFAULTS);
eqv($effWh['webhooks'][0]['url'] ?? null, 'https://x.io/h', 'per-wallet webhooks не отброшены');
eqv(resolve_wallet_settings(['id' => 'wy'], GLOBAL_SETTINGS_DEFAULTS)['webhooks'], [], 'без override — пустой список из дефолтов');

// =============================================================
// wallets_handler
// =============================================================
section('wallets: add_wallet (валидный/невалидный адрес)');
$res = dashboard_handle_wallets_post(['action' => 'add_wallet', 'address' => ADDR_A, 'label' => 'T1'], load_wallets_config());
eqv($res['messageType'], 'success', 'add_wallet success');
$cfg = load_wallets_config();
eqv(count($cfg['wallets']), 1, 'кошелёк добавлен');
eqv($cfg['wallets'][0]['id'], 'w1', 'id=w1');
$bal = storage_load_balance('wallet', 'w1', 0.0);
approx($bal['balance'], (float) $cfg['global_settings']['virtual_balance'], 'счёт инициализирован стартовым балансом');

$res = dashboard_handle_wallets_post(['action' => 'add_wallet', 'address' => 'not-an-address'], load_wallets_config());
eqv($res['messageType'], 'error', 'невалидный адрес → error');
eqv(count(load_wallets_config()['wallets']), 1, 'кошелёк не добавлен при ошибке');

section('wallets: add_wallet отклоняет дубль адреса');
$res = dashboard_handle_wallets_post(['action' => 'add_wallet', 'address' => ADDR_A, 'label' => 'T1-dup'], load_wallets_config());
eqv($res['messageType'], 'error', 'повторный add того же адреса → error');
eqv(count(load_wallets_config()['wallets']), 1, 'дубликат не создан');
// Регистр hex-адреса не должен обходить проверку дублей
$res = dashboard_handle_wallets_post(['action' => 'add_wallet', 'address' => '0x' . strtoupper(substr(ADDR_A, 2)), 'label' => 'T1-upper'], load_wallets_config());
eqv($res['messageType'], 'error', 'дубль в другом регистре тоже отклонён');
eqv(count(load_wallets_config()['wallets']), 1, 'дубликат (регистр) не создан');

section('wallets: update_wallet');
$res = dashboard_handle_wallets_post([
    'action' => 'update_wallet', 'wallet_id' => 'w1', 'label' => 'T1-renamed',
    'address' => ADDR_A, 'enabled' => '1', 'status' => 'TEST', 'rating' => '4',
], load_wallets_config());
eqv($res['messageType'], 'success', 'update_wallet success');
$cfg = load_wallets_config();
eqv($cfg['wallets'][0]['label'], 'T1-renamed', 'label обновлён');
eqv($cfg['wallets'][0]['status'], 'TEST', 'status обновлён');
eqv($cfg['wallets'][0]['rating'], 4, 'rating обновлён');

section('wallets: update_wallet отвергает некорректный адрес');
$res = dashboard_handle_wallets_post([
    'action' => 'update_wallet', 'wallet_id' => 'w1', 'label' => 'Broken',
    'address' => 'not-an-address', 'enabled' => '1', 'status' => 'TEST',
], load_wallets_config());
eqv($res['messageType'], 'error', 'невалидный адрес при редактировании → error');
$cfg = load_wallets_config();
eqv($cfg['wallets'][0]['address'], ADDR_A, 'адрес не изменён');
eqv($cfg['wallets'][0]['label'], 'T1-renamed', 'label не изменён (сохранение отклонено целиком)');

section('wallets: bulk_import (dedup + invalid)');
$res = dashboard_handle_wallets_post([
    'action' => 'bulk_import',
    'addresses' => ADDR_B . ", Beta\n" . ADDR_A . "\nbadaddr\n" . ADDR_B,  // ADDR_A дубль, ADDR_B дубль внутри
    'import_enabled' => '1',
], load_wallets_config());
$cfg = load_wallets_config();
eqv(count($cfg['wallets']), 2, 'импортирован только ADDR_B (дубли/мусор отброшены)');

section('wallets: manage_tags');
$res = dashboard_handle_wallets_post(['action' => 'manage_tags', 'tags_list' => "scalper, swing\nbtc-only"], load_wallets_config());
$cfg = load_wallets_config();
eqv($cfg['available_tags'], ['btc-only', 'scalper', 'swing'], 'теги сохранены и отсортированы');

section('wallets: add_log / delete_log');
dashboard_handle_wallets_post(['action' => 'add_log', 'wallet_id' => 'w1', 'note' => 'тест-запись'], load_wallets_config());
$log = storage_get_wallet_log('w1', 10);
ok(count($log) === 1 && $log[0]['note'] === 'тест-запись', 'запись журнала добавлена');
dashboard_handle_wallets_post(['action' => 'delete_log', 'wallet_id' => 'w1', 'entry_id' => (string) $log[0]['id']], load_wallets_config());
eqv(count(storage_get_wallet_log('w1', 10)), 0, 'запись журнала удалена');

section('wallets: reset_wallet');
storage_save_balance('wallet', 'w1', dashboard_fresh_wallet_balance(1234.0)); // загрязняем
$res = dashboard_handle_wallets_post(['action' => 'reset_wallet', 'wallet_id' => 'w1'], load_wallets_config());
eqv($res['messageType'], 'success', 'reset_wallet success');
$bal = storage_load_balance('wallet', 'w1', 0.0);
$cfg = load_wallets_config();
$expectStart = (float) resolve_wallet_settings($cfg['wallets'][0], $cfg['global_settings'])['virtual_balance'];
approx($bal['balance'], $expectStart, 'баланс сброшен к стартовому');

section('wallets: delete_wallet');
$res = dashboard_handle_wallets_post(['action' => 'delete_wallet', 'wallet_id' => 'w1'], load_wallets_config());
$cfg = load_wallets_config();
ok(!in_array('w1', array_column($cfg['wallets'], 'id'), true), 'w1 удалён из конфига');

section('wallets: toggle_wallet');
// Остался только w2 (импортирован с enabled=true)
$w2id = load_wallets_config()['wallets'][0]['id'];
$res = dashboard_handle_wallets_post(['action' => 'toggle_wallet', 'wallet_id' => $w2id], load_wallets_config());
eqv($res['messageType'], 'success', 'toggle success');
eqv(load_wallets_config()['wallets'][0]['enabled'], false, 'кошелёк приостановлен');
dashboard_handle_wallets_post(['action' => 'toggle_wallet', 'wallet_id' => $w2id], load_wallets_config());
eqv(load_wallets_config()['wallets'][0]['enabled'], true, 'повторный toggle снова включает');
$res = dashboard_handle_wallets_post(['action' => 'toggle_wallet', 'wallet_id' => 'no-such-id'], load_wallets_config());
eqv($res['messageType'], 'error', 'несуществующий id → error');

section('wallets: bulk_wallets (enable/disable/delete)');
dashboard_handle_wallets_post(['action' => 'add_wallet', 'address' => ADDR_A, 'label' => 'T-bulk'], load_wallets_config());
$cfg = load_wallets_config();
$allIds = array_column($cfg['wallets'], 'id');
eqv(count($allIds), 2, 'два кошелька для массовых операций');

$res = dashboard_handle_wallets_post(['action' => 'bulk_wallets', 'bulk_op' => 'disable', 'wallet_ids' => $allIds], load_wallets_config());
eqv($res['messageType'], 'success', 'bulk disable success');
$cfg = load_wallets_config();
ok(empty(array_filter($cfg['wallets'], fn($w) => !empty($w['enabled']))), 'все выбранные приостановлены');

$res = dashboard_handle_wallets_post(['action' => 'bulk_wallets', 'bulk_op' => 'enable', 'wallet_ids' => $allIds], load_wallets_config());
$cfg = load_wallets_config();
eqv(count(array_filter($cfg['wallets'], fn($w) => !empty($w['enabled']))), 2, 'все выбранные включены обратно');

$res = dashboard_handle_wallets_post(['action' => 'bulk_wallets', 'bulk_op' => 'delete', 'wallet_ids' => []], load_wallets_config());
eqv($res['messageType'], 'error', 'пустой выбор → error');
$res = dashboard_handle_wallets_post(['action' => 'bulk_wallets', 'bulk_op' => 'explode', 'wallet_ids' => $allIds], load_wallets_config());
eqv($res['messageType'], 'error', 'неизвестная операция → error');
eqv(count(load_wallets_config()['wallets']), 2, 'кошельки не тронуты при ошибках');

$bulkId = array_values(array_diff($allIds, [$w2id]))[0];
$res = dashboard_handle_wallets_post(['action' => 'bulk_wallets', 'bulk_op' => 'delete', 'wallet_ids' => [$bulkId, 'no-such-id']], load_wallets_config());
eqv($res['messageType'], 'success', 'bulk delete success (чужой id отброшен)');
$cfg = load_wallets_config();
eqv(count($cfg['wallets']), 1, 'удалён ровно один кошелёк');
eqv($cfg['wallets'][0]['id'], $w2id, 'остался w2');

section('forms: parse_bulk_wallet_edits');
$avail = ['btc-only', 'scalper', 'swing'];
$edits = parse_bulk_wallet_edits([
    'bulk_apply_poll_interval_minutes' => '1', 'bulk_poll_interval_minutes' => '5',
    'bulk_apply_hl_margin_mode'        => '1', 'bulk_hl_margin_mode' => 'isolated',
    'bulk_apply_scale_factor'          => '1', 'bulk_scale_factor' => '',      // пусто = вернуть глобальное
    'bulk_default_leverage'            => '9',                                  // БЕЗ apply-чекбокса → игнор
    'bulk_tags_mode' => 'add', 'bulk_tags' => ['scalper', 'no-such-tag'],
], $avail);
eqv($edits['settings']['poll_interval_minutes'], 5.0, 'отмеченное число разобрано');
eqv($edits['settings']['hl_margin_mode'], 'isolated', 'отмеченный select разобран');
ok(array_key_exists('scale_factor', $edits['settings']) && $edits['settings']['scale_factor'] === null,
    'отмеченное пустое поле → null (сброс к глобальному)');
ok(!array_key_exists('default_leverage', $edits['settings']), 'поле без apply-чекбокса не попадает в изменения');
eqv($edits['tags_mode'], 'add', 'режим тегов разобран');
eqv($edits['tags'], ['scalper'], 'неизвестный тег отфильтрован');
$edits = parse_bulk_wallet_edits(['bulk_tags_mode' => 'explode'], $avail);
eqv($edits['tags_mode'], '', 'неизвестный режим тегов → не менять');
eqv($edits['settings'], [], 'без отмеченных полей settings пуст');

section('forms: слиппедж и funding как per-wallet override');
// Оба ключа есть в GLOBAL_SETTINGS_DEFAULTS и принимаются резолвером, но
// парсер их не разбирал — введённое в карточке трейдера значение молча
// терялось при сохранении.
$ovCost = parse_wallet_overrides([
    'x_slippage_pct_per_side'   => '0.02',
    'x_funding_rate_hourly_pct' => '0.00125',
], 'x_');
approx($ovCost['slippage_pct_per_side'], 0.02, 'слиппедж разобран как override');
approx($ovCost['funding_rate_hourly_pct'], 0.00125, 'funding разобран как override');
$ovCostEmpty = parse_wallet_overrides([], 'x_');
ok($ovCostEmpty['slippage_pct_per_side'] === null, 'пустой слиппедж = наследовать глобальный');
ok($ovCostEmpty['funding_rate_hourly_pct'] === null, 'пустой funding = наследовать глобальный');
// Отрицательный слиппедж бессмыслен — режется в 0.
$ovCostNeg = parse_wallet_overrides(['x_slippage_pct_per_side' => '-1'], 'x_');
approx($ovCostNeg['slippage_pct_per_side'], 0.0, 'отрицательный слиппедж → 0');
// И то же самое доезжает до эффективных настроек кошелька.
$effCost = resolve_wallet_settings(
    ['id' => 'wc', 'settings' => ['slippage_pct_per_side' => 0.02]],
    array_merge(GLOBAL_SETTINGS_DEFAULTS, ['slippage_pct_per_side' => 0.05])
);
approx($effCost['slippage_pct_per_side'], 0.02, 'override слиппеджа перебивает глобальный');

// Массовое изменение теперь тоже умеет эти поля.
$editsCost = parse_bulk_wallet_edits([
    'bulk_apply_slippage_pct_per_side'   => '1', 'bulk_slippage_pct_per_side' => '0.03',
    'bulk_apply_funding_rate_hourly_pct' => '1', 'bulk_funding_rate_hourly_pct' => '',
], ['btc-only']);
approx($editsCost['settings']['slippage_pct_per_side'], 0.03, 'bulk: слиппедж разобран');
ok(array_key_exists('funding_rate_hourly_pct', $editsCost['settings'])
    && $editsCost['settings']['funding_rate_hourly_pct'] === null,
    'bulk: пустой funding → сброс к глобальному');

section('settings_schema: каталог покрывает то, что реально сохраняется');
// Каждое поле каталога со scope=both ОБЯЗАНО разбираться parse_wallet_overrides:
// иначе оно рисуется в карточке трейдера, но введённое значение теряется.
$schemaParsed = parse_wallet_overrides([], 'x_');
$schemaMissing = [];
foreach (settings_schema() as $group) {
    foreach ($group['fields'] as $field) {
        if (($field['scope'] ?? 'both') !== 'both') {
            continue;
        }
        if (!array_key_exists($field['key'], $schemaParsed)) {
            $schemaMissing[] = $field['key'];
        }
    }
}
ok(empty($schemaMissing), 'все поля каталога со scope=both разбираются парсером'
    . ($schemaMissing ? ' (потерялись: ' . implode(', ', $schemaMissing) . ')' : ''));

// И каждый ключ каталога должен существовать в GLOBAL_SETTINGS_DEFAULTS —
// иначе resolve_settings молча выбросит его как «ключ вне пространства базы».
$schemaUnknown = [];
foreach (settings_schema() as $group) {
    foreach ($group['fields'] as $field) {
        if (!array_key_exists($field['key'], GLOBAL_SETTINGS_DEFAULTS)) {
            $schemaUnknown[] = $field['key'];
        }
    }
}
ok(empty($schemaUnknown), 'все ключи каталога есть в GLOBAL_SETTINGS_DEFAULTS'
    . ($schemaUnknown ? ' (лишние: ' . implode(', ', $schemaUnknown) . ')' : ''));

// Подписи: ни одно поле не должно быть подписано именем ключа из JSON.
$schemaRawLabels = [];
foreach (settings_schema() as $group) {
    foreach ($group['fields'] as $field) {
        if ($field['label'] === $field['key'] || strpos($field['label'], '_') !== false) {
            $schemaRawLabels[] = $field['key'];
        }
        if (trim($field['what'] ?? '') === '') {
            $schemaRawLabels[] = $field['key'] . ' (без описания)';
        }
    }
}
ok(empty($schemaRawLabels), 'у каждого поля человеческая подпись и описание'
    . ($schemaRawLabels ? ' (нарушают: ' . implode(', ', $schemaRawLabels) . ')' : ''));

section('wallets: bulk_wallets op=edit (настройки + теги)');
dashboard_handle_wallets_post(['action' => 'add_wallet', 'address' => ADDR_A, 'label' => 'T-bulk2'], load_wallets_config());
$cfg = load_wallets_config();
$allIds = array_column($cfg['wallets'], 'id');
// заранее даём одному кошельку override и тег — проверим сброс/merge
$res = dashboard_handle_wallets_post([
    'action' => 'update_wallet', 'wallet_id' => $w2id, 'label' => 'T-w2', 'address' => ADDR_B,
    'enabled' => '1', 'status' => 'WATCH', 'tags' => ['btc-only'],
    'edit_' . $w2id . '_scale_factor' => '0.5',
], load_wallets_config());
eqv(load_wallets_config()['wallets'][0]['settings']['scale_factor'], 0.5, 'подготовка: override scale_factor=0.5');

$res = dashboard_handle_wallets_post([
    'action' => 'bulk_wallets', 'bulk_op' => 'edit', 'wallet_ids' => $allIds,
    'bulk_apply_poll_interval_minutes' => '1', 'bulk_poll_interval_minutes' => '10',
    'bulk_apply_execution_mode'        => '1', 'bulk_execution_mode' => 'direct',
    'bulk_apply_scale_factor'          => '1', 'bulk_scale_factor' => '',
    'bulk_tags_mode' => 'add', 'bulk_tags' => ['scalper'],
], load_wallets_config());
eqv($res['messageType'], 'success', 'bulk edit success');
$cfg = load_wallets_config();
foreach ($cfg['wallets'] as $w) {
    approx((float) $w['settings']['poll_interval_minutes'], 10.0, $w['id'] . ': poll_interval задан массово');
    eqv($w['settings']['execution_mode'], 'direct', $w['id'] . ': execution_mode задан массово');
    ok($w['settings']['scale_factor'] === null, $w['id'] . ': override scale_factor сброшен к глобальному');
    ok(in_array('scalper', $w['tags'] ?? [], true), $w['id'] . ': тег добавлен');
}
ok(in_array('btc-only', $cfg['wallets'][0]['tags'], true), 'существующий тег не потерян при add');

$res = dashboard_handle_wallets_post([
    'action' => 'bulk_wallets', 'bulk_op' => 'edit', 'wallet_ids' => [$w2id],
    'bulk_tags_mode' => 'remove', 'bulk_tags' => ['btc-only'],
], load_wallets_config());
eqv(load_wallets_config()['wallets'][0]['tags'], ['scalper'], 'remove убирает только отмеченный тег');

$res = dashboard_handle_wallets_post([
    'action' => 'bulk_wallets', 'bulk_op' => 'edit', 'wallet_ids' => [$w2id],
    'bulk_tags_mode' => 'replace', 'bulk_tags' => ['swing'],
], load_wallets_config());
eqv(load_wallets_config()['wallets'][0]['tags'], ['swing'], 'replace заменяет теги целиком');

$res = dashboard_handle_wallets_post([
    'action' => 'bulk_wallets', 'bulk_op' => 'edit', 'wallet_ids' => $allIds,
], load_wallets_config());
eqv($res['messageType'], 'error', 'bulk edit без отмеченных полей → error');

// чистим второй кошелёк, чтобы не влиять на последующие секции
$bulk2 = array_values(array_diff($allIds, [$w2id]))[0];
dashboard_handle_wallets_post(['action' => 'delete_wallet', 'wallet_id' => $bulk2], load_wallets_config());
eqv(count(load_wallets_config()['wallets']), 1, 'вспомогательный кошелёк удалён');

// =============================================================
// pools_handler
// =============================================================
section('pools: add_pool');
$res = dashboard_handle_pools_post(['action' => 'add_pool', 'pool_label' => 'P1', 'virtual_balance' => '500', 'leverage' => '4'], load_pools_config()['pools']);
eqv($res['messageType'], 'success', 'add_pool success');
$pools = load_pools_config()['pools'];
eqv(count($pools), 1, 'пул создан');
eqv($pools[0]['id'], 'p1', 'pool id=p1');
eqv($pools[0]['execution_mode'], 'webhook', 'новый пул по умолчанию webhook (не live)');
$pbal = storage_load_balance('pool', 'p1', 0.0);
approx($pbal['balance'], 500.0, 'баланс пула = стартовый 500');

section('pools: add_trader + проверка аллокации ≤100%');
$res = dashboard_handle_pools_post(['action' => 'add_trader', 'pool_id' => 'p1', 'trader_address' => ADDR_A, 'trader_label' => 'TA', 'allocation_pct' => '60'], load_pools_config()['pools']);
eqv($res['messageType'], 'success', 'первый трейдер 60% ok');
$res = dashboard_handle_pools_post(['action' => 'add_trader', 'pool_id' => 'p1', 'trader_address' => ADDR_B, 'trader_label' => 'TB', 'allocation_pct' => '50'], load_pools_config()['pools']);
eqv($res['messageType'], 'error', 'второй трейдер 50% → суммарно 110% → error');
$pools = load_pools_config()['pools'];
eqv(count($pools[0]['traders']), 1, 'второй трейдер не добавлен');

section('pools: update_trader');
$tid = $pools[0]['traders'][0]['id'];
$res = dashboard_handle_pools_post([
    'action' => 'update_trader', 'pool_id' => 'p1', 'trader_id' => $tid,
    'trader_label' => 'TA2', 'trader_address' => ADDR_A, 'allocation_pct' => '40', 'trader_enabled' => '1',
    'grid_enabled' => '1', 'grid_levels' => '3', 'grid_step_pct' => '1', 'grid_size_factor' => '2', 'grid_distribution' => 'multiplicative',
    'sl_enabled' => '1', 'sl_type' => 'pnl_pct', 'sl_value' => '40',
    'tp_enabled' => '0', 'tp_value' => '0',
], load_pools_config()['pools']);
eqv($res['messageType'], 'success', 'update_trader success');
$pools = load_pools_config()['pools'];
eqv($pools[0]['traders'][0]['label'], 'TA2', 'label трейдера обновлён');
approx((float) $pools[0]['traders'][0]['allocation_pct'], 40.0, 'аллокация обновлена');
ok($pools[0]['traders'][0]['grid']['enabled'] === true, 'сетка трейдера сохранена');
eqv($pools[0]['traders'][0]['grid']['levels'], 3, 'levels сетки трейдера');
eqv($pools[0]['traders'][0]['grid']['distribution'], 'multiplicative', 'распределение сетки трейдера');
approx((float) $pools[0]['traders'][0]['stop_loss']['value'], 40.0, 'SL трейдера сохранён');
ok($pools[0]['traders'][0]['take_profit']['enabled'] === false, 'TP трейдера выключен');

section('pools: update_pool — live (execution_mode) и режим маржи');
$res = dashboard_handle_pools_post([
    'action' => 'update_pool', 'pool_id' => 'p1', 'pool_label' => 'P1', 'pool_enabled' => '1',
    'virtual_balance' => '500', 'leverage' => '4', 'fee_pct_per_side' => '0.035',
    'execution_mode' => 'direct', 'hl_margin_mode' => 'isolated',
], load_pools_config()['pools']);
eqv($res['messageType'], 'success', 'update_pool success');
$pools = load_pools_config()['pools'];
eqv($pools[0]['execution_mode'], 'direct', 'пул переключён в direct (live)');
eqv($pools[0]['hl_margin_mode'], 'isolated', 'режим маржи пула = isolated');

section('pools: настройки и вебхуки НЕ затирают друг друга (раздельные actions)');
// 1) Сохраняем вебхуки отдельным action.
$res = dashboard_handle_pools_post([
    'action' => 'update_pool_webhooks', 'pool_id' => 'p1',
    'pool_wh_0_service' => 'katoshi', 'pool_wh_0_enabled' => '1',
    'pool_wh_0_api_key' => 'kk', 'pool_wh_0_bot_id' => '7',
], load_pools_config()['pools']);
eqv($res['messageType'], 'success', 'update_pool_webhooks success');
$pools = load_pools_config()['pools'];
eqv($pools[0]['webhooks'][0]['service'] ?? null, 'katoshi', 'вебхук пула сохранён');
eqv($pools[0]['webhooks'][0]['bot_id'] ?? null, 7, 'bot_id сохранён');

// 2) Задаём субаккаунт через форму настроек — вебхуки должны уцелеть.
$res = dashboard_handle_pools_post([
    'action' => 'update_pool', 'pool_id' => 'p1', 'pool_label' => 'P1', 'pool_enabled' => '1',
    'virtual_balance' => '500', 'leverage' => '4', 'fee_pct_per_side' => '0.035',
    'execution_mode' => 'direct', 'hl_margin_mode' => 'isolated',
    'hl_subaccount' => '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
], load_pools_config()['pools']);
eqv($res['messageType'], 'success', 'update_pool с субаккаунтом success');
$pools = load_pools_config()['pools'];
eqv($pools[0]['hl_subaccount'], '0xcccccccccccccccccccccccccccccccccccccccc', 'субаккаунт задан (нижний регистр)');
eqv($pools[0]['webhooks'][0]['service'] ?? null, 'katoshi', 'сохранение настроек НЕ затёрло вебхуки');

// 3) Сохраняем вебхуки снова (в форме нет hl_subaccount) — субаккаунт должен уцелеть.
$res = dashboard_handle_pools_post([
    'action' => 'update_pool_webhooks', 'pool_id' => 'p1',
    'pool_wh_0_service' => 'katoshi', 'pool_wh_0_enabled' => '1',
    'pool_wh_0_api_key' => 'kk2', 'pool_wh_0_bot_id' => '8',
], load_pools_config()['pools']);
$pools = load_pools_config()['pools'];
eqv($pools[0]['hl_subaccount'], '0xcccccccccccccccccccccccccccccccccccccccc', 'сохранение вебхуков НЕ сбросило субаккаунт');
eqv($pools[0]['execution_mode'], 'direct', 'execution_mode не тронут вебхук-формой');
eqv($pools[0]['webhooks'][0]['api_key'] ?? null, 'kk2', 'вебхук при этом обновлён');

section('pools: update_trader отвергает некорректный адрес');
$res = dashboard_handle_pools_post([
    'action' => 'update_trader', 'pool_id' => 'p1', 'trader_id' => $tid,
    'trader_label' => 'TA3', 'trader_address' => 'oops', 'allocation_pct' => '40', 'trader_enabled' => '1',
], load_pools_config()['pools']);
eqv($res['messageType'], 'error', 'невалидный адрес трейдера → error');
$pools = load_pools_config()['pools'];
eqv($pools[0]['traders'][0]['address'], ADDR_A, 'адрес трейдера не изменён');
eqv($pools[0]['traders'][0]['label'], 'TA2', 'label не изменён (сохранение отклонено целиком)');

section('pools: remove_trader');
dashboard_handle_pools_post(['action' => 'remove_trader', 'pool_id' => 'p1', 'trader_id' => $tid], load_pools_config()['pools']);
eqv(count(load_pools_config()['pools'][0]['traders']), 0, 'трейдер удалён');

section('pools: reset_pool / delete_pool');
storage_save_balance('pool', 'p1', fresh_pool_balance(9999.0));
dashboard_handle_pools_post(['action' => 'reset_pool', 'pool_id' => 'p1'], load_pools_config()['pools']);
approx(storage_load_balance('pool', 'p1', 0.0)['balance'], 500.0, 'reset_pool вернул стартовый баланс');
dashboard_handle_pools_post(['action' => 'delete_pool', 'pool_id' => 'p1'], load_pools_config()['pools']);
eqv(count(load_pools_config()['pools']), 0, 'пул удалён');

// =============================================================
// traders_xlsx.php + xlsx.php — экспорт/импорт настроек в XLSX
// =============================================================
section('xlsx: запись/чтение round-trip');
$xlsxFile = $tmp . '/state/test.xlsx';
$srcRows = [
    ['id', 'label', 'число', 'пусто', 'спец'],
    ['w1', 'Трейдер <А> & "Б"', 0.5, '', "多行\nтекст"],
    ['w2', '', -3, '', 'x'],
];
ok(xlsx_write($xlsxFile, $srcRows, 'Test'), 'xlsx_write вернул true');
$readRows = xlsx_read_rows($xlsxFile);
ok($readRows !== null, 'xlsx_read_rows прочитал файл');
eqv($readRows[0], ['id', 'label', 'число', 'пусто', 'спец'], 'заголовок совпал');
eqv($readRows[1][1], 'Трейдер <А> & "Б"', 'спецсимволы XML пережили round-trip');
approx((float) $readRows[1][2], 0.5, 'число пережило round-trip');
eqv($readRows[2][0], 'w2', 'вторая строка на месте');
eqv($readRows[1][4], "多行\nтекст", 'unicode и перенос строки сохранены');
ok(xlsx_read_rows(LOG_FILE) === null || !is_file(LOG_FILE), 'не-zip файл → null (не фатально)');
@unlink($xlsxFile);

section('traders_xlsx: экспорт строк');
$xlsxConfig = [
    'global_settings' => GLOBAL_SETTINGS_DEFAULTS,
    'available_tags'  => ['scalper'],
    'wallets' => [
        [
            'id' => 'w1', 'label' => 'Alpha', 'address' => ADDR_A, 'enabled' => true,
            'status' => 'ACTIVE', 'rating' => 4, 'tags' => ['scalper'], 'notes' => 'note', 'source' => 'src',
            'settings' => [
                'scale_factor'    => 0.5,
                'default_leverage' => 7,
                'allowed_symbols' => ['BTC', 'ETH'],
                'symbol_leverage' => ['BTC' => 5],
                'telegram_notify' => true,
                'stop_loss'       => ['enabled' => true, 'type' => 'pnl_pct', 'value' => 25],
            ],
        ],
        ['id' => 'w2', 'label' => 'Beta', 'address' => ADDR_B, 'enabled' => false, 'settings' => []],
    ],
];
$expRows = traders_xlsx_export_rows($xlsxConfig);
$header  = $expRows[0];
eqv(count($expRows), 3, 'заголовок + 2 трейдера');
$col = array_flip($header);
eqv($expRows[1][$col['id']], 'w1', 'id в экспорте');
eqv($expRows[1][$col['scale_factor']], 0.5, 'override scale_factor выгружен числом');
eqv($expRows[1][$col['allowed_symbols']], 'BTC, ETH', 'allowed_symbols через запятую');
eqv($expRows[1][$col['symbol_leverage']], 'BTC=5', 'symbol_leverage как пары');
eqv($expRows[1][$col['telegram_notify']], 1, 'bool override → 1');
eqv(json_decode($expRows[1][$col['stop_loss']], true)['value'], 25, 'stop_loss как JSON');
eqv($expRows[1][$col['cooldown_seconds']], '', 'незаданный override → пустая ячейка');
eqv($expRows[2][$col['enabled']], 0, 'выключенный трейдер → enabled=0');

section('traders_xlsx: полный цикл экспорт → импорт без изменений');
$file = traders_xlsx_export_file($xlsxConfig);
ok($file !== null, 'traders_xlsx_export_file создал файл');
$res = traders_xlsx_apply_rows(xlsx_read_rows($file), $xlsxConfig);
@unlink($file);
eqv($res['updated'], 2, 'оба трейдера сопоставлены');
eqv($res['created'], 0, 'ничего не создано');
eqv($res['warnings'], [], 'без предупреждений');
eqv($res['config']['wallets'][0]['settings']['scale_factor'], 0.5, 'scale_factor пережил цикл');
eqv($res['config']['wallets'][0]['settings']['symbol_leverage'], ['BTC' => 5], 'symbol_leverage пережил цикл');
eqv($res['config']['wallets'][0]['settings']['stop_loss'],
    ['enabled' => true, 'type' => 'pnl_pct', 'value' => 25.0], 'stop_loss пережил цикл');
ok(array_key_exists('cooldown_seconds', $res['config']['wallets'][0]['settings'])
    && $res['config']['wallets'][0]['settings']['cooldown_seconds'] === null,
    'пустая ячейка = null (наследование)');

section('traders_xlsx: импорт правок');
$rows = [
    ['id', 'address', 'label', 'enabled', 'status', 'tags', 'scale_factor', 'default_leverage', 'sizing_mode'],
    // w1: меняем настройки, сбрасываем default_leverage на глобальный
    ['w1', ADDR_A, 'Alpha2', '0', 'TEST', 'scalper, degen', '0.9', '', 'proportional'],
    // новый трейдер по адресу (id пустой)
    ['', '0x' . str_repeat('c', 40), 'Gamma', '1', 'WATCH', '', '0.2', '4', ''],
    // мусорная строка: невалидный адрес
    ['', 'not-an-address', 'Junk', '', '', '', '', '', ''],
    // неизвестный id без адреса — восстановить нечего
    ['w99', '', 'Ghost', '', '', '', '', '', ''],
];
$res = traders_xlsx_apply_rows($rows, $xlsxConfig);
eqv($res['updated'], 1, 'обновлён один (w1)');
eqv($res['created'], 1, 'создан один (Gamma)');
eqv($res['skipped'], 2, 'мусор и неизвестный id пропущены');
$w1 = $res['config']['wallets'][0];
eqv($w1['label'], 'Alpha2', 'label обновлён');
eqv($w1['enabled'], false, 'enabled=0 применён');
eqv($w1['status'], 'TEST', 'status обновлён');
eqv($w1['tags'], ['scalper', 'degen'], 'теги заменены');
ok(in_array('degen', $res['config']['available_tags'], true), 'новый тег попал в глобальный список');
approx($w1['settings']['scale_factor'], 0.9, 'scale_factor обновлён');
eqv($w1['settings']['default_leverage'], null, 'пустая ячейка сбросила override к глобальному');
eqv($w1['settings']['sizing_mode'], 'proportional', 'sizing_mode применён');
eqv($w1['settings']['stop_loss']['value'] ?? null, 25, 'колонки, которых нет в файле, не тронуты');
$gamma = $res['config']['wallets'][2];
eqv($gamma['label'], 'Gamma', 'новый трейдер создан с label');
eqv($gamma['address'], '0x' . str_repeat('c', 40), 'адрес нового трейдера');
approx($gamma['settings']['scale_factor'], 0.2, 'настройки нового трейдера применены');
eqv(count($res['warnings']), 2, 'два предупреждения по мусорным строкам');

section('traders_xlsx: восстановление экспорта в пустую установку (бэкап)');
$emptyConfig = ['global_settings' => GLOBAL_SETTINGS_DEFAULTS, 'available_tags' => [], 'wallets' => []];
$res = traders_xlsx_apply_rows(traders_xlsx_export_rows($xlsxConfig), $emptyConfig);
eqv($res['created'], 2, 'оба трейдера восстановлены в пустой конфиг');
eqv($res['updated'], 0, 'обновлять было нечего');
eqv($res['warnings'], [], 'без предупреждений');
eqv($res['config']['wallets'][0]['id'], 'w1', 'id из файла сохранён (история в БД остаётся привязанной)');
eqv($res['config']['wallets'][1]['id'], 'w2', 'второй id тоже сохранён');
eqv($res['config']['wallets'][0]['label'], 'Alpha', 'label восстановлен');
eqv($res['config']['wallets'][0]['status'], 'ACTIVE', 'status восстановлен');
approx($res['config']['wallets'][0]['settings']['scale_factor'], 0.5, 'overrides восстановлены');
eqv($res['config']['wallets'][1]['enabled'], false, 'enabled=0 восстановлен');
ok(in_array('scalper', $res['config']['available_tags'], true), 'теги восстановлены в глобальный список');

section('traders_xlsx: валидация значений и createNew=false');
$rows = [
    ['id', 'default_leverage', 'executor_url', 'hl_subaccount', 'sizing_percent', 'stop_loss'],
    ['w1', 'abc', 'http://localhost/x', '0xZZ', '250', '{битый json'],
];
$res = traders_xlsx_apply_rows($rows, $xlsxConfig);
$w1s = $res['config']['wallets'][0]['settings'];
eqv($w1s['default_leverage'], 7, 'невалидное число не затёрло прежний override');
eqv($w1s['executor_url'] ?? null, null, 'приватный executor_url отвергнут');
eqv($w1s['stop_loss']['value'] ?? null, 25, 'битый JSON stop_loss не применён');
approx($w1s['sizing_percent'], 100.0, 'sizing_percent зажат в 0..100');
eqv(count($res['warnings']), 4, 'по предупреждению на каждое битое поле');

$rows = [['address', 'label'], ['0x' . str_repeat('d', 40), 'NoCreate']];
$res = traders_xlsx_apply_rows($rows, $xlsxConfig, false);
eqv($res['created'], 0, 'createNew=false не создаёт трейдеров');
eqv($res['skipped'], 1, 'строка с новым адресом пропущена');

$res = traders_xlsx_apply_rows([['label'], ['x']], $xlsxConfig);
ok($res['updated'] === 0 && !empty($res['warnings']), 'файл без id/address → предупреждение, ничего не изменено');

section('бэкапы конфига: потеря трейдеров обратима');

// Сценарий, ради которого всё это и делается: список трейдеров затирается
// (кривая выкладка, пустая запись), и его нужно вернуть С АДРЕСАМИ — в БД их
// нет, только wallet_id и label, восстановить копирование по ним нельзя.
@array_map('unlink', (array) glob(config_backups_dir() . '/*.json'));
save_wallets_config(['global_settings' => [], 'available_tags' => ['scalper'], 'wallets' => [
    ['id' => 'bk1', 'label' => 'Избранный', 'address' => ADDR_A, 'enabled' => true,
     'status' => 'ACTIVE', 'rating' => 5, 'tags' => ['scalper'], 'settings' => ['sizing_fixed_usd' => 250]],
    ['id' => 'bk2', 'label' => 'Второй', 'address' => ADDR_B, 'enabled' => true,
     'status' => 'TEST', 'rating' => 4, 'tags' => [], 'settings' => []],
]]);
// Дедуп: первая перезапись кладёт бэкап текущего состояния, а вот следующая
// с тем же содержимым — уже нет (иначе ротация вытеснила бы полезные копии).
save_wallets_config(load_wallets_config());
$bkCountBefore = count(config_backups_list('wallets'));
save_wallets_config(load_wallets_config());
ok(count(config_backups_list('wallets')) === $bkCountBefore, 'одинаковое содержимое не плодит бэкапы');

// ...а изменение — плодит, и в ОТДЕЛЬНЫЙ файл даже в ту же секунду.
$lostCfg = load_wallets_config();
$lostCfg['wallets'] = [];
save_wallets_config($lostCfg);
eqv(count(load_wallets_config()['wallets']), 0, 'трейдеры потеряны (симуляция аварии)');

$bkList = config_backups_list('wallets');
$bkGood = null;
foreach ($bkList as $b) {
    if ($b['wallets'] === 2) { $bkGood = $b['name']; break; }
}
ok($bkGood !== null, 'в бэкапах есть версия с двумя трейдерами');

ok(config_restore_backup('wallets.json', $bkGood), 'восстановление отработало');
$restored = load_wallets_config();
eqv(count($restored['wallets']), 2, 'трейдеры вернулись');
eqv($restored['wallets'][0]['address'], ADDR_A, 'АДРЕС восстановлен (главное — его нет в БД)');
eqv($restored['wallets'][0]['rating'], 5, 'рейтинг избранного восстановлен');
eqv($restored['wallets'][0]['tags'], ['scalper'], 'теги восстановлены');
eqv($restored['wallets'][0]['settings']['sizing_fixed_usd'], 250, 'per-wallet override восстановлен');
eqv($restored['available_tags'], ['scalper'], 'список тегов восстановлен');

// Восстановление обратимо: версия «без трейдеров» тоже ушла в бэкап.
$afterRestore = config_backups_list('wallets');
$hasEmpty = false;
foreach ($afterRestore as $b) {
    if ($b['wallets'] === 0) { $hasEmpty = true; break; }
}
ok($hasEmpty, 'версия до восстановления сохранена — шаг обратим');

// Защита от подстановки пути и от битого файла.
ok(!config_restore_backup('wallets.json', '../../../etc/passwd'), 'путь извне отвергнут');
ok(!config_restore_backup('wallets.json', 'wallets-нет-такого.json'), 'несуществующий бэкап отвергнут');
$brokenPath = config_backups_dir() . '/wallets-00000000-000000.json';
file_put_contents($brokenPath, '{битый жсон');
ok(!config_restore_backup('wallets.json', basename($brokenPath)), 'битый бэкап не затирает рабочий файл');
eqv(count(load_wallets_config()['wallets']), 2, 'после отказа список трейдеров цел');
@unlink($brokenPath);

// Ротация не даёт каталогу расти бесконечно.
for ($i = 0; $i < CONFIG_BACKUPS_KEEP + 6; $i++) {
    $c = load_wallets_config();
    $c['global_settings']['sizing_fixed_usd'] = 100 + $i;   // каждый раз новое содержимое
    save_wallets_config($c);
}
ok(count(config_backups_list('wallets')) <= CONFIG_BACKUPS_KEEP,
    'ротация держит не больше ' . CONFIG_BACKUPS_KEEP . ' копий (сейчас ' . count(config_backups_list('wallets')) . ')');

// --- очистка временных файлов --------------------------------
@unlink(DB_FILE); @unlink(DB_FILE . '-wal'); @unlink(DB_FILE . '-shm');
@unlink(LOG_FILE); @unlink(WALLETS_FILE); @unlink(HEARTBEAT_FILE); @unlink(CRON_TICK_FILE);
@unlink(STATE_DIR . '/pools.json');
@array_map('unlink', (array) glob(config_backups_dir() . '/*.json'));
@rmdir(config_backups_dir());
@rmdir($tmp . '/logs'); @rmdir($tmp . '/state'); @rmdir($tmp);

echo "\n";
if ($GLOBALS['__fail'] === 0) {
    echo "\033[32mRESULT: {$GLOBALS['__pass']} passed, 0 failed\033[0m\n";
    exit(0);
}
echo "\033[31mRESULT: {$GLOBALS['__pass']} passed, {$GLOBALS['__fail']} failed\033[0m\n";
exit(1);
