<?php
/**
 * Shared account-selector architecture — Trading Center redesign.
 *
 * This is the ONE real implementation of "search / recently-active-first /
 * first 5 + Show More / Show Less" account picking in this app. It was
 * extracted from public/journal.php's "Right Now" workspace (built
 * 2026-07-29) rather than reimplemented, per explicit instruction not to
 * build a second selector. public/journal.php's own renderRnAccountPicker()
 * now delegates to apRenderAccountPicker() below (see that file) so both
 * pages share one interaction contract and one CSS/JS pair
 * (assets/css/account-picker.css, assets/js/account-picker.js).
 *
 * apFetchAccounts() is deliberately a NEW, independent query function
 * rather than a forced generalization of journal.php's own
 * rnFetchActivePropAccounts()/rnFetchActiveLiveAccounts() — those two stay
 * completely untouched (lowest possible risk to journal.php's large,
 * carefully-verified history). This file only needs to reproduce their
 * ordering/filtering *semantics* (ORDER BY last_activity DESC, the same
 * "exclude breached/closed" vs "include exactly these states" rules) for
 * the new bucket shapes Trading Center needs (per-phase, breached,
 * eligible-for-promotion, per-execution-model) — a handful of lines of SQL,
 * not a shared data layer.
 */

/**
 * @param array $opts
 *   account_type      'prop'|'personal'                              (required)
 *   lifecycle_in      string[]  exact lifecycle_state allow-list       (optional)
 *   lifecycle_not_in  string[]  exact lifecycle_state exclude-list     (optional)
 *   challenge_type    string    exact match on challenge_accounts      (prop only, optional)
 *   execution_model   string    exact match on trading_accounts        (personal only, optional)
 *   execution_model_null bool   true = match rows where it's NULL      (personal only, optional)
 *   workflow_state    'main'|'validation'  which workflow's accounts    (optional, default 'main')
 *                      to return — see the 2026-08-17 correction below.
 *
 * 2026-08-16/17 workflow-isolation-leakage pass. Two corrections layered on
 * top of each other this same day, both from Kevin's own direct, repeated
 * correction — recorded here so the reasoning isn't lost:
 *   1. First attempt scoped only the trade-derived NUMBERS (Total Trades,
 *      activity ordering), leaving the account POOL itself unfiltered
 *      ("accounts are universal"). Kevin explicitly rejected this: "the
 *      entire account set is leaking between workflows."
 *   2. Second attempt filtered the account pool via a DERIVED rule (has a
 *      trade in this workflow, OR has zero trades at all). Kevin rejected
 *      this too, and for a real, concrete reason, not just principle: a
 *      brand-new Validation account with zero trades yet was
 *      indistinguishable from a brand-new Main account with zero trades
 *      yet under that rule, so BOTH still showed up in BOTH workflows —
 *      which is exactly how he ended up unable to find/select a real
 *      Validation account he'd just created, buried among Main accounts,
 *      and took a trade without journaling it as a direct result.
 *
 * The real fix: trading_accounts.workflow_context (new column,
 * migration_add_account_workflow_context.sql) is a first-class, persisted
 * property of the account itself, written ONCE at creation time
 * (api/accounts/create.php) from whichever workflow the user was actually
 * in at that moment, and never touched again (a phase promotion mutates
 * lifecycle_state on the same row — it does not touch this field). This
 * function now filters directly on that column — a plain equality check,
 * not a derived heuristic — so a freshly-created account with zero trades
 * is unambiguously owned by exactly one workflow from the instant it
 * exists, closing the exact gap the derived-rule version left open.
 * Defaults to 'main' so every pre-existing call site (this function
 * predates the Validation Workflow feature) gets the same isolation
 * without needing to opt in explicitly.
 */
