<?php
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../api/analytics/engine.php';
require_once __DIR__ . '/TradeGrading.php';

/**
 * ════════════════════════════════════════════════════════════════════
 * Trader Profile Behavior Engine
 * ────────────────────────────────────────────────────────────────────
 * Sits alongside AnalyticsEngine/EdgeHealthEngine as its own leaf under
 * the shared "Analytics Engine" layer (see CLAUDE.md's Trader Profile
 * architecture section) — same pattern (own PDO connection, static
 * methods, no ScopedDb) as those two, for the same reason: this class
 * reads across every account a user has, unscoped by the request's own
 * session/account context, the same way AnalyticsEngine already does.
 *
 * Every public method here is either (a) a genuinely new computation not
 * available anywhere else in the codebase (loss-streak risk escalation,
 * winners-cut-short/losers-held-long, checklist-based patience, best
 * trading conditions, growth timeline), or (b) a thin all-account
 * (no account_type filter) variant of a query Reports already runs —
 * never a re-derivation of something AnalyticsEngine/EdgeHealthEngine
 * already computes correctly (strategy adherence, discipline score,
 * consistency score, all-account win-rate/expectancy are pulled directly
 * from those two engines by TraderProfileService instead).
 *
 * Every method here is gated by an explicit minimum-sample threshold and
 * returns ['available' => false, ...] below it — never a fabricated
 * pattern from too little data. Thresholds are documented per-method and
 * mirrored in CLAUDE.md.
 * ════════════════════════════════════════════════════════════════════
 */

class TraderProfileBehaviorEngine
{
    // A+/A/B/C mapping — was hand-duplicated verbatim from public/reports.php's
    // $fsGradeMap; now both read src/TradeGrading.php::GRADE_MAP instead
    // (2026-08-08, docs/architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md §9,
    // Wave 4d), so grading can no longer independently drift between the
    // two pages. Source: trades.trade_classification's real CHECK-
    // constrained values (see CLAUDE.md's confirmed schema notes).

    const MIN_STREAK_BUCKET_N = 2;      // trades needed in a streak-position bucket to report it
    const MIN_STREAK_OCCURRENCES = 3;   // distinct streaks of length >= 2 needed to render Emotional Pattern
    const EMOTIONAL_LOOKBACK_TRADES = 300;

    const MIN_RISK_AFTER_LOSS_N = 5;    // per bucket (baseline vs post-loss)
    const MIN_WINNERS_LOSERS_N = 5;     // per side (winners vs losers)
    const MIN_PATIENCE_N = 5;
    const MIN_CONDITION_N = 8;          // per "when you trade best" condition
    const MIN_CONDITION_DELTA_PP = 8.0; // win-rate percentage-point delta to call a condition meaningful

    const MIN_MONTH_TRADES = 5;         // trades needed for a calendar month to count in Growth Timeline
    const MIN_TIMELINE_MONTHS = 3;      // qualifying months needed to render Growth Timeline at all
    const TIMELINE_LOOKBACK_MONTHS = 24;
    const TIMELINE_MAX_ENTRIES = 6;

    const PNL_EXPR = "CASE WHEN ta.account_type = 'prop_firm' THEN COALESCE(t.challenge_pnl, t.broker_pnl, t.projected_pnl, 0) ELSE COALESCE(t.broker_pnl, t.projected_pnl, 0) END";

    private static $pdo = null;

    private static function getConnection() {
        if (self::$pdo === null) {
            $dsn = "mysql:host=" . DB_HOST . ";port=" . DB_PORT . ";dbname=" . DB_NAME . ";charset=utf8mb4";
            self::$pdo = new PDO($dsn, DB_USER, DB_PASS, [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES => false,
            ]);
        }
        return self::$pdo;
    }

    private static function isCGrade(?string $classification): bool {
        return TradeGrading::isCGrade($classification);
    }

