<?php
/**
 * MT5Reconciler - shared by api/mt5/reconcile.php (the real reconcile
 * endpoint) and public/mt5-resolve.php (manual match/dismiss actions on
 * the resolve page). Kept in its own file rather than defined inline in
 * the API endpoint specifically so mt5-resolve.php can use the exact
 * same matching/apply logic without require()-ing an endpoint file that
 * would otherwise immediately try to handle whatever the current
 * request happens to be.
 */
// ── Reconciler ──────────────────────────────────────────────────────

class MT5Reconciler
{
    private PDO $pdo;
    private string $userId;
    private string $accountId;
    private ?string $reconciliationId;

    private const SCORE_INSTRUMENT = 25;
    private const SCORE_DIRECTION = 15;
    private const SCORE_TIME = 20;
    private const SCORE_SIZE = 15;
    private const SCORE_PRICE = 10;
    private const SCORE_PROFIT = 15;

    private const AUTO_MATCH_THRESHOLD = 80;
    private const CONFIRMATION_THRESHOLD_MIN = 45;
    private const TIME_WINDOW_HOURS = 6; // open-time proximity window
    private const SIZE_TOLERANCE_PCT = 20;
    private const PRICE_TOLERANCE_PCT = 0.5;

    public function __construct(PDO $pdo, string $userId, string $accountId, ?string $reconciliationId)
    {
        $this->pdo = $pdo;
        $this->userId = $userId;
        $this->accountId = $accountId;
        $this->reconciliationId = $reconciliationId;
    }

    public function reconcile(array $mt5Positions): array
    {
        $matched = [];
        $unmatchedMt5 = [];
        $unmatchedTaderalign = [];
        $needsConfirmation = [];

        $taTrades = $this->fetchCandidateTrades();
        $matchedTaIds = [];

        foreach ($mt5Positions as $mt5) {
            $mt5['netted_profit'] = (float)($mt5['profit'] ?? 0) + (float)($mt5['commission'] ?? 0) + (float)($mt5['swap'] ?? 0);

            // ── Path A: comment-hash match ──
            $comment = $mt5['comment'] ?? '';
            if ($comment && preg_match('/TraderAlign-([a-f0-9]{8,64})/i', $comment, $hm)) {
                $hashMatch = $this->findByCommentHash($hm[1], $taTrades, $matchedTaIds);
                if ($hashMatch) {
                    $this->applyReconciliation($hashMatch['id'], $mt5);
                    $matched[] = ['traderalign_id' => $hashMatch['id'], 'mt5_ticket' => $mt5['ticket'], 'confidence' => 100, 'method' => 'comment_hash'];
                    $matchedTaIds[] = $hashMatch['id'];
                    continue;
                }
            }

            // ── Path B: signal scoring ──
            $best = null;
            $bestId = null;
            $bestScore = 0;
            $bestBreakdown = [];
            foreach ($taTrades as $ta) {
                if (in_array($ta['id'], $matchedTaIds, true)) continue;
                [$score, $breakdown] = $this->scoreMatch($mt5, $ta);
                if ($score > $bestScore) {
                    $bestScore = $score;
                    $best = $ta;
                    $bestId = $ta['id'];
                    $bestBreakdown = $breakdown;
                }
            }

            if ($bestScore >= self::AUTO_MATCH_THRESHOLD && $best) {
                $this->applyReconciliation($best['id'], $mt5);
                $matched[] = ['traderalign_id' => $best['id'], 'mt5_ticket' => $mt5['ticket'], 'confidence' => $bestScore, 'method' => 'signal_scoring', 'score_breakdown' => $bestBreakdown];
                $matchedTaIds[] = $bestId;
            } elseif ($bestScore >= self::CONFIRMATION_THRESHOLD_MIN && $best) {
                $needsConfirmation[] = [
                    'traderalign_id' => $best['id'], 'mt5_ticket' => $mt5['ticket'], 'confidence' => $bestScore,
                    'score_breakdown' => $bestBreakdown, 'mt5_position' => $mt5,
                    'traderalign_trade' => $this->sanitizeTa($best),
                ];
            } else {
                $unmatchedMt5[] = $mt5;
                $this->persistUnmatchedBrokerTrade($mt5);
            }
        }

        foreach ($taTrades as $ta) {
            if (!in_array($ta['id'], $matchedTaIds, true)) {
                $unmatchedTaderalign[] = $this->sanitizeTa($ta);
            }
        }

        $this->updateReconciliationCounts(count($matched), count($needsConfirmation), count($unmatchedMt5), count($unmatchedTaderalign));

        return [
            'matched' => $matched,
            'unmatched_mt5' => $unmatchedMt5,
            'unmatched_traderalign' => $unmatchedTaderalign,
            'needs_confirmation' => $needsConfirmation,
            'summary' => [
                'total_mt5_positions' => count($mt5Positions),
                'total_ta_trades' => count($taTrades),
                'auto_matched' => count($matched),
                'needs_confirmation' => count($needsConfirmation),
                'unmatched_mt5' => count($unmatchedMt5),
                'unmatched_ta' => count($unmatchedTaderalign),
            ],
        ];
    }