function apFetchAccounts(string $userId, ScopedDb $db, array $opts): array {
    $accountType = $opts['account_type'] ?? 'prop';
    $workflowState = $opts['workflow_state'] ?? 'main';
    $where = ['ta.user_id = :uid', 'ta.deleted_at IS NULL', 'ta.is_active = 1', 'ta.workflow_context = :wfctx'];
    $params = [':uid' => $userId, ':wfctx' => $workflowState];
    // last_activity is still scoped to this same workflow's own trades —
    // now a belt-and-suspenders match with the account-level filter above
    // (an account can only ever carry trades matching its own
    // workflow_context, by construction, since draft.php derives a trade's
    // is_validation from its strategy, and strategies.php only ever offers
    // strategies matching the account's already-resolved workflow) rather
    // than the load-bearing filter it used to be.
    $activityScope = validationScopeSql($workflowState);

    if (!empty($opts['lifecycle_in'])) {
        $keys = [];
        foreach (array_values($opts['lifecycle_in']) as $i => $state) {
            $k = ":lc{$i}";
            $keys[] = $k;
            $params[$k] = $state;
        }
        $where[] = 'ta.lifecycle_state IN (' . implode(',', $keys) . ')';
    }
    if (!empty($opts['lifecycle_not_in'])) {
        $keys = [];
        foreach (array_values($opts['lifecycle_not_in']) as $i => $state) {
            $k = ":nlc{$i}";
            $keys[] = $k;
            $params[$k] = $state;
        }
        $where[] = "(ta.lifecycle_state IS NULL OR ta.lifecycle_state NOT IN (" . implode(',', $keys) . '))';
    }

    if ($accountType === 'prop') {
        if (!empty($opts['challenge_type'])) {
            $where[] = 'ca.challenge_type = :ctype';
            $params[':ctype'] = $opts['challenge_type'];
        }
        $sql = "SELECT ta.id, ta.label, ta.account_type, ta.lifecycle_state, ta.starting_balance, ta.current_balance,
                       ta.created_at, ta.last_selected_at, ta.last_selected_seq, ta.prop_firm_name, ta.platform, ta.broker_server, ta.broker_login,
                       ta.broker_password_encrypted, ta.account_number, ta.tags, ta.account_notes,
                       ca.id AS challenge_account_id, ca.challenge_type, ca.account_size, ca.rules, ca.leverage,
                       ca.attempt_number, ca.current_stage, ca.passed_at, ca.breached_at, ca.current_outcome,
                       (SELECT MAX(COALESCE(closed_at, captured_at)) FROM trades WHERE account_id = ta.id AND deleted_at IS NULL AND {$activityScope}) AS last_activity
                FROM trading_accounts ta
                JOIN challenge_accounts ca ON ca.account_id = ta.id AND ca.deleted_at IS NULL
                WHERE " . implode(' AND ', $where) . "
                ORDER BY last_activity DESC";
    } else {
        if (array_key_exists('execution_model', $opts) && $opts['execution_model'] !== null) {
            $where[] = 'ta.execution_model = :execm';
            $params[':execm'] = $opts['execution_model'];
        } elseif (!empty($opts['execution_model_null'])) {
            $where[] = 'ta.execution_model IS NULL';
        }
        $where[] = "ta.account_type = 'personal'";
        $sql = "SELECT ta.id, ta.label, ta.account_type, ta.lifecycle_state, ta.starting_balance, ta.current_balance,
                       ta.created_at, ta.last_selected_at, ta.last_selected_seq, ta.currency, ta.net_deposits, ta.net_withdrawals, ta.initial_deposit, ta.peak_equity,
                       ta.execution_model, ta.platform, ta.broker_server, ta.broker_login,
                       ta.broker_password_encrypted, ta.account_number, ta.tags, ta.account_notes, ta.current_outcome,
                       (SELECT MAX(COALESCE(closed_at, captured_at)) FROM trades WHERE account_id = ta.id AND deleted_at IS NULL AND {$activityScope}) AS last_activity
                FROM trading_accounts ta
                WHERE " . implode(' AND ', $where) . "
                ORDER BY last_activity DESC";
    }

    return $db->raw($sql, $params);
}

/**
 * Which lifecycle_state values mean "this account finished a phase and is
 * ready to promote" for a given challenge type — the same set
 * public/trading-center.php's tcStageButtonsForChallenge() already uses for
 * its 'continue' bucket's lifecycle_in list, pulled out here as the single
 * source of truth so public/add-prop-account.php's Continue Existing
 * Attempt picker can query the identical set without duplicating it.
 * instant_funding has no phase concept — nothing is ever "ready to
 * promote" for it, so it returns an empty list, not a fabricated one.
 */
function apPromotableLifecycleStates(string $challengeType): array {
    return match ($challengeType) {
        'one_step' => ['phase_1_complete'],
        'two_step' => ['phase_1_complete', 'phase_2_complete'],
        'three_step' => ['phase_1_complete', 'phase_2_complete', 'phase_3_complete'],
        default => [],
    };
}