    /**
     * Same n-aware tier vocabulary/thresholds EdgeHealthEngine::getPeriodFactors()
     * already established (none/single_trade/very_small_sample/small_sample/
     * developing/confident, thresholds n=0/1/<10/<20/<100) — reused verbatim
     * here for the Character-bar signals this class computes (Patience,
     * Trust in Strategy via stage1, Emotional Control) so every bar on the
     * page speaks the same confidence vocabulary, not a second invented one.
     */
    public static function tierForN(int $n): string {
        if ($n === 0) return 'none';
        if ($n === 1) return 'single_trade';
        if ($n < 10) return 'very_small_sample';
        if ($n < 20) return 'small_sample';
        if ($n < 100) return 'developing';
        return 'confident';
    }

    /**
     * Shared trade-fetch primitive — every account (no account_type
     * filter), closed, non-validation, not deleted, oldest first.
     * $startStr/$endStr null = all-time. $limitLast = only the most
     * recent N trades (used by getEmotionalPattern's lookback cap).
     */
    private static function fetchSequence($userId, $startStr = null, $endStr = null, $limitLast = null) {
        $db = self::getConnection();
        $dateFilter = '';
        $params = [':uid' => $userId];
        if ($startStr !== null && $endStr !== null) {
            $dateFilter = ' AND t.closed_at >= :s AND t.closed_at <= :e';
            $params[':s'] = $startStr;
            $params[':e'] = $endStr;
        }
        $sql = "SELECT t.id, t.closed_at, t.risk_pct, t.trade_classification, t.max_potential_profit,
                       t.max_potential_loss, t.session, t.mood_before, t.energy_before, t.confidence_before,
                       t.account_id, cr.ticked_pct, " . self::PNL_EXPR . " AS effective_pnl
                FROM trades t
                LEFT JOIN trading_accounts ta ON ta.id = t.account_id
                LEFT JOIN checklist_results cr ON cr.trade_lifecycle_id = t.trade_lifecycle_id
                WHERE t.user_id = :uid AND t.status = 'closed' AND t.deleted_at IS NULL
                  AND (t.is_validation = 0 OR t.is_validation IS NULL){$dateFilter}
                ORDER BY t.closed_at " . ($limitLast ? "DESC LIMIT " . (int)$limitLast : "ASC");
        $stmt = $db->prepare($sql);
        $stmt->execute($params);
        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
        if ($limitLast) $rows = array_reverse($rows); // restore chronological order
        return $rows;
    }

    /**
     * Tags each trade with its position in a losing streak:
     *   0            = not currently in a losing streak (incl. wins/breakevens after 0-1 prior losses)
     *   1 / 2 / 3     = this trade IS the Nth consecutive loss
     *   '4+'          = this trade is the 4th (or later) consecutive loss
     *   'recovery'    = a win immediately following a streak of >= 2 losses
     */
    private static function tagStreaks(array $trades): array {
        $runningLosses = 0;
        foreach ($trades as &$t) {
            $pnl = round((float)$t['effective_pnl'], 2);
            if ($pnl < -0.005) {
                $n = $runningLosses + 1;
                $t['streak_position'] = $n >= 4 ? '4+' : $n;
                $runningLosses = $n;
            } else {
                $t['streak_position'] = ($pnl > 0.005 && $runningLosses >= 2) ? 'recovery' : 0;
                $runningLosses = 0;
            }
        }
        unset($t);
        return $trades;
    }

    // ── #2/#3: shared streak-bucket aggregation, used by both the ────────
    //    period-scoped Blind Spot signal and the all-time Emotional Pattern
    private static function aggregateStreakBuckets(array $tagged): array {
        $buckets = [0 => [], 1 => [], 2 => [], 3 => [], '4+' => [], 'recovery' => []];
        foreach ($tagged as $t) {
            $buckets[$t['streak_position']][] = $t;
        }
        $out = [];
        foreach ($buckets as $pos => $rows) {
            $n = count($rows);
            if ($n === 0) { $out[$pos] = ['n' => 0]; continue; }
            $riskPcts = array_map(fn($r) => (float)$r['risk_pct'], array_filter($rows, fn($r) => $r['risk_pct'] !== null));
            $confidences = array_map(fn($r) => (float)$r['confidence_before'], array_filter($rows, fn($r) => $r['confidence_before'] !== null));
            $cCount = count(array_filter($rows, fn($r) => self::isCGrade($r['trade_classification'])));
            $out[$pos] = [
                'n' => $n,
                'avg_risk_pct' => $riskPcts ? array_sum($riskPcts) / count($riskPcts) : null,
                'c_rate_pct' => round(($cCount / $n) * 100, 1),
                'avg_confidence' => $confidences ? array_sum($confidences) / count($confidences) : null,
            ];
        }
        return $out;
    }

