<?php
/**
 * ════════════════════════════════════════════════════════════════════
 * Trading Archive Engine
 * ────────────────────────────────────────────────────────────────────
 * Historical intelligence for finished accounts (breached prop-firm
 * accounts, passed/funded prop-firm accounts, closed live accounts).
 * Everything this engine returns is computed directly from real rows in
 * `trades` / `trading_accounts` / `challenge_accounts` / `strategy_versions`
 * / `checklist_results` — no invented copy, no hardcoded findings. Every
 * statistic that isn't backed by enough real samples returns null/empty
 * rather than a fabricated number, and every caller is expected to render
 * an honest "not enough history yet" state for it (see MIN_* constants).
 *
 * Reuses, rather than re-derives, journey-segmentation logic shared with
 * Active Journeys: JourneySegmentation::partitionAccountJourney() (src/
 * JourneySegmentation.php) is the ONE place "which real trade belongs to
 * which phase" is decided — both this engine and Active Journeys'
 * rnBuildJourney() (public/journal.php) call it, so the two can never
 * independently drift on how the same account's history is bucketed
 * (2026-08-06 — extracted into its own class specifically so nothing can
 * accidentally bypass it and call its internal per-trade partitioning
 * step directly; see that file's own header for the full reasoning).
 * rnDeriveTerminalStatus()/rnFindPassedOnDate()/rnFindStartedOnDate()
 * (public/journal.php) are reused for the same "one implementation, both
 * callers" reason. Nothing in this file is called BY Active Journeys —
 * the dependency runs one way.
 *
 * One entry point: TradingArchiveEngine::build($userId, $db, $type, $lens,
 * $program). Internally it always gathers the FULL segment population for
 * the account $type (every lifecycle state, every program) once, then
 * derives the lens/program-scoped view plus every comparative/difficulty
 * statistic from that one in-memory set — comparative analytics and
 * difficulty scoring need the population outside the current lens (e.g. a
 * phase's pass rate needs both its passed AND breached segments), so
 * re-querying per-lens would either be wrong or redundant.
 *
 * "Segment" is the unit of analysis throughout: for a prop-firm account,
 * one segment per real phase the account's trades were tagged into (Phase
 * 1 / Phase 2 / Phase 3 / Funded Account — via
 * JourneySegmentation::partitionAccountJourney()); for a live account,
 * exactly one segment (the whole account, closed accounts only). A
 * segment's own `status` (passed/breached/funded_active/closed_profit/
 * closed_loss/active) is what the Lessons From Success / Lessons From
 * Failure lens filters on — matching CLAUDE.md's own documented
 * architecture ("an account that passed Phase 1 and later breached Phase
 * 3 counts in both lenses").
 * ════════════════════════════════════════════════════════════════════
 */

require_once __DIR__ . '/JourneySegmentation.php';
require_once __DIR__ . '/PropFirmRuleEngine.php';
require_once __DIR__ . '/../api/analytics/engine.php';
require_once __DIR__ . '/../api/analytics/edge_health_engine.php';
require_once __DIR__ . '/TradeGrading.php';
// Validation Workflow (2026-08-18 correction) — this engine's account/trade
// queries used to be hardcoded main-workflow-only (accounts had no
// trading_accounts.workflow_context filter at all; trades hardcoded
// "is_validation = 0 OR is_validation IS NULL"), which meant Trading
// Archive as a whole had to be hidden from a validating user rather than
// showing them their own, correctly-scoped history. Required directly here
// (not just relied on being already loaded by index.php) so this engine
// stays self-contained, matching every other require above.
require_once __DIR__ . '/ValidationService.php';
require_once __DIR__ . '/RenderHelpers.php';

class TradingArchiveEngine
{
    // Minimum real samples required before a statistic/finding/pattern is
    // shown at all — below this, the caller renders an honest "not enough
    // history yet" message instead of a number computed from too little
    // evidence. Kept as named constants so every gate in this file is
    // visibly using the same small set of thresholds, not ad hoc numbers.
    const MIN_GROUP_SEGMENTS   = 3;   // difficulty score / phase pass-rate / comparative analytics
    const MIN_GROUP_TRADES     = 5;   // pooled per-trade averages (risk%, RR, mood, checklist)
    const MIN_PATTERN_TRADES   = 8;   // behavioral pattern discovery (post-loss risk deltas etc.)
    const MIN_HISTORICAL_CONSISTENCY_TRADES = 5;

    // Lessons From Success (2026-08-04) — sanity thresholds for the
    // page's own value-judgment claims ("Consistent risk management",
    // "Favorable reward:risk maintained"). A real, honestly-computed
    // number is never hidden elsewhere on this page; these two gates
    // exist ONLY so a positive-sounding LABEL is never attached to a
    // number that doesn't actually support it.
    const MAX_SANE_RISK_PCT_FOR_CLAIM = 10.0; // "typical risk per trade" above this isn't "consistent" by any real trading standard
    const MIN_FAVORABLE_RR = 1.0;             // reward must be at least on par with risk to call it "favorable"

    // ────────────────────────────────────────────────────────────────
    // Entry point
    // ────────────────────────────────────────────────────────────────

    public static function build(string $userId, $db, string $type, string $lens, string $program): array
    {
        $allProp = self::gatherPropSegments($userId, $db);
        $allLive = self::gatherLiveSegments($userId, $db);

        $population = $type === 'prop' ? $allProp : $allLive;

        // Program filter (prop only) — applied AFTER gathering, so
        // comparative analytics below can still see the full population.
        $scoped = $population;
        if ($type === 'prop' && $program !== 'all') {
            $scoped = array_values(array_filter($scoped, fn($s) => $s['challenge_type'] === $program));
        }

        $lensSegments = array_values(array_filter($scoped, function ($s) use ($lens) {
            if ($lens === 'success') return in_array($s['status'], ['passed', 'funded_active', 'closed_profit'], true);
            return in_array($s['status'], ['breached', 'closed_loss'], true);
        }));

        $result = [
            'type' => $type, 'lens' => $lens, 'program' => $program,
            'segment_count' => count($lensSegments),
            'account_count' => count(array_unique(array_column($lensSegments, 'account_id'))),
        ];

        if (empty($lensSegments)) {
            $result['empty'] = true;
            $result['mt5'] = self::mt5Readiness($userId, $db);
            return $result;
        }
        $result['empty'] = false;

        $group = self::aggregateGroup($lensSegments);
        $result['stats'] = $group;

        $result['historical_consistency'] = self::historicalConsistency($group);

        if ($type === 'prop') {
            $result['phase_breakdown'] = self::phaseBreakdown($scoped);
            $result['difficulty'] = self::difficultyBoard($scoped);
            $easiestHardest = self::easiestHardestPhase($result['phase_breakdown']);
            $result['easiest_phase'] = $easiestHardest['easiest'];
            $result['hardest_phase'] = $easiestHardest['hardest'];
            $result['breach_causes'] = $lens === 'failure' ? self::breachCauses($lensSegments) : [];
        } else {
            $result['phase_breakdown'] = [];
            $result['difficulty'] = [];
            $result['easiest_phase'] = null;
            $result['hardest_phase'] = null;
            $result['breach_causes'] = $lens === 'failure' ? self::breachCauses($lensSegments) : [];
        }

        $result['findings'] = self::findings($lensSegments, $group, $lens, $type);
        $result['patterns'] = self::patterns($lensSegments, $lens);
        $result['comparative'] = self::comparativeAnalytics($allProp, $allLive, $type, $program);
        $result['mt5'] = self::mt5Readiness($userId, $db);
        $result['mt5_duration'] = $result['mt5']['ready'] ? self::mt5DurationAnalytics($lensSegments) : null;
        $result['evidence_rows'] = self::flattenRows($lensSegments);
        $result['post_loss_spike_ids'] = [];
        $result['post_loss_pause_ids'] = [];
        foreach ($lensSegments as $s) {
            $result['post_loss_spike_ids'] = array_merge($result['post_loss_spike_ids'], $s['metrics']['post_loss_spike_trade_ids']);
            $result['post_loss_pause_ids'] = array_merge($result['post_loss_pause_ids'], $s['metrics']['post_loss_pause_trade_ids']);
        }

        return $result;
    }

    // ────────────────────────────────────────────────────────────────
    // Segment gathering — prop
    // ────────────────────────────────────────────────────────────────

    private static function gatherPropSegments(string $userId, $db): array
    {
        // Validation Workflow (2026-08-18 correction) — both queries below
        // used to be main-workflow-only (accounts had no workflow_context
        // filter at all; trades hardcoded excluding is_validation=1). Now
        // resolved once and applied to both, same pattern
        // rnFetchActivePropAccounts()/rnFetchActiveLiveAccounts() (public/
        // journal.php) already use for Active Journeys' own account picker
        // — an account belongs to exactly one workflow for its whole life
        // (trading_accounts.workflow_context), and its trades' is_validation
        // value is derived from the same workflow at capture time.
        $wfState = ValidationService::getWorkflowState($userId, $db);
        $accounts = $db->raw(
            "SELECT ta.id, ta.label, ta.account_type, ta.lifecycle_state, ta.current_outcome, ta.boundary_event,
                    ta.starting_balance, ta.created_at, ta.prop_firm_name,
                    cx.challenge_type, cx.account_size, cx.rules, cx.passed_at, cx.breached_at
             FROM trading_accounts ta
             LEFT JOIN challenge_accounts cx ON cx.id = (
                 SELECT ca2.id FROM challenge_accounts ca2 WHERE ca2.account_id = ta.id ORDER BY ca2.updated_at DESC LIMIT 1
             )
             WHERE ta.user_id = :uid AND ta.deleted_at IS NULL AND ta.account_type = 'prop_firm' AND ta.workflow_context = :wfctx",
            [':uid' => $userId, ':wfctx' => $wfState]
        );
        if (empty($accounts)) return [];

        $tradeValidationSql = validationScopeSql($wfState, 't');
        $trades = $db->raw(
            "SELECT t.id, t.account_id, t.instrument, t.direction, t.session, t.entry_price, t.actual_entry_price,
                    t.actual_exit_price, t.sl_price, t.actual_sl_price, t.tp_price, t.tp2_price, t.tp3_price, t.tp4_price, t.tp5_price,
                    t.tp6_price, t.tp7_price, t.tp8_price, t.tp9_price, t.tp10_price, t.risk_reward_tp1, t.risk_pct,
                    t.opened_at, t.closed_at, t.captured_at, t.trade_classification,
                    t.prop_firm_funded, t.prop_firm_phase, t.prop_phase_at_entry,
                    t.max_potential_loss, t.max_potential_profit, t.risk_per_position, t.notes,
                    t.broker_open_time, t.broker_close_time, t.is_reconciled,
                    t.mood_before, t.energy_before, t.confidence_before, t.balance_at_entry,
                    t.strategy_version_id, sv.max_risk_per_trade_pct AS declared_risk_pct,
                    sv.backtested_avg_rr, sv.setup_criteria, sv.entry_triggers, sv.instruments_traded AS declared_instruments,
                    sv.version_number AS strategy_version_number, s.id AS strategy_id, TRIM(s.name) AS strategy_name,
                    cr.ticked_pct,
                    COALESCE(t.challenge_pnl, t.broker_pnl, t.projected_pnl, 0) AS effective_pnl
             FROM trades t
             JOIN trading_accounts ta ON ta.id = t.account_id
             LEFT JOIN strategy_versions sv ON sv.id = t.strategy_version_id
             LEFT JOIN strategies s ON s.id = sv.strategy_id
             LEFT JOIN checklist_results cr ON cr.trade_lifecycle_id = t.trade_lifecycle_id
             WHERE t.user_id = :uid AND t.deleted_at IS NULL AND {$tradeValidationSql}
               AND t.status = 'closed' AND ta.account_type = 'prop_firm' AND ta.deleted_at IS NULL AND ta.workflow_context = :wfctx
             ORDER BY t.account_id ASC, t.captured_at ASC",
            [':uid' => $userId, ':wfctx' => $wfState]
        );

        $byAccount = [];
        foreach ($trades as $t) { $byAccount[$t['account_id']][] = $t; }

        $segments = [];
        foreach ($accounts as $acc) {
            $accTrades = $byAccount[$acc['id']] ?? [];
            if (empty($accTrades)) continue; // nothing to learn from an untraded account

            $lifecycleState = (string)($acc['lifecycle_state'] ?? '');
            $currentLabel = match (true) {
                str_contains($lifecycleState, 'phase_1') => 'Phase 1',
                str_contains($lifecycleState, 'phase_2') => 'Phase 2',
                str_contains($lifecycleState, 'phase_3') => 'Phase 3',
                $lifecycleState === 'funded_active'       => 'Funded Account',
                default                                    => 'Current Phase',
            };
            $rules = !empty($acc['rules']) ? (json_decode($acc['rules'], true) ?: []) : [];
            $accountSize = (float)($acc['account_size'] ?? $acc['starting_balance'] ?? 0);

            // Single source of truth for journey segmentation —
            // JourneySegmentation::partitionAccountJourney() (src/
            // JourneySegmentation.php) applies the same Instant Funding
            // single-bucket rule and funded-state reconciliation Active
            // Journeys' own rnBuildJourney() uses, so the same account
            // can never show as Funded on one page and stuck on Phase
            // 1/2/3 on another. Its internal per-trade partitioning step
            // is a `private` class method (real PHP-enforced privacy,
            // not just a comment) — there is no way to bypass the
            // Instant Funding / funded-state corrections above from
            // outside JourneySegmentation, by construction.
            $buckets = JourneySegmentation::partitionAccountJourney($accTrades, $acc['challenge_type'] ?? null, $currentLabel);
            $keys = array_keys($buckets);
            $n = count($keys);
            foreach ($keys as $i => $label) {
                $isLast = $i === $n - 1;
                $status = $isLast ? rnDeriveTerminalStatus($lifecycleState, $label) : 'passed';
                if ($status === 'active') continue; // still in progress — Active Journeys' domain, not history

                $rows = self::buildRows($buckets[$label], $accountSize);
                $startDate = rnFindStartedOnDate($db, $acc['id'], $label);
                if ($startDate === null) {
                    $capturedAts = array_filter(array_column($buckets[$label], 'captured_at'));
                    $startDate = !empty($capturedAts) ? min($capturedAts) : ($acc['created_at'] ?? null);
                }
                $closedAts = array_filter(array_column($buckets[$label], 'closed_at'));
                $capturedAts2 = array_filter(array_column($buckets[$label], 'captured_at'));
                $endDate = !empty($closedAts) ? max($closedAts) : (!empty($capturedAts2) ? max($capturedAts2) : $startDate);
                $passedOn = $status === 'passed' ? (rnFindPassedOnDate($db, $acc['id'], $label) ?? $endDate) : null;

                $segments[] = [
                    'account_id' => $acc['id'], 'account_label' => $acc['label'], 'account_type' => 'prop_firm',
                    'prop_firm_name' => $acc['prop_firm_name'], 'challenge_type' => $acc['challenge_type'],
                    'phase_label' => $label, 'status' => $status,
                    // The account's own CURRENT real lifecycle_state (constant
                    // across every segment of this account, unlike 'status'
                    // above which is per-segment-terminal) — needed by
                    // Lessons From Success to exclude any account that is
                    // now breached/closed even from its own EARLIER passed
                    // segments (an account that passed Phase 1 and later
                    // breached Phase 3 has real lessons in both lenses per
                    // this engine's general architecture, but the success
                    // LEARNING page's own explicit brief is narrower: "no
                    // breached accounts appear here" and every contributing
                    // account must still be active/continuing today).
                    'account_lifecycle_state' => $lifecycleState,
                    'start_date' => $startDate, 'end_date' => $endDate, 'passed_on' => $passedOn,
                    'account_size' => $accountSize, 'max_dd_pct_configured' => self::realDeclaredMaxDdPct($rules, $label),
                    'current_outcome' => $status === 'breached' ? $acc['current_outcome'] : null,
                    // Balance is never carried as a raw DB field on the
                    // segment (2026-08-07) — neither trading_accounts.
                    // current_balance (never resets at promotion) nor
                    // challenge_accounts.current_balance (resets
                    // correctly, but is never updated per trade — frozen
                    // the moment a trade closes) is independently correct
                    // for "this segment's own real balance." Every
                    // consumer instead computes it fresh from this
                    // segment's own real rows (account_size + sum of this
                    // segment's own trades' effective_pnl) — see
                    // successStory()'s and gatherSuccessAccounts()'s own
                    // safeguard doc comments, and
                    // rnBuildPhaseCardFromRows() in public/journal.php,
                    // for the full investigation this settled on.
                    // Real, decoded challenge_accounts.rules JSON, additive
                    // — Lessons From Failure's single-account rebuild needs
                    // the account's own declared daily-loss-limit % and
                    // profit-target % (rnPhaseTargets()'s same key cascade),
                    // neither of which realDeclaredMaxDdPct() above exposes.
                    'rules' => $rules,
                    'rows' => $rows,
                    'metrics' => self::segmentMetrics($rows, $accountSize),
                ];
            }
        }
        return $segments;
    }

    // ────────────────────────────────────────────────────────────────
    // Segment gathering — live
    // ────────────────────────────────────────────────────────────────

    /**
     * Live accounts eligible for a "success journey" (2026-08-06): a
     * still-ACTIVE, currently-profitable account is a real success story
     * too, not just a closed one — confirmed as the intended scope (the
     * approved prototype shows a live, pulsing "Active" account here, not
     * a closed one). Broadened from lifecycle_state = 'closed' only to
     * IN ('closed', 'active') — the only two real values this schema uses
     * for a personal account. Each segment now carries its own
     * `is_still_active` flag plus the raw balance-ledger fields
     * (net_deposits/net_withdrawals/peak_equity/current_balance) the new
     * Account Growth / Financial Behaviour / Deposit-Withdrawal-Behaviour
     * sections need — none of this touches gatherPropSegments() or any
     * prop-firm rendering.
     *
     * Status is now one of 4 values (was 2): closed_profit/closed_loss
     * (unchanged meaning) plus active_profit/active_loss (new). Only
     * closed_profit/active_profit ever qualify for the Success lens
     * (gatherSuccessAccounts()); only closed_loss ever qualifies for the
     * old population-wide Failure lens (build()) — active_loss matches
     * neither, which is correct: a still-open account currently down
     * hasn't failed, it just hasn't resolved yet.
     */
    private static function gatherLiveSegments(string $userId, $db): array
    {
        // Validation Workflow (2026-08-18 correction) — same real gap and
        // same fix as gatherPropSegments() above.
        $wfState = ValidationService::getWorkflowState($userId, $db);
        $accounts = $db->raw(
            "SELECT id, label, account_type, lifecycle_state, current_outcome, starting_balance, current_balance,
                    initial_deposit, net_deposits, net_withdrawals, peak_equity, created_at, closed_at
             FROM trading_accounts
             WHERE user_id = :uid AND deleted_at IS NULL AND account_type = 'personal' AND lifecycle_state IN ('closed', 'active') AND workflow_context = :wfctx",
            [':uid' => $userId, ':wfctx' => $wfState]
        );
        if (empty($accounts)) return [];

        $tradeValidationSql = validationScopeSql($wfState, 't');
        $trades = $db->raw(
            "SELECT t.id, t.account_id, t.instrument, t.direction, t.session, t.entry_price, t.actual_entry_price,
                    t.actual_exit_price, t.sl_price, t.actual_sl_price, t.tp_price, t.tp2_price, t.tp3_price, t.tp4_price, t.tp5_price,
                    t.tp6_price, t.tp7_price, t.tp8_price, t.tp9_price, t.tp10_price, t.risk_reward_tp1, t.risk_pct,
                    t.opened_at, t.closed_at, t.captured_at, t.trade_classification,
                    t.max_potential_loss, t.max_potential_profit, t.risk_per_position, t.notes,
                    t.broker_open_time, t.broker_close_time, t.is_reconciled,
                    t.mood_before, t.energy_before, t.confidence_before, t.balance_at_entry,
                    t.strategy_version_id, sv.max_risk_per_trade_pct AS declared_risk_pct,
                    sv.backtested_avg_rr, sv.setup_criteria, sv.entry_triggers, sv.instruments_traded AS declared_instruments,
                    sv.version_number AS strategy_version_number, s.id AS strategy_id, TRIM(s.name) AS strategy_name,
                    cr.ticked_pct,
                    COALESCE(t.broker_pnl, t.projected_pnl, 0) AS effective_pnl
             FROM trades t
             JOIN trading_accounts ta ON ta.id = t.account_id
             LEFT JOIN strategy_versions sv ON sv.id = t.strategy_version_id
             LEFT JOIN strategies s ON s.id = sv.strategy_id
             LEFT JOIN checklist_results cr ON cr.trade_lifecycle_id = t.trade_lifecycle_id
             WHERE t.user_id = :uid AND t.deleted_at IS NULL AND {$tradeValidationSql}
               AND t.status = 'closed' AND ta.account_type = 'personal' AND ta.deleted_at IS NULL AND ta.workflow_context = :wfctx
             ORDER BY t.account_id ASC, t.captured_at ASC",
            [':uid' => $userId, ':wfctx' => $wfState]
        );
        $byAccount = [];
        foreach ($trades as $t) { $byAccount[$t['account_id']][] = $t; }

        $segments = [];
        foreach ($accounts as $acc) {
            $accTrades = $byAccount[$acc['id']] ?? [];
            if (empty($accTrades)) continue;

            $isStillActive = $acc['lifecycle_state'] === 'active';
            $deposit = (float)($acc['initial_deposit'] ?? $acc['starting_balance'] ?? 0);
            $currentBalance = (float)$acc['current_balance'];
            $success = $currentBalance > $deposit;
            $rows = self::buildRows($accTrades, $deposit);
            $capturedAts = array_filter(array_column($accTrades, 'captured_at'));
            $closedAts = array_filter(array_column($accTrades, 'closed_at'));
            $startDate = !empty($capturedAts) ? min($capturedAts) : $acc['created_at'];
            // A still-open account has no real closure date — "end_date"
            // for it means "as of right now," not "as of its last trade"
            // (an account can be genuinely active with a quiet stretch
            // since its last trade, and Account Age/the equity curve must
            // still read through to today, matching the prototype's own
            // "Today" x-axis endpoint).
            $endDate = $isStillActive
                ? date('Y-m-d H:i:s')
                : ($acc['closed_at'] ?? (!empty($closedAts) ? max($closedAts) : (!empty($capturedAts) ? max($capturedAts) : $startDate)));
            $status = $isStillActive
                ? ($success ? 'active_profit' : 'active_loss')
                : ($success ? 'closed_profit' : 'closed_loss');

            $segments[] = [
                'account_id' => $acc['id'], 'account_label' => $acc['label'], 'account_type' => 'personal',
                'prop_firm_name' => null, 'challenge_type' => null,
                'phase_label' => 'Live Account', 'status' => $status,
                'account_lifecycle_state' => $acc['lifecycle_state'] ?? 'closed',
                'is_still_active' => $isStillActive,
                'start_date' => $startDate, 'end_date' => $endDate, 'passed_on' => $success ? $endDate : null,
                'account_size' => $deposit, 'max_dd_pct_configured' => null,
                'current_outcome' => (!$success && !$isStillActive) ? $acc['current_outcome'] : null,
                'created_at' => $acc['created_at'],
                'current_balance' => $currentBalance,
                'peak_equity' => (float)($acc['peak_equity'] ?? $currentBalance),
                'net_deposits' => (float)($acc['net_deposits'] ?? 0),
                'net_withdrawals' => (float)($acc['net_withdrawals'] ?? 0),
                'rows' => $rows,
                'metrics' => self::segmentMetrics($rows, $deposit),
            ];
        }
        return $segments;
    }

    /**
     * The REAL declared max-drawdown-% for one phase of one account, read
     * directly from that account's own challenge_accounts.rules JSON — and
     * ONLY from real data. Deliberately NOT the same lookup as Active
     * Journeys' rnPhaseTargets() (public/journal.php), which silently
     * injects a hardcoded fallback (8%/5%/5%/10%) whenever a phase-specific
     * key is absent — fine for that page's live progress-bar math (some
     * number is better than none for an in-progress account), but wrong
     * for Difficulty Score, which reports this figure back to the trader
     * AS a fact about their own configuration ("typical limit: X%"). A
     * fabricated default blended into that average would misrepresent an
     * assumption as a real configured rule. Returns null — never a default
     * — when this account's own rules genuinely don't carry a usable
     * value; callers must average only over non-null results and disclose
     * when some accounts had none, never fill the gap with a guess.
     */
    private static function realDeclaredMaxDdPct(array $rules, string $label): ?float
    {
        // Real bug found and fixed (2026-08-06, second refinement pass) —
        // Instant Funding's phase_label is 'Instant Funding' (see
        // buildAccountFailureJourney()'s own relabeling comment), which
        // matches none of the four named phases and previously fell
        // straight through the old `if ($key === null) return null;`
        // guard — even when that account's own rules genuinely DID carry
        // a flat, non-phase-prefixed `max_dd_pct` key (confirmed live:
        // real Instant Funding accounts in this database store
        // {"max_dd_pct":8,...}, per CLAUDE.md's own documented rules
        // schema — max_dd_pct/daily_dd_pct are the same general keys used
        // across every challenge type INCLUDING Instant Funding, only the
        // profit-target key differs). Fixed to match
        // failureDailyLossPct()'s already-correct sibling pattern: an
        // unmatched label still checks the flat key directly, rather than
        // giving up before checking anything at all.
        $key = match ($label) {
            'Phase 1' => 'phase_1', 'Phase 2' => 'phase_2', 'Phase 3' => 'phase_3',
            'Funded Account' => 'funded', default => null,
        };
        $candidates = match ($key) {
            null => [$rules['max_dd_pct'] ?? null],
            'funded' => [$rules['funded_max_loss'] ?? null, $rules['max_dd_pct'] ?? null],
            default => [$rules["{$key}_max_loss"] ?? null, $rules['max_dd_pct'] ?? null],
        };
        foreach ($candidates as $v) {
            if ($v !== null && $v !== '' && is_numeric($v)) return (float)$v;
        }
        return null;
    }

    /** Running-balance row list — same shape/construction Active Journeys' rnBuildPhaseCardFromRows() uses, so the same evidence rows can feed a table styled identically. */
    private static function buildRows(array $trades, float $accountSize): array
    {
        $balance = $accountSize;
        $rows = [];
        foreach ($trades as $t) {
            $pnl = $t['effective_pnl'] !== null ? (float)$t['effective_pnl'] : 0.0;
            $balance += $pnl;
            $rows[] = ['trade' => $t, 'pnl' => $pnl, 'balance' => $balance];
        }
        return $rows;
    }

    // ────────────────────────────────────────────────────────────────
    // Per-segment metrics — the raw material every stat/finding/pattern
    // in this file is built from. All real, all computed here once.
    // ────────────────────────────────────────────────────────────────