    /**
     * TraderAlign trades eligible for reconciliation - scoped to ONE
     * account only (never user-wide: matching across accounts is a real
     * data-integrity risk, not a convenience), never validation-workflow
     * trades (those are never real broker execution by design), never
     * already-reconciled or soft-deleted trades.
     */
    private function fetchCandidateTrades(): array
    {
        $sql = "
            SELECT t.*, ta.account_type, ta.label AS account_label, ta.broker_server
            FROM trades t
            JOIN trading_accounts ta ON ta.id = t.account_id AND ta.user_id = t.user_id
            WHERE t.user_id = :uid
              AND t.account_id = :account_id
              AND t.deleted_at IS NULL
              AND t.status IN ('open', 'closed', 'draft')
              AND (t.is_reconciled IS NULL OR t.is_reconciled = 0)
              AND (t.is_validation = 0 OR t.is_validation IS NULL)
            ORDER BY t.captured_at DESC
            LIMIT 2000
        ";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([':uid' => $this->userId, ':account_id' => $this->accountId]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    private function findByCommentHash(string $hash, array $taTrades, array $excludeIds): ?array
    {
        foreach ($taTrades as $t) {
            if (in_array($t['id'], $excludeIds, true)) continue;
            if (($t['mt5_comment_hash'] ?? '') === $hash) return $t;
        }
        return null;
    }

    private function effectiveTaPnl(array $ta): ?float
    {
        // Same fallback cascade the rest of the app uses for "what this
        // trade is actually worth" - never a re-derived formula.
        foreach (['challenge_pnl', 'broker_pnl', 'projected_pnl'] as $col) {
            if (isset($ta[$col]) && $ta[$col] !== null && $ta[$col] !== '') return (float)$ta[$col];
        }
        return null;
    }

    private function scoreMatch(array $mt5, array $ta): array
    {
        $breakdown = ['instrument' => 0, 'direction' => 0, 'time' => 0, 'size' => 0, 'price' => 0, 'profit' => 0];

        $mt5Symbol = self::normalizeSymbol($mt5['symbol'] ?? '');
        $taSymbol = self::normalizeSymbol($ta['instrument'] ?? '');
        if ($mt5Symbol !== '' && $mt5Symbol === $taSymbol) {
            $breakdown['instrument'] = self::SCORE_INSTRUMENT;
        } elseif ($mt5Symbol !== '' && $taSymbol !== '' && (strpos($mt5Symbol, $taSymbol) !== false || strpos($taSymbol, $mt5Symbol) !== false)) {
            $breakdown['instrument'] = round(self::SCORE_INSTRUMENT * 0.7, 1);
        }

        $mt5Dir = $mt5['type'] ?? '';
        $taDir = strtolower((string)($ta['direction'] ?? ''));
        if (in_array($mt5Dir, ['long', 'short'], true) && $mt5Dir === $taDir) {
            $breakdown['direction'] = self::SCORE_DIRECTION;
        }

        $mt5Time = strtotime($mt5['time'] ?? '');
        $taTime = strtotime($ta['opened_at'] ?? $ta['captured_at'] ?? '');
        if ($mt5Time && $taTime) {
            $diffHours = abs($mt5Time - $taTime) / 3600;
            if ($diffHours <= self::TIME_WINDOW_HOURS) {
                $breakdown['time'] = round(self::SCORE_TIME * (1 - ($diffHours / self::TIME_WINDOW_HOURS)), 1);
            }
        }

        $mt5Vol = (float)($mt5['volume'] ?? 0);
        $taVol = (float)($ta['lots_per_position'] ?? $ta['actual_lots'] ?? 0);
        if ($mt5Vol > 0 && $taVol > 0) {
            $diffPct = abs($mt5Vol - $taVol) / max($mt5Vol, $taVol) * 100;
            if ($diffPct <= self::SIZE_TOLERANCE_PCT) {
                $breakdown['size'] = round(self::SCORE_SIZE * (1 - ($diffPct / self::SIZE_TOLERANCE_PCT)), 1);
            }
        }

        $mt5Price = (float)($mt5['price'] ?? 0);
        $taPrice = (float)($ta['entry_price'] ?? $ta['actual_entry_price'] ?? 0);
        if ($mt5Price > 0 && $taPrice > 0) {
            $diffPct = abs($mt5Price - $taPrice) / $taPrice * 100;
            if ($diffPct <= self::PRICE_TOLERANCE_PCT) {
                $breakdown['price'] = round(self::SCORE_PRICE * (1 - ($diffPct / self::PRICE_TOLERANCE_PCT)), 1);
            }
        }

        // Profit is one of the most discriminating signals available - two
        // unrelated trades landing on the same instrument/direction/day
        // are common; landing within cents of the same P&L is not.
        $mt5Pnl = $mt5['netted_profit'] ?? ((float)($mt5['profit'] ?? 0) + (float)($mt5['commission'] ?? 0) + (float)($mt5['swap'] ?? 0));
        $taPnl = $this->effectiveTaPnl($ta);
        if ($taPnl !== null) {
            $diff = abs($mt5Pnl - $taPnl);
            $scale = max(1.0, abs($taPnl));
            $relDiff = $diff / $scale;
            if ($relDiff <= 0.05) {
                $breakdown['profit'] = self::SCORE_PROFIT;
            } elseif ($relDiff <= 0.25) {
                $breakdown['profit'] = round(self::SCORE_PROFIT * (1 - (($relDiff - 0.05) / 0.20)), 1);
            }
        }

        $total = round(array_sum($breakdown), 1);
        $breakdown['total'] = $total;
        return [$total, $breakdown];
    }

    private static function normalizeSymbol(string $s): string
    {
        $s = strtoupper(trim($s));
        return preg_replace('/[\.\-_][A-Z]+$/', '', $s);
    }

    /**
     * Writes broker-verified data onto a matched TraderAlign trade. The
     * netted P&L (profit + commission + swap) is what actually moved the
     * account's balance for this position - confirmed against a real
     * statement's own running Balance column, deal by deal - so that's
     * what's written to broker_pnl, not the raw "Profit" figure alone.
     */
    public function applyReconciliation(string $tradeId, array $mt5): bool
    {
        try {
            $check = $this->pdo->prepare('SELECT id, status, is_manually_edited FROM trades WHERE id = :id AND user_id = :uid AND deleted_at IS NULL');
            $check->execute([':id' => $tradeId, ':uid' => $this->userId]);
            $trade = $check->fetch(PDO::FETCH_ASSOC);
            if (!$trade) return false;
            if (!empty($trade['is_manually_edited'])) return false;

            $netted = (float)($mt5['profit'] ?? 0) + (float)($mt5['commission'] ?? 0) + (float)($mt5['swap'] ?? 0);

            $updates = [
                'is_reconciled = 1', 'reconciled_at = NOW()',
                'broker_pnl = :broker_pnl',
                'broker_open_time = :open_time',
                'broker_lot_size = :lot_size',
                'broker_entry_price = :entry_price',
                'broker_commission = :commission',
                'broker_swap = :swap',
                'mt5_position_id = :position_id',
            ];
            $params = [
                ':id' => $tradeId,
                ':uid' => $this->userId,
                ':broker_pnl' => $netted,
                ':open_time' => $mt5['time'] ?? null,
                ':lot_size' => (float)($mt5['volume'] ?? 0),
                ':entry_price' => (float)($mt5['price'] ?? 0),
                ':commission' => $mt5['commission'] ?? null,
                ':swap' => $mt5['swap'] ?? null,
                ':position_id' => (string)($mt5['ticket'] ?? ''),
            ];

            if (!empty($mt5['close_time'])) {
                $updates[] = 'broker_close_time = :close_time';
                $params[':close_time'] = $mt5['close_time'];
            }
            if (isset($mt5['close_price']) && $mt5['close_price'] !== null) {
                $updates[] = 'broker_exit_price = :exit_price';
                $params[':exit_price'] = (float)$mt5['close_price'];
            }
            if (isset($mt5['sl']) && $mt5['sl'] !== null) {
                $updates[] = 'broker_sl_price = :sl_price';
                $params[':sl_price'] = (float)$mt5['sl'];
            }

            if (in_array($trade['status'], ['draft', 'open'], true)) {
                $updates[] = "status = 'closed'";
                $updates[] = 'closed_at = :closed_at';
                $params[':closed_at'] = $mt5['close_time'] ?? $mt5['time'] ?? date('Y-m-d H:i:s');
            }

            $sql = 'UPDATE trades SET ' . implode(', ', $updates) . ' WHERE id = :id AND user_id = :uid';
            $this->pdo->prepare($sql)->execute($params);

            $this->recordMql5Event($tradeId, $mt5, $netted);

            try {
                TradeClassificationEngine::classifyOneTrade($tradeId, $this->pdo);
            } catch (\Throwable $e) {
                error_log("TradeClassificationEngine failed for {$tradeId}: " . $e->getMessage());
            }

            return true;
        } catch (\Throwable $e) {
            error_log("MT5 reconcile: failed to apply reconciliation for {$tradeId}: " . $e->getMessage());
            return false;
        }
    }

    private function recordMql5Event(string $tradeId, array $mt5, float $netted): void
    {
        try {
            $payload = json_encode([
                'ticket' => $mt5['ticket'] ?? null, 'symbol' => $mt5['symbol'] ?? null,
                'type' => $mt5['type'] ?? null, 'volume' => $mt5['volume'] ?? null,
                'open_price' => $mt5['price'] ?? null, 'close_price' => $mt5['close_price'] ?? null,
                'sl' => $mt5['sl'] ?? null, 'tp' => $mt5['tp'] ?? null,
                'open_time' => $mt5['time'] ?? null, 'close_time' => $mt5['close_time'] ?? null,
                'commission' => $mt5['commission'] ?? null, 'swap' => $mt5['swap'] ?? null,
                'raw_profit' => $mt5['profit'] ?? null, 'netted_pnl' => $netted,
                'comment' => $mt5['comment'] ?? null,
            ]);
            $stmt = $this->pdo->prepare("
                INSERT INTO mql5_trades (id, user_id, account_id, mt5_position_id, event_kind, event_payload, captured_at, linked_trade_id)
                VALUES (UUID(), :uid, :account_id, :position_id, 'closed', :payload, NOW(), :trade_id)
                ON DUPLICATE KEY UPDATE event_payload = VALUES(event_payload), linked_trade_id = VALUES(linked_trade_id)
            ");
            $stmt->execute([
                ':uid' => $this->userId, ':account_id' => $this->accountId,
                ':position_id' => (string)($mt5['ticket'] ?? ''), ':payload' => $payload, ':trade_id' => $tradeId,
            ]);
        } catch (\Throwable $e) {
            error_log('mql5_trades event write failed: ' . $e->getMessage());
        }
    }

    /**
     * A real MT5 execution with no matching TraderAlign trade - either a
     * trade that was placed but never journaled, or (if it keeps showing
     * up unresolved after review) a sign the trader is trading outside
     * the discipline this app exists to enforce. Persisted so it survives
     * a reload, and deduped against an already-unresolved row for the
     * same account/ticket so re-uploading an overlapping statement
     * doesn't pile up duplicates.
     */
    private function persistUnmatchedBrokerTrade(array $mt5): void
    {
        try {
            $ticket = (string)($mt5['ticket'] ?? '');
            $dup = $this->pdo->prepare("
                SELECT id FROM unmatched_broker_trades
                WHERE user_id = :uid AND account_id = :account_id AND resolution = 'unresolved'
                  AND JSON_UNQUOTE(JSON_EXTRACT(raw_broker_row, '$.ticket')) = :ticket
                LIMIT 1
            ");
            $dup->execute([':uid' => $this->userId, ':account_id' => $this->accountId, ':ticket' => $ticket]);
            if ($dup->fetch()) return;

            if (!$this->reconciliationId) return; // reconciliation_id is NOT NULL on this table - no durable audit parent, nothing to attach to

            $stmt = $this->pdo->prepare("
                INSERT INTO unmatched_broker_trades (
                    id, user_id, account_id, reconciliation_id, raw_broker_row,
                    instrument, direction, open_time, close_time, pnl, resolution, created_at
                ) VALUES (
                    UUID(), :uid, :account_id, :recon_id, :raw,
                    :instrument, :direction, :open_time, :close_time, :pnl, 'unresolved', NOW()
                )
            ");
            $stmt->execute([
                ':uid' => $this->userId, ':account_id' => $this->accountId, ':recon_id' => $this->reconciliationId,
                ':raw' => json_encode($mt5),
                ':instrument' => $mt5['symbol'] ?? null,
                ':direction' => in_array($mt5['type'] ?? '', ['long', 'short'], true) ? $mt5['type'] : null,
                ':open_time' => $mt5['time'] ?? null,
                ':close_time' => $mt5['close_time'] ?? null,
                ':pnl' => $mt5['netted_profit'] ?? null,
            ]);
        } catch (\Throwable $e) {
            error_log('unmatched_broker_trades write failed: ' . $e->getMessage());
        }
    }

    private function updateReconciliationCounts(int $matched, int $needsConfirmation, int $unmatchedMt5, int $unmatchedTa): void
    {
        if (!$this->reconciliationId) return;
        try {
            $stmt = $this->pdo->prepare('
                UPDATE broker_reconciliations
                SET rows_matched_a = rows_matched_a + :matched,
                    rows_matched_b = :needs_confirm,
                    rows_unmatched = :unmatched_mt5,
                    journal_unmatched = :unmatched_ta
                WHERE id = :id AND user_id = :uid
            ');
            $stmt->execute([
                ':matched' => $matched, ':needs_confirm' => $needsConfirmation,
                ':unmatched_mt5' => $unmatchedMt5, ':unmatched_ta' => $unmatchedTa,
                ':id' => $this->reconciliationId, ':uid' => $this->userId,
            ]);
        } catch (\Throwable $e) {
            error_log('broker_reconciliations count update failed: ' . $e->getMessage());
        }
    }

    private function sanitizeTa(array $t): array
    {
        return [
            'id' => $t['id'], 'instrument' => $t['instrument'] ?? '', 'direction' => $t['direction'] ?? '',
            'lots_per_position' => (float)($t['lots_per_position'] ?? 0), 'entry_price' => (float)($t['entry_price'] ?? 0),
            'sl_price' => (float)($t['sl_price'] ?? 0), 'tp_price' => (float)($t['tp_price'] ?? 0),
            'projected_pnl' => (float)($t['projected_pnl'] ?? 0),
            'effective_pnl' => $this->effectiveTaPnl($t),
            'status' => $t['status'] ?? 'unknown', 'captured_at' => $t['captured_at'] ?? '',
            'closed_at' => $t['closed_at'] ?? null, 'account_label' => $t['account_label'] ?? null,
            'account_type' => $t['account_type'] ?? null,
        ];
    }
}
