<?php
/**
 * Colex AI Chat API
 * 
 * Handles all Colex conversation and message operations.
 * Uses AnalyticsEngine for all data — never raw SQL.
 */

// Ensure this file is accessed through the router
//
// 2026-08-19 security fix (pre-launch audit finding L-2): this now sits
// behind api/.htaccess's own Apache-level `Require all denied` on direct
// .php access (see that file), so this branch should be unreachable in
// practice — kept as defense-in-depth in case that layer is ever removed
// or misconfigured. Was returning 500 (Internal Server Error), which is
// the wrong status for a client-request-shape problem and could trigger
// unnecessary server-error alerting/monitoring noise; this is a rejected
// request, not a server fault, so it now returns 403 like every other
// "not permitted" response in this app.
if (!isset($auth) || !isset($db)) {
    http_response_code(403);
    header('Content-Type: application/json');
    echo json_encode(['success' => false, 'error' => 'Direct access not permitted.']);
    exit;
}

// Ensure user is authenticated
if (!$auth->isLoggedIn()) {
    http_response_code(401);
    header('Content-Type: application/json');
    echo json_encode(['success' => false, 'error' => 'Authentication required']);
    exit;
}

require_once __DIR__ . '/../analytics/engine.php';
require_once __DIR__ . '/../analytics/edge_health_engine.php';
require_once __DIR__ . '/usage-engine.php';
require_once __DIR__ . '/../../src/PropFirmRuleEngine.php';
require_once __DIR__ . '/../../src/ColexHelpers.php';
require_once __DIR__ . '/../../src/ColexProvider.php';

$userId = $auth->getUserId();
$method = $_SERVER['REQUEST_METHOD'];

// ── GET: Load messages or conversations ──────────────────
if ($method === 'GET') {
    $action = isset($_GET['action']) ? $_GET['action'] : '';
    
    if ($action === 'get_messages') {
        $conversationId = isset($_GET['conversation_id']) ? $_GET['conversation_id'] : null;
        if (!$conversationId) {
            echo json_encode(['success' => false, 'error' => 'conversation_id required']);
            exit;
        }
        
        // Verify conversation belongs to user
        $conversation = $db->raw(
            "SELECT id FROM colex_conversations WHERE id = ? AND user_id = ?",
            [$conversationId, $userId]
        );
        
        if (empty($conversation)) {
            echo json_encode(['success' => false, 'error' => 'Conversation not found']);
            exit;
        }
        
        $messages = $db->raw(
            "SELECT id, role, content, cards, created_at 
             FROM colex_messages 
             WHERE conversation_id = ? 
             ORDER BY created_at ASC",
            [$conversationId]
        );
        
        // Parse cards JSON
        foreach ($messages as &$msg) {
            if (!empty($msg['cards'])) {
                $msg['cards'] = json_decode($msg['cards'], true);
            }
        }
        
        echo json_encode(['success' => true, 'messages' => $messages]);
        exit;
    }
    
    if ($action === 'get_conversations') {
        $conversations = $db->raw(
            "SELECT id, title, created_at FROM colex_conversations 
             WHERE user_id = ? ORDER BY created_at DESC LIMIT 50",
            [$userId]
        );
        
        echo json_encode(['success' => true, 'conversations' => $conversations]);
        exit;
    }
    
    echo json_encode(['success' => false, 'error' => 'Invalid action']);
    exit;
}

// ── POST: Handle messages and conversations ──────────────
if ($method === 'POST') {
    $input = json_decode(file_get_contents('php://input'), true);
    $action = isset($input['action']) ? $input['action'] : 'send_message';
    
    // ── Create Conversation ──────────────────────────────
    if ($action === 'create_conversation') {
        $conversationId = generateUUID();
        
        $db->raw(
            "INSERT INTO colex_conversations (id, user_id, title, created_at) VALUES (?, ?, ?, NOW())",
            [$conversationId, $userId, 'New Conversation']
        );
        
        echo json_encode([
            'success' => true,
            'conversation_id' => $conversationId,
            'conversation_updated' => true
        ]);
        exit;
    }
    
    // ── Send Message ─────────────────────────────────────
    $message = isset($input['message']) ? trim($input['message']) : '';
    $period = isset($input['period']) ? $input['period'] : 'current_week';
    $dateFrom = isset($input['date_from']) ? $input['date_from'] : null;
    $dateTo = isset($input['date_to']) ? $input['date_to'] : null;
    $conversationId = isset($input['conversation_id']) ? $input['conversation_id'] : null;

    if (empty($message)) {
        echo json_encode(['success' => false, 'error' => 'Message required']);
        exit;
    }

    // ── Ownership check (2026-08-19 security fix) ─────────
    // Real, confirmed cross-user IDOR: this "send_message" path previously
    // used a client-supplied conversation_id to append a message, read the
    // full prior history into the LLM context, and append the reply — all
    // with no check that the conversation belonged to the requesting user.
    // The GET action=get_messages path (and delete-conversation.php,
    // pin-conversation.php) already did this correctly; this brings
    // send_message in line with that same pattern. Checked before the
    // allowance gate so a probing request against a foreign ID never even
    // spends part of the user's own AI allowance.
    if ($conversationId) {
        $ownedConversation = $db->raw(
            "SELECT id FROM colex_conversations WHERE id = ? AND user_id = ?",
            [$conversationId, $userId]
        );
        if (empty($ownedConversation)) {
            http_response_code(404);
            echo json_encode(['success' => false, 'error' => 'Conversation not found']);
            exit;
        }
    }

    // ── Allowance Gate ────────────────────────────────────
    // The actual enforcement point (Rule 4) — checked before touching the
    // conversation/message tables or the AI provider at all, so an
    // exhausted user can't spend a provider call by hitting this endpoint
    // directly even if the UI's disabled input were bypassed.
    list($canSend, $allowanceStatus) = canSendColexMessage($db, $userId);
    if (!$canSend) {
        http_response_code(403);
        echo json_encode([
            'success' => false,
            'error' => 'allowance_exhausted',
            'usage_status' => formatAllowanceForFrontend($allowanceStatus)
        ]);
        exit;
    }
    
    // Create conversation if none exists
    if (!$conversationId) {
        $conversationId = generateUUID();
        $db->raw(
            "INSERT INTO colex_conversations (id, user_id, title, created_at) VALUES (?, ?, ?, NOW())",
            [$conversationId, $userId, truncateTitle($message)]
        );
    } else {
        // Update title if this is the first message
        $msgCount = $db->raw(
            "SELECT COUNT(*) as cnt FROM colex_messages WHERE conversation_id = ?",
            [$conversationId]
        );
        if (empty($msgCount) || $msgCount[0]['cnt'] == 0) {
            $db->raw(
                "UPDATE colex_conversations SET title = ? WHERE id = ?",
                [truncateTitle($message), $conversationId]
            );
        }
    }
    
    // Store user message
    $userMessageId = generateUUID();
    $db->raw(
        "INSERT INTO colex_messages (id, conversation_id, role, content, created_at) VALUES (?, ?, 'user', ?, NOW())",
        [$userMessageId, $conversationId, $message]
    );
    
    // ── Assemble Context ─────────────────────────────────
    // Reported live (2026-08-10): selecting Current Week/Current Month/
    // etc. in the UI had no effect on chat responses — every message
    // without a literal time keyword in its own text ("today", "this
    // week", ...) silently fell back to getAllTimeRange(), even though
    // the frontend always sends the real, explicitly-selected `period`
    // with every request (colex.php's `currentPeriod`, set by the
    // dropdown, included in every /api/colex/chat POST body) and the
    // dropdown has no "All Time" option at all — there is no real UI
    // state this fallback was ever the correct answer for. The sibling
    // endpoint api/colex/stats.php (which feeds the greeting/stat cards
    // at the top of the page — the part that WAS scoping correctly,
    // per the report) already unconditionally uses
    // calculateDateRange($period, ...) with no message-text gate at
    // all; this brings chat.php's own context assembly in line with
    // that already-correct pattern instead of second-guessing it based
    // on message wording. $isTimeBound is kept — it's still read below
    // for $recentTradesLimit, a real, separate, unaffected use.
    $isTimeBound = messageIsTimeBound($message);
    $dateRange = calculateDateRange($period, $dateFrom, $dateTo);
    
    // Get active accounts via the engine — no raw SQL, no guessed columns
    $accounts = AnalyticsEngine::getUserAccounts($userId);

    // 2026-08-12 workflow-isolation audit: getUserMetrics() defaults to
    // main-workflow-only trades — Colex is reachable from both workflows
    // (sidebar link is unconditional), so a validating user with no
    // main-workflow history would always get told "0 trades" regardless of
    // their real, current validation activity. Resolved once here and
    // carried on $context so every response generator below (including
    // generateComparisonResponse(), which only receives $context, not
    // $db) can reuse the same scope without a second workflow-state query.
    $colexChatValidationScope = ValidationService::getWorkflowState($userId, $db) === 'validation' ? 'only' : 'exclude';

    $context = [
        'user_id' => $userId,
        'accounts' => $accounts,
        'period' => $dateRange,
        // Raw selector value ("current_week", "previous_month", "custom", ...)
        // alongside the already-resolved $dateRange — response generators
        // use this with colexPeriodPosition() to phrase a natural, period-
        // aware opening instead of a raw date range.
        'period_key' => $period,
        'metrics' => null,
        'challenge_metrics' => null,
        'recent_trades' => [],
        'behavior_summary' => [],
        'validation_scope' => $colexChatValidationScope,
    ];

    // Get metrics aggregated across ALL of the user's accounts — not just
    // one "primary" account, which is what caused inconsistent 0-trades
    // vs N-trades responses depending on which account happened to be
    // largest by balance.
    $context['metrics'] = AnalyticsEngine::getUserMetrics($userId, [
        'dateFrom' => $dateRange['from'],
        'dateTo' => $dateRange['to'],
        'validationScope' => $colexChatValidationScope,
    ]);
    
    // Challenge metrics only apply to prop-firm accounts, and each prop
    // account has its own breach/pass thresholds — pull all of them and
    // surface whichever is closest to breach (the most urgent to coach on).
    $propFirmAccounts = array_filter($accounts, function($a) {
        return ($a['account_type'] ?? '') === 'prop_firm';
    });
    $worstChallenge = null;
    $worstBreachPct = -INF;
    foreach ($propFirmAccounts as $propAccount) {
        $cm = AnalyticsEngine::getChallengeMetrics($propAccount['id']);
        if (isset($cm['error'])) continue;
        $breachPct = $cm['distance_to_breach']['percentage'] ?? -INF;
        if ($worstChallenge === null || $breachPct < $worstBreachPct) {
            $worstChallenge = $cm;
            $worstBreachPct = $breachPct;
        }
    }
    $context['challenge_metrics'] = $worstChallenge;
    
    // Get recent trades via the engine — reuses the same four-stage
    // computation as getFourStageComparison(), so instrument/direction/pnl
    // are never re-derived or guessed here. Pull a wider sample when
    // scanning the full history so pattern/session questions aren't
    // starved down to just the last 10 trades out of years of activity.
    $recentTradesLimit = $isTimeBound ? 10 : 100;
    $context['recent_trades'] = AnalyticsEngine::getRecentTrades(
        $userId, $dateRange['from'], $dateRange['to'], $recentTradesLimit
    );
    
    // ── Generate Response: LLM reasoning, engine fallback ──
    // Primary path: assemble the knowledge base + this user's context +
    // conversation history into a system prompt and let Claude reason over
    // it. No question-type classification happens in PHP — the model
    // decides what the question is actually asking and answers it.
    //
    // Fallback path (no ANTHROPIC_API_KEY configured yet): the pre-existing
    // engine-computed intent dispatcher below, which still produces real
    // numbers from AnalyticsEngine — not canned product answers — for
    // trading-data questions. Pure product/general questions get an honest
    // "reasoning isn't connected yet" message in fallback mode rather than
    // a fabricated answer.
    $knowledgeBase = loadKnowledgeBase();
    $planContext = getUserPlanContext($db, $userId);
    $userContextText = assembleUserContextText($context, $planContext);
    $systemPrompt = buildColexSystemPrompt($knowledgeBase, $userContextText);
    $conversationHistory = getConversationHistoryForLLM($db, $conversationId, $userMessageId);
    $llmMessages = array_merge($conversationHistory, [['role' => 'user', 'content' => $message]]);

    $llmResult = callAnthropicAPI($systemPrompt, $llmMessages);

    if ($llmResult !== null) {
        $response = ['message' => $llmResult['text'], 'cards' => null, 'suggestions' => []];
        $routeTaken = 'llm_reasoning';
        $intent = 'n/a (llm)';

        // Obtain the provider's usage metadata and hand it to the usage
        // engine's conversion layer — this file does no unit math itself
        // (Rule 1). 'chat' is the internal feature tag (Rule 7); future
        // capabilities (voice, image analysis, etc.) would pass their own
        // tag from wherever they call the provider.
        recordColexUsage($db, $userId, 'anthropic', $llmResult['usage'], 'chat');
        $allowanceStatus = getColexAllowanceStatus($db, $userId);
    } else {
        $intent = detectIntent($message);
        if ($intent === 'general') {
            $response = [
                // Branding audit (2026-08-10): was "...no Anthropic API
                // key configured..." — a real, live, user-facing leak of
                // the underlying model provider. Colex should present as
                // Colex AI throughout; the reason it can't reason freely
                // yet is an internal detail, not something to name to
                // the trader.
                'message' => "Colex's full reasoning engine isn't connected yet, so I can't answer open-ended questions naturally right now. I can still share your trading performance: try asking about your win rate, drawdown, or recent trades.",
                'suggestions' => ['Analyze my trading performance', 'How was my discipline?', 'Show my drawdown']
            ];
        } else {
            $response = generateResponse($intent, $message, $context, $db, $userId);
        }
        $routeTaken = 'fallback_engine';
        // No provider call was made in fallback mode, so no usage to
        // record — $allowanceStatus from the gate check above still
        // reflects this user's current standing.
    }

    error_log("[Colex] route='{$routeTaken}' intent='{$intent}' time_bound=" . ($isTimeBound ? 'true' : 'false')
        . " scope=\"" . $dateRange['display'] . "\" tier='" . $planContext['tier'] . "' total_trades=" . ($context['metrics']['total_trades'] ?? 'n/a')
        . " response=\"" . substr($response['message'] ?? '', 0, 200) . "\"");
    
    // Store assistant message
    $assistantMessageId = generateUUID();
    $cardsJson = !empty($response['cards']) ? json_encode($response['cards']) : null;
    
    $db->raw(
        "INSERT INTO colex_messages (id, conversation_id, role, content, cards, created_at) 
         VALUES (?, ?, 'assistant', ?, ?, NOW())",
        [$assistantMessageId, $conversationId, $response['message'], $cardsJson]
    );
    
    echo json_encode([
        'success' => true,
        'conversation_id' => $conversationId,
        'response' => $response['message'],
        'cards' => $response['cards'] ?? null,
        'suggestions' => $response['suggestions'] ?? [],
        'conversation_updated' => true,
        'usage_status' => formatAllowanceForFrontend($allowanceStatus)
    ]);
    exit;
}