    /** Period-scoped: avg risk_pct / C-rate right after a loss vs. baseline (no active streak). Feeds Biggest Blind Spot. */
    public static function getRiskAfterLossSignal($userId, $startStr, $endStr): array {
        $tagged = self::tagStreaks(self::fetchSequence($userId, $startStr, $endStr));
        $buckets = self::aggregateStreakBuckets($tagged);
        $baseline = $buckets[0];
        $postLossRows = array_filter($tagged, fn($t) => $t['streak_position'] !== 0 && $t['streak_position'] !== 'recovery');
        $postLossN = count($postLossRows);
        if ($baseline['n'] < self::MIN_RISK_AFTER_LOSS_N || $postLossN < self::MIN_RISK_AFTER_LOSS_N) {
            return ['available' => false, 'reason' => 'not_enough_trades'];
        }
        $postLossRisks = array_map(fn($t) => (float)$t['risk_pct'], array_filter($postLossRows, fn($t) => $t['risk_pct'] !== null));
        $postLossC = count(array_filter($postLossRows, fn($t) => self::isCGrade($t['trade_classification'])));
        $postLossAvgRisk = $postLossRisks ? array_sum($postLossRisks) / count($postLossRisks) : null;
        $postLossCRate = round(($postLossC / $postLossN) * 100, 1);
        $baselineRisk = $baseline['avg_risk_pct'];
        $relIncrease = ($baselineRisk && $baselineRisk > 0 && $postLossAvgRisk !== null)
            ? (($postLossAvgRisk - $baselineRisk) / $baselineRisk) * 100
            : null;
        return [
            'available' => true,
            'baseline_n' => $baseline['n'], 'baseline_avg_risk_pct' => $baselineRisk, 'baseline_c_rate_pct' => $baseline['c_rate_pct'],
            'post_loss_n' => $postLossN, 'post_loss_avg_risk_pct' => $postLossAvgRisk, 'post_loss_c_rate_pct' => $postLossCRate,
            'relative_risk_increase_pct' => $relIncrease,
            'c_rate_delta_pp' => $postLossCRate - $baseline['c_rate_pct'],
        ];
    }

