<?php
$userId = $auth->requireAuth();
require_once __DIR__ . '/../api/analytics/engine.php';
require_once __DIR__ . '/../api/analytics/edge_health_engine.php';
require_once __DIR__ . '/../src/TradeClassificationEngine.php';
require_once __DIR__ . '/../src/Csrf.php';
require_once __DIR__ . '/../src/StrategyPicker.php';

/**
 * Effective P&L for one trade row — prop-firm accounts must read
 * challenge_pnl (capped at the breach/pass boundary), personal accounts
 * read broker_pnl (uncapped). Requires the row to carry an `account_type`
 * column (joined from trading_accounts) alongside the usual trade columns.
 * Falls back to projected_pnl for trades that somehow have no recorded
 * outcome yet, same fallback reports.php's $pnlExpr already uses.
 */
function dashEffectivePnl(array $t): float {
    if (($t['account_type'] ?? null) === 'prop_firm') {
        return (float)($t['challenge_pnl'] ?? $t['broker_pnl'] ?? $t['projected_pnl'] ?? 0);
    }
    return (float)($t['broker_pnl'] ?? $t['projected_pnl'] ?? 0);
}

/**
 * Four behavioral pattern alerts, built once here and reused by both the
 * strategy-selected and no-strategy-selected branches below (previously
 * two independent, drifting copies of a single "revenge_cluster"/
 * "tilt_window" check). Rewritten 2026-08-18 alongside
 * src/TradeClassificationEngine.php's fix — every alert here now derives
 * from the SAME real, corrected trade_classification/classification_reason
 * values Active Coaching's own tier logic produces, not a raw loss-streak
 * count. Definitions, per explicit business rule:
 *   - Revenge trading   = a loss followed by a trade risked beyond the
 *     tier recommendation (trade_classification = 'revenge_trade').
 *   - Fear of trading   = a loss followed by a trade risked excessively
 *     BELOW the tier recommendation (trade_classification = 'tilt_warning',
 *     repurposed from its old, much looser "any 2nd loss" meaning).
 *   - Impulsive trading = a win followed by a trade risked beyond the
 *     tier recommendation. Not its own stored value (folds into
 *     'elevated_win', see TradeClassificationEngine's own doc comment on
 *     why); identified here via classification_reason's persisted
 *     'preceded_by' field.
 *   - FOMO / overtrading = too many trades closed in one calendar day,
 *     the one pattern that is NOT a per-trade risk-sizing question at
 *     all, so it's computed independently of trade_classification.
 * $svidClause/$params let this serve both the strategy-scoped and
 * whole-account branches without a second copy of the query shapes.
 *
 * LINKING (2026-08-18): each alert cites a specific set of trades — when
 * the flagged trades all belong to the SAME account, the alert carries a
 * 'link' key straight into Active Journeys with that account selected,
 * auto-triggering the exact same client-side classification filter
 * (rnFilterTrades(), see journal.php) the trader would get by clicking a
 * coaching chip there. When the flagged trades span more than one
 * account, no single account link could show only the cited trades, so
 * no 'link' key is set — never a link that points somewhere broader than
 * what the message actually claims. Impulsive Trading and FOMO/
 * Overtrading never get a link at all: rnFilterTrades() can only match on
 * the stored trade_classification value, and neither "elevated_win
 * preceded by a win specifically" nor "closed today" is expressible as
 * one classification value without also matching unrelated trades, so a
 * link there would point at more than the trades actually cited.
 */
function dashBuildBehaviorAlerts($db, string $userId, string $dashValidationCond, ?string $filterVersionId = null): array {
    // Tunable, not independently statistically derived — chosen to sit
    // clearly above a normal active trading day rather than a precisely
    // researched figure. Revisit if real usage shows it firing too often
    // or too rarely.
    $fomoTradeCountThreshold = 8;

    $alerts = [];
    $svidClause = $filterVersionId !== null ? ' AND strategy_version_id = :svid' : '';
    $params = [':uid' => $userId];
    if ($filterVersionId !== null) $params[':svid'] = $filterVersionId;

    // Resolves an Active Journeys link for a set of flagged trade rows
    // (each carrying account_id) — only when every one of them shares the
    // exact same account, per this function's own doc comment above.
    $resolveLink = function (array $flaggedRows, string $classification, string $label) use ($db, $userId) {
        $accountIds = array_unique(array_filter(array_column($flaggedRows, 'account_id')));
        if (count($accountIds) !== 1) return null;
        $accountId = reset($accountIds);
        try {
            // ScopedDb::select() already scopes every query to this user's
            // own user_id internally (binding its own :uid placeholder) —
            // repeating "AND user_id = :uid" here would bind :uid twice in
            // one query string, a real SQLSTATE[HY093] under MariaDB/PDO's
            // real (non-emulated) prepared statements, not just redundant.
            $acct = $db->select('trading_accounts', 'id = :aid', [':aid' => $accountId], 'account_type', '', 1);
            $accountType = $acct[0]['account_type'] ?? null;
        } catch (\Throwable $e) { return null; }
        if ($accountType === null) return null;
        return activeJourneysUrl([
            'rn_type' => $accountType === 'prop_firm' ? 'prop' : 'live',
            'rn_account' => $accountId,
            'auto_filter' => $classification,
            'auto_filter_label' => $label,
        ]);
    };

    try {
        $recent5 = $db->raw("SELECT trade_classification, account_id FROM trades WHERE user_id = :uid AND {$dashValidationCond} AND status = 'closed' AND deleted_at IS NULL{$svidClause} ORDER BY closed_at DESC LIMIT 5", $params);
        $revengeRows = array_filter($recent5, fn($t) => $t['trade_classification'] === 'revenge_trade');
        $revengeCount = count($revengeRows);
        if ($revengeCount >= 3) {
            $alert = ['type' => 'revenge_trading', 'msg' => "Revenge Trading Detected: {$revengeCount} of your last 5 closed trades increased risk beyond what your current drawdown tier recommends, right after a loss. Taking a short break before the next trade could help protect your account."];
            $link = $resolveLink($revengeRows, 'revenge_trade', 'Revenge Trades');
            if ($link !== null) $alert['link'] = $link;
            $alerts[] = $alert;
        }
    } catch (\Throwable $e) {}

    try {
        $recent24h = $db->raw("SELECT trade_classification, classification_reason, account_id FROM trades WHERE user_id = :uid AND {$dashValidationCond} AND status = 'closed' AND deleted_at IS NULL{$svidClause} AND closed_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) ORDER BY closed_at DESC LIMIT 10", $params);

        $fearRows = array_filter($recent24h, fn($t) => ($t['trade_classification'] ?? null) === 'tilt_warning');
        $fearCount = count($fearRows);
        if ($fearCount >= 2) {
            $alert = ['type' => 'fear_of_trading', 'msg' => "Fear of Trading Detected: {$fearCount} trades in the last 24 hours were sized well below what your current drawdown tier allows, right after a loss. Sizing down out of fear can protect one trade, but it slows real recovery. Consider trading your normal plan instead of shrinking further."];
            $link = $resolveLink($fearRows, 'tilt_warning', 'Fear of Trading');
            if ($link !== null) $alert['link'] = $link;
            $alerts[] = $alert;
        }

        $impulsiveCount = 0;
        foreach ($recent24h as $t) {
            if (($t['trade_classification'] ?? null) !== 'elevated_win') continue;
            $reason = !empty($t['classification_reason']) ? json_decode($t['classification_reason'], true) : null;
            if (($reason['preceded_by'] ?? null) === 'win') $impulsiveCount++;
        }
        if ($impulsiveCount >= 2) {
            // No 'link' — see this function's own doc comment on why
            // Impulsive Trading can't be expressed as one classification
            // filter without also matching unrelated elevated_win trades.
            $alerts[] = ['type' => 'impulsive_trading', 'msg' => "Impulsive Trading Detected: {$impulsiveCount} trades in the last 24 hours increased risk beyond your current tier right after a win, chasing a bigger result. A winning streak is exactly when sizing discipline matters most."];
        }
    } catch (\Throwable $e) {}

    try {
        $today = $db->raw("SELECT COUNT(*) AS n FROM trades WHERE user_id = :uid AND {$dashValidationCond} AND status = 'closed' AND deleted_at IS NULL{$svidClause} AND DATE(closed_at) = CURDATE()", $params);
        $todayCount = (int)($today[0]['n'] ?? 0);
        if ($todayCount >= $fomoTradeCountThreshold) {
            // No 'link' — "closed today" has no classification value to
            // filter by, same reasoning as Impulsive Trading above.
            $alerts[] = ['type' => 'fomo_overtrading', 'msg' => "FOMO / Overtrading Detected: {$todayCount} trades closed today. Taking this many trades in a single day is often a sign of chasing setups rather than waiting for your real edge. Consider stopping for today."];
        }
    } catch (\Throwable $e) {}

    return $alerts;
}

// Read user's tier. (Round 3 audit #11: the previous comment here claimed
// this needed to bypass "ScopedDb caching/column filtering" — ScopedDb does
// neither of those things (see src/ScopedDb.php: no cache, and select()
// only ever returns the columns you ask it for), and 'user_profiles' is on
// ScopedDb's own table allowlist, so that reasoning didn't hold up. Now
// routed through $db like everything else.)
$tier = 'free'; // default fallback
$profileRow = null;
try {
    $rows = $db->select('user_profiles', '', [], 'tier', '', 1);
    $profileRow = $rows[0] ?? null;
    if ($profileRow && !empty($profileRow['tier'])) {
        $tier = $profileRow['tier'];
    }
} catch (\Throwable $e) {
    error_log("Failed to read user tier: " . $e->getMessage());
}

// ── Validation Workflow scope ──────────────────────────────────────────────
// This one $workflowState drives every is_validation condition on this
// page — both the strategy-filter dropdown query right below (previously
// had NO is_validation filter at all, a real leak: a validation strategy
// could appear in Dashboard's own strategy switcher) and every trade
// query further down (previously hardcoded to always exclude validation
// trades, which is correct for 'main' but would show an empty dashboard
// for a validating user).
$workflowState = ValidationService::getWorkflowState($userId, $db);
$dashValidationCond  = $workflowState === 'validation' ? '(is_validation = 1)' : '(is_validation = 0 OR is_validation IS NULL)';
$dashValidationCondT = $workflowState === 'validation' ? '(t.is_validation = 1)' : '(t.is_validation = 0 OR t.is_validation IS NULL)';
// 2026-08-18: same $canReturnToMain rule sidebar.php's own "Back to Main
// Workflow" control enforces (ValidationService::returnToMain() is the
// real, server-side security boundary either way — this is just deciding
// whether to show the link). Only ever true for a user who reached
// validation via "Validate a New Edge →" from an existing main-workflow
// session — never for someone who chose "I'm Developing a Trading Edge"
// at onboarding, and never for a brand-new trader who landed in Learning
// first: neither of those has a main workflow to go back to.
$dashCanReturnToMain = $workflowState === 'validation' && ValidationService::getEntrySource($userId, $db) === 'main_workflow';

// ── Dashboard filters (2026-08-12 cleanup) ─────────────────────────────────
// Same filter set Journal's Trade Journal section exposes
// (src/JournalTradesQuery.php's buildJournalTradesFilters()): Instrument /
// Session / Classification / From / To — replacing the old client-side-only
// search+direction+status+classification bar, which only ever hid/showed
// cards already fetched from an unfiltered query. These five values now
// narrow every canonical metric on this page (via
// AnalyticsEngine::buildFilterClause()'s new instrument/session/
// classification/closedFrom/closedTo keys and
// getFourStagePeriodSummary()/EdgeHealthEngine::getPeriodRiskOfRuin()'s
// existing extra-condition mechanism), not just the visible trade cards.
// Applies identically to both workflows — $dashValidationCond above already
// scopes everything else to the active workflow.
$dashFilterInstrument     = trim((string)($_GET['instrument'] ?? ''));
$dashFilterSession        = trim((string)($_GET['session'] ?? ''));
$dashFilterClassification = trim((string)($_GET['classification'] ?? ''));
$dashFilterFrom           = trim((string)($_GET['from'] ?? ''));
$dashFilterTo             = trim((string)($_GET['to'] ?? ''));
$dashHasActiveFilters = $dashFilterInstrument !== '' || $dashFilterSession !== '' || $dashFilterClassification !== '' || $dashFilterFrom !== '' || $dashFilterTo !== '';

// closed_at bounds this filter set represents. Defaults to the same
// "2000-01-01"..now unbounded-window convention already used elsewhere on
// this page for an all-time read — per explicit instruction, never
// hardcoded to "this month": no date filter means every matching trade,
// described honestly below as "the current period," not a fabricated
// calendar window.
$dashClosedFromDt = $dashFilterFrom !== '' ? $dashFilterFrom . ' 00:00:00' : '2000-01-01 00:00:00';
$dashClosedToDt   = $dashFilterTo   !== '' ? $dashFilterTo   . ' 23:59:59' : date('Y-m-d H:i:s');

// Plain-English period label — read by the new Strategy Performance card
// ("Rule violations resulted in $X in losses during {this}").
if ($dashFilterFrom !== '' && $dashFilterTo !== '') {
    $dashPeriodLabel = 'from ' . date('M j, Y', strtotime($dashFilterFrom)) . ' to ' . date('M j, Y', strtotime($dashFilterTo));
} elseif ($dashFilterFrom !== '') {
    $dashPeriodLabel = 'since ' . date('M j, Y', strtotime($dashFilterFrom));
} elseif ($dashFilterTo !== '') {
    $dashPeriodLabel = 'through ' . date('M j, Y', strtotime($dashFilterTo));
} else {
    $dashPeriodLabel = 'the current period';
}

// Extra raw-SQL condition (trades aliased t) for the queries on this page
// that aren't already routed through buildFilterClause() — reused verbatim
// by every raw query below and passed through to
// getFourStagePeriodSummary()/EdgeHealthEngine::getPeriodRiskOfRuin()'s
// existing $svidCondition extension point (already designed to accept
// arbitrary extra AND clauses, e.g. the strategy_version_id filter those
// call sites already append).
$dashExtraWhere = '';
$dashExtraParams = [];
if ($dashFilterInstrument !== '')     { $dashExtraWhere .= ' AND t.instrument = :dfinstrument'; $dashExtraParams[':dfinstrument'] = $dashFilterInstrument; }
if ($dashFilterSession !== '')        { $dashExtraWhere .= ' AND t.session = :dfsession'; $dashExtraParams[':dfsession'] = $dashFilterSession; }
if ($dashFilterClassification !== '') { $dashExtraWhere .= ' AND t.trade_classification = :dfclassification'; $dashExtraParams[':dfclassification'] = $dashFilterClassification; }
if ($dashFilterFrom !== '')           { $dashExtraWhere .= ' AND t.closed_at >= :dfclosedfrom'; $dashExtraParams[':dfclosedfrom'] = $dashClosedFromDt; }
if ($dashFilterTo !== '')             { $dashExtraWhere .= ' AND t.closed_at <= :dfclosedto'; $dashExtraParams[':dfclosedto'] = $dashClosedToDt; }

// ── Account type scope: Prop Firm / Live (2026-08-20 premium-polish pass) ──
// Same 'prop'/'live' vocabulary reports.php's own account_view toggle
// already uses — kept consistent so a link between the two pages (see
// "Open Full Reports →" below) can carry the same value across. `trades`
// itself has no account_type column (only trading_accounts does), so this
// is expressed as a subquery on account_id rather than a direct column
// filter — that also keeps it safe to reuse inside the bare-`trades`-table
// $db->count() calls further down this file, which adapt $dashExtraWhere
// to an unaliased table by stripping a literal "t." prefix: a subquery
// written as "t.account_id IN (...)" degrades cleanly to "account_id IN
// (...)" there, exactly like every other fragment already in this string.
// Distinct placeholder names (:dvuid/:dvtype) deliberately avoid reusing
// ":uid" a second time in one query string — see CLAUDE.md's Database
// Access rule on repeated named placeholders under real (non-emulated)
// prepares.
$dashAccountView = (($_GET['account_view'] ?? '') === 'live') ? 'live' : 'prop';
$dashAccountTypeDb = $dashAccountView === 'live' ? 'personal' : 'prop_firm';
$dashExtraWhere .= ' AND t.account_id IN (SELECT id FROM trading_accounts WHERE user_id = :dvuid AND account_type = :dvtype)';
$dashExtraParams[':dvuid'] = $userId;
$dashExtraParams[':dvtype'] = $dashAccountTypeDb;

// Same filters, shaped for AnalyticsEngine::buildFilterClause()-based calls
// (getUserMetrics()) — validationScope resolved from this page's own
// $workflowState.
$dashUmFilters = ['validationScope' => $workflowState === 'validation' ? 'only' : 'exclude', 'accountType' => $dashAccountTypeDb];
if ($dashFilterInstrument !== '')     $dashUmFilters['instrument'] = $dashFilterInstrument;
if ($dashFilterSession !== '')        $dashUmFilters['session'] = $dashFilterSession;
if ($dashFilterClassification !== '') $dashUmFilters['classification'] = $dashFilterClassification;
if ($dashFilterFrom !== '')           $dashUmFilters['closedFrom'] = $dashClosedFromDt;
if ($dashFilterTo !== '')             $dashUmFilters['closedTo'] = $dashClosedToDt;

/**
 * Builds a URL back to this page, merging the given overrides on top of
 * the current $_GET — so switching the Prop Firm/Live toggle, or clearing
 * filters, never silently drops strategy_id or the other filter values.
 * Passing null for a key removes it from the URL entirely.
 */
function dashUrl(array $overrides = []): string {
    $params = $_GET;
    foreach ($overrides as $k => $v) {
        if ($v === null) { unset($params[$k]); } else { $params[$k] = $v; }
    }
    $params = array_filter($params, fn($v) => $v !== null && $v !== '');
    $qs = http_build_query($params);
    return $qs !== '' ? '?' . $qs : '?';
}

// ── Strategy filter ───────────────────────────────────────────────────────────
$allStrategies = [];
try {
    $allStrategies = $db->select('strategies', "is_active = 1 AND deleted_at IS NULL AND {$dashValidationCond}", [], 'id, name, current_version_id', 'created_at DESC');
} catch (\Throwable $e) {
    error_log("Failed to fetch strategies for filter: " . $e->getMessage());
}

$selectedStrategyId = null;
$selectedStrategy   = null;
$filterVersionId    = null;

// 2026-08-20: carry the active strategy forward automatically. Real bug
// reported directly: a trade saved against strategy X could still land
// the trader on Dashboard defaulted to a different strategy, forcing a
// manual re-pick just to find their own trade. Priority order: an
// explicit ?strategy_id= on THIS request (which also becomes the new
// system-wide active strategy, since arriving here with one is only ever
// a direct consequence of an explicit pick on the Strategies page), then
// the real, persisted active-strategy pointer (spGetActiveStrategyId(),
// src/StrategyPicker.php), then most recently created, as a last resort
// for a user who has never explicitly picked one yet.
$dashRequestedStrategyId = $_GET['strategy_id'] ?? null;
if ($dashRequestedStrategyId) {
    $selectedStrategyId = $dashRequestedStrategyId;
    foreach ($allStrategies as $s) {
        if ((string)$s['id'] === (string)$selectedStrategyId) {
            $selectedStrategy = $s;
            $filterVersionId  = $s['current_version_id'];
            break;
        }
    }
    if ($selectedStrategy) {
        spRecordActiveStrategy($userId, $selectedStrategy['id'], $db, $workflowState);
    }
}