    private static function segmentMetrics(array $rows, float $accountSize): array
    {
        $n = count($rows);
        $classes = ['disciplined_win' => 0, 'controlled_loss' => 0, 'breakeven' => 0, 'elevated_win' => 0, 'loss_high_risk' => 0, 'tilt_warning' => 0, 'revenge_trade' => 0];
        $winners = 0; $losers = 0; $breakevens = 0;
        $riskPcts = []; $declaredPcts = []; $rrs = []; $rMultiples = [];
        $moodWin = []; $moodLoss = []; $energyWin = []; $energyLoss = []; $confWin = []; $confLoss = [];
        $checklistSkipped = 0;
        $declaredAdherent = 0; $declaredChecked = 0; $declaredContractConstrained = 0;
        $maxRiskSpike = null;
        $postLossRiskWith = []; $postLossRiskWithout = [];
        $postLossSpikeIds = []; $postLossPauseIds = [];
        $recoveryLengths = [];

        $winStreak = 0; $lossStreak = 0; $maxWinStreak = 0; $maxLossStreak = 0;
        $terminalDir = null; $terminalLen = 0;
        $prevSign = null;
        $sinceLastLoss = null; // trades elapsed since most recent loss, for recovery-length tracking
        $baselineRisk = null;

        // First pass: baseline avg risk (all trades) for post-loss delta comparison.
        foreach ($rows as $r) {
            $rp = $r['trade']['risk_pct'] ?? null;
            if ($rp !== null && $rp !== '' && (float)$rp > 0) $riskPcts[] = (float)$rp;
        }
        $baselineRisk = !empty($riskPcts) ? array_sum($riskPcts) / count($riskPcts) : null;

        foreach ($rows as $i => $r) {
            $t = $r['trade']; $pnl = $r['pnl'];
            $sign = $pnl > 0.01 ? 1 : ($pnl < -0.01 ? -1 : 0);

            if ($sign > 0) { $winners++; $winStreak = ($prevSign === 1) ? $winStreak + 1 : 1; $lossStreak = 0; $maxWinStreak = max($maxWinStreak, $winStreak); }
            elseif ($sign < 0) { $losers++; $lossStreak = ($prevSign === -1) ? $lossStreak + 1 : 1; $winStreak = 0; $maxLossStreak = max($maxLossStreak, $lossStreak); }
            else { $breakevens++; $winStreak = 0; $lossStreak = 0; }
            if ($sign !== 0) { $terminalDir = $sign > 0 ? 'win' : 'loss'; $terminalLen = $sign > 0 ? $winStreak : $lossStreak; }
            $prevSign = $sign;

            // Recovery: how many trades after the most recent loss (counting
            // the winning trade itself as trade #1) it took to book a win.
            // Increment first, then check — a loss immediately followed by a
            // win must record 1 (that next trade IS the recovery), never 0.
            if ($sinceLastLoss !== null) {
                $sinceLastLoss++;
                if ($sign > 0) { $recoveryLengths[] = $sinceLastLoss; $sinceLastLoss = null; }
            }
            if ($sign < 0) { $sinceLastLoss = 0; }

            $cls = $t['trade_classification'] ?? null;
            if ($cls !== null && isset($classes[$cls])) $classes[$cls]++;

            $rp = $t['risk_pct'] ?? null;
            if ($rp !== null && $rp !== '' && (float)$rp > 0) {
                $rpF = (float)$rp;
                if ($maxRiskSpike === null || $rpF > $maxRiskSpike) $maxRiskSpike = $rpF;
                // Post-loss classification uses the PREVIOUS row's sign, computed before this loop overwrote $prevSign.
                if ($i > 0) {
                    $priorPnl = $rows[$i - 1]['pnl'];
                    $priorSign = $priorPnl > 0.01 ? 1 : ($priorPnl < -0.01 ? -1 : 0);
                    if ($priorSign < 0) {
                        $postLossRiskWith[] = $rpF;
                        if ($baselineRisk !== null && $baselineRisk > 0) {
                            if ($rpF >= $baselineRisk * 1.15) $postLossSpikeIds[] = $t['id'];
                            if ($rpF <= $baselineRisk * 0.85) $postLossPauseIds[] = $t['id'];
                        }
                    } else {
                        $postLossRiskWithout[] = $rpF;
                    }
                }
            }

            $dp = $t['declared_risk_pct'] ?? null;
            if ($dp !== null && $dp !== '' && (float)$dp > 0) {
                $declaredPcts[] = (float)$dp;
                if ($rp !== null && $rp !== '') {
                    // Wave 5c (docs/architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md
                    // §18): was a flat `$rp <= $dp * 1.2` (20% relative,
                    // no per-asset-class differentiation, no Contract-
                    // Constrained exclusion) — the exact stale formula
                    // Reports/Trader Profile moved off of in the 2026-07-27
                    // finishing pass (see CLAUDE.md). Repointed to the same
                    // canonical AnalyticsEngine::calculatorRiskTolerance()/
                    // minAchievableRiskPct() Stage 1 already uses: a
                    // Contract-Constrained trade (declared target never
                    // mathematically reachable at this instrument's broker-
                    // minimum lot size, at this account's balance) is
                    // excluded from BOTH the numerator and denominator —
                    // structural instrument limitation, not behavioral
                    // over-risk — mirroring computeFourStageRaw()'s own
                    // exact contract_constrained/risk_overage split.
                    $tolerance = AnalyticsEngine::calculatorRiskTolerance((float)$dp, $t['instrument'] ?? null);
                    $entryP = (isset($t['entry_price']) && $t['entry_price'] !== null && $t['entry_price'] !== '') ? (float)$t['entry_price'] : null;
                    $slP = (isset($t['sl_price']) && $t['sl_price'] !== null && $t['sl_price'] !== '') ? (float)$t['sl_price'] : null;
                    $balAtEntry = (isset($t['balance_at_entry']) && $t['balance_at_entry'] !== null && $t['balance_at_entry'] !== '') ? (float)$t['balance_at_entry'] : null;
                    $minAchievable = AnalyticsEngine::minAchievableRiskPct($t['instrument'] ?? null, $entryP, $slP, $balAtEntry);
                    if ($minAchievable !== null && $tolerance !== null && $minAchievable > (float)$dp + $tolerance) {
                        $declaredContractConstrained++;
                    } else {
                        $declaredChecked++;
                        if ($tolerance !== null && (float)$rp <= (float)$dp + $tolerance) $declaredAdherent++;
                    }
                }
            }

            $rr = $t['risk_reward_tp1'] ?? null;
            if ($rr !== null && $rr !== '') $rrs[] = (float)$rr;

            $segR = self::tradeRMultiple($t, $pnl);
            if ($segR !== null) $rMultiples[] = $segR;

            $ticked = $t['ticked_pct'] ?? null;
            if ($ticked === null || (float)$ticked < 100) $checklistSkipped++;

            $mood = $t['mood_before'] ?? null; $energy = $t['energy_before'] ?? null; $conf = $t['confidence_before'] ?? null;
            if ($sign > 0) {
                if ($mood !== null) $moodWin[] = (float)$mood;
                if ($energy !== null) $energyWin[] = (float)$energy;
                if ($conf !== null) $confWin[] = (float)$conf;
            } elseif ($sign < 0) {
                if ($mood !== null) $moodLoss[] = (float)$mood;
                if ($energy !== null) $energyLoss[] = (float)$energy;
                if ($conf !== null) $confLoss[] = (float)$conf;
            }
        }

        $decided = $winners + $losers;
        $totalPnl = array_sum(array_column($rows, 'pnl'));

        // Peak-to-trough max drawdown %, walked over this segment's own running balance.
        $peak = $accountSize; $maxDdPct = 0.0;
        foreach ($rows as $r) {
            if ($r['balance'] > $peak) $peak = $r['balance'];
            if ($peak > 0) { $ddPct = (($peak - $r['balance']) / $peak) * 100; if ($ddPct > $maxDdPct) $maxDdPct = $ddPct; }
        }

        $avg = fn($a) => !empty($a) ? array_sum($a) / count($a) : null;
        $baseline = $avg($postLossRiskWithout) ?? $baselineRisk;
        $postLossAvg = $avg($postLossRiskWith);
        $postLossDeltaPct = ($postLossAvg !== null && $baseline !== null && $baseline > 0)
            ? (($postLossAvg - $baseline) / $baseline) * 100 : null;

        // Median actual risk% — additive alongside the pre-existing MEAN
        // (avg_actual_risk_pct) below, which stays byte-identical in
        // meaning for every existing consumer (Lessons From Failure's
        // Core Statistics "Actual Risk Taken" row reads that key and is
        // untouched by this addition). The median exists specifically for
        // Lessons From Success's "Risk per Trade"/"Consistent risk
        // management" claims, found during this feature's own live
        // verification to be badly skewed by a single outlier account
        // (one synthetic breach-test account averaging 184% risk pulled a
        // pool whose real median sat around 6-7% up to 50%+) — the MEAN
        // is the wrong statistic for a value-judgment claim like
        // "consistent"/"typical" on data this skewed; the median is a
        // real, honestly-computed, outlier-robust alternative, not a
        // fabricated or capped number.
        $medianOf = function (array $a): ?float {
            if (empty($a)) return null;
            sort($a);
            $c = count($a);
            $mid = (int)floor($c / 2);
            return $c % 2 ? $a[$mid] : ($a[$mid - 1] + $a[$mid]) / 2;
        };

        return [
            'trade_count' => $n, 'win_count' => $winners, 'loss_count' => $losers, 'breakeven_count' => $breakevens,
            'win_rate' => $decided > 0 ? round($winners / $decided * 100, 1) : null,
            'total_pnl' => $totalPnl,
            'avg_actual_risk_pct' => $avg($riskPcts), 'n_actual_risk' => count($riskPcts),
            'median_actual_risk_pct' => $medianOf($riskPcts),
            'avg_declared_risk_pct' => $avg($declaredPcts), 'n_declared_risk' => count($declaredPcts),
            'avg_rr' => $avg($rrs), 'n_rr' => count($rrs),
            'expectancy_r' => $avg($rMultiples), 'n_r_multiple' => count($rMultiples),
            'classification_counts' => $classes,
            'classification_rate' => $n > 0 ? array_map(fn($c) => round($c / $n * 100, 1), $classes) : $classes,
            'checklist_skip_rate' => $n > 0 ? round($checklistSkipped / $n * 100, 1) : null,
            'declared_adherence_rate' => $declaredChecked > 0 ? round($declaredAdherent / $declaredChecked * 100, 1) : null,
            // §2 dead-code #10 fix (2026-08-10) — the real denominator
            // behind declared_adherence_rate above (trades with a declared
            // risk% AND not Contract-Constrained) was computed but never
            // exposed; findings()/patterns() were gating their declared-
            // adherence claims on the group's overall pooled trade count
            // ($n) instead, a much larger and less accurate proxy for "how
            // many trades actually had something to compare against."
            'n_declared_checked' => $declaredChecked,
            // Wave 5c — kept structurally separate from declared_adherence_rate
            // above (a Contract-Constrained trade is excluded from that rate
            // entirely, not counted as a violation), same split CLAUDE.md
            // documents for Reports' Intent vs Reality card.
            'declared_contract_constrained_count' => $declaredContractConstrained,
            'declared_contract_constrained_rate' => ($declaredChecked + $declaredContractConstrained) > 0
                ? round($declaredContractConstrained / ($declaredChecked + $declaredContractConstrained) * 100, 1) : null,
            'avg_mood_win' => $avg($moodWin), 'avg_mood_loss' => $avg($moodLoss),
            'avg_energy_win' => $avg($energyWin), 'avg_energy_loss' => $avg($energyLoss),
            'avg_confidence_win' => $avg($confWin), 'avg_confidence_loss' => $avg($confLoss),
            'max_risk_spike' => $maxRiskSpike,
            'max_win_streak' => $maxWinStreak, 'max_loss_streak' => $maxLossStreak,
            'terminal_streak_dir' => $terminalDir, 'terminal_streak_len' => $terminalLen,
            'max_drawdown_pct' => round($maxDdPct, 2),
            'post_loss_risk_delta_pct' => $postLossDeltaPct, 'n_post_loss_risk' => count($postLossRiskWith),
            'post_loss_spike_trade_ids' => $postLossSpikeIds, 'post_loss_pause_trade_ids' => $postLossPauseIds,
            'recovery_lengths' => $recoveryLengths,
            'revenge_rate' => $n > 0 ? round($classes['revenge_trade'] / $n * 100, 1) : null,
            'tilt_rate' => $n > 0 ? round($classes['tilt_warning'] / $n * 100, 1) : null,
        ];
    }

    // ────────────────────────────────────────────────────────────────
    // Group-level aggregation (pooled across a list of segments)
    // ────────────────────────────────────────────────────────────────

    public static function aggregateGroup(array $segments): array
    {
        $flatRows = self::flattenRows($segments);
        $pooled = self::segmentMetrics($flatRows, 0.0); // accountSize=0 -> drawdown from pooled rows isn't meaningful, ignored below

        $tradeCounts = array_column(array_column($segments, 'metrics'), 'trade_count');
        $days = [];
        foreach ($segments as $s) {
            if (!empty($s['start_date']) && !empty($s['end_date'])) {
                $d = (strtotime($s['end_date']) - strtotime($s['start_date'])) / 86400;
                if ($d >= 0) $days[] = $d;
            }
        }
        sort($days);
        $median = function ($a) { $c = count($a); if ($c === 0) return null; $m = (int)floor($c / 2); return $c % 2 ? $a[$m] : ($a[$m - 1] + $a[$m]) / 2; };

        $maxDds = array_filter(array_column(array_column($segments, 'metrics'), 'max_drawdown_pct'), fn($v) => $v !== null);
        $maxWinStreaks = array_column(array_column($segments, 'metrics'), 'max_win_streak');
        $maxLossStreaks = array_column(array_column($segments, 'metrics'), 'max_loss_streak');
        $terminalWinLens = []; $terminalLossLens = [];
        foreach ($segments as $s) {
            $m = $s['metrics'];
            if ($m['terminal_streak_dir'] === 'win') $terminalWinLens[] = $m['terminal_streak_len'];
            if ($m['terminal_streak_dir'] === 'loss') $terminalLossLens[] = $m['terminal_streak_len'];
        }
        $riskSpikes = array_filter(array_column(array_column($segments, 'metrics'), 'max_risk_spike'), fn($v) => $v !== null);
        $checklistSkipRates = array_filter(array_column(array_column($segments, 'metrics'), 'checklist_skip_rate'), fn($v) => $v !== null);
        $recoveryAll = [];
        foreach ($segments as $s) { $recoveryAll = array_merge($recoveryAll, $s['metrics']['recovery_lengths']); }

        $avg = fn($a) => !empty($a) ? array_sum($a) / count($a) : null;

        // Coefficient of variation of pooled actual risk% — stddev/mean.
        // Computed once here (not inside segmentMetrics(), which has no use
        // for it) since it's only needed at group level, by Historical
        // Discipline Index and pattern discovery. Sourced from
        // EdgeHealthEngine::coefficientOfVariation() (2026-08-08) — the
        // exact same formula this file used to reimplement inline (docs/
        // architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md §8b, Wave 4b).
        $pooledRiskPcts = [];
        foreach ($flatRows as $r) {
            $rp = $r['trade']['risk_pct'] ?? null;
            if ($rp !== null && $rp !== '' && (float)$rp > 0) $pooledRiskPcts[] = (float)$rp;
        }
        $riskCv = EdgeHealthEngine::coefficientOfVariation($pooledRiskPcts) ?? 0.0;

        return [
            '_risk_cv' => $riskCv,
            'n_segments' => count($segments), 'n_accounts' => count(array_unique(array_column($segments, 'account_id'))),
            'avg_trades' => $avg($tradeCounts),
            'avg_days' => $avg($days), 'median_days' => $median($days),
            'n_days_samples' => count($days),
            'avg_max_drawdown_pct' => $avg($maxDds), 'n_drawdown_samples' => count($maxDds),
            'avg_max_win_streak' => $avg($maxWinStreaks), 'avg_max_loss_streak' => $avg($maxLossStreaks),
            'avg_terminal_win_streak' => $avg($terminalWinLens), 'n_terminal_win' => count($terminalWinLens),
            'avg_terminal_loss_streak' => $avg($terminalLossLens), 'n_terminal_loss' => count($terminalLossLens),
            'avg_risk_spike' => $avg($riskSpikes),
            'avg_checklist_skip_rate' => $avg($checklistSkipRates),
            'avg_recovery_trades' => $avg($recoveryAll), 'n_recovery_samples' => count($recoveryAll),
            // Pooled per-trade stats (weighted by real trade count, not
            // averaged-of-averages) — win rate, risk, RR, expectancy,
            // classification mix, checklist adherence, mood/confidence.
            'pooled' => $pooled,
            'pooled_trade_count' => count($flatRows),
        ];
    }

    public static function flattenRows(array $segments): array
    {
        $rows = [];
        foreach ($segments as $s) {
            foreach ($s['rows'] as $r) {
                $r['_account_id'] = $s['account_id'];
                $r['_account_label'] = $s['account_label'];
                $r['_phase_label'] = $s['phase_label'];
                $r['_status'] = $s['status'];
                $r['_challenge_type'] = $s['challenge_type'];
                $rows[] = $r;
            }
        }
        return $rows;
    }

    // ────────────────────────────────────────────────────────────────
    // Historical Discipline Index (renamed 2026-08-08, was "Historical
    // Consistency Score" — the display label collided with Reports/
    // Journal/Trader Profile's own real "Consistency Score," which is a
    // genuinely different statistic; see
    // docs/architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md §8b, Wave 4b).
    // Deliberately NOT the same metric as Active Journeys/Edge Health's
    // "Execution Consistency" factor. That score grades a strategy's
    // live, ongoing risk/R consistency; this one grades how consistently
    // disciplined a whole population of FINISHED accounts traded, from
    // two real, pooled signals: how stable their actual risk-taking was
    // (coefficient of variation of risk_pct, now sourced from
    // EdgeHealthEngine::coefficientOfVariation() rather than an inline
    // reimplementation — see aggregateGroup() above), and what share of
    // their trades avoided every tilt/revenge/oversized/elevated
    // classification. Equal-weighted, both terms real and explained
    // wherever this score is shown. The function name and the
    // 'historical_consistency' data array key are UNCHANGED — only the
    // user-facing display label was renamed.
    // ────────────────────────────────────────────────────────────────

    public static function historicalConsistency(array $group): array
    {
        $pooled = $group['pooled'];
        $n = $group['pooled_trade_count'];
        if ($n < self::MIN_HISTORICAL_CONSISTENCY_TRADES || $pooled['n_actual_risk'] < self::MIN_HISTORICAL_CONSISTENCY_TRADES) {
            return ['available' => false, 'n' => $n];
        }
        $classes = $pooled['classification_counts'];
        $clean = $classes['disciplined_win'] + $classes['controlled_loss'] + $classes['breakeven'];
        $disciplineRate = $n > 0 ? $clean / $n * 100 : 0.0;

        // Coefficient of variation of actual risk% pooled across every trade in the group.
        $riskCv = $group['_risk_cv'] ?? 0.0;
        $riskConsistency = 100 - min(100, $riskCv * 100);

        $score = round(0.5 * $riskConsistency + 0.5 * $disciplineRate, 1);
        return [
            'available' => true, 'score' => max(0, min(100, $score)), 'n' => $n,
            'risk_consistency' => round($riskConsistency, 1), 'discipline_rate' => round($disciplineRate, 1),
            'risk_cv' => round($riskCv, 3),
        ];
    }

    // ────────────────────────────────────────────────────────────────
    // Phase breakdown / easiest-hardest / difficulty scoring
    // ────────────────────────────────────────────────────────────────

    /** Groups ALL resolved (passed or breached) segments — regardless of lens — by phase label, for pass-rate/difficulty purposes. */
    public static function phaseBreakdown(array $allPropSegments): array
    {
        $byLabel = [];
        foreach ($allPropSegments as $s) {
            if (!in_array($s['status'], ['passed', 'breached', 'funded_active'], true)) continue;
            $byLabel[$s['phase_label']][] = $s;
        }
        $out = [];
        foreach ($byLabel as $label => $segs) {
            $resolved = array_values(array_filter($segs, fn($s) => in_array($s['status'], ['passed', 'breached'], true)));
            $passed = array_values(array_filter($resolved, fn($s) => $s['status'] === 'passed'));
            $breached = array_values(array_filter($resolved, fn($s) => $s['status'] === 'breached'));
            $passRate = count($resolved) > 0 ? count($passed) / count($resolved) * 100 : null;
            $group = self::aggregateGroup($segs);
            $out[$label] = [
                'label' => $label, 'n_resolved' => count($resolved), 'n_passed' => count($passed), 'n_breached' => count($breached),
                'n_funded_ongoing' => count($segs) - count($resolved),
                'pass_rate' => $passRate, 'group' => $group,
            ];
        }
        return $out;
    }

    public static function easiestHardestPhase(array $phaseBreakdown): array
    {
        $eligible = array_filter($phaseBreakdown, fn($p) => $p['n_resolved'] >= self::MIN_GROUP_SEGMENTS && $p['pass_rate'] !== null);
        // §2 dead-code #11 fix (2026-08-10) — "Easiest"/"Hardest" is
        // inherently a RELATIVE claim between at least two real phases;
        // with exactly one phase clearing the sample floor, the loop below
        // (which initializes both from the first eligible item and never
        // has anything else to compare it against) resolved both labels to
        // the identical phase — a single data point can't honestly be
        // called both the easiest AND the hardest thing in its own set.
        if (count($eligible) < 2) return ['easiest' => null, 'hardest' => null];
        $easiest = null; $hardest = null;
        foreach ($eligible as $p) {
            if ($easiest === null || $p['pass_rate'] > $eligible[$easiest]['pass_rate']) $easiest = $p['label'];
            if ($hardest === null || $p['pass_rate'] < $eligible[$hardest]['pass_rate']) $hardest = $p['label'];
        }
        return ['easiest' => $easiest !== null ? $eligible[$easiest] : null, 'hardest' => $hardest !== null ? $eligible[$hardest] : null];
    }

    /** One 0-10 difficulty score per phase label with enough resolved history, plus the real numbers that produced it. */
    public static function difficultyBoard(array $allPropSegments): array
    {
        $breakdown = self::phaseBreakdown($allPropSegments);
        $board = [];
        foreach ($breakdown as $label => $p) {
            if ($p['n_resolved'] < self::MIN_GROUP_SEGMENTS) { $board[$label] = ['available' => false, 'n_resolved' => $p['n_resolved']]; continue; }
            $g = $p['group'];
            $failRate = 1 - ($p['pass_rate'] / 100);
            $dayScore = $g['avg_days'] !== null ? min(1, $g['avg_days'] / 60) : 0;

            // Real, per-account configured drawdown limit ONLY — never a
            // hardcoded assumption. Some prop firms configure 5%, others
            // 8%, 10%, 12%+; this must always be read from each account's
            // own stored rules, not assumed. Segments whose account has no
            // usable configured value are excluded from the average (not
            // defaulted to a guess) and counted separately so the caller
            // can disclose partial coverage rather than imply every
            // account's real limit was known.
            $ddSum = 0.0; $ddCount = 0; $ddMissing = 0;
            foreach ($allPropSegments as $s) {
                if ($s['phase_label'] !== $label) continue;
                if ($s['max_dd_pct_configured'] !== null) { $ddSum += $s['max_dd_pct_configured']; $ddCount++; }
                else { $ddMissing++; }
            }
            $typicalLimit = $ddCount > 0 ? $ddSum / $ddCount : null;
            $ddScore = ($g['avg_max_drawdown_pct'] !== null && $typicalLimit !== null && $typicalLimit > 0)
                ? min(1, $g['avg_max_drawdown_pct'] / $typicalLimit) : 0;
            $streakScore = $g['avg_max_loss_streak'] !== null ? min(1, $g['avg_max_loss_streak'] / 8) : 0;
            $recoveryScore = $g['avg_recovery_trades'] !== null ? min(1, $g['avg_recovery_trades'] / 10) : 0;

            $score = round($failRate * 4 + $dayScore * 2 + $ddScore * 2 + $streakScore * 1.5 + $recoveryScore * 0.5, 1);
            $score = max(0, min(10, $score));

            if ($typicalLimit === null) {
                $limitPhrase = 'no configured drawdown limit on record for these accounts';
            } elseif ($ddMissing > 0) {
                $limitPhrase = sprintf('typical configured limit %.1f%%, based on %d of %d accounts with a limit on record', $typicalLimit, $ddCount, $ddCount + $ddMissing);
            } else {
                $limitPhrase = sprintf('typical configured limit %.1f%%', $typicalLimit);
            }
            $lossStreakPhrase = naturalCount($g['avg_max_loss_streak'], 'consecutive loss', 'consecutive losses') ?? 'not enough data to estimate';
            $recoveryPhrase = $g['avg_recovery_trades'] !== null
                ? 'typically ' . naturalCount($g['avg_recovery_trades'], 'trade') . ' to recover after a loss'
                : 'not enough data yet to estimate recovery speed';

            $board[$label] = [
                'available' => true, 'score' => $score, 'n_resolved' => $p['n_resolved'],
                'pass_rate' => round($p['pass_rate'], 1), 'avg_days' => $g['avg_days'], 'avg_max_drawdown_pct' => $g['avg_max_drawdown_pct'],
                'avg_max_loss_streak' => $g['avg_max_loss_streak'], 'avg_recovery_trades' => $g['avg_recovery_trades'],
                'typical_dd_limit' => $typicalLimit,
                'explanation' => sprintf(
                    'Difficulty %.1f/10, based on a %.0f%% pass rate (%d of %d resolved attempts), an average %.1f%% drawdown before the outcome (%s), losing streaks that typically ran %s, and %s.',
                    $score, $p['pass_rate'], $p['n_passed'], $p['n_resolved'],
                    $g['avg_max_drawdown_pct'] ?? 0, $limitPhrase, $lossStreakPhrase, $recoveryPhrase
                ),
            ];
        }
        return $board;
    }

    // ────────────────────────────────────────────────────────────────
    // Breach-cause bucketing — same convention as Reports' Boundary
    // Analysis "Behavior Patterns Leading to Breaches" tally.
    // ────────────────────────────────────────────────────────────────

    /**
     * A blank current_outcome on a breached/closed-at-a-loss account is not
     * a trader failing to fill in a field — verified live (2026-07-31):
     * every real breach in this database with a NULL current_outcome ALSO
     * has a NULL boundary_occurred_at, meaning it never passed through the
     * real resolve_trade_id breach flow at all (that handler always writes
     * boundary_occurred_at = NOW() and defaults an empty reason to 'OTHER'
     * — it can never leave current_outcome truly blank). A blank value can
     * only mean this account's breach predates/bypassed that pipeline
     * (seeded/legacy data), so it is labeled as such rather than implying
     * the trader skipped a now-mandatory field.
     */
    public static function bucketOutcome(?string $text): string
    {
        $text = trim((string)$text);
        if ($text === '') return 'Legacy data (reason unavailable)';
        if (preg_match('/^[A-Z0-9_]+$/', $text)) {
            return ucwords(strtolower(str_replace('_', ' ', $text)));
        }
        return 'Trader-described (other)';
    }

    public static function breachCauses(array $segments): array
    {
        $tally = [];
        foreach ($segments as $s) {
            if ($s['status'] !== 'breached' && $s['status'] !== 'closed_loss') continue;
            $bucket = self::bucketOutcome($s['current_outcome']);
            $tally[$bucket] = ($tally[$bucket] ?? 0) + 1;
        }
        arsort($tally);
        $total = array_sum($tally);
        $out = [];
        foreach (array_slice($tally, 0, 6, true) as $label => $count) {
            $out[] = ['label' => $label, 'count' => $count, 'pct' => $total > 0 ? round($count / $total * 100, 1) : 0];
        }
        return $out;
    }

    // ────────────────────────────────────────────────────────────────
    // Evidence-backed findings — every sentence embeds real numbers and
    // only fires when the underlying sample clears MIN_GROUP_TRADES /
    // MIN_GROUP_SEGMENTS. Each finding optionally carries a filter the
    // page's embedded evidence table can be driven by.
    // ────────────────────────────────────────────────────────────────

    public static function findings(array $segments, array $group, string $lens, string $type): array
    {
        $out = [];
        $pooled = $group['pooled'];
        $n = $group['pooled_trade_count'];
        $nSeg = $group['n_segments'];

        if ($lens === 'success') {
            if ($nSeg >= self::MIN_GROUP_SEGMENTS && $group['avg_trades'] !== null) {
                $out[] = ['text' => sprintf('Reaching a successful outcome typically took %s, over a typical span of %s.',
                    naturalCount($group['avg_trades'], 'trade'),
                    $group['median_days'] !== null ? formatDurationDays($group['median_days']) : 'an unknown amount of time'),
                    'filter' => null];
            }
            // §2 dead-code #9 fix (2026-08-10) — "Most" is a claim about a
            // real MAJORITY of the population, but this only ever checked
            // the absolute count of terminal-win-streak segments against
            // the sample floor (3), never that count as a fraction of the
            // group's real total resolved segments ($nSeg) — "Most" could
            // be literally true at 6% of the real population (e.g. 3 of 50
            // segments). Now requires a genuine majority in addition to the
            // existing sample floor.
            if ($group['n_terminal_win'] >= self::MIN_GROUP_SEGMENTS && $nSeg > 0 && ($group['n_terminal_win'] / $nSeg) > 0.5) {
                $out[] = ['text' => sprintf('Most successful attempts closed out on a winning streak: the run of consecutive wins leading straight into the pass typically reached %s.',
                    naturalCount($group['avg_terminal_win_streak'], 'consecutive win', 'consecutive wins')), 'filter' => null];
            }
            if ($group['n_recovery_samples'] >= self::MIN_GROUP_TRADES) {
                $out[] = ['text' => sprintf('After a loss, it typically took %s to book the next win across these accounts.',
                    naturalCount($group['avg_recovery_trades'], 'trade')), 'filter' => null];
            }
            if ($n >= self::MIN_GROUP_TRADES && $pooled['avg_declared_risk_pct'] !== null && $pooled['avg_actual_risk_pct'] !== null) {
                $out[] = ['text' => sprintf('Declared risk averaged %.2f%% per trade; actual risk taken averaged %.2f%%.',
                    $pooled['avg_declared_risk_pct'], $pooled['avg_actual_risk_pct']), 'filter' => null];
            }
            if ($n >= self::MIN_GROUP_TRADES && $pooled['avg_rr'] !== null) {
                $out[] = ['text' => sprintf('Average planned reward:risk across these trades was 1 : %.2f, with a %.1f%% win rate and %s R expectancy per trade.',
                    $pooled['avg_rr'], $pooled['win_rate'] ?? 0, $pooled['expectancy_r'] !== null ? number_format($pooled['expectancy_r'], 2) : 'unknown'),
                    'filter' => null];
            }
            if ($n >= self::MIN_GROUP_TRADES && $pooled['revenge_rate'] !== null) {
                $out[] = ['text' => sprintf('Only %.1f%% of trades on the way to success were classified Revenge Trades.', $pooled['revenge_rate']),
                    'filter' => $pooled['classification_counts']['revenge_trade'] > 0 ? ['attr' => 'classification', 'value' => 'revenge_trade', 'label' => 'Revenge Trades'] : null];
            }
        } else {
            // §2 dead-code #10 fix (2026-08-10) — gated on $n (the group's
            // overall pooled trade count) rather than on the real, smaller
            // denominator declared_adherence_rate is actually computed
            // over (n_declared_checked — trades with a declared risk% AND
            // not excluded as Contract-Constrained). A large $n with only
            // a handful of trades carrying a declared risk% could still
            // produce a confident 0%/100% "exceeded declared risk" claim
            // from that tiny real subgroup.
            if ($pooled['n_declared_checked'] >= self::MIN_GROUP_TRADES && $pooled['avg_actual_risk_pct'] !== null && $pooled['avg_declared_risk_pct'] !== null && $pooled['avg_declared_risk_pct'] > 0) {
                $overRate = $pooled['declared_adherence_rate'] !== null ? 100 - $pooled['declared_adherence_rate'] : null;
                if ($overRate !== null) {
                    $out[] = ['text' => sprintf('%.0f%% of trades leading to these outcomes exceeded declared risk beyond normal lot-size tolerance.', $overRate), 'filter' => null];
                }
            }
            if ($group['n_terminal_loss'] >= self::MIN_GROUP_SEGMENTS) {
                $out[] = ['text' => sprintf('The losing streak running directly into a breach typically reached %s.',
                    naturalCount($group['avg_terminal_loss_streak'], 'consecutive loss', 'consecutive losses')), 'filter' => null];
            }
            if ($n >= self::MIN_GROUP_TRADES && $pooled['revenge_rate'] !== null && $pooled['revenge_rate'] > 0) {
                $out[] = ['text' => sprintf('%.1f%% of trades in these accounts were classified Revenge Trades.', $pooled['revenge_rate']),
                    'filter' => ['attr' => 'classification', 'value' => 'revenge_trade', 'label' => 'Revenge Trades']];
            }
            if ($n >= self::MIN_GROUP_TRADES && $pooled['tilt_rate'] !== null && $pooled['tilt_rate'] > 0) {
                $out[] = ['text' => sprintf('%.1f%% of trades carried a Tilt Warning.', $pooled['tilt_rate']),
                    'filter' => ['attr' => 'classification', 'value' => 'tilt_warning', 'label' => 'Tilt Warning Trades']];
            }
            if ($n >= self::MIN_GROUP_TRADES && $group['avg_checklist_skip_rate'] !== null) {
                $out[] = ['text' => sprintf('%.0f%% of trades were taken without a fully completed checklist.', $group['avg_checklist_skip_rate']),
                    'filter' => ['attr' => 'checklist-skipped', 'value' => '1', 'label' => 'Checklist Not Fully Completed']];
            }
            if ($n >= self::MIN_GROUP_TRADES && $pooled['avg_confidence_loss'] !== null && $pooled['avg_confidence_win'] !== null) {
                $delta = $pooled['avg_confidence_win'] - $pooled['avg_confidence_loss'];
                if (abs($delta) >= 0.3) {
                    $out[] = ['text' => sprintf('Self-reported confidence before losing trades averaged %.1f/5, versus %.1f/5 before winning trades.',
                        $pooled['avg_confidence_loss'], $pooled['avg_confidence_win']), 'filter' => null];
                }
            }
            if ($pooled['max_risk_spike'] !== null && $pooled['avg_actual_risk_pct'] !== null && $pooled['avg_actual_risk_pct'] > 0) {
                $out[] = ['text' => sprintf('The largest single risk spike recorded was %.2f%%, against an average of %.2f%% across these trades.',
                    $pooled['max_risk_spike'], $pooled['avg_actual_risk_pct']), 'filter' => null];
            }
        }

        return $out;
    }