/** Same labels journal.php's rnChallengeTypeLabel() uses — duplicated here (trivial, pure) rather than requiring journal.php. */
function apChallengeTypeLabel(?string $type): string {
    return match ($type) {
        'one_step' => '1 Step Challenge',
        'two_step' => '2 Step Challenge',
        'three_step' => '3 Step Challenge',
        'instant_funding' => 'Instant Funding',
        default => 'Challenge',
    };
}

/**
 * Same badge rule journal.php's now-deleted rnAccountLifecycleBadge()
 * used to duplicate (Single Source of Truth migration Wave 5e — that copy
 * had zero real call sites, confirmed via a full-repo grep; this shared
 * function is the one live implementation), PLUS an explicit `breached`
 * case (the old copy had none — it silently fell through to "Active" —
 * harmless there only because journal's own fetch functions already
 * exclude breached accounts before this ever runs on one. Reused here for
 * buckets that DO include breached accounts, so the explicit case is
 * required, not cosmetic.)
 */
function apAccountLifecycleBadge(?string $lifecycleState): array {
    $s = $lifecycleState ?? '';
    if ($s === 'breached') return ['Breached', 'rn-badge-red'];
    if ($s === 'closed') return ['Closed', 'rn-badge-neutral'];
    if ($s === 'funded_active') return ['Funded', 'rn-badge-gold'];
    if (str_contains($s, '_complete')) return ['Passed', 'rn-badge-green'];
    return ['Active', 'rn-badge-blue'];
}

/** Plain-English lifecycle stage, for the glow indicator and Account Summary's "Current Stage" row. */
function apHumanizeLifecycleState(?string $lifecycleState): string {
    $s = $lifecycleState ?? '';
    return match (true) {
        $s === '' => 'Active',
        $s === 'breached' => 'Breached',
        $s === 'closed' => 'Closed',
        $s === 'funded_active' => 'Funded',
        $s === 'phase_1_active' => 'Phase 1 Active',
        $s === 'phase_2_active' => 'Phase 2 Active',
        $s === 'phase_3_active' => 'Phase 3 Active',
        $s === 'phase_1_complete' => 'Phase 1 Complete',
        $s === 'phase_2_complete' => 'Phase 2 Complete',
        $s === 'phase_3_complete' => 'Phase 3 Complete',
        default => ucwords(str_replace('_', ' ', $s)),
    };
}

/**
 * The glowing "🟢 Phase 2 Active" indicator — built from the same
 * @keyframes rn-pulse + badge family already in account-picker.css.
 * Deliberately NEVER glows for a breached account (spec: "Never point to
 * Breached") — that case renders the plain, static, non-glow badge instead.
 */
function apLifecycleGlowBadge(?string $lifecycleState): string {
    $s = $lifecycleState ?? '';
    $label = apHumanizeLifecycleState($lifecycleState);
    if ($s === 'breached' || $s === 'closed') {
        [$badgeLabel, $badgeClass] = apAccountLifecycleBadge($lifecycleState);
        return '<span class="rn-badge ' . $badgeClass . '">' . htmlspecialchars($badgeLabel) . '</span>';
    }
    $goldClass = $s === 'funded_active' ? ' ap-glow-gold' : '';
    return '<span class="ap-glow-badge' . $goldClass . '"><span class="ap-glow-dot"></span>' . htmlspecialchars($label) . '</span>';
}

/**
 * The shared picker widget itself — search box, first-5-visible list with
 * the rest hidden behind Show More/Show Less. Markup/classes match
 * journal.php's original renderRnAccountPicker() so both pages share
 * assets/css/account-picker.css and assets/js/account-picker.js without
 * any duplication or divergence.
 *
 * @param callable|null $subLabelFn function(array $account): string — the
 *        small line under each account's name (e.g. "2 Step Challenge" or
 *        "Live Account"). Defaults to apChallengeTypeLabel() when omitted.
 * @param string|null $emptyMessage shown inside the panel when there are
 *        zero accounts to pick from. Left null by journal.php's delegate
 *        (its original had no such message) so that call site's output is
 *        unchanged; Trading Center passes a real message.
 * @param bool $expanded when true, this renders as a plain, static, in-page
 *        search box + account list — NOT the collapsed "Select account ▾"
 *        dropdown widget at all (no toggle button, no floating/absolutely-
 *        positioned popover). For entry points where the picker itself
 *        IS the page's whole purpose (e.g. "Continue Existing Attempt" —
 *        see CLAUDE.md's 2026-08-01 Trading Center fixes), reusing the
 *        dropdown widget just to force it open left a redundant, inert
 *        "Select account ▾" button sitting next to a popover-styled panel
 *        floating off to the side — confusing, and not actually "the
 *        promotion form" the page is supposed to be. Default false keeps
 *        every other existing call site (journal.php's Right Now picker,
 *        Trading Center's phase/breached/live buckets) rendering exactly
 *        as before — this is additive, not a behavior change for anyone
 *        who doesn't opt in.
 */
function apRenderAccountPicker(
    array $accounts,
    ?string $selectedId,
    string $baseUrl,
    array $baseParams = [],
    string $paramName = 'account_id',
    ?callable $subLabelFn = null,
    ?string $emptyMessage = null,
    bool $expanded = false,
    ?string $placeholderLabel = null
): void {
    $selected = null;
    foreach ($accounts as $a) { if ($a['id'] === $selectedId) { $selected = $a; break; } }
    // $placeholderLabel is a new, purely-cosmetic 9th argument (defaults
    // null, so every existing call site — omitting it — renders exactly the
    // old "Select account" text, unchanged). Lets a caller whose picker is
    // never really "selecting into" this widget itself (e.g. Trading
    // Center's live-accounts "Find Any Account" search, which always
    // navigates away rather than landing on a chosen state) label the
    // collapsed button meaningfully instead of a generic verb.
    $selectedLabel = $selected['label'] ?? ($placeholderLabel ?? 'Select account');
    $initialVisible = 5;
    $total = count($accounts);
    ?>
    <div class="rn-account-picker<?= $expanded ? ' rn-account-picker-inline' : '' ?>">
        <?php if (!$expanded): ?>
        <button type="button" class="rn-account-picker-btn" onclick="rnToggleAccountPicker(this)">
            <span class="rn-account-picker-current"><?= htmlspecialchars($selectedLabel) ?></span>
            <span class="rn-account-picker-chev">▾</span>
        </button>
        <?php endif; ?>
        <div class="rn-account-picker-panel<?= $expanded ? ' rn-account-picker-panel-inline' : '' ?>"<?= $expanded ? '' : ' style="display:none"' ?>>
            <input type="text" class="rn-account-search" placeholder="Search accounts…" oninput="rnFilterAccounts(this)"<?= $expanded ? ' autofocus' : '' ?>>
            <div class="rn-account-list">
                <?php foreach ($accounts as $i => $a):
                    $isExtra = $i >= $initialVisible;
                    $badge = apAccountLifecycleBadge($a['lifecycle_state'] ?? null);
                    $sub = $subLabelFn ? $subLabelFn($a) : apChallengeTypeLabel($a['challenge_type'] ?? null);
                    $linkParams = $baseParams;
                    $linkParams[$paramName] = $a['id'];
                    $href = $baseUrl . '?' . http_build_query($linkParams);
                ?>
                <a href="<?= htmlspecialchars($href) ?>"
                   class="rn-account-item<?= $a['id'] === $selectedId ? ' rn-account-item-selected' : '' ?><?= $isExtra ? ' rn-acct-extra' : '' ?>"
                   style="<?= $isExtra ? 'display:none' : '' ?>"
                   data-search="<?= htmlspecialchars(strtolower($a['label'])) ?>">
                    <span class="rn-account-item-label"><?= htmlspecialchars($a['label']) ?></span>
                    <span class="rn-account-item-meta"><?= htmlspecialchars($sub) ?> <span class="rn-badge <?= $badge[1] ?>"><?= $badge[0] ?></span></span>
                </a>
                <?php endforeach; ?>
            </div>
            <?php if ($total > $initialVisible): ?>
            <button type="button" class="rn-show-more-accts" data-expanded="0"
                    data-more-label="Show More (<?= $total - $initialVisible ?> more)"
                    onclick="rnToggleAccountsMore(this)">Show More (<?= $total - $initialVisible ?> more)</button>
            <?php endif; ?>
            <?php if ($total === 0 && $emptyMessage !== null): ?>
            <div style="padding:.6rem .2rem;font-size:.78rem;color:var(--foreground-dim)"><?= htmlspecialchars($emptyMessage) ?></div>
            <?php endif; ?>
        </div>
    </div>
    <?php
}

/**
 * Total closed, non-deleted trades for an account, scoped to $workflowState
 * (default 'main', same as before this parameter existed — every
 * pre-existing call site is unaffected unless it opts in). 2026-08-16/17
 * workflow-isolation-leakage pass: Trading Center's Account Home "Total
 * Trades" figure was always hardcoded to the main-workflow-only condition
 * regardless of which workflow was actually viewing the page — a
 * validating user saw their real main-workflow trade count here, not
 * their validation trade count for that account. Same
 * validationScopeSql() (src/RenderHelpers.php) every other trade-workflow
 * scoping site in this app already uses.
 */
function apAccountTradeCount(string $accountId, ScopedDb $db, string $workflowState = 'main'): int {
    $scope = validationScopeSql($workflowState);
    $rows = $db->raw(
        "SELECT COUNT(*) AS c FROM trades WHERE account_id = :aid AND status = 'closed' AND deleted_at IS NULL AND {$scope}",
        [':aid' => $accountId]
    );
    return (int)($rows[0]['c'] ?? 0);
}

/** Most recent lifecycle_events row of the given event_type(s) for an account — used for Promotion Date (no dedicated column exists for this). */
function apLatestLifecycleEvent(string $accountId, array $eventTypes, PDO $pdo): ?array {
    $keys = [];
    $params = [':aid' => $accountId];
    foreach (array_values($eventTypes) as $i => $type) { $k = ":et{$i}"; $keys[] = $k; $params[$k] = $type; }
    $stmt = $pdo->prepare(
        "SELECT * FROM lifecycle_events WHERE account_id = :aid AND event_type IN (" . implode(',', $keys) . ")
         ORDER BY occurred_at DESC LIMIT 1"
    );
    $stmt->execute($params);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return $row ?: null;
}

/** A single rule value from challenge_accounts.rules — "Not specified" (never a fabricated default) when the firm hasn't declared it. */
function apChallengeRuleValue(array $rules, string $key, ?string $suffix = '%'): string {
    if (!array_key_exists($key, $rules) || $rules[$key] === null || $rules[$key] === '') return 'Not specified';
    $v = $rules[$key];
    if ($suffix === '%' && is_numeric($v)) {
        return rtrim(rtrim(number_format((float)$v, 2), '0'), '.') . '%';
    }
    return (string)$v;
}

/**
 * Profit Target / Maximum Drawdown / Daily Drawdown / Minimum Trading Days
 * / Profit Split, read directly from challenge_accounts.rules with the
 * same key-cascade conventions already established elsewhere in this app
 * (journal.php's rnPhaseTargets()) — but deliberately with NO hardcoded
 * default percentages. A missing key renders "Not specified" rather than
 * silently fabricating a number, matching this codebase's own
 * later-established correctness principle (see CLAUDE.md's
 * realDeclaredMaxDdPct() fix) that a silent fallback default is a bug
 * class, not a feature.
 */
function apChallengeRulesSummary(array $rules, string $challengeType, ?string $lifecycleState): array {
    $lifecycleState = $lifecycleState ?? '';
    $profitTargetKey = match (true) {
        $challengeType === 'one_step' => 'target_pct',
        $challengeType === 'instant_funding' => 'consistency_requirement_pct',
        str_starts_with($lifecycleState, 'phase_1') => 'phase_1_target_pct',
        str_starts_with($lifecycleState, 'phase_2') => 'phase_2_target_pct',
        str_starts_with($lifecycleState, 'phase_3') => 'phase_3_target_pct',
        $lifecycleState === 'funded_active' => 'funded_profit_target',
        default => 'phase_1_target_pct',
    };
    // Renamed from "Consistency Requirement" (2026-08-17) — collided with
    // the real, new Consistency Rule feature (Biggest Day / Total Profit,
    // computed against actual trades). This is a different, pre-existing
    // concept: Instant Funding's own stand-in declared value in the
    // profit-target slot (same rules-JSON key, consistency_requirement_pct,
    // unchanged) — never computed against anything, just a firm's
    // declared number, same as Profit Target is for every other type.
    $profitTargetLabel = $challengeType === 'instant_funding' ? 'Funding Requirement' : 'Profit Target';

    // Maximum Loss Model (2026-08-17) — a whole-account setting (unlike
    // every other row here, which is phase-cascaded), so read directly
    // rather than through apChallengeRuleValueCascaded(). Always shown,
    // defaulting to "Fixed" for the absent-key case — unlike the other
    // rows' "Not specified" convention, Fixed genuinely IS the real,
    // active default behavior for every account without this key (see
    // PropFirmRuleEngine::compute()'s own normalization), not a missing
    // declaration being covered up.
    $maxLossModelRaw = strtolower(trim((string)($rules['max_loss_model'] ?? 'fixed')));
    $maxLossModelLabel = $maxLossModelRaw === 'trailing' ? 'Trailing' : 'Fixed';
    $maxDrawdownLabel = 'Maximum Drawdown';
    if ($maxLossModelRaw === 'trailing') $maxDrawdownLabel .= ' (Trailing)';

    $summary = [
        $profitTargetLabel => apChallengeRuleValue($rules, $profitTargetKey),
        $maxDrawdownLabel => apChallengeRuleValueCascaded($rules, $lifecycleState, 'max_loss', 'max_dd_pct'),
        'Daily Drawdown' => apChallengeRuleValueCascaded($rules, $lifecycleState, 'daily_loss', 'daily_dd_pct'),
        'Minimum Trading Days' => apChallengeRuleValue($rules, 'min_trading_days', null),
        'Profit Split' => apChallengeRuleValue($rules, 'profit_split_pct'),
        'Maximum Loss Model' => $maxLossModelLabel,
    ];

    // Daily Reset and Consistency Rule — only shown when actually
    // configured, unlike the always-present rows above. Both are new,
    // optional features most existing accounts won't have set; showing
    // "Not configured" on every single account's Rules card would be
    // clutter with no information value, so the row is omitted entirely
    // rather than shown blank.
    $resetTime = trim((string)($rules['daily_reset_time'] ?? ''));
    $resetTz = trim((string)($rules['daily_reset_timezone'] ?? ''));
    if ($resetTime !== '' && $resetTz !== '') {
        $summary['Daily Reset'] = "{$resetTime} ({$resetTz})";
    }

    $consistencyMethod = trim((string)($rules['consistency_method'] ?? ''));
    $consistencyMaxPct = $rules['consistency_max_pct'] ?? null;
    if ($consistencyMethod !== '' && $consistencyMaxPct !== null && $consistencyMaxPct !== '') {
        $consistencyLabel = $consistencyMethod === 'biggest_day_over_total_profit'
            ? 'Biggest Winning Day ÷ Total Profit'
            : $consistencyMethod;
        $summary['Consistency Rule'] = $consistencyLabel . ' ≤ ' . rtrim(rtrim(number_format((float)$consistencyMaxPct, 2), '0'), '.') . '%';
    }

    return $summary;
}

/**
 * Phase-aware drawdown-rule lookup — was the audit's confirmed cascade-#6
 * bug (docs/architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md §6/§20): Max/Daily
 * Drawdown read ONLY the flat rules[...] key here, contradicting this
 * file's own doc-comment (which claimed parity with journal.php's
 * rnPhaseTargets()/PropFirmRuleEngine::resolveRules() cascade) — a real
 * account whose firm declares different per-phase limits than its flat
 * default showed the wrong number on this card.
 *
 * Deliberately does NOT call PropFirmRuleEngine::resolveRules() directly —
 * that method's job is boundary MATH, where a hardcoded default (10%/5%)
 * is correct and necessary (you need *some* number to compute a breach
 * line against). This function's job is DISPLAY of what the firm actually
 * declared, where showing a fabricated default as if it were a real
 * declared rule would be the exact "silent fallback" anti-pattern
 * apChallengeRuleValue()'s own doc comment (and CLAUDE.md's
 * realDeclaredMaxDdPct() precedent) already correctly refuses to do. So
 * this mirrors resolveRules()'s exact phase-specific-key-first, then-flat-
 * key cascade ORDER, but stops at "Not specified" instead of substituting
 * a hardcoded default when neither key is actually set — genuine phase-
 * awareness without reintroducing a fabricated number.
 */
function apChallengeRuleValueCascaded(array $rules, string $lifecycleState, string $phaseSuffix, string $flatKey): string {
    $phase = match (true) {
        str_starts_with($lifecycleState, 'phase_1') => 'phase_1',
        str_starts_with($lifecycleState, 'phase_2') => 'phase_2',
        str_starts_with($lifecycleState, 'phase_3') => 'phase_3',
        $lifecycleState === 'funded_active' => 'funded',
        default => null,
    };
    if ($phase !== null) {
        $phaseKey = "{$phase}_{$phaseSuffix}";
        $viaPhase = apChallengeRuleValue($rules, $phaseKey);
        if ($viaPhase !== 'Not specified') return $viaPhase;
    }
    return apChallengeRuleValue($rules, $flatKey);
}

/**
 * Read-only preview of what api/accounts/create.php's mode=promote state
 * machine WOULD do — mirrors that logic for display purposes only. The
 * actual mutation still only ever happens through the real, unmodified
 * endpoint; this never writes anything.
 */
function apPromotionPreview(string $lifecycleState, string $challengeType): array {
    $to = match (true) {
        $lifecycleState === 'phase_1_complete' && $challengeType === 'one_step' => 'funded_active',
        $lifecycleState === 'phase_1_complete' => 'phase_2_active',
        $lifecycleState === 'phase_2_complete' && $challengeType === 'three_step' => 'phase_3_active',
        $lifecycleState === 'phase_2_complete' => 'funded_active',
        $lifecycleState === 'phase_3_complete' => 'funded_active',
        default => $lifecycleState,
    };
    return [
        'from' => apHumanizeLifecycleState($lifecycleState),
        'to' => apHumanizeLifecycleState($to),
        'to_raw' => $to,
    ];
}

/** Execution-model categories for the Live Trading Center — new concept, doesn't exist anywhere else in this app. */
function apExecutionModelCategories(): array {
    return [
        'ecn' => ['label' => 'ECN', 'tooltip' => 'Orders go straight to the interbank market. Tighter spreads, usually with a separate commission per trade.'],
        'stp' => ['label' => 'STP', 'tooltip' => 'Your broker passes orders straight through to liquidity providers, with no dealing desk in between.'],
        'standard' => ['label' => 'Standard', 'tooltip' => 'A typical retail account. Usually no separate commission, spreads may be a bit wider.'],
        'raw_spread' => ['label' => 'Raw Spread', 'tooltip' => 'Spreads as close to zero as the market allows, with a commission charged per trade instead.'],
    ];
}
function apExecutionModelLabel(?string $model): string {
    $cats = apExecutionModelCategories();
    return $cats[$model]['label'] ?? 'Not Categorized';
}

/**
 * 2026-08-18 — "Quick Access = selection history, not trade activity" pass.
 * Reported directly by Kevin: Quick Access (public/trading-center.php) was
 * ordering its cards by trading_accounts.last_activity (trade-derived,
 * falling back to created_at for a never-traded account), so a dormant
 * account a trader wasn't actually working with could still outrank ones
 * they'd genuinely opened — a real account named "depo - yes" was showing
 * up there despite never having been selected. Quick Access is supposed to
 * be a most-recently-selected-first shortcut list, not an activity feed.
 *
 * apRecordAccountSelection() stamps the account's ordering position at the
 * moment it's genuinely, explicitly selected — landing on its Account Home
 * in Trading Center (an account_id resolved from the URL, not a fallback),
 * or arriving at Strategies with a real ?account_id=. apLastSelectedAccountId()
 * reads the single most-recent one back, scoped to a workflow — this is also
 * what gives Strategies' "current account" real, database-backed persistence
 * across logout/login/days instead of only living in $_SESSION (see
 * public/strategies.php).
 *
 * last_selected_seq (not last_selected_at alone) is what strict ordering is
 * actually computed from — a real bug found via testing, same day, same
 * pass: last_selected_at is a plain DATETIME (whole-second precision), and
 * several selections landing within the same second (a fast clicker, or an
 * automated verification loop) produced tied timestamps, which broke the
 * exact "A -> B -> C -> A -> B -> A displays A, B, C" ordering guarantee.
 * last_selected_seq is a real, atomically-incremented monotonic counter
 * (computed and written in the SAME UPDATE statement, via an UPDATE...JOIN —
 * ScopedDb has no primitive for "read current max, write max+1" as one
 * atomic operation, so this uses $db->pdo() directly, the same established
 * pattern this app already uses for other atomic-increment writes, e.g.
 * dashboard.php's resolve_trade_id balance update) — two selections can
 * never receive the same sequence number, no matter how close together in
 * time they happen. last_selected_at is still written alongside it (a
 * human-readable "when", not used for ordering) and is also what marks an
 * account as "has ever been selected" (both are always set together, so
 * checking either is equivalent).
 *
 * ScopedDb::update() already scopes its WHERE clause to user_id for every
 * OTHER write in this app; this function's own WHERE explicitly repeats
 * that same user_id scoping (ta.user_id = :uid) since it can't go through
 * ScopedDb::update() here — passing a foreign account_id still silently
 * updates zero rows, not someone else's account.
 */
function apRecordAccountSelection(string $userId, string $accountId, ScopedDb $db): void {
    $stmt = $db->pdo()->prepare(
        "UPDATE trading_accounts ta
         JOIN (SELECT COALESCE(MAX(last_selected_seq), 0) + 1 AS next_seq FROM trading_accounts WHERE user_id = :uid_sub) seq
         SET ta.last_selected_at = NOW(6), ta.last_selected_seq = seq.next_seq
         WHERE ta.id = :id AND ta.user_id = :uid"
    );
    $stmt->execute([':uid_sub' => $userId, ':id' => $accountId, ':uid' => $userId]);
}

/**
 * The single account this user most recently, explicitly selected (see
 * apRecordAccountSelection() above), scoped to the given workflow — a real,
 * persisted value, unlike $_SESSION['active_account_id']. Ordered by the
 * collision-proof last_selected_seq, not last_selected_at (see above).
 * Returns null when nothing has ever been explicitly selected in this
 * workflow yet (a brand new account/workflow, or every selection predates
 * this feature).
 */
function apLastSelectedAccountId(string $userId, ScopedDb $db, string $workflowState = 'main'): ?string {
    $rows = $db->raw(
        "SELECT id FROM trading_accounts
         WHERE user_id = :uid AND workflow_context = :wfctx AND deleted_at IS NULL AND last_selected_seq IS NOT NULL
         ORDER BY last_selected_seq DESC LIMIT 1",
        [':uid' => $userId, ':wfctx' => $workflowState]
    );
    return $rows[0]['id'] ?? null;
}

/**
 * A second, independent "last selected" pointer — 2026-08-20, real bug
 * reported directly: apRecordAccountSelection() above fires on EVERY
 * Account Home view in Trading Center (correct — that's what Quick Access's
 * "recently viewed" ordering is supposed to reflect), but Strategies'
 * cross-login memory was reading that SAME pointer, so merely browsing a
 * different account's balance in Trading Center (no intent to switch what
 * you're trading) silently overwrote which account Strategies would resolve
 * to after a later logout/login. This pair (strategies_selected_at/_seq) is
 * stamped ONLY by apRecordStrategiesSelection() below, called ONLY from
 * public/strategies.php's own explicit ?account_id= arrival (a real
 * "Continue to Strategies ->" click, or a reload of an already-selected
 * account) — Trading Center's two Account Home call sites are completely
 * unaffected, so Quick Access's own behavior/ordering is untouched.
 *
 * "The only time the selected account should disappear from Strategies is
 * when you choose a different one" — i.e. only another explicit arrival at
 * Strategies (via a NEW ?account_id=) can move this pointer; passively
 * viewing other accounts elsewhere in the app never does.
 */
function apRecordStrategiesSelection(string $userId, string $accountId, ScopedDb $db): void {
    // Also bumps Quick Access's own ordering — a trader explicitly choosing
    // to trade this account is a genuine "I selected this" event for that
    // purpose too, not just for Strategies' own dedicated pointer.
    apRecordAccountSelection($userId, $accountId, $db);
    $stmt = $db->pdo()->prepare(
        "UPDATE trading_accounts ta
         JOIN (SELECT COALESCE(MAX(strategies_selected_seq), 0) + 1 AS next_seq FROM trading_accounts WHERE user_id = :uid_sub) seq
         SET ta.strategies_selected_at = NOW(6), ta.strategies_selected_seq = seq.next_seq
         WHERE ta.id = :id AND ta.user_id = :uid"
    );
    $stmt->execute([':uid_sub' => $userId, ':id' => $accountId, ':uid' => $userId]);
}

/**
 * The single account this user most recently, explicitly chose to trade
 * (see apRecordStrategiesSelection() above) — scoped to the given workflow,
 * ordered by the collision-proof strategies_selected_seq. This, not
 * apLastSelectedAccountId(), is what public/strategies.php's own cross-
 * login fallback must read: "the account I'm trading," not "the account I
 * most recently glanced at anywhere in the app."
 */
function apLastStrategiesAccountId(string $userId, ScopedDb $db, string $workflowState = 'main'): ?string {
    $rows = $db->raw(
        "SELECT id FROM trading_accounts
         WHERE user_id = :uid AND workflow_context = :wfctx AND deleted_at IS NULL AND strategies_selected_seq IS NOT NULL
         ORDER BY strategies_selected_seq DESC LIMIT 1",
        [':uid' => $userId, ':wfctx' => $workflowState]
    );
    return $rows[0]['id'] ?? null;
}