    /**
     * All-time (capped to the most recent EMOTIONAL_LOOKBACK_TRADES closed
     * trades), across every account. Produces the streak-position ladder
     * for the Emotional Pattern section. Requires >= MIN_STREAK_OCCURRENCES
     * distinct streaks of length >= 2 in the lookback window.
     */
    public static function getEmotionalPattern($userId): array {
        $tagged = self::tagStreaks(self::fetchSequence($userId, null, null, self::EMOTIONAL_LOOKBACK_TRADES));
        $occurrences = count(array_filter($tagged, fn($t) => $t['streak_position'] === 2));
        if ($occurrences < self::MIN_STREAK_OCCURRENCES) {
            return ['available' => false, 'reason' => 'not_enough_streaks', 'occurrences' => $occurrences];
        }
        $buckets = self::aggregateStreakBuckets($tagged);
        $baselineRisk = $buckets[0]['avg_risk_pct'];
        $rungs = [];
        foreach ([0, 1, 2, 3, '4+', 'recovery'] as $pos) {
            $b = $buckets[$pos];
            if ($b['n'] < self::MIN_STREAK_BUCKET_N && $pos !== 0) continue; // skip unreportable rungs, keep the ladder honest
            $relIncrease = ($pos !== 0 && $baselineRisk && $baselineRisk > 0 && $b['avg_risk_pct'] !== null)
                ? (($b['avg_risk_pct'] - $baselineRisk) / $baselineRisk) * 100 : null;
            // §6.1/§6.3 fix (2026-08-10) — $tier used to default to
            // 'baseline' before the branches below, and stayed there for
            // ANY real loss-streak position (pos !== 0) whose risk data
            // happened to be missing (elseif required $relIncrease !==
            // null to run at all) — rendered as "Your default state — no
            // active losing streak" for a rung that is, by construction,
            // an active losing-streak position. Position 0 is the only
            // real 'baseline' (no streak); every other position with no
            // usable risk comparison now gets its own honest
            // 'insufficient_risk_data' tier instead of silently
            // inheriting the "no streak" default.
            $tier = ($pos === 0) ? 'baseline' : 'insufficient_risk_data';
            if ($pos === 'recovery') {
                // §6.3 — was abs($relIncrease) <= 15, so a large risk
                // DECREASE after the streak (e.g. -40%, a trader becoming
                // more cautious) still classified as 'still_elevated',
                // whose copy literally says risk "hasn't fully come back
                // down" — the opposite of what a negative number means.
                // A decrease is never "still elevated" — only a genuine
                // increase above the +15% band is.
                $tier = ($relIncrease === null || $relIncrease <= 15) ? 'reset' : 'still_elevated';
            } elseif ($pos !== 0 && $relIncrease !== null) {
                if ($relIncrease < 15) $tier = 'steady';
                elseif ($relIncrease < 40) $tier = 'shifting';
                elseif ($relIncrease < 80) $tier = 'escalating';
                else $tier = 'breaking';
                // §6.9 fix (2026-08-10) — a sharp rise in C-grade rate
                // reinforces (never downgrades) the tier, but the old
                // version only ever nudged 'steady' up exactly one level
                // to 'shifting', treating a 25pp c-rate delta and a 90pp
                // one identically — under-describing a genuinely dramatic,
                // non-risk-driven discipline collapse. Now scales the
                // reinforcement to the real delta's own magnitude (mirrors
                // the risk-increase tiering's own 3-band shape above) and
                // applies regardless of which tier the risk-increase calc
                // already landed on, taking the MAX of the two (never a
                // downgrade — a tier already at 'breaking' from risk alone
                // can't be pulled back down by a modest c-rate reading).
                $cRateDelta = $b['c_rate_pct'] - $buckets[0]['c_rate_pct'];
                $tierRank = ['steady' => 0, 'shifting' => 1, 'escalating' => 2, 'breaking' => 3];
                $cRateFloorRank = match (true) {
                    $cRateDelta >= 75 => $tierRank['breaking'],
                    $cRateDelta >= 50 => $tierRank['escalating'],
                    $cRateDelta >= 25 => $tierRank['shifting'],
                    default => -1, // no reinforcement — c-rate delta too small to matter
                };
                if ($cRateFloorRank > $tierRank[$tier]) {
                    $tier = array_search($cRateFloorRank, $tierRank, true);
                }
            }
            $rungs[] = ['position' => $pos, 'n' => $b['n'], 'avg_risk_pct' => $b['avg_risk_pct'],
                'c_rate_pct' => $b['c_rate_pct'], 'avg_confidence' => $b['avg_confidence'],
                'relative_risk_increase_pct' => $relIncrease, 'tier' => $tier];
        }
        return ['available' => true, 'occurrences' => $occurrences, 'lookback_trades' => count($tagged), 'rungs' => $rungs];
    }

    /** Period-scoped: are winners cut short relative to plan while losers run closer to the full planned stop? */
    public static function getWinnersLosersBehavior($userId, $startStr, $endStr): array {
        $trades = self::fetchSequence($userId, $startStr, $endStr);
        $winners = []; $losers = [];
        foreach ($trades as $t) {
            $pnl = (float)$t['effective_pnl'];
            if ($pnl > 0 && !empty($t['max_potential_profit']) && (float)$t['max_potential_profit'] > 0) {
                $winners[] = min(2.0, $pnl / (float)$t['max_potential_profit']); // cap absurd outliers, still real
            } elseif ($pnl < 0 && !empty($t['max_potential_loss']) && (float)$t['max_potential_loss'] > 0) {
                $losers[] = min(2.0, abs($pnl) / (float)$t['max_potential_loss']);
            }
        }
        if (count($winners) < self::MIN_WINNERS_LOSERS_N || count($losers) < self::MIN_WINNERS_LOSERS_N) {
            return ['available' => false, 'reason' => 'not_enough_trades'];
        }
        $avgCapture = array_sum($winners) / count($winners);
        $avgRealization = array_sum($losers) / count($losers);
        return [
            'available' => true,
            'winners_n' => count($winners), 'avg_winner_capture_ratio' => $avgCapture,
            'losers_n' => count($losers), 'avg_loser_realization_ratio' => $avgRealization,
            'cuts_winners_short' => ($avgCapture < 0.55 && $avgRealization >= 0.85),
        ];
    }

    /** Period-scoped: real checklist completion at entry (the Patience bar's value), plus its outcome split. */
    public static function getPatienceSignal($userId, $startStr, $endStr): array {
        $trades = self::fetchSequence($userId, $startStr, $endStr);
        $withChecklist = array_filter($trades, fn($t) => $t['ticked_pct'] !== null);
        $n = count($withChecklist);
        if ($n < self::MIN_PATIENCE_N) {
            return ['available' => false, 'reason' => 'not_enough_trades', 'n' => $n];
        }
        $pcts = array_map(fn($t) => (float)$t['ticked_pct'], $withChecklist);
        $avgTicked = array_sum($pcts) / $n;
        $full = array_filter($withChecklist, fn($t) => (float)$t['ticked_pct'] >= 99.5);
        $partial = array_filter($withChecklist, fn($t) => (float)$t['ticked_pct'] < 99.5);
        $splitAvailable = count($full) >= self::MIN_PATIENCE_N && count($partial) >= self::MIN_PATIENCE_N;
        $result = ['available' => true, 'n' => $n, 'avg_ticked_pct' => $avgTicked, 'split_available' => $splitAvailable];
        if ($splitAvailable) {
            $winRate = fn($rows) => round((count(array_filter($rows, fn($t) => (float)$t['effective_pnl'] > 0)) / count($rows)) * 100, 1);
            $result['full_checklist_n'] = count($full);
            $result['full_checklist_win_rate'] = $winRate($full);
            $result['partial_checklist_n'] = count($partial);
            $result['partial_checklist_win_rate'] = $winRate($partial);
        }
        return $result;
    }

    /** Period-scoped, all-account classification distribution — feeds the Emotional Control bar. */
    public static function getClassificationDistribution($userId, $startStr, $endStr): array {
        $db = self::getConnection();
        $stmt = $db->prepare(
            "SELECT trade_classification, COUNT(*) AS n FROM trades
             WHERE user_id = :uid AND (is_validation = 0 OR is_validation IS NULL) AND status = 'closed' AND deleted_at IS NULL
               AND closed_at >= :s AND closed_at <= :e
             GROUP BY trade_classification"
        );
        $stmt->execute([':uid' => $userId, ':s' => $startStr, ':e' => $endStr]);
        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
        $total = 0; $grades = ['A+' => 0, 'A' => 0, 'B' => 0, 'C' => 0];
        foreach ($rows as $r) {
            $n = (int)$r['n'];
            $total += $n;
            $grade = TradeGrading::GRADE_MAP[$r['trade_classification']] ?? null;
            if ($grade) $grades[$grade] += $n;
        }
        return ['total' => $total, 'grades' => $grades,
            'c_rate_pct' => $total > 0 ? round(($grades['C'] / $total) * 100, 1) : null];
    }

    /** Period-scoped, all-account: which real conditions produced a meaningfully different win rate. Returns 0-4 qualifying lines. */
    // §6.7 fix (2026-08-10) — the multiplier that separates "genuinely not
    // enough trades yet to evaluate any condition" from "plenty of trades,
    // performance is just genuinely uniform across every session/
    // frequency/risk band" — a real, disclosed judgment call, not a
    // statistically-derived cutoff. 3x MIN_CONDITION_N is comfortably past
    // the point where several of the ~11 condition buckets this function
    // checks could plausibly have cleared their own individual sample
    // floor, so an empty result at or above it is a real, meaningful
    // finding about uniformity, not a data-shortage message.
    const BEST_CONDITIONS_WELL_SAMPLED_MULTIPLIER = 3;

    public static function getBestConditions($userId, $startStr, $endStr): array {
        $trades = self::fetchSequence($userId, $startStr, $endStr);
        $totalN = count($trades);
        // §6.7 fix (2026-08-10) — was a bare array of conditions, so the
        // caller had no way to tell "genuinely too few trades" apart from
        // "plenty of trades, but nothing here was meaningfully different
        // from baseline" — both rendered the identical "still gathering
        // enough trades" empty-state message. total_n/well_sampled let the
        // view render an honest, distinct message for each real case.
        if ($totalN === 0) return ['conditions' => [], 'total_n' => 0, 'well_sampled' => false];
        $winRate = fn($rows) => count($rows) > 0 ? (count(array_filter($rows, fn($t) => (float)$t['effective_pnl'] > 0)) / count($rows)) * 100 : null;
        $baseline = $winRate($trades);
        $conditions = [];

        // By session
        $bySession = [];
        foreach ($trades as $t) {
            $s = $t['session'];
            if (!in_array($s, ['Sydney', 'Tokyo', 'London', 'New York'], true)) continue;
            $bySession[$s][] = $t;
        }
        foreach ($bySession as $s => $rows) {
            self::addConditionIfMeaningful($conditions, "session:{$s}", $s . ' session', $rows, $baseline, $winRate);
        }

        // By trades-per-day (how many trades were taken that calendar day)
        $perDay = [];
        foreach ($trades as $t) { $perDay[substr($t['closed_at'], 0, 10)][] = $t; }
        $byFrequencyBucket = [];
        foreach ($perDay as $rows) {
            $label = count($rows) === 1 ? '1 trade that day' : (count($rows) === 2 ? '2 trades that day' : '3+ trades that day');
            foreach ($rows as $t) { $byFrequencyBucket[$label][] = $t; }
        }
        foreach ($byFrequencyBucket as $label => $rows) {
            self::addConditionIfMeaningful($conditions, "freq:{$label}", $label, $rows, $baseline, $winRate);
        }

        // By risk band
        $bands = ['Risking under 0.5%' => [0, 0.5], 'Risking 0.5%-1.5%' => [0.5, 1.5], 'Risking 1.5%-3%' => [1.5, 3], 'Risking over 3%' => [3, INF]];
        foreach ($bands as $label => [$lo, $hi]) {
            $rows = array_filter($trades, fn($t) => $t['risk_pct'] !== null && (float)$t['risk_pct'] >= $lo && (float)$t['risk_pct'] < $hi);
            self::addConditionIfMeaningful($conditions, "risk:{$label}", $label, $rows, $baseline, $winRate);
        }

        usort($conditions, fn($a, $b) => abs($b['delta_pp']) <=> abs($a['delta_pp']));
        return [
            'conditions' => array_slice($conditions, 0, 4),
            'total_n' => $totalN,
            'well_sampled' => $totalN >= self::MIN_CONDITION_N * self::BEST_CONDITIONS_WELL_SAMPLED_MULTIPLIER,
        ];
    }

    private static function addConditionIfMeaningful(array &$conditions, string $key, string $label, array $rows, ?float $baseline, callable $winRateFn): void {
        $n = count($rows);
        if ($n < self::MIN_CONDITION_N || $baseline === null) return;
        $wr = $winRateFn($rows);
        $delta = $wr - $baseline;
        if (abs($delta) < self::MIN_CONDITION_DELTA_PP) return;
        $conditions[] = ['key' => $key, 'label' => $label, 'n' => $n, 'win_rate' => $wr, 'baseline_win_rate' => $baseline, 'delta_pp' => $delta];
    }

