<?php
// =============================================================
// ENGINE — filter + dedupe + cooldown + signal mapping
// =============================================================
// Бэкенд state: SQLite (entity_state + seen_fills через storage.php).
// State адресуется парой (entity_type, entity_id):
//   wallet:        ('wallet',       $walletId)
//   trader в пуле: ('pool_trader',  "$poolId:$traderId")
// =============================================================

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


/**
 * Загружает engine state из SQLite.
 * Возвращает ['trades' => [...], 'cooldown' => [...], 'last_seen_ts' => int|null]
 */
function load_state(string $entityType, string $entityId): array
{
    return storage_load_state($entityType, $entityId);
}

/**
 * Сохраняет engine state в SQLite (UPSERT + seen_fills + pruning).
 */
function save_state(string $entityType, string $entityId, array $state): void
{
    storage_save_state($entityType, $entityId, $state);
}

/**
 * Основная pipeline функция для ОДНОГО entity (кошелёк или трейдер пула).
 * Подготавливает сигналы из fills БЕЗ сохранения watermark в БД.
 * Возвращает ['signals' => [...], 'state' => [...]] где state — обновлённый
 * state готовый к записи, но ещё не записанный.
 *
 * Вызывающий код должен:
 *   1. Выполнить сигналы (execute_signals / track_pool_position)
 *   2. Только потом вызвать commit_state($entityType, $entityId, $pendingState)
 *
 * Это гарантирует что watermark сдвигается только после успешного исполнения.
 * При падении между execute и commit — fills будут обработаны повторно,
 * но дедупликация по fill_id (seen_fills) предотвратит двойное исполнение.
 */
function process_fills_prepare(array $fills, array $settings, string $entityType, string $entityId, ?array $state = null): array
{
    // $state можно передать уже загруженным (bot.php грузит его для watermark
    // батч-запроса) — тогда не читаем БД повторно. null = загрузить здесь.
    $state   = $state ?? load_state($entityType, $entityId);
    $signals = [];
    // ВНИМАНИЕ к единицам времени: $now/cooldown/seen_at — секунды (time()),
    // а $trade['timestamp'] и last_seen_ts — миллисекунды (поле time из
    // Hyperliquid-филла). Сравнивать timestamp с timestamp, $now — с cooldown.
    $now     = time();

    // Сигналы применяются к позиции строго по порядку (open→close→reverse),
    // поэтому fills ОБЯЗАНЫ идти по возрастанию времени. userFillsByTime обычно
    // отдаёт ascending, но userFills (первый запуск/fallback) — descending, и
    // порядок батч-ответа не гарантирован. Сортируем явно, чтобы close никогда
    // не обработался раньше open. Стабильный tie-break по индексу для fills с
    // одинаковым timestamp (PHP usort стабилен с 8.0, но не полагаемся на это).
    $idx = 0;
    foreach ($fills as &$f) { $f['_seq'] = $idx++; }
    unset($f);
    usort($fills, function ($a, $b) {
        return [$a['timestamp'], $a['_seq']] <=> [$b['timestamp'], $b['_seq']];
    });

    $allowedSymbols  = $settings['allowed_symbols'];
    $cooldownSeconds = $settings['cooldown_seconds'];

    $lastSeenTs = $state['last_seen_ts'] ?? null;

    // --- Первый запуск: инициализация watermark без генерации сигналов ---
    if ($lastSeenTs === null) {
        $maxTs = 0;
        foreach ($fills as $trade) {
            if ($trade['timestamp'] > $maxTs) {
                $maxTs = $trade['timestamp'];
            }
        }
        // История пуста (у кошелька нет ни одной сделки) — встаём «на сейчас».
        // Без этого watermark оставался null, и КАЖДЫЙ цикл заново запрашивал
        // полный userFills (тяжёлый по весу API) вместо userFillsByTime.
        if ($maxTs === 0) {
            $maxTs = (int) round(microtime(true) * 1000);
        }

        foreach ($fills as $trade) {
            if ($trade['timestamp'] === $maxTs) {
                $state['trades'][$trade['id']] = $now;
            }
        }

        $state['last_seen_ts'] = $maxTs;
        // Сохраняем сразу — при инициализации сигналов нет, нечего терять
        save_state($entityType, $entityId, $state);
        bot_log('INIT: watermark set to ' . $maxTs . ', skipping ' . count($fills) . ' historical fills');
        return ['signals' => [], 'state' => $state];
    }

    $maxTsInBatch = $lastSeenTs;

    foreach ($fills as $trade) {
        // 0. Строго старше watermark — это история, пропускаем
        if ($trade['timestamp'] < $lastSeenTs) {
            continue;
        }

        if ($trade['timestamp'] > $maxTsInBatch) {
            $maxTsInBatch = $trade['timestamp'];
        }

        // 1. Фильтр по символу
        if (!empty($allowedSymbols) && !in_array($trade['symbol'], $allowedSymbols, true)) {
            bot_log('SKIP symbol: ' . $trade['symbol']);
            continue;
        }

        // 2. Дедупликация по fill_id — уже видели этот trade?
        if (isset($state['trades'][$trade['id']])) {
            // Освежаем seen_at: fill ровно на границе watermark возвращается
            // каждый цикл (startTime inclusive). Без обновления его seen_at
            // протух бы через 24ч и pruning удалил бы запись, а watermark не
            // сдвинулся (нет новых fills) → повторная обработка = дубликат.
            $state['trades'][$trade['id']] = $now;
            continue;
        }

        // 3. Маппинг сигнала (нужна сторона для cooldown по направлению)
        $signal = map_to_signal($trade, $settings);
        if ($signal === null) {
            bot_log('WARNING: unknown side value "' . $trade['side'] . '" для trade ' . $trade['id'] . ' — ожидается B или A (Hyperliquid). Сигнал пропущен.');
            continue;
        }

        // 4. Cooldown — только для ВХОДОВ (open/reverse/auto), по (symbol, side).
        // Закрытие (action=close) никогда не глушится: пропустить exit опаснее,
        // чем пропустить лишний вход. Антиспам действует на повторные входы в ту
        // же сторону.
        $isEntry     = $signal['action'] !== 'close';
        $cooldownKey = $trade['symbol'] . '|' . $signal['side'];
        if ($isEntry) {
            $lastSignalTime = $state['cooldown'][$cooldownKey] ?? 0;
            if (($now - $lastSignalTime) < $cooldownSeconds) {
                $remaining = $cooldownSeconds - ($now - $lastSignalTime);
                bot_log('SKIP cooldown: ' . $trade['symbol'] . ' ' . strtoupper($signal['side']) . ' (wait ' . $remaining . 's)');
                // Помечаем seen: cooldown = ПРОПУСТИТЬ вход, а не отложить.
                // Без этого fill ровно на границе watermark (возвращается каждый
                // цикл) исполнился бы позже, когда cooldown истечёт, — по уже
                // протухшему сигналу и в отличие от остальных fills батча.
                $state['trades'][$trade['id']] = $now;
                continue;
            }
        }

        // 5. Фиксируем в pending state (в БД ещё НЕ пишем)
        $state['trades'][$trade['id']] = $now;
        if ($isEntry) {
            $state['cooldown'][$cooldownKey] = $now;
        }

        $signals[] = $signal;
        bot_log('SIGNAL: ' . $signal['symbol'] . ' ' . strtoupper($signal['side'])
            . ' [' . $signal['action'] . '] size=' . $signal['size']);
    }

    $state['last_seen_ts'] = $maxTsInBatch;

    // НЕ сохраняем — вернём pending state, запишем только после execute
    return ['signals' => $signals, 'state' => $state];
}

/**
 * Сохраняет state после успешного исполнения сигналов.
 * Вызывается из bot.php ПОСЛЕ execute_signals().
 */
function commit_state(string $entityType, string $entityId, array $state): void
{
    save_state($entityType, $entityId, $state);
}

/**
 * Классифицирует намерение трейдера по полю dir из Hyperliquid-филла.
 * Возвращает [action, side] или null, если dir неизвестен/отсутствует
 * (тогда вызывающий код откатывается на вывод стороны из B/A).
 *
 *   action: 'open'    — открытие/наращивание позиции
 *           'close'   — закрытие (в т.ч. частичное) позиции
 *           'reverse' — разворот (закрытие + открытие в обратную сторону)
 *   side:   итоговая сторона позиции трейдера после этого fill ('long'|'short').
 *           Для close — сторона ЗАКРЫВАЕМОЙ позиции.
 */
function classify_fill_dir(?string $dir): ?array
{
    // Нормализуем пробелы вокруг '>' и по краям: "Long > Short", "Long>Short" → одно
    $d = $dir === null ? '' : preg_replace('/\s*>\s*/', ' > ', trim($dir));

    return match ($d) {
        'Open Long'    => ['open',    'long'],
        'Close Long'   => ['close',   'long'],
        'Open Short'   => ['open',    'short'],
        'Close Short'  => ['close',   'short'],
        'Long > Short' => ['reverse', 'short'],
        'Short > Long' => ['reverse', 'long'],
        default        => null,
    };
}

/**
 * Конвертирует fill в торговый сигнал с risk scaling.
 * $settings — эффективные настройки кошелька (после resolve_wallet_settings).
 *
 * Намерение трейдера берётся из dir (open/close/reverse). Если dir неизвестен —
 * action='auto' и сторона выводится из B/A (легаси-поведение: противоположный
 * сигнал трактуется трекером как разворот).
 */
function map_to_signal(array $trade, array $settings): ?array
{
    // Hyperliquid: 'B' = Bid/Buy (long), 'A' = Ask/Sell (short)
    $sideMap = ['B' => 'long', 'A' => 'short'];

    $classified = classify_fill_dir($trade['dir'] ?? null);
    if ($classified !== null) {
        [$action, $side] = $classified;
    } else {
        // Фолбэк: dir отсутствует/незнаком — выводим сторону из B/A, action=auto
        if (!isset($sideMap[$trade['side']])) {
            return null;
        }
        $action = 'auto';
        $side   = $sideMap[$trade['side']];
    }

    $finalSize = round($trade['size'] * $settings['scale_factor'], 6);
    $leverage  = $settings['symbol_leverage'][$trade['symbol']] ?? $settings['default_leverage'];

    // Размер позиции трейдера в USD — нужен для proportional sizing.
    // trader_price может быть null (редко), тогда proportional недоступен для этого сигнала.
    $traderSizeUsd = ($trade['trader_price'] ?? null) !== null
        ? round($trade['size'] * $trade['trader_price'], 4)
        : null;

    return [
        'symbol'          => $trade['symbol'],
        // Биржевое имя монеты в исходном регистре ('kPEPE') — для приёмников,
        // которым нужен точный тикер Hyperliquid (katoshi). Fallback на symbol.
        'symbol_exchange' => $trade['symbol_exchange'] ?? $trade['symbol'],
        'side'            => $side,
        'action'          => $action,         // open | close | reverse | auto
        'size'            => $finalSize,
        'leverage'        => $leverage,
        'source_trade'    => $trade['id'],
        'trader_price'    => $trade['trader_price'] ?? null,
        'trader_size_usd' => $traderSizeUsd,   // размер позиции трейдера в USD (для proportional)
        'trader_size_coins' => (float) $trade['size'],   // размер в МОНЕТАХ (для доли частичного закрытия)
        'timestamp'       => time(),
    ];
}