// ── Helper Functions ──────────────────────────────────────

// 2026-08-19 security fix: was built from mt_rand() — PHP's own manual
// explicitly says not to use it for anything security-sensitive. These IDs
// (colex_conversations.id / colex_messages.id) matter here specifically
// because the ownership-check fix above is the only thing standing between
// "unguessable ID" and a real IDOR — random_bytes() is the correct,
// cryptographically strong source. Same output shape/format as before
// (real UUIDv4), same as ScopedDb::uuid() elsewhere in this app.
function generateUUID() {
    $data = random_bytes(16);
    $data[6] = chr(ord($data[6]) & 0x0f | 0x40); // version 4
    $data[8] = chr(ord($data[8]) & 0x3f | 0x80); // variant
    return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}

function truncateTitle($text, $maxLength = 60) {
    if (mb_strlen($text) <= $maxLength) return $text;
    return mb_substr($text, 0, $maxLength - 3) . '...';
}

// calculateDateRange() — was this file's own standalone copy of
// src/ColexHelpers.php's function (Single Source of Truth migration Wave
// 5d). Deleted, not just repointed: this copy was missing the
// previous_quarter/previous_year cases ColexHelpers' version already has
// (falling through to the 'default' — silently treating "Previous
// Quarter"/"Previous Year" as "current week" instead) — a real, live,
// reachable bug, since public/colex.php's own header selector has always
// offered both as real dropdown options. Now sourced from the one shared,
// more complete implementation (required above) instead of a second,
// independently-drifting copy — same 'from'/'to'/'display' return shape,
// so no call site below needed to change.

/**
 * STALE COMMENT CORRECTED (2026-08-10, exhaustive narrative audit): the
 * behavior this comment used to describe — treating the header's context
 * selector as a "convenience default" that only narrows the query when
 * the message text itself contains explicit time language — was the
 * exact bug reported directly by Kevin ("when you select current week,
 * current month, etc, you get same generic response on all selected
 * timeframes") and fixed the same day. $dateRange now unconditionally
 * uses calculateDateRange($period, ...) regardless of what this function
 * returns (see the POST handler above, ~line 180) — matching how
 * getFourStageComparison() and every other real page in this app already
 * scope to the selected period unconditionally, never gated on message
 * wording.
 *
 * messageIsTimeBound()'s only remaining real use is sizing
 * $recentTradesLimit (10 vs. 100 recent trades pulled) — a genuinely
 * separate, still-correct concern: a time-scoped question ("what happened
 * today?") only needs a handful of recent trades, while a pattern/
 * session/history question benefits from a wider sample within whatever
 * period is already selected. It no longer decides WHICH period is
 * queried, only how many trades are pulled from it.
 */
function messageIsTimeBound($message) {
    $message = strtolower($message);

    $timeKeywords = [
        'today', 'yesterday', 'tonight',
        'this week', 'last week', 'past week', 'previous week',
        'this month', 'last month', 'past month', 'previous month',
        'this quarter', 'last quarter', 'past quarter', 'previous quarter',
        'this year', 'last year', 'past year', 'previous year',
        'ytd', 'year to date',
        'this period', 'last period', 'previous period', 'current period',
    ];

    foreach ($timeKeywords as $kw) {
        if (str_contains($message, $kw)) {
            return true;
        }
    }

    // "last 7 days", "past 3 weeks", "last 2 months", etc.
    if (preg_match('/\b(last|past)\s+\d+\s+(day|days|week|weeks|month|months)\b/', $message)) {
        return true;
    }

    // Quarter shorthand: "Q1", "Q3 2025"
    if (preg_match('/\bq[1-4]\b/', $message)) {
        return true;
    }

    // Month names only count as time-bound when they're used in a dated
    // context (preceded by in/during/since/for, or followed by a year) so
    // that "may" as a modal verb ("how may I improve") isn't a false hit.
    $months = 'january|february|march|april|may|june|july|august|september|october|november|december';
    if (preg_match('/\b(in|during|since|for)\s+(' . $months . ')\b/', $message)) {
        return true;
    }
    if (preg_match('/\b(' . $months . ')\s+\d{4}\b/', $message)) {
        return true;
    }

    // Explicit dates: 2026-06-01, 06/01/2026, 6/1/26
    if (preg_match('/\b\d{4}-\d{1,2}(-\d{1,2})?\b/', $message) ||
        preg_match('/\b\d{1,2}\/\d{1,2}(\/\d{2,4})?\b/', $message)) {
        return true;
    }

    // "since March", "between June and July"
    if (preg_match('/\bsince\b/', $message) || preg_match('/\bbetween\b.+\band\b/', $message)) {
        return true;
    }

    return false;
}

/**
 * ORPHANED (2026-08-10, exhaustive narrative audit) — confirmed via a
 * full-repo grep to have zero real call sites. This was the fallback
 * range used by the old, now-removed "only scope to the selector if the
 * message contains explicit time language" behavior (see the corrected
 * comment above messageIsTimeBound() and the POST handler's own
 * ~line-169 comment for the full incident writeup) — $dateRange is now
 * unconditionally calculateDateRange($period, ...) instead, so nothing
 * ever reaches this function anymore. Left in place, not deleted, per
 * this codebase's established additive-only convention — a future
 * feature that genuinely needs an explicit "search my whole history"
 * scope (as opposed to "whatever period is currently selected") could
 * still reuse this rather than reinventing it.
 */
function getAllTimeRange() {
    $now = new DateTime();
    return [
        'from' => '1970-01-01 00:00:00',
        'to' => $now->format('Y-m-d H:i:s'),
        'display' => 'All Time'
    ];
}