    /**
     * All-time, monthly buckets (only months with >= MIN_MONTH_TRADES
     * count), scanning the last TIMELINE_LOOKBACK_MONTHS calendar months.
     * strategy_adherence_pct is pulled from AnalyticsEngine::getFourStagePeriodSummary()
     * per qualifying month — reused, not re-derived.
     */
    public static function getGrowthTimeline($userId): array {
        $db = self::getConnection();
        // 3-value "undisciplined for grading" list — sourced from
        // src/TradeGrading.php::UNDISCIPLINED_FOR_GRADING (2026-08-08),
        // was a hand-copied literal IN-list here (fixed constants, not
        // user input — safe to interpolate directly).
        $undisciplinedList = "'" . implode("','", TradeGrading::UNDISCIPLINED_FOR_GRADING) . "'";
        $stmt = $db->prepare(
            "SELECT DATE_FORMAT(t.closed_at, '%Y-%m') AS ym, COUNT(*) AS n,
                    AVG(t.risk_pct) AS avg_risk,
                    SUM(CASE WHEN t.trade_classification IN ({$undisciplinedList}) THEN 1 ELSE 0 END) AS c_count,
                    AVG(cr.ticked_pct) AS avg_ticked
             FROM trades t
             LEFT JOIN trading_accounts ta ON ta.id = t.account_id
             LEFT JOIN checklist_results cr ON cr.trade_lifecycle_id = t.trade_lifecycle_id
             WHERE t.user_id = :uid AND (t.is_validation = 0 OR t.is_validation IS NULL) AND t.status = 'closed' AND t.deleted_at IS NULL
               AND t.closed_at >= DATE_SUB(CURDATE(), INTERVAL " . self::TIMELINE_LOOKBACK_MONTHS . " MONTH)
             GROUP BY ym
             ORDER BY ym ASC"
        );
        $stmt->execute([':uid' => $userId]);
        $months = array_values(array_filter($stmt->fetchAll(PDO::FETCH_ASSOC), fn($m) => (int)$m['n'] >= self::MIN_MONTH_TRADES));
        if (count($months) < self::MIN_TIMELINE_MONTHS) {
            return ['available' => false, 'reason' => 'not_enough_months', 'months_found' => count($months)];
        }
        foreach ($months as &$m) {
            $m['c_rate_pct'] = round(((int)$m['c_count'] / (int)$m['n']) * 100, 1);
            $m['avg_risk'] = $m['avg_risk'] !== null ? (float)$m['avg_risk'] : null;
            $m['avg_ticked'] = $m['avg_ticked'] !== null ? (float)$m['avg_ticked'] : null;
            [$y, $mo] = explode('-', $m['ym']);
            $monthStart = "{$m['ym']}-01 00:00:00";
            $monthEnd = date('Y-m-t 23:59:59', strtotime($monthStart));
            $m['adherence_pct'] = null;
            try {
                $summary = AnalyticsEngine::getFourStagePeriodSummary($userId, 'all', $monthStart, $monthEnd);
                $m['adherence_pct'] = $summary['stage1']['strategy_adherence_pct'] ?? null;
            } catch (\Throwable $e) {
                // leave adherence_pct null for this month — the other 3 metrics still work
            }
        }
        unset($m);

        $entries = [];
        for ($i = 1; $i < count($months); $i++) {
            $cur = $months[$i];
            $priorWindow = array_slice($months, max(0, $i - 3), min(3, $i));
            $baseline = self::averageBaseline($priorWindow);
            $flag = self::detectMostSignificantChange($cur, $baseline);
            if ($flag) {
                $flag['month_label'] = date('F Y', strtotime($cur['ym'] . '-01'));
                $flag['ym'] = $cur['ym'];
                $entries[] = $flag;
            }
        }
        $entries = array_slice($entries, -self::TIMELINE_MAX_ENTRIES);

        $latest = end($months);
        return [
            'available' => true,
            'months_scanned' => count($months),
            'entries' => $entries,
            'current_month' => [
                'ym' => $latest['ym'], 'month_label' => date('F Y', strtotime($latest['ym'] . '-01')),
                'n' => (int)$latest['n'], 'avg_risk' => $latest['avg_risk'], 'c_rate_pct' => $latest['c_rate_pct'],
                'avg_ticked' => $latest['avg_ticked'], 'adherence_pct' => $latest['adherence_pct'],
            ],
        ];
    }

    private static function averageBaseline(array $months): array {
        $avg = fn($key) => (function () use ($months, $key) {
            $vals = array_filter(array_map(fn($m) => $m[$key], $months), fn($v) => $v !== null);
            return $vals ? array_sum($vals) / count($vals) : null;
        })();
        return ['avg_risk' => $avg('avg_risk'), 'c_rate_pct' => $avg('c_rate_pct'), 'avg_ticked' => $avg('avg_ticked'), 'adherence_pct' => $avg('adherence_pct')];
    }