if (!$selectedStrategy) {
    $dashActiveStrategyId = spGetActiveStrategyId($userId, $db, $workflowState);
    if ($dashActiveStrategyId) {
        foreach ($allStrategies as $s) {
            if ((string)$s['id'] === (string)$dashActiveStrategyId) {
                $selectedStrategy = $s;
                $selectedStrategyId = $s['id'];
                $filterVersionId = $s['current_version_id'];
                break;
            }
        }
    }
}

if (!$selectedStrategy && !empty($allStrategies)) {
    $selectedStrategy = $allStrategies[0];
    $selectedStrategyId = $selectedStrategy['id'];
    $filterVersionId = $selectedStrategy['current_version_id'];
}

// Plain strategy name — no "(vN)" (TRADERALIGN STRATEGY BUSINESS RULES
// redesign, 2026-08-11, §13: version numbers are internal bookkeeping,
// never user-facing). A strategy now has exactly one persistent
// declaration row per strategy_version_id join — see CLAUDE.md.
$strategyDisplayName = $selectedStrategy ? $selectedStrategy['name'] : '';

// ── Graduated-with-warning summary card (2026-08-12 edge-warning pass) ──
// Only for a strategy that was explicitly graduated via the "Continue
// Anyway" override on a NOT-yet-demonstrated edge
// (ValidationService::graduate()'s edge_warning_acknowledged flag) — a
// strategy that graduated cleanly (edge demonstrated, no warning ever
// shown) gets no card here, nothing to summarize. Status is live, not
// frozen at graduation time: recomputed from the strategy's real,
// current, post-graduation trades on every render via
// ValidationService::getGraduationWarningStatus(), the single source for
// this comparison — this file never re-derives expectancy/profit-factor
// math itself.
$graduationWarningRecord = null;
$graduationWarningStatus = null;
if ($workflowState === 'main' && $selectedStrategy && !empty($selectedStrategy['current_version_id'])) {
    $gRecord = ValidationService::findGraduationRecordForStrategy($userId, $selectedStrategyId, $db);
    if ($gRecord && !empty($gRecord['edge_warning_acknowledged'])) {
        $graduationWarningRecord = $gRecord;
        $gCurrentMetrics = ValidationService::getPostGraduationMetrics($userId, $selectedStrategy['current_version_id']);
        $graduationWarningStatus = ValidationService::getGraduationWarningStatus($gRecord, $gCurrentMetrics);
    }
}