    // ────────────────────────────────────────────────────────────────
    // Success / failure pattern discovery — internal, within-lens
    // comparison (post-loss behavior vs. the group's own baseline), not
    // hardcoded which patterns exist. A pattern only appears when its real
    // computed delta clears a stated threshold.
    // ────────────────────────────────────────────────────────────────

    public static function patterns(array $segments, string $lens): array
    {
        $group = self::aggregateGroup($segments);
        $pooled = $group['pooled'];
        $n = $group['pooled_trade_count'];
        $out = [];
        if ($n < self::MIN_PATTERN_TRADES) return $out;

        if ($pooled['n_post_loss_risk'] >= self::MIN_PATTERN_TRADES && $pooled['post_loss_risk_delta_pct'] !== null) {
            $delta = $pooled['post_loss_risk_delta_pct'];
            if ($lens === 'success' && $delta <= -10) {
                $out[] = ['text' => sprintf('Risk-taking dropped an average of %.0f%% immediately after a loss, before rising back to normal.', abs($delta)),
                    'filter' => !empty($pooled['post_loss_pause_trade_ids']) ? ['attr' => 'post-loss-pause', 'value' => '1', 'label' => 'Reduced-Risk Trades After a Loss'] : null];
            } elseif ($lens === 'failure' && $delta >= 15) {
                $out[] = ['text' => sprintf('Risk-taking increased an average of %.0f%% on the trade immediately following a loss.', $delta),
                    'filter' => !empty($pooled['post_loss_spike_trade_ids']) ? ['attr' => 'post-loss-spike', 'value' => '1', 'label' => 'Increased-Risk Trades After a Loss'] : null];
            }
        }

        // §2 dead-code #10 fix (2026-08-10) — this claim had NO sample-
        // size floor at all (every other claim in this function checks at
        // least MIN_PATTERN_TRADES against $n, itself already gated at the
        // top of the function) — declared_adherence_rate's real denominator
        // (n_declared_checked) can be a small fraction of $n, so a confident
        // 0%/100% could fire from a handful of trades even on an otherwise
        // well-sampled group.
        if ($pooled['declared_adherence_rate'] !== null && $pooled['n_declared_checked'] >= self::MIN_GROUP_TRADES) {
            if ($lens === 'success' && $pooled['declared_adherence_rate'] >= 85) {
                $out[] = ['text' => sprintf('Declared risk was respected on %.0f%% of trades with a strategy attached.', $pooled['declared_adherence_rate']), 'filter' => null];
            } elseif ($lens === 'failure' && $pooled['declared_adherence_rate'] < 60) {
                $out[] = ['text' => sprintf('Declared risk was exceeded beyond normal lot-size tolerance on %.0f%% of trades with a strategy attached.', 100 - $pooled['declared_adherence_rate']), 'filter' => null];
            }
        }

        $riskCv = $group['_risk_cv'] ?? null;
        if ($riskCv !== null) {
            if ($lens === 'success' && $riskCv <= 0.35) {
                $out[] = ['text' => 'Position sizing stayed steady from trade to trade rather than swinging widely.', 'filter' => null];
            } elseif ($lens === 'failure' && $riskCv > 0.7) {
                $out[] = ['text' => 'Position sizing swung widely from trade to trade rather than staying steady.', 'filter' => null];
            }
        }

        if ($pooled['avg_rr'] !== null) {
            if ($lens === 'success' && $pooled['avg_rr'] >= 1.5) {
                $out[] = ['text' => sprintf('A higher average reward:risk (1 : %.2f) was maintained throughout.', $pooled['avg_rr']), 'filter' => null];
            }
        }

        if ($pooled['revenge_rate'] !== null) {
            if ($lens === 'success' && $pooled['revenge_rate'] <= 2) {
                $out[] = ['text' => 'Revenge trading was essentially absent.', 'filter' => null];
            } elseif ($lens === 'failure' && $pooled['revenge_rate'] >= 10) {
                $out[] = ['text' => sprintf('Revenge trading was a recurring pattern (%.1f%% of trades).', $pooled['revenge_rate']),
                    'filter' => ['attr' => 'classification', 'value' => 'revenge_trade', 'label' => 'Revenge Trades']];
            }
        }

        if ($group['avg_checklist_skip_rate'] !== null) {
            if ($lens === 'failure' && $group['avg_checklist_skip_rate'] >= 50) {
                $out[] = ['text' => sprintf('The checklist was skipped or left incomplete on %.0f%% of trades.', $group['avg_checklist_skip_rate']),
                    'filter' => ['attr' => 'checklist-skipped', 'value' => '1', 'label' => 'Checklist Not Fully Completed']];
            }
        }

        return $out;
    }

    // ────────────────────────────────────────────────────────────────
    // Comparative analytics
    // ────────────────────────────────────────────────────────────────

    /**
     * Dispatches to a completely separate comparison set per account type —
     * this was the root of a real context-leakage bug (found 2026-07-31):
     * the old single implementation always computed all four prop-firm-
     * specific comparisons (1 Step vs 2 Step, Phase 1 vs Phase 2, Instant
     * Funding vs Standard Challenge, and a cross-type Live-vs-Prop blend)
     * regardless of which type/program tab was active, so Live Accounts
     * silently showed the exact same phase/challenge-program comparisons
     * as Prop Firms, and every Prop program tab showed the identical
     * unscoped set instead of comparisons meaningful to the program
     * actually selected. A live account has no phase, challenge, or
     * funding concept at all — it must never see those terms.
     */
    public static function comparativeAnalytics(array $allProp, array $allLive, string $type, string $program): array
    {
        return $type === 'prop'
            ? self::comparativeAnalyticsProp($allProp, $program)
            : self::comparativeAnalyticsLive($allLive);
    }

    /**
     * Scoped strictly to the active program — never the old "compute all
     * four regardless of tab" behavior. Each program only sees comparisons
     * that are conceptually meaningful for IT:
     *   - 1 Step:            vs. 2 Step (the one program-level comparison
     *                         that actually involves one-step accounts).
     *   - 2 Step:             Phase 1 vs. Phase 2, scoped to two_step
     *                         accounts only (never blended with 3-step).
     *   - 3 Step:             Phase 1 vs. Phase 2 AND Phase 2 vs. Phase 3,
     *                         scoped to three_step accounts only. Phase 3
     *                         is never silently dropped when data is thin —
     *                         an explicit "not enough data" message renders
     *                         in its place instead.
     *   - Instant Funding:    vs. Standard Challenge only. Never compared
     *                         against "Phase 1" — instant-funded accounts
     *                         have no phase concept to compare.
     * A cross-program "Live vs. Prop" blend is deliberately not produced
     * anywhere in Trading Archive — mixing account types belongs to a
     * dedicated cross-account view, not a single type's own archive page
     * (the same principle already established for Reports' Comparison
     * work: blended prop+live analytics don't belong inside a page scoped
     * to one type).
     */
    private static function comparativeAnalyticsProp(array $allProp, string $program): array
    {
        $resolvedProp = array_values(array_filter($allProp, fn($s) => in_array($s['status'], ['passed', 'breached'], true)));
        $byChallengeType = function (array $segs, string $ct) { return array_values(array_filter($segs, fn($s) => $s['challenge_type'] === $ct)); };
        $byPhase = function (array $segs, string $label) { return array_values(array_filter($segs, fn($s) => $s['phase_label'] === $label)); };
        $out = [];

        if ($program === 'one_step') {
            $cmp = self::compareTwoGroups('1 Step', $byChallengeType($resolvedProp, 'one_step'), '2 Step', $byChallengeType($resolvedProp, 'two_step'));
            $out['one_step_vs_two_step'] = $cmp ?? ['message' => 'Not enough resolved 1 Step and 2 Step attempts yet to compare.'];
            return $out;
        }

        if ($program === 'two_step') {
            $segs = $byChallengeType($resolvedProp, 'two_step');
            $cmp = self::compareTwoGroups('Phase 1', $byPhase($segs, 'Phase 1'), 'Phase 2', $byPhase($segs, 'Phase 2'));
            $out['phase1_vs_phase2'] = $cmp ?? ['message' => 'Not enough completed Phase 1 and Phase 2 attempts yet to compare.'];
            return $out;
        }

        if ($program === 'three_step') {
            $segs = $byChallengeType($resolvedProp, 'three_step');
            $phase1 = $byPhase($segs, 'Phase 1'); $phase2 = $byPhase($segs, 'Phase 2'); $phase3 = $byPhase($segs, 'Phase 3');
            $p1v2 = self::compareTwoGroups('Phase 1', $phase1, 'Phase 2', $phase2);
            $out['phase1_vs_phase2'] = $p1v2 ?? ['message' => 'Not enough completed Phase 1 and Phase 2 attempts yet to compare.'];
            // Phase 3 must never be silently omitted just because it's thin data.
            $p2v3 = self::compareTwoGroups('Phase 2', $phase2, 'Phase 3', $phase3);
            $out['phase2_vs_phase3'] = $p2v3 ?? ['message' => 'Not enough completed Phase 3 attempts yet.'];
            return $out;
        }

        if ($program === 'instant_funding') {
            $instant = $byChallengeType($resolvedProp, 'instant_funding');
            $challenge = array_values(array_filter($resolvedProp, fn($s) => in_array($s['challenge_type'], ['one_step', 'two_step', 'three_step'], true)));
            $cmp = self::compareTwoGroups('Instant Funding', $instant, 'Standard Challenge', $challenge);
            $out['instant_vs_challenge'] = $cmp ?? ['message' => 'Not enough resolved Instant Funding attempts yet to compare against standard challenges.'];
            return $out;
        }

        return $out;
    }

    /**
     * Live Accounts have no phase, challenge, or funding concept — this is
     * a completely separate comparison set, never the prop-firm one.
     * Broker Comparison groups by the real "{Broker} - {Nickname}" label
     * convention already established elsewhere in this app (Right Now's
     * Account Summary panel derives the same way) — no new schema field,
     * just parsing a field already fetched. Account Longevity splits the
     * resolved population at its own real median duration rather than a
     * fixed/hardcoded day threshold, since what counts as "long-held" is
     * relative to each trader's own account history, not a fixed number
     * that would be wrong for most real distributions.
     */
    private static function comparativeAnalyticsLive(array $allLive): array
    {
        $resolved = array_values(array_filter($allLive, fn($s) => in_array($s['status'], ['closed_profit', 'closed_loss'], true)));
        $out = [];

        $byBroker = [];
        foreach ($resolved as $s) { $byBroker[self::brokerFromLabel($s['account_label'])][] = $s; }
        $eligible = array_filter($byBroker, fn($g) => count($g) >= self::MIN_GROUP_SEGMENTS);
        if (count($eligible) >= 2) {
            uasort($eligible, fn($a, $b) => count($b) <=> count($a));
            $names = array_keys($eligible);
            $out['broker_comparison'] = self::compareTwoGroups($names[0], $eligible[$names[0]], $names[1], $eligible[$names[1]]);
        } else {
            $out['broker_comparison'] = ['message' => 'Not enough closed accounts across two different brokers yet to compare.'];
        }

        $withDuration = [];
        foreach ($resolved as $s) {
            if (!empty($s['start_date']) && !empty($s['end_date'])) {
                $d = (strtotime($s['end_date']) - strtotime($s['start_date'])) / 86400;
                if ($d >= 0) $withDuration[] = ['seg' => $s, 'days' => $d];
            }
        }
        if (count($withDuration) >= self::MIN_GROUP_SEGMENTS * 2) {
            usort($withDuration, fn($a, $b) => $a['days'] <=> $b['days']);
            $mid = (int)floor(count($withDuration) / 2);
            $shorter = array_slice($withDuration, 0, $mid);
            $longer = array_slice($withDuration, $mid);
            $shorterDays = array_column($shorter, 'days'); $longerDays = array_column($longer, 'days');
            if (count($shorter) >= self::MIN_GROUP_SEGMENTS && count($longer) >= self::MIN_GROUP_SEGMENTS && !empty($longerDays) && !empty($shorterDays) && max($longerDays) > min($shorterDays)) {
                $out['account_longevity'] = self::compareTwoGroups(
                    'Shorter-Held Accounts', array_column($shorter, 'seg'),
                    'Longer-Held Accounts', array_column($longer, 'seg')
                );
            } else {
                $out['account_longevity'] = ['message' => 'Not enough spread in account lifespan yet to compare shorter- vs. longer-held accounts.'];
            }
        } else {
            $out['account_longevity'] = ['message' => 'Not enough closed accounts yet to compare account longevity.'];
        }

        return $out;
    }

    /** Parses the real "{Broker} - {Nickname}" account-label convention (same one Active Journeys' Account Summary panel already relies on) to group live accounts by broker for comparison — falls back to the whole label when an account was never labeled that way. */
    private static function brokerFromLabel(string $label): string
    {
        $parts = explode(' - ', $label, 2);
        $broker = trim($parts[0]);
        return $broker !== '' ? $broker : 'Unlabeled Account';
    }

    private static function compareTwoGroups(string $labelA, array $segsA, string $labelB, array $segsB): ?array
    {
        if (count($segsA) < self::MIN_GROUP_SEGMENTS || count($segsB) < self::MIN_GROUP_SEGMENTS) return null;
        return [
            'a' => self::sideSummary($labelA, $segsA),
            'b' => self::sideSummary($labelB, $segsB),
        ];
    }

    private static function sideSummary(string $label, array $segs): array
    {
        $passStatuses = ['passed', 'closed_profit'];
        $passed = count(array_filter($segs, fn($s) => in_array($s['status'], $passStatuses, true)));
        $group = self::aggregateGroup($segs);
        return [
            'label' => $label, 'n' => count($segs),
            'pass_rate' => round($passed / count($segs) * 100, 1),
            'avg_days' => $group['avg_days'], 'avg_max_drawdown_pct' => $group['avg_max_drawdown_pct'],
            'avg_recovery_trades' => $group['avg_recovery_trades'],
            'win_rate' => $group['pooled']['win_rate'], 'expectancy_r' => $group['pooled']['expectancy_r'],
            'avg_risk_spike' => $group['avg_risk_spike'],
        ];
    }

    // ────────────────────────────────────────────────────────────────
    // MT5 reconciliation readiness — architecture prepared for holding-
    // duration / session-timing / overnight-exposure analytics; activates
    // automatically once reconciled trades exist, never a hardcoded claim.
    // ────────────────────────────────────────────────────────────────

    public static function mt5Readiness(string $userId, $db): array
    {
        // Validation Workflow (2026-08-18 correction) — same real gap as
        // gatherPropSegments()/gatherLiveSegments().
        $tradeValidationSql = validationScopeSql(ValidationService::getWorkflowState($userId, $db));
        $row = $db->raw(
            "SELECT COUNT(*) AS total, SUM(is_reconciled = 1) AS reconciled
             FROM trades WHERE user_id = :uid AND deleted_at IS NULL AND {$tradeValidationSql}",
            [':uid' => $userId]
        );
        $total = (int)($row[0]['total'] ?? 0);
        $reconciled = (int)($row[0]['reconciled'] ?? 0);
        return ['total_trades' => $total, 'reconciled_trades' => $reconciled, 'ready' => $reconciled > 0];
    }

    /**
     * Only called when mt5Readiness()['ready'] is true — real holding-
     * duration analytics from broker_open_time/broker_close_time. Note:
     * "behavior before a breach/pass" (per the item asking for this to be
     * prepared) is NOT an MT5-dependent metric at all — that's exactly what
     * the failure/success lens's Findings and Patterns above already
     * describe (the real trades leading into whichever outcome is
     * selected), so it isn't duplicated here. This function is only for
     * the genuinely broker-only signals: precise durations, overnight and
     * weekend exposure, and trading-style buckets. Weekend Holds added
     * 2026-07-31 as further prep, computed the same way as Overnight Holds
     * — a real, cheap signal from the same two timestamps, ready the
     * moment reconciled data exists.
     */
    public static function mt5DurationAnalytics(array $segments): ?array
    {
        $durations = []; $winnerDurations = []; $loserDurations = []; $overnight = 0; $weekend = 0;
        $styleCounts = ['scalper' => 0, 'day_trader' => 0, 'swing' => 0, 'position' => 0];
        foreach ($segments as $s) {
            foreach ($s['rows'] as $r) {
                $t = $r['trade'];
                if (empty($t['is_reconciled']) || empty($t['broker_open_time']) || empty($t['broker_close_time'])) continue;
                $openTs = strtotime($t['broker_open_time']); $closeTs = strtotime($t['broker_close_time']);
                if ($openTs === false || $closeTs === false || $closeTs < $openTs) continue;
                $minutes = ($closeTs - $openTs) / 60;
                $durations[] = $minutes;
                if ($r['pnl'] > 0.01) $winnerDurations[] = $minutes; elseif ($r['pnl'] < -0.01) $loserDurations[] = $minutes;
                if (date('Y-m-d', $openTs) !== date('Y-m-d', $closeTs)) $overnight++;
                $spansWeekend = false;
                for ($d = strtotime(date('Y-m-d', $openTs)); $d <= strtotime(date('Y-m-d', $closeTs)); $d += 86400) {
                    if ((int)date('N', $d) >= 6) { $spansWeekend = true; break; }
                }
                if ($spansWeekend) $weekend++;
                $hours = $minutes / 60;
                if ($hours <= 2) $styleCounts['scalper']++; elseif ($hours <= 24) $styleCounts['day_trader']++; elseif ($hours <= 120) $styleCounts['swing']++; else $styleCounts['position']++;
            }
        }
        if (empty($durations)) return null;
        $avg = fn($a) => !empty($a) ? array_sum($a) / count($a) : null;
        return [
            'n' => count($durations), 'avg_minutes' => $avg($durations),
            'avg_minutes_winners' => $avg($winnerDurations), 'avg_minutes_losers' => $avg($loserDurations),
            'overnight_count' => $overnight, 'overnight_pct' => round($overnight / count($durations) * 100, 1),
            'weekend_count' => $weekend, 'weekend_pct' => round($weekend / count($durations) * 100, 1),
            'style_counts' => $styleCounts,
        ];
    }

    // ════════════════════════════════════════════════════════════════
    // Lessons From Success — a dedicated LEARNING view (2026-08-04),
    // deliberately separate from build()/getFourStagePeriodSummary-style
    // report content. Every number here answers "what can be learned from
    // the accounts that succeeded", never "how did this reporting period
    // compare to another one" — there is no date-range/period concept
    // ────────────────────────────────────────────────────────────────
    // REBUILT 2026-08-05 into a single-ACCOUNT archived journey, per
    // explicit product decision: this page previously blended every
    // eligible account into one portfolio-style summary (avg days to
    // pass across N accounts, pooled win rate across N accounts, etc.) —
    // the same "aggregate across many accounts" shape Reports and Active
    // Journeys' own "Trade Journal" section already use elsewhere in this
    // app. The new architecture is deliberately NOT that: a
    // journal.php-style account SELECTOR (below the program filters,
    // same shared src/AccountPicker.php component Active Journeys
    // already uses) picks exactly one successful account, and every
    // number/chart/table on the page from that point on is scoped to
    // that one account's own real segment history — no blended
    // portfolio averages anywhere. Selecting a different account is a
    // real full-page navigation (a plain <a href>, exactly like Active
    // Journeys' own account picker), never an in-page re-aggregation.
    //
    // Three real entry points now exist instead of one:
    //   gatherSuccessAccounts()      — one DB round-trip, groups every
    //                                   eligible account's own success
    //                                   segments by account_id.
    //   buildSuccessAccountSelector() — turns that grouping into the
    //                                   picker's row list (id/label/
    //                                   current real stage), sorted by
    //                                   this account's own most recent
    //                                   real activity.
    //   buildAccountSuccessJourney() — the one selected account's own
    //                                   real journey: KPIs, per-LEVEL
    //                                   pass/duration breakdown, habits,
    //                                   funded goal, instrument
    //                                   performance, winning edge, and
    //                                   the evidence trade table — pure
    //                                   computation over that one
    //                                   account's own already-fetched
    //                                   segments, no further queries
    //                                   except the real account_goals
    //                                   lookup fundedGoalAchievement()
    //                                   already made (reused unchanged,
    //                                   just scoped to a 1-account map).
    //
    // Every sub-computation below that's already generic over an
    // arbitrary list of segments/rows (strongestSuccessHabits(),
    // instrumentPerformance(), bestStrategy(), fundedGoalAchievement(),
    // aggregateGroup(), flattenRows()) is REUSED unchanged, simply given
    // a 1-account segment list instead of a population-wide one — their
    // own MIN_GROUP_TRADES/MIN_GROUP_SEGMENTS gates already produce an
    // honest "not enough history yet" result on a thin single-account
    // sample, exactly as they did for a thin population before.
    // ════════════════════════════════════════════════════════════════
    // (historical doc, from the pre-2026-08-05 population-wide version —
    // kept because it still describes a real, deliberate architectural
    // choice this rebuild also preserves: which accounts even qualify
    // as "successful" in the first place.)
    //
    // Scope is narrower than build()'s own success lens: an account only
    // anywhere in this method or its helpers, by design, per the explicit
    // brief for this page.
    //
    // Scope is narrower than build()'s own success lens: an account only
    // contributes here if it is CURRENTLY active/continuing (not
    // 'breached', not 'closed') — even one of its OWN earlier passed
    // segments is excluded if the account has since breached elsewhere.
    // This is a real, deliberate divergence from this engine's general
    // "an account can teach lessons in both lenses" architecture (see
    // build()'s own doc comment) — this specific learning page's brief is
    // explicit that "no breached accounts appear here" and every
    // contributing account must read as still active today, not just
    // that ONE segment of it once passed.
    // ════════════════════════════════════════════════════════════════

    /** Ranks a phase label so "highest phase reached" can be computed with a simple max(). */
    private static function phaseRank(string $label): int
    {
        return match ($label) {
            'Funded Account' => 4, 'Phase 3' => 3, 'Phase 2' => 2, 'Phase 1' => 1, 'Live Account' => 0, default => -1,
        };
    }

    /** Decodes a JSON array column (setup_criteria/entry_triggers/instruments_traded) into a plain string list — empty array, never an error, for null/invalid input. */
    private static function decodeJsonList($raw): array
    {
        if (empty($raw)) return [];
        $decoded = json_decode((string)$raw, true);
        return is_array($decoded) ? array_values(array_filter(array_map('strval', $decoded), fn($v) => $v !== '')) : [];
    }

    /**
     * One DB round-trip — gathers the full account_type population,
     * scopes it to the requested prop program (live has none), filters
     * to success-eligible segments (unchanged business rule from the
     * pre-2026-08-05 version — see the historical doc block above), and
     * groups the result by account_id. Both buildSuccessAccountSelector()
     * (the picker's row list) and buildAccountSuccessJourney() (one
     * selected account's full detail) are pure, DB-free functions over
     * this one map — selecting a different account in the UI never
     * re-queries anything, it just re-derives from the already-fetched
     * segments for that account_id.
     */
    public static function gatherSuccessAccounts(string $userId, $db, string $type, string $program): array
    {
        $population = $type === 'prop' ? self::gatherPropSegments($userId, $db) : self::gatherLiveSegments($userId, $db);

        $scoped = $population;
        if ($type === 'prop' && $program !== 'all') {
            $scoped = array_values(array_filter($scoped, fn($s) => $s['challenge_type'] === $program));
        }

        $successSegments = array_values(array_filter($scoped, function ($s) use ($type) {
            if ($type === 'prop') {
                if (in_array($s['account_lifecycle_state'], ['breached', 'closed'], true)) return false;
                return in_array($s['status'], ['passed', 'funded_active'], true);
            }
            return in_array($s['status'], ['closed_profit', 'active_profit'], true);
        }));

        $byAccount = [];
        foreach ($successSegments as $s) { $byAccount[$s['account_id']][] = $s; }

        // Real safeguard (2026-08-07) — a real, confirmed contradiction
        // was found live: an account can cross its own declared breach
        // floor in real, uncapped broker terms while lifecycle_state was
        // never flipped to 'breached' upstream (one real trade's
        // uncapped broker_pnl blew straight through the capped
        // challenge_pnl the account's own official ledger shows, and
        // trading continued afterward regardless — a genuine data-
        // integrity gap, not a hypothetical). lifecycle_state alone is
        // therefore not sufficient proof an account still belongs on a
        // "success" page. Checked here against the account's own most-
        // advanced (terminal) segment's own real capped P&L — the SAME
        // phase-reset-scoped model used everywhere else on this page
        // (never the whole-account trading_accounts.current_balance,
        // which does not reset at promotion and would misrepresent a
        // still-mid-evaluation account's real standing — see
        // rnBuildPhaseCardFromRows()'s own doc comment, public/
        // journal.php, for the full investigation behind that decision).
        // An account that fails this check is excluded from Lessons
        // From Success entirely, independent of whether lifecycle_state
        // was ever corrected. Never touches the database; purely an
        // additional read-time filter on top of the existing lifecycle/
        // status checks above.
        if ($type === 'prop') {
            foreach ($byAccount as $accountId => $segs) {
                $terminal = end($segs);
                $maxDdPct = $terminal['max_dd_pct_configured'] ?? null;
                $accountSize = (float)($terminal['account_size'] ?? 0.0);
                if ($maxDdPct !== null && $maxDdPct > 0 && $accountSize > 0) {
                    // Maximum Loss Model (2026-08-17) — a Trailing
                    // account's real breach floor is anchored to this
                    // TERMINAL segment's own real peak balance (the
                    // phase's own high-water mark — matches
                    // PropFirmRuleEngine::compute()'s exact peakBalance
                    // definition, phase-scoped, never the whole account
                    // across every phase), not the flat account_size —
                    // `$accountSize * (1 - $maxDdPct/100)` (the old
                    // formula) is only correct for Fixed. $terminal['rows']
                    // is already this segment's own buildRows() balance
                    // walk, so the peak is simply its highest point.
                    $maxLossModel = strtolower(trim((string)($terminal['rules']['max_loss_model'] ?? 'fixed')));
                    $ddReferenceBalance = $accountSize;
                    if ($maxLossModel === 'trailing' && !empty($terminal['rows'])) {
                        $ddReferenceBalance = max($accountSize, max(array_column($terminal['rows'], 'balance')));
                    }
                    $breachFloor = $ddReferenceBalance * (1 - $maxDdPct / 100);
                    $terminalPnl = array_sum(array_column($terminal['rows'] ?? [], 'pnl'));
                    $terminalBalance = $accountSize + $terminalPnl;
                    if ($terminalBalance < $breachFloor) {
                        unset($byAccount[$accountId]);
                    }
                }
            }
        }

        return [
            'by_account' => $byAccount,
            'is_instant_funding' => $type === 'prop' && $program === 'instant_funding',
        ];
    }

    /**
     * The account picker's row list — one row per eligible account, each
     * carrying that account's own REAL current stage (never a generic
     * "Passed" — see accountStageInfo()), sorted by the account's own
     * most recent real activity (its latest contributing segment's
     * end_date), newest first — the same "recently active first"
     * convention Active Journeys' own account picker already uses.
     */
    public static function buildSuccessAccountSelector(array $byAccount, string $type, string $program): array
    {
        $out = [];
        foreach ($byAccount as $accountId => $segs) {
            $stage = self::accountStageInfo($segs, $type, $program);
            $ends = array_filter(array_column($segs, 'end_date'));
            $lastActivity = !empty($ends) ? max($ends) : null;
            $best = $segs[0];
            $out[] = [
                'id' => (string)$accountId,
                'raw_label' => $best['account_label'],
                // The account's real current program stage, always
                // included in the label per the brief's own worked
                // examples ("FTMO 100K – Attempt 3 (Phase 2)") — never a
                // blanket "(Passed)" regardless of which stage the
                // account's program actually uses.
                'label' => $best['account_label'] . ' (' . $stage['short'] . ')',
                'lifecycle_state' => $best['account_lifecycle_state'],
                'challenge_type' => $best['challenge_type'],
                'stage_short' => $stage['short'],
                // §2 Live #3 fix (2026-08-10) — accountStageInfo() already
                // computes whether this account has genuinely RESOLVED its
                // current stage (in_progress=false, e.g. "Successfully
                // passed Phase 1 — awaiting promotion") vs. is still
                // actively attempting it unresolved (in_progress=true, e.g.
                // "Currently attempting Phase 2") — that distinction was
                // being computed and then dropped before it ever reached
                // this row, so taSuccessJourneySubtitle() had no way to
                // avoid claiming "success" at a stage the account hasn't
                // actually finished yet.
                'in_progress' => $stage['in_progress'],
                'last_activity' => $lastActivity,
            ];
        }
        usort($out, fn($a, $b) => strtotime($b['last_activity'] ?? '1970-01-01') <=> strtotime($a['last_activity'] ?? '1970-01-01'));
        return $out;
    }

    /** The real level sequence for a challenge type — Instant Funding returns none (business rule: no phase concept at all). */
    private static function levelSequence(string $challengeType): array
    {
        return match ($challengeType) {
            'one_step' => ['Phase 1'],
            'two_step' => ['Phase 1', 'Phase 2'],
            'three_step' => ['Phase 1', 'Phase 2', 'Phase 3'],
            default => [],
        };
    }

    /**
     * The account's own real current phase RANK, read directly from its
     * real trading_accounts.lifecycle_state — per this codebase's own
     * documented Lifecycle Rule ("Read lifecycle_state directly from
     * DB — never recompute from math"), this is the ONE authoritative
     * source both accountStageInfo() (Journey Status / account picker
     * label) and accountLevels() (Pass Rate by Level / Time to Pass by
     * Level) now derive from, so the two can never disagree with each
     * other again.
     *
     * This directly replaces trusting a phase segment's own 'passed'
     * status as truth for "how far has this account really gotten" —
     * confirmed live (real, found bug, not a hypothetical): a segment is
     * only ever forced 'passed' by gatherPropSegments() because it isn't
     * the chronologically-last real trade-tagged bucket, which is a
     * statement about TRADE ORDERING, not about whether the account's
     * own official record ever actually promoted that far. A real test
     * account ("aaas 5K - Attempt 1") has trades tagged phase_1 → phase_2
     * → phase_3 → funded within minutes of each other, yet its own real
     * lifecycle_state is still 'phase_2_active' — meaning Phase 3 and
     * Funded were never officially reached at all, but the old
     * segment-trusting logic showed "Phase 3: Passed" anyway (and, since
     * accountStageInfo() used the same flawed derivation, the account
     * picker's own label agreed with that wrong reading — the two were
     * "consistent" with each other and still both wrong). 'resolved'
     * distinguishes "finished this phase, awaiting promotion" (a
     * `*_complete` state) from "still actively attempting it right now"
     * (a `*_active` state) — the former is real progress the trader can
     * bank on, the latter is a live, unresolved status.
     */
    private static function currentPhaseRank(string $lifecycleState): array
    {
        return match (true) {
            $lifecycleState === 'funded_active' => ['rank' => 4, 'resolved' => true],
            str_starts_with($lifecycleState, 'phase_3') => ['rank' => 3, 'resolved' => str_ends_with($lifecycleState, '_complete')],
            str_starts_with($lifecycleState, 'phase_2') => ['rank' => 2, 'resolved' => str_ends_with($lifecycleState, '_complete')],
            str_starts_with($lifecycleState, 'phase_1') => ['rank' => 1, 'resolved' => str_ends_with($lifecycleState, '_complete')],
            default => ['rank' => 0, 'resolved' => false],
        };
    }

    /**
     * This one account's real current program stage — the single source
     * of truth for the account picker's parenthetical, the Journey
     * Status KPI card, and every "…to {stage} success/completion" bit of
     * copy on this page. Derived from currentPhaseRank() — see that
     * method's own doc comment for why this is no longer derived from
     * the segment list directly.
     */
    private static function accountStageInfo(array $segments, string $type, string $program): array
    {
        if ($type === 'live') {
            $s = $segments[0] ?? null;
            $isStillActive = $s !== null && !empty($s['is_still_active']);
            if ($isStillActive) {
                return [
                    'short' => 'Active', 'phrase' => 'Live and funded',
                    'badge' => 'green', 'in_progress' => true,
                ];
            }
            return [
                'short' => 'Closed (Profit)',
                'phrase' => 'Closed with a real, realized profit',
                'badge' => 'green', 'in_progress' => false,
            ];
        }
        if ($program === 'instant_funding') {
            return ['short' => 'Instant Funding', 'phrase' => 'Instantly funded and trading from day one', 'badge' => 'gold', 'in_progress' => false];
        }

        $lifecycleState = $segments[0]['account_lifecycle_state'] ?? '';
        $current = self::currentPhaseRank($lifecycleState);

        if ($current['rank'] === 4) {
            return ['short' => 'Funded', 'phrase' => 'Currently funded and trading', 'badge' => 'gold', 'in_progress' => false];
        }
        if ($current['rank'] >= 1) {
            $label = 'Phase ' . $current['rank'];
            if ($current['resolved']) {
                return ['short' => $label, 'phrase' => 'Successfully passed ' . $label . ', awaiting promotion', 'badge' => 'purple', 'in_progress' => false];
            }
            // Genuinely still in progress — this account has NOT passed
            // this phase yet, so its own real "highest reached" label
            // must never read as if it had (see currentPhaseRank()'s doc
            // comment for the real bug this distinction fixes).
            return ['short' => $label, 'phrase' => 'Currently attempting ' . $label, 'badge' => 'blue', 'in_progress' => true];
        }
        // Defensive only — gatherSuccessAccounts()'s own eligibility
        // filter guarantees at least one passed/funded segment exists
        // for every account reaching this method, so this branch should
        // be structurally unreachable.
        return ['short' => 'In Progress', 'phrase' => "This account's journey is still in progress", 'badge' => 'blue', 'in_progress' => true];
    }

    /**
     * Per-LEVEL pass/duration breakdown for ONE account — "Pass Rate by
     * Level" / "Time to Pass by Level" below. Deliberately NOT phaseBreakdown()
     * (used elsewhere in this file for the population-wide Lessons From
     * Failure difficulty board), which needs MIN_GROUP_SEGMENTS real
     * accounts per phase to report a rate at all — meaningless for a
     * single account, which only ever has AT MOST one real segment per
     * level. A level with no resolved segment
     * yet (the account hasn't reached it) is real, honest information —
     * "Coming Next" — never a fabricated pass rate.
     *
     * `label` is always the account's real phase name ("Phase 1"/"Phase
     * 2"/"Phase 3"/"Funded") — a prior version of this card additionally
     * numbered these "Level 1"/"Level 2" in the donut legend only (while
     * the duration bars below it kept saying "Phase 1"/"Phase 2"),
     * introducing two different names for the exact same real thing on
     * one page. Corrected per direct feedback: "Level" and "Phase" never
     * meant two different concepts here, so this card only ever uses one
     * name for each stage now.
     *
     * Real THREE-way status, per direct feedback that "passed" vs. "not
     * reached yet" was missing the middle case — the level the account is
     * CURRENTLY, actively working on right now: 'passed', 'active', or
     * 'not_reached'.
     *
     * REBUILT (real, confirmed bug fix, not a wording tweak): this used
     * to trust a phase segment's own 'passed' status directly — but that
     * status is only ever set true by gatherPropSegments() because the
     * segment isn't the chronologically-last real trade-tagged bucket, a
     * statement about trade ORDER, not about whether the account's own
     * official lifecycle_state ever actually promoted that far. Now
     * derives "how far has this account really gotten" from
     * currentPhaseRank($lifecycleState) instead — the SAME single
     * authority accountStageInfo() uses — so a phase ranked beyond the
     * account's own real current phase can never show "Passed" again,
     * regardless of what a leaked/premature trade tag's forced segment
     * status claims. See currentPhaseRank()'s own doc comment for the
     * real "aaas 5K - Attempt 1" case this fix was built against.
     *
     * SECOND real bug, found via direct feedback on this same card,
     * confirmed against this app's own documented Lifecycle Rule before
     * fixing: passing a phase in this app is NEVER auto-promoted —
     * `resolve_trade_id`'s pass handler only ever sets the CURRENT
     * stage's own `_complete` variant; moving to the next phase (or to
     * Funded) is a deliberate, separate trader action via Trading
     * Center's "Ready to Promote" flow (see CLAUDE.md's Dashboard
     * section). So a `phase_2_complete` account genuinely, correctly
     * sits in a real "finished Phase 2, hasn't clicked Promote yet"
     * limbo — plain "Passed" on that specific row, with no further
     * explanation, reads as a bug ("if it passed, why isn't it
     * Funded?") even though the computation was already accurate. Two
     * new statuses make this unmistakable: 'passed_awaiting_promotion'
     * for the account's own highest resolved phase (a real, temporary,
     * one-click-away-from-advancing state — distinct from an EARLIER
     * phase's plain, unambiguous 'passed'), and 'ready_to_promote' for
     * whichever stage sits immediately after it (the next phase, or
     * Funded if the account just cleared its last phase) — genuinely
     * "the very next click," not merely "coming up eventually."
     */
    private static function accountLevels(array $segments, string $challengeType, string $lifecycleState): array
    {
        $sequence = self::levelSequence($challengeType);
        $byLabel = [];
        foreach ($segments as $s) { $byLabel[$s['phase_label']] = $s; }
        $current = self::currentPhaseRank($lifecycleState);
        // Only meaningful when the account's current phase is fully
        // resolved (not merely active) — the rank of whichever stage a
        // real, one-click promotion would move it to right now.
        $readyToPromoteRank = $current['resolved'] ? $current['rank'] + 1 : null;

        $levels = [];
        foreach ($sequence as $i => $label) {
            $rank = $i + 1;
            $s = $byLabel[$label] ?? null;
            $days = ($s !== null && !empty($s['start_date']) && !empty($s['end_date'])) ? max(0, (strtotime($s['end_date']) - strtotime($s['start_date'])) / 86400) : null;
            $trades = $s['metrics']['trade_count'] ?? 0;

            if ($rank < $current['rank']) {
                $levels[] = ['label' => $label, 'status' => 'passed', 'days' => $days, 'trades' => $trades];
            } elseif ($rank === $current['rank']) {
                $levels[] = $current['resolved']
                    ? ['label' => $label, 'status' => 'passed_awaiting_promotion', 'days' => $days, 'trades' => $trades]
                    : ['label' => $label, 'status' => 'active', 'days' => null, 'trades' => 0];
            } elseif ($readyToPromoteRank !== null && $rank === $readyToPromoteRank) {
                $levels[] = ['label' => $label, 'status' => 'ready_to_promote', 'days' => null, 'trades' => 0];
            } else {
                $levels[] = ['label' => $label, 'status' => 'not_reached', 'days' => null, 'trades' => 0];
            }
        }
        if ($current['rank'] === 4) {
            // Funded is real and reached the moment lifecycle_state says
            // so — whether or not it has any trades tagged to it yet (a
            // freshly-promoted, zero-trade Funded stage is still real
            // progress, not "not reached").
            $s = $byLabel['Funded Account'] ?? null;
            $days = ($s !== null && !empty($s['start_date']) && !empty($s['end_date'])) ? max(0, (strtotime($s['end_date']) - strtotime($s['start_date'])) / 86400) : null;
            $levels[] = ['label' => 'Funded', 'status' => 'funded', 'days' => $days, 'trades' => $s['metrics']['trade_count'] ?? 0];
        } elseif ($readyToPromoteRank !== null && $readyToPromoteRank > count($sequence)) {
            // The account just cleared its LAST real phase — Funded
            // itself is the one-click-away target now, not merely
            // "coming up eventually."
            $levels[] = ['label' => 'Funded', 'status' => 'ready_to_promote', 'days' => null, 'trades' => 0];
        } else {
            $levels[] = ['label' => 'Funded', 'status' => 'not_reached', 'days' => null, 'trades' => 0];
        }
        return $levels;
    }

    /**
     * The "no phase concept" replacement for Section Two's first two
     * cards — used by Instant Funding (no phases at all, ever) and Live
     * Accounts (a live account has no evaluation/phase concept either).
     * Real, simple, honestly-computed facts about this one account's own
     * trading activity — never a fabricated "success rate"/"trades
     * needed" stat that only made sense pooled across many accounts.
     */
    private static function accountActivityOverview(array $segments): array
    {
        $s = $segments[0] ?? null;
        if ($s === null) return ['available' => false];
        $trades = 0;
        foreach ($segments as $seg) { $trades += $seg['metrics']['trade_count']; }
        $starts = array_filter(array_column($segments, 'start_date'));
        $ends = array_filter(array_column($segments, 'end_date'));
        $start = !empty($starts) ? min($starts) : null;
        $end = !empty($ends) ? max($ends) : null;
        $days = ($start !== null && $end !== null) ? max(0.0001, (strtotime($end) - strtotime($start)) / 86400) : null;
        $perWeek = ($days !== null && $trades > 0) ? round($trades / max(1, $days / 7), 1) : null;
        return ['available' => true, 'start_date' => $start, 'end_date' => $end, 'trades' => $trades, 'trades_per_week' => $perWeek, 'days' => $days];
    }

    /**
     * "Your Winning Edge" for ONE account — deliberately a different,
     * narrower field set than the old population-wide winningEdge():
     * best winning streak, best instrument, best strategy, best RR (the
     * RR actually planned on this account's single most-profitable real
     * trade, by realized R-multiple — not an average), market focus,
     * and the account's real typical (median) risk per trade. "Core
     * Setup" and "Confirmation Tools" are deliberately excluded per the
     * approved brief — those belong to Strategy Builder detail, not this
     * archive card.
     */
    private static function accountWinningEdge(array $segments, array $rows): array
    {
        $n = count($rows);
        if ($n < self::MIN_GROUP_TRADES) return ['available' => false];

        $bestStreak = 0;
        foreach ($segments as $s) { $bestStreak = max($bestStreak, $s['metrics']['max_win_streak']); }

        $bestStrategy = self::bestStrategy($rows);
        $instr = self::instrumentPerformance($rows);

        $bestRR = null; $bestR = null;
        foreach ($rows as $r) {
            $maxLoss = $r['trade']['max_potential_loss'] ?? null;
            if ($maxLoss === null || (float)$maxLoss <= 0) continue;
            $rm = $r['pnl'] / (float)$maxLoss;
            if ($bestR === null || $rm > $bestR) { $bestR = $rm; $bestRR = $r['trade']['risk_reward_tp1'] ?? null; }
        }

        // A min–max risk% RANGE (the first version of this card) reads as
        // "the account risked anywhere from X% to Y%" — misleading when a
        // single outlier trade (e.g. one manual override) stretches the
        // range to something like "1.01% – 50.00%". A single typical
        // value is what a trader actually wants here, and the MEDIAN
        // (not the mean) is what the rest of this file already uses for
        // exactly this kind of value-judgment "typical risk" claim — see
        // segmentMetrics()'s own median_actual_risk_pct doc comment for
        // why outlier-robustness matters for this specific number.
        $riskPcts = [];
        foreach ($rows as $r) {
            $rp = $r['trade']['risk_pct'] ?? null;
            if ($rp !== null && $rp !== '' && (float)$rp > 0) $riskPcts[] = (float)$rp;
        }
        $medianRiskPct = null;
        if (!empty($riskPcts)) {
            sort($riskPcts);
            $c = count($riskPcts);
            $mid = (int) floor($c / 2);
            $medianRiskPct = $c % 2 ? $riskPcts[$mid] : ($riskPcts[$mid - 1] + $riskPcts[$mid]) / 2;
        }

        return [
            'available' => true,
            'best_winning_streak' => $bestStreak,
            'best_instrument' => $instr['available'] ? $instr['best'] : null,
            'best_strategy' => $bestStrategy,
            'best_rr' => ($bestRR !== null && $bestRR !== '') ? (float)$bestRR : null,
            'market_focus' => $bestStrategy['declared_instruments'] ?? [],
            'median_risk_pct' => $medianRiskPct,
        ];
    }

    /**
     * "Trades That Built This Success" — this ONE account's own real
     * closed trades, ranked highest-impact-first by realized R-multiple
     * (pnl ÷ that trade's own planned max loss), never by raw dollar P&L
     * (which would just favor a bigger account/position size, not a
     * better-executed trade). `stars` is a real 1-5 rank WITHIN this
     * account's own trade history (best-ranked trades near 5, weakest
     * near 1) — not an absolute, invented threshold.
     */
    /**
     * Real, DIRECT DB signal for "did this trade over-risk or lose
     * consistency" — src/TradeClassificationEngine.php's confirmed real
     * logic (sp_classify_trade's replacement as of 2026-08-18; see that
     * file's own header comment): elevated_win/loss_high_risk fire when
     * this trade's own real risk % exceeded Active Coaching's tier-
     * recommended risk % at the moment it closed (a real, DB-computed
     * over-risk signal, not inferred here); revenge_trade additionally
     * requires the immediately preceding trade on the same phase/
     * workflow to have been a loss; tilt_warning now means the opposite
     * of over-risk — a loss sized well BELOW the tier recommendation,
     * also following a loss ("fear of trading"). disciplined_win/
     * controlled_loss/breakeven carry no such flag. Used to CAP (never boost) the
     * R-multiple-derived star score — a trade that made money by
     * over-risking or trading on tilt shouldn't out-rank a smaller,
     * disciplined win just because the dollar outcome happened to be
     * bigger. Sourced from src/TradeGrading.php::UNDISCIPLINED_FOR_SEVERITY_CAP
     * (2026-08-08) — this class's own real 4-value set (includes
     * elevated_win, unlike the 3-value UNDISCIPLINED_FOR_GRADING used
     * elsewhere for letter-grading), now named and shared explicitly
     * rather than looking like an unlabeled accidental variant of the
     * other set (docs/architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md §9,
     * Wave 4d).
     */

    /**
     * Real 1-5 star rating for a single trade's realized R-multiple —
     * fixed, absolute thresholds, capped by real trading-discipline
     * evidence. Deliberately NOT the account's own rank/percentile
     * position within this evidence list (a real, confirmed bug found
     * live: two trades sharing the exact same +2.98R outcome rendered
     * 5 stars and 1 star respectively, purely because of which array
     * index a tie happened to land on after sorting — an artifact of
     * RANK, not a real difference in trade quality). Fixed values mean
     * two trades with the same real R-multiple and the same real
     * discipline classification ALWAYS get the same star count,
     * regardless of how many other trades exist in the list or how
     * they happen to sort.
     */
    /**
     * Real bug found and fixed (2026-08-06, fourth refinement pass) —
     * every real/planned "R-multiple" computation in this file required
     * trades.max_potential_loss specifically as its denominator, and
     * silently produced null (rendering as a bare, unexplained "—" on
     * every evidence table) whenever that ONE column happened to be
     * empty — even when the exact same real planned-risk dollar figure
     * was available under a sibling column, trades.risk_per_position.
     * Confirmed live on a real account (three real closed trades with
     * max_potential_loss = NULL but risk_per_position populated with the
     * real dollar amount the calculator computed at save time — verified
     * the two columns represent the identical "planned dollar risk for
     * this position" concept: broker_pnl ÷ risk_per_position on those
     * exact three trades gives a clean -1.00R each, matching all three
     * trades' own confirmed exit price sitting exactly on their stop
     * loss). This was a genuine data-completeness gap in one column, not
     * a broken join, a missing outcome, or a break-even trade (a real
     * break-even already renders "+0.00R", never a dash — the dash only
     * ever meant "no denominator to divide by"). Fixed once, here, so
     * every real R-multiple computation in this file (the two live
     * evidence tables, and the pooled expectancy-in-R statistic below)
     * checks both real sources instead of just one.
     */
    private static function tradeRMultiple(array $trade, float $pnl): ?float
    {
        $denom = null;
        if (isset($trade['max_potential_loss']) && $trade['max_potential_loss'] !== null && $trade['max_potential_loss'] !== '' && (float)$trade['max_potential_loss'] > 0) {
            $denom = (float)$trade['max_potential_loss'];
        } elseif (isset($trade['risk_per_position']) && $trade['risk_per_position'] !== null && $trade['risk_per_position'] !== '' && (float)$trade['risk_per_position'] > 0) {
            $denom = (float)$trade['risk_per_position'];
        }
        return $denom !== null ? $pnl / $denom : null;
    }

    private static function successTradeImpactStars(?float $rMultiple, ?string $classification): ?int
    {
        if ($rMultiple === null) return null;
        $base = match (true) {
            $rMultiple >= 3.0 => 5,
            $rMultiple >= 2.0 => 4,
            $rMultiple >= 1.0 => 3,
            $rMultiple >= 0.0 => 2,
            default => 1,
        };
        if ($classification !== null && in_array($classification, TradeGrading::UNDISCIPLINED_FOR_SEVERITY_CAP, true)) {
            $base = min($base, 2);
        }
        return $base;
    }

    private static function successEvidenceTrades(array $rows): array
    {
        $out = [];
        foreach ($rows as $r) {
            $t = $r['trade'];
            $rMultiple = self::tradeRMultiple($t, $r['pnl']);
            $classification = $t['trade_classification'] ?? null;
            $out[] = [
                'id' => $t['id'] ?? null,
                'date' => $t['closed_at'] ?? $t['captured_at'] ?? null,
                'instrument' => strtoupper((string)($t['instrument'] ?? '')),
                'session' => $t['session'] ?? null,
                'direction' => $t['direction'] ?? null,
                'planned_rr' => ($t['risk_reward_tp1'] ?? null) !== null && $t['risk_reward_tp1'] !== '' ? (float)$t['risk_reward_tp1'] : null,
                'pnl' => $r['pnl'],
                'r_multiple' => $rMultiple,
                'stars' => self::successTradeImpactStars($rMultiple, $classification),
            ];
        }
        // Highest real realized outcome first — display ordering only,
        // completely independent of the (now value-based, not
        // rank-based) star computation above.
        usort($out, fn($a, $b) => ($b['r_multiple'] ?? -INF) <=> ($a['r_multiple'] ?? -INF));
        return $out;
    }

    /**
     * "What Created The Pass" (2026-08-07, correction pass) — REPLACES an
     * earlier draft of this card that reused Lessons From Failure's own
     * "target vs. limit, exceeded/used" framing almost verbatim, just
     * recolored green. Direct, explicit feedback: that's the wrong
     * philosophy entirely, not a styling problem — a success story must
     * answer "what did this account do RIGHT that it should keep doing,"
     * never "how close did it come to breaking a rule." The test that
     * governs every sentence/stat in this function: if the copy would
     * still read sensibly with the page title swapped to "Lessons From
     * Failure," it doesn't belong here. Concretely: no dollar-limit
     * comparisons, no "exceeded"/"breach"/"violation" language, no
     * drawdown-as-danger framing — only real, computed evidence of
     * repeatable strengths (growth achieved, consistency, checklist
     * follow-through, resilience through a real losing stretch).
     * Contrast with failureCoaching(): that function explains what a
     * breached account was told and didn't follow; this one explains
     * what a passed account actually did and should keep doing — the two
     * are deliberately not interchangeable.
     */
    private static function successStory(array $rows, array $group, array $consistency, float $accountSize, string $currentLabel, array $terminalRows, ?array $canonicalCurrent = null): array
    {
        $n = count($rows);
        if ($n === 0) return ['available' => false];
        $pooled = $group['pooled'];
        $sentences = [];

        // REVERTED (2026-08-07) — a same-day attempt to switch this to
        // trading_accounts.current_balance (a real, single, never-
        // resetting DB field) was itself based on a wrong premise, found
        // and corrected the same day: real prop-firm evaluations DO
        // reset per phase promotion — verified live and directly, by
        // creating a brand-new account, passing Phase 1, and confirming
        // challenge_accounts.current_balance correctly snapped back to
        // account_size at the exact promotion timestamp, while
        // trading_accounts.current_balance did not (and, when a page
        // was changed to read that field, a freshly-promoted Phase 2
        // account wrongly showed its Phase 1 ending balance instead of a
        // fresh start — a real, reported regression). So the correct
        // model is the one this function used before that same-day
        // detour: scoped to the account's own CURRENT segment's real
        // rows only ($terminalRows — the last segment, matching
        // rnBuildPhaseCardFromRows()'s now-confirmed-correct model in
        // public/journal.php, which this function must stay consistent
        // with).
        // $canonicalCurrent (when present — see buildAccountSuccessJourney()'s
        // own phase_label-matched safety check) is PropFirmRuleEngine's
        // real, canonical current-stage balance; falls back to this
        // function's own long-standing local computation ($accountSize +
        // this terminal segment's own trades' P&L — see this function's own
        // doc comment above for why that fallback is correct, not just
        // tolerated, whenever the canonical figure isn't available or
        // doesn't apply to this specific terminal segment).
        $terminalN = count($terminalRows);
        // §2 Live #1 fix (2026-08-10) — $canonicalCurrent !== null is
        // already this function's own established, hard-won signal for
        // "the terminal segment being narrated genuinely IS this
        // account's real live current stage right now" (see the doc
        // comment above — it exists specifically to keep the BALANCE
        // number honest for a superseded/historical terminal segment).
        // The narrative TEXT never read that same signal — it always
        // said "In its current {stage} stage..." regardless, so a
        // superseded stage (the account has since progressed further,
        // e.g. passed Phase 1 and is now unresolved in Phase 2) was
        // narrated as if it were still happening right now, directly
        // contradicting the real current-stage KPI card rendered
        // elsewhere on the same page. Fixed by deriving the sentence's
        // own tense from the exact same boolean already used for the
        // balance number, instead of hardcoding "current" unconditionally.
        $isGenuinelyCurrentStage = $canonicalCurrent !== null;
        $stageAccountSize = $canonicalCurrent['starting_balance'] ?? $accountSize;
        $stageTerminalPnl = $canonicalCurrent !== null
            ? ($canonicalCurrent['balance'] - $stageAccountSize)
            : ($terminalN > 0 ? array_sum(array_column($terminalRows, 'pnl')) : null);
        if ($stageTerminalPnl !== null) {
            $terminalPnl = $stageTerminalPnl;
            $growthPct = $stageAccountSize > 0 ? $terminalPnl / $stageAccountSize * 100 : null;
            if ($growthPct !== null) {
                $stageLabel = $currentLabel === 'Funded Account' ? 'Funded' : $currentLabel;
                $stagePhrase = $isGenuinelyCurrentStage
                    ? 'current ' . $stageLabel
                    : $stageLabel . ' stage (since surpassed, with the account now progressing further)';
                if ($terminalPnl >= 0) {
                    $sentences[] = $isGenuinelyCurrentStage
                        ? ('In its ' . $stagePhrase . ' stage, this account grew from $' . number_format($stageAccountSize, 2)
                            . ' to $' . number_format($stageAccountSize + $terminalPnl, 2) . ' (+' . number_format($growthPct, 1) . '%).')
                        : ('In its ' . $stagePhrase . ', this account grew from $' . number_format($stageAccountSize, 2)
                            . ' to $' . number_format($stageAccountSize + $terminalPnl, 2) . ' (+' . number_format($growthPct, 1) . '%).');
                } else {
                    // A real, honest edge case: the account is genuinely
                    // funded/passed by its own live lifecycle state, but
                    // its current stage's own real running total happens
                    // to sit below where it started right now — never
                    // hidden or reworded into false positivity, just
                    // stated plainly.
                    $sentences[] = $isGenuinelyCurrentStage
                        ? ('In its ' . $stagePhrase . ' stage, this account currently sits at $' . number_format($stageAccountSize + $terminalPnl, 2)
                            . ' (' . number_format($growthPct, 1) . '% from its $' . number_format($stageAccountSize, 2) . ' starting balance).')
                        : ('In its ' . $stagePhrase . ', this account ended at $' . number_format($stageAccountSize + $terminalPnl, 2)
                            . ' (' . number_format($growthPct, 1) . '% from its $' . number_format($stageAccountSize, 2) . ' starting balance).');
                }
            }
        }

        // Every remaining sentence asserts a genuine POSITIVE behaviour
        // that produced the pass — never a losing period narrated as if
        // surviving it were the achievement (direct feedback, 2026-08-07
        // third correction pass: "A successful account should primarily
        // explain the positive behaviours that produced the pass, not
        // glorify its losing periods"). A weak/mediocre real number is
        // still left out of the narrative entirely rather than described
        // apologetically, matching this file's own established pattern
        // of never asserting a value-judgment claim on a number that
        // doesn't clear a real bar (see MIN_FAVORABLE_RR,
        // MAX_SANE_RISK_PCT_FOR_CLAIM elsewhere in this class). This
        // account's own real losing-streak/recovery facts still have an
        // honest home — the Execution Discipline card's "Toughest
        // Stretch Overcome" tile — they no longer double as a narrative
        // sentence here.
        if ($consistency['available'] && $consistency['score'] >= 55) {
            $tier = $consistency['score'] >= 75 ? 'highly consistent' : 'consistent';
            $sentences[] = 'Execution stayed ' . $tier . ' across the whole passing journey, with a real Consistency Score of '
                . number_format($consistency['score'], 0) . '/100, built from steady position sizing and a low rate of tilt or revenge trades.';
        }

        $checklistPct = $pooled['checklist_skip_rate'] !== null ? round(100 - $pooled['checklist_skip_rate'], 0) : null;
        if ($checklistPct !== null && $checklistPct >= 50 && $n >= 3) {
            $sentences[] = 'The pre-trade checklist was followed in full on ' . $checklistPct
                . '% of trades across the journey: the plan was being checked before capital was risked, not just after the fact.';
        }

        if (empty($sentences)) {
            $sentences[] = 'This account\'s own real trade history is the clearest record of what worked. See the sections below for the specific evidence.';
        }

        return [
            'available' => true,
            'narrative' => implode(' ', $sentences),
            'consistency_score' => $consistency['available'] ? $consistency['score'] : null,
            'checklist_pct' => $checklistPct,
        ];
    }

    /**
     * "Execution Discipline" (2026-08-07, correction pass) — REPLACES an
     * earlier draft of this card that reused Lessons From Failure's own
     * "Risk & Behavior Insights" tile SET almost unchanged (avg risk vs.
     * plan, highest risk taken, losing streak, daily-loss-limit
     * breaches, trades over plan risk, time in drawdown) — the exact
     * same "investigation into risk violations" framing successStory()'s
     * own doc comment above describes, just wearing a green coat of
     * paint. Every tile here is a genuinely different metric, chosen
     * specifically because it answers "what discipline produced this
     * pass" rather than "how many rules were nearly broken" — and
     * deliberately non-duplicative of Strongest Success Habits (a
     * curated, threshold-gated highlight LIST) and Your Winning Edge
     * (best streak/instrument/strategy/RR/typical risk): this card is
     * the always-visible raw numbers behind those curated call-outs,
     * computed once from the same pooled data, never a second formula.
     * No dollar-limit comparisons anywhere in this function.
     */
    private static function successExecutionDiscipline(array $rows, array $group, array $consistency): array
    {
        $n = count($rows);
        if ($n === 0) return ['available' => false];
        $pooled = $group['pooled'];

        $classes = $pooled['classification_counts'];
        $disciplinedCount = $classes['disciplined_win'] + $classes['controlled_loss'] + $classes['breakeven'];
        $disciplinedRate = round($disciplinedCount / $n * 100, 1);

        $checklistPct = $pooled['checklist_skip_rate'] !== null ? round(100 - $pooled['checklist_skip_rate'], 1) : null;

        // Share of trades sitting AT OR ABOVE this account's own running
        // equity high — the positive-framed inverse of "time in
        // drawdown" (the same running-P&L walk, read the other way).
        $running = 0.0; $peakRunning = 0.0; $atOrAbovePeak = 0;
        foreach ($rows as $r) {
            $running += $r['pnl'];
            if ($running >= $peakRunning) { $peakRunning = $running; $atOrAbovePeak++; }
        }
        $atPeakPct = round($atOrAbovePeak / $n * 100, 1);

        $recoveryText = ($group['n_recovery_samples'] ?? 0) > 0 && function_exists('naturalCount')
            ? ucfirst(naturalCount($group['avg_recovery_trades'], 'trade')) : '-';

        $maxLossStreak = $pooled['max_loss_streak'] ?? 0;

        $tiles = [
            ['label' => 'Disciplined Trade Rate', 'value' => $disciplinedRate . '%',
                'note' => $disciplinedCount . ' of ' . $n . ' trades executed with full discipline',
                'tone' => $disciplinedRate >= 70 ? 'positive' : 'neutral'],
            ['label' => 'Checklist Follow-Through', 'value' => $checklistPct !== null ? $checklistPct . '%' : '-',
                'note' => 'Pre-trade checklist fully completed before entry',
                'tone' => ($checklistPct !== null && $checklistPct >= 70) ? 'positive' : 'neutral'],
            ['label' => 'Consistency Score', 'value' => $consistency['available'] ? number_format($consistency['score'], 0) . '/100' : '-',
                'note' => $consistency['available'] ? 'Steady sizing and a low emotional-trade rate' : 'Needs more closed trades to score',
                'tone' => ($consistency['available'] && $consistency['score'] >= 60) ? 'positive' : 'neutral'],
            ['label' => 'Recovery Speed', 'value' => $recoveryText,
                'note' => 'Typical time to bounce back after a loss', 'tone' => 'neutral'],
            ['label' => 'Time At or Above Peak', 'value' => $atPeakPct . '%',
                'note' => 'Share of trades at a new or matching equity high',
                'tone' => $atPeakPct >= 50 ? 'positive' : 'neutral'],
            ['label' => 'Toughest Stretch Overcome', 'value' => $maxLossStreak > 0 ? $maxLossStreak . '-Trade Losing Streak' : 'None Faced',
                'note' => $maxLossStreak > 0 ? 'Recovered and kept executing the plan' : 'A clean run, with no extended losing streak',
                'tone' => 'positive'],
        ];

        return ['available' => true, 'tiles' => $tiles];
    }

    /**
     * One selected account's full archived success journey — every
     * value below is computed purely from THIS account's own already-
     * fetched segments (no further DB queries except the real
     * account_goals lookup fundedGoalAchievement() already made,
     * reused unchanged and simply scoped to a 1-account map). No
     * blended/portfolio averages anywhere in this return value.
     */
    public static function buildAccountSuccessJourney(array $segments, string $type, string $program, string $userId, $db): array
    {
        $isInstantFunding = $type === 'prop' && $program === 'instant_funding';
        $hasPhaseConcept = $type === 'prop' && !$isInstantFunding;

        $best = $segments[0];
        $accountId = $best['account_id'];
        $accountLabel = $best['account_label'];
        $challengeType = $best['challenge_type'];
        $lifecycleState = $best['account_lifecycle_state'];

        $stage = self::accountStageInfo($segments, $type, $program);
        $group = self::aggregateGroup($segments);
        $rows = self::flattenRows($segments);

        $starts = array_filter(array_column($segments, 'start_date'));
        $ends = array_filter(array_column($segments, 'end_date'));
        // §2 Live #2 fix (2026-08-10) — for the success lens, $segments
        // only ever contains this account's RESOLVED (passed/funded)
        // segments (an in-progress, not-yet-resolved phase is excluded
        // entirely — see the "$canonicalCurrent" doc comment further
        // below in this method), so max($ends) previously stopped at the
        // last resolved segment's own end date. taSuccessKpiCards()'s
        // caption for this exact KPI already asserts "From account
        // creation to today" whenever $stage['in_progress'] is true — a
        // real claim this value silently fell short of by however many
        // days the account has spent in its current unresolved phase.
        // Journey Duration is a whole-account "how long has this taken"
        // metric, so a still-progressing account's own duration
        // legitimately extends to right now, not just through its last
        // completed phase — fixed by extending the end boundary to the
        // real current moment whenever the caption's own "in progress"
        // condition is true, making the existing caption's claim
        // literally correct instead of overstated.
        $journeyEndBoundary = !empty($stage['in_progress']) ? date('Y-m-d H:i:s') : (!empty($ends) ? max($ends) : null);
        $journeyDays = (!empty($starts) && $journeyEndBoundary !== null) ? max(0, (strtotime($journeyEndBoundary) - strtotime(min($starts))) / 86400) : null;

        $maxDd = null;
        foreach ($segments as $s) {
            $m = $s['metrics']['max_drawdown_pct'];
            if ($m !== null && ($maxDd === null || $m > $maxDd)) $maxDd = $m;
        }

        $result = [
            'empty' => false,
            'type' => $type, 'program' => $program,
            'is_instant_funding' => $isInstantFunding,
            'has_phase_concept' => $hasPhaseConcept,
            'account_id' => $accountId, 'account_label' => $accountLabel,
            'challenge_type' => $challengeType, 'account_lifecycle_state' => $lifecycleState,
            'stage' => $stage,
            'kpi' => [
                'journey_days' => $journeyDays,
                'max_drawdown_pct' => $maxDd,
                'win_rate' => $group['pooled']['win_rate'],
                'trades_taken' => $group['pooled_trade_count'],
            ],
        ];

        $result['levels'] = $hasPhaseConcept ? self::accountLevels($segments, (string)$challengeType, $lifecycleState) : [];
        $result['activity_overview'] = !$hasPhaseConcept ? self::accountActivityOverview($segments) : ['available' => false];

        // "What Created The Pass" / "Execution Discipline" (2026-08-07,
        // correction pass) — genuinely success-oriented content, NOT a
        // recolored reuse of Lessons From Failure's own "target vs.
        // limit" / "risk violation" cards (an earlier draft did exactly
        // that and was explicitly rejected — see successStory()'s own doc
        // comment for the full reasoning). Prop-firm only — a live
        // account has no declared rules/consistency-score-relevant
        // program structure the same way, matching the caller's own
        // explicit "don't touch Live Accounts" scope.
        if ($type === 'prop') {
            $terminal = end($segments);
            $terminalRows = $terminal['rows'] ?? [];
            $consistency = self::historicalConsistency($group);

            // Single source of truth for "this account's current-stage
            // balance," per docs/architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md
            // §1a/1f and the MIGRATION_PLAN's Wave 3a — PropFirmRuleEngine
            // computes exactly the same figure ($accountSize + this
            // segment's own trades' effective P&L) successStory() already
            // derives locally, but only ever for the account's REAL live
            // current phase. Never trusted blindly: only substituted when
            // the engine's own phase_label agrees with this terminal
            // segment's label — the same array_key_last()-style safety
            // check already established (and hard-won — see
            // JourneySegmentation's own doc history) elsewhere in this
            // codebase, so a terminal segment that's genuinely HISTORICAL
            // (the account has since moved past it, e.g. a Phase 1 segment
            // for an account now in Phase 2) is never mistakenly treated as
            // current — the local $accountSize + $terminalPnl computation
            // remains the only correct source for that case, since
            // PropFirmRuleEngine structurally has no notion of a phase the
            // account has already left.
            $canonicalCurrent = null;
            $pfrMetrics = PropFirmRuleEngine::compute($accountId);
            if (!isset($pfrMetrics['error']) && $pfrMetrics['phase_label'] === ($terminal['phase_label'] ?? null)) {
                $canonicalCurrent = ['balance' => (float)$pfrMetrics['phase_balance'], 'starting_balance' => (float)$pfrMetrics['starting_balance']];
            }

            $result['pass_story'] = self::successStory($rows, $group, $consistency, (float)$best['account_size'], (string)($terminal['phase_label'] ?? ''), $terminalRows, $canonicalCurrent);
            $result['execution_discipline'] = self::successExecutionDiscipline($rows, $group, $consistency);
        } else {
            $result['pass_story'] = ['available' => false];
            $result['execution_discipline'] = ['available' => false];
        }

        $byAccountForGoal = [$accountId => $segments];

        $result['strongest_habits'] = self::strongestSuccessHabits($rows, $group);
        $result['funded_goal'] = self::fundedGoalAchievement($userId, $db, $byAccountForGoal, $type);
        $result['instrument_performance'] = self::instrumentPerformance($rows);
        $result['winning_edge'] = self::accountWinningEdge($segments, $rows);
        $result['evidence_trades'] = self::successEvidenceTrades($rows);

        // Live Accounts single-account extras (2026-08-06) — see this
        // file's own "LIVE ACCOUNTS — single-account extras" section
        // header for the full rationale and real data constraints behind
        // each of these. $best is this account's one real segment (a live
        // account always has exactly one).
        if ($type === 'live') {
            $ageDays = self::liveAccountAgeDays($best['created_at'] ?? $best['start_date'], $best['end_date']);
            $depositBehaviour = self::liveDepositBehaviour($best, $db);
            $withdrawalBehaviour = self::liveWithdrawalBehaviour($best, $db);

            $result['is_still_active'] = !empty($best['is_still_active']);
            $result['account_age_days'] = $ageDays;
            $result['account_created_at'] = $best['created_at'] ?? $best['start_date'];
            $result['current_balance'] = $best['current_balance'];
            $result['return_pct'] = $best['account_size'] > 0 ? round((($best['current_balance'] - $best['account_size']) / $best['account_size']) * 100, 2) : null;
            $result['account_snapshot'] = self::liveAccountSnapshot($best);
            $result['equity_curve'] = self::liveEquityCurve($best, $db);
            $result['trading_consistency'] = self::liveTradingConsistency($group);
            $result['financial_behaviour'] = self::liveFinancialBehaviour($best);
            $result['deposit_behaviour'] = $depositBehaviour;
            $result['withdrawal_behaviour'] = $withdrawalBehaviour;
            $result['growth_interruptions'] = self::liveGrowthInterruptions($best, $depositBehaviour, $withdrawalBehaviour);
            $result['funding_quality'] = $depositBehaviour; // same real classification, presented as its own card
            $result['trading_longevity'] = self::liveTradingLongevity($best, $ageDays);
        }

        return $result;
    }

    /**
     * Strongest Success Habits — a ranked list, each entry gated on its
     * own real minimum sample. Every entry is a genuinely computed fact
     * about the pooled success trades/segments, never an invented
     * category — an item simply doesn't appear when its underlying
     * signal isn't real/strong enough to report.
     */
    private static function strongestSuccessHabits(array $rows, array $group): array
    {
        $pooled = $group['pooled'];
        $n = count($rows);
        $out = [];

        // Best session, by real share of total realized profit. §2 Live #6
        // fix (2026-08-10) — this was the one habit in this function with
        // no MIN_GROUP_TRADES gate at all, unlike every sibling below it
        // (checklist adherence, risk consistency, instrument, strategy,
        // reward:risk) — a single winning trade in one session trivially
        // clears 100% of "total profit" and fired confident "strongest
        // results" language regardless of sample size. Gated on the real
        // number of trades ACTUALLY TAKEN in that specific session (not
        // the group's overall $n) — a session claim is only as reliable as
        // that session's own sample, not the whole account's trade count.
        $bySession = []; $sessionTradeCounts = []; $totalProfit = 0.0;
        foreach ($rows as $r) {
            $s = $r['trade']['session'] ?? null;
            if ($s === null || $s === '') continue;
            $sessionTradeCounts[$s] = ($sessionTradeCounts[$s] ?? 0) + 1;
            if ($r['pnl'] <= 0) continue;
            $bySession[$s] = ($bySession[$s] ?? 0) + $r['pnl'];
            $totalProfit += $r['pnl'];
        }
        if (!empty($bySession) && $totalProfit > 0) {
            arsort($bySession);
            $top = array_key_first($bySession);
            $pct = round($bySession[$top] / $totalProfit * 100, 1);
            if ($pct >= 30 && ($sessionTradeCounts[$top] ?? 0) >= self::MIN_GROUP_TRADES) {
                $out[] = ['title' => $top . ' session produces the strongest results', 'detail' => $pct . '% of total profit generated in this session', 'key' => 'session', 'session' => $top, 'pct' => $pct];
            }
        }

        if ($n >= self::MIN_GROUP_TRADES && $group['avg_checklist_skip_rate'] !== null) {
            $completion = round(100 - $group['avg_checklist_skip_rate'], 1);
            if ($completion >= 70) {
                $out[] = ['title' => 'High checklist adherence', 'detail' => 'avg completion rate ' . number_format($completion, 1) . '%', 'key' => 'checklist', 'pct' => $completion];
            }
        }

        // Uses the MEDIAN, not the mean — see segmentMetrics()'s doc
        // comment on median_actual_risk_pct. Also, unlike every other
        // habit above, this ASSERTS a value judgment ("consistent"), so
        // it only fires when the number actually clears a sane real-world
        // risk-management bar (MAX_SANE_RISK_PCT_FOR_CLAIM) — a pool whose
        // typical risk-per-trade is itself in the tens or hundreds of
        // percent has nothing "consistent" to report, however large its
        // sample size, and silently claiming otherwise (which the old
        // unconditional version of this habit did) is exactly the kind of
        // sanity-check failure this page must never show.
        if ($n >= self::MIN_GROUP_TRADES && $pooled['median_actual_risk_pct'] !== null && $pooled['median_actual_risk_pct'] <= self::MAX_SANE_RISK_PCT_FOR_CLAIM) {
            $out[] = ['title' => 'Consistent risk management', 'detail' => 'typical risk per trade: ' . number_format($pooled['median_actual_risk_pct'], 2) . '%', 'key' => 'risk'];
        }

        $instr = self::instrumentPerformance($rows);
        if ($instr['available'] && $instr['best'] !== null) {
            $out[] = ['title' => strtoupper($instr['best']['instrument']) . ' performs best', 'detail' => 'highest expectancy instrument at ' . number_format($instr['best']['expectancy_r'], 2) . 'R', 'key' => 'instrument'];
        }

        $bestStrategy = self::bestStrategy($rows);
        if ($bestStrategy !== null) {
            $usedPct = round($bestStrategy['n'] / max(1, $n) * 100, 1);
            $out[] = ['title' => 'Best performing strategy', 'detail' => $bestStrategy['name'] . ', used in ' . $usedPct . '% of these trades', 'key' => 'strategy', 'name' => $bestStrategy['name'], 'used_pct' => $usedPct];
        }

        // "Favorable" is a value judgment too — only claim it once reward
        // is actually at least on par with risk (>= 1:1), the bare
        // minimum any real trader would call favorable. The old
        // unconditional version could have labeled a 1:0.3 ratio
        // "favorable" purely because a sample existed.
        if ($n >= self::MIN_GROUP_TRADES && $pooled['avg_rr'] !== null && $pooled['avg_rr'] >= self::MIN_FAVORABLE_RR) {
            $out[] = ['title' => 'Favorable reward:risk maintained', 'detail' => '1 : ' . number_format($pooled['avg_rr'], 2) . ' average', 'key' => 'rr', 'avg_rr' => $pooled['avg_rr']];
        }

        return $out;
    }

    /** Best strategy by R-expectancy among strategies with enough trades AND enough R-multiple samples — pure computation, no invented ranking. */
    private static function bestStrategy(array $rows): ?array
    {
        $byStrategy = [];
        foreach ($rows as $r) {
            $sid = $r['trade']['strategy_id'] ?? null;
            if ($sid === null) continue;
            $maxLoss = $r['trade']['max_potential_loss'] ?? null;
            $byStrategy[$sid]['name'] = $r['trade']['strategy_name'] ?? 'Unnamed Strategy';
            $byStrategy[$sid]['version'] = $r['trade']['strategy_version_number'] ?? null;
            $byStrategy[$sid]['n'] = ($byStrategy[$sid]['n'] ?? 0) + 1;
            $byStrategy[$sid]['setup_criteria'] = $r['trade']['setup_criteria'] ?? null;
            $byStrategy[$sid]['entry_triggers'] = $r['trade']['entry_triggers'] ?? null;
            $byStrategy[$sid]['declared_instruments'] = $r['trade']['declared_instruments'] ?? null;
            if ($maxLoss !== null && (float)$maxLoss > 0) {
                $byStrategy[$sid]['r_sum'] = ($byStrategy[$sid]['r_sum'] ?? 0) + ($r['pnl'] / (float)$maxLoss);
                $byStrategy[$sid]['r_n'] = ($byStrategy[$sid]['r_n'] ?? 0) + 1;
            }
        }
        $best = null;
        foreach ($byStrategy as $sid => $d) {
            if ($d['n'] < self::MIN_GROUP_TRADES || empty($d['r_n']) || $d['r_n'] < self::MIN_GROUP_TRADES) continue;
            $exp = $d['r_sum'] / $d['r_n'];
            if ($best === null || $exp > $best['expectancy_r']) {
                $best = [
                    'id' => $sid, 'name' => $d['name'], 'version' => $d['version'], 'expectancy_r' => round($exp, 2), 'n' => $d['n'],
                    'setup_criteria' => self::decodeJsonList($d['setup_criteria']),
                    'entry_triggers' => self::decodeJsonList($d['entry_triggers']),
                    'declared_instruments' => self::decodeJsonList($d['declared_instruments']),
                ];
            }
        }
        return $best;
    }

    /**
     * Per-instrument trade-share + R-expectancy, pooled across whichever rows are passed in. Shared by Instrument Performance, Strongest Success Habits, and Your Winning Edge — one computation, three consumers.
     *
     * Deliberately NOT repointed at mv_instrument_performance or
     * reports.php's own per-instrument query — this operates on an
     * already-fetched, segment-scoped row set (one account/phase-bucket/
     * lens combination) and computes win-rate + R-multiple per
     * instrument, neither of which the view exposes; its rows also carry
     * the data-* attributes this page's evidence-table filter chips drill
     * through to. See docs/architecture/
     * SINGLE_SOURCE_OF_TRUTH_MIGRATION_PLAN.md's Wave 6 entry.
     */
    public static function instrumentPerformance(array $rows): array
    {
        $byInstrument = [];
        foreach ($rows as $r) {
            $instrument = strtoupper((string)($r['trade']['instrument'] ?? ''));
            if ($instrument === '') continue;
            $byInstrument[$instrument]['count'] = ($byInstrument[$instrument]['count'] ?? 0) + 1;
            $maxLoss = $r['trade']['max_potential_loss'] ?? null;
            if ($maxLoss !== null && (float)$maxLoss > 0) {
                $byInstrument[$instrument]['r_sum'] = ($byInstrument[$instrument]['r_sum'] ?? 0) + ($r['pnl'] / (float)$maxLoss);
                $byInstrument[$instrument]['r_n'] = ($byInstrument[$instrument]['r_n'] ?? 0) + 1;
            }
        }
        if (empty($byInstrument)) return ['available' => false];

        $total = array_sum(array_column($byInstrument, 'count'));
        $list = [];
        foreach ($byInstrument as $sym => $d) {
            $list[] = [
                'instrument' => $sym, 'count' => $d['count'], 'pct' => round($d['count'] / $total * 100, 1),
                'expectancy_r' => (isset($d['r_n']) && $d['r_n'] >= 3) ? round($d['r_sum'] / $d['r_n'], 2) : null,
            ];
        }
        usort($list, fn($a, $b) => $b['count'] <=> $a['count']);
        $top = array_slice($list, 0, 5);
        $othersCount = $total - array_sum(array_column($top, 'count'));
        if ($othersCount > 0) $top[] = ['instrument' => 'Others', 'count' => $othersCount, 'pct' => round($othersCount / $total * 100, 1), 'expectancy_r' => null];

        $withR = array_values(array_filter($list, fn($d) => $d['expectancy_r'] !== null));
        $avgExpectancy = !empty($withR) ? array_sum(array_column($withR, 'expectancy_r')) / count($withR) : null;
        $best = null;
        if (!empty($withR)) { usort($withR, fn($a, $b) => $b['expectancy_r'] <=> $a['expectancy_r']); $best = $withR[0]; }

        return ['available' => true, 'slices' => $top, 'avg_expectancy_r' => $avgExpectancy, 'best' => $best];
    }

    /** Real target-size bucket, expressed relative to the goal's OWN starting balance (scale-invariant — a $500 goal on a $1,000 account and a $50,000 goal on a $100,000 account are the "same size" in the sense that matters here), never a fixed dollar bucket that would assume one account scale. */
    private static function goalSizeBucketLabel(float $size, float $startingBalance): string
    {
        if ($startingBalance <= 0) return 'Unknown';
        $pct = ($size / $startingBalance) * 100;
        if ($pct <= 10) return 'Up to 10% of balance';
        if ($pct <= 25) return '10-25% of balance';
        if ($pct <= 50) return '25-50% of balance';
        return '50%+ of balance';
    }

    /**
     * Real, computed behavioral delay factors — account_goals has no
     * stored "why did this take longer" field, so nothing here is a
     * lookup of a stored reason. Only the SLOWER half of achieved goals
     * (by real time-to-goal, split at this group's own median) is
     * classified at all — a goal that resolved quickly has nothing to
     * explain. Each classified goal is bucketed into whichever single
     * real, computed behavioral signal it matched most strongly, from
     * that goal's own real trade window — never a hardcoded percentage,
     * and never a market-condition guess.
     *
     * Priority order (2026-08-05, aligned to the approved vocabulary for
     * this page): the three real, DIRECT `trade_classification` signals
     * (revenge_trade / tilt_warning / loss_high_risk — already a
     * database column, not an inference) are checked first, since a
     * literal classified trade in the window is more reliable evidence
     * than a derived pattern. Only when none of those fired does this
     * fall back to the three derived signals (a materially slower
     * trading pace than the account's own overall average; a genuine
     * multi-day gap between trades; unusually volatile position sizing).
     * "Too much hesitation" / "waiting too long for entries" as their own
     * distinct categories were deliberately left out — this schema has
     * no reliable per-trade signal for hesitation before entry, and
     * inventing one would violate the "only reliable data" rule this
     * page is built on.
     */
    private static function classifyGoalDelays(array $goals, array $rowsByAccount, array $timeToGoalDays): array
    {
        if (count($timeToGoalDays) < self::MIN_GROUP_SEGMENTS) return [];
        $sorted = $timeToGoalDays; sort($sorted);
        $mid = (int) floor(count($sorted) / 2);
        $median = count($sorted) % 2 ? $sorted[$mid] : ($sorted[$mid - 1] + $sorted[$mid]) / 2;

        $tally = [];
        foreach ($goals as $g) {
            if ($g['status'] !== 'achieved' || empty($g['created_at']) || empty($g['achieved_at'])) continue;
            $days = (strtotime($g['achieved_at']) - strtotime($g['created_at'])) / 86400;
            if ($days < $median) continue; // only the slower half took longer than typical — nothing to explain for the faster half

            $rows = $rowsByAccount[$g['account_id']] ?? [];
            $windowRows = array_values(array_filter($rows, function ($r) use ($g) {
                $c = $r['trade']['captured_at'] ?? null;
                if ($c === null) return false;
                $t = strtotime($c);
                return $t >= strtotime($g['created_at']) && $t <= strtotime($g['achieved_at']);
            }));
            $wn = count($windowRows);
            if ($wn < 2) { $tally['Other'] = ($tally['Other'] ?? 0) + 1; continue; }

            $windowTradesPerDay = $days > 0 ? $wn / $days : 0;
            $overallTradesPerDay = self::accountTradesPerDay($rows);

            $maxGapDays = 0.0;
            for ($i = 1; $i < $wn; $i++) {
                $t1 = strtotime($windowRows[$i - 1]['trade']['captured_at']);
                $t2 = strtotime($windowRows[$i]['trade']['captured_at']);
                $gap = ($t2 - $t1) / 86400;
                if ($gap > $maxGapDays) $maxGapDays = $gap;
            }

            $riskPcts = [];
            foreach ($windowRows as $r) { $rp = $r['trade']['risk_pct'] ?? null; if ($rp !== null && $rp !== '' && (float)$rp > 0) $riskPcts[] = (float)$rp; }
            $riskCv = null;
            if (count($riskPcts) >= 2) {
                $mean = array_sum($riskPcts) / count($riskPcts);
                if ($mean > 0) $riskCv = sqrt(array_sum(array_map(fn($v) => ($v - $mean) ** 2, $riskPcts)) / count($riskPcts)) / $mean;
            }

            $classCounts = ['revenge_trade' => 0, 'tilt_warning' => 0, 'loss_high_risk' => 0];
            foreach ($windowRows as $r) {
                $cls = $r['trade']['trade_classification'] ?? null;
                if ($cls !== null && isset($classCounts[$cls])) $classCounts[$cls]++;
            }

            $bucket = 'Other';
            if ($classCounts['revenge_trade'] > 0) $bucket = 'Revenge trading';
            elseif ($classCounts['tilt_warning'] > 0) $bucket = 'Overtrading';
            elseif ($classCounts['loss_high_risk'] > 0) $bucket = 'Over-risking';
            elseif ($overallTradesPerDay > 0 && $windowTradesPerDay <= $overallTradesPerDay * 0.6) $bucket = 'Reducing trade frequency';
            elseif ($maxGapDays >= 5) $bucket = 'Waiting too long between entries';
            elseif ($riskCv !== null && $riskCv > 0.6) $bucket = 'Poor consistency after drawdown';
            $tally[$bucket] = ($tally[$bucket] ?? 0) + 1;
        }

        if (empty($tally)) return [];
        arsort($tally);
        $total = array_sum($tally);
        $out = [];
        foreach ($tally as $label => $count) $out[] = ['label' => $label, 'count' => $count, 'pct' => round($count / $total * 100, 1)];
        return $out;
    }

    private static function accountTradesPerDay(array $rows): float
    {
        if (count($rows) < 2) return 0.0;
        $first = strtotime($rows[0]['trade']['captured_at'] ?? 'now');
        $last = strtotime(end($rows)['trade']['captured_at'] ?? 'now');
        $span = max(1, ($last - $first) / 86400);
        return count($rows) / $span;
    }

    /**
     * Average path to goal — the average % of a goal's target reached at
     * each real decile of ELAPSED TIME TOWARD THAT GOAL's OWN completion
     * (0%,10%,...,100% of its own real duration), pooled across every
     * achieved goal. This is a genuine trajectory shape learned from real
     * history, deliberately time-normalized per goal rather than
     * calendar-dated — it answers "what does the typical path to a goal
     * look like", never "how did this month compare to last month".
     */
    private static function goalTimeline(array $goals, array $rowsByAccount): array
    {
        $achieved = array_values(array_filter($goals, fn($g) => $g['status'] === 'achieved' && !empty($g['achieved_at'])));
        if (count($achieved) < self::MIN_GROUP_SEGMENTS) return ['available' => false, 'n' => count($achieved)];

        $deciles = range(0, 100, 10);
        $sums = array_fill_keys($deciles, 0.0);
        $counts = array_fill_keys($deciles, 0);

        foreach ($achieved as $g) {
            $span = (float)$g['target_balance'] - (float)$g['starting_balance'];
            if ($span <= 0) continue;
            $startTs = strtotime($g['created_at']); $endTs = strtotime($g['achieved_at']);
            $totalDays = ($endTs - $startTs) / 86400;
            if ($totalDays <= 0) continue;

            $rowsForAccount = $rowsByAccount[$g['account_id']] ?? [];
            $windowRows = array_values(array_filter($rowsForAccount, function ($r) use ($startTs, $endTs) {
                $c = $r['trade']['captured_at'] ?? null;
                if ($c === null) return false;
                $t = strtotime($c);
                return $t >= $startTs && $t <= $endTs;
            }));

            $cumPnl = 0.0;
            $pointsByFraction = [0.0 => 0.0];
            foreach ($windowRows as $r) {
                $cumPnl += $r['pnl'];
                $t = strtotime($r['trade']['captured_at']);
                $fraction = min(100, max(0, (($t - $startTs) / 86400) / $totalDays * 100));
                $pointsByFraction[$fraction] = ($cumPnl / $span) * 100;
            }
            $pointsByFraction[100.0] = ($cumPnl / $span) * 100;
            ksort($pointsByFraction);

            foreach ($deciles as $d) {
                $val = null;
                foreach ($pointsByFraction as $frac => $prog) { if ($frac <= $d) $val = $prog; else break; }
                if ($val !== null) { $sums[$d] += max(0, $val); $counts[$d]++; }
            }
        }

        $points = [];
        foreach ($deciles as $d) {
            $points[] = ['x' => $d, 'y' => $counts[$d] > 0 ? round($sums[$d] / $counts[$d], 1) : null, 'n' => $counts[$d]];
        }
        return ['available' => true, 'n' => count($achieved), 'points' => $points];
    }

    /**
     * Funded Goal Achievement — reuses the real account_goals table
     * (Right Now Journal's own "Growth Target" feature, which already
     * works identically for funded prop accounts and live accounts — see
     * CLAUDE.md) rather than inventing a parallel goal concept. Applies
     * ONLY to accounts eligible for that feature: funded prop accounts
     * (status='funded_active' among this scope's contributing segments)
     * or, for the Live tab, every contributing live account. Returns
     * ['applicable' => false] — the caller hides the whole section —
     * whenever no eligible account in the current scope has ever set a
     * goal, per the brief's own "only show when funded accounts exist"
     * instruction.
     */
    private static function fundedGoalAchievement(string $userId, $db, array $byAccount, string $type): array
    {
        $eligibleAccountIds = [];
        foreach ($byAccount as $accountId => $segs) {
            if ($type === 'live') { $eligibleAccountIds[] = $accountId; continue; }
            foreach ($segs as $s) { if ($s['status'] === 'funded_active') { $eligibleAccountIds[] = $accountId; break; } }
        }
        $eligibleAccountIds = array_values(array_unique($eligibleAccountIds));
        if (empty($eligibleAccountIds)) return ['applicable' => false];

        $placeholders = []; $params = [':uid' => $userId];
        foreach ($eligibleAccountIds as $i => $id) { $placeholders[] = ":a{$i}"; $params[":a{$i}"] = $id; }
        $goals = $db->raw(
            "SELECT id, account_id, starting_balance, target_balance, reason, status, created_at, achieved_at, replaced_at
             FROM account_goals WHERE user_id = :uid AND account_id IN (" . implode(',', $placeholders) . ")
             ORDER BY created_at ASC",
            $params
        );
        if (empty($goals)) return ['applicable' => false];

        $goalsSet = count($goals);
        $achieved = array_values(array_filter($goals, fn($g) => $g['status'] === 'achieved'));
        $goalsAchieved = count($achieved);
        $achievementPct = $goalsSet > 0 ? round($goalsAchieved / $goalsSet * 100, 1) : null;

        $timeToGoalDays = [];
        foreach ($achieved as $g) {
            if (!empty($g['created_at']) && !empty($g['achieved_at'])) {
                $d = (strtotime($g['achieved_at']) - strtotime($g['created_at'])) / 86400;
                if ($d >= 0) $timeToGoalDays[] = $d;
            }
        }
        $avgTimeToGoal = !empty($timeToGoalDays) ? array_sum($timeToGoalDays) / count($timeToGoalDays) : null;

        // Rebuild each eligible account's own ordered trade rows (already
        // fetched, no new query) so goal completion % can be reconstructed
        // from the account's real running balance at each goal's own
        // resolution moment — account_goals stores no balance snapshot at
        // replace time, so this is the only non-fabricated way to answer
        // "how close did a REPLACED (not achieved) goal actually get".
        $rowsByAccount = [];
        foreach ($byAccount as $accountId => $segs) {
            $rowsAll = [];
            foreach ($segs as $s) { foreach ($s['rows'] as $r) { $rowsAll[] = $r; } }
            usort($rowsAll, fn($a, $b) => strtotime($a['trade']['captured_at'] ?? 'now') <=> strtotime($b['trade']['captured_at'] ?? 'now'));
            $rowsByAccount[$accountId] = $rowsAll;
        }

        $completionPcts = [];
        foreach ($goals as $g) {
            $span = (float)$g['target_balance'] - (float)$g['starting_balance'];
            if ($span <= 0) continue;
            $resolveAt = $g['achieved_at'] ?? $g['replaced_at'];
            $rowsForAccount = $rowsByAccount[$g['account_id']] ?? [];
            $cumPnl = 0.0; $found = false;
            foreach ($rowsForAccount as $r) {
                $capturedAt = $r['trade']['captured_at'] ?? null;
                if ($capturedAt === null || strtotime($capturedAt) < strtotime($g['created_at'])) continue;
                if ($resolveAt !== null && strtotime($capturedAt) > strtotime($resolveAt)) break;
                $cumPnl += $r['pnl']; $found = true;
            }
            if ($found || $g['status'] === 'achieved') $completionPcts[] = max(0, ($cumPnl / $span) * 100);
        }
        $avgCompletionPct = !empty($completionPcts) ? array_sum($completionPcts) / count($completionPcts) : null;

        $sizeBuckets = [];
        foreach ($goals as $g) {
            $size = (float)$g['target_balance'] - (float)$g['starting_balance'];
            if ($size <= 0) continue;
            $label = self::goalSizeBucketLabel($size, (float)$g['starting_balance']);
            $sizeBuckets[$label] = ($sizeBuckets[$label] ?? 0) + 1;
        }

        // Bucket boundaries (2026-08-06): "Within 10 days / 11–20 / 21–40 /
        // 41+" — matches the approved Live Accounts prototype's "Goal
        // Completion Time Distribution" exactly. Fastest/Typical(median)/
        // Longest are the same real timeToGoalDays array, just reduced to
        // 3 summary numbers instead of a distribution — gated on the same
        // MIN_GROUP_SEGMENTS floor as every other goal-speed stat here.
        $speedBuckets = ['Within 10 days' => 0, '11-20 days' => 0, '21-40 days' => 0, '41+ days' => 0];
        foreach ($timeToGoalDays as $d) {
            if ($d <= 10) $speedBuckets['Within 10 days']++;
            elseif ($d <= 20) $speedBuckets['11-20 days']++;
            elseif ($d <= 40) $speedBuckets['21-40 days']++;
            else $speedBuckets['41+ days']++;
        }
        $speedTotal = array_sum($speedBuckets);
        $speedOut = [];
        if ($speedTotal > 0) {
            foreach ($speedBuckets as $label => $count) { $speedOut[] = ['label' => $label, 'count' => $count, 'pct' => round($count / $speedTotal * 100, 1)]; }
        }

        $fastestGoalDays = null; $typicalGoalDays = null; $longestGoalDays = null;
        if (count($timeToGoalDays) >= self::MIN_GROUP_SEGMENTS) {
            $sortedDays = $timeToGoalDays; sort($sortedDays);
            $fastestGoalDays = $sortedDays[0];
            $longestGoalDays = end($sortedDays);
            $midIdx = (int) floor(count($sortedDays) / 2);
            $typicalGoalDays = count($sortedDays) % 2 ? $sortedDays[$midIdx] : ($sortedDays[$midIdx - 1] + $sortedDays[$midIdx]) / 2;
        }

        return [
            'applicable' => true,
            'goals_set' => $goalsSet, 'goals_achieved' => $goalsAchieved, 'achievement_pct' => $achievementPct,
            'avg_time_to_goal_days' => $avgTimeToGoal, 'n_time_to_goal_samples' => count($timeToGoalDays),
            'avg_completion_pct' => $avgCompletionPct, 'n_completion_samples' => count($completionPcts),
            'size_distribution' => $sizeBuckets,
            'speed_buckets' => $speedOut,
            'fastest_goal_days' => $fastestGoalDays, 'typical_goal_days' => $typicalGoalDays, 'longest_goal_days' => $longestGoalDays,
            'delay_factors' => self::classifyGoalDelays($goals, $rowsByAccount, $timeToGoalDays),
            'timeline' => self::goalTimeline($goals, $rowsByAccount),
        ];
    }

    // ════════════════════════════════════════════════════════════════════
    // LESSONS FROM FAILURE — single-account rebuild (2026-08-06)
    // ────────────────────────────────────────────────────────────────
    // Mirrors the single-account architecture the Lessons From Success
    // rebuild established above (gatherSuccessAccounts() /
    // buildSuccessAccountSelector() / buildAccountSuccessJourney()) but for
    // the opposite lens: one selected BREACHED prop-firm account (or one
    // CLOSED-AT-A-LOSS live account) and the real, computed story of why it
    // ended. Every number below is derived from that one account's own real
    // segments/rows (already gathered by gatherPropSegments()/
    // gatherLiveSegments() above) — no portfolio-wide blending, no
    // invented findings. A signal only appears (Primary Reasons, coaching,
    // avoidability) when it's real and clears an explicit threshold; a
    // thin history renders an honest "not enough signal" message rather
    // than a fabricated verdict, matching this file's established
    // philosophy throughout.
    // ════════════════════════════════════════════════════════════════════

    /**
     * Same shape as gatherSuccessAccounts() but for the Failure lens.
     * Prop-firm only, by design — 2026-08-06, explicit instruction:
     * Lessons From Failure is strictly a prop-firm-challenge concept
     * (breach rules, phases, program types) and must never surface Live
     * Accounts in any form, including a "closed at a loss" fallback
     * variant. Live's own closed-at-a-loss accounts are a real, distinct
     * concept, just not this feature's — one entry per account with at
     * least one real BREACHED segment; every OTHER real segment of that
     * same account is included too, so the Journey timeline can show the
     * real earlier phases it passed on the way there.
     */
    public static function gatherFailureAccounts(string $userId, $db, string $program): array
    {
        $population = self::gatherPropSegments($userId, $db);

        $scoped = $population;
        if ($program !== 'all') {
            $scoped = array_values(array_filter($scoped, fn($s) => $s['challenge_type'] === $program));
        }

        $failedAccountIds = [];
        foreach ($scoped as $s) {
            if ($s['status'] === 'breached') $failedAccountIds[$s['account_id']] = true;
        }

        $byAccount = [];
        foreach ($scoped as $s) {
            if (isset($failedAccountIds[$s['account_id']])) $byAccount[$s['account_id']][] = $s;
        }

        return ['by_account' => $byAccount, 'is_instant_funding' => $program === 'instant_funding'];
    }

    /** The failed-account picker's row list — real current status baked into the label, most-recently-ended first, matching Lessons From Success's own picker convention. Prop-firm only — see gatherFailureAccounts()'s own doc comment. */
    public static function buildFailureAccountSelector(array $byAccount): array
    {
        $out = [];
        foreach ($byAccount as $accountId => $segs) {
            $terminal = end($segs);
            $statusLabel = 'Breached';
            $ends = array_filter(array_column($segs, 'end_date'));
            $lastActivity = !empty($ends) ? max($ends) : null;
            $out[] = [
                'id' => (string)$accountId,
                'raw_label' => $terminal['account_label'],
                'label' => $terminal['account_label'] . ' (' . $statusLabel . ')',
                'lifecycle_state' => $terminal['account_lifecycle_state'],
                'challenge_type' => $terminal['challenge_type'],
                'stage_short' => $statusLabel,
                'last_activity' => $lastActivity,
            ];
        }
        usort($out, fn($a, $b) => strtotime($b['last_activity'] ?? '1970-01-01') <=> strtotime($a['last_activity'] ?? '1970-01-01'));
        return $out;
    }

    /** All-time breach tally across EVERY real breached prop-firm account this user has ever had, regardless of the currently-selected program filter — feeds the "Breaches By Reason (All Time)" donut, deliberately not scoped to one program the way the rest of this page is. */
    public static function allTimeBreachCauses(string $userId, $db): array
    {
        $allProp = self::gatherPropSegments($userId, $db);
        $breached = array_values(array_filter($allProp, fn($s) => $s['status'] === 'breached'));
        $accountCount = count(array_unique(array_column($breached, 'account_id')));
        return ['causes' => self::breachCauses($breached), 'account_count' => $accountCount];
    }

    /**
     * Every real segment of ONE specific account, by id, regardless of its
     * type (prop or live) or current status — used exclusively by the
     * breached-account "View in Trades Table" destination (see
     * public/journal.php's renderBreachTradesView()), which needs a
     * breached/closed account's real full history even though Active
     * Journeys' own fetchers (rnFetchActivePropAccounts()/
     * rnFetchActiveLiveAccounts()) deliberately exclude exactly those
     * accounts. Never used to populate Active Journeys' own account
     * picker — that page still only ever lists active accounts.
     */
    public static function gatherAccountSegmentsById(string $userId, $db, string $accountId): array
    {
        $propSegs = array_values(array_filter(self::gatherPropSegments($userId, $db), fn($x) => $x['account_id'] === $accountId));
        if (!empty($propSegs)) return ['type' => 'prop', 'segments' => $propSegs];
        $liveSegs = array_values(array_filter(self::gatherLiveSegments($userId, $db), fn($x) => $x['account_id'] === $accountId));
        if (!empty($liveSegs)) return ['type' => 'live', 'segments' => $liveSegs];
        return ['type' => null, 'segments' => []];
    }

    /**
     * Real declared daily-loss-limit % for a phase label — same key-cascade
     * AnalyticsEngine::getChallengeMetrics()/journal.php's rnPhaseTargets()
     * already use for this exact rule, duplicated here (a small, pure
     * lookup) rather than reaching into either. Returns null — never a
     * fabricated default — when the firm never declared one.
     *
     * An unrecognized label (Instant Funding's single 'Instant Funding'/
     * 'Current Phase' bucket — see buildAccountFailureJourney()'s own
     * relabeling comment) still checks the flat, non-phase-prefixed
     * `daily_dd_pct` key directly: per CLAUDE.md's own documented rules
     * schema, `daily_dd_pct`/`max_dd_pct` are the same general keys used
     * across every challenge type INCLUDING Instant Funding — only the
     * profit-target key differs for that type (consistency_requirement_pct
     * instead of target_pct), which is why $profitTargetPct is guarded
     * separately in buildAccountFailureJourney() rather than here. This is
     * a real, honestly-declared rule value when present, not a guess.
     */
    private static function failureDailyLossPct(array $rules, string $label): ?float
    {
        $key = match ($label) {
            'Phase 1' => 'phase_1', 'Phase 2' => 'phase_2', 'Phase 3' => 'phase_3', 'Funded Account' => 'funded', default => null,
        };
        $candidates = match ($key) {
            null => [$rules['daily_dd_pct'] ?? null],
            'funded' => [$rules['funded_daily_loss'] ?? null, $rules['daily_dd_pct'] ?? null],
            default => [$rules["{$key}_daily_loss"] ?? null, $rules['daily_dd_pct'] ?? null],
        };
        foreach ($candidates as $v) { if ($v !== null && $v !== '' && is_numeric($v)) return (float)$v; }
        return null;
    }

    /** "Rule Broken" label for the Breach At A Glance card — names the real configured dollar limit behind whichever rule the account's own logged breach reason points at, falling back to the account's own configured max-drawdown limit (the most common real rule) rather than a generic phrase whenever a real dollar figure is available. Prop-firm only — see gatherFailureAccounts()'s own doc comment on why Lessons From Failure never handles Live Accounts. */
    private static function failureRuleBrokenLabel(string $reasonLabel, ?float $dailyLossDollar, ?float $maxDdDollarConfigured): string
    {
        $lower = strtolower($reasonLabel);
        if (str_contains($lower, 'daily') && $dailyLossDollar !== null) {
            return 'Daily Loss Limit (-$' . number_format($dailyLossDollar, 2) . ')';
        }
        if ($maxDdDollarConfigured !== null) {
            return 'Maximum Drawdown (-$' . number_format($maxDdDollarConfigured, 2) . ')';
        }
        return 'Account Rules Violated';
    }

    /**
     * One selected BREACHED account's full archived breach story — every
     * value computed purely from THIS account's own real segments/rows
     * (already gathered), plus its own declared daily-loss/profit-target
     * percentages. No portfolio-wide blending anywhere in this return
     * value. Prop-firm only, always — see gatherFailureAccounts()'s own
     * doc comment: Lessons From Failure is strictly a 1-Step/2-Step/3-
     * Step/Instant Funding concept and never handles Live Accounts in
     * any form.
     */
    public static function buildAccountFailureJourney(array $segments, string $program, string $userId, $db): array
    {
        $challengeType = $segments[0]['challenge_type'] ?? null;
        $isInstantFunding = $challengeType === 'instant_funding';

        // Instant Funding has no real Phase 1/2/3 concept at all — its one
        // real segment (JourneySegmentation::partitionAccountJourney()
        // never splits it by phase, see that class's own doc comment)
        // arrives labeled 'Current Phase' (gatherPropSegments()'s generic
        // fallback for a lifecycle_state that isn't phase_1/2/3/funded_
        // active — 'breached' included). Relabeled here, at the top,
        // before anything else reads phase_label, so the timeline,
        // Breach At A Glance, and every other consumer below shows the
        // real concept ("Instant Funding") instead of that fallback
        // string. Real data is completely unchanged — this only replaces
        // one display label on this account's own already-real segment.
        if ($isInstantFunding) {
            foreach ($segments as &$s) { if ($s['phase_label'] === 'Current Phase') $s['phase_label'] = 'Instant Funding'; }
            unset($s);
        }

        $best = $segments[0];
        $terminal = end($segments);
        $accountId = $best['account_id'];
        $accountLabel = $best['account_label'];
        $lifecycleState = $terminal['account_lifecycle_state'];
        $accountSize = (float)$terminal['account_size'];
        $rules = $terminal['rules'] ?? [];

        $rows = self::flattenRows($segments); // chronological, whole real account history
        $n = count($rows);
        $group = self::aggregateGroup($segments);

        // Continuous "story" equity curve — one running balance across the
        // WHOLE account's real history, deliberately never resetting at a
        // phase boundary the way each segment's own local rows do for
        // Active Journeys' per-phase cards (rnBuildPhaseCardFromRows()) —
        // this chart's whole purpose is one continuous arc from account
        // creation to breach, built from the same real per-trade P&L.
        $story = [];
        $bal = $accountSize; $peak = $accountSize; $maxDdDollar = 0.0;
        foreach ($rows as $i => $r) {
            $bal += $r['pnl'];
            if ($bal > $peak) $peak = $bal;
            $ddDollar = $peak - $bal;
            if ($ddDollar > $maxDdDollar) $maxDdDollar = $ddDollar;
            $story[] = ['idx' => $i, 'trade' => $r['trade'], 'pnl' => $r['pnl'], 'balance' => $bal, 'peak' => $peak];
        }
        $totalPnl = $n > 0 ? end($story)['balance'] - $accountSize : 0.0;
        $maxDrawdownPctOfAccount = $accountSize > 0 ? ($maxDdDollar / $accountSize) * 100 : 0.0;

        $breachRow = $n > 0 ? $story[$n - 1] : null; // the account's real final closed trade
        $breachTradeId = $breachRow['trade']['id'] ?? null;

        $startDate = $best['start_date'];
        $endDate = $terminal['end_date'];
        $daysActive = (!empty($startDate) && !empty($endDate)) ? max(0, (strtotime($endDate) - strtotime($startDate)) / 86400) : null;

        $breachPhaseLabel = $terminal['phase_label'];
        $dailyLossPct = self::failureDailyLossPct($rules, $breachPhaseLabel);
        $dailyLossDollar = $dailyLossPct !== null ? $accountSize * $dailyLossPct / 100 : null;
        $maxDdPctConfigured = $terminal['max_dd_pct_configured'];
        // Maximum Loss Model (2026-08-17) — the drawdown BUDGET/floor this
        // account was actually held to is anchored to $accountSize for
        // Fixed, but to the TERMINAL (breaching) phase's own real peak
        // balance for Trailing — never the whole multi-phase $peak walked
        // above (that one intentionally spans every phase this account
        // ever traded, for the continuous story chart; the Phase Boundary
        // Balance Rule means a Trailing floor resets at each phase
        // promotion, exactly matching PropFirmRuleEngine::compute()'s own
        // phase-scoped peakBalance). $terminal['rows'] is already this
        // one phase's own real running-balance walk (buildRows()), so the
        // phase's peak is simply the highest balance it ever reached —
        // no new query, no re-derivation of anything already computed.
        $maxLossModel = strtolower(trim((string)($rules['max_loss_model'] ?? 'fixed')));
        $ddReferenceBalance = $accountSize;
        if ($maxLossModel === 'trailing' && !empty($terminal['rows'])) {
            $ddReferenceBalance = max($accountSize, max(array_column($terminal['rows'], 'balance')));
        }
        $maxDdDollarConfigured = $maxDdPctConfigured !== null ? $ddReferenceBalance * $maxDdPctConfigured / 100 : null;
        // Instant Funding never routes through rnPhaseTargets() — that
        // function's own key cascade falls back to a hardcoded 8% "phase
        // 1" default for any label it doesn't recognize (real Phase
        // 1/2/3/Funded Account only), and Instant Funding's real profit
        // concept is declared under a different key entirely
        // (consistency_requirement_pct, not a profit-target percentage —
        // see CLAUDE.md's challenge_accounts.rules documentation). Rather
        // than misusing that unrelated key as if it meant the same thing,
        // this stays null (honestly "Not specified") for Instant Funding,
        // matching this file's own established never-fabricate principle.
        $profitTargetPct = (!$isInstantFunding && function_exists('rnPhaseTargets')) ? (rnPhaseTargets($rules, $breachPhaseLabel)['profit_target_pct'] ?? null) : null;
        // Real bug found and fixed (2026-08-06, third refinement pass) —
        // this used to collapse ANY non-positive result to null (showing
        // "Not specified" on the Account Rules & Thresholds card), on the
        // reasoning that rnPhaseTargets()'s 'funded' branch has no
        // further fallback and can return a bare, ambiguous 0 for a
        // Funded phase with nothing configured. That reasoning is real
        // for the Funded case specifically — but it was being applied
        // unconditionally, including to Phase 1/2/3, where it's WRONG:
        // those three branches always fall back to a hardcoded 8/5/5
        // default when a key is genuinely missing, so a resolved 0 for
        // Phase 1/2/3 can ONLY mean the account's own rules explicitly
        // declared "phase_N_target_pct": 0 — confirmed live against a
        // real account whose stored rules JSON does exactly that. That
        // was real, correctly-retrieved data being mislabeled "Not
        // specified" (implying it was never fetched at all), not a
        // missing value. Fixed to keep the distinction: Phase 1/2/3 keep
        // a real declared 0 as a real 0 (never collapsed); only the
        // genuinely ambiguous Funded-phase 0 still falls back to null,
        // since there the resolved 0 truly could mean either thing and
        // asserting it's a real declared value would be a guess.
        if ($breachPhaseLabel === 'Funded Account' && $profitTargetPct !== null && $profitTargetPct <= 0) $profitTargetPct = null;
        $profitTargetDollar = $profitTargetPct !== null ? $accountSize * $profitTargetPct / 100 : null;

        // Real per-calendar-day P&L across the whole account — needed for
        // the daily-loss-limit stat tile and the avoidability verdict.
        $byDay = [];
        foreach ($story as $s) {
            $when = $s['trade']['closed_at'] ?? $s['trade']['captured_at'] ?? null;
            if ($when === null) continue;
            $byDay[substr($when, 0, 10)][] = $s;
        }
        $dailyLossBreachDays = 0; $worstDayLoss = 0.0;
        foreach ($byDay as $dayRows) {
            $dayPnl = array_sum(array_column($dayRows, 'pnl'));
            if ($dayPnl < $worstDayLoss) $worstDayLoss = $dayPnl;
            if ($dailyLossDollar !== null && $dayPnl <= -$dailyLossDollar) $dailyLossBreachDays++;
        }
        $totalDays = count($byDay);

        $breachDay = null;
        if ($breachRow !== null) {
            $when = $breachRow['trade']['closed_at'] ?? $breachRow['trade']['captured_at'] ?? null;
            $breachDay = $when !== null ? substr($when, 0, 10) : null;
        }
        $breachDayRows = $breachDay !== null ? ($byDay[$breachDay] ?? []) : [];
        $actualDailyLossOnBreachDay = !empty($breachDayRows) ? array_sum(array_column($breachDayRows, 'pnl')) : null;

        $breachReasonRaw = $terminal['current_outcome'] ?? null;
        $breachReasonLabel = self::bucketOutcome($breachReasonRaw);
        $ruleBrokenLabel = self::failureRuleBrokenLabel($breachReasonLabel, $dailyLossDollar, $maxDdDollarConfigured);

        $largestLoss = null;
        foreach ($rows as $r) { if ($largestLoss === null || $r['pnl'] < $largestLoss) $largestLoss = $r['pnl']; }

        $distanceFromGoal = ($profitTargetDollar !== null) ? ($profitTargetDollar - $totalPnl) : null;

        // Computed once, shared by both Final Coaching Before Breach and
        // Could This Breach Have Been Avoided? — see
        // preBreachRiskSnapshot()'s own doc comment for why these two
        // cards must reason from the identical real recommendation
        // rather than two independently-derived numbers.
        $preBreachSnapshot = self::preBreachRiskSnapshot($rows, $story, $ddReferenceBalance, $maxDdDollarConfigured, $accountId, $userId, $db);

        return [
            'empty' => false,
            'type' => 'prop', 'program' => $program,
            'account_id' => $accountId, 'account_label' => $accountLabel,
            'challenge_type' => $challengeType, 'account_lifecycle_state' => $lifecycleState,
            'account_size' => $accountSize, 'prop_firm_name' => $terminal['prop_firm_name'] ?? null,
            'breach_trade_id' => $breachTradeId,
            'timeline' => $segments,
            'stats' => [
                'days_active' => $daysActive, 'start_date' => $startDate, 'end_date' => $endDate,
                'total_trades' => $n, 'win_rate' => $group['pooled']['win_rate'],
                'net_pnl' => $totalPnl, 'net_pnl_pct' => $accountSize > 0 ? $totalPnl / $accountSize * 100 : null,
                'max_drawdown_pct' => $maxDrawdownPctOfAccount, 'max_drawdown_dollar' => $maxDdDollar,
                'largest_loss' => $largestLoss, 'largest_loss_pct' => ($largestLoss !== null && $accountSize > 0) ? $largestLoss / $accountSize * 100 : null,
                'breach_reason' => $breachReasonLabel,
            ],
            'glance' => [
                'breach_reason' => $breachReasonLabel, 'rule_broken' => $ruleBrokenLabel,
                'daily_loss_limit_dollar' => $dailyLossDollar, 'daily_loss_limit_pct' => $dailyLossPct,
                'actual_daily_loss' => $actualDailyLossOnBreachDay,
                'breach_trade_number' => $n, 'breach_trade_date' => $breachRow['trade']['closed_at'] ?? $breachRow['trade']['captured_at'] ?? null,
                'phase_when_breached' => $breachPhaseLabel,
                'distance_from_profit_goal' => $distanceFromGoal,
            ],
            // 'breach_level_dollar' (item 7, 2026-08-06 refinement pass) —
            // the account's own real configured minimum-equity floor
            // (ddReferenceBalance − configured max drawdown $ — accountSize
            // for Fixed, the terminal phase's own peak for Trailing, see
            // $ddReferenceBalance above), used by the simplified equity
            // chart to draw one honest "danger zone" threshold line. Null
            // — never a guessed number — for an account whose rules
            // genuinely never declared a max-drawdown %, same
            // never-fabricate rule as $maxDdDollarConfigured itself
            // (realDeclaredMaxDdPct()).
            'equity' => ['story' => $story, 'account_size' => $accountSize, 'breach_index' => $n > 0 ? $n - 1 : null,
                'max_drawdown_pct' => $maxDrawdownPctOfAccount,
                'breach_level_dollar' => $maxDdDollarConfigured !== null ? $ddReferenceBalance - $maxDdDollarConfigured : null],
            'primary_reasons' => self::failurePrimaryReasons($rows, $group, $byDay, $dailyLossDollar, $breachDay),
            'risk_behavior' => self::riskBehaviorTiles($rows, $group, $dailyLossBreachDays, $totalDays),
            'avoidable' => self::failureAvoidability($rows, $story, $byDay, $breachDay, $dailyLossDollar, $accountSize, $profitTargetDollar, $n, $maxDdDollarConfigured, $preBreachSnapshot),
            'goals' => self::failureGoalsProgress($story, $accountSize, $profitTargetPct, $profitTargetDollar, $dailyLossPct, $dailyLossDollar, $worstDayLoss, $maxDdPctConfigured, $maxDdDollarConfigured, $maxDdDollar, $totalPnl),
            'coaching' => self::failureCoaching($rows, $byDay, $breachDay, $dailyLossDollar, $preBreachSnapshot),
            'evidence_trades' => self::failureEvidenceTrades($rows, $breachTradeId),
        ];
    }

    /**
     * Ranked, real, threshold-gated behavioral signals leading into the
     * breach — never a fixed five, only whichever real signals actually
     * clear a threshold, sorted by real computed impact. Each reason's
     * math is unchanged from its original derivation (verified again
     * during the 2026-08-06 refinement pass — every impact score and
     * dollar figure below is a direct, correct read of this account's
     * own pooled trade data, no formula changed); what changed is the
     * `detail` copy itself, rewritten so someone new to prop trading can
     * follow the full chain: what the rule/pattern was, what actually
     * happened, and why that combination breaches an account — plus a
     * real `icon` per reason type so the render layer never has to guess.
     */
    private static function failurePrimaryReasons(array $rows, array $group, array $byDay, ?float $dailyLossDollar, ?string $breachDay): array
    {
        $pooled = $group['pooled'];
        $n = count($rows);
        $totalDays = count($byDay);
        $avgPerDay = $totalDays > 0 ? $n / $totalDays : null;
        $breachDayCount = ($breachDay !== null && isset($byDay[$breachDay])) ? count($byDay[$breachDay]) : null;

        $candidates = [];

        if ($breachDayCount !== null && $avgPerDay !== null && $avgPerDay > 0 && $breachDayCount >= 2) {
            $ratio = $breachDayCount / max(1.0, $avgPerDay);
            if ($ratio >= 1.4) {
                $candidates[] = ['label' => 'Overtrading', 'icon' => '📈', 'impact' => (int)min(100, round(35 + ($ratio - 1) * 30)),
                    'detail' => 'This account typically took ' . number_format($avgPerDay, 1) . ' trade(s) per day, but took ' . $breachDayCount . ' on the day it breached. Trading more than usual, in the same session, tends to compound losses faster than a normal trading pace does.'];
            }
        }

        if ($dailyLossDollar !== null && $breachDay !== null && isset($byDay[$breachDay])) {
            $dayPnl = array_sum(array_column($byDay[$breachDay], 'pnl'));
            if ($dayPnl <= -$dailyLossDollar) {
                $overshoot = abs($dayPnl) - $dailyLossDollar;
                $candidates[] = ['label' => 'Daily Loss Limit Exceeded', 'icon' => '🛑', 'impact' => (int)min(100, round(65 + ($dailyLossDollar > 0 ? ($overshoot / $dailyLossDollar) * 40 : 0))),
                    'detail' => 'The account was allowed to lose up to $' . number_format($dailyLossDollar, 2) . ' in a single day. It actually lost $' . number_format(abs($dayPnl), 2)
                        . ' that day, $' . number_format($overshoot, 2) . ' past the limit. Most prop firms treat any day that crosses this line as an automatic breach, regardless of what happens afterward.'];
            }
        }

        if (($pooled['revenge_rate'] ?? 0) > 0) {
            $candidates[] = ['label' => 'Revenge Trading', 'icon' => '😤', 'impact' => (int)min(100, round($pooled['revenge_rate'] * 4 + 20)),
                'detail' => number_format($pooled['revenge_rate'], 1) . '% of this account\'s trades were classified Revenge Trades: entered right after a loss, usually at a larger size, in an attempt to win the loss back immediately rather than waiting for the next real setup.'];
        }

        if ($pooled['post_loss_risk_delta_pct'] !== null && $pooled['post_loss_risk_delta_pct'] >= 15 && $pooled['n_post_loss_risk'] >= 3) {
            $candidates[] = ['label' => 'Increased Risk After Losses', 'icon' => '⚠', 'impact' => (int)min(100, round($pooled['post_loss_risk_delta_pct'])),
                'detail' => 'On the trade right after a loss, this account\'s risk per trade rose by an average of ' . number_format($pooled['post_loss_risk_delta_pct'], 0)
                    . '% compared to its normal sizing. Sizing up after a loss means each additional mistake costs more than the one before it, the opposite of what protects a drawdown-limited account.'];
        }

        if ($group['avg_checklist_skip_rate'] !== null && $group['avg_checklist_skip_rate'] > 30) {
            $candidates[] = ['label' => 'Ignored Pre-Trade Checklist', 'icon' => '☑', 'impact' => (int)min(100, round($group['avg_checklist_skip_rate'] * 0.6)),
                'detail' => number_format($group['avg_checklist_skip_rate'], 0) . '% of this account\'s trades were taken without fully completing the strategy\'s own pre-trade checklist. Skipping the checklist removes the one built-in check that a setup actually met the plan before risking real capital.'];
        }

        usort($candidates, fn($a, $b) => $b['impact'] <=> $a['impact']);
        return array_slice($candidates, 0, 5);
    }

    /**
     * Six real, computed risk/behavior tiles — every value drawn from this
     * account's own pooled trade history, never a portfolio average.
     * Shared by both lenses (renamed from failureRiskBehaviorTiles()
     * 2026-08-07, when Lessons From Success (Single Account) got its own
     * "Risk & Behavior Insights" card reusing this exact same computation
     * — none of the underlying math changed). $successMode (default
     * false, so the Failure call site above is byte-identical to before)
     * only changes which CSS tone label a tile reports: on the success
     * page a real but non-alarming figure (e.g. this account did take a
     * few over-plan-risk trades along the way) renders in this app's
     * existing gold "notable" accent rather than red "breach" — never
     * hidden, never repainted as if it hadn't happened, just not styled
     * to alarm on a page whose whole point is what worked.
     */
    private static function riskBehaviorTiles(array $rows, array $group, int $dailyLossBreachDays, int $totalDays, bool $successMode = false): array
    {
        $pooled = $group['pooled'];
        $declared = $pooled['avg_declared_risk_pct'];
        $actual = $pooled['avg_actual_risk_pct'];
        // §2 Live #7 fix (2026-08-10) — was a raw `$actual > $declared`
        // strict inequality, zero tolerance band, while segmentMetrics()
        // elsewhere in this exact file (see its own §18/Wave 5c comment
        // above) applies AnalyticsEngine::calculatorRiskTolerance() before
        // ever calling a trade "over plan." Reused here, not reinvented —
        // no per-trade instrument context at this pooled-average level, so
        // this calls the same tolerance function without an instrument
        // (its own documented global fallback), same as the pooled-average
        // comparison already does everywhere else this exact tolerance
        // question comes up without a single instrument to point at.
        $avgRiskTolerance = $declared !== null ? AnalyticsEngine::calculatorRiskTolerance($declared) : null;
        $aboveActual = ($declared !== null && $actual !== null && $avgRiskTolerance !== null && $actual > $declared + $avgRiskTolerance);

        $elevatedCount = 0; $overRiskCount = 0;
        if ($declared !== null && $declared > 0) {
            foreach ($rows as $r) {
                $t = $r['trade'];
                $rp = $t['risk_pct'] ?? null;
                if ($rp === null) continue;
                $rp = (float)$rp;
                // 1.5x is a genuinely different, wider "elevated risk spike"
                // bar than the near-target adherence question below — not
                // itself part of this finding (it isn't the "Above Plan"
                // claim), left as-is.
                if ($rp >= $declared * 1.5) $elevatedCount++;
                // §2 Live #7 fix (2026-08-10) — this per-trade loop used a
                // flat 1.2x multiplier with no per-asset-class tolerance and
                // no Contract-Constrained exclusion, both of which
                // segmentMetrics() already applies for the exact same
                // question (declared vs. actual risk) a few hundred lines
                // above — the identical technique is reused here, per-trade,
                // rather than reinvented: a trade whose declared target was
                // never mathematically reachable at this instrument's
                // broker-minimum lot size is excluded outright (a structural
                // instrument limitation, not over-risking), and every other
                // trade gets the same real, per-instrument tolerance band
                // instead of a flat multiplier that a lot-size-quantization
                // rounding artifact could trivially cross.
                $instrument = $t['instrument'] ?? null;
                $tradeTolerance = AnalyticsEngine::calculatorRiskTolerance($declared, $instrument);
                $entryP = (isset($t['entry_price']) && $t['entry_price'] !== null && $t['entry_price'] !== '') ? (float)$t['entry_price'] : null;
                $slP = (isset($t['sl_price']) && $t['sl_price'] !== null && $t['sl_price'] !== '') ? (float)$t['sl_price'] : null;
                $balAtEntry = (isset($t['balance_at_entry']) && $t['balance_at_entry'] !== null && $t['balance_at_entry'] !== '') ? (float)$t['balance_at_entry'] : null;
                $minAchievable = AnalyticsEngine::minAchievableRiskPct($instrument, $entryP, $slP, $balAtEntry);
                $contractConstrained = $minAchievable !== null && $tradeTolerance !== null && $minAchievable > $declared + $tradeTolerance;
                if (!$contractConstrained && $tradeTolerance !== null && $rp > $declared + $tradeTolerance) $overRiskCount++;
            }
        }
        $overRiskPct = $pooled['declared_adherence_rate'] !== null ? round(100 - $pooled['declared_adherence_rate'], 1) : null;

        $n = count($rows);
        $running = 0.0; $peakRunning = 0.0; $inDrawdownCount = 0;
        foreach ($rows as $r) {
            $running += $r['pnl'];
            if ($running > $peakRunning) $peakRunning = $running;
            if ($running < $peakRunning) $inDrawdownCount++;
        }
        $timeInDrawdownPct = $n > 0 ? round($inDrawdownCount / $n * 100, 1) : null;

        $watchTone = $successMode ? 'accent' : 'negative';
        $cleanTone = $successMode ? 'positive' : 'neutral';

        return [
            ['label' => 'Avg Risk Per Trade', 'value' => $actual !== null ? number_format($actual, 2) . '%' : '-',
                'note' => $declared !== null ? ($aboveActual ? 'Above Plan (' . number_format($declared, 2) . '%)' : 'Within Plan (' . number_format($declared, 2) . '%)') : null,
                'tone' => $aboveActual ? $watchTone : $cleanTone],
            // §2 Live #5 fix (2026-08-10) — both of these tiles hardcoded
            // 'negative' in failure mode (!$successMode) regardless of the
            // real value, unlike every other tile in this function (and
            // unlike their own success-mode branch, which already
            // correctly derives tone from the real number) — a genuinely
            // clean number (0 elevated-risk trades, a 0/1/2-length loss
            // streak) still rendered red. $watchTone/$cleanTone already
            // encode the correct failure-mode-vs-success-mode color for
            // "real problem" vs. "genuinely clean" (negative/neutral vs.
            // accent/positive) — reused here instead of a second literal.
            ['label' => 'Highest Risk Taken', 'value' => $pooled['max_risk_spike'] !== null ? number_format($pooled['max_risk_spike'], 2) . '%' : '-',
                'note' => $elevatedCount > 0 ? $elevatedCount . ' Trade' . ($elevatedCount === 1 ? '' : 's') . ' at Elevated Risk' : null,
                'tone' => $elevatedCount > 0 ? $watchTone : $cleanTone],
            ['label' => 'Consecutive Losing Streak', 'value' => (string)($pooled['max_loss_streak'] ?? 0),
                'note' => 'Longest run of losses on this account',
                'tone' => ($pooled['max_loss_streak'] ?? 0) >= 3 ? $watchTone : $cleanTone],
            ['label' => 'Daily Loss Limit Breaches', 'value' => (string)$dailyLossBreachDays,
                'note' => $totalDays > 0 ? number_format($dailyLossBreachDays / $totalDays * 100, 1) . '% of total trading days' : null,
                'tone' => $dailyLossBreachDays > 0 ? $watchTone : $cleanTone],
            ['label' => 'Trades Over Plan Risk', 'value' => $overRiskCount . ' Trade' . ($overRiskCount === 1 ? '' : 's'),
                'note' => $overRiskPct !== null ? number_format($overRiskPct, 1) . '% of total trades' : null,
                'tone' => $overRiskCount > 0 ? $watchTone : $cleanTone],
            ['label' => 'Time in Drawdown', 'value' => $timeInDrawdownPct !== null ? $timeInDrawdownPct . '%' : '-',
                'note' => 'Share of trades below this account\'s own running peak', 'tone' => 'neutral'],
        ];
    }

    /**
     * Item 9 (2026-08-06 refinement pass) — the real account-preservation
     * math behind "how many losses like this could the account have
     * survived." Built from the account's own real closing losses (never
     * an assumed/typical loss size) against its own real configured
     * drawdown budget — the exact same $accountSize/$maxDrawdownDollar
     * relationship already established across this whole feature
     * (rnAccountPreservationState()/renderRnTradeTable()'s State column,
     * rnComputeRiskRecommendation()'s Recommended Risk tile). Returns
     * null — never a guessed number — when there isn't at least one real
     * loss or a real configured drawdown budget to compute this from.
     */
    private static function accountPreservationSurvivalNote(array $rows, ?float $maxDdDollarConfigured): ?array
    {
        if ($maxDdDollarConfigured === null || $maxDdDollarConfigured <= 0) return null;
        $losses = [];
        foreach ($rows as $r) { if ($r['pnl'] < -0.005) $losses[] = abs($r['pnl']); }
        if (empty($losses)) return null;
        $avgLoss = array_sum($losses) / count($losses);
        if ($avgLoss <= 0) return null;
        return [
            'avg_loss' => $avgLoss,
            'loss_count' => count($losses),
            'survived_at_current' => (int)floor($maxDdDollarConfigured / $avgLoss),
            'survived_at_half' => (int)floor($maxDdDollarConfigured / ($avgLoss / 2)),
        ];
    }

    /**
     * Item 11 (2026-08-06, second refinement pass) — the ONE shared
     * source of truth both Final Coaching Before Breach and Could This
     * Breach Have Been Avoided? reason from, per Kevin's own explicit
     * instruction: an internal computation model, never its own UI
     * section. Reconstructs Active Coaching's real Recommended Risk
     * system (public/journal.php's rnComputeRiskRecommendation() — the
     * exact same function Right Now's coaching strip calls, not a second
     * implementation) from the account's own real drawdown-budget
     * consumption, real losing streak, and real recent tilt/revenge/
     * oversized-risk count, replayed up to (not including) the final,
     * breaching trade. Returns null only when there's no trade history
     * or the shared function genuinely isn't available.
     */
    private static function preBreachRiskSnapshot(array $rows, array $story, float $ddReferenceBalance, ?float $maxDdDollarConfigured, string $accountId, string $userId, $db): ?array
    {
        $n = count($rows);
        if ($n === 0 || !function_exists('rnComputeRiskRecommendation')) return null;

        // The account's own real running balance right before the final
        // trade (story[n-2] — one entry earlier than the breach itself),
        // measured against its own configured drawdown floor. $ddReferenceBalance
        // is the caller's already-model-aware reference balance (2026-08-17
        // — account_size for Fixed, the terminal phase's own real peak for
        // Trailing, see buildAccountFailureJourney()'s own $ddReferenceBalance
        // comment) — this parameter used to be a flat $accountSize only,
        // silently wrong for Trailing accounts the same way the rest of
        // this file's breach-floor math was before that fix.
        $balanceBeforeFinal = $n >= 2 ? $story[$n - 2]['balance'] : $ddReferenceBalance;
        $ddUsedRatio = null;
        if ($maxDdDollarConfigured !== null && $maxDdDollarConfigured > 0) {
            $ddUsedRatio = max(0.0, $ddReferenceBalance - $balanceBeforeFinal) / $maxDdDollarConfigured;
        }

        $priorRows = array_slice($rows, 0, $n - 1);
        $streak = 0;
        for ($i = count($priorRows) - 1; $i >= 0; $i--) {
            if ($priorRows[$i]['pnl'] < -0.005) { $streak--; } else { break; }
        }
        $recentTrades = [];
        foreach (array_slice($priorRows, -10) as $r) {
            $recentTrades[] = ['classification' => $r['trade']['trade_classification'] ?? null, 'pnl' => $r['pnl']];
        }
        $nearFailure = $ddUsedRatio !== null && $ddUsedRatio >= 0.9;

        $rec = rnComputeRiskRecommendation($accountId, $userId, $db, [
            'dd_used_ratio' => $ddUsedRatio ?? 0.0, 'streak' => $streak, 'near_failure' => $nearFailure, 'recent_trades' => $recentTrades,
        ]);

        $finalTrade = $rows[$n - 1]['trade'];
        $actualRiskPct = isset($finalTrade['risk_pct']) && $finalTrade['risk_pct'] !== null && $finalTrade['risk_pct'] !== '' ? (float)$finalTrade['risk_pct'] : null;
        // A meaningful gap, not just any overage — reuses the same 20%
        // relative-tolerance convention already established elsewhere in
        // this codebase (Reports' per-asset-class calculator tolerance)
        // rather than flagging a trivial few-hundredths-of-a-percent
        // rounding difference as "the deciding factor."
        $riskExceededRecommendation = $actualRiskPct !== null && $rec['recommended'] > 0 && $actualRiskPct > $rec['recommended'] * 1.2;

        return [
            'rec' => $rec, 'dd_used_ratio' => $ddUsedRatio, 'streak_before_final' => $streak,
            'final_trade' => $finalTrade, 'actual_risk_pct' => $actualRiskPct,
            'risk_exceeded_recommendation' => $riskExceededRecommendation,
        ];
    }

    /**
     * "Could this breach have been avoided?" — item 11, second refinement
     * pass (2026-08-06). Kevin's own explicit correction: this card was
     * previously reasoning backwards ("not clearly avoidable" whenever no
     * single rule-crossing was found), when the real, useful question is
     * always "what action, taken before or during this journey, would
     * most likely have prevented this breach" — every real breach has a
     * real answer to that question, so this never concludes "not
     * avoidable" anymore (the true n=0/no-history case is the sole
     * exception — there's nothing to reason about at all). Reasons from
     * the SAME $snapshot Final Coaching Before Breach reads (see
     * preBreachRiskSnapshot() above), interpreted from a different
     * angle: that card explains WHAT was recommended and WHETHER it was
     * followed; this card explains HOW closely following it would have
     * changed the actual outcome, in real dollars.
     */
    private static function failureAvoidability(array $rows, array $story, array $byDay, ?string $breachDay, ?float $dailyLossDollar, float $accountSize, ?float $profitTargetDollar, int $n, ?float $maxDdDollarConfigured, ?array $snapshot): array
    {
        if ($n === 0) {
            return ['verdict' => false, 'headline' => 'Not enough history to say.', 'explanation' => 'This account has no closed trades on record.', 'calc_note' => null];
        }

        // Case A — an earlier trade on the SAME calendar day as the
        // breach already crossed the daily loss limit before the final
        // trade was placed. The most concrete, mechanical form of
        // avoidability: a hard rule was already crossed.
        if ($n >= 2 && $dailyLossDollar !== null && $breachDay !== null && isset($byDay[$breachDay]) && count($byDay[$breachDay]) >= 2) {
            $dayRows = $byDay[$breachDay]; // chronological, same order as $story
            $cum = 0.0; $stopIdx = null;
            foreach ($dayRows as $i => $s) {
                $cum += $s['pnl'];
                if ($cum <= -$dailyLossDollar && $i < count($dayRows) - 1) { $stopIdx = $i; break; }
            }
            if ($stopIdx !== null) {
                $stopRow = $story[$dayRows[$stopIdx]['idx']];
                $wouldBeDdPct = $accountSize > 0 ? ($stopRow['peak'] - $stopRow['balance']) / $accountSize * 100 : null;
                $wouldBePnl = $stopRow['balance'] - $accountSize;
                $distanceIfStopped = $profitTargetDollar !== null ? $profitTargetDollar - $wouldBePnl : null;
                return [
                    'verdict' => true,
                    'headline' => 'Stopping after the daily loss limit was reached would have prevented this breach.',
                    'explanation' => 'The daily loss limit was already crossed earlier that same day, before the trade that actually ended the account. Had trading stopped at that point, the account would still be active today, with a drawdown of '
                        . ($wouldBeDdPct !== null ? number_format(abs($wouldBeDdPct), 2) . '%' : 'a smaller drawdown')
                        . ($distanceIfStopped !== null ? ' and $' . number_format(abs($distanceIfStopped), 2) . ' closer to its profit target' : '') . '.',
                    'calc_note' => 'Calculated by replaying this account\'s own trades up to the exact point the daily loss limit was first crossed on the breach day, then comparing that balance against what actually happened afterward.',
                ];
            }
        }

        // Case B — the final trade was itself a real revenge/tilt/
        // oversized-risk trade taken directly after a loss.
        $breachTrade = $rows[$n - 1]['trade'];
        $breachClass = $breachTrade['trade_classification'] ?? null;
        if ($n >= 2 && in_array($breachClass, ['revenge_trade', 'tilt_warning', 'loss_high_risk'], true) && $rows[$n - 2]['pnl'] < 0) {
            return [
                'verdict' => true,
                'headline' => 'Waiting before re-entering after that loss would have prevented this breach.',
                'explanation' => 'The final trade was classified "' . rnClassificationNoun($breachClass) . '" and was placed directly after a loss, without a pause between them. A cooldown before re-entering, rather than immediately taking another trade, was the recommended action at that point, and would most likely have kept this specific trade from happening at all.',
                'calc_note' => 'Based on this trade\'s own real classification and its position directly after a losing trade in this account\'s history.',
            ];
        }

        // Case C — the general case (covers a first-trade breach too):
        // position sizing itself was the deciding factor. Scales the
        // breach trade's own real loss down by the ratio between the
        // risk % actually used and the recommended risk % from the
        // shared snapshot — the same "scale two already-known real
        // numbers" technique already established throughout this
        // codebase, never a fabricated formula.
        if ($snapshot !== null && $snapshot['risk_exceeded_recommendation']) {
            $recommended = $snapshot['rec']['recommended'];
            $actualRiskPct = $snapshot['actual_risk_pct'];
            $actualLoss = abs($rows[$n - 1]['pnl']);
            $hypotheticalLoss = $actualLoss * ($recommended / $actualRiskPct);
            $multiple = $actualRiskPct / max(0.01, $recommended);

            $explanation = 'This trade risked ' . number_format($actualRiskPct, 2) . '% of the account'
                . ($multiple >= 2 ? ', about ' . number_format($multiple, 1) . 'x TraderAlign\'s own recommended ' . number_format($recommended, 2) . '% at that point' : ', above TraderAlign\'s own recommended ' . number_format($recommended, 2) . '% at that point')
                . '. At the recommended size, this same losing trade would have cost approximately $' . number_format($hypotheticalLoss, 2) . ' instead of the $' . number_format($actualLoss, 2) . ' it actually lost.';

            if ($maxDdDollarConfigured !== null && $maxDdDollarConfigured > 0) {
                if ($hypotheticalLoss < $maxDdDollarConfigured) {
                    $explanation .= ' That would have stayed comfortably inside the account\'s $' . number_format($maxDdDollarConfigured, 2) . ' drawdown budget, and the account would very likely still be active today, with room for many more trades.';
                } else {
                    $explanation .= ' Even at that recommended size this trade alone would have used a large share of the account\'s $' . number_format($maxDdDollarConfigured, 2) . ' drawdown budget, though it would not have ended the account on this trade by itself.';
                }
            }

            return [
                'verdict' => true,
                'headline' => 'Trading within TraderAlign\'s recommended risk framework would have prevented this breach.',
                'explanation' => $explanation,
                'calc_note' => 'Calculated by scaling this trade\'s own real loss down by the ratio between the risk % actually used and TraderAlign\'s recommended risk % at that exact point in the account\'s history: the same real stop-loss distance and position math the trade itself used, just at the recommended size instead.',
            ];
        }

        // Fallback — risk sizing on the final trade itself was already
        // reasonable; the real, computed survival math still gives an
        // honest, forward-looking answer to "what would have helped."
        $survival = self::accountPreservationSurvivalNote($rows, $maxDdDollarConfigured);
        if ($survival !== null && $maxDdDollarConfigured !== null) {
            $explanation = 'This account\'s average real loss was $' . number_format($survival['avg_loss'], 2) . ' against a $' . number_format($maxDdDollarConfigured, 2)
                . ' drawdown budget, enough for roughly ' . $survival['survived_at_current'] . ' loss(es) that size. It took ' . $survival['loss_count'] . ' real losing trade(s) to reach the breach. '
                . 'Reducing risk to half as soon as the account entered drawdown would have stretched that same budget to roughly ' . $survival['survived_at_half'] . ' loss(es) of that halved size, most likely enough to survive this stretch and reach a recovery.';
            return [
                'verdict' => true,
                'headline' => 'Reducing risk as soon as the account entered drawdown would most likely have prevented this breach.',
                'explanation' => $explanation,
                'calc_note' => 'Calculated by comparing this account\'s own real average loss size, and how many losses of that size its own real configured drawdown budget could absorb, against how many real losing trades it actually took before breaching.',
            ];
        }

        return [
            'verdict' => true,
            'headline' => 'A more conservative, evidence-based approach would most likely have prevented this breach.',
            'explanation' => 'This account\'s own trade history is the most complete record of what led to the breach. See Primary Reasons For The Breach and this account\'s own trades below for the specific evidence.',
            'calc_note' => null,
        ];
    }

    /**
     * One Account Rules & Thresholds column: target/limit vs. real
     * actual. $isLimit flips the "exceeded" framing — a loss LIMIT is
     * breached by going past it, a profit TARGET simply isn't reached
     * yet.
     *
     * Wording rebuilt in full (2026-08-06, fourth refinement pass) —
     * Kevin's own direct feedback on the prior fix: technically correct
     * but unreadable ("Target: No target required (declared 0%)" reads
     * like implementation metadata, not trading analytics — even the
     * developer who wrote it couldn't explain it back in plain English).
     * The underlying DATA was already correct (see the still-accurate
     * history in this function's git-tracked prior revisions); this pass
     * only changes how it's put into words, on three real principles
     * Kevin stated directly: (1) never expose "declared 0%"-style
     * internal storage language unless it genuinely helps the trader,
     * (2) never force a number into "target" framing when it isn't one —
     * $peakPnl/$actualDollar without a real target to measure against is
     * just a plain fact about the account, so it's now labeled as
     * exactly that ("Highest profit reached" / "Final result"), and (3)
     * "Target:"/"Limit:" prefixes are now baked directly into
     * target_text itself (a complete, standalone sentence every time)
     * rather than a hardcoded template prefix the caller can't control —
     * see taFailureGoalsProgress()'s own updated comment for the
     * matching render-side fix. $noTargetNoun lets each of the two
     * profit-target rows describe what the same $actualDollar figure
     * actually represents for that row (a period's high point vs. its
     * real final number) instead of one generic phrase trying to fit
     * both. New `show_bar` flag: a progress bar implies "progress toward
     * something" — hidden entirely when there's genuinely nothing to
     * measure progress against, rather than showing an arbitrary 0%- or
     * 100%-filled bar that has no real meaning.
     */
    private static function accountRuleRow(string $label, ?float $targetPct, ?float $targetDollar, float $actualDollar, ?float $pctOfTarget, bool $isLimit, string $noTargetNoun = 'Result'): array
    {
        $prefix = $isLimit ? 'Limit' : 'Target';
        // Real bug found and fixed (2026-08-07, discovered while adapting
        // this shared function for Lessons From Success): the
        // "genuinely no target/limit configured" branch below used to
        // discard $actualDollar entirely and print a bare, unexplained
        // "—" — even though the real number was already computed and sitting
        // right there (e.g. a funded account's real peak profit, or a
        // limitless account's real worst single-day loss). That's the
        // exact "never show an unexplained placeholder when a real value
        // exists" failure mode this whole feature otherwise avoids
        // carefully. Fixed to state the real number plainly, using the
        // same $noTargetNoun the caller already supplies for the
        // sibling "target exists but is a real declared 0%" case just
        // below — never a fabricated target, just the real fact that WAS
        // computed. Applies identically to both lenses (Lessons From
        // Failure's own callers below now pass a real noun for the two
        // limit rows too, instead of the prior generic default).
        if ($targetDollar === null) {
            if ($isLimit) {
                $actualText = abs($actualDollar) < 0.005
                    ? $noTargetNoun . ': $0.00'
                    : $noTargetNoun . ': -$' . number_format(abs($actualDollar), 2);
                $state = 'neutral';
            } else {
                $sign = $actualDollar >= 0 ? '+$' : '-$';
                $actualText = $noTargetNoun . ': ' . $sign . number_format(abs($actualDollar), 2);
                $state = $actualDollar >= 0 ? 'positive' : 'negative';
            }
            return [
                'label' => $label,
                'target_text' => $isLimit ? 'No loss limit is configured for this account.' : 'No profit target is configured for this account.',
                'actual_text' => $actualText, 'pct_text' => null, 'bar_pct' => 0, 'state' => $state, 'show_bar' => false,
            ];
        }
        if (!$isLimit && $targetPct !== null && $targetPct <= 0) {
            $sign = $actualDollar >= 0 ? '+$' : '-$';
            return [
                'label' => $label,
                'target_text' => 'No profit target applies to this account.',
                'actual_text' => $noTargetNoun . ': ' . $sign . number_format(abs($actualDollar), 2),
                'pct_text' => null, 'bar_pct' => 0,
                'state' => $actualDollar >= 0 ? 'positive' : 'negative', 'show_bar' => false,
            ];
        }
        $exceeded = $isLimit ? (abs($actualDollar) >= $targetDollar) : ($actualDollar >= $targetDollar);
        $state = $isLimit ? ($exceeded ? 'negative' : 'neutral') : ($exceeded ? 'positive' : ($actualDollar < 0 ? 'negative' : 'neutral'));
        $barPct = $targetDollar > 0 ? min(100, abs($actualDollar) / $targetDollar * 100) : 0;
        $actualLabel = $isLimit
            ? ($exceeded ? 'Exceeded: -$' . number_format(abs($actualDollar), 2) : 'Used: -$' . number_format(abs($actualDollar), 2))
            : ($exceeded ? 'Achieved $' . number_format($actualDollar, 2) : ($actualDollar < 0 ? '-$' . number_format(abs($actualDollar), 2) : '$' . number_format($actualDollar, 2)));
        return [
            'label' => $label,
            'target_text' => $prefix . ': $' . number_format($targetDollar, 2) . ' (' . number_format($targetPct ?? 0, 0) . '%)',
            'actual_text' => $actualLabel,
            'pct_text' => $pctOfTarget !== null ? number_format($pctOfTarget, 1) . '%' : null,
            'bar_pct' => $barPct, 'state' => $state, 'show_bar' => true,
        ];
    }

    /**
     * Profit Target / Max Daily Loss / Max Overall Loss / Final Net P&L
     * — four real columns, computed before the breach (peak profit
     * reached) alongside the real final outcome. Renamed from "Net P&L
     * Target" (2026-08-06, fourth refinement pass) — this row was never
     * actually measuring progress toward a second target; it reuses the
     * SAME profit target as the Profit Target column, just paired with
     * the account's real FINAL number instead of its peak. Calling it a
     * "target" implied a second, distinct goal that doesn't exist —
     * "Final Net P&L" states plainly what the number in this column
     * actually is.
     */
    private static function failureGoalsProgress(array $story, float $accountSize, ?float $profitTargetPct, ?float $profitTargetDollar, ?float $dailyLossPct, ?float $dailyLossDollar, float $worstDayLoss, ?float $maxDdPctConfigured, ?float $maxDdDollarConfigured, float $maxDdDollarActual, float $totalPnl): array
    {
        $peakPnl = 0.0;
        foreach ($story as $s) { $p = $s['balance'] - $accountSize; if ($p > $peakPnl) $peakPnl = $p; }

        return [
            self::accountRuleRow('Profit Target', $profitTargetPct, $profitTargetDollar, $peakPnl,
                $accountSize > 0 ? $peakPnl / $accountSize * 100 : null, false, 'Highest profit reached'),
            self::accountRuleRow('Max Daily Loss', $dailyLossPct, $dailyLossDollar, -abs($worstDayLoss),
                ($dailyLossDollar !== null && $dailyLossDollar > 0) ? -(abs($worstDayLoss) / $dailyLossDollar * 100) : null, true, 'Largest daily loss recorded'),
            self::accountRuleRow('Max Overall Loss', $maxDdPctConfigured, $maxDdDollarConfigured, -$maxDdDollarActual,
                ($maxDdDollarConfigured !== null && $maxDdDollarConfigured > 0) ? -($maxDdDollarActual / $maxDdDollarConfigured * 100) : null, true, 'Largest drawdown recorded'),
            self::accountRuleRow('Final Net P&L', $profitTargetPct, $profitTargetDollar, $totalPnl,
                $accountSize > 0 ? $totalPnl / $accountSize * 100 : null, false, 'Final result'),
        ];
    }