// ── Colex Product Knowledge System (LLM reasoning) ────────
//
// Architecture: Colex has two context sources — TraderAlign Knowledge
// (knowledge.json, reference material) and User Context (tier, trading
// data, usage, journey type, conversation history). Both are assembled
// into a system prompt and handed to the Claude API, which reasons over
// them to produce a personalized natural-language answer. There is no
// switch statement or keyword match that selects between canned answers
// here — every question, whatever it's about, goes through the same
// reasoning call.
//
// generateResponse()/detectIntent() further down this file remain in
// place, but only as the FALLBACK path used when no Anthropic API key is
// configured yet (see callAnthropicAPI() below) — they compute real
// numbers from AnalyticsEngine, they don't select from canned product
// answers, and using them as a degraded-mode safety net is intentional
// (see the task spec: "let the existing engine.php provide data
// responses" until the API is connected).

/**
 * Loads knowledge.json once per request (cached in a static). This is
 * reference material handed to the LLM as-is — nothing here picks an
 * answer out of it; that reasoning happens inside the model.
 */
function loadKnowledgeBase() {
    static $kb = null;
    if ($kb !== null) return $kb;

    $path = __DIR__ . '/knowledge.json';
    if (!file_exists($path)) {
        error_log("[Colex] knowledge.json not found at {$path}");
        $kb = [];
        return $kb;
    }

    $raw = file_get_contents($path);
    $decoded = json_decode($raw, true);
    if (json_last_error() !== JSON_ERROR_NONE) {
        error_log("[Colex] knowledge.json failed to parse: " . json_last_error_msg());
        $kb = [];
        return $kb;
    }

    $kb = $decoded;
    return $kb;
}

/**
 * The user's subscription tier and onboarding journey type.
 *
 * Tier lives in user_profiles.tier as a lowercase string
 * ('free'|'starter'|'pro'|'elite'|'ultimate'), matching the lowercase keys
 * in knowledge.json's subscription_tiers so no translation is needed.
 * Falls back to 'free' on any lookup failure so a schema hiccup degrades
 * gracefully instead of breaking chat entirely.
 */
function getUserPlanContext($db, $userId) {
    $planContext = [
        'tier' => 'free',
        'journey_type' => null,
    ];

    try {
        $row = $db->raw(
            "SELECT tier, onboarding_context FROM user_profiles WHERE user_id = ? LIMIT 1",
            [$userId]
        );
        if (!empty($row)) {
            $row = $row[0];
            if (!empty($row['tier'])) {
                $planContext['tier'] = strtolower(trim($row['tier']));
            }
            if (!empty($row['onboarding_context'])) {
                $ctx = json_decode($row['onboarding_context'], true) ?: [];
                $planContext['journey_type'] = $ctx['journey_type'] ?? null;
            }
        }
    } catch (Exception $e) {
        error_log("[Colex] Could not load plan context (falling back to free): " . $e->getMessage());
    }

    return $planContext;
}

/**
 * Turns the already-computed $context (accounts, metrics, challenge
 * status) plus plan context into a plain-text block for the LLM. This is
 * data assembly, not response generation — it states facts, it doesn't
 * phrase an answer to any particular question.
 */
function assembleUserContextText($context, $planContext) {
    $metrics = $context['metrics'] ?? [];
    $accounts = $context['accounts'] ?? [];
    $totalTrades = (int)($metrics['total_trades'] ?? 0);

    $lines = [];
    $lines[] = "Subscription tier: " . $planContext['tier'];
    $lines[] = "Onboarding journey type: " . ($planContext['journey_type'] ?: 'not set');
    $lines[] = "Number of trading accounts: " . count($accounts);
    $lines[] = "Time scope of the trading data below: " . ($context['period']['display'] ?? 'unknown');
    $lines[] = "Total trades in that scope: {$totalTrades}";

    if ($totalTrades > 0 && !isset($metrics['error'])) {
        // colexSignedMoney()/abs() here (2026-08-10, LLM-readiness pass)
        // — net_pnl/expectancy can be negative, and a naive "$" .
        // number_format($negative) renders "$-450.00" (sign lands inside
        // the currency symbol); current_drawdown_pct is stored as a
        // negative fraction by design (see AnalyticsEngine), which reads
        // as a literal negative-percentage typo in this plain-English
        // context line. Both bugs would otherwise feed the real LLM
        // slightly wrong-looking facts the moment ANTHROPIC_API_KEY is
        // configured — this text is the LLM's entire view of the
        // user's numbers, so it needs to be correct now, not patched
        // later once the key goes live and someone notices Colex
        // talking about "$-450.00."
        $lines[] = "Win rate: " . round(($metrics['win_rate'] ?? 0) * 100, 1) . "%";
        $lines[] = "Net P&L: " . colexSignedMoney($metrics['net_pnl'] ?? 0);
        $lines[] = "Expectancy per trade: " . colexSignedMoney($metrics['expectancy'] ?? 0);
        $lines[] = "Current drawdown: " . round(abs((float)($metrics['current_drawdown_pct'] ?? 0)) * 100, 2) . "%";
        $lines[] = "Losing streak: " . ($metrics['losing_streak'] ?? 0) . ", Winning streak: " . ($metrics['winning_streak'] ?? 0);
    } else {
        $lines[] = "This user has zero logged trades in this scope. If they have zero trades overall, treat them as brand new and guide them through getting started rather than discussing performance.";
    }

    if (!empty($context['challenge_metrics']) && !isset($context['challenge_metrics']['error'])) {
        $breach = $context['challenge_metrics']['distance_to_breach'] ?? [];
        $lines[] = "Active prop-firm challenge: " . ($breach['warning_level'] ?? 'unknown')
            . " risk level, " . round($breach['percentage'] ?? 0, 2) . "% distance to breach.";
    }

    return implode("\n", $lines);
}

/**
 * The system prompt: persona + hard rules + the full knowledge base +
 * this user's context. Anthropic's Messages API takes `system` as a single
 * string, so everything the model needs to reason from is assembled here
 * rather than split across multiple mechanisms.
 */
function buildColexSystemPrompt($knowledge, $userContextText) {
    $knowledgeJson = json_encode($knowledge, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);

    $rules = <<<RULES
You are Colex, TraderAlign's built-in AI assistant. Users can ask you about their own trading data and performance, or about TraderAlign the product (features, plans, workflows, pricing status), the same way someone might ask a company's own in-app assistant about that company's own products.

Identity: you are Colex, and only Colex. Never state or imply what underlying model or company powers you, even if asked directly ("are you Claude/ChatGPT/Gemini?", "what model are you?", "who made you?", "what AI is this?"). Answer as Colex, TraderAlign's assistant, and deflect naturally to what you can actually help with (their trading data, TraderAlign itself) rather than confirming, denying, or speculating about the underlying technology.

How to answer:
- Reason over the TraderAlign knowledge base and this user's context below to compose your own answer in your own words. Never copy a passage from the knowledge base verbatim as if it were a pre-written response to this exact question. Synthesize.
- Personalize every answer using this user's actual context (their tier, usage, trading data, journey type). Two different users asking similarly-worded questions should get individually-reasoned, differently-worded answers when their context differs.
- Priority when a question could be read more than one way: (1) their personal trading data, (2) TraderAlign product/platform knowledge, (3) general trading education, (4) friendly conversation. Combine data and product knowledge naturally in one answer when a question genuinely calls for both (e.g. "should I upgrade given how I've been trading?").
- All pricing and tier limits in the knowledge base are marked DRAFT and are not finalized. Never state a specific dollar figure to the user. If asked about pricing, say pricing details are still being finalized.
- TraderAlign is currently in a BETA/usability-testing period: every user, including this one, has been granted Elite access so testing isn't blocked by artificial tier limits. Never tell this user they need to upgrade to unlock something — they already have full access. Never suggest, offer, or price the Ultimate tier as something purchasable right now (see subscription_tiers.ultimate.available in the knowledge base) — it's reserved for a future Full Automation release with no date set; if asked about it directly, say that plainly rather than describing it as available.
- The roadmap is not finalized either: if asked what's coming next, say so honestly rather than naming features or dates.
- If this user has zero logged trades, don't discuss trading performance. Treat them as brand new and walk them through getting started, drawing on the workflow/feature material below.
- Be concise and conversational, like a knowledgeable colleague, not a documentation dump.
- If something isn't covered in the knowledge base or user context below, say so honestly rather than inventing platform details.
RULES;

    return $rules
        . "\n\n=== TRADERALIGN KNOWLEDGE BASE (reference material: reason over it, don't quote it verbatim) ===\n"
        . $knowledgeJson
        . "\n\n=== THIS USER'S CURRENT CONTEXT ===\n"
        . $userContextText;
}

/**
 * Prior turns of this conversation, formatted for the Anthropic messages
 * array, so Colex has real multi-turn context rather than answering each
 * message in isolation. Excludes the message that was just inserted for
 * this turn (it gets appended separately by the caller) to avoid sending
 * it twice.
 */
function getConversationHistoryForLLM($db, $conversationId, $excludeMessageId, $limit = 12) {
    if (!$conversationId) return [];

    try {
        $rows = $db->raw(
            "SELECT id, role, content FROM colex_messages 
             WHERE conversation_id = ? AND id != ? 
             ORDER BY created_at DESC LIMIT ?",
            [$conversationId, $excludeMessageId, $limit]
        );
    } catch (Exception $e) {
        error_log("[Colex] Could not load conversation history: " . $e->getMessage());
        return [];
    }

    $rows = array_reverse($rows); // back to chronological order

    $messages = [];
    foreach ($rows as $row) {
        $messages[] = [
            'role' => $row['role'] === 'assistant' ? 'assistant' : 'user',
            'content' => $row['content']
        ];
    }
    return $messages;
}

// getColexApiKey() / COLEX_MODEL / callAnthropicAPI() moved to
// src/ColexProvider.php (2026-08-10, Alignex→Colex rebrand cleanup) —
// required near the top of this file — so Strategy Builder's Polish/
// AI-First checklist assist (api/colex/assist.php) can share the same
// authenticated call path instead of duplicating it or, as it did
// before this cleanup, calling the provider directly from client-side
// JS with no API key at all.

/**
 * Turns the worst single trade in a list into a concrete, specific
 * sentence fragment (symbol, dollar amount, session) instead of a vague
 * "review your losses" style comment.
 */
function describeWorstTrade($trades) {
    $worst = null;
    $worstPnl = 0;
    foreach ($trades as $trade) {
        $pnl = (float)($trade['broker_pnl'] ?? $trade['challenge_pnl'] ?? 0);
        if ($pnl < $worstPnl) {
            $worstPnl = $pnl;
            $worst = $trade;
        }
    }
    if (!$worst) return null;
    
    $symbol = $worst['symbol'] ?? 'an unknown instrument';
    $amount = number_format(abs($worstPnl), 2);
    $session = '';
    if (!empty($worst['captured_at'])) {
        $hour = (int)date('H', strtotime($worst['captured_at']));
        $session = ($hour >= 3 && $hour < 12) ? 'London' : (($hour >= 8 && $hour < 17) ? 'New York' : 'Asian');
    }
    
    $detail = "-\${$amount} on {$symbol}";
    if ($session) $detail .= " during the {$session} session";
    return $detail;
}

/** Same idea as describeWorstTrade(), but for the single best trade. */
function describeBestTradeInstrument($trades) {
    $best = null;
    $bestPnl = -INF;
    foreach ($trades as $trade) {
        $pnl = (float)($trade['broker_pnl'] ?? $trade['challenge_pnl'] ?? 0);
        if ($pnl > $bestPnl) {
            $bestPnl = $pnl;
            $best = $trade;
        }
    }
    return $best['symbol'] ?? null;
}

function detectIntent($message) {
    $original = $message;
    $message = strtolower($message);
    
    // Ordered: first matching category wins. Each list is intentionally
    // broad (word stems, not just exact phrases) so real trader phrasing
    // ("performing", "imporove" typos aside) still lands somewhere
    // specific instead of falling through to the generic overview.
    $intentKeywords = [
        // 'how is my' was removed from this list (caught live,
        // 2026-08-10): it's an unbounded prefix that matches ANY
        // "How is my X?" question — since 'performance' is checked
        // first, "how is my discipline"/"how is my drawdown" were both
        // silently routed to the performance response instead of ever
        // reaching the discipline/drawdown lists below. 'performance'
        // (the bare word) and 'how am i doing' already cover the
        // legitimate vague-performance-question case without this
        // false-positive-prone catch-all.
        'performance' => ['performance', 'perform', 'profit', 'win rate', 'winning', 'winners',
                           'losers', 'recap', 'overview', 'summary', 'how am i doing'],
        'drawdown'    => ['drawdown', ' dd ', 'breach', 'risk', 'exposure', 'position size',
                           'bleed', 'decline', 'loss recover'],
        'discipline'  => ['discipline', 'strategy', 'strategies', 'mistake', 'adherence',
                           'follow my plan', 'stick to', 'consistency', 'deviat'],
        'patterns'    => ['pattern', 'revenge', 'tilt', 'impulsive', 'behavior', 'habit', 'session'],
        'comparison'  => ['compare', ' vs ', 'versus', 'previous period', 'last period',
                           'last week', 'last month', 'before'],
        'advice'      => ['advice', 'coach', 'improve', 'suggest', 'tip', 'recommend', 'better'],
    ];
    
    foreach ($intentKeywords as $intent => $keywords) {
        foreach ($keywords as $kw) {
            if (str_contains($message, $kw)) {
                error_log("[Colex] intent='{$intent}' matched keyword='{$kw}' for message=\"{$original}\"");
                return $intent;
            }
        }
    }
    
    error_log("[Colex] intent='general' (no keyword matched) for message=\"{$original}\"");
    return 'general';
}

function generateResponse($intent, $message, $context, $db, $userId) {
    switch ($intent) {
        case 'performance':
            return generatePerformanceResponse($context);
        case 'drawdown':
            return generateDrawdownResponse($context);
        case 'discipline':
            return generateDisciplineResponse($context);
        case 'patterns':
            return generatePatternsResponse($context, $db, $userId);
        case 'comparison':
            return generateComparisonResponse($context);
        case 'advice':
            return generateAdviceResponse($context);
        default:
            return generateGeneralResponse($context, $message);
    }
}

function generatePerformanceResponse($context) {
    $metrics = $context['metrics'];
    $accounts = $context['accounts'];
    $recentTrades = $context['recent_trades'];
    
    if (!$metrics || isset($metrics['error'])) {
        $pos = colexPeriodPosition($context['period_key'] ?? '', $context['period'] ?? []);
        return [
            'message' => "I couldn't find trading data {$pos['mid_ref']}. Once you have some closed trades, I can provide a full performance breakdown.",
            'suggestions' => ['Upload my trades', 'Connect MT5 account', 'What can you analyze?']
        ];
    }
    
    $pnl = $metrics['net_pnl'] ?? 0;
    $winRate = round(($metrics['win_rate'] ?? 0) * 100, 1);
    $expectancy = $metrics['expectancy'] ?? 0;
    $totalTrades = $metrics['total_trades'] ?? 0;
    // best_trade/worst_trade/profit_factor are null (not 0) whenever there's
    // no trade in that category yet (no wins, no losses) — deliberately NOT
    // defaulted to 0 here, since 0 would read as "a real trade worth $0" or
    // "a real profit factor of 0" instead of "no data yet." Every use below
    // checks for null explicitly instead.
    $bestTrade = $metrics['best_trade'] ?? null;
    $worstTrade = $metrics['worst_trade'] ?? null;
    $profitFactor = $metrics['profit_factor'] ?? null;

    $pnlDirection = $pnl >= 0 ? 'positive' : 'negative';
    $pnlFmt = colexSignedMoney($pnl);
    $bestInstrumentSymbol = describeBestTradeInstrument($recentTrades);

    // Requested directly (2026-08-10): a natural, context-aware opening
    // ("Today is the first day of your trading week...", "So far this
    // month, you've taken 42 trades...") replaces the old raw "This
    // period (Aug 10 - Aug 10, 2026) you took..." — see
    // colexPeriodPosition()/colexOpeningTradeCount() (ColexHelpers.php)
    // for the shared logic every generator's opening now builds on.
    // Exact dates are still available in $context['period']['display']
    // for any caller that explicitly needs them (e.g. a message that
    // asks for precise reporting-period boundaries).
    $pos = colexPeriodPosition($context['period_key'] ?? '', $context['period'] ?? []);

    if ($totalTrades === 0) {
        return [
            'message' => colexOpeningTradeCount($pos, 0),
            'suggestions' => ['What can you analyze?', 'Set up a strategy', 'Learn about risk management']
        ];
    }

    $tenseVerb = $pos['is_complete'] ? 'was' : 'is';
    $message = colexOpeningTradeCount($pos, $totalTrades) . ", with a {$winRate}% win rate. "
        . "Your P&L {$tenseVerb} {$pnlFmt}. ";
    if ($bestTrade !== null) {
        $bestTradeFmt = number_format($bestTrade, 2);
        $message .= $bestInstrumentSymbol
            ? "Your best trade was \${$bestTradeFmt} on {$bestInstrumentSymbol}."
            : "Your best trade was \${$bestTradeFmt}.";
    } else {
        $message .= "You don't have a winning trade yet {$pos['mid_ref']}.";
    }

    if ($pnl < 0) {
        $worstDetail = describeWorstTrade($recentTrades);
        if ($worstDetail) {
            // Caught live (2026-08-10): describeWorstTrade() already
            // returns a signed value ("-$49.62 on BTC/USD..."), so
            // "lost {$worstDetail}" rendered "Your worst trade lost
            // -$49.62..." — "lost" already conveys the negative
            // direction, so pairing it with a leading "-" reads as a
            // confusing double-negative (could be misparsed as a gain).
            // "was" reads naturally with the signed value, matching how
            // generateAdviceResponse()'s own call site already phrases
            // the identical string ("Your biggest loss recently was
            // {$worstTradeDetail}.").
            $message .= " Your worst trade this period was {$worstDetail}. Let's identify what went wrong.";
        } elseif ($worstTrade !== null) {
            // Match describeWorstTrade()'s own convention: abs() + "lost
            // $X.XX", never "lost $-X.XX" (a bare number_format() on a
            // negative value renders the sign inside the dollar amount).
            $message .= " Your worst trade lost \$" . number_format(abs($worstTrade), 2) . ". Let's identify what went wrong.";
        }
    } elseif ($profitFactor !== null) {
        $message .= " Profit factor stands at " . number_format($profitFactor, 2) . ".";
    } else {
        $message .= " You haven't had a losing trade yet {$pos['mid_ref']}, so profit factor isn't calculable until you do.";
    }
    
    // Find best strategy based on recent trades - FIXED: instrument_display → symbol
    $instruments = [];
    foreach ($recentTrades as $trade) {
        $inst = $trade['symbol'] ?? 'Unknown';
        if (!isset($instruments[$inst])) $instruments[$inst] = ['wins' => 0, 'total' => 0, 'pnl' => 0];
        $instruments[$inst]['total']++;
        $pnlVal = (float)($trade['broker_pnl'] ?? $trade['challenge_pnl'] ?? 0);
        if ($pnlVal > 0) $instruments[$inst]['wins']++;
        $instruments[$inst]['pnl'] += $pnlVal;
    }
    
    // Narrative audit §4.3: this loop had no minimum trade-count floor —
    // a single 1-trade instrument at 100% win rate outranked a genuinely
    // well-sampled 50-trade instrument at 70%, and rendered with the
    // same visual confidence ('direction' => 'positive'). Require at
    // least 3 trades on an instrument before it's eligible to be called
    // "best" — the same floor this file's own generateAdviceResponse()
    // already uses as its bar for "enough data to say something."
    $bestInstrument = '';
    $bestWinRate = 0;
    foreach ($instruments as $inst => $data) {
        if ($data['total'] < 3) continue;
        $wr = ($data['wins'] / $data['total']) * 100;
        if ($wr > $bestWinRate) {
            $bestWinRate = $wr;
            $bestInstrument = $inst;
        }
    }
    
    $cards = [
        [
            'type' => 'summary',
            'title' => 'Period Performance',
            'stats' => [
                'pnl' => [
                    'label' => 'Net P&L',
                    'value' => colexSignedMoney($pnl),
                    'direction' => $pnlDirection
                ],
                'win_rate' => [
                    'label' => 'Win Rate',
                    'value' => $winRate . '%',
                    'direction' => $winRate >= 50 ? 'positive' : 'negative'
                ],
                'expectancy' => [
                    'label' => 'Expectancy',
                    'value' => colexSignedMoney($expectancy),
                    'direction' => $expectancy > 0 ? 'positive' : 'negative'
                ],
                'profit_factor' => [
                    'label' => 'Profit Factor',
                    'value' => $profitFactor === null ? '-' : number_format($profitFactor, 2),
                    'direction' => $profitFactor === null ? 'neutral' : ($profitFactor >= 1.5 ? 'positive' : ($profitFactor >= 1 ? 'neutral' : 'negative'))
                ],
                'best_trade' => [
                    'label' => 'Best Trade',
                    'value' => $bestTrade === null ? '-' : colexSignedMoney($bestTrade),
                    'direction' => 'positive'
                ],
                'worst_trade' => [
                    'label' => 'Worst Trade',
                    'value' => $worstTrade === null ? '-' : colexSignedMoney($worstTrade),
                    'direction' => $worstTrade !== null && $worstTrade > 0 ? 'positive' : 'negative'
                ]
            ]
        ]
    ];
    
    if ($bestInstrument) {
        $cards[0]['stats']['best_instrument'] = [
            'label' => 'Best Instrument',
            'value' => $bestInstrument . ' (' . round($bestWinRate) . '% WR)',
            'direction' => 'positive'
        ];
    }
    
    $suggestions = [];
    if ($winRate < 40) {
        // Same fix as ColexHelpers::generateSuggestedPrompts() (§4.6) —
        // "dropping"/"drop" presupposes a decline this function never
        // checks for (no prior-period comparison happens here at all;
        // this could be a trader's very first period, already at 35%
        // from trade one). Phrased as a forward-looking question instead.
        $suggestions[] = "How can I improve my {$winRate}% win rate?";
    }
    // $bestTrade is null (not 0) whenever there's no winning trade yet
    // this period — a real, common state now that periods are correctly
    // scoped instead of defaulting to all-time (a losing week has no
    // best trade at all). abs(null) is a PHP 8.1+ deprecation; treat a
    // missing best trade as 0 for this magnitude comparison, which also
    // reads correctly (a real loss with no offsetting win at all should
    // surface "Review my largest loss").
    if ($worstTrade < 0 && abs($worstTrade) > abs($bestTrade ?? 0)) {
        $suggestions[] = 'Review my largest loss';
    }
    $suggestions[] = 'Compare to last period';
    $suggestions[] = 'How can I improve?';
    
    return [
        'message' => $message,
        'cards' => $cards,
        'suggestions' => array_slice($suggestions, 0, 3)
    ];
}

function generateDrawdownResponse($context) {
    $metrics = $context['metrics'];
    $challengeMetrics = $context['challenge_metrics'];
    
    if (!$metrics || isset($metrics['error'])) {
        $pos = colexPeriodPosition($context['period_key'] ?? '', $context['period'] ?? []);
        return [
            'message' => "I need trading data to analyze drawdowns, and I don't see any closed trades {$pos['mid_ref']}. Please make sure you have trades recorded, or try a different reporting period.",
            'suggestions' => ['Show my performance', 'What is drawdown?', 'Upload trades']
        ];
    }
    
    // current_drawdown_pct/max_drawdown_pct are stored as NEGATIVE
    // fractions (e.g. -0.0811 for an 8.11% drawdown) — correct for a
    // data table read alongside other signed figures, but "your current
    // drawdown is -8.11%" reads as a negative-percentage typo in a
    // spoken sentence. abs() here for natural-language display, same
    // convention getPeriodStats()'s drawdown_percentage already
    // established for this exact field elsewhere in this codebase.
    $currentDD = round(abs((float)($metrics['current_drawdown_pct'] ?? 0)) * 100, 2);
    $maxDD = round(abs((float)($metrics['max_drawdown_pct'] ?? 0)) * 100, 2);
    $losingStreak = $metrics['losing_streak'] ?? 0;
    $pos = colexPeriodPosition($context['period_key'] ?? '', $context['period'] ?? []);

    $message = "Your current drawdown is {$currentDD}% (peak {$maxDD}% {$pos['mid_ref']}). ";
    
    if ($challengeMetrics && !isset($challengeMetrics['error'])) {
        $breachAmount = $challengeMetrics['distance_to_breach']['amount'] ?? null;
        if ($breachAmount !== null) {
            // Caught live (2026-08-10): distance_to_breach.amount can be
            // negative — the account's real current balance has already
            // crossed past the breach threshold (a real, live state:
            // the formal breach isn't recorded in lifecycle_state until
            // Draft Resolution confirms it, so the raw math can say
            // "past the line" before that happens). "You're $-5,029.68
            // away from the breach level" reads backwards — still
            // implies room to spare when there is none.
            if ($breachAmount < 0) {
                $message .= "You've already gone past your breach level by \$" . number_format(abs($breachAmount), 2) . ". ";
            } else {
                $message .= "You're \$" . number_format($breachAmount, 2) . " away from the breach level. ";
            }
        }
    }
    
    if ($losingStreak > 2) {
        $message .= "You're on a {$losingStreak}-trade losing streak. Consider reducing position size or taking a break to reset.";
    } elseif ($currentDD > 5) {
        $message .= "This is a significant drawdown. Review your recent losing trades to identify common patterns.";
    } else {
        $message .= "Your drawdown is within manageable levels. Keep following your risk management plan.";
    }
    
    $cards = [
        [
            'type' => 'drawdown',
            'title' => 'Drawdown Analysis',
            'stats' => [
                'current_dd' => [
                    'label' => 'Current Drawdown',
                    'value' => $currentDD . '%',
                    'direction' => $currentDD > 5 ? 'negative' : 'neutral'
                ],
                'max_dd' => [
                    'label' => 'Max Drawdown',
                    'value' => $maxDD . '%',
                    'direction' => $maxDD > 10 ? 'negative' : 'neutral'
                ],
                'losing_streak' => [
                    'label' => 'Losing Streak',
                    'value' => $losingStreak . ' trades',
                    'direction' => $losingStreak > 2 ? 'negative' : 'neutral'
                ]
            ]
        ]
    ];
    
    // Add challenge-specific info
    if ($challengeMetrics && !isset($challengeMetrics['error'])) {
        $dailyDD = $challengeMetrics['daily_drawdown'] ?? [];
        $breach = $challengeMetrics['distance_to_breach'] ?? [];
        
        // Same sign-safety reasoning as $breachAmount above, applied
        // defensively here too — "remaining" can go negative once a
        // limit is already exceeded, so don't assume it's always a
        // positive magnitude.
        $dailyRemaining = (float)($dailyDD['remaining_amount'] ?? 0);
        $cards[0]['stats']['daily_dd_remaining'] = [
            'label' => $dailyRemaining < 0 ? 'Daily DD Exceeded By' : 'Daily DD Remaining',
            'value' => colexSignedMoney(abs($dailyRemaining)),
            'direction' => $dailyRemaining > 100 ? 'positive' : 'negative'
        ];

        // distance_to_breach.percentage can also be negative (already
        // past the line) — a "Distance to Breach" label paired with a
        // bare negative percentage reads as if there's negative room
        // rather than "already exceeded," so relabel when past it.
        $breachPct = (float)($breach['percentage'] ?? 0);
        $cards[0]['stats']['breach_distance'] = [
            'label' => $breachPct < 0 ? 'Past Breach Level By' : 'Distance to Breach',
            'value' => number_format(abs($breachPct), 2) . '%',
            'direction' => ($breach['warning_level'] ?? 'safe') === 'danger' ? 'negative' : 'positive'
        ];

        if (($breach['warning_level'] ?? '') === 'danger') {
            $message .= ($breachAmount !== null && $breachAmount < 0)
                ? ' ⚠️ You have already breached your challenge account\'s drawdown limit. This needs to be resolved immediately.'
                : ' ⚠️ You are dangerously close to breaching your challenge account. Reduce risk immediately.';
        }
    }
    
    return [
        'message' => $message,
        'cards' => $cards,
        'suggestions' => ['Review worst losing trades', 'What caused this drawdown?', 'How do I recover?']
    ];
}

function generateDisciplineResponse($context) {
    $metrics = $context['metrics'];
    $recentTrades = $context['recent_trades'];
    $userId = $context['user_id'];
    $period = $context['period'];
    
    if (!$metrics || isset($metrics['error'])) {
        return [
            'message' => "I need trade data to evaluate your discipline. Start logging trades with strategy links for adherence tracking.",
            'suggestions' => ['How do I track discipline?', 'Link strategy to trades', 'Show my performance']
        ];
    }
    
    // avg_winner/avg_loser are null (not 0) whenever there's no trade in
    // that category yet — same principle already applied to best_trade/
    // worst_trade in generatePerformanceResponse() above. Defaulting to
    // 0 here would misread a real "no losing trades yet" state as "your
    // average loser is a real $0.00 trade."
    $avgWinner = $metrics['avg_winner'] ?? null;
    $avgLoser = $metrics['avg_loser'] ?? null;
    $totalTrades = $metrics['total_trades'] ?? 0;
    $pos = colexPeriodPosition($context['period_key'] ?? '', $period);

    // Real strategy-edge deviation counts via the same four-stage
    // computation used by the Journal's four-stage toggle — not a guess.
    $stageSummary = AnalyticsEngine::getFourStagePeriodSummary($userId, 'all', $period['from'], $period['to']);

    $message = '';
    if (($stageSummary['trades_in_period'] ?? 0) > 0) {
        $deviatedCount = $stageSummary['stage1']['trades_over_declared_risk'] ?? 0;
        $declaredRisk = round($stageSummary['stage1']['avg_declared_risk_pct'] ?? 0, 2);
        $actualRisk = round($stageSummary['stage2']['avg_risk_pct'] ?? 0, 2);
        $message .= "You deviated from your strategy edge {$deviatedCount} time" . ($deviatedCount == 1 ? '' : 's') . " {$pos['mid_ref']}. ";
        if ($declaredRisk > 0 || $actualRisk > 0) {
            $message .= "Your biggest gap was risk: planned {$declaredRisk}% but executed {$actualRisk}% on average. ";
        }
    }
    
    if ($avgWinner !== null && $avgLoser !== null) {
        $message .= "Across {$totalTrades} trades, your average winner is \$" . number_format($avgWinner, 2)
            . " and average loser is \$" . number_format(abs($avgLoser), 2) . ". ";
    } elseif ($avgWinner !== null) {
        $message .= "Across {$totalTrades} trades, your average winner is \$" . number_format($avgWinner, 2)
            . ", but you haven't had a losing trade yet {$pos['mid_ref']}. ";
    } elseif ($avgLoser !== null) {
        $message .= "Across {$totalTrades} trades, your average loser is \$" . number_format(abs($avgLoser), 2)
            . ", but you don't have a winning trade yet {$pos['mid_ref']}. ";
    } elseif ($totalTrades > 0) {
        $message .= "Across {$totalTrades} trades {$pos['mid_ref']}, none have closed as a clear win or loss yet. They've all landed at breakeven. ";
    }
    
    // Risk Consistency — was an independent raw-stddev formula over just
    // the "recent trades" subset (10-100 trades depending on time-bound
    // scope), fixed absolute thresholds unrelated to any other score in
    // the app (docs/architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md §8c,
    // Consolidated #9 — undisclosed as distinct from Reports/Journal/
    // Trader Profile's real Consistency Score, so a trader could see
    // "excellent" here and a middling score on Reports for the same
    // trades, with no way to reconcile the two since they weren't the
    // same statistic). Now the same EdgeHealthEngine score every other
    // page reads, scoped to this same period — reusing (not duplicating)
    // getFourStagePeriodSummary()'s own $period['from']/['to'] already
    // used two lines above for the identical window.
    $edgeFactors = EdgeHealthEngine::getPeriodFactors($userId, 'all', $period['from'], $period['to']);
    $consistencyScore = $edgeFactors['consistency_score'] ?? null;

    // Narrative audit §4.4: getPeriodFactors() already returns a real,
    // n-aware 'tier'/'n' pair specifically so callers can gate their
    // confidence — this function fetched it but only ever read
    // consistency_score, never tier. Coefficient-of-variation-based
    // consistency scoring trivially computes near-zero variance (a
    // near-100 "excellent" score) at n=1, since a single value has
    // nothing to vary against — so a trader with exactly one trade this
    // period could be told "very consistent — great discipline" off a
    // sample that can't actually demonstrate consistency at all.
    $consistencyN = $edgeFactors['n'] ?? $totalTrades;
    $consistencyTier = $edgeFactors['tier'] ?? 'confident';
    $riskConsistency = 'unknown';
    if ($consistencyScore !== null && $consistencyTier === 'single_trade') {
        $riskConsistency = 'unknown';
        $message .= "With only 1 trade {$pos['mid_ref']}, there's no real position-sizing pattern to grade yet. Consistency needs more than one data point to mean anything.";
    } elseif ($consistencyScore !== null && in_array($consistencyTier, ['very_small_sample', 'small_sample'], true)) {
        // Same 80/65/50 thresholds as the confident branch below, still
        // reporting the real score rather than withholding it — just
        // honestly hedged as a small sample instead of a settled habit.
        if ($consistencyScore >= 80) {
            $riskConsistency = 'excellent';
            $message .= "Based on just {$consistencyN} trades {$pos['mid_ref']}, your risk sizing looks very consistent, but that's too small a sample yet to call it a settled habit.";
        } elseif ($consistencyScore >= 50) {
            $riskConsistency = 'good';
            $message .= "Based on just {$consistencyN} trades {$pos['mid_ref']}, your risk sizing looks fairly consistent, though still too early to call this a confirmed pattern.";
        } else {
            $riskConsistency = 'poor';
            $message .= "Based on just {$consistencyN} trades {$pos['mid_ref']}, your risk per trade already varies a fair amount. Worth watching as more trades come in.";
        }
    } elseif ($consistencyScore !== null) {
        // Same 80/65/50 thresholds already established for this exact
        // score elsewhere in the app (public/journal.php's rnScoreTag()),
        // collapsed onto this card's existing 3-bucket vocabulary rather
        // than inventing a fourth, Colex-only threshold set.
        if ($consistencyScore >= 80) {
            $riskConsistency = 'excellent';
            $message .= "Your risk sizing is very consistent: great discipline.";
        } elseif ($consistencyScore >= 50) {
            $riskConsistency = 'good';
            $message .= "Your risk sizing is fairly consistent with minor variations.";
        } else {
            $riskConsistency = 'poor';
            $message .= "Your risk per trade varies significantly. Consistent position sizing is key to long-term success.";
        }
    } else {
        $message .= "I need more trades with risk data to assess your position sizing discipline.";
    }
    
    // Calculate R:R discipline — only meaningful once both a real
    // average winner AND a real average loser exist; either one being
    // null (no trade in that category yet) makes the ratio itself
    // uncalculable, not a real "0" ratio.
    $rrDiscipline = ($avgWinner !== null && $avgLoser !== null && abs($avgLoser) > 0)
        ? round($avgWinner / abs($avgLoser), 1) : null;

    $cards = [
        [
            'type' => 'discipline',
            'title' => 'Discipline Metrics',
            'stats' => [
                'avg_winner' => [
                    'label' => 'Avg Winner',
                    'value' => $avgWinner === null ? '-' : '$' . number_format($avgWinner, 2),
                    'direction' => 'positive'
                ],
                'avg_loser' => [
                    'label' => 'Avg Loser',
                    'value' => $avgLoser === null ? '-' : '$' . number_format(abs($avgLoser), 2),
                    'direction' => 'negative'
                ],
                'rr_ratio' => [
                    // Item 5 (2026-07-28 refinement pass): "1 : X", never a
                    // bare "X:1" — $rrDiscipline is avgWinner/abs(avgLoser),
                    // i.e. reward per $1 risked, so risk is the fixed "1"
                    // and $rrDiscipline is the reward side. A bare "X:1"
                    // paired with the "Risk:Reward" label would misread as
                    // risk=X/reward=1 — backwards from what's computed.
                    'label' => 'Risk:Reward Ratio',
                    'value' => $rrDiscipline === null ? '-' : '1 : ' . $rrDiscipline,
                    'direction' => $rrDiscipline === null ? 'neutral' : ($rrDiscipline >= 2 ? 'positive' : 'negative')
                ],
                'risk_consistency' => [
                    'label' => 'Risk Consistency',
                    'value' => ucfirst($riskConsistency),
                    'direction' => $riskConsistency === 'excellent' ? 'positive' : ($riskConsistency === 'poor' ? 'negative' : 'neutral')
                ]
            ]
        ]
    ];
    
    return [
        'message' => $message,
        'cards' => $cards,
        'suggestions' => ['How to improve discipline?', 'Review risk violations', 'Show my behavior patterns']
    ];
}

function generatePatternsResponse($context, $db, $userId) {
    $recentTrades = $context['recent_trades'];
    $metrics = $context['metrics'];
    $period = $context['period'];
    $pos = colexPeriodPosition($context['period_key'] ?? '', $period);

    $losingStreak = $metrics['losing_streak'] ?? 0;
    $winningStreak = $metrics['winning_streak'] ?? 0;
    
    // Analyze session patterns
    $sessions = ['London' => 0, 'New York' => 0, 'Asian' => 0];
    $sessionPnL = ['London' => 0, 'New York' => 0, 'Asian' => 0];
    
    foreach ($recentTrades as $trade) {
        if (!empty($trade['captured_at'])) {
            $hour = (int)date('H', strtotime($trade['captured_at']));
            $pnl = (float)($trade['broker_pnl'] ?? $trade['challenge_pnl'] ?? 0);
            
            if ($hour >= 3 && $hour < 12) {
                $sessions['London']++;
                $sessionPnL['London'] += $pnl;
            } elseif ($hour >= 8 && $hour < 17) {
                $sessions['New York']++;
                $sessionPnL['New York'] += $pnl;
            } else {
                $sessions['Asian']++;
                $sessionPnL['Asian'] += $pnl;
            }
        }
    }
    
    // Narrative audit §4.5: the only floor here was $sessions[$session] > 0
    // (a single trade) — a session with one lucky (or unlucky) trade could
    // be named "your best session" or "your weakest session" outright.
    // Require at least 3 trades in a session before it's eligible, the
    // same floor this file already uses elsewhere (generateAdviceResponse(),
    // generatePerformanceResponse()'s best-instrument fix) for "enough data
    // to name a pattern."
    // Real bug caught live (2026-08-10): PHP_FLOAT_MIN is the smallest
    // POSITIVE representable float (~2.2e-308), not negative infinity —
    // a common PHP footgun. Using it as the initial "best" baseline
    // means a genuinely negative session P&L (e.g. every session this
    // period was a loss) can never satisfy "$pnl > $bestSessionPnL",
    // so $bestSession silently stayed '' even when a real, qualifying
    // session existed — confirmed live on a losing week where Asian was
    // the only session clearing the ≥3-trade floor, entirely negative,
    // and "Best Session" rendered blank. -PHP_FLOAT_MAX is the correct
    // "true negative infinity" sentinel for a max-finding comparison.
    $bestSession = '';
    $bestSessionPnL = -PHP_FLOAT_MAX;
    $worstSession = '';
    $worstSessionPnL = PHP_FLOAT_MAX;

    foreach ($sessionPnL as $session => $pnl) {
        if ($sessions[$session] >= 3 && $pnl > $bestSessionPnL) {
            $bestSessionPnL = $pnl;
            $bestSession = $session;
        }
        if ($sessions[$session] >= 3 && $pnl < $worstSessionPnL) {
            $worstSessionPnL = $pnl;
            $worstSession = $session;
        }
    }
    
    // Real tilt/revenge signal — was inferred purely from
    // losing_streak >= 3 (a raw consecutive-loss count with no
    // relationship to the trader's actual emotional-state classification),
    // independently disagreeing with this SAME feature's own stats widget
    // (src/ColexHelpers.php's getPeriodStats(), which already correctly
    // counts real trade_classification = 'revenge_trade' rows) —
    // docs/architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md §9, Wave 4c.
    // sp_classify_trade is this app's sole write path for
    // trade_classification (confirmed via a full-repo grep — zero PHP
    // writers), so reading it here rather than re-deriving an
    // approximation from streak length means a real 3+ loss streak with
    // zero real tilt/revenge-classified trades in it no longer falsely
    // triggers this warning, and a real tilt/revenge trade that happens
    // NOT to be part of a 3+ streak (e.g. one bad trade sandwiched
    // between two wins) is no longer silently missed.
    // 2026-08-12 workflow-isolation audit: hardcoded to exclude validation
    // trades regardless of the user's current workflow — meant a validating
    // user with no main-workflow history got a permanently-empty signal
    // here instead of one reflecting their real, current (validation)
    // trades. Scoped to whichever workflow is actually active, same
    // pattern as every other trade query in this app.
    $colexValidationCond = validationScopeSql(ValidationService::getWorkflowState($userId, $db));
    $classRows = $db->raw(
        "SELECT trade_classification, COUNT(*) as c FROM trades
         WHERE user_id = :uid AND captured_at BETWEEN :from AND :to
         AND status = 'closed' AND {$colexValidationCond}
         AND trade_classification IN ('revenge_trade', 'tilt_warning')
         GROUP BY trade_classification",
        [':uid' => $userId, ':from' => $period['from'], ':to' => $period['to']]
    );
    $revengeCount = 0; $tiltCount = 0;
    foreach ($classRows as $row) {
        if ($row['trade_classification'] === 'revenge_trade') $revengeCount = (int)$row['c'];
        if ($row['trade_classification'] === 'tilt_warning') $tiltCount = (int)$row['c'];
    }
    $emotionalTradeCount = $revengeCount + $tiltCount;

    $message = '';
    if ($losingStreak >= 3) {
        $message = "You're on a {$losingStreak}-trade losing streak. ";
    } elseif ($winningStreak >= 3) {
        $message = "You're on a {$winningStreak}-trade winning streak. Stay disciplined and don't get overconfident. ";
    }

    if ($bestSession && $worstSession && $bestSession !== $worstSession) {
        $message .= "Your best session is {$bestSession} (" . colexSignedMoney($bestSessionPnL) . ") and your weakest is {$worstSession} (" . colexSignedMoney($worstSessionPnL) . "). ";
    } elseif ($bestSession && $bestSession === $worstSession) {
        // Only one session cleared the ≥3-trade floor above — "best
        // (X) vs. weakest (X)" naming the same session twice reads as
        // confusing/redundant rather than as "there's nothing to
        // compare yet," which is the real state.
        $message .= "{$bestSession} is the only session with enough trades ({$sessions[$bestSession]}) {$pos['mid_ref']} to read a real pattern from, netting " . colexSignedMoney($bestSessionPnL) . " so far. ";
    }

    if ($emotionalTradeCount > 0) {
        $parts = [];
        if ($revengeCount > 0) $parts[] = "{$revengeCount} trade" . ($revengeCount === 1 ? '' : 's') . " flagged as revenge trading";
        if ($tiltCount > 0) $parts[] = "{$tiltCount} flagged as tilt warnings";
        $message .= ucfirst($pos['mid_ref']) . ", " . implode(' and ', $parts) . ". Step away and review your plan before trades like these.";
    }

    $cards = [
        [
            'type' => 'patterns',
            'title' => 'Behavioral Patterns',
            'stats' => [
                'winning_streak' => [
                    'label' => 'Best Win Streak',
                    'value' => $winningStreak . ' trades',
                    'direction' => 'positive'
                ],
                'losing_streak' => [
                    'label' => 'Worst Loss Streak',
                    'value' => $losingStreak . ' trades',
                    'direction' => $losingStreak > 2 ? 'negative' : 'neutral'
                ],
                'best_session' => [
                    'label' => 'Best Session',
                    'value' => $bestSession,
                    // Now that the PHP_FLOAT_MIN bug above is fixed,
                    // "best" (the least-bad of the sessions that
                    // cleared the trade floor) can genuinely still be
                    // net-negative on an all-losing period — a
                    // hardcoded 'positive' here would color-code a real
                    // loss as a win. Empty value (no session cleared
                    // the ≥3-trade floor yet) is 'neutral', not an
                    // alarm-red 'negative' — same fix, same reasoning,
                    // as worst_session's own empty-value case below.
                    'direction' => $bestSession === '' ? 'neutral' : ($bestSessionPnL >= 0 ? 'positive' : 'negative')
                ],
                'worst_session' => [
                    'label' => 'Weakest Session',
                    'value' => $worstSession,
                    // Narrative audit §4.5: was hardcoded 'negative'
                    // regardless of the session's real P&L sign — a
                    // still-profitable session (just the least profitable
                    // of the three) rendered with the same red/negative
                    // tone as a genuinely losing one.
                    'direction' => $worstSession === '' ? 'neutral' : ($worstSessionPnL >= 0 ? 'positive' : 'negative')
                ],
                'emotional_trades' => [
                    'label' => 'Revenge/Tilt Trades',
                    'value' => $emotionalTradeCount,
                    'direction' => $emotionalTradeCount > 0 ? 'negative' : 'neutral'
                ]
            ]
        ]
    ];
    
    return [
        'message' => $message ?: "I'm analyzing your trading patterns. More trades will help me identify stronger behavioral trends.",
        'cards' => $cards,
        'suggestions' => ['Review my worst session', 'How to break a losing streak?', 'Analyze my discipline']
    ];
}

function generateComparisonResponse($context) {
    $metrics = $context['metrics'];
    $dateRange = $context['period'];
    $userId = $context['user_id'];
    
    // Calculate previous period
    $from = new DateTime($dateRange['from']);
    $to = new DateTime($dateRange['to']);
    $interval = $from->diff($to);
    
    $prevFrom = (clone $from)->sub($interval)->sub(new DateInterval('P1D'));
    $prevTo = (clone $from)->sub(new DateInterval('P1D'));
    
    if (empty($context['accounts'])) {
        return [
            'message' => "I need an active account with trade data to compare periods.",
            'suggestions' => ['Show current performance', 'Add an account', 'What can you analyze?']
        ];
    }
    
    // Get previous period metrics, aggregated across all accounts (same as
    // the current period), not a single "primary" account. Reuses the same
    // validation_scope $context['metrics'] above was computed with (this
    // function only receives $context, not $db, so it can't re-derive the
    // workflow state itself — see where $context['validation_scope'] is set).
    $prevMetrics = AnalyticsEngine::getUserMetrics($userId, [
        'dateFrom' => $prevFrom->format('Y-m-d H:i:s'),
        'dateTo' => $prevTo->format('Y-m-d H:i:s'),
        'validationScope' => $context['validation_scope'] ?? 'exclude',
    ]);

    // Narrative audit §4.1: the old `$prevPnL != 0 ? ... : 0` used a
    // defaulted 0% "change" as a stand-in for "no prior period to
    // compare against" — but that default always renders "(improved)"
    // (0 >= 0), and $prevPnL === 0 is ALSO the real, legitimate value
    // for a genuine prior period that happened to net to exactly zero.
    // The only accurate signal for "was there real prior data" is
    // total_trades, not the P&L figure itself — a trader's very first
    // reporting period, or any period with zero prior trade history,
    // has total_trades === 0 regardless of what net_pnl defaults to.
    $prevTrades = (int)($prevMetrics['total_trades'] ?? 0);
    $currentTrades = (int)($metrics['total_trades'] ?? 0);
    $currentPnL = $metrics['net_pnl'] ?? 0;
    $currentWR = round(($metrics['win_rate'] ?? 0) * 100, 1);

    if ($prevTrades === 0) {
        $pos = colexPeriodPosition($context['period_key'] ?? '', $dateRange);
        $pnlStr = colexSignedMoney($currentPnL, true);
        $soFarTail = $pos['is_complete'] ? '' : ' so far';
        return [
            'message' => "There's no closed-trade history in the period right before this one. This looks like the first real reporting period with trade data, so there's nothing to compare it against yet. " . ucfirst($pos['mid_ref']) . "{$soFarTail}: {$pnlStr} P&L, {$currentWR}% win rate.",
            'suggestions' => ['Show current performance', 'What can you analyze?', 'Review recent trades']
        ];
    }

    $prevPnL = $prevMetrics['net_pnl'] ?? 0;
    $prevWR = round(($prevMetrics['win_rate'] ?? 0) * 100, 1);
    $wrChange = $currentWR - $prevWR;

    // $prevPnL can still legitimately be exactly 0 here (a real
    // breakeven prior period, now confirmed real via $prevTrades > 0
    // above) — a percentage change against a zero baseline is
    // undefined, not 0%, so state the real dollar swing instead of a
    // fabricated percentage.
    $pnlChange = $prevPnL != 0 ? round((($currentPnL - $prevPnL) / abs($prevPnL)) * 100, 1) : null;
    if ($pnlChange !== null) {
        $message = "Compared to last period: P&L changed {$pnlChange}% ";
        $message .= $pnlChange >= 0 ? "(improved)" : "(declined)";
    } else {
        $pnlStr = colexSignedMoney($currentPnL, true);
        $message = "Compared to last period: P&L went from exactly \$0.00 to {$pnlStr}";
    }
    $message .= " and win rate changed {$wrChange}% ";
    $message .= $wrChange >= 0 ? "(improved)." : "(declined).";

    if ($pnlChange !== null && $pnlChange < -20) {
        $message .= " This is a significant decline. Let's review what changed.";
    } elseif ($pnlChange === null && $currentPnL < 0) {
        $message .= " Worth reviewing what's driving the current-period loss.";
    }

    // Neither side of this comparison is gated on sample size — a
    // 1-trade period compared against a well-sampled prior one still
    // rendered a fully confident "P&L changed X% (declined)" claim, with
    // no hint that one side is far too thin to trust. Same n-aware
    // floors colexClassifyTraderSituation() already applies elsewhere in
    // Colex (via colexTimeframeFloor(), shared not duplicated).
    $bucket = colexTimeframeBucket($context['period_key'] ?? '', $dateRange);
    $floor = colexTimeframeFloor($bucket);
    if ($currentTrades < $floor || $prevTrades < $floor) {
        $thinSide = $currentTrades < $floor ? "this period ({$currentTrades} trade" . ($currentTrades === 1 ? '' : 's') . ")" : "last period ({$prevTrades} trade" . ($prevTrades === 1 ? '' : 's') . ")";
        $message .= " Worth noting: {$thinSide} is a small sample, so treat this comparison as a rough signal rather than a settled trend.";
    }

    $cards = [
        [
            'type' => 'comparison',
            'title' => 'Period Comparison',
            'stats' => [
                'current_pnl' => [
                    'label' => 'Current P&L',
                    'value' => colexSignedMoney($currentPnL),
                    'direction' => $currentPnL >= 0 ? 'positive' : 'negative'
                ],
                'previous_pnl' => [
                    'label' => 'Previous P&L',
                    'value' => colexSignedMoney($prevPnL),
                    'direction' => $prevPnL >= 0 ? 'positive' : 'negative'
                ],
                'current_wr' => [
                    'label' => 'Current Win Rate',
                    'value' => $currentWR . '%',
                    'direction' => $currentWR >= 50 ? 'positive' : 'negative'
                ],
                'previous_wr' => [
                    'label' => 'Previous Win Rate',
                    'value' => $prevWR . '%',
                    'direction' => $prevWR >= 50 ? 'positive' : 'negative'
                ]
            ]
        ]
    ];
    
    return [
        'message' => $message,
        'cards' => $cards,
        'suggestions' => ['What changed between periods?', 'Review losing trades from last period', 'How to improve next period?']
    ];
}