$resolved = null;
$resolveError = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['resolve_trade_id'])) {
    $tradeId = $_POST['resolve_trade_id'];
    $outcome = $_POST['outcome'] ?? 'lost';
    $actualEntry = $_POST['actual_entry_price'] ?? null;
    $actualExit = $_POST['actual_exit_price'] ?? null;
    $actualSl = $_POST['actual_sl_price'] ?? null;
    $actualPnl = (float)($_POST['actual_pnl'] ?? 0);
    $actualLots = $_POST['actual_lots'] ?? null;
    // Actual instrument/timeframe traded — Stage 3 execution-gap capture
    // (2026-07-27 Reports finishing pass, item 2). Instrument is a
    // confirm-or-correct field (form pre-fills it with the trade's own
    // instrument, so this is usually a no-op save of the same value);
    // timeframe is optional with no default. Both nullable — legacy
    // trades and anyone who skips the field simply have no Stage 3/4
    // instrument-timeframe data, same as before this column existed.
    $allowedTimeframes = timeframeOptions();
    $actualInstrumentRaw = trim((string)($_POST['actual_instrument'] ?? ''));
    $actualInstrument = $actualInstrumentRaw !== '' ? strtolower($actualInstrumentRaw) : null;
    $actualTimeframeRaw = (string)($_POST['actual_timeframe'] ?? '');
    $actualTimeframe = in_array($actualTimeframeRaw, $allowedTimeframes, true) ? $actualTimeframeRaw : null;
    // Submitted by Drafts' "What happened to the account after this trade?"
    // step (prop-firm trades only) — previously read nowhere in this
    // handler, so a trader confirming a breach/pass never actually
    // persisted anything beyond the trade itself.
    $accountStatus = $_POST['account_status'] ?? 'still_active';
    $breachReason  = trim((string)($_POST['breach_reason'] ?? ''));
    // "Managed Exit" workflow (2026-08-03) — a real declaration of Break
    // Even or an early managed exit (partial profit before the planned
    // Full TP), submitted by Drafts' new dropdown alongside the existing
    // actual_* fields. Only these two values are ever stored — an ordinary
    // TP-hit/stop-loss-hit/manual-close resolution leaves this null and
    // keeps relying on the existing tp_level_hit/price-tolerance inference
    // in AnalyticsEngine::computeFourStageRaw(), unchanged.
    $exitClassification = $_POST['exit_classification'] ?? null;
    if (!in_array($exitClassification, ['breakeven', 'managed_exit'], true)) $exitClassification = null;
    $declaredRMultipleRaw = $_POST['declared_r_multiple'] ?? null;
    $declaredRMultiple = null;

    $trade = $db->selectById('trades', $tradeId);

    // Validation Workflow (spec §7): "Keep the existing Draft lifecycle...
    // do not create a second validation Draft engine." This handler is the
    // app's SOLE lifecycle mutator (see CLAUDE.md's Lifecycle Rule) —
    // Drafts' Won/Lost form always posts here regardless of a trade's
    // is_validation flag (public/drafts.php's resolve_trade_id form has no
    // separate validation branch). A prior exclusion here
    // ("if (!empty($trade['is_validation'])) { $trade = null; }") would have
    // made every validation trade permanently unresolvable — there is no
    // other resolution path anywhere in the codebase for it to defer to —
    // which would have silently broken the entire feature (validation
    // requires accumulating CLOSED trades). Removed; validation trades now
    // resolve through the exact same breach/pass/balance math as any other
    // trade on the same (universal, shared) account — only the ceiling
    // check immediately below is validation-specific.
    if ($trade && !empty($trade['is_validation']) && !empty($trade['strategy_version_id'])) {
        // Server-side 100-trade ceiling (spec §32: "the cap must be enforced
        // server-side... through either UI or API"). Checked against the
        // real, current closed-trade count for this trade's own strategy —
        // never trusts anything client-supplied. Rejects the WHOLE
        // resolution (nothing written, trade stays draft/open) if this
        // strategy has already reached the cap — same reject-never-clamp
        // pattern already established by this handler's own Managed Exit
        // validation just below.
        $validationProgress = ValidationService::getProgress($userId, $trade['strategy_version_id'], $db);
        if ($validationProgress['at_cap']) {
            $trade = null;
            $resolveError = 'This validation strategy has already reached the ' . ValidationService::MAX_TRADES . '-trade validation ceiling. Enter Main Workflow with it from the Validation Tracker before resolving any further trades.';
        }
    }

    if ($trade && in_array($trade['status'], ['draft', 'open'])) {
        // Deterministic Break-Even override — computed here, server-side,
        // from the trade's own just-submitted actual prices, and applied
        // LAST, unconditionally, regardless of whatever exit_classification
        // the client sent (including a 'managed_exit' selection made before
        // the trader moved their stop to entry). This is the real,
        // authoritative rule: no dropdown selection can ever override it,
        // because it's derived from the trader's own confirmed data, not a
        // client-side claim.
        $entryNum = is_numeric($actualEntry) ? (float)$actualEntry : null;
        $slNum = is_numeric($actualSl) ? (float)$actualSl : null;
        if ($entryNum !== null && $slNum !== null && abs($entryNum) > 0.0000001
            && abs($slNum - $entryNum) <= abs($entryNum) * 0.0005) {
            $exitClassification = 'breakeven';
        }

        // Managed Exit validation — this handler's first-ever numeric
        // validation. Per explicit data-integrity instruction: an
        // out-of-bounds value REJECTS the entire resolution (nothing is
        // written, the trade stays draft/open, every entered value is
        // preserved for the trader to correct) — it is never clamped,
        // capped, or silently corrected. The planned Risk:Reward is
        // re-fetched from the trade's own row (never trusted from the
        // client) so a tampered request can't bypass this.
        if ($exitClassification === 'managed_exit') {
            $plannedRr = isset($trade['risk_reward_tp1']) && $trade['risk_reward_tp1'] !== null ? (float)$trade['risk_reward_tp1'] : null;
            $submittedR = is_numeric($declaredRMultipleRaw) ? (float)$declaredRMultipleRaw : null;
            if ($plannedRr === null || $plannedRr <= 0) {
                $resolveError = 'Invalid Managed Exit: this trade has no planned Risk:Reward to validate a Managed Exit against. Resolve it normally, or attach a strategy with a declared Risk:Reward first.';
            } elseif ($submittedR === null || $submittedR <= 0) {
                $resolveError = 'Invalid Managed Exit: no valid Managed Exit value was submitted. Please choose a preset value or enter a Custom R below the planned target.';
            } elseif ($submittedR >= $plannedRr) {
                $resolveError = 'Invalid Managed Exit: this trade\'s planned target was 1:' . number_format($plannedRr, 2) . ' (' . number_format($plannedRr, 2) . 'R). You entered ' . number_format($submittedR, 2) . 'R, which ' . ($submittedR == $plannedRr ? 'equals the planned Full TP, not a Managed Exit' : 'exceeds the planned target') . '. Please choose a Managed Exit value below the planned target, or edit the actual trade prices if your trade genuinely exceeded the original plan.';
            } else {
                $declaredRMultiple = round($submittedR, 3);
            }
        }
    }

    if ($trade && in_array($trade['status'], ['draft', 'open']) && $resolveError === null) {
        try {
            // Round 3 audit #11: this whole handler stays on raw $pdo rather
            // than ScopedDb deliberately, not as leftover inconsistency.
            // ScopedDb has no transaction support at all (no begin/commit/
            // rollBack wrapper), no way to express an atomic increment like
            // `current_balance = current_balance + :delta` (its update()
            // only sets literal values), no way to UPDATE by a WHERE column
            // other than a single id/idColumn match (challenge_accounts here
            // is matched on account_id, not its own id), and no stored-
            // procedure CALL support. The reads mixed in here ($db->selectById,
            // $db->raw) are already the right tool for those — see the
            // now-consolidated read-only queries elsewhere in this file for
            // what "the same pattern" looks like where ScopedDb can express it.
            $pdo->beginTransaction();

            // 2026-08-19 concurrency fix (scalability/race-condition pass):
            // re-fetch and LOCK the trade row (SELECT ... FOR UPDATE) here,
            // inside the transaction, and re-validate its status before
            // trusting anything about it. The $trade read at the very top
            // of this handler happens BEFORE the transaction even starts —
            // fine for the cheap early-exit validation above, but NOT safe
            // to build a financial write on: a double-submit of the exact
            // same resolve_trade_id request (double-click, a network retry
            // re-sending the same POST, two browser tabs) would otherwise
            // both pass that early check, both compute their own P&L delta,
            // and both apply it to trading_accounts.current_balance below —
            // silently double-crediting or double-debiting the account for
            // the SAME trade. FOR UPDATE on this single, already-indexed
            // primary-key row makes a second concurrent request for the
            // SAME trade_id block here until the first one commits or rolls
            // back; when it unblocks, this fresh read sees the first
            // request's already-applied status='closed' and is rejected by
            // the check below instead of re-applying the trade a second
            // time. Scoped to exactly one row (id = :id) — never blocks a
            // different trade, a different account, or a different user.
            $lockedTradeStmt = $pdo->prepare('SELECT * FROM trades WHERE id = :id AND user_id = :uid FOR UPDATE');
            $lockedTradeStmt->execute([':id' => $tradeId, ':uid' => $userId]);
            $trade = $lockedTradeStmt->fetch(PDO::FETCH_ASSOC);
            if (!$trade || !in_array($trade['status'], ['draft', 'open'], true)) {
                throw new \RuntimeException('This trade has already been resolved. Refresh the page to see its current status.');
            }

            // 2026-08-19 security fix (workflow isolation): scoped by
            // workflow_context in addition to user_id — same rule already
            // enforced on account creation/deposit/withdrawal/close and now
            // also on trade creation/validation (api/trades/draft.php,
            // validate.php). This is the third and last of the three gaps
            // found in the trade create->validate->resolve pipeline during
            // the pre-launch security audit; a real live check confirmed
            // zero currently-open/draft trades are affected by tightening
            // this (i.e. this closes the gap without blocking any real
            // in-flight trade).
            //
            // 2026-08-19 concurrency fix: also FOR UPDATE, same reasoning as
            // the trade row above but for a different race — two DIFFERENT
            // open trades on the SAME account being resolved concurrently.
            // Without a lock here, both requests could read this account's
            // pre-trade balance before either has written, both independently
            // decide (via AnalyticsEngine::getChallengeMetrics() below, which
            // uses its own separate PDO connection and therefore wouldn't
            // otherwise see this transaction's in-flight state at all) that
            // their own trade alone doesn't cross the breach/target boundary,
            // and both commit — when combined, the account may really have
            // crossed it. Locking this row forces the second request to wait
            // until the first commits, so its own subsequent
            // getChallengeMetrics() call (a fresh, unlocked read on its own
            // connection, executed only after the wait) correctly sees the
            // first trade's already-committed effect on the account.
            $resolveWorkflowState = ValidationService::getWorkflowState($userId, $db);
            $account = null;
            if ($trade['account_id']) {
                $acctRows = $db->raw(
                    "SELECT * FROM trading_accounts WHERE id = :id AND user_id = :uid AND workflow_context = :wf LIMIT 1 FOR UPDATE",
                    [':id' => $trade['account_id'], ':uid' => $userId, ':wf' => $resolveWorkflowState]
                );
                $account = $acctRows[0] ?? null;
            }
            $isPropFirm = $account && $account['account_type'] === 'prop_firm';

            // ── P&L Rule: prop-firm accounts must use challenge_pnl (capped
            // at the breach/profit-target boundary), never uncapped
            // broker_pnl, for anything downstream (balance, classification,
            // analytics). challenge_pnl is broker_pnl capped at whichever
            // boundary this trade's uncapped P&L would cross — using the
            // exact breach_level / target_profit_amount already computed by
            // AnalyticsEngine::getChallengeMetrics() (reused, not
            // reimplemented), evaluated against the account's balance
            // immediately BEFORE this trade (i.e. before either UPDATE
            // below runs). Personal accounts are untouched: broker_pnl only,
            // challenge_pnl/excess_pnl stay NULL, balance moves by the full
            // uncapped amount, exactly as before.
            $challengePnl = null;
            $excessPnl = null;
            $balanceDelta = $actualPnl;

            // 2026-08-19 security fix (pre-launch audit finding C-1,
            // CRITICAL): these two flags are the server's OWN, independent
            // determination of whether this trade's real numbers actually
            // crossed the breach or profit-target boundary — computed from
            // the exact same AnalyticsEngine::getChallengeMetrics() values
            // already used two lines below for the challenge_pnl capping
            // math, never from anything the client submitted. Previously,
            // the lifecycle-state transition further down this handler
            // (lifecycle_state='breached' / '_complete', passed_at,
            // breached_at) was driven ENTIRELY by the client-supplied
            // $accountStatus value — a crafted request could submit
            // account_status=passed_phase on a losing trade and the server
            // would mark the phase "Passed" (confirmed live, chained all
            // the way to a fraudulent lifecycle_state='funded_active' on an
            // account with a net loss), or submit account_status=
            // still_active on a trade that mathematically breached the
            // account (challenge_pnl was already being correctly capped at
            // the breach line in the same request) and the account would
            // never be marked breached at all. $mathConfirmsPass gates the
            // '_complete'/passed_at transition below: a passed_phase claim
            // that isn't backed by this trade's own math is now a silent
            // no-op (the trade still closes normally — this is not a
            // trade-resolution error, since the real Drafts UI already
            // computes and enforces this exact math client-side before
            // ever letting a trader click "Passed", so a legitimate user
            // can never actually trigger this path; only a bypassed/
            // crafted request can). $mathConfirmsBreach OR-combines with
            // $accountStatus==='breached' below (never AND) — a trader can
            // still self-report a breach for a reason the P&L math alone
            // can't see (e.g. a consistency-rule or news-trading
            // violation, both real free-text reasons already recorded in
            // this app's historical breach data), but a real, math-
            // confirmed breach can no longer be silently avoided by simply
            // not confirming it.
            $mathConfirmsBreach = false;
            $mathConfirmsPass = false;

            if ($isPropFirm) {
                $metrics = AnalyticsEngine::getChallengeMetrics($trade['account_id']);

                if (!isset($metrics['error'])) {
                    // ROOT-CAUSE FIX (2026-08-07) — $balanceBeforeTrade used
                    // to read $account['current_balance']
                    // (trading_accounts.current_balance), a whole-account
                    // ledger that never resets at a phase promotion. For an
                    // account still mid-evaluation, that let carried-over
                    // profit from an earlier phase inflate the "before"
                    // balance this capping math compares against $breachLevel/
                    // $targetBalance (both correctly anchored to THIS
                    // phase's own fresh starting_balance) — silently giving
                    // the trader far more breach buffer, and firing "phase
                    // passed" far short of the real configured target.
                    // getChallengeMetrics()'s own 'phase_balance' field is
                    // now the single, phase-reset-aware source of truth for
                    // this exact comparison (see its own doc comment) —
                    // read from $metrics, not $account, to guarantee this
                    // stays in lockstep with what Drafts/Journal/Calculator
                    // already display for the same account.
                    $balanceBeforeTrade = (float)($metrics['phase_balance'] ?? $account['current_balance']);
                    $startingBalance = (float)($metrics['account_metrics']['starting_balance'] ?? $account['starting_balance']);
                    $breachLevel     = (float)$metrics['overall_drawdown']['breach_level_amount'];
                    $profitNeeded    = (float)$metrics['distance_to_pass']['target_profit_amount'];
                    $targetBalance   = $startingBalance + $profitNeeded;
                    $balanceAfterTrade = $balanceBeforeTrade + $actualPnl;

                    if ($actualPnl < 0 && $balanceAfterTrade < $breachLevel) {
                        // Cap the loss at exactly what it takes to reach the
                        // breach line from where the account stood before
                        // this trade; min(0, ...) so an account that was
                        // already at/past the line before this trade counts
                        // no further capped loss (all of it is excess).
                        $challengePnl = min(0.0, round($breachLevel - $balanceBeforeTrade, 2));
                    } elseif ($actualPnl > 0 && $profitNeeded > 0 && $balanceAfterTrade > $targetBalance) {
                        // Same idea on the pass side: cap the counted profit
                        // at exactly reaching the target balance.
                        $challengePnl = max(0.0, round($targetBalance - $balanceBeforeTrade, 2));
                    } else {
                        $challengePnl = round($actualPnl, 2);
                    }
                    $excessPnl = round($actualPnl - $challengePnl, 2);

                    // Independent server-side boundary determination (see
                    // the block comment above) — reuses these exact same
                    // values, computed once, never re-derived from client
                    // input. A breach is confirmed once the account's real
                    // balance is AT OR PAST the breach line (<=, not <,
                    // since exactly on the line is already a breach — the
                    // capping math above uses a strict < for which branch
                    // applies the cap, but the boundary itself is
                    // inclusive); a pass is confirmed once balance is AT OR
                    // PAST the target.
                    $mathConfirmsBreach = ($actualPnl < 0 && $balanceAfterTrade <= $breachLevel);
                    $mathConfirmsPass = ($actualPnl > 0 && $profitNeeded > 0 && $balanceAfterTrade >= $targetBalance);
                } else {
                    // No challenge_accounts row / metrics unavailable for
                    // this account — cannot cap without a boundary to cap
                    // against. Resolve uncapped rather than blocking the
                    // trader from closing the trade at all.
                    $challengePnl = round($actualPnl, 2);
                    $excessPnl = 0.0;
                }
                $balanceDelta = $challengePnl;
            }

            // Historical FX capture — Account Currency / FX Single Source
            // of Truth (2026-08-11), §7. Written ONCE, here, at the sole
            // lifecycle mutator, exactly like every other field this
            // handler finalizes on close. Only populated for a non-USD
            // account (NULL for every USD account — the default, and
            // every real account in this database today). Captures the
            // USD↔account-currency rate AT THE MOMENT this trade closed,
            // via the one centralized CurrencyService, so a future report
            // can reconstruct "what this trade was worth" using the rate
            // that was actually in effect then — never today's rate
            // silently reinterpreting an old trade (see CLAUDE.md's
            // Currency/FX Domain section).
            $fxRateToAccountCurrency = null;
            $fxRatePair = null;
            $fxRateCapturedAt = null;
            $fxRateSource = null;
            $fxAccountCurrency = $account ? strtoupper($account['currency'] ?? 'USD') : 'USD';
            if ($fxAccountCurrency !== 'USD') {
                $fxRateInfo = CurrencyService::getRate('USD', $fxAccountCurrency);
                if ($fxRateInfo !== null) {
                    $fxRateToAccountCurrency = $fxRateInfo['rate'];
                    $fxRatePair = 'USD/' . $fxAccountCurrency;
                    $fxRateCapturedAt = date('Y-m-d H:i:s');
                    $fxRateSource = $fxRateInfo['source'];
                }
            }

            $stmt = $pdo->prepare("UPDATE trades SET
                status = 'closed',
                closed_at = NOW(),
                broker_pnl = :pnl,
                challenge_pnl = :challenge_pnl,
                excess_pnl = :excess_pnl,
                actual_entry_price = :actual_entry,
                actual_exit_price = :actual_exit,
                actual_sl_price = :actual_sl,
                actual_lots = :actual_lots,
                actual_instrument = :actual_instrument,
                actual_timeframe = :actual_timeframe,
                exit_classification = :exit_classification,
                declared_r_multiple = :declared_r_multiple,
                fx_rate_to_account_currency = :fx_rate,
                fx_rate_pair = :fx_pair,
                fx_rate_captured_at = :fx_captured_at,
                fx_rate_source = :fx_source,
                is_manually_edited = 1
                WHERE id = :id AND user_id = :uid");

            $stmt->execute([
                ':pnl' => $actualPnl,
                ':challenge_pnl' => $challengePnl,
                ':excess_pnl' => $excessPnl,
                ':actual_entry' => $actualEntry,
                ':actual_exit' => $actualExit,
                ':actual_sl' => $actualSl,
                ':actual_lots' => $actualLots,
                ':actual_instrument' => $actualInstrument,
                ':actual_timeframe' => $actualTimeframe,
                ':exit_classification' => $exitClassification,
                ':declared_r_multiple' => $declaredRMultiple,
                ':fx_rate' => $fxRateToAccountCurrency,
                ':fx_pair' => $fxRatePair,
                ':fx_captured_at' => $fxRateCapturedAt,
                ':fx_source' => $fxRateSource,
                ':id' => $tradeId,
                ':uid' => $userId
            ]);

            if ($trade['account_id']) {
                $stmt = $pdo->prepare("UPDATE trading_accounts SET current_balance = current_balance + :delta, updated_at = NOW() WHERE id = :aid AND user_id = :uid");
                $stmt->execute([':delta' => $balanceDelta, ':aid' => $trade['account_id'], ':uid' => $userId]);
            }

            // ── Lifecycle Rule: this IS the sole lifecycle mutator — but
            // until now it never actually read account_status/breach_reason,
            // so a trader confirming "Breached" or "Passed Phase" in Drafts
            // had no effect beyond closing the trade. Prop-firm only,
            // matching Drafts only showing this step for prop accounts.
            if ($isPropFirm && $trade['account_id']) {
                // 2026-08-19 security fix (finding C-1): $accountStatus
                // alone is never trusted as sole authority any more —
                // 'breached' OR-combines with the server's own
                // $mathConfirmsBreach (a real, math-confirmed breach can no
                // longer be avoided by submitting 'still_active'/anything
                // else); 'passed_phase' is gated below on $mathConfirmsPass
                // actually being true (a false claim on a losing/
                // insufficient trade is now a silent no-op instead of a
                // trusted state transition). See the block comment above
                // $mathConfirmsBreach's declaration for the full incident.
                if ($accountStatus === 'breached' || $mathConfirmsBreach) {
                    $reasonText = $breachReason !== ''
                        ? $breachReason
                        : ($mathConfirmsBreach && $accountStatus !== 'breached'
                            ? 'AUTO_DETECTED_DRAWDOWN_BREACH'
                            : 'OTHER');

                    $stmt = $pdo->prepare("UPDATE trading_accounts SET
                        lifecycle_state = 'breached',
                        boundary_event = 'BREACH',
                        boundary_occurred_at = NOW(),
                        current_outcome = :reason,
                        updated_at = NOW()
                        WHERE id = :aid AND user_id = :uid");
                    $stmt->execute([':reason' => $reasonText, ':aid' => $trade['account_id'], ':uid' => $userId]);

                    $stmt = $pdo->prepare("UPDATE challenge_accounts SET
                        lifecycle_state = 'breached',
                        current_outcome = :reason,
                        boundary_event = 'BREACH',
                        breached_at = NOW(),
                        updated_at = NOW()
                        WHERE account_id = :aid AND user_id = :uid");
                    $stmt->execute([':reason' => $reasonText, ':aid' => $trade['account_id'], ':uid' => $userId]);

                    $stmt = $pdo->prepare("INSERT INTO lifecycle_events (id, account_id, account_type, event_type, event_metadata, occurred_at)
                        VALUES (:id, :aid, 'prop_firm', 'ACCOUNT_BREACHED', :meta, NOW())");
                    $stmt->execute([
                        ':id' => ScopedDb::uuid(),
                        ':aid' => $trade['account_id'],
                        ':meta' => json_encode(['reason' => $reasonText, 'trade_id' => $tradeId]),
                    ]);

                } elseif ($accountStatus === 'passed_phase') {
                    // ROOT-CAUSE FIX (2026-08-08) — this branch used to read
                    // challenge_accounts.current_stage directly, the exact
                    // stale-column-trust pattern already fixed 125 lines
                    // earlier in this SAME handler for the challenge_pnl
                    // capping math, and already fixed in
                    // AnalyticsEngine::getChallengeMetrics() itself
                    // (2026-08-07). Confirmed live and real, not
                    // theoretical: two genuinely funded accounts
                    // (lifecycle_state='funded_active', current_stage
                    // stuck at 'phase_1') would have had this branch WRITE
                    // lifecycle_state='phase_1_complete' — an active
                    // regression backward from Funded, not a silent no-op
                    // — the moment a trader confirmed "Passed Phase" on
                    // either of them. A third account
                    // (lifecycle_state='phase_2_active', current_stage
                    // stuck at 'phase_1') would have had the wrong
                    // '_complete' phase written entirely. $metrics is
                    // already computed above (via
                    // AnalyticsEngine::getChallengeMetrics(), the same
                    // call that already correctly powers this handler's
                    // challenge_pnl capping) and its current_stage field
                    // derives from trading_accounts.lifecycle_state first —
                    // reused here rather than re-queried, guaranteeing this
                    // branch can never see a different phase than the
                    // capping math just above it saw for the identical
                    // request. Confirmed byte-identical to
                    // PropFirmRuleEngine::compute()'s own
                    // lifecycle_state-derived phase determination via the
                    // 84/84-account golden-value regression
                    // (scripts/verify_prop_firm_rule_engine.php).
                    $currentStage = !isset($metrics['error']) ? ($metrics['current_stage'] ?? null) : null;
                    // Passing a phase closes THIS attempt out as complete —
                    // per Journal's own confirmed directive text ("Promote
                    // to the next phase via Add Account"), promotion to the
                    // next phase is a separate, already-existing action, not
                    // something trade resolution does implicitly. "funded"
                    // has no "_complete" state (there's no further phase to
                    // close out), so passed_phase is a no-op there.
                    $stageCompleteMap = [
                        'phase_1' => 'phase_1_complete', 'evaluation'   => 'phase_1_complete',
                        'phase_2' => 'phase_2_complete', 'verification' => 'phase_2_complete',
                        'phase_3' => 'phase_3_complete',
                    ];
                    // 2026-08-19 security fix (finding C-1): $newState is
                    // only ever actually applied when $mathConfirmsPass is
                    // true — i.e. when THIS resolution's own independently-
                    // computed balance genuinely reached the phase's real
                    // profit target. Previously this branch trusted
                    // $accountStatus==='passed_phase' unconditionally,
                    // which was confirmed live-exploitable: a losing trade
                    // resolved with account_status=passed_phase flipped
                    // lifecycle_state to '_complete' regardless of the real
                    // balance, and chained through the real promotion
                    // endpoint all the way to a fraudulent 'funded_active'
                    // account with a net trading loss. The trade itself
                    // still closes normally either way — only the
                    // lifecycle-state claim is now independently verified.
                    $newState = ($mathConfirmsPass) ? ($stageCompleteMap[$currentStage] ?? null) : null;

                    if ($newState !== null) {
                        $stmt = $pdo->prepare("UPDATE trading_accounts SET lifecycle_state = :state, updated_at = NOW() WHERE id = :aid AND user_id = :uid");
                        $stmt->execute([':state' => $newState, ':aid' => $trade['account_id'], ':uid' => $userId]);

                        $stmt = $pdo->prepare("UPDATE challenge_accounts SET lifecycle_state = :state, passed_at = NOW(), updated_at = NOW() WHERE account_id = :aid AND user_id = :uid");
                        $stmt->execute([':state' => $newState, ':aid' => $trade['account_id'], ':uid' => $userId]);

                        $stmt = $pdo->prepare("INSERT INTO lifecycle_events (id, account_id, account_type, event_type, event_metadata, occurred_at)
                            VALUES (:id, :aid, 'prop_firm', :etype, :meta, NOW())");
                        $stmt->execute([
                            ':id' => ScopedDb::uuid(),
                            ':aid' => $trade['account_id'],
                            ':etype' => strtoupper($currentStage) . '_COMPLETED',
                            ':meta' => json_encode(['trade_id' => $tradeId]),
                        ]);
                    }
                }
            }

            // sp_classify_trade (the old stored procedure) is retired as
            // of 2026-08-18 — trade_classification now follows Active
            // Coaching's real tier-recommended risk %, via
            // TradeClassificationEngine, instead of the old "any loss in
            // the last 10 closed trades" rule. Same PDO/transaction as
            // every other write above, so it sees this trade's just-
            // written (uncommitted) challenge_pnl/status — see
            // TradeClassificationEngine's own header comment on why a
            // second connection would not.
            TradeClassificationEngine::classifyOneTrade($tradeId, $pdo);

            $pdo->commit();

            $resolved = true;
        } catch (\Throwable $e) {
            if ($pdo->inTransaction()) { $pdo->rollBack(); }
            $resolveError = $e->getMessage();
        }
    }
}

// Only fetch performance data for non-free users
$totalTrades = 0;
$wins = 0;
$losses = 0;
$breakevens = 0;
$totalPnl = 0;
$winRate = null;
$avgRiskPct = null;
$bestTrade = null;
$worstTrade = null;
$sessionRows = [];
$instrumentRows = [];
$adherence = null;
$fsSummary = null;
$expectancy = null;
$profitFactor = null;
$equityEfficiency = null;
$riskOfRuin = null;
$maxConsecWins = 0;
$maxConsecLosses = 0;
$dailyLossState = null;
$drawdownState = null;
$alerts = [];
$recentTrades = [];
$totalTradesCount = 0;   // uncapped total of closed trades in current scope — drives header + Show All button
$tradesByDate = [];
$dailySummaries = [];

// How many trade rows to pull from the DB for client-side "Show All / Show Less".
// Must be high enough to cover realistic trade history; raise further if needed.
const DASHBOARD_TRADES_FETCH_LIMIT = 500;
// How many trade rows are shown by default before "Show All" is needed.
const DASHBOARD_TRADES_DEFAULT_VISIBLE = 20;

// Shared P&L expression — prop-firm accounts must be measured against
// challenge_pnl (capped at the breach/pass boundary), never uncapped
// broker_pnl; personal accounts keep broker_pnl. Same expression/fallback
// chain as reports.php's $pnlExpr, so the two pages can't drift apart on
// which P&L a trade card shows. Used by both branches below (strategy
// selected / no strategy selected).
//
// Fixed: the inner CASE used to go straight to challenge_pnl (or NULL)
// for prop-firm trades with no broker_pnl/projected_pnl fallback of its
// own — only the outer COALESCE's single projected_pnl step covered it.
// Any prop-firm trade with challenge_pnl NULL but a real broker_pnl (never
// backfilled — see CLAUDE.md) silently fell through to projected_pnl
// instead of its real broker_pnl. Now matches the two places that already
// had this right: the mv_* views' COALESCE(challenge_pnl, broker_pnl,
// projected_pnl, 0) and this file's own dashEffectivePnl() PHP helper
// (challenge_pnl ?? broker_pnl ?? projected_pnl ?? 0) — both of which keep
// the account_type branch rather than cascading challenge_pnl into the
// personal-account path (challenge_pnl should never apply there).
$dashPnlExpr = "CASE WHEN ta.account_type = 'prop_firm' THEN COALESCE(t.challenge_pnl, t.broker_pnl, t.projected_pnl, 0) ELSE COALESCE(t.broker_pnl, t.projected_pnl, 0) END";
$dashJoin = "trades t LEFT JOIN trading_accounts ta ON ta.id = t.account_id";

// ── Account Currency / FX Single Source of Truth (2026-08-11) ─────────
// $dashPnlExpr's SUM/MAX/MIN below are trade-currency-agnostic — a real
// bug once a user's accounts don't all share one currency, since a trade
// under a KES account and a trade under a USD account would be summed as
// if they were the same unit. $dashCurrency is this page's one resolved
// display currency (the user's own user_profiles.base_currency, same
// value AnalyticsEngine::getUserMetrics() resolves to internally — see
// CLAUDE.md's Currency/FX Domain section). The headline Total/Best/Worst
// P&L figures now come straight from getUserMetrics() (2026-08-12
// Dashboard cleanup — see the metrics blocks below), which already
// converts each trade's own account currency into the user's base
// currency before summing; the secondary session/instrument P&L
// breakdowns further down this file are NOT yet converted — flagged, not
// silently left looking complete, since retrofitting every
// SUM($dashPnlExpr) in this file is a larger, separate pass (see
// CLAUDE.md).
$dashCurrency = AnalyticsEngine::getUserBaseCurrency($userId);
$dashCurrencySymbol = CurrencyService::symbol($dashCurrency);

// ── Prop Firm / Live trade counts — feeds the account-type toggle's own
// count badges and the "switch to the other type" empty state below.
// Deliberately scoped to the selected strategy (when one's selected) but
// NOT to the Instrument/Session/Classification/From/To filters above —
// this is a stable navigational hint ("which side has data at all"), not
// a live-filtered figure, so switching the toggle never feels like it
// silently reset the trader's other filters.
$dashAccountTypeCounts = ['prop_firm' => 0, 'personal' => 0];
if ($tier !== 'free') {
    try {
        $tcSvidClause = $filterVersionId ? ' AND t.strategy_version_id = :svid' : '';
        $tcParams = [':uid' => $userId];
        if ($filterVersionId) $tcParams[':svid'] = $filterVersionId;
        $tcRows = $db->raw("SELECT ta.account_type AS at, COUNT(*) AS n FROM trades t LEFT JOIN trading_accounts ta ON ta.id = t.account_id WHERE t.user_id = :uid AND {$dashValidationCondT} AND t.status = 'closed' AND t.deleted_at IS NULL{$tcSvidClause} GROUP BY ta.account_type", $tcParams);
        foreach ($tcRows as $r) { if (isset($dashAccountTypeCounts[$r['at']])) $dashAccountTypeCounts[$r['at']] = (int)$r['n']; }
    } catch (\Throwable $e) {}
}
$dashOtherAccountView = $dashAccountView === 'live' ? 'prop' : 'live';
$dashOtherAccountTypeDb = $dashAccountView === 'live' ? 'prop_firm' : 'personal';

if ($tier !== 'free') {

    if ($filterVersionId) {

        // ── Headline metrics — all from ONE canonical call ──────────────
        // Was this file's own hand-written raw SQL aggregate (COUNT/SUM/
        // MAX/MIN over $dashPnlExpr) duplicating exactly what
        // AnalyticsEngine::getUserMetrics() already computes (win/loss/
        // breakeven counts, net P&L, best/worst trade, win rate, win/loss
        // streaks — the same canonical computeMetricsFromTrades() every
        // other page's metrics go through), plus a second, separately
        // hand-written currency-aware re-aggregation
        // (dashCurrencyAwarePnl()) duplicating that same total/best/worst
        // a third way. getUserMetrics() already converts every trade into
        // the user's base currency internally (see its own doc comment),
        // so this single call replaces all of it — one source of truth,
        // and it's the one that now actually respects the Instrument/
        // Session/Classification/From/To filters below (via
        // $dashUmFilters, threaded into buildFilterClause()'s new filter
        // keys).
        $umFilters = array_merge($dashUmFilters, ['strategyVersionId' => $filterVersionId]);
        $um = AnalyticsEngine::getUserMetrics($userId, $umFilters);

        $totalTrades  = (int)($um['total_trades'] ?? 0);
        $wins         = (int)($um['win_count'] ?? 0);
        $losses       = (int)($um['loss_count'] ?? 0);
        $breakevens   = (int)($um['breakeven_count'] ?? 0);
        $winRate      = $totalTrades > 0 ? round(((float)($um['win_rate'] ?? 0)) * 100, 1) : null;
        $totalPnl     = round((float)($um['net_pnl'] ?? 0), 2);
        $bestTrade    = $um['best_trade']  !== null ? round((float)$um['best_trade'],  2) : null;
        $worstTrade   = $um['worst_trade'] !== null ? round((float)$um['worst_trade'], 2) : null;
        $expectancy   = $um['expectancy'] !== null ? round((float)$um['expectancy'], 2) : null;
        $profitFactor = $um['profit_factor'] !== null ? round((float)$um['profit_factor'], 2) : null;
        $maxConsecWins   = (int)($um['winning_streak'] ?? 0);
        $maxConsecLosses = (int)($um['losing_streak'] ?? 0);

        // Avg Risk — not part of getUserMetrics()'s return shape; one
        // small supporting aggregate, same filter scope as everything else.
        try {
            $arRows = $db->raw("
                SELECT AVG(t.risk_pct) AS avg_risk_pct
                FROM {$dashJoin}
                WHERE t.user_id = :uid AND {$dashValidationCondT} AND t.status = 'closed' AND t.deleted_at IS NULL AND t.strategy_version_id = :svid {$dashExtraWhere}
            ", array_merge([':uid' => $userId, ':svid' => $filterVersionId], $dashExtraParams));
            $avgRiskPct = !empty($arRows[0]['avg_risk_pct']) ? round((float)$arRows[0]['avg_risk_pct'], 2) : null;
        } catch (\Throwable $e) { $avgRiskPct = null; }

        try {
            $sessionRows = $db->raw("
                SELECT t.session AS session, COUNT(*) AS trades,
                    SUM(CASE WHEN {$dashPnlExpr} > 0 THEN 1 ELSE 0 END) AS wins,
                    SUM({$dashPnlExpr}) AS total_pnl
                FROM {$dashJoin}
                WHERE t.user_id = :uid AND {$dashValidationCondT} AND t.status = 'closed' AND t.deleted_at IS NULL AND t.strategy_version_id = :svid AND t.session IS NOT NULL {$dashExtraWhere}
                GROUP BY t.session ORDER BY trades DESC
            ", array_merge([':uid' => $userId, ':svid' => $filterVersionId], $dashExtraParams));
        } catch (\Throwable $e) { $sessionRows = []; }

        try {
            $instrumentRows = $db->raw("
                SELECT t.instrument AS instrument, COUNT(*) AS trades,
                    SUM(CASE WHEN {$dashPnlExpr} > 0 THEN 1 ELSE 0 END) AS wins,
                    SUM({$dashPnlExpr}) AS total_pnl
                FROM {$dashJoin}
                WHERE t.user_id = :uid AND {$dashValidationCondT} AND t.status = 'closed' AND t.deleted_at IS NULL AND t.strategy_version_id = :svid {$dashExtraWhere}
                GROUP BY t.instrument ORDER BY trades DESC LIMIT 8
            ", array_merge([':uid' => $userId, ':svid' => $filterVersionId], $dashExtraParams));
        } catch (\Throwable $e) { $instrumentRows = []; }

        try {
            // Kept solely for pnl_off_declared_instruments (a $-P&L
            // attribution metric with no equivalent anywhere in
            // AnalyticsEngine, used by the off-strategy-drift alert below)
            // — genuinely distinct from the rule-adherence figures the new
            // Strategy Performance card reads from $fsSummary. Known,
            // disclosed limitation: this view is grouped by (user_id,
            // strategy_version_id) only — no account_id column — so it
            // can't be scoped by the Prop Firm/Live toggle the way every
            // other query on this page now is; the resulting
            // off_strategy_drift alert below reflects both account types
            // blended together regardless of which one is selected.
            $adherenceRows = $db->raw(
                "SELECT * FROM mv_strategy_adherence WHERE user_id = :uid AND strategy_version_id = :svid",
                [':uid' => $userId, ':svid' => $filterVersionId]
            );
            $adherence = !empty($adherenceRows) ? $adherenceRows[0] : null;
        } catch (\Throwable $e) { $adherence = null; }

        // ── Strategy Performance (rule-adherence $ split) ───────────────
        // Sourced from AnalyticsEngine::getFourStagePeriodSummary()'s new
        // rule_adherence bucket — the same real per-trade adherence signal
        // (stage1.risk_overage, contract_constrained exclusion) this
        // engine already used for strategy_adherence_pct, just bucketed by
        // $ instead of averaged into a percentage. Scoped to exactly the
        // filters selected above (period = $dashClosedFromDt..$dashClosedToDt,
        // instrument/session/classification via $dashExtraWhere) and to
        // the active workflow's own trades.
        try {
            $fsSummary = AnalyticsEngine::getFourStagePeriodSummary(
                $userId, 'all', $dashClosedFromDt, $dashClosedToDt,
                ' AND t.strategy_version_id = :svid' . $dashExtraWhere,
                array_merge([':svid' => $filterVersionId], $dashExtraParams),
                null, $workflowState === 'validation' ? 'only' : 'exclude'
            );
        } catch (\Throwable $e) { $fsSummary = null; }

        if ($totalTrades > 0) {
            // Equity Efficiency — depends on gross_loss, a figure
            // getUserMetrics() doesn't expose; a minimal, separately-scoped
            // query for just that one number, same filter scope as above.
            try {
                $glRows = $db->raw("
                    SELECT SUM(CASE WHEN {$dashPnlExpr} < 0 THEN -{$dashPnlExpr} ELSE 0 END) AS gross_loss
                    FROM {$dashJoin}
                    WHERE t.user_id = :uid AND {$dashValidationCondT} AND t.status = 'closed' AND t.deleted_at IS NULL AND t.strategy_version_id = :svid {$dashExtraWhere}
                ", array_merge([':uid' => $userId, ':svid' => $filterVersionId], $dashExtraParams));
                $grossLoss = (float)($glRows[0]['gross_loss'] ?? 0);
                $equityEfficiency = ($grossLoss > 0 && $totalPnl + $grossLoss > 0)
                    ? round($totalPnl / ($totalPnl + $grossLoss), 2)
                    : null;
            } catch (\Throwable $e) {}

            // Risk of Ruin — same real risk%-scaled formula every other
            // page uses (EdgeHealthEngine's), scoped to the same filtered
            // period/strategy this whole page is now scoped to.
            try {
                if ($totalTrades >= 20) {
                    $riskOfRuin = EdgeHealthEngine::getPeriodRiskOfRuin(
                        $userId, 'all', $dashClosedFromDt, $dashClosedToDt,
                        ' AND t.strategy_version_id = :svid' . $dashExtraWhere,
                        array_merge([':svid' => $filterVersionId], $dashExtraParams),
                        null, $workflowState === 'validation' ? 'only' : 'exclude'
                    );
                }
            } catch (\Throwable $e) {}
        }

        $accounts = $db->select('trading_accounts', 'is_active = 1 AND deleted_at IS NULL', [], '*', 'created_at DESC', 1);
        $account  = $accounts[0] ?? null;
        if ($account) {
            try { $dls = $db->raw("SELECT fn_current_daily_loss_state(:aid) AS state", [':aid' => $account['id']]); $dailyLossState = $dls[0]['state'] ?? null; } catch (\Throwable $e) {}
            try { $dds = $db->raw("SELECT fn_current_drawdown_state(:aid) AS state", [':aid' => $account['id']]); $drawdownState = $dds[0]['state'] ?? null; } catch (\Throwable $e) {}
        }

        // Alerts read the trader's real most-recent behavior (last 5
        // trades / last 24h / today), independent of whatever period/
        // instrument filters are currently selected above — deliberately
        // unfiltered. See dashBuildBehaviorAlerts()'s own doc comment for
        // the 4 pattern definitions.
        $alerts = [];
        if ($totalTrades >= 3) {
            $alerts = dashBuildBehaviorAlerts($db, $userId, $dashValidationCond, $filterVersionId);
            if ($adherence) {
                $offInstPnl = round((float)($adherence['pnl_off_declared_instruments'] ?? 0), 2);
                if ($offInstPnl < 0) $alerts[] = ['type' => 'off_strategy_drift', 'msg' => "Trading instruments outside your declared strategy has resulted in a loss of \$" . abs($offInstPnl) . " over this period."];
            }
        }

        // ── Recent trades: fetch up to DASHBOARD_TRADES_FETCH_LIMIT rows so client-side
        //    "Show All / Show Less" has real data to reveal. The 20-row default view is
        //    enforced in HTML/CSS/JS only — never by the SQL LIMIT. Same
        //    Instrument/Session/Classification/From/To filters as every metric above.
        try {
            $recentTrades = $db->raw("SELECT t.*, ta.account_type, ta.currency FROM {$dashJoin} WHERE t.user_id = :uid AND {$dashValidationCondT} AND t.status = 'closed' AND t.deleted_at IS NULL AND t.strategy_version_id = :svid {$dashExtraWhere} ORDER BY t.closed_at DESC LIMIT " . DASHBOARD_TRADES_FETCH_LIMIT, array_merge([':uid' => $userId, ':svid' => $filterVersionId], $dashExtraParams));
        } catch (\Throwable $e) { $recentTrades = []; }

        // ── Uncapped count of ALL closed trades in this scope — independent of the
        //    fetch-limit above. This is what drives the header text and the
        //    "Show All Trades (N)" button label/visibility. Falls back to $totalTrades
        //    (from the stats aggregate) if this query fails, and to the fetched row
        //    count as a last resort, so the header is never wrong because of one
        //    broken query while the other succeeds.
        try {
            $totalTradesCount = (int)$db->count('trades', "{$dashValidationCond} AND status = 'closed' AND deleted_at IS NULL AND strategy_version_id = :svid" . str_replace('t.', '', $dashExtraWhere), array_merge([':svid' => $filterVersionId], $dashExtraParams));
        } catch (\Throwable $e) {
            $totalTradesCount = $totalTrades > 0 ? $totalTrades : count($recentTrades);
        }

        $tradesByDate = [];
        foreach ($recentTrades as $trade) {
            $dateKey = date('Y-m-d', strtotime($trade['closed_at'] ?? $trade['captured_at']));
            $tradesByDate[$dateKey][] = $trade;
        }
        krsort($tradesByDate);

        $dailySummaries = [];
        foreach ($tradesByDate as $dateKey => $dayTrades) {
            $dayPnl = array_sum(array_map('dashEffectivePnl', $dayTrades));
            $dayWins = count(array_filter($dayTrades, fn($t) => dashEffectivePnl($t) > 0));
            $dailySummaries[$dateKey] = ['pnl' => $dayPnl, 'trades' => count($dayTrades), 'wins' => $dayWins];
        }

    } else {
        // Fallback: no strategy selected (only reachable when the user has
        // zero active strategies at all — the top of this file
        // auto-selects the first strategy if any exist).
        //
        // Headline metrics now come from the same canonical
        // AnalyticsEngine::getUserMetrics() call the strategy-selected
        // branch above uses (no strategyVersionId filter this time — every
        // account this user has traded). This retires the old
        // mv_account_performance-based aggregate entirely: that view has
        // no way to apply the Instrument/Session/Classification/From/To
        // filters below, is pre-aggregated per (user, account, phase) —
        // its own past source of a real "2 vs 243 trades" bug, documented
        // history kept below for context — and getUserMetrics() already
        // resolves every account's currency into the user's base currency
        // internally, so the separate currency-aware re-aggregation this
        // branch used to do by hand is no longer needed either.
        //
        // (Historical note, kept for context: mv_account_performance is
        // grouped by (user_id, account_id, phase_record_id), one row per
        // account/phase — a user with more than one trading_accounts row,
        // or more than one phase per account, has many rows here, and an
        // earlier version of this branch that took a single `ORDER BY
        // last_closed_at DESC LIMIT 1` row silently reported one segment's
        // total as the user's whole-account total. That's the exact bug
        // class getUserMetrics() avoids by construction, querying `trades`
        // directly rather than a pre-aggregated view.)
        $um = AnalyticsEngine::getUserMetrics($userId, $dashUmFilters);
        $totalTrades  = (int)($um['total_trades'] ?? 0);
        $wins         = (int)($um['win_count'] ?? 0);
        $losses       = (int)($um['loss_count'] ?? 0);
        $breakevens   = (int)($um['breakeven_count'] ?? 0);
        $winRate      = $totalTrades > 0 ? round(((float)($um['win_rate'] ?? 0)) * 100, 1) : null;
        $totalPnl     = round((float)($um['net_pnl'] ?? 0), 2);
        $bestTrade    = $um['best_trade']  !== null ? round((float)$um['best_trade'],  2) : null;
        $worstTrade   = $um['worst_trade'] !== null ? round((float)$um['worst_trade'], 2) : null;
        $expectancy   = $um['expectancy'] !== null ? round((float)$um['expectancy'], 2) : null;
        $profitFactor = $um['profit_factor'] !== null ? round((float)$um['profit_factor'], 2) : null;
        $maxConsecWins   = (int)($um['winning_streak'] ?? 0);
        $maxConsecLosses = (int)($um['losing_streak'] ?? 0);

        try {
            $arRows = $db->raw("
                SELECT AVG(t.risk_pct) AS avg_risk_pct
                FROM {$dashJoin}
                WHERE t.user_id = :uid AND {$dashValidationCondT} AND t.status = 'closed' AND t.deleted_at IS NULL {$dashExtraWhere}
            ", array_merge([':uid' => $userId], $dashExtraParams));
            $avgRiskPct = !empty($arRows[0]['avg_risk_pct']) ? round((float)$arRows[0]['avg_risk_pct'], 2) : null;
        } catch (\Throwable $e) { $avgRiskPct = null; }

        // No strategy attached anywhere in this scope — session/instrument
        // charts and the new Strategy Performance card have nothing to
        // scope to, same as before this pass. Deliberately left reading
        // the pre-existing materialized views here (unfiltered) — a
        // considered decision, not an oversight: this branch only fires
        // for a user with zero strategies at all, and there is no
        // strategy for a filtered rule-adherence breakdown to attach to.
        // Scoped to the active Prop Firm/Live toggle via account_id, the one
        // column both views expose that maps back to trading_accounts.
        $sessionRows = $db->raw("SELECT session, trades, wins, total_pnl FROM mv_session_performance WHERE user_id = :uid AND account_id IN (SELECT id FROM trading_accounts WHERE user_id = :uid2 AND account_type = :dvtype) ORDER BY trades DESC", [':uid' => $userId, ':uid2' => $userId, ':dvtype' => $dashAccountTypeDb]);
        $instrumentRows = $db->raw("SELECT instrument, trades, wins, total_pnl FROM mv_instrument_performance WHERE user_id = :uid AND account_id IN (SELECT id FROM trading_accounts WHERE user_id = :uid2 AND account_type = :dvtype) ORDER BY trades DESC LIMIT 8", [':uid' => $userId, ':uid2' => $userId, ':dvtype' => $dashAccountTypeDb]);
        $adherence = null;
        $fsSummary = null;

        if ($totalTrades > 0) {
            try {
                $glRows = $db->raw("
                    SELECT SUM(CASE WHEN {$dashPnlExpr} < 0 THEN -{$dashPnlExpr} ELSE 0 END) AS gross_loss
                    FROM {$dashJoin}
                    WHERE t.user_id = :uid AND {$dashValidationCondT} AND t.status = 'closed' AND t.deleted_at IS NULL {$dashExtraWhere}
                ", array_merge([':uid' => $userId], $dashExtraParams));
                $grossLoss = (float)($glRows[0]['gross_loss'] ?? 0);
                $equityEfficiency = ($grossLoss > 0 && $totalPnl + $grossLoss > 0)
                    ? round($totalPnl / ($totalPnl + $grossLoss), 2)
                    : null;
            } catch (\Throwable $e) {}

            try {
                if ($totalTrades >= 20) {
                    $riskOfRuin = EdgeHealthEngine::getPeriodRiskOfRuin(
                        $userId, 'all', $dashClosedFromDt, $dashClosedToDt,
                        $dashExtraWhere, $dashExtraParams,
                        null, $workflowState === 'validation' ? 'only' : 'exclude'
                    );
                }
            } catch (\Throwable $e) {}

            // fn_current_daily_loss_state doesn't exist in this database
            // (confirmed via `SELECT * FROM mysql.proc` — see CLAUDE.md),
            // and unlike the metrics above there's no working inline
            // equivalent to fall back to — left null. Ties to Known Bug #6
            // ("Daily drawdown reset logic needs per-prop-firm reset times").
            $dailyLossState = null;
            $accounts = $db->select('trading_accounts', 'is_active = 1 AND deleted_at IS NULL', [], '*', 'created_at DESC', 1);
            $account = $accounts[0] ?? null;
            if ($account) {
                try { $dds = $db->raw("SELECT fn_current_drawdown_state(:aid) AS state", [':aid' => $account['id']]); $drawdownState = $dds[0]['state'] ?? null; } catch (\Throwable $e) {}
            }
        }
        $alerts = [];
        if ($totalTrades >= 3) {
            $alerts = dashBuildBehaviorAlerts($db, $userId, $dashValidationCond, null);
        }

        // ── Recent trades: fetch up to DASHBOARD_TRADES_FETCH_LIMIT rows (not hard-capped
        //    at 20) so client-side "Show All / Show Less" has real data to reveal. Same
        //    Instrument/Session/Classification/From/To filters as every metric above.
        $recentTrades = $db->raw(
            "SELECT t.*, ta.account_type, ta.currency FROM {$dashJoin} WHERE t.user_id = :uid AND {$dashValidationCondT} AND t.status = 'closed' AND t.deleted_at IS NULL {$dashExtraWhere} ORDER BY t.closed_at DESC LIMIT " . DASHBOARD_TRADES_FETCH_LIMIT,
            array_merge([':uid' => $userId], $dashExtraParams)
        );

        // ── Uncapped total count, independent of the fetch above — drives header text
        //    and the "Show All Trades (N)" button. Falls back gracefully if it fails.
        try {
            $totalTradesCount = (int)$db->count('trades', "status = 'closed' AND deleted_at IS NULL AND {$dashValidationCond}" . str_replace('t.', '', $dashExtraWhere), $dashExtraParams);
        } catch (\Throwable $e) {
            $totalTradesCount = $totalTrades > 0 ? $totalTrades : count($recentTrades);
        }

        $tradesByDate = [];
        foreach ($recentTrades as $trade) { $dateKey = date('Y-m-d', strtotime($trade['closed_at'] ?? $trade['captured_at'])); $tradesByDate[$dateKey][] = $trade; }
        krsort($tradesByDate);
        $dailySummaries = [];
        foreach ($tradesByDate as $dateKey => $dayTrades) { $dayPnl = array_sum(array_map('dashEffectivePnl', $dayTrades)); $dayWins = count(array_filter($dayTrades, fn($t) => dashEffectivePnl($t) > 0)); $dailySummaries[$dateKey] = ['pnl' => $dayPnl, 'trades' => count($dayTrades), 'wins' => $dayWins]; }
    }
}

// ── Header trade-count source of truth ──────────────────────────────────────
// Use the most reliable signal available: prefer the uncapped $totalTradesCount
// (independent COUNT query), fall back to $totalTrades (stats aggregate), so the
// header can never say "No trades yet" while trade cards are visibly rendering.
$headerTradeCount = $totalTradesCount > 0 ? $totalTradesCount : $totalTrades;

// ── Filter dropdown option lists ────────────────────────────────────────
// Same distinct-instrument / fixed-session-list / distinct-classification
// approach as Journal's renderTradeJournalSection(), scoped to this
// strategy (when one's selected), to the active workflow, and to the
// active Prop Firm/Live toggle — a trader filtering their strategy's own
// trades shouldn't see instruments/classifications that never occurred
// under it or under the account type currently in view.
$dashFilterInstruments = [];
$dashFilterClassifications = [];
if ($tier !== 'free') {
    try {
        $instScope = $filterVersionId ? " AND strategy_version_id = :svid" : "";
        $instParams = $filterVersionId ? [':uid' => $userId, ':svid' => $filterVersionId] : [':uid' => $userId];
        $instScope .= " AND account_id IN (SELECT id FROM trading_accounts WHERE user_id = :dvuid3 AND account_type = :dvtype3)";
        $instParams[':dvuid3'] = $userId;
        $instParams[':dvtype3'] = $dashAccountTypeDb;
        $dashFilterInstruments = $db->raw("SELECT DISTINCT instrument FROM trades WHERE user_id = :uid AND {$dashValidationCond} AND status = 'closed' AND deleted_at IS NULL AND instrument IS NOT NULL AND instrument != ''{$instScope} ORDER BY instrument", $instParams);
        $dashFilterClassifications = $db->raw("SELECT DISTINCT trade_classification FROM trades WHERE user_id = :uid AND {$dashValidationCond} AND status = 'closed' AND deleted_at IS NULL AND trade_classification IS NOT NULL{$instScope} ORDER BY trade_classification", $instParams);
    } catch (\Throwable $e) {}
}
$dashFilterSessions = ['Sydney', 'Tokyo', 'London', 'New York'];

$isElite = in_array($tier, ['elite', 'ultimate']);
$isStarterOrPro = in_array($tier, ['starter', 'pro']);

// pnlClass()/pnlFormat() now live in src/RenderHelpers.php (required globally
// by index.php) — removed from here to stop the six-file copy-paste drift.
function classPillClass($c) { return match($c) { 'disciplined_win'=>'pill-green','elevated_win'=>'pill-amber','breakeven'=>'pill-neutral','controlled_loss'=>'pill-neutral','loss_high_risk'=>'pill-orange','tilt_warning'=>'pill-orange','revenge_trade'=>'pill-red', default=>'pill-neutral' }; }
// Same mapping as classPillClass(), for the calmer dot+label .tc-tag
// treatment trade cards use instead of a full colored/bordered .pill badge.
function classTagClass($c) { return str_replace('pill-', 'tc-tag-', classPillClass($c)); }
function ddPillClass($s) { return match($s) { 'safe'=>'pill-green','warning'=>'pill-amber','danger'=>'pill-orange','critical'=>'pill-red', default=>'pill-neutral' }; }

/**
 * Deterministic-but-varied phrase picker — same technique
 * src/TraderProfileNarrative.php's pick() uses elsewhere in this app: a
 * stable hash of the seed picks one of several equally-true phrasings, so
 * the same underlying numbers don't render with the exact same sentence
 * on every single visit, without any stored state or randomness that
 * would flip mid-session. Pass real data (counts, $ amounts, ids) as the
 * seed so the choice only changes when the underlying numbers do.
 */
function dashPick(string $seed, array $variants): string {
    if (count($variants) <= 1) return $variants[0] ?? '';
    $idx = abs(crc32($seed . implode('', array_map('strlen', $variants)))) % count($variants);
    return $variants[$idx];
}

$moodLabels = [1 => 'Poor', 2 => 'Below Average', 3 => 'Neutral', 4 => 'Good', 5 => 'Excellent'];
$energyLabels = [1 => 'Exhausted', 2 => 'Low', 3 => 'Moderate', 4 => 'Alert', 5 => 'Fully Focused'];
$confidenceLabels = [1 => 'Uncertain', 2 => 'Doubtful', 3 => 'Moderate', 4 => 'Confident', 5 => 'High Conviction'];

$drawdownMessage = match($drawdownState) { 'safe' => 'Your current drawdown remains within acceptable limits.', 'warning' => 'Your drawdown is beginning to approach concerning levels.', 'danger' => 'Your account is approaching its maximum drawdown threshold.', 'critical' => 'Your account has reached its critical drawdown limit.', default => null };
$dailyLossMessage = match($dailyLossState) { 'clear' => 'You remain within your daily loss limit.', 'warning' => 'You are approaching your daily loss limit.', 'stop' => 'You have reached your daily loss limit.', default => null };
$riskOfRuinMessage = null;
if ($totalTrades < 20) $riskOfRuinMessage = 'Additional trade history needed.';
elseif ($riskOfRuin !== null) $riskOfRuinMessage = $riskOfRuin > 20 ? 'Elevated' : ($riskOfRuin > 5 ? 'Moderate' : 'Low');

function behaviouralTagline(?int $mood, ?int $energy, ?int $confidence): string {
    $m=[1=>'Poor',2=>'Below Avg',3=>'Neutral',4=>'Good',5=>'Excellent'];
    $e=[1=>'Exhausted',2=>'Low',3=>'Moderate',4=>'Alert',5=>'Focused'];
    $c=[1=>'Uncertain',2=>'Doubtful',3=>'Moderate',4=>'Confident',5=>'High Conviction'];
    $p=[]; if($mood)$p[]="Mood: {$m[$mood]}"; if($energy)$p[]="Energy: {$e[$energy]}"; if($confidence)$p[]="Confidence: {$c[$confidence]}";
    return !empty($p)?implode(' · ',$p):'Not recorded';
}
// Rewritten 2026-08-18 alongside src/TradeClassificationEngine.php's fix
// — every classification here now means what Active Coaching's tier
// recommendation says it means, not the old planned-vs-actual/loss-
// streak logic. tilt_warning and revenge_trade in particular changed
// meaning (tilt_warning was "any 2nd loss," now means fear of trading —
// a loss risked well below the tier's recommendation; revenge_trade
// required only "a loss somewhere in the last 10," now requires THIS
// trade itself to be over-risked AND immediately preceded by a loss).
function classificationInsight(string $c): string {
    return match($c){
        'disciplined_win'=>'Won, risked within your current drawdown tier.',
        'elevated_win'=>'Won, but risked more than your current drawdown tier recommends.',
        'breakeven'=>'Finished near breakeven.',
        'controlled_loss'=>'Lost, risked within your current drawdown tier.',
        'loss_high_risk'=>'Lost, risked more than your current drawdown tier recommends.',
        'tilt_warning'=>'Lost, risked well below your current tier right after a prior loss: fear of trading, not over-risking.',
        'revenge_trade'=>'Lost, risked more than your current tier recommends right after a prior loss: the classic revenge-trading pattern.',
        default=>'Review this trade.'
    };
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - TraderAlign</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/traderalign/assets/tokens.css">
<link rel="icon" type="image/png" sizes="32x32" href="/traderalign/assets/favicon/favicon-32.png">
    <link rel="icon" type="image/png" sizes="16x16" href="/traderalign/assets/favicon/favicon-16.png">
    <link rel="apple-touch-icon" sizes="180x180" href="/traderalign/assets/favicon/apple-touch-icon.png">
    <link rel="shortcut icon" href="/traderalign/assets/favicon/favicon.ico">
<link rel="stylesheet" href="/traderalign/assets/css/brand.css">
<?php if ($tier !== 'free'): ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4" onerror="console.error('Failed to load Chart.js from CDN')"></script>
<?php endif; ?>
<style>
/* ── Base ─────────────────────────────────────────── */
.dash-layout{display:flex;min-height:100vh}
.sidebar{width:4rem;background:var(--sidebar);border-right:1px solid var(--sidebar-border);display:flex;flex-direction:column;align-items:center;padding-top:1rem;gap:.25rem;position:fixed;top:0;left:0;bottom:0;z-index:40}
.sidebar a{display:flex;align-items:center;justify-content:center;width:2.75rem;height:2.75rem;border-radius:var(--radius-md);color:var(--foreground-dim);transition:background var(--t-fast),color var(--t-fast);position:relative}
.sidebar a:hover{background:var(--background-surface);color:var(--foreground)}
.sidebar a.active{background:var(--background-surface);color:var(--sidebar-primary)}
.sidebar a .tt{position:absolute;left:3.5rem;background:var(--popover);color:var(--foreground);font-size:.75rem;padding:.25rem .625rem;border-radius:var(--radius-sm);white-space:nowrap;pointer-events:none;opacity:0;transition:opacity var(--t-fast)}
.sidebar a:hover .tt{opacity:1}
.main{margin-left:4rem;flex:1;padding:2rem;max-width:calc(100vw - 4rem)}

/* ── Page header ──────────────────────────────────── */
.page-header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    margin-bottom: 1.75rem;
    flex-wrap: wrap;
    gap: 1rem;
}
.page-header-left { display: flex; flex-direction: column; gap: .3rem; }
.page-title{font-size:1.65rem;font-weight:700;letter-spacing:-.01em;color:var(--foreground);display:flex;align-items:center;gap:.6rem}
.page-sub{color:var(--foreground-muted);font-size:.85rem}
.page-header-right{display:flex;flex-direction:column;align-items:flex-end;gap:.55rem}
.page-header-subtle-link{font-size:.7rem;color:var(--foreground-dim);text-decoration:none;margin-top:.15rem;display:inline-block;transition:color var(--t-fast)}
.page-header-subtle-link:hover{color:var(--foreground-muted)}
.page-header-secondary-link{font-size:.78rem}

/* ── Account type toggle: Prop Firm / Live ───────────
   A quiet, segmented pill control — the single most important piece of
   navigation on this page, so it must read as unmistakable without being
   loud: no color-cycling, no glow, just a clear elevated "active" state
   against a muted, inactive sibling. */
/* Purely tonal active state — no saturated brand-blue fill or text color.
   A segmented control this close to the top of the page needs to read as
   unmistakable through elevation and weight alone (background lift +
   slightly bolder neutral text), the same restrained language as every
   other "active" state on this page — a loud accent color here would be
   the first thing a trader's eye catches on every single visit, which is
   the opposite of calm. */
.account-toggle{display:inline-flex;padding:.2rem;gap:.15rem;background:var(--background-surface);border:1px solid var(--border);border-radius:var(--radius-full)}
.account-toggle a{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem 1rem;border-radius:var(--radius-full);font-size:.76rem;font-weight:500;letter-spacing:.01em;color:var(--foreground-dim);text-decoration:none;transition:background .16s ease,color .16s ease}
.account-toggle a:hover{color:var(--foreground-muted)}
.account-toggle a.active{background:var(--background-elevated);color:var(--foreground);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.3)}
.account-toggle a .at-count{font-family:var(--font-mono);font-size:.66rem;font-weight:500;color:var(--foreground-dim);padding:.05rem .35rem;border-radius:var(--radius-full);background:var(--background)}
.account-toggle a.active .at-count{color:var(--foreground-muted);background:color-mix(in srgb,var(--foreground) 6%,var(--background))}

/* ── Status bar ───────────────────────────────────── */
.status-bar{display:flex;flex-wrap:wrap;gap:.6rem;margin-bottom:1rem}
.status-msg{font-size:.8rem;line-height:1.45;padding:.5rem .875rem;border-radius:var(--radius-md)}
.status-msg.safe{background:color-mix(in srgb,var(--positive) 10%,transparent);border:1px solid color-mix(in srgb,var(--positive) 22%,transparent);color:var(--positive)}
.status-msg.warning{background:color-mix(in srgb,var(--warning) 10%,transparent);border:1px solid color-mix(in srgb,var(--warning) 22%,transparent);color:var(--warning)}
.status-msg.danger{background:color-mix(in srgb,#F97316 10%,transparent);border:1px solid color-mix(in srgb,#F97316 22%,transparent);color:#F97316}
.status-msg.stop{background:color-mix(in srgb,var(--negative) 10%,transparent);border:1px solid color-mix(in srgb,var(--negative) 22%,transparent);color:var(--negative)}
.alert-card{font-size:.82rem;line-height:1.45;padding:.6rem .875rem;border-radius:var(--radius-md);margin-bottom:.4rem}
.alert-card.warn{background:color-mix(in srgb,var(--warning) 8%,transparent);border:1px solid color-mix(in srgb,var(--warning) 22%,transparent);color:var(--warning)}
.alert-card.bad{background:color-mix(in srgb,var(--negative) 8%,transparent);border:1px solid color-mix(in srgb,var(--negative) 22%,transparent);color:var(--negative)}
.resolve-error-banner{display:flex;flex-direction:column;gap:.25rem;background:color-mix(in srgb,var(--negative) 10%,transparent);border:1px solid color-mix(in srgb,var(--negative) 25%,transparent);border-radius:var(--radius-md);padding:.85rem 1.1rem;margin-bottom:1.2rem;font-size:.85rem}
.resolve-error-banner strong{color:var(--negative);font-size:.78rem;font-weight:800;text-transform:uppercase;letter-spacing:.03em}
.resolve-error-banner span{color:var(--foreground)}
.promoted-banner{display:flex;flex-direction:column;gap:.25rem;background:color-mix(in srgb,var(--positive) 10%,transparent);border:1px solid color-mix(in srgb,var(--positive) 25%,transparent);border-radius:var(--radius-md);padding:.85rem 1.1rem;margin-bottom:1.2rem;font-size:.85rem}
.promoted-banner strong{color:var(--positive);font-size:.78rem;font-weight:800;text-transform:uppercase;letter-spacing:.03em}
.promoted-banner span{color:var(--foreground)}

/* ── Section cards ────────────────────────────────── */
.section-card{background:var(--background-elevated);border:1px solid var(--border);border-radius:var(--radius-xl);padding:1.4rem 1.5rem;margin-bottom:1.1rem;box-shadow:0 1px 2px rgba(0,0,0,.16),0 10px 28px -18px rgba(0,0,0,.55)}
.section-card h3{font-size:.85rem;font-weight:600;letter-spacing:.02em;color:var(--foreground);margin-bottom:.9rem}
.section-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1.1rem;margin-bottom:1.1rem}
.chart-wrap{position:relative;width:100%;height:220px}
.chart-wrap canvas{display:block;width:100%!important;height:100%!important}
.empty-state{text-align:center;padding:2.5rem 1rem;color:var(--foreground-dim)}

/* ── Metric mini-grid — consolidated Overview / Performance Metrics cards.
   2026-08-12 Dashboard cleanup: replaces the old .stat-grid/.stat-tile and
   .perf-metrics-row/.perf-metric-tile pattern (each metric its own bordered
   card) with plain grid cells inside ONE .section-card, so the page reads
   as two clean sections instead of ~12 separate floating tiles. Hero
   tiles (Total P&L / Win Rate) carry deliberately stronger weight than
   the rest so the eye lands there first — a single, unmistakable point of
   emphasis rather than every metric competing equally. */
.metric-mini-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:1.3rem 1.75rem}
.metric-mini{display:flex;flex-direction:column;gap:.28rem}
/* Sized back down from an earlier, oversized pass — the hero figures were
   reading as a giant, childish display number rather than a confident
   data point. Hero stays the clear anchor of the card (still a full step
   larger and heavier than everything else), just no longer shouting. */
.mm-label{font-size:.64rem;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:var(--foreground-dim)}
.mm-value{font-family:var(--font-mono);font-size:1.05rem;font-weight:600;color:var(--foreground);line-height:1.2;letter-spacing:-.005em}
.metric-mini.hero .mm-value{font-size:1.3rem;font-weight:700}
.mm-sub{font-size:.7rem;color:var(--foreground-muted)}

/* ── Strategy Performance — Followed/Broke Rules $ split ──────────── */
.rule-split{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:.85rem}
.rule-split-item{border-radius:var(--radius-lg);padding:.9rem 1rem;display:flex;flex-direction:column;gap:.3rem}
.rule-split-item.followed{background:color-mix(in srgb,var(--positive) 8%,transparent);border:1px solid color-mix(in srgb,var(--positive) 22%,transparent)}
.rule-split-item.broken{background:color-mix(in srgb,var(--negative) 8%,transparent);border:1px solid color-mix(in srgb,var(--negative) 22%,transparent)}
.rsi-top{display:flex;align-items:center;gap:.4rem;font-size:.72rem;font-weight:600;color:var(--foreground-dim);text-transform:uppercase;letter-spacing:.04em}
.rsi-value{font-family:var(--font-mono);font-size:1.35rem;font-weight:700}
.rsi-count{font-size:.7rem;color:var(--foreground-muted)}
.rule-summary{font-size:.82rem;line-height:1.5;color:var(--foreground-muted)}
.rule-summary.negative{color:var(--negative)}
.rule-summary.positive{color:var(--positive)}

/* ── Pills ────────────────────────────────────────── */
/* Toned down from an earlier, much brighter version (18%/30% color-mix) —
   at full saturation these read as neon/glowing against the dark cards
   they sit on, especially several in a row. Same quieter 9-10%/20-24%
   idiom already established elsewhere on this page (.status-msg,
   .rule-split-item) — present and legible, never lit up. */
.pill{display:inline-flex;align-items:center;padding:.15rem .55rem;border-radius:var(--radius-full);font-size:.63rem;font-weight:600;letter-spacing:.03em;text-transform:uppercase;white-space:nowrap}
.pill-green{background:color-mix(in srgb,var(--positive) 10%,transparent);color:var(--positive);border:1px solid color-mix(in srgb,var(--positive) 22%,transparent)}
.pill-amber{background:color-mix(in srgb,var(--warning) 10%,transparent);color:var(--warning);border:1px solid color-mix(in srgb,var(--warning) 22%,transparent)}
.pill-orange{background:color-mix(in srgb,#F97316 10%,transparent);color:#F97316;border:1px solid color-mix(in srgb,#F97316 22%,transparent)}
.pill-red{background:color-mix(in srgb,var(--negative) 10%,transparent);color:var(--negative);border:1px solid color-mix(in srgb,var(--negative) 22%,transparent)}
.pill-neutral{background:var(--background);color:var(--foreground-dim);border:1px solid var(--border)}
.positive{color:var(--positive)}.negative{color:var(--negative)}

/* ── Tier badge ───────────────────────────────────── */
.tier-badge{display:inline-block;font-size:.63rem;font-weight:700;padding:.18rem .55rem;border-radius:var(--radius-full);text-transform:uppercase;letter-spacing:.04em}
.tier-badge.free{background:var(--background-surface);color:var(--foreground-dim);border:1px solid var(--border)}
.tier-badge.starter{background:color-mix(in srgb,var(--primary) 15%,transparent);color:var(--primary);border:1px solid color-mix(in srgb,var(--primary) 30%,transparent)}
.tier-badge.pro{background:color-mix(in srgb,var(--warning) 15%,transparent);color:var(--warning);border:1px solid color-mix(in srgb,var(--warning) 30%,transparent)}
.tier-badge.elite{background:color-mix(in srgb,var(--positive) 15%,transparent);color:var(--positive);border:1px solid color-mix(in srgb,var(--positive) 30%,transparent)}
.tier-badge.ultimate{background:linear-gradient(135deg,var(--primary),var(--positive));color:#fff;border:1px solid transparent}

/* ── Recent Trades section ────────────────────────── */
.trades-section {
    background: var(--background-elevated);
    border: 1px solid var(--border);
    border-radius: var(--radius-xl);
    margin-bottom: 1.1rem;
    overflow: hidden;
    box-shadow: 0 1px 2px rgba(0,0,0,.16), 0 10px 28px -18px rgba(0,0,0,.55);
}
.trades-section-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 1.15rem 1.5rem .9rem;
    border-bottom: 1px solid var(--border);
    gap: .75rem;
    flex-wrap: wrap;
}
.trades-section-title {
    font-size: .9rem;
    font-weight: 600;
    letter-spacing: .01em;
    color: var(--foreground);
    display: flex;
    align-items: center;
    gap: .5rem;
}
.trades-section-title .strategy-scope {
    font-size: .75rem;
    font-weight: 400;
    color: var(--foreground-dim);
}
.trades-section-body {
    padding: .9rem 1.5rem 1.4rem;
}

/* ── Filter bar — same shape as Journal's Trade Journal filter bar
   (Instrument / Session / Classification / From / To / Filter / Clear),
   2026-08-12 Dashboard cleanup. */
.filter-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.6rem;margin-bottom:1.4rem;padding:.85rem 1rem;background:var(--background-elevated);border:1px solid var(--border);border-radius:var(--radius-lg)}
.filter-bar select,.filter-bar input{background:var(--background-surface);border:1px solid var(--border);color:var(--foreground);padding:.42rem .65rem;border-radius:var(--radius-md);font-size:.78rem;font-family:inherit;transition:border-color var(--t-fast)}
.filter-bar select:focus,.filter-bar input:focus{outline:none;border-color:var(--primary)}
.filter-bar .filter-date-label{font-size:.72rem;color:var(--foreground-dim);align-self:center;cursor:default}
.filter-results-count{font-size:.7rem;color:var(--foreground-dim);margin-bottom:.6rem}

/* ── Trade date headers ───────────────────────────── */
.trade-date-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: .55rem 0 .55rem;
    margin: 1.5rem 0 .7rem;
    border-bottom: 1px solid var(--border);
}
.trade-date-header:first-child { margin-top: 0; }
.tdh-left { display: flex; align-items: center; gap: .55rem; }
.tdh-date { font-size: .78rem; font-weight: 700; letter-spacing: .01em; color: var(--foreground-muted); }
.tdh-count {
    font-size: .66rem;
    color: var(--foreground-dim);
    background: var(--background-surface);
    padding: .08rem .4rem;
    border-radius: var(--radius-full);
    font-family: var(--font-mono);
}
.tdh-pnl { font-family: var(--font-mono); font-size: .9rem; font-weight: 700; }

/* ── Trade cards grid ─────────────────────────────── */
.trade-cards {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
    gap: .7rem;
}
/* Quiet, professional trade cards — a financial-terminal surface, not a
   glowing/gaming one. Cards sit as a slightly LIGHTER, raised surface on
   top of the section's own background-elevated fill (--background-surface
   is one step up from --background-elevated) — the earlier version used
   --background here, which is darker than the section wrapper around it
   and read as a punched-out black hole rather than a raised card. Outcome
   is communicated with a short, restrained accent tick + a faint
   background tint (same color-mix idiom already used elsewhere on this
   page for .rule-split-item/.status-msg) instead of a saturated
   full-height edge — legible at a glance, never "lit up." */
.trade-card {
    background: var(--background-surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    padding: 1.05rem 1.2rem;
    line-height: 1.5;
    transition: border-color .16s ease, background .16s ease, transform .16s ease, box-shadow .16s ease;
    position: relative;
}
.trade-card::before {
    content: '';
    position: absolute;
    left: 0; top: 1.05rem; bottom: 1.05rem;
    width: 2px;
    border-radius: var(--radius-full);
    background: var(--border);
}
.trade-card:hover {
    transform: translateY(-1px);
    border-color: color-mix(in srgb, var(--foreground-dim) 40%, var(--border));
    box-shadow: 0 4px 14px -8px rgba(0,0,0,.5);
}
/* Also applied to .trade-date-header/.trade-cards.trade-day-group — see
   the day-group visibility fix in the Recent Trades render loop below. */
.hidden-card { display: none; }

/* Win / loss / neutral outcome — a muted accent tick + a barely-there
   background tint, never a bright saturated border/glow. Tick opacity
   deliberately low (45% mixed with the plain border tone) — enough to
   read green/red at a glance, not bright enough to look "lit up" when a
   whole grid of them sits side by side. */
.trade-card.tc-win  { background: color-mix(in srgb, var(--positive) 2.5%, var(--background-surface)); }
.trade-card.tc-win::before  { background: color-mix(in srgb, var(--positive) 45%, var(--border)); }
.trade-card.tc-loss { background: color-mix(in srgb, var(--negative) 2.5%, var(--background-surface)); }
.trade-card.tc-loss::before { background: color-mix(in srgb, var(--negative) 45%, var(--border)); }
.trade-card.tc-be::before   { background: var(--foreground-dim); }

/* Header typography, calmed down deliberately. Previously Direction was
   bold, uppercase, AND colored positive/negative right next to the (also
   bold, colored) P&L figure — three loud, differently-colored elements
   competing on one line read as noise, not information. Direction isn't
   inherently good/bad news (a Short is not "negative"), so it no longer
   borrows win/loss color at all — it's now a quiet neutral tag. P&L stays
   the one deliberately strong, colored element per card, so the eye has a
   single clear place to land instead of three. */
.tc-header {
    display: flex;
    align-items: center;
    margin-bottom: .55rem;
    gap: .55rem;
}
.tc-header .tc-instrument {
    font-size: .92rem;
    font-weight: 600;
    letter-spacing: -.005em;
    color: var(--foreground);
}
.tc-header .tc-dir {
    font-size: .68rem;
    font-weight: 500;
    color: var(--foreground-dim);
}
.tc-header .tc-pnl {
    font-family: var(--font-mono);
    font-size: 1rem;
    font-weight: 700;
    letter-spacing: -.01em;
    margin-left: auto;
}
.tc-meta {
    display: flex;
    align-items: center;
    gap: .8rem;
    flex-wrap: wrap;
    margin-bottom: .55rem;
}
/* Classification/draft status — a small colored dot + plain, calm text
   instead of a bold uppercase, colored, bordered badge. A grid of many
   trade cards each carrying 1-2 solid-colored badges is what read as
   "shouting" — a dot carries the same at-a-glance signal (color) while
   the label itself stays quiet, sentence-cased, and the same neutral tone
   as the rest of the card's secondary text. */
.tc-tag {
    display: inline-flex;
    align-items: center;
    gap: .4rem;
    font-size: .72rem;
    font-weight: 500;
    color: var(--foreground-muted);
}
.tc-tag-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.tc-tag-green  .tc-tag-dot { background: var(--positive); }
.tc-tag-amber  .tc-tag-dot { background: var(--warning); }
.tc-tag-orange .tc-tag-dot { background: #F97316; }
.tc-tag-red    .tc-tag-dot { background: var(--negative); }
.tc-tag-neutral .tc-tag-dot { background: var(--foreground-dim); }
.tc-behaviour {
    font-size: .72rem;
    color: var(--foreground-dim);
    margin-bottom: .6rem;
    line-height: 1.6;
}
.tc-footer {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: .5rem;
    flex-wrap: wrap;
    padding-top: .55rem;
    border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}
.tc-footer-left {
    display: flex;
    align-items: center;
    gap: .5rem;
    flex-wrap: wrap;
}
.tc-time { font-size: .67rem; color: var(--foreground-dim); }

/* ── Resolve form ─────────────────────────────────── */
.resolve-form{display:none;margin-top:.6rem;padding:.75rem;background:var(--background-surface);border:1px solid var(--border);border-radius:var(--radius-md)}
.resolve-form.show{display:block}
.resolve-form .plan-row{font-size:.72rem;color:var(--foreground-dim);margin-bottom:.5rem;padding:.4rem .5rem;background:var(--background);border-radius:var(--radius-sm)}
.resolve-form .plan-row span{font-family:var(--font-mono);color:var(--foreground-muted)}
.resolve-form .input-row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-bottom:.5rem}
.resolve-form .input-row label{font-size:.68rem;color:var(--foreground-dim);text-transform:uppercase;letter-spacing:.03em;min-width:40px}
.resolve-form input{width:100px;height:2rem;padding:0 .4rem;background:var(--background);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--foreground);font-family:var(--font-mono);font-size:.78rem}
.resolve-form input:focus{outline:none;border-color:var(--primary)}
.resolve-form .pnl-display{font-family:var(--font-mono);font-size:.9rem;font-weight:600;padding:.3rem .5rem}
.btn-sm{height:1.55rem;padding:0 .6rem;font-size:.7rem;border-radius:var(--radius-sm);border:none;cursor:pointer;font-weight:600}
.btn-won{background:var(--positive);color:#fff}.btn-lost{background:var(--negative);color:#fff}
.btn-ghost-sm{height:1.55rem;padding:0 .6rem;font-size:.7rem;background:transparent;color:var(--foreground-muted);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer}

/* ── Links ────────────────────────────────────────── */
.journal-link{font-size:.7rem;color:var(--primary);text-decoration:none}
.journal-link:hover{opacity:.8}

/* ── Toggle btn ───────────────────────────────────── */
.toggle-trades-btn{display:block;width:100%;margin-top:1.1rem;padding:.65rem;text-align:center;font-size:.78rem;font-weight:600;letter-spacing:.01em;color:var(--foreground-muted);background:var(--background-surface);border:1px solid var(--border);border-radius:var(--radius-md);cursor:pointer;transition:background var(--t-fast),color var(--t-fast),border-color var(--t-fast)}
.toggle-trades-btn:hover{background:var(--background-elevated);color:var(--foreground);border-color:color-mix(in srgb,var(--foreground-dim) 40%,var(--border))}

/* ── Upgrade cards ────────────────────────────────── */
/* Static, restrained accent — the animated color-cycling glow this block
   used to carry (freeBorderFlow/starterBorderFlow/proBorderFlow) read as
   gamified rather than premium; a single quiet left-border tone (same
   treatment already used on this page's non-animated upgrade cards, e.g.
   the "no strategy selected" card) says "upgrade" clearly without ever
   drawing the eye away from the actual data on the page. */
.upgrade-card{background:var(--background-elevated);border:1px solid var(--border);border-left:3px solid transparent;border-radius:var(--radius-xl);padding:2rem;margin-bottom:1.1rem;position:relative;overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.16),0 10px 28px -18px rgba(0,0,0,.55)}
.upgrade-card::after{content:'';position:absolute;top:0;left:0;width:100%;height:100%;background:radial-gradient(ellipse at top left,color-mix(in srgb,var(--primary) 4%,transparent),transparent 70%);pointer-events:none;z-index:0}
.upgrade-card > *{position:relative;z-index:2}
.upgrade-card.free-tier{border-left-color:var(--primary)}
.upgrade-card.starter-tier{border-left-color:var(--primary)}
.upgrade-card.pro-tier{border-left-color:var(--warning)}
@keyframes proBorderFlow{0%,100%{box-shadow:0 0 8px color-mix(in srgb,var(--primary) 45%,transparent),0 0 22px color-mix(in srgb,var(--primary) 18%,transparent)}25%{box-shadow:0 0 8px color-mix(in srgb,#f59e0b 45%,transparent),0 0 22px color-mix(in srgb,#f59e0b 18%,transparent)}50%{box-shadow:0 0 8px color-mix(in srgb,#f97316 45%,transparent),0 0 22px color-mix(in srgb,#f97316 18%,transparent)}75%{box-shadow:0 0 8px color-mix(in srgb,#f43f5e 45%,transparent),0 0 22px color-mix(in srgb,#f43f5e 18%,transparent)}}
.uc-icon{font-size:2rem;margin-bottom:.75rem;display:block}
.uc-title{font-size:1.2rem;font-weight:700;color:var(--foreground);margin-bottom:.75rem;line-height:1.3}
.uc-body{font-size:.88rem;color:var(--foreground-muted);line-height:1.7;margin-bottom:1.5rem;max-width:640px}
.uc-btn{display:inline-flex;align-items:center;gap:.4rem;font-weight:600;text-decoration:none}
.uc-btn .arrow{transition:transform 0.2s ease}
.uc-btn:hover .arrow{transform:translateX(3px)}
.free-hero{max-width:720px;margin:0 auto}
.free-hero .uc-body{font-size:.9rem}

/* ── Responsive ───────────────────────────────────── */
@media(max-width:768px){
    .main{padding:1rem}
    .metric-mini-grid{grid-template-columns:repeat(2,1fr)}
    .rule-split{grid-template-columns:1fr}
    .section-grid{grid-template-columns:1fr}
    .trade-cards{grid-template-columns:1fr}
    .resolve-form .input-row{flex-direction:column;align-items:stretch}
    .filter-bar{flex-direction:column;align-items:stretch}
    .upgrade-card{padding:1.25rem}
    .page-header{flex-direction:column}
    .page-header-right{align-items:flex-start;width:100%}
    .account-toggle{width:100%}
    .account-toggle a{flex:1;justify-content:center}
}
@media(max-width:420px){
    .metric-mini-grid{grid-template-columns:1fr 1fr}
    .account-toggle a{padding:.4rem .6rem;font-size:.72rem}
}
</style>
</head>
<body class="dash-layout">

<?php require __DIR__ . '/sidebar.php'; ?>

<?php if (isset($_GET['debug_stats'])): ?>
<div style="background:#ff0000;color:#fff;padding:1rem;font-family:monospace;font-size:14px;position:relative;z-index:9999;margin-left:4rem;">
    DEBUG BUILD <?= date('Y-m-d H:i:s') ?> | filterVersionId=<?= htmlspecialchars(var_export($filterVersionId, true)) ?> | tier=<?= htmlspecialchars($tier) ?> | totalTrades=<?= (int)$totalTrades ?> | workflowState=<?= htmlspecialchars($workflowState) ?>
</div>
<?php endif; ?>

<main class="main">

    <!-- Page header -->
    <div class="page-header">
        <div class="page-header-left">
            <h1 class="page-title">
                Dashboard
                <span class="tier-badge <?= htmlspecialchars($tier) ?>"><?= ucfirst($tier) ?></span>
            </h1>
            <p class="page-sub"><?= htmlspecialchars($_SESSION['email'] ?? 'Trader') ?> · <?= $headerTradeCount > 0 ? "{$headerTradeCount} closed trades" : 'No trades yet' ?></p>
            <?php if ($workflowState !== 'validation'): ?>
            <!-- 2026-08-12 workflow-isolation audit: replaces the old
                 sidebar icon (moved out per explicit instruction — the
                 sidebar was already full and this doesn't need equal
                 billing with primary nav). Same underlying entry point
                 (strategy-validation.php, unchanged) — just relocated and
                 muted so it's discoverable without adding clutter. -->
            <a href="/traderalign/strategy-validation" class="page-header-subtle-link">Validate a New Edge →</a>
            <?php endif; ?>
        </div>
        <div class="page-header-right">
            <?php if ($tier !== 'free' && $selectedStrategy): ?>
            <!-- ── Account type toggle: Prop Firm / Live (2026-08-20) ──────
                 Same 'prop'/'live' vocabulary reports.php's own toggle
                 uses. Every metric, chart, and trade card on this page is
                 already scoped to $dashAccountTypeDb via $dashExtraWhere/
                 $dashUmFilters above — switching here reloads the whole
                 page scoped to the other account type, never a client-side
                 re-filter of already-fetched data. -->
            <div class="account-toggle" role="tablist" aria-label="Account type">
                <a href="<?= htmlspecialchars(dashUrl(['account_view' => 'prop'])) ?>" role="tab" aria-selected="<?= $dashAccountView === 'prop' ? 'true' : 'false' ?>" class="<?= $dashAccountView === 'prop' ? 'active' : '' ?>">
                    Prop Firm
                    <?php if ($dashAccountTypeCounts['prop_firm'] > 0): ?><span class="at-count"><?= $dashAccountTypeCounts['prop_firm'] ?></span><?php endif; ?>
                </a>
                <a href="<?= htmlspecialchars(dashUrl(['account_view' => 'live'])) ?>" role="tab" aria-selected="<?= $dashAccountView === 'live' ? 'true' : 'false' ?>" class="<?= $dashAccountView === 'live' ? 'active' : '' ?>">
                    Live
                    <?php if ($dashAccountTypeCounts['personal'] > 0): ?><span class="at-count"><?= $dashAccountTypeCounts['personal'] ?></span><?php endif; ?>
                </a>
            </div>
            <?php endif; ?>
            <?php if ($tier !== 'free' && $selectedStrategy): ?>
            <?php if ($workflowState === 'validation'): ?>
                <?php // 2026-08-18: "Open Validation Tracker →" removed from here —
                // the sidebar's own "Validation Tracker" icon (always present
                // while validating) already covers that link, and this exact
                // spot is a more natural home for the one control every other
                // validation-workflow entry point was missing: a way back to
                // Main. Only for a user who reached validation via "Validate a
                // New Edge →" from an existing main-workflow session
                // ($dashCanReturnToMain) — never for onboarding-originated
                // validation or a brand-new trader, neither of which has a main
                // workflow to return to; those cases now render nothing here. ?>
                <?php if ($dashCanReturnToMain): ?>
            <a href="#" onclick="dashReturnToMain(this); return false;" class="journal-link page-header-secondary-link">← Back to Main Workflow</a>
                <?php endif; ?>
            <?php else: ?>
            <a href="/traderalign/reports?<?= htmlspecialchars(http_build_query(array_filter(['strategy_id' => $selectedStrategyId, 'account_view' => $dashAccountView]))) ?>" class="journal-link page-header-secondary-link">Open Full Reports →</a>
            <?php endif; ?>
            <?php endif; ?>
        </div>
    </div>

    <?php if (($_GET['promoted'] ?? '') === '1'): ?>
    <!-- Real, concrete gap found + fixed 2026-08-11 (validation-flow pass):
         journal-validation.php's "Move Strategy to Production" promotion
         has always redirected here with ?promoted=1 on success, but this
         page never read the param — a trader landed here with zero
         acknowledgment that the promotion actually happened. Same visible-
         banner convention as $resolveError just below, positive-toned. -->
    <div class="promoted-banner" role="status">
        <strong>Strategy Promoted</strong>
        <span>Your validation strategy is now live in production. New trades captured under it will count toward your real trading history and analytics.</span>
    </div>
    <?php endif; ?>

    <?php if ($resolveError): ?>
    <!-- Surfaces resolve_trade_id's $resolveError (2026-08-03) — previously
         computed on both the pre-existing transaction-failure path and the
         new Managed Exit validation, but never actually shown anywhere,
         meaning a rejected resolution silently failed from the trader's
         point of view (the trade correctly stayed unresolved, but nothing
         explained why). A plain, visible banner, not a tooltip — the
         trader lands here right after clicking Save Resolution. -->
    <div class="resolve-error-banner" role="alert">
        <strong><?= str_contains($resolveError, ':') ? htmlspecialchars(trim(explode(':', $resolveError, 2)[0])) : 'Trade Not Resolved' ?></strong>
        <span><?= htmlspecialchars(str_contains($resolveError, ':') ? trim(explode(':', $resolveError, 2)[1]) : $resolveError) ?></span>
    </div>
    <?php endif; ?>

    <?php if ($tier === 'free'): ?>
    <div class="upgrade-card free-tier free-hero">
        <span class="uc-icon">📊</span>
        <h2 class="uc-title">Ready to see how your trading is really performing?</h2>
        <p class="uc-body">You already know how the calculator helps you plan before you enter a trade, giving you clarity on position size, risk, and your overall game plan. That's a solid start. But the real edge comes after the trade closes. With TraderAlign's advanced tools, you can go deeper: track your trades, review your decisions, spot patterns, and actually measure what's working. Plus, get AI-powered insights and strategy management to help you refine your process over time. These features are part of our paid plans, built for traders who want to move beyond guesswork and start building real consistency.</p>
        <a href="/traderalign/pricing" class="btn btn-primary btn-lg uc-btn">View Plans <span class="arrow">→</span></a>
    </div>

    <?php elseif ($tier !== 'free' && !$selectedStrategy): ?>
    <div class="upgrade-card" style="border-left: 3px solid var(--primary);">
        <span class="uc-icon">📋</span>
        <h2 class="uc-title">No strategy selected yet.</h2>
        <p class="uc-body">Go to Trading Center to pick an account and strategy. Your last used strategy will automatically appear here for analysis.</p>
        <a href="/traderalign/trading-center" class="btn btn-primary btn-lg uc-btn">Go to Trading Center <span class="arrow">→</span></a>
    </div>

    <?php else: ?>

    <!-- Graduated-with-warning summary card — extremely simple by design;
         detailed validation analytics stay in the Validation Tracker. -->
    <?php if ($graduationWarningRecord && $graduationWarningStatus):
        $gwToneColor = match($graduationWarningStatus['tone']) {
            'positive' => 'var(--positive)',
            'negative' => 'var(--negative)',
            'caution'  => 'var(--warning)',
            default    => 'var(--foreground-dim)',
        };
        $gwMeta = $graduationWarningRecord['metrics_at_graduation'] ?? [];
    ?>
    <div class="upgrade-card" style="border-left: 3px solid <?= $gwToneColor ?>;margin-bottom:1rem">
        <span class="uc-icon">🧪</span>
        <h2 class="uc-title" style="color:<?= $gwToneColor ?>">Validation status: <?= htmlspecialchars($graduationWarningStatus['label']) ?></h2>
        <p class="uc-body">
            This strategy entered the main workflow after <b><?= (int)($graduationWarningRecord['trade_count_at_graduation'] ?? 0) ?></b> validation trades
            (win rate <?= $gwMeta['win_rate'] !== null ? round((float)$gwMeta['win_rate'] * 100, 1) . '%' : '-' ?>,
            <?= $gwMeta['avg_r_multiple'] !== null ? number_format((float)$gwMeta['avg_r_multiple'], 2) . 'R avg' : 'avg R unavailable' ?>,
            expectancy <?= (float)($gwMeta['expectancy'] ?? 0) > 0 ? '+' : '' ?><?= number_format((float)($gwMeta['expectancy'] ?? 0), 2) ?>/trade).
            At that point it had <b>not yet demonstrated a profitable edge</b>, and was moved into the main workflow anyway on your explicit choice to continue.
            <?php if ($graduationWarningStatus['status'] === 'insufficient_new_data'): ?>
            Only <?= $graduationWarningStatus['new_trades'] ?> new trade(s) have closed since. Not enough yet to say whether that's changing.
            <?php else: ?>
            Since then, <?= $graduationWarningStatus['new_trades'] ?> new trades have closed and this strategy's real performance is being tracked here.
            <?php endif; ?>
        </p>
    </div>
    <?php endif; ?>

    <!-- Status / alert bar -->
    <?php if (($drawdownMessage || $dailyLossMessage) && $totalTrades > 0): ?>
    <div class="status-bar">
        <?php if($drawdownMessage && $drawdownState !== 'safe'): ?><div class="status-msg <?= htmlspecialchars($drawdownState??'safe') ?>"><?= htmlspecialchars($drawdownMessage) ?></div><?php endif; ?>
        <?php if($dailyLossMessage && $dailyLossState !== 'clear'): ?><div class="status-msg <?= htmlspecialchars($dailyLossState??'clear') ?>"><?= htmlspecialchars($dailyLossMessage) ?></div><?php endif; ?>
    </div>
    <?php endif; ?>

    <?php if(!empty($alerts)): ?>
    <div style="margin-bottom:1rem"><?php foreach($alerts as $a): ?>
        <?php $alertClass = 'alert-card ' . (in_array($a['type'], ['revenge_trading', 'impulsive_trading'], true) ? 'bad' : 'warn'); ?>
        <?php if (!empty($a['link'])): ?>
        <a href="<?= htmlspecialchars($a['link']) ?>" class="<?= $alertClass ?>" style="display:block;text-decoration:none;color:inherit;cursor:pointer"><?= htmlspecialchars($a['msg']) ?> <span style="text-decoration:underline;white-space:nowrap">View these trades &rarr;</span></a>
        <?php else: ?>
        <div class="<?= $alertClass ?>"><?= htmlspecialchars($a['msg']) ?></div>
        <?php endif; ?>
    <?php endforeach; ?></div>
    <?php endif; ?>

    <!-- ── Filters — same Instrument / Session / Classification / From / To
         set as Journal's Trade Journal section, GET-submitted so every
         metric below (not just the visible trade cards) is scoped to
         them. -->
    <form method="get" class="filter-bar" id="dashFilterForm">
        <?php if ($selectedStrategyId): ?><input type="hidden" name="strategy_id" value="<?= htmlspecialchars($selectedStrategyId) ?>"><?php endif; ?>
        <input type="hidden" name="account_view" value="<?= htmlspecialchars($dashAccountView) ?>">
        <select name="instrument" title="Filter by instrument" aria-label="Filter by instrument"><option value="">All Instruments</option>
            <?php foreach ($dashFilterInstruments as $i): ?><option value="<?= htmlspecialchars($i['instrument']) ?>" <?= $dashFilterInstrument === $i['instrument'] ? 'selected' : '' ?>><?= htmlspecialchars($i['instrument']) ?></option><?php endforeach; ?>
        </select>
        <select name="session" title="Filter by trading session" aria-label="Filter by trading session"><option value="">All Sessions</option>
            <?php foreach ($dashFilterSessions as $s): ?><option value="<?= $s ?>" <?= $dashFilterSession === $s ? 'selected' : '' ?>><?= $s ?></option><?php endforeach; ?>
        </select>
        <select name="classification" title="Filter by trade classification" aria-label="Filter by trade classification"><option value="">All Classifications</option>
            <?php foreach ($dashFilterClassifications as $c): $cv = $c['trade_classification']; ?><option value="<?= htmlspecialchars($cv) ?>" <?= $dashFilterClassification === $cv ? 'selected' : '' ?>><?= htmlspecialchars(ucwords(str_replace('_', ' ', $cv))) ?></option><?php endforeach; ?>
        </select>
        <span class="filter-date-label" title="Filter trades from this date onward. Leave &quot;To&quot; empty to show only this single day.">From</span>
        <input type="date" name="from" value="<?= htmlspecialchars($dashFilterFrom) ?>" title="From date. Leave &quot;To&quot; empty to show only trades on this single day" aria-label="From date">
        <span class="filter-date-label" title="Filter trades up to and including this date.">To</span>
        <input type="date" name="to" value="<?= htmlspecialchars($dashFilterTo) ?>" title="To date, inclusive of this day" aria-label="To date">
        <button type="submit" class="btn btn-primary btn-sm">Filter</button>
        <?php if ($dashHasActiveFilters): ?><a href="<?= htmlspecialchars(dashUrl(['instrument' => null, 'session' => null, 'classification' => null, 'from' => null, 'to' => null])) ?>" class="btn btn-sm" style="align-self:center">Clear</a><?php endif; ?>
    </form>
    <script>
    (function () {
        var form = document.getElementById('dashFilterForm');
        if (!form) return;
        Array.prototype.forEach.call(form.querySelectorAll('select, input[type="date"]'), function (el) {
            el.addEventListener('change', function () { form.submit(); });
        });
    })();
    </script>

    <!-- ── Overview — one consolidated card instead of 6 separate tiles ── -->
    <div class="section-card">
        <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:.5rem;margin-bottom:1.1rem">
            <div>
                <h3 style="margin-bottom:.15rem">Overview <span style="font-size:.75rem;font-weight:400;color:var(--foreground-dim)">- <?= htmlspecialchars($strategyDisplayName) ?></span></h3>
                <p style="font-size:.75rem;color:var(--foreground-dim)"><?= htmlspecialchars(ucfirst($selectedStrategy['trading_style'] ?? '')) ?></p>
            </div>
            <!-- 2026-08-17: the Overview card's own "Validation Tracker →"
                 link was removed — redundant with the page-header-level
                 "Open Validation Tracker →" link, per explicit instruction. -->
        </div>
        <div class="metric-mini-grid">
            <div class="metric-mini hero">
                <span class="mm-label">Total P&amp;L <?= $dashCurrency !== 'USD' ? '(' . htmlspecialchars($dashCurrency) . ')' : '' ?></span>
                <span class="mm-value <?= pnlClass($totalPnl) ?>"><?= CurrencyService::format($totalPnl, $dashCurrency, true) ?></span>
                <?php if($bestTrade !== null): ?><span class="mm-sub">Best <?= CurrencyService::format($bestTrade, $dashCurrency, true) ?></span><?php endif; ?>
            </div>
            <div class="metric-mini hero">
                <span class="mm-label">Win Rate</span>
                <span class="mm-value"><?= $winRate !== null ? $winRate.'%' : '-' ?></span>
                <?php if($totalTrades > 0 && $totalTrades < 20): ?>
                <span class="mm-sub" style="color:var(--warning)">Under 20 trades</span>
                <?php elseif($totalTrades > 0): ?>
                <span class="mm-sub"><?= $wins ?> Win<?= $wins !== 1 ? 's' : '' ?> · <?= $losses ?> Loss<?= $losses !== 1 ? 'es' : '' ?> · <?= $breakevens ?> Breakeven<?= $breakevens !== 1 ? 's' : '' ?></span>
                <?php endif; ?>
            </div>
            <div class="metric-mini">
                <span class="mm-label">Closed Trades</span>
                <span class="mm-value"><?= $totalTrades ?></span>
                <?php if($worstTrade !== null): ?><span class="mm-sub">Worst <?= CurrencyService::format($worstTrade, $dashCurrency) ?></span><?php endif; ?>
            </div>
            <div class="metric-mini">
                <span class="mm-label">Avg Risk</span>
                <span class="mm-value"><?= $avgRiskPct !== null ? $avgRiskPct.'%' : '-' ?></span>
            </div>
        </div>
    </div>

    <!-- ── Performance Metrics — consolidated (Expectancy / Profit Factor /
         Equity Efficiency / Risk of Ruin / Best & Worst Streak) ────────── -->
    <?php if($totalTrades > 0): ?>
    <div class="section-card">
        <h3>Performance Metrics</h3>
        <div class="metric-mini-grid">
            <div class="metric-mini">
                <span class="mm-label">Expectancy</span>
                <span class="mm-value <?= pnlClass($expectancy??0) ?>"><?= $expectancy !== null ? CurrencyService::format($expectancy, $dashCurrency, true) : '-' ?></span>
                <span class="mm-sub">per trade, avg</span>
            </div>
            <div class="metric-mini">
                <span class="mm-label">Profit Factor</span>
                <span class="mm-value"><?= $profitFactor !== null ? number_format($profitFactor,2) : '-' ?></span>
                <span class="mm-sub">gross win / gross loss</span>
            </div>
            <div class="metric-mini">
                <span class="mm-label">Equity Efficiency</span>
                <span class="mm-value"><?= $equityEfficiency !== null ? number_format($equityEfficiency,2) : '-' ?></span>
            </div>
            <?php if ($isElite): ?>
            <div class="metric-mini">
                <span class="mm-label">Risk of Ruin</span>
                <span class="mm-value" style="color:<?= $riskOfRuin !== null ? ($riskOfRuin > 20 ? 'var(--negative)' : ($riskOfRuin > 5 ? 'var(--warning)' : 'var(--positive)')) : 'var(--foreground-dim)' ?>"><?= $riskOfRuin !== null ? $riskOfRuin.'%' : '-' ?></span>
                <?php if($riskOfRuinMessage): ?><span class="mm-sub"><?= htmlspecialchars($riskOfRuinMessage) ?></span><?php endif; ?>
            </div>
            <?php else: ?>
            <div class="metric-mini">
                <span class="mm-label">Risk of Ruin</span>
                <span class="mm-value" style="color:var(--foreground-dim)">-</span>
                <span class="mm-sub"><a href="/traderalign/pricing" style="color:var(--primary);text-decoration:none">Upgrade to Elite →</a></span>
            </div>
            <?php endif; ?>
            <div class="metric-mini">
                <span class="mm-label">Best Streak</span>
                <span class="mm-value positive"><?= $maxConsecWins ?> Win<?= $maxConsecWins !== 1 ? 's' : '' ?></span>
            </div>
            <div class="metric-mini">
                <span class="mm-label">Worst Streak</span>
                <span class="mm-value negative"><?= $maxConsecLosses ?> Loss<?= $maxConsecLosses !== 1 ? 'es' : '' ?></span>
            </div>
        </div>
    </div>

    <!-- Upgrade nudge cards for starter/pro -->
    <?php if ($tier === 'starter'): ?>
    <div class="upgrade-card starter-tier">
        <span class="uc-icon">📈</span>
        <h2 class="uc-title">Ready to go deeper with your trading analytics?</h2>
        <p class="uc-body">You're already tracking your trades and seeing your basic stats. The next level gives you session and instrument breakdowns, expectancy calculations, MT5 broker reconciliation, and AI-powered strategy polish. See exactly where your edge lives and where it leaks.</p>
        <a href="/traderalign/pricing" class="btn btn-primary btn-lg uc-btn">Upgrade to Pro <span class="arrow">→</span></a>
    </div>
    <?php endif; ?>
    <?php if ($tier === 'pro'): ?>
    <div class="upgrade-card pro-tier">
        <span class="uc-icon">🏦</span>
        <h2 class="uc-title">Ready for institutional-grade analytics?</h2>
        <p class="uc-body">Your trading data has real statistical power now. Elite unlocks Risk of Ruin, prop firm phase tracking, quarterly and annual reports, and deeper behavioural analytics including drawdown-state classification. See not just what happened, but whether you hold discipline under pressure.</p>
        <a href="/traderalign/pricing" class="btn btn-primary btn-lg uc-btn">Upgrade to Elite <span class="arrow">→</span></a>
    </div>
    <?php endif; ?>

    <!-- ── Charts ─────────────────────────────────────── -->
    <div class="section-grid">
        <div class="section-card">
            <h3>Performance by Session</h3>
            <?php if(!empty($sessionRows)): ?><div class="chart-wrap"><canvas id="sessionChart"></canvas></div><?php else: ?><div class="empty-state"><p>No session data available yet.</p></div><?php endif; ?>
        </div>
        <div class="section-card">
            <h3>Performance by Instrument</h3>
            <?php if(!empty($instrumentRows)): ?><div class="chart-wrap"><canvas id="instrumentChart"></canvas></div><?php else: ?><div class="empty-state"><p>No instrument data available yet.</p></div><?php endif; ?>
        </div>
    </div>

    <!-- ── Strategy Performance — real rule-adherence $ split ──────────── -->
    <?php
    // Sourced from AnalyticsEngine::getFourStagePeriodSummary()'s
    // rule_adherence bucket (see engine.php) — the same real per-trade
    // adherence signal Strategy Adherence used to average into a
    // percentage, now split by real $ instead. Scoped to exactly the
    // Instrument/Session/Classification/From/To filters selected above.
    $ruleAdh = $fsSummary['rule_adherence'] ?? null;
    ?>
    <div class="section-card">
        <h3>Strategy Performance <span style="font-size:.75rem;font-weight:400;color:var(--foreground-dim)">- <?= htmlspecialchars($strategyDisplayName) ?></span></h3>
        <?php if ($ruleAdh && $ruleAdh['evaluable_count'] > 0): ?>
        <div class="rule-split">
            <div class="rule-split-item followed">
                <div class="rsi-top"><span class="rsi-icon">✅</span><span class="rsi-label">Followed Your Rules</span></div>
                <span class="rsi-value positive"><?= CurrencyService::format($ruleAdh['followed_pnl'], $dashCurrency, true) ?></span>
                <span class="rsi-count"><?= $ruleAdh['followed_count'] ?> trade<?= $ruleAdh['followed_count'] !== 1 ? 's' : '' ?></span>
            </div>
            <div class="rule-split-item broken">
                <div class="rsi-top"><span class="rsi-icon">❌</span><span class="rsi-label">Broke Your Rules</span></div>
                <span class="rsi-value <?= pnlClass($ruleAdh['broken_pnl']) ?>"><?= CurrencyService::format($ruleAdh['broken_pnl'], $dashCurrency, true) ?></span>
                <span class="rsi-count"><?= $ruleAdh['broken_count'] ?> trade<?= $ruleAdh['broken_count'] !== 1 ? 's' : '' ?></span>
            </div>
        </div>
        <?php if ($ruleAdh['broken_count'] > 0):
            $rsCount = (int)$ruleAdh['broken_count'];
            $rsCountLabel = $rsCount . ' rule-breaking trade' . ($rsCount !== 1 ? 's' : '');
            $rsSeed = ($selectedStrategyId ?? '') . '|' . $rsCount . '|' . round((float)$ruleAdh['broken_pnl'], 2) . '|' . $dashPeriodLabel;
            $rsAmtAbs = CurrencyService::format(abs($ruleAdh['broken_pnl']), $dashCurrency);
            $rsAmtSigned = CurrencyService::format($ruleAdh['broken_pnl'], $dashCurrency, true);
            $rsPeriod = htmlspecialchars($dashPeriodLabel);
        ?>
            <?php if ($ruleAdh['broken_pnl'] < 0): ?>
            <p class="rule-summary negative"><?= dashPick($rsSeed, [
                "{$rsCountLabel} cost {$rsAmtAbs} in losses {$rsPeriod}.",
                "Trading outside your defined rules cost {$rsAmtAbs} {$rsPeriod}, {$rsCount} trade" . ($rsCount !== 1 ? 's' : '') . " in total.",
                "{$rsCountLabel} lost {$rsAmtAbs} {$rsPeriod}. A costly, direct reminder of why the rules exist.",
            ]) ?></p>
            <?php elseif ($ruleAdh['broken_pnl'] > 0): ?>
            <!-- Deliberately NOT worded as praise for the outcome: a
                 profitable rule-breaking trade is still a rule broken, per
                 explicit direction. The win doesn't validate the process. -->
            <p class="rule-summary"><?= dashPick($rsSeed, [
                "{$rsCountLabel} netted {$rsAmtSigned} {$rsPeriod}. Outcome doesn't validate the process. A win outside your rules is still a rule broken.",
                "Trades outside your defined rules made {$rsAmtSigned} {$rsPeriod}. A profitable result doesn't make the decision correct, it just means it worked out this time.",
                "{$rsCountLabel} added {$rsAmtSigned} in profit {$rsPeriod}. That doesn't excuse the process; the same choice under different conditions could just as easily have cost you.",
            ]) ?></p>
            <?php else: ?>
            <p class="rule-summary"><?= dashPick($rsSeed, [
                "{$rsCountLabel} broke even {$rsPeriod}, still outside your defined rules.",
                "{$rsCountLabel} netted close to nothing {$rsPeriod}. No cost this time, but still outside your process.",
            ]) ?></p>
            <?php endif; ?>
        <?php else: ?>
            <p class="rule-summary positive">No rule-violating trades <?= htmlspecialchars($dashPeriodLabel) ?>: full discipline.</p>
        <?php endif; ?>
        <?php else: ?>
        <div class="empty-state"><p>No trades with a declared risk limit to check adherence against <?= htmlspecialchars($dashPeriodLabel) ?>.</p></div>
        <?php endif; ?>
    </div>
    <?php endif; /* $totalTrades > 0 */ ?>

    <!-- ── Recent Trades — prominent own section ─────── -->
    <?php if(!empty($recentTrades)): ?>
    <div class="trades-section">
        <div class="trades-section-header">
            <div class="trades-section-title">
                Recent Trades
                <?php if($selectedStrategy): ?>
                <span class="strategy-scope">- <?= htmlspecialchars($strategyDisplayName) ?></span>
                <?php endif; ?>
            </div>
            <?php // 2026-08-18: "Open Validation Tracker →" removed from here,
            // per instruction — the one "← Back to Main Workflow" control
            // above (page header area) is the single instance kept; this
            // spot renders nothing now instead of a second copy. ?>
        </div>

        <div class="trades-section-body">
            <?php if ($dashHasActiveFilters): ?>
            <div class="filter-results-count">
                <?= $totalTradesCount ?> trade<?= $totalTradesCount !== 1 ? 's' : '' ?> match your filters
            </div>
            <?php endif; ?>

            <div id="tradeCardsContainer">
                <?php
                $globalIndex = 0;
                foreach ($tradesByDate as $dateKey => $dayTrades):
                    $summary = $dailySummaries[$dateKey];
                    $dayLabel = date('l, F j, Y', strtotime($dateKey));
                    $dayPnlFormatted = ($summary['pnl'] >= 0 ? '+' : '') . number_format($summary['pnl'], 2);
                    $dayPnlClass = $summary['pnl'] > 0 ? 'positive' : ($summary['pnl'] < 0 ? 'negative' : '');
                    // Real fix for the "long list of date headers with no
                    // visible cards beneath them" bug: $tradesByDate/dates
                    // are always rendered for every day in the fetched
                    // window (up to DASHBOARD_TRADES_FETCH_LIMIT), but only
                    // the first DASHBOARD_TRADES_DEFAULT_VISIBLE trades are
                    // meant to be visible by default — the header/group for
                    // a day whose ENTIRE trade set falls beyond that cutoff
                    // must be hidden right alongside its (already-hidden)
                    // cards, not left visibly showing a date/count/P&L with
                    // nothing under it. $groupStartIndex is this day's first
                    // trade's global index (indices increase monotonically
                    // across days, newest-first) — if that's already past
                    // the cutoff, every trade in the group is too.
                    $groupStartIndex = $globalIndex;
                    $groupHiddenClass = $groupStartIndex >= DASHBOARD_TRADES_DEFAULT_VISIBLE ? ' hidden-card' : '';
                ?>
                <div class="trade-date-header<?= $groupHiddenClass ?>" data-group-start="<?= $groupStartIndex ?>">
                    <div class="tdh-left">
                        <span class="tdh-date"><?= htmlspecialchars($dayLabel) ?></span>
                        <span class="tdh-count"><?= $summary['trades'] ?> trade<?= $summary['trades'] !== 1 ? 's' : '' ?></span>
                    </div>
                    <span class="tdh-pnl <?= $dayPnlClass ?>"><?= $dayPnlFormatted ?></span>
                </div>

                <div class="trade-cards trade-day-group<?= $groupHiddenClass ?>" data-date="<?= $dateKey ?>" data-group-start="<?= $groupStartIndex ?>">
                    <?php foreach ($dayTrades as $t):
                        $index = $globalIndex++;
                        $pp    = (float)($t['max_potential_profit'] ?? $t['projected_pnl'] ?? ((float)($t['risk_per_position']??0)*(float)($t['risk_reward_tp1']??0)));
                        $m     = $t['mood_before'] ?? null;
                        $e     = $t['energy_before'] ?? null;
                        $c     = $t['confidence_before'] ?? null;
                        $cl    = $t['trade_classification'] ?? null;
                        $pnl   = dashEffectivePnl($t);
                        $tid   = $t['id'];
                        $tag   = behaviouralTagline($m, $e, $c);
                        $plannedEntry = (float)($t['entry_price'] ?? 0);
                        $plannedSl    = (float)($t['sl_price'] ?? 0);
                        $plannedTp    = (float)($t['tp_price'] ?? 0);
                        $lossAmount   = (float)($t['max_potential_loss'] ?? $t['risk_per_position'] ?? 0);
                        $plannedLots  = (float)($t['lots_per_position'] ?? 0);
                        $ppFmt     = number_format($pp, 2, '.', '');
                        $lossFmt   = number_format($lossAmount, 2, '.', '');
                        $entryFmt  = number_format($plannedEntry, 5, '.', '');
                        $slFmt     = number_format($plannedSl, 5, '.', '');
                        $tpFmt     = number_format($plannedTp, 5, '.', '');
                        $lotsFmt   = number_format($plannedLots, 2, '.', '');
                        // "Default visible" cutoff is purely a DOM/CSS concern now — controlled
                        // by DASHBOARD_TRADES_DEFAULT_VISIBLE, not by how many rows SQL fetched.
                        $cardClass = $index >= DASHBOARD_TRADES_DEFAULT_VISIBLE ? ' hidden-card' : '';
                        // Win/loss accent class
                        $accentClass = $pnl > 0 ? 'tc-win' : ($pnl < 0 ? 'tc-loss' : 'tc-be');
                    ?>
                    <div class="trade-card <?= $accentClass . $cardClass ?>"
                         data-trade-index="<?= $index ?>">

                        <div class="tc-header">
                            <span class="tc-instrument"><?= htmlspecialchars($t['instrument']) ?></span>
                            <span class="tc-dir"><?= $t['direction']==='long'?'Long':'Short' ?></span>
                            <span class="tc-pnl <?= pnlClass($pnl) ?>"><?= CurrencyService::format($pnl, $t['currency'] ?? 'USD', true) ?></span>
                        </div>

                        <div class="tc-meta">
                            <?php if($cl): ?><span class="tc-tag <?= classTagClass($cl) ?>"><span class="tc-tag-dot"></span><?= htmlspecialchars(ucwords(str_replace('_',' ',$cl))) ?></span><?php endif; ?>
                            <?php if($t['status']==='draft'): ?><span class="tc-tag tc-tag-neutral"><span class="tc-tag-dot"></span>Draft</span><?php endif; ?>
                        </div>

                        <?php if($tag !== 'Not recorded'): ?>
                        <div class="tc-behaviour"><?= htmlspecialchars($tag) ?></div>
                        <?php endif; ?>

                        <div class="tc-footer">
                            <div class="tc-footer-left">
                                <span class="tc-time"><?= date('M j, g:i A', strtotime($t['closed_at'] ?? $t['captured_at'])) ?></span>
                                <a href="/traderalign/drafts" class="journal-link" style="font-size:.67rem">← Drafts</a>
                            </div>
                            <div style="display:flex;align-items:center;gap:.4rem;flex-wrap:wrap">
                                <a href="/traderalign/journal?id=<?= $tid ?>" class="journal-link">Journal →</a>
                                <?php if(in_array($t['status'],['draft','open'])): ?>
                                <button class="btn-sm btn-won" onclick="resolveTrade('<?= $tid ?>','won',<?= $entryFmt ?>,<?= $slFmt ?>,<?= $tpFmt ?>,<?= $lotsFmt ?>,<?= $ppFmt ?>)">Won</button>
                                <button class="btn-sm btn-lost" onclick="resolveTrade('<?= $tid ?>','lost',<?= $entryFmt ?>,<?= $slFmt ?>,<?= $slFmt ?>,<?= $lotsFmt ?>,-<?= $lossFmt ?>)">Lost</button>
                                <?php endif; ?>
                            </div>
                        </div>

                        <?php if(in_array($t['status'],['draft','open'])): ?>
                        <div class="resolve-form" id="resolve-<?= $tid ?>">
                            <form method="POST">
                                <?= Csrf::field() ?>
                                <input type="hidden" name="resolve_trade_id" value="<?= $tid ?>">
                                <input type="hidden" name="outcome" id="outcome-<?= $tid ?>">
                                <div class="plan-row">Plan (sealed): Entry <span id="planEntry-<?= $tid ?>"><?= number_format($plannedEntry,5) ?></span> · SL <span id="planSl-<?= $tid ?>"><?= number_format($plannedSl,5) ?></span> · TP <span id="planTp-<?= $tid ?>"><?= number_format($plannedTp,5) ?></span> · Lots <span id="planLots-<?= $tid ?>"><?= number_format($plannedLots,2) ?></span></div>
                                <div class="input-row">
                                    <label>Entry</label><input type="number" name="actual_entry_price" id="actEntry-<?= $tid ?>" step="0.00001" oninput="calcPnl('<?= $tid ?>')">
                                    <label>Exit</label><input type="number" name="actual_exit_price" id="actExit-<?= $tid ?>" step="0.00001" oninput="calcPnl('<?= $tid ?>')">
                                    <label>SL</label><input type="number" name="actual_sl_price" id="actSl-<?= $tid ?>" step="0.00001">
                                    <label>Lots</label><input type="number" name="actual_lots" id="actLots-<?= $tid ?>" step="0.01" value="<?= $lotsFmt ?>">
                                </div>
                                <div class="input-row">
                                    <label>Instrument</label><input type="text" name="actual_instrument" id="actInstrument-<?= $tid ?>" value="<?= htmlspecialchars($t['instrument']) ?>" style="width:130px" title="Confirm or correct the instrument actually traded, pre-filled from the plan.">
                                    <label>Timeframe</label>
                                    <select name="actual_timeframe" id="actTimeframe-<?= $tid ?>" style="width:100px;height:2rem;background:var(--background);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--foreground);font-family:var(--font-mono);font-size:.78rem" title="Which chart timeframe this trade was actually taken on (optional).">
                                        <option value="">-</option>
                                        <?php foreach (timeframeOptions() as $tf): ?>
                                        <option value="<?= $tf ?>"><?= $tf ?></option>
                                        <?php endforeach; ?>
                                    </select>
                                </div>
                                <div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
                                    <span style="font-size:.75rem;color:var(--foreground-dim)">Actual P&L: $</span>
                                    <input type="number" name="actual_pnl" id="pnl-<?= $tid ?>" step="0.01" required>
                                    <span class="pnl-display" id="pnlCalc-<?= $tid ?>" style="font-size:.7rem;color:var(--foreground-dim)"></span>
                                    <button type="submit" class="btn btn-primary btn-sm">Confirm</button>
                                    <button type="button" class="btn-ghost-sm" onclick="hideResolve('<?= $tid ?>')">Cancel</button>
                                </div>
                            </form>
                        </div>
                        <?php endif; ?>
                    </div>
                    <?php endforeach; ?>
                </div>
                <?php endforeach; ?>
            </div>

            <?php
            // Show the toggle button whenever we actually rendered more cards than the
            // default-visible count — this matches the real number of DOM cards, not a
            // SQL-capped count that could never exceed the default.
            $renderedCardCount = $globalIndex; // total cards actually output above
            if ($renderedCardCount > DASHBOARD_TRADES_DEFAULT_VISIBLE):
            ?>
            <button class="toggle-trades-btn" id="toggleTradesBtn" onclick="toggleAllTrades()">
                Show All Trades (<?= $renderedCardCount ?>)
            </button>
            <?php endif; ?>
        </div>
    </div>

    <?php else: ?>
    <div class="section-card"><div class="empty-state">
        <div style="font-size:2rem;margin-bottom:.75rem">📓</div>
        <?php if ($dashHasActiveFilters): ?>
        <h3 style="color:var(--foreground);margin-bottom:.5rem">No trades match your filters</h3>
        <p>Try widening the Instrument/Session/Classification/date range above.</p>
        <a class="btn btn-sm" style="margin-top:1rem" href="<?= htmlspecialchars(dashUrl(['instrument' => null, 'session' => null, 'classification' => null, 'from' => null, 'to' => null])) ?>">Clear filters</a>
        <?php elseif ($selectedStrategy && $dashAccountTypeCounts[$dashOtherAccountTypeDb] > 0): ?>
        <!-- Zero trades in the account type currently being viewed, but the
             other type has real data for this same strategy — the "handle
             beautifully" case: point the trader at the toggle instead of a
             generic dead end. -->
        <h3 style="color:var(--foreground);margin-bottom:.5rem">No <?= $dashAccountView === 'live' ? 'Live' : 'Prop Firm' ?> trades for this strategy yet</h3>
        <p><?= $dashAccountTypeCounts[$dashOtherAccountTypeDb] ?> trade<?= $dashAccountTypeCounts[$dashOtherAccountTypeDb] !== 1 ? 's' : '' ?> found on your <?= $dashOtherAccountView === 'live' ? 'Live' : 'Prop Firm' ?> accounts instead.</p>
        <a class="btn btn-primary btn-lg" style="margin-top:1rem" href="<?= htmlspecialchars(dashUrl(['account_view' => $dashOtherAccountView])) ?>">View <?= $dashOtherAccountView === 'live' ? 'Live' : 'Prop Firm' ?> Trades →</a>
        <?php else: ?>
        <h3 style="color:var(--foreground);margin-bottom:.5rem"><?= $selectedStrategy ? 'No trades for this strategy yet' : 'Your journal is empty' ?></h3>
        <p><?= $selectedStrategy ? 'Try selecting a different strategy to see its data.' : 'Start by ticking the checklist and using the calculator.' ?></p>
        <?php if (!$selectedStrategy): ?><a class="btn btn-primary btn-lg" style="margin-top:1rem" href="/traderalign/trading-center">Start a Trade</a><?php endif; ?>
        <?php endif; ?>
    </div></div>
    <?php endif; ?>

    <?php endif; /* not free */ ?>
</main>

<script>
// 2026-08-20: the old sessionStorage['active_strategy_id'] read/redirect
// that used to live here is retired. It was superseded, not merely
// duplicated, by the real server-side active-strategy pointer this page
// now resolves before it ever renders (see the PHP block above,
// spGetActiveStrategyId()/spRecordActiveStrategy() in
// src/StrategyPicker.php). Keeping the old client-side redirect running
// alongside the new server-side fix would have been actively harmful, not
// just redundant: a stale sessionStorage value from a previous day/tab
// could silently override the correct, freshly-resolved strategy this
// page had already rendered, undoing the fix it was meant to support. See
// migration_add_user_active_strategy.sql for the full incident writeup.

// 2026-08-18: "← Back to Main Workflow" — replaces the old "Open
// Validation Tracker →" shortcut's spot in the page header (the sidebar's
// own "Validation Tracker" icon already covers that link while
// validating). Same POST /api/validation/return-to-main endpoint the
// prior sidebar-based control used (ValidationService::returnToMain() is
// the real, server-side-enforced boundary either way) — does not
// graduate or touch the validation strategy/trades in any way, only
// flips workflow_state back to 'main'.
function dashReturnToMain(el) {
    if (!confirm('Return to your Main Workflow? Your validation strategy and its trades stay exactly as they are. You can pick up validating again later.')) return;
    el.style.pointerEvents = 'none';
    el.style.opacity = '.6';
    fetch('/traderalign/api/validation/return-to-main', { method: 'POST' })
        .then(function(r) { return r.json(); })
        .then(function(d) {
            if (d.success) {
                window.location.href = '/traderalign/dashboard';
            } else {
                el.style.pointerEvents = '';
                el.style.opacity = '';
                alert(d.error || 'Could not return to Main Workflow. Please try again.');
            }
        })
        .catch(function() {
            el.style.pointerEvents = '';
            el.style.opacity = '';
            alert('Could not return to Main Workflow. Please try again.');
        });
}

function resolveTrade(id, outcome, entry, sl, exit, lots, autoPnl) {
    document.getElementById('outcome-' + id).value = outcome;
    document.getElementById('actEntry-' + id).value = entry.toFixed(5);
    document.getElementById('actExit-' + id).value = exit.toFixed(5);
    document.getElementById('actSl-' + id).value = sl.toFixed(5);
    document.getElementById('actLots-' + id).value = lots.toFixed(2);
    document.getElementById('pnl-' + id).value = autoPnl.toFixed(2);
    document.getElementById('resolve-' + id).classList.add('show');
    calcPnl(id);
}
function hideResolve(id) { document.getElementById('resolve-' + id).classList.remove('show'); }
function calcPnl(id) {
    var entry = parseFloat(document.getElementById('actEntry-' + id).value) || 0;
    var exit  = parseFloat(document.getElementById('actExit-'  + id).value) || 0;
    var planSl    = parseFloat(document.getElementById('planSl-' + id).textContent) || 0;
    var planEntry = parseFloat(document.getElementById('planEntry-' + id).textContent) || 0;
    var dir = planSl < planEntry ? 'long' : 'short';
    var pnl = (entry && exit) ? (dir === 'long' ? exit - entry : entry - exit) : 0;
    document.getElementById('pnlCalc-' + id).textContent = pnl !== 0 ? '(calc: ' + (pnl >= 0 ? '+' : '') + pnl.toFixed(5) + ' /unit)' : '';
}

// Show All / Show Less — purely a reveal of already-fetched, already
// server-filtered rows beyond the default-visible cutoff (see
// DASHBOARD_TRADES_DEFAULT_VISIBLE). Narrowing WHICH trades appear here at
// all is now done server-side by the Instrument/Session/Classification/
// From/To filter form above, not client-side — see the 2026-08-12
// Dashboard cleanup note in CLAUDE.md.
var allTradesVisible = false;
var totalTradesCount = <?= isset($renderedCardCount) ? $renderedCardCount : 0 ?>;

function toggleAllTrades() {
    allTradesVisible = !allTradesVisible;
    var btn = document.getElementById('toggleTradesBtn');
    if (btn) btn.textContent = allTradesVisible ? 'Show Less' : 'Show All Trades (' + totalTradesCount + ')';
    var cutoff = <?= DASHBOARD_TRADES_DEFAULT_VISIBLE ?>;
    // Individual cards beyond the cutoff, keyed by their own real index —
    // unchanged from before.
    document.querySelectorAll('.trade-card[data-trade-index]').forEach(function(card) {
        var idx = parseInt(card.getAttribute('data-trade-index'), 10);
        if (idx >= cutoff) card.classList.toggle('hidden-card', !allTradesVisible);
    });
    // Date headers AND their trade-cards containers, keyed by the first
    // trade's index in that day (data-group-start, rendered server-side —
    // see the Recent Trades loop). A day whose entire trade set falls
    // beyond the cutoff toggles the SAME way its cards do, so "Show All"
    // never leaves a bare date/count/P&L row with nothing under it.
    document.querySelectorAll('[data-group-start]').forEach(function(el) {
        var start = parseInt(el.getAttribute('data-group-start'), 10);
        if (start >= cutoff) el.classList.toggle('hidden-card', !allTradesVisible);
    });
    // When collapsing back, scroll the toggle button into view so the user isn't
    // left scrolled deep into a now-hidden part of the list.
    if (!allTradesVisible && btn) {
        btn.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
    }
}
</script>

<?php if($tier !== 'free' && $totalTrades > 0): ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
    if (typeof Chart === 'undefined') return;
    Chart.defaults.color = '#B2B5BE';
    Chart.defaults.borderColor = '#2A2E39';
    Chart.defaults.font.family = 'Inter, system-ui, sans-serif';
    Chart.defaults.font.size = 11;

    <?php if(!empty($sessionRows)): ?>
    try {
        var sc = document.getElementById('sessionChart');
        if (sc) {
            var sd = {
                labels: <?= json_encode(array_column($sessionRows, 'session')) ?>,
                trades: <?= json_encode(array_map('intval', array_column($sessionRows, 'trades'))) ?>,
                wins:   <?= json_encode(array_map('intval', array_column($sessionRows, 'wins'))) ?>,
                pnl:    <?= json_encode(array_map(function($r){return round((float)$r['total_pnl'],2);},$sessionRows)) ?>
            };
            new Chart(sc, { type:'bar', data:{ labels:sd.labels, datasets:[{label:'Trades',data:sd.trades,backgroundColor:'#60A5FA',borderRadius:4,barPercentage:.6}] }, options:{ responsive:true,maintainAspectRatio:false, plugins:{legend:{display:false},tooltip:{callbacks:{label:function(c){var i=c.dataIndex;return['Trades: '+c.parsed.y,'Wins: '+(sd.wins[i]||0),'P&L: $'+(sd.pnl[i]||0).toFixed(2)];}}}} , scales:{x:{grid:{display:false},ticks:{maxRotation:45}},y:{grid:{color:'#2A2E39'},beginAtZero:true}}}});
        }
    } catch(e) { console.error('Session chart:', e); }
    <?php endif; ?>

    <?php if(!empty($instrumentRows)): ?>
    try {
        var ic = document.getElementById('instrumentChart');
        if (ic) {
            var id = {
                labels: <?= json_encode(array_column($instrumentRows, 'instrument')) ?>,
                trades: <?= json_encode(array_map('intval', array_column($instrumentRows, 'trades'))) ?>,
                wins:   <?= json_encode(array_map('intval', array_column($instrumentRows, 'wins'))) ?>,
                pnl:    <?= json_encode(array_map(function($r){return round((float)$r['total_pnl'],2);},$instrumentRows)) ?>,
                colors: <?= json_encode(array_map(function($r){return (float)$r['total_pnl']>=0?'#26A69A':'#EF5350';},$instrumentRows)) ?>
            };
            new Chart(ic, { type:'bar', data:{ labels:id.labels, datasets:[{label:'P&L ($)',data:id.pnl,backgroundColor:id.colors,borderRadius:4,barPercentage:.6}] }, options:{ responsive:true,maintainAspectRatio:false, plugins:{legend:{display:false},tooltip:{callbacks:{label:function(c){var i=c.dataIndex;var t=id.trades[i]||0;var w=id.wins[i]||0;var wr=t>0?((w/t)*100).toFixed(1):'0.0';return['P&L: $'+c.parsed.y.toFixed(2),'Trades: '+t,'Wins: '+w+' ('+wr+'%)'];}}}} , scales:{x:{grid:{display:false},ticks:{maxRotation:45}},y:{grid:{color:'#2A2E39'}}}}});
        }
    } catch(e) { console.error('Instrument chart:', e); }
    <?php endif; ?>
});
</script>
<?php endif; ?>

</body>
</html>