    /**
     * "Final Coaching Before Breach" — item 11, second refinement pass
     * (2026-08-06). A concise, past-tense NARRATIVE (not a bullet dump of
     * live coaching instructions copied onto the page) reading from the
     * SAME $snapshot preBreachRiskSnapshot() computed — this card's job
     * is specifically "what was TraderAlign telling you before this
     * happened, and was it followed," a historical-summary question
     * distinct from Could This Breach Have Been Avoided?'s counterfactual
     * one. Every sentence states a real recommendation that existed
     * against what actually happened, then closes by making explicit
     * that it was available but not followed — never a generic "protect
     * your capital" filler line.
     */
    private static function failureCoaching(array $rows, array $byDay, ?string $breachDay, ?float $dailyLossDollar, ?array $snapshot): string
    {
        $n = count($rows);
        if ($n === 0) return 'This account has no closed trades to reconstruct coaching from.';
        if ($snapshot === null) return 'Not enough account configuration is on record to reconstruct a specific recommendation for this trade.';

        $rec = $snapshot['rec'];
        $recommended = $rec['recommended'];
        $actualRiskPct = $snapshot['actual_risk_pct'];
        $sentences = [];

        // The primary sentence — what was recommended vs. what was
        // actually risked on the trade that ended the account.
        if ($snapshot['risk_exceeded_recommendation']) {
            $sentences[] = 'Before this trade, TraderAlign had already recommended risking no more than ' . number_format($recommended, 2)
                . '% per trade' . ($rec['overriding_declared'] ? ', reduced from the declared ' . number_format($rec['declared'], 2) . '% because of the account\'s real drawdown at that point' : '')
                . ', but the trade was instead placed at ' . number_format($actualRiskPct, 2) . '%.';
        } elseif ($rec['overriding_declared']) {
            $sentences[] = 'Before this trade, TraderAlign had already recommended reducing risk to about ' . number_format($recommended, 2) . '% per trade because of the account\'s real drawdown at that point.';
        } else {
            $sentences[] = 'Before this trade, the account\'s risk sizing was within TraderAlign\'s recommended range. The recommendation in place was to continue at the declared ' . ($rec['declared'] !== null ? number_format($rec['declared'], 2) . '%' : number_format($recommended, 2) . '%') . ' per trade.';
        }

        // Daily-loss-protection context — was the daily limit already
        // active (crossed by an EARLIER trade the same day) before this
        // final trade was placed.
        if ($dailyLossDollar !== null && $breachDay !== null && isset($byDay[$breachDay]) && count($byDay[$breachDay]) >= 2) {
            $dayRows = $byDay[$breachDay];
            $cum = 0.0; $alreadyActive = false;
            foreach ($dayRows as $i => $s) {
                if ($i === count($dayRows) - 1) break; // don't count the final trade itself
                $cum += $s['pnl'];
                if ($cum <= -$dailyLossDollar) { $alreadyActive = true; break; }
            }
            if ($alreadyActive) $sentences[] = 'TraderAlign\'s daily loss protection had already been triggered earlier that same day. Trading continued past that point regardless.';
        }

        // Checklist state on the FINAL trade specifically — not a
        // whole-account average.
        $finalTrade = $snapshot['final_trade'];
        $tickedPct = $finalTrade['ticked_pct'] ?? null;
        if ($tickedPct === null || $tickedPct === '') {
            $sentences[] = 'No pre-trade checklist was completed before this trade. TraderAlign had no confirmation the setup matched the declared strategy.';
        } elseif ((float)$tickedPct < 100) {
            $sentences[] = 'The strategy\'s own pre-trade checklist was only ' . round((float)$tickedPct) . '% complete on this trade. TraderAlign had already flagged the setup as not fully validated before it was executed.';
        }

        $sentences[] = 'These recommendations existed at the time this trade was placed, but were not followed.';
        return implode(' ', $sentences);
    }

    /** Trailing window of this account's real trades ending at the breach — reuses successTradeImpactStars()'s exact scoring (the brief's own instruction: the star logic stays the same across active, live, funded, and breached accounts), each row flagged is_breach_trade so the caller can render the final one distinctly. */
    private static function failureEvidenceTrades(array $rows, ?string $breachTradeId): array
    {
        $n = count($rows);
        $window = array_slice($rows, max(0, $n - 8), 8, true);
        $out = [];
        foreach ($window as $idx => $r) {
            $t = $r['trade'];
            $rMultiple = self::tradeRMultiple($t, $r['pnl']);
            $classification = $t['trade_classification'] ?? null;
            $out[] = [
                'number' => $idx + 1, 'id' => $t['id'] ?? null,
                'date' => $t['closed_at'] ?? $t['captured_at'] ?? null,
                'instrument' => strtoupper((string)($t['instrument'] ?? '')),
                'session' => $t['session'] ?? null,
                'direction' => $t['direction'] ?? null,
                'planned_rr' => (($t['risk_reward_tp1'] ?? null) !== null && $t['risk_reward_tp1'] !== '') ? (float)$t['risk_reward_tp1'] : null,
                'pnl' => $r['pnl'], 'r_multiple' => $rMultiple,
                'stars' => self::successTradeImpactStars($rMultiple, $classification),
                'is_breach_trade' => $breachTradeId !== null && ($t['id'] ?? null) === $breachTradeId,
            ];
        }
        return $out; // chronological — oldest of the window first, breach trade last
    }

    // ════════════════════════════════════════════════════════════════════
    // LIVE ACCOUNTS — single-account extras (2026-08-06)
    // ────────────────────────────────────────────────────────────────
    // Everything below is exclusive to the Live Accounts branch of the
    // single-account "Lessons From Success" rebuild, matching Kevin's
    // approved prototype. Nothing here is called from, or changes the
    // behavior of, the Prop Firms branch (accountLevels()/
    // strongestSuccessHabits()/fundedGoalAchievement()/etc. above are
    // completely untouched by this section beyond the two small,
    // Live-scoped edits made to gatherLiveSegments()/gatherSuccessAccounts()
    // /accountStageInfo() further up this file).
    //
    // Real, disclosed data constraint that shapes several methods below:
    // `lifecycle_events` records THAT a deposit/withdrawal happened and
    // WHEN, but never the dollar amount of that specific event — only the
    // account's running `net_deposits`/`net_withdrawals` totals exist.
    // Individual-event dollar amounts used below (equityCurve's merged
    // timeline, financial behaviour breakdowns) are therefore each
    // event's fair share of that real total — `net_deposits ÷ deposit
    // event count`, etc. — never an invented number: the SUM across every
    // event is always exactly the real total, only the per-event split is
    // an even-share approximation. Confirmed live before building this
    // (see the session's own reconciliation check) that
    // current_balance = starting_balance + trade P&L + net_deposits −
    // net_withdrawals holds exactly for the large majority of real
    // accounts — the few that don't are pre-existing test/demo data drift,
    // not something introduced here.
    // ════════════════════════════════════════════════════════════════════

    /**
     * Portfolio-wide census — REMOVED from this single-account page
     * (2026-08-06 correction): "Total Blown Accounts" / "Total Closed
     * Accounts" don't belong here at all — a single selected account
     * can't itself have "10 blown accounts," and every other number on
     * this page describes only the one selected account, never the
     * trader's whole portfolio. Kept defined (not deleted) per this
     * codebase's additive-only convention, in case a future GROUPED Live
     * Accounts archive page — a genuinely different page from this one —
     * ever wants a real portfolio census; not called from anywhere in
     * the current single-account build.
     *
     * "Blown" is defined conservatively as a closed account whose ending
     * balance is at or below 1% of its own starting capital — a genuine
     * wipeout, not merely "closed at a loss" (a modest loss is a normal,
     * non-catastrophic outcome and shouldn't inflate this count). "Closed
     * Accounts" is every OTHER closed account (not blown) — so the two
     * counts never double-count the same account.
     */
    public static function liveAccountPortfolioCounts(string $userId, $db): array
    {
        // Validation Workflow (2026-08-18 correction) — kept correct even
        // though currently unreachable, matching this codebase's own
        // "orphaned but not left wrong" convention.
        $rows = $db->raw(
            "SELECT starting_balance, current_balance, COALESCE(initial_deposit, starting_balance) AS base
             FROM trading_accounts
             WHERE user_id = :uid AND deleted_at IS NULL AND account_type = 'personal' AND lifecycle_state = 'closed' AND workflow_context = :wfctx",
            [':uid' => $userId, ':wfctx' => ValidationService::getWorkflowState($userId, $db)]
        );
        $blown = 0; $closed = 0;
        foreach ($rows as $r) {
            $base = (float)($r['base'] ?? $r['starting_balance'] ?? 0);
            $current = (float)($r['current_balance'] ?? 0);
            if ($base > 0 && $current <= $base * 0.01) $blown++;
            else $closed++;
        }
        return ['total_blown' => $blown, 'total_closed' => $closed];
    }

    /**
     * Account Age in raw real days — for a still-active account, measured
     * through to right now (never a stale "as of last trade" date); for a
     * closed account, its real fixed lifespan. Returns raw days rather
     * than a decimal year count (2026-08-06 correction — a bare "0.1
     * Years" is exactly the kind of fabricated-precision decimal this
     * codebase's own established convention rejects everywhere else; see
     * formatDurationNatural()'s own doc comment). The caller formats this
     * with formatDurationNatural(), the same minute/hour/day/week/month/
     * year compositional formatter already used elsewhere on this page
     * for Average Time To Goal, so "90 days" reads as "3 months", "365
     * days" reads as "1 year", etc. — never a raw fractional-year number.
     */
    private static function liveAccountAgeDays(string $createdAt, string $endDate): ?float
    {
        $start = strtotime($createdAt);
        $end = strtotime($endDate);
        if ($start === false || $end === false || $end < $start) return null;
        return ($end - $start) / 86400;
    }

    /**
     * Real, single-account-only KPI facts (2026-08-06 correction) —
     * replaces the removed portfolio-wide "Total Blown/Closed Accounts"
     * cards with facts about THIS account alone: Current Drawdown (real
     * distance from this account's own real historical peak equity, not
     * the all-time MAX drawdown segmentMetrics() already tracks — that's
     * "how bad did it ever get," this is "how far under water is it
     * right now"), Largest Win, Largest Loss, Winning/Losing trade
     * counts (already in $group['pooled'] elsewhere, exposed here too
     * for a single call site). All computed from this account's own real
     * rows — nothing pooled across other accounts.
     */
    private static function liveAccountSnapshot(array $segment): array
    {
        $rows = $segment['rows'];
        $peakEquity = (float)($segment['peak_equity'] ?? $segment['current_balance']);
        $currentBalance = (float)$segment['current_balance'];
        $currentDrawdownPct = $peakEquity > 0 ? round(max(0, ($peakEquity - $currentBalance) / $peakEquity) * 100, 2) : null;

        $largestWin = null; $largestLoss = null; $winCount = 0; $lossCount = 0;
        foreach ($rows as $r) {
            $pnl = $r['pnl'];
            if ($pnl > 0.01) { $winCount++; if ($largestWin === null || $pnl > $largestWin) $largestWin = $pnl; }
            elseif ($pnl < -0.01) { $lossCount++; if ($largestLoss === null || $pnl < $largestLoss) $largestLoss = $pnl; }
        }

        return [
            'current_drawdown_pct' => $currentDrawdownPct,
            'largest_win' => $largestWin, 'largest_loss' => $largestLoss,
            'winning_trades' => $winCount, 'losing_trades' => $lossCount,
        ];
    }

    /**
     * Real deposit/withdrawal EVENT timestamps for this one account
     * (lifecycle_events — no dollar amount stored per event, see this
     * section's own header comment). Only CAPITAL_ADDED counts as a
     * "deposit" here — PARTIAL_WITHDRAWAL is the one "withdrawal" event.
     * INITIAL_DEPOSIT (the account's original funding) is deliberately
     * excluded (2026-08-06 fix): trading_accounts.net_deposits — the
     * only source this class has for each deposit's real dollar amount,
     * since individual events carry no $ value — already excludes the
     * initial funding by this schema's own convention (initial_deposit
     * is a separate column). Counting INITIAL_DEPOSIT as a "deposit"
     * while sourcing its dollar total from net_deposits produced a real,
     * confirmed bug: an account with no post-funding deposits at all
     * showed "1 deposit, $0.00" instead of the honest "no deposits yet"
     * — the count and the dollar figure were answering two different
     * questions. Excluding it also makes sense on its own terms: opening
     * an account isn't a "deposit behavior" decision the way adding more
     * capital later is — there's no prior trading history to be
     * reacting to yet.
     */
    private static function liveFetchCapitalEvents(string $accountId, $db): array
    {
        $rows = $db->raw(
            "SELECT event_type, occurred_at FROM lifecycle_events
             WHERE account_id = :aid AND event_type IN ('CAPITAL_ADDED', 'PARTIAL_WITHDRAWAL')
             ORDER BY occurred_at ASC",
            [':aid' => $accountId]
        );
        $deposits = []; $withdrawals = [];
        foreach ($rows as $r) {
            if ($r['event_type'] === 'PARTIAL_WITHDRAWAL') $withdrawals[] = $r['occurred_at'];
            else $deposits[] = $r['occurred_at'];
        }
        return ['deposits' => $deposits, 'withdrawals' => $withdrawals];
    }

    /**
     * Real, merged equity curve — trade P&L events (real $, real
     * timestamp) interleaved chronologically with deposit/withdrawal
     * events (each event's fair share of the real net_deposits/
     * net_withdrawals total — see this section's header comment — real $,
     * real timestamp). Sampled at each real calendar-year boundary from
     * account creation through to "today" (or account closure), matching
     * the approved prototype's own year-labeled x-axis. The FINAL point
     * is always forced to the account's real, known current_balance
     * exactly, regardless of what the interior reconstruction computed —
     * the one number this chart must never get wrong is where the line
     * actually ends up today.
     */
    private static function liveEquityCurve(array $segment, $db): array
    {
        $rows = $segment['rows'];
        $capitalEvents = self::liveFetchCapitalEvents($segment['account_id'], $db);
        $depositShare = !empty($capitalEvents['deposits']) ? $segment['net_deposits'] / count($capitalEvents['deposits']) : 0.0;
        $withdrawalShare = !empty($capitalEvents['withdrawals']) ? $segment['net_withdrawals'] / count($capitalEvents['withdrawals']) : 0.0;

        $events = [];
        foreach ($rows as $r) {
            $ts = $r['trade']['captured_at'] ?? null;
            if ($ts === null) continue;
            $events[] = ['ts' => strtotime($ts), 'delta' => $r['pnl']];
        }
        foreach ($capitalEvents['deposits'] as $ts) { $events[] = ['ts' => strtotime($ts), 'delta' => $depositShare]; }
        foreach ($capitalEvents['withdrawals'] as $ts) { $events[] = ['ts' => strtotime($ts), 'delta' => -$withdrawalShare]; }
        usort($events, fn($a, $b) => $a['ts'] <=> $b['ts']);

        $startingBalance = $segment['account_size'];
        $createdTs = strtotime($segment['created_at'] ?? $segment['start_date']);
        $endTs = strtotime($segment['end_date']);
        if ($createdTs === false || $endTs === false || $endTs <= $createdTs) {
            return ['available' => false];
        }

        $startYear = (int) date('Y', $createdTs);
        $endYear = (int) date('Y', $endTs);
        $yearBoundaries = [];
        for ($y = $startYear + 1; $y <= $endYear; $y++) { $yearBoundaries[] = mktime(0, 0, 0, 1, 1, $y); }

        // A same-year-old account (created and still within its first
        // calendar year) produces zero year boundaries — real, common for
        // a brand-new account, and NOT a reason to plot only one point.
        // The final point (today/closure, real current_balance) is always
        // added explicitly below, so this chart always has at least a
        // real start-vs-end pair to draw a line between, regardless of
        // how young the account is.
        $running = $startingBalance;
        $eventIdx = 0;
        $points = [['label' => (string)$startYear, 'ts' => $createdTs, 'value' => $running]];
        foreach ($yearBoundaries as $boundaryTs) {
            if ($boundaryTs >= $endTs) break;
            while ($eventIdx < count($events) && $events[$eventIdx]['ts'] <= $boundaryTs) {
                $running += $events[$eventIdx]['delta'];
                $eventIdx++;
            }
            $points[] = ['label' => (string) date('Y', $boundaryTs), 'ts' => $boundaryTs, 'value' => $running];
        }
        while ($eventIdx < count($events) && $events[$eventIdx]['ts'] <= $endTs) {
            $running += $events[$eventIdx]['delta'];
            $eventIdx++;
        }
        // Guarantee: the very last plotted point is always the account's
        // real, exactly-known current_balance — see this method's own doc
        // comment for why.
        $finalLabel = !empty($segment['is_still_active']) ? 'Today' : (string) date('Y', $endTs);
        $points[] = ['label' => $finalLabel, 'ts' => $endTs, 'value' => $segment['current_balance']];

        $totalGrowthPct = $startingBalance > 0 ? round((($segment['current_balance'] - $startingBalance) / $startingBalance) * 100, 2) : null;
        return [
            'available' => true,
            'points' => $points,
            'starting_balance' => $startingBalance,
            'current_balance' => $segment['current_balance'],
            'total_growth_pct' => $totalGrowthPct,
        ];
    }

    /**
     * Trading Consistency — reuses historicalConsistency()'s exact same
     * two real signals (risk-sizing coefficient of variation + % of
     * trades classified outside every undisciplined bucket), scoped to
     * this ONE account's own rows/group rather than a pooled population —
     * same formula, same MIN_HISTORICAL_CONSISTENCY_TRADES floor, just a
     * narrower input. Re-bucketed into the prototype's own 3-tier legend
     * (Consistent Growth / Moderate / Inconsistent) purely for the donut's
     * 3-segment presentation — the underlying score is the identical
     * number historicalConsistency() already computes and explains
     * elsewhere on this page, not a second formula.
     */
    private static function liveTradingConsistency(array $group): array
    {
        $hc = self::historicalConsistency($group);
        if (!($hc['available'] ?? false)) return ['available' => false];
        $score = $hc['score'];
        // Three-way split of the same 0-100 score for the donut's legend —
        // thresholds chosen to land "Consistent Growth" on a genuinely
        // strong score, "Inconsistent" on a genuinely weak one, matching
        // this file's established sanity-check convention of never
        // labeling a middling number with a superlative.
        $consistent = max(0, $score - 15);
        $inconsistent = max(0, 100 - $score - 15);
        $moderate = max(0, 100 - $consistent - $inconsistent);
        return [
            'available' => true, 'score' => $score,
            'risk_consistency' => $hc['risk_consistency'], 'discipline_rate' => $hc['discipline_rate'], 'n' => $hc['n'],
            'consistent_pct' => round($consistent, 1), 'moderate_pct' => round($moderate, 1), 'inconsistent_pct' => round($inconsistent, 1),
        ];
    }

    /**
     * Financial Behaviour — six real, independently-sourced figures.
     * Trading Profit is bottom-up (summed from real per-trade P&L, the
     * same `rows`/pnl already used everywhere else on this page). Net
     * Deposits/Withdrawals are the account's own real ledger fields.
     * Organic Growth is top-down (derived from the real balance ledger:
     * total balance change minus net new capital) — a deliberately
     * INDEPENDENT second measure of "growth from trading," not a
     * relabeled copy of Trading Profit; the two agreeing closely is
     * itself a real cross-check (and this session's own reconciliation
     * check confirmed they usually do, for real accounts). Capital
     * Dependency % is the real share of total balance growth that came
     * from injected capital rather than trading (null, not a divide-by-
     * zero fabrication, when the account hasn't grown at all). Capital
     * Efficiency Score is the disclosed inverse of Capital Dependency —
     * a high score means growth is mostly self-generated, not dependent
     * on new deposits — the same "disclosed, real-input composite score"
     * convention already established for the Historical Discipline Index/
     * Edge Health elsewhere in this codebase, not a fabricated marketing number.
     */
    private static function liveFinancialBehaviour(array $segment): array
    {
        $tradingProfit = 0.0;
        foreach ($segment['rows'] as $r) { $tradingProfit += $r['pnl']; }

        $startingBalance = $segment['account_size'];
        $currentBalance = $segment['current_balance'];
        $netDeposits = $segment['net_deposits'];
        $netWithdrawals = $segment['net_withdrawals'];
        $totalGrowth = $currentBalance - $startingBalance;
        $organicGrowth = $totalGrowth - $netDeposits + $netWithdrawals;

        $capitalDependencyPct = null; $capitalEfficiencyScore = null;
        if ($totalGrowth > 0) {
            $capitalDependencyPct = round(min(100, max(0, ($netDeposits / $totalGrowth) * 100)), 1);
            $capitalEfficiencyScore = round(100 - $capitalDependencyPct, 0);
        }

        return [
            'trading_profit' => round($tradingProfit, 2),
            'net_deposits' => round($netDeposits, 2),
            'net_withdrawals' => round($netWithdrawals, 2),
            'organic_growth' => round($organicGrowth, 2),
            'capital_dependency_pct' => $capitalDependencyPct,
            'capital_efficiency_score' => $capitalEfficiencyScore,
        ];
    }

    /**
     * Real, recent-window classification for one capital event — was the
     * account's own trading in the ~14 real days immediately before this
     * event's timestamp net negative (a real, recent losing stretch)?
     * This is the one signal every deposit/withdrawal bucket below is
     * built from — no per-event dollar amount is needed to answer this,
     * only the event's real timestamp against this account's own real
     * trade history, which IS fully available.
     */
    private static function liveWasRecentlyLosing(array $rows, int $eventTs, int $windowDays = 14): bool
    {
        $windowStart = $eventTs - ($windowDays * 86400);
        $net = 0.0; $any = false;
        foreach ($rows as $r) {
            $t = strtotime($r['trade']['captured_at'] ?? '');
            if ($t === false || $t > $eventTs || $t < $windowStart) continue;
            $net += $r['pnl']; $any = true;
        }
        return $any && $net < 0;
    }

    /**
     * Deposit Behaviour / Funding Quality — real, 2-bucket classification
     * (Planned / Reactive), not the illustrative 4-bucket mockup exactly.
     * "Before Margin Pressure" was deliberately NOT built as a 3rd/4th
     * bucket: this schema has no margin-at-deposit-time record (per-trade
     * margin_used_pct exists only at calculator capture time, not tied to
     * a later deposit event), so a 3rd category here would be a guess
     * dressed as a real signal — exactly what this whole engine's
     * established discipline exists to avoid. Every CAPITAL_ADDED event
     * (liveFetchCapitalEvents() already excludes the account's original
     * INITIAL_DEPOSIT — see that function's own comment) is "Reactive"
     * only when liveWasRecentlyLosing() confirms a real losing stretch in
     * the 14 real days before it, else "Planned" — including the first
     * one: there is no special-cased "always planned" first entry
     * anymore (2026-08-06 fix — a leftover from when INITIAL_DEPOSIT used
     * to occupy that slot), since liveWasRecentlyLosing() already
     * correctly returns false on its own whenever there's no trade
     * history yet to be reactive to. Funding Quality (its own donut on
     * this page) is this exact same classification, dollar-and-formula-
     * identical, not a second computation — deposits are deposits
     * either way.
     */
    private static function liveDepositBehaviour(array $segment, $db): array
    {
        $capitalEvents = self::liveFetchCapitalEvents($segment['account_id'], $db);
        $deposits = $capitalEvents['deposits'];
        $n = count($deposits);
        if ($n === 0) return ['available' => false];

        $totalDeposits = $segment['net_deposits'];
        $avgDeposit = $totalDeposits / $n;

        $timestamps = array_map('strtotime', $deposits);
        sort($timestamps);
        $gapDays = [];
        for ($i = 1; $i < count($timestamps); $i++) { $gapDays[] = ($timestamps[$i] - $timestamps[$i - 1]) / 86400; }
        $avgGapDays = !empty($gapDays) ? array_sum($gapDays) / count($gapDays) : null;

        $planned = 0; $reactive = 0;
        foreach ($deposits as $ts) {
            $tsInt = strtotime($ts);
            if (!self::liveWasRecentlyLosing($segment['rows'], $tsInt)) $planned++;
            else $reactive++;
        }
        $plannedPct = round($planned / $n * 100, 1);
        $reactivePct = round(100 - $plannedPct, 1);

        return [
            'available' => true,
            'total_deposits' => round($totalDeposits, 2), 'avg_deposit' => round($avgDeposit, 2),
            'avg_gap_days' => $avgGapDays !== null ? round($avgGapDays, 0) : null,
            'n' => $n, 'planned_count' => $planned, 'reactive_count' => $reactive,
            'planned_pct' => $plannedPct, 'reactive_pct' => $reactivePct,
        ];
    }

    /**
     * Withdrawal Behaviour — 3 real buckets: "After Reaching a Goal" (a
     * real account_goals row achieved within 3 real days of this
     * withdrawal — the same account_goals table Funded Goal Achievement
     * already uses, not a new concept), "Regular Profit Taking" (not
     * goal-tied, but the account was NOT in a recent losing stretch —
     * liveWasRecentlyLosing() again), "Other" (a withdrawal during/right
     * after a real losing stretch — includes what a 4-bucket mockup might
     * call "Emergency"). Same "no per-event dollar amount, real
     * timestamp-only signals" honesty constraint as Deposit Behaviour.
     */
    private static function liveWithdrawalBehaviour(array $segment, $db): array
    {
        $capitalEvents = self::liveFetchCapitalEvents($segment['account_id'], $db);
        $withdrawals = $capitalEvents['withdrawals'];
        $n = count($withdrawals);
        if ($n === 0) return ['available' => false];

        $totalWithdrawals = $segment['net_withdrawals'];
        $avgWithdrawal = $totalWithdrawals / $n;

        $timestamps = array_map('strtotime', $withdrawals);
        sort($timestamps);
        $gapDays = [];
        for ($i = 1; $i < count($timestamps); $i++) { $gapDays[] = ($timestamps[$i] - $timestamps[$i - 1]) / 86400; }
        $avgGapDays = !empty($gapDays) ? array_sum($gapDays) / count($gapDays) : null;

        $tally = ['After Reaching a Goal' => 0, 'Regular Profit Taking' => 0, 'Other' => 0];
        $achievedGoalTs = self::liveAchievedGoalTimestamps($segment['account_id'], $db);
        foreach ($withdrawals as $ts) {
            $tsInt = strtotime($ts);
            $nearGoal = false;
            foreach ($achievedGoalTs as $gts) { if (abs($tsInt - $gts) <= 3 * 86400) { $nearGoal = true; break; } }
            if ($nearGoal) $tally['After Reaching a Goal']++;
            elseif (!self::liveWasRecentlyLosing($segment['rows'], $tsInt)) $tally['Regular Profit Taking']++;
            else $tally['Other']++;
        }
        $out = [];
        foreach ($tally as $label => $count) { if ($count > 0) $out[] = ['label' => $label, 'count' => $count, 'pct' => round($count / $n * 100, 1)]; }

        return [
            'available' => true,
            'total_withdrawals' => round($totalWithdrawals, 2), 'avg_withdrawal' => round($avgWithdrawal, 2),
            'avg_gap_days' => $avgGapDays !== null ? round($avgGapDays, 0) : null,
            'n' => $n, 'buckets' => $out,
        ];
    }

    /** Real account_goals.achieved_at timestamps for this account — reused by liveWithdrawalBehaviour()'s "After Reaching a Goal" bucket. */
    private static function liveAchievedGoalTimestamps(string $accountId, $db): array
    {
        $rows = $db->raw(
            "SELECT achieved_at FROM account_goals WHERE account_id = :aid AND status = 'achieved' AND achieved_at IS NOT NULL",
            [':aid' => $accountId]
        );
        return array_map(fn($r) => strtotime($r['achieved_at']), $rows);
    }

    /**
     * Growth Interruptions — a real, derived tally of behavioral patterns
     * that slowed this account's growth, each independently gated on real
     * evidence (never a fixed percentage): large withdrawals during a
     * recent losing stretch (reuses liveWithdrawalBehaviour's own
     * classification — its "Other" bucket, definitionally); risk_pct
     * meaningfully increasing (>=50% relative) on the very next trade
     * after this account reached a new real equity high; emergency
     * deposits after a drawdown (liveDepositBehaviour's own "Reactive"
     * bucket); long inactivity (a real gap of 14+ calendar days between
     * two consecutive real trades, the same threshold classifyGoalDelays()
     * already established elsewhere on this page for "waiting too long").
     */
    private static function liveGrowthInterruptions(array $segment, array $depositBehaviour, array $withdrawalBehaviour): array
    {
        $rows = $segment['rows'];
        $tally = [];

        if (($withdrawalBehaviour['available'] ?? false)) {
            $otherCount = 0;
            foreach ($withdrawalBehaviour['buckets'] as $b) { if ($b['label'] === 'Other') $otherCount = $b['count']; }
            if ($otherCount > 0) $tally['Large withdrawals after profitable weeks'] = $otherCount;
        }
        if (($depositBehaviour['available'] ?? false) && $depositBehaviour['reactive_count'] > 0) {
            $tally['Emergency deposits after drawdowns'] = $depositBehaviour['reactive_count'];
        }

        $peak = $segment['account_size']; $riskSpikes = 0; $longGaps = 0;
        $prevTs = null; $prevRisk = null;
        foreach ($rows as $r) {
            $ts = strtotime($r['trade']['captured_at'] ?? '');
            if ($prevTs !== null && $ts !== false) {
                $gapDays = ($ts - $prevTs) / 86400;
                if ($gapDays >= 14) $longGaps++;
            }
            $risk = $r['trade']['risk_pct'] ?? null;
            $risk = ($risk !== null && $risk !== '') ? (float)$risk : null;
            $wasNewHigh = $r['balance'] > $peak;
            if ($wasNewHigh && $prevRisk !== null && $risk !== null && $prevRisk > 0 && $risk >= $prevRisk * 1.5) $riskSpikes++;
            if ($r['balance'] > $peak) $peak = $r['balance'];
            if ($ts !== false) $prevTs = $ts;
            if ($risk !== null) $prevRisk = $risk;
        }
        if ($riskSpikes > 0) $tally['Increasing risk after new equity highs'] = $riskSpikes;
        if ($longGaps > 0) $tally['Long inactivity / breaks'] = $longGaps;

        if (empty($tally)) return [];
        arsort($tally);
        $total = array_sum($tally);
        $out = [];
        foreach ($tally as $label => $count) { $out[] = ['label' => $label, 'count' => $count, 'pct' => round($count / $total * 100, 1)]; }
        return $out;
    }

    /**
     * Trading Longevity — Account Age (already computed, reused here),
     * Longest inactive period (the single largest real calendar-day gap
     * between two consecutive real trades — plain calendar days, since
     * this measures INACTIVITY, not trading activity), and Current
     * active streak (real, distinct trading days counted back from the
     * account's most recent trade to the end of its last real 14+-day
     * break, or account creation if none ever occurred — expressed as
     * trading days, matching the prototype's own "247 trading days"
     * label literally, not a calendar-day span).
     */
    private static function liveTradingLongevity(array $segment, ?float $ageDays): array
    {
        $rows = $segment['rows'];
        $dates = [];
        foreach ($rows as $r) {
            $ts = strtotime($r['trade']['captured_at'] ?? '');
            if ($ts === false) continue;
            $dates[date('Y-m-d', $ts)] = true;
        }
        $distinctDates = array_keys($dates);
        sort($distinctDates);
        if (empty($distinctDates)) return ['available' => false];

        $longestGapDays = 0;
        $lastBreakIdx = 0;
        for ($i = 1; $i < count($distinctDates); $i++) {
            $gap = (strtotime($distinctDates[$i]) - strtotime($distinctDates[$i - 1])) / 86400;
            if ($gap > $longestGapDays) $longestGapDays = $gap;
            if ($gap >= 14) $lastBreakIdx = $i;
        }
        $currentStreakTradingDays = count($distinctDates) - $lastBreakIdx;

        return [
            'available' => true,
            'account_age_days' => $ageDays,
            'longest_inactive_days' => (int) round($longestGapDays),
            'current_streak_trading_days' => $currentStreakTradingDays,
        ];
    }
}