function generateAdviceResponse($context) {
    $metrics = $context['metrics'];
    $recentTrades = $context['recent_trades'];
    
    if (!$metrics || isset($metrics['error']) || $metrics['total_trades'] < 3) {
        return [
            'message' => "I need at least 3 trades to provide meaningful coaching suggestions. Keep trading and logging your results!",
            'suggestions' => ['Track more trades', 'Set up a strategy', 'Learn about risk management']
        ];
    }
    
    $advice = [];
    $weaknesses = [];
    
    $winRate = round(($metrics['win_rate'] ?? 0) * 100, 1);
    $losingStreak = $metrics['losing_streak'] ?? 0;
    // avg_winner/avg_loser are null (not 0) whenever there's no trade in
    // that category yet — same principle as generateDisciplineResponse()/
    // generatePerformanceResponse() above. Left null here (not defaulted
    // or abs()'d yet) so the "losers larger than winners" check below can
    // tell "no data" apart from a real $0 average.
    $avgLoser = $metrics['avg_loser'] ?? null;
    $avgWinner = $metrics['avg_winner'] ?? null;
    $expectancy = $metrics['expectancy'] ?? 0;
    
    // Analyze weaknesses
    if ($winRate < 40) {
        $weaknesses[] = 'win rate below 40%';
        $advice[] = "Your win rate is {$winRate}%, below the 40% threshold you'd want for a sustainable edge. Focus on higher-probability setups and avoid forcing trades in choppy markets.";
    }
    
    if ($losingStreak >= 3) {
        $weaknesses[] = 'extended losing streak';
        $advice[] = "Take a 24-hour break to reset mentally. Review your last {$losingStreak} losing trades for common mistakes.";
    }
    
    if ($avgLoser !== null && $avgWinner !== null && abs($avgLoser) > $avgWinner && $winRate < 60) {
        $weaknesses[] = 'losers larger than winners';
        $advice[] = "Your average loser (\$" . number_format(abs($avgLoser), 2) . ") exceeds your average winner (\$" . number_format($avgWinner, 2) . "). Tighten stops or aim for better R:R ratios.";
    }
    
    if ($expectancy <= 0) {
        $weaknesses[] = $expectancy < 0 ? 'negative expectancy' : 'breakeven expectancy';
        $expectancyFmt = colexSignedMoney($expectancy);
        // $expectancy == 0 exactly is a real, distinct breakeven state,
        // not "negative" — the old copy asserted "negative, meaning the
        // average trade loses money" unconditionally, which is simply
        // false at exactly $0.00 (a breakeven system doesn't lose money
        // on average, it also doesn't make any).
        $expectancyTail = $expectancy < 0
            ? "negative, meaning the average trade loses money over time"
            : "exactly breakeven, meaning the average trade neither makes nor loses money over time";
        $advice[] = "Your trading system currently has an expectancy of {$expectancyFmt} per trade: {$expectancyTail}. Paper trade until you find a consistent edge.";
    }
    
    // Concrete detail beats a vague warning — name the actual worst trade
    $worstTradeDetail = describeWorstTrade($recentTrades);
    if ($worstTradeDetail) {
        $advice[] = "Your biggest loss recently was {$worstTradeDetail}.";
    }
    
    // Session-based advice from recent trades
    $sessionLosses = [];
    foreach ($recentTrades as $trade) {
        $pnl = (float)($trade['broker_pnl'] ?? $trade['challenge_pnl'] ?? 0);
        if ($pnl < 0 && !empty($trade['captured_at'])) {
            $hour = (int)date('H', strtotime($trade['captured_at']));
            $session = ($hour >= 3 && $hour < 12) ? 'London' : (($hour >= 8 && $hour < 17) ? 'New York' : 'Asian');
            $sessionLosses[$session] = ($sessionLosses[$session] ?? 0) + 1;
        }
    }
    
    $worstSession = !empty($sessionLosses) ? array_search(max($sessionLosses), $sessionLosses) : '';
    if ($worstSession && $sessionLosses[$worstSession] >= 2) {
        $advice[] = "You're losing most during the {$worstSession} session. Consider reducing size or avoiding this session until you identify the issue.";
    }
    
    // Challenge-specific advice
    if (!empty($context['challenge_metrics']) && !isset($context['challenge_metrics']['error'])) {
        $challenge = $context['challenge_metrics'];
        $breach = $challenge['distance_to_breach'] ?? [];
        
        if (($breach['warning_level'] ?? '') === 'danger') {
            $advice[] = "⚠️ You're close to breaching your challenge. Cut position size by 50% and only take A+ setups.";
        }
        
        $progress = round((float)($challenge['distance_to_pass']['progress_percentage'] ?? 0), 1);
        if ($progress > 70 && $progress < 100) {
            $advice[] = "You're {$progress}% toward passing. Don't rush. Protect your progress with tighter risk management.";
        }
    }
    
    // Narrative audit §4.2: this function's own top guard already
    // requires >= 3 real trades before reaching this point, so an empty
    // $weaknesses/$advice list here means the checks genuinely found
    // nothing to flag — a good outcome, not a data shortfall. The old
    // code labeled it "Insufficient data" (false — there IS sufficient
    // data, nothing was wrong with it) and, if $advice also ended up
    // empty, rendered a "here are your key areas for improvement:"
    // headline followed by nothing at all.
    if (empty($advice)) {
        return [
            'message' => "Nothing stands out as a weakness across the checks I ran: win rate, losing streaks, risk:reward, expectancy, session concentration, and challenge proximity all look solid right now. Keep executing your current process and keep logging trades so this stays accurate as more data comes in.",
            'cards' => [
                [
                    'type' => 'advice',
                    'title' => 'Coaching Insights',
                    'stats' => [
                        'weaknesses' => [
                            'label' => 'Identified Weaknesses',
                            'value' => 'None found',
                            'direction' => 'positive'
                        ],
                        'primary_focus' => [
                            'label' => 'Primary Focus',
                            'value' => 'Keep executing your current process',
                            'direction' => 'neutral'
                        ],
                        'risk_level' => [
                            'label' => 'Risk Level',
                            'value' => 'Low',
                            'direction' => 'positive'
                        ]
                    ]
                ]
            ],
            'suggestions' => ['Show current performance', 'Review my risk management', 'What patterns show up in my trades?']
        ];
    }

    $message = "Based on your trading data, here are your key areas for improvement:\n\n";
    $message .= implode("\n\n", array_map(function($a, $i) { return ($i + 1) . ". {$a}"; }, $advice, array_keys($advice)));

    $cards = [
        [
            'type' => 'advice',
            'title' => 'Coaching Insights',
            'stats' => [
                'weaknesses' => [
                    'label' => 'Identified Weaknesses',
                    'value' => !empty($weaknesses) ? implode(', ', $weaknesses) : 'None found in the core metrics, see notes below',
                    'direction' => !empty($weaknesses) ? 'negative' : 'positive'
                ],
                'primary_focus' => [
                    'label' => 'Primary Focus',
                    'value' => !empty($advice) ? explode('.', $advice[0])[0] : 'Build trade history',
                    'direction' => 'neutral'
                ],
                'risk_level' => [
                    'label' => 'Risk Level',
                    'value' => count($weaknesses) >= 3 ? 'High' : (count($weaknesses) >= 1 ? 'Moderate' : 'Low'),
                    'direction' => count($weaknesses) >= 3 ? 'negative' : 'neutral'
                ]
            ]
        ]
    ];
    
    return [
        'message' => $message,
        'cards' => $cards,
        'suggestions' => ['Explain my weaknesses in detail', 'Create an improvement plan', 'Review my risk management']
    ];
}

function generateGeneralResponse($context, $message) {
    $metrics = $context['metrics'];
    $accounts = $context['accounts'];
    
    $accountCount = count($accounts);
    // Phase-aware total balance (2026-08-08) — was a raw, unconditional sum
    // of trading_accounts.current_balance across every account, the same
    // confirmed bug class as Trading Center's Account Home
    // (docs/architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md §1j): a prop-firm
    // account still mid-evaluation carries its whole-ledger, never-resets
    // balance here, which can massively overstate what a multi-phase-
    // promoted account actually holds right now. Personal accounts are
    // unaffected — current_balance is already correct for them.
    $totalBalance = 0;
    foreach ($accounts as $acc) {
        if (($acc['account_type'] ?? null) === 'prop_firm' && !empty($acc['id'])) {
            $pfr = PropFirmRuleEngine::compute($acc['id']);
            if (!isset($pfr['error'])) {
                $totalBalance += (float)$pfr['phase_balance'];
                continue;
            }
        }
        $totalBalance += (float)($acc['current_balance'] ?? 0);
    }
    
    $response = "I'm Colex, your trading analyst. ";

    if ($metrics && !isset($metrics['error']) && $metrics['total_trades'] > 0) {
        $pnl = $metrics['net_pnl'] ?? 0;
        $winRate = round(($metrics['win_rate'] ?? 0) * 100, 1);
        $pos = colexPeriodPosition($context['period_key'] ?? '', $context['period'] ?? []);
        $response .= "You have {$accountCount} active account(s) with " . colexSignedMoney($totalBalance) . " total balance. ";
        $response .= colexOpeningTradeCount($pos, (int)$metrics['total_trades']) . ", with a {$winRate}% win rate and " . colexSignedMoney($pnl) . " P&L. ";
        $response .= "Ask me about your performance, drawdowns, discipline, or for coaching advice.";
    } else {
        $response .= "You have {$accountCount} active account(s). Once you log some trades, I can analyze your performance, identify patterns, and give coaching advice.";
    }
    
    return [
        'message' => $response,
        'cards' => $metrics && !isset($metrics['error']) ? [[
            'type' => 'overview',
            'title' => 'Account Overview',
            'stats' => [
                'accounts' => [
                    'label' => 'Active Accounts',
                    'value' => $accountCount,
                    'direction' => 'neutral'
                ],
                'balance' => [
                    'label' => 'Total Balance',
                    'value' => colexSignedMoney($totalBalance),
                    'direction' => 'neutral'
                ],
                'trades' => [
                    'label' => 'Period Trades',
                    'value' => $metrics['total_trades'] ?? 0,
                    'direction' => 'neutral'
                ]
            ]
        ]] : null,
        'suggestions' => [
            'How is my performance?',
            'Analyze my drawdown',
            'Give me coaching advice'
        ]
    ];
}