    // §6.8 fix (2026-08-10) — each metric's own real "clears the bar"
    // threshold (15pp / 15pp / 15pp / 25% relative), used both to gate a
    // candidate in (unchanged) and now also to normalize its magnitude
    // into a comparable "how many multiples past its own threshold" figure
    // — the only way to fairly compare a percentage-point delta against a
    // relative-percent delta without inventing a made-up conversion
    // between two genuinely different units.
    const CHANGE_THRESHOLDS = ['adherence' => 15.0, 'c_rate' => 15.0, 'patience' => 15.0, 'risk' => 25.0];

    private static function detectMostSignificantChange(array $cur, array $baseline): ?array {
        $candidates = [];
        if ($baseline['adherence_pct'] !== null && $cur['adherence_pct'] !== null) {
            $delta = $cur['adherence_pct'] - $baseline['adherence_pct'];
            if (abs($delta) >= self::CHANGE_THRESHOLDS['adherence']) $candidates[] = ['metric' => 'adherence', 'delta' => $delta, 'from' => $baseline['adherence_pct'], 'to' => $cur['adherence_pct'], 'priority' => 1];
        }
        if ($baseline['c_rate_pct'] !== null) {
            $delta = $cur['c_rate_pct'] - $baseline['c_rate_pct'];
            if (abs($delta) >= self::CHANGE_THRESHOLDS['c_rate']) $candidates[] = ['metric' => 'c_rate', 'delta' => $delta, 'from' => $baseline['c_rate_pct'], 'to' => $cur['c_rate_pct'], 'priority' => 2];
        }
        if ($baseline['avg_ticked'] !== null && $cur['avg_ticked'] !== null) {
            $delta = $cur['avg_ticked'] - $baseline['avg_ticked'];
            if (abs($delta) >= self::CHANGE_THRESHOLDS['patience']) $candidates[] = ['metric' => 'patience', 'delta' => $delta, 'from' => $baseline['avg_ticked'], 'to' => $cur['avg_ticked'], 'priority' => 3];
        }
        if ($baseline['avg_risk'] !== null && $baseline['avg_risk'] > 0 && $cur['avg_risk'] !== null) {
            $relDelta = (($cur['avg_risk'] - $baseline['avg_risk']) / $baseline['avg_risk']) * 100;
            if (abs($relDelta) >= self::CHANGE_THRESHOLDS['risk']) $candidates[] = ['metric' => 'risk', 'delta' => $relDelta, 'from' => $baseline['avg_risk'], 'to' => $cur['avg_risk'], 'priority' => 4];
        }
        if (!$candidates) return null;
        // §6.8 fix (2026-08-10) — was a pure fixed-priority sort (adherence
        // always beats c_rate always beats patience always beats risk,
        // regardless of magnitude), so a mild adherence improvement that
        // barely cleared its own threshold could permanently hide a much
        // larger, simultaneous regression in a lower-priority metric (e.g.
        // a real 60pp c-rate collapse never surfaces if a 15pp adherence
        // gain fires in the same month). Now ranks primarily by real
        // severity (how many multiples past its own threshold each
        // candidate's delta reaches — a genuine, comparable magnitude
        // measure across differently-scaled metrics), falling back to the
        // original fixed priority only as a tie-break when severities are
        // equal, preserving this function's prior deterministic behavior
        // for that specific case.
        foreach ($candidates as &$c) { $c['severity'] = abs($c['delta']) / self::CHANGE_THRESHOLDS[$c['metric']]; }
        unset($c);
        usort($candidates, fn($a, $b) => $b['severity'] <=> $a['severity'] ?: $a['priority'] <=> $b['priority']);
        $pick = $candidates[0];
        $pick['direction'] = $pick['delta'] > 0
            ? (in_array($pick['metric'], ['c_rate', 'risk']) ? 'worsened' : 'improved')
            : (in_array($pick['metric'], ['c_rate', 'risk']) ? 'improved' : 'worsened');
        return $pick;
    }
}
