<?php
require_once __DIR__ . '/../../config/config.php';

/**
 * ════════════════════════════════════════════════════════════════════
 * Edge Health Engine
 * ────────────────────────────────────────────────────────────────────
 * Labeled "Edge Health" in Reports (the Journal-side naming question —
 * "Consistency Score" or otherwise — is out of scope here; Journal gets
 * its own separate redesign later).
 *
 * Coaching-first, not a trade-count tracker (2026-07-28 final refinement
 * pass, item 7 — this doc comment previously described the pre-redesign
 * behavior and was stale). All four weighted factors are computed and
 * narrated from Trade 1 onward — see getScore()'s own docblock below.
 * MIN_TRADES_FOR_GRADE (100) is a statistical-confidence floor, not a
 * gate: below it, getScore() still returns real scores and real per-factor
 * coaching, just with an honest sample-size caveat in one footer sentence
 * ('confidence_line') rather than blocking the card or leading with a
 * countdown.
 *
 * Computed ENTIRELY from real journaled trade history —
 * actual win rate, actual R:R achieved, actual expectancy, actual
 * risk-of-ruin from real risk taken, execution consistency over time.
 * Never from a strategy's declared values (that's Stage 1's job,
 * computeFourStageRaw() in engine.php — this engine deliberately never
 * reads strategy_versions.max_risk_per_trade_pct/backtested_avg_rr for
 * the score itself).
 *
 * Four weighted factors (approved weights):
 *   - Risk of Ruin          35%
 *   - Edge Quality          30%
 *   - Execution Consistency 20%
 *   - Discipline Trend      15%
 *
 * This is intentionally the same underlying computation a future
 * risk-trust-ladder (deciding when a risk override above declared
 * strategy risk, or expanded TP2-10 usage, stops being flagged as a
 * violation) would need — real win rate/expectancy/risk-of-ruin from
 * actual history is the shared input either consumer would want. Built
 * as one scoring core (getRawMetrics() below) specifically so a future
 * trust-ladder consumer can call it directly rather than duplicating
 * these formulas. Not built here — Edge Health is backward-looking
 * (grades what happened), a trust ladder needs a forward decision rule
 * on top of the same inputs; that's a thin additional layer, not a
 * reason for a second engine.
 * ════════════════════════════════════════════════════════════════════
 */

class EdgeHealthEngine
{
    const MIN_TRADES_FOR_GRADE = 100;

    const WEIGHT_RISK_OF_RUIN  = 0.35;
    const WEIGHT_EDGE_QUALITY  = 0.30;
    const WEIGHT_CONSISTENCY   = 0.20;
    const WEIGHT_DISCIPLINE    = 0.15;

    private static $pdo = null;

    /** Same connection pattern as AnalyticsEngine — its own PDO handle, not borrowed from $db. */
    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;
    }

    /**
     * Public entry point. $strategyVersionId scopes to one strategy's real
     * trade history (an "edge" belongs to a strategy); null aggregates
     * across every one of the user's non-validation closed trades, same
     * "no strategy selected" fallback pattern already used elsewhere
     * (dashboard.php's strategy filter).
     *
     * REDESIGNED 2026-07-28 (second refinement pass, item 4). Previous
     * behavior: below MIN_TRADES_FOR_GRADE (100), this returned ONLY a
     * trade count and a countdown ('gradeable' => false, nothing else) —
     * which made the 100-trade threshold read as the actual goal ("reach
     * 100 trades"), rather than what it always was: a statistical-
     * confidence floor. The real objective is a strategy that's
     * consistently profitable; 100 trades is just the point at which the
     * four factor scores below stop moving much trade-to-trade.
     *
     * Now: every one of the four weighted factors is computed and returned
     * from Trade 1 onward (the underlying score* functions already
     * degrade gracefully to a neutral 50 when a given factor's own data
     * requirement isn't met yet — e.g. scoreDisciplineTrend needs 20
     * trades to split in half, scoreConsistency needs 2+ risk% or R
     * readings — so there was never a reason those had to wait for 100).
     * 'gradeable' now means "any real trade history exists at all" (n>=1),
     * not "n>=100". A separate 'confidence_tier' (reusing the exact same
     * n-aware vocabulary as getPeriodFactors()/the rest of this app) and a
     * single 'trades_remaining_for_full_confidence' figure are the ONLY
     * places sample size still appears — everywhere else on the card is
     * about what the numbers actually show, per the explicit instruction
     * that reaching 100 trades should be a by-product of good execution,
     * not the headline.
     */
    public static function getScore($userId, $strategyVersionId = null, $accountId = null) {
        $trades = self::fetchTradeSequence($userId, $strategyVersionId, $accountId);
        $n = count($trades);

        if ($n === 0) {
            return [
                'gradeable' => false,
                'has_history' => false,
                'trades_journaled' => 0,
            ];
        }

        $metrics = self::computeRawMetrics($trades);

        $riskOfRuinScore  = self::scoreRiskOfRuin($metrics);
        $edgeQualityScore = self::scoreEdgeQuality($metrics);
        $consistencyScore = self::scoreConsistency($metrics);
        $disciplineScore  = self::scoreDisciplineTrend($trades);

        $overall = round(
            $riskOfRuinScore  * self::WEIGHT_RISK_OF_RUIN +
            $edgeQualityScore * self::WEIGHT_EDGE_QUALITY +
            $consistencyScore * self::WEIGHT_CONSISTENCY +
            $disciplineScore  * self::WEIGHT_DISCIPLINE,
            1
        );

        // Per-factor trend direction (2026-07-28) — "is this improving or
        // deteriorating," not just "what is it right now." Reuses the same
        // chronological first-half/second-half split scoreDisciplineTrend()
        // already relies on, generalized to the other three factors via
        // computeFactorScoresForSet(). Needs 20+ trades to split meaningfully
        // (same floor scoreDisciplineTrend already imposes on itself); below
        // that every factor's trend is 'insufficient_data' rather than a
        // misleadingly confident up/down read on a handful of trades.
        $trends = ['risk_of_ruin' => 'insufficient_data', 'edge_quality' => 'insufficient_data', 'consistency' => 'insufficient_data'];
        if ($n >= 20) {
            $mid = (int)floor($n / 2);
            $firstScores  = self::computeFactorScoresForSet(array_slice($trades, 0, $mid));
            $secondScores = self::computeFactorScoresForSet(array_slice($trades, $mid));
            foreach (['risk_of_ruin', 'edge_quality', 'consistency'] as $key) {
                $delta = $secondScores[$key] - $firstScores[$key];
                $trends[$key] = $delta > 5 ? 'improving' : ($delta < -5 ? 'declining' : 'stable');
            }
        }
        // Discipline Trend IS a trend by construction (it's already the
        // first-half-vs-second-half expectancy delta) — express its own
        // sign the same way as the other three for a consistent UI instead
        // of a separate concept.
        $trends['discipline'] = $n < 20 ? 'insufficient_data' : ($disciplineScore > 55 ? 'improving' : ($disciplineScore < 45 ? 'declining' : 'stable'));

        $factors = [
            'risk_of_ruin'  => ['label' => 'Risk of Ruin',           'score' => $riskOfRuinScore,  'weight' => self::WEIGHT_RISK_OF_RUIN,  'trend' => $trends['risk_of_ruin']],
            'edge_quality'  => ['label' => 'Edge Quality',           'score' => $edgeQualityScore, 'weight' => self::WEIGHT_EDGE_QUALITY,  'trend' => $trends['edge_quality']],
            'consistency'   => ['label' => 'Execution Consistency', 'score' => $consistencyScore, 'weight' => self::WEIGHT_CONSISTENCY,   'trend' => $trends['consistency']],
            'discipline'    => ['label' => 'Discipline Trend',      'score' => $disciplineScore,  'weight' => self::WEIGHT_DISCIPLINE,    'trend' => $trends['discipline']],
        ];

        $tier = 'confident';
        if ($n === 1) $tier = 'single_trade';
        elseif ($n < 10) $tier = 'very_small_sample';
        elseif ($n < 20) $tier = 'small_sample';
        elseif ($n < self::MIN_TRADES_FOR_GRADE) $tier = 'developing';

        return [
            'gradeable' => true,
            'has_history' => true,
            'trades_journaled' => $n,
            'confidence_tier' => $tier,
            'fully_confident' => $n >= self::MIN_TRADES_FOR_GRADE,
            'trades_remaining_for_full_confidence' => max(0, self::MIN_TRADES_FOR_GRADE - $n),
            'score' => $overall,
            'stars' => self::scoreToStars($overall),
            'factors' => $factors,
            'reasons' => self::buildReasons($factors, $metrics),
            'narrative' => self::buildNarrative($overall, $factors, $metrics, $tier),
            'metrics' => $metrics,
        ];
    }

    /** Risk of Ruin / Edge Quality / Consistency scores for an arbitrary trade subset — used to diff two chronological halves for trend direction. Discipline Trend is excluded (it's already inherently a two-half comparison of the whole sequence, not a single-subset score). */
    private static function computeFactorScoresForSet($trades) {
        $metrics = self::computeRawMetrics($trades);
        return [
            'risk_of_ruin' => self::scoreRiskOfRuin($metrics),
            'edge_quality' => self::scoreEdgeQuality($metrics),
            'consistency'  => self::scoreConsistency($metrics),
        ];
    }

    /**
     * Period-scoped, ungated sibling of getScore() — used by Reports' new
     * 4-Stage Summary "Discipline Score"/"Consistency Score" tiles. Reuses
     * the exact same computeRawMetrics()/scoreConsistency()/
     * scoreDisciplineTrend() core as the full Edge Health grade (never a
     * parallel formula), just fed a period-scoped, account-view-scoped
     * trade list instead of a strategy's whole all-time history, and with
     * NO MIN_TRADES_FOR_GRADE gate — a lighter-weight read intended for a
     * single reporting period (which can be as small as 1-2 trades), not a
     * full confident grade. Callers must apply their own n-aware wording
     * (see $tier below) rather than presenting a 2-trade week with the same
     * confidence as a 100+ trade Edge Health score.
     */
    public static function getPeriodFactors($userId, $accountView, $startStr, $endStr, $svidCondition = '', $svidParams = [], $accountId = null) {
        $trades = self::fetchPeriodTradeSequence($userId, $accountView, $startStr, $endStr, $svidCondition, $svidParams, $accountId);
        $n = count($trades);

        if ($n === 0) {
            return ['n' => 0, 'tier' => 'none', 'consistency_score' => null, 'discipline_score' => null];
        }

        $metrics = self::computeRawMetrics($trades);
        $consistencyScore = self::scoreConsistency($metrics);
        $disciplineScore  = self::scoreDisciplineTrend($trades);

        // Same n-aware tiering idiom already established elsewhere in this
        // app (ProgressiveInsightsEngine's n=1 / small-n / confident split)
        // — reused here rather than inventing new thresholds. Discipline
        // Trend itself already goes neutral (50) below 20 trades internally
        // (see scoreDisciplineTrend), so the 'developing' tier below exists
        // to make that neutrality legible to the caller rather than reading
        // as a real "50/100 average" verdict.
        $tier = 'confident';
        if ($n === 1) $tier = 'single_trade';
        elseif ($n < 10) $tier = 'very_small_sample';
        elseif ($n < 20) $tier = 'small_sample';
        elseif ($n < self::MIN_TRADES_FOR_GRADE) $tier = 'developing';

        return [
            'n' => $n,
            'tier' => $tier,
            'consistency_score' => $consistencyScore,
            'discipline_score' => $disciplineScore,
        ];
    }

    /**
     * Real Risk-of-Ruin percentage (0-100, the actual probability figure —
     * not the inverted 0-100 "score" scoreRiskOfRuin() produces for Edge
     * Health's own weighted grade), for whatever period/account/strategy
     * scope the caller needs. Added 2026-08-08
     * (docs/architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md §16 Consolidated
     * #7, Wave 5a) specifically so dashboard.php's own hardcoded-exponent-20
     * gambler's-ruin formula (`pow($ratio, 20)`, disagreeing with every
     * other page's real risk%-scaled version whenever a trader's average
     * risk isn't exactly 5%, since 100/5=20 is the only risk% where the two
     * formulas coincide) could be deleted rather than kept as a second,
     * independent implementation. Same real formula
     * computeRawMetrics()/scoreRiskOfRuin() already use internally
     * (Van Tharp-style fixed-fractional approximation, capital units =
     * 100 / this trader's own real average risk%) — exposed here as a thin
     * public wrapper, not re-derived.
     *
     * $validationScope (optional, additive — 2026-08-12 Dashboard cleanup):
     * same 'exclude'/'only' escape hatch as
     * AnalyticsEngine::getFourStagePeriodSummary() — default 'exclude'
     * keeps every existing caller byte-identical; 'only' scopes to a
     * validating user's own is_validation=1 trades.
     */
    public static function getPeriodRiskOfRuin($userId, $accountView, $startStr, $endStr, $svidCondition = '', $svidParams = [], $accountId = null, $validationScope = 'exclude'): ?float {
        $trades = self::fetchPeriodTradeSequence($userId, $accountView, $startStr, $endStr, $svidCondition, $svidParams, $accountId, $validationScope);
        if (empty($trades)) return null;
        $metrics = self::computeRawMetrics($trades);
        return $metrics['risk_of_ruin_pct'];
    }

    /** Period + account-view + optional strategy-version + optional single-account scoped trade list — same shape fetchTradeSequence() returns, generalized with the extra filters getFourStagePeriodSummary() already uses. */
    private static function fetchPeriodTradeSequence($userId, $accountView, $startStr, $endStr, $svidCondition, $svidParams, $accountId = null, $validationScope = 'exclude') {
        $db = self::getConnection();
        $typeFilter = match ($accountView) {
            'prop' => "AND ta.account_type = 'prop_firm'",
            'live' => "AND ta.account_type = 'personal'",
            default => '',
        };
        $acctFilter = !empty($accountId) ? " AND t.account_id = :facctid" : '';
        $validationCond = $validationScope === 'only' ? "t.is_validation = 1" : "(t.is_validation = 0 OR t.is_validation IS NULL)";
        $pnlExpr = "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";
        $stmt = $db->prepare("
            SELECT t.id, t.risk_pct, t.risk_reward_tp1, t.closed_at, t.max_potential_loss,
                   {$pnlExpr} AS effective_pnl
            FROM trades t LEFT JOIN trading_accounts ta ON ta.id = t.account_id
            WHERE t.user_id = :uid AND t.status = 'closed' AND t.deleted_at IS NULL
              AND {$validationCond}
              AND t.closed_at >= :s AND t.closed_at <= :e {$typeFilter}{$acctFilter}" . $svidCondition . "
            ORDER BY t.closed_at ASC
        ");
        $acctParams = !empty($accountId) ? [':facctid' => $accountId] : [];
        $stmt->execute(array_merge([':uid' => $userId, ':s' => $startStr, ':e' => $endStr], $acctParams, $svidParams));
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * $accountId (optional, additive — 2026-07-29, Right Now Journal pass)
     * scopes the whole-history score to one account instead of every
     * account the strategy has ever traded on. Backward-compatible: every
     * existing caller (Reports' Edge Health card) omits it and is
     * unaffected.
     */
    private static function fetchTradeSequence($userId, $strategyVersionId, $accountId = null) {
        $db = self::getConnection();
        $svidCondition = $strategyVersionId ? ' AND t.strategy_version_id = :svid' : '';
        $acctCondition = $accountId ? ' AND t.account_id = :acctid' : '';
        $params = [':uid' => $userId];
        if ($strategyVersionId) $params[':svid'] = $strategyVersionId;
        if ($accountId) $params[':acctid'] = $accountId;

        // Same P&L rule as everywhere else: challenge_pnl for prop-firm,
        // broker_pnl for personal, with the standard fallback cascade.
        $pnlExpr = "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";
        $stmt = $db->prepare("
            SELECT t.id, t.risk_pct, t.risk_reward_tp1, t.closed_at, t.max_potential_loss,
                   {$pnlExpr} AS effective_pnl
            FROM trades t LEFT JOIN trading_accounts ta ON ta.id = t.account_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)
              {$svidCondition}{$acctCondition}
            ORDER BY t.closed_at ASC
        ");
        $stmt->execute($params);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Real, journaled-history-only metrics. Deliberately never reads a
     * strategy's declared risk%/R:R — see class doc comment.
     */
    private static function computeRawMetrics($trades) {
        $wins = 0; $losses = 0; $breakevens = 0;
        $winPnls = []; $lossPnls = [];
        $riskPcts = []; $achievedRrs = [];

        foreach ($trades as $t) {
            $pnl = (float)$t['effective_pnl'];
            if ($pnl > 0) { $wins++; $winPnls[] = $pnl; }
            elseif ($pnl < 0) { $losses++; $lossPnls[] = $pnl; }
            else { $breakevens++; }

            if ($t['risk_pct'] !== null && (float)$t['risk_pct'] > 0) $riskPcts[] = (float)$t['risk_pct'];
        }

        $decided = $wins + $losses;
        $winRate = $decided > 0 ? $wins / $decided : 0.0;
        $avgWinner = !empty($winPnls) ? array_sum($winPnls) / count($winPnls) : 0.0;
        $avgLoser  = !empty($lossPnls) ? array_sum($lossPnls) / count($lossPnls) : 0.0; // negative
        $avgRiskPct = !empty($riskPcts) ? array_sum($riskPcts) / count($riskPcts) : null;

        // Achieved reward:risk ratio from real $ outcomes — avg winner size
        // vs avg loser size, both real, never the declared/intended R:R.
        $achievedRr = ($avgLoser < 0) ? ($avgWinner / abs($avgLoser)) : null;

        // Expectancy in R-multiples (R = one unit of average risk taken):
        // E[R] = winRate * achievedRR - (1 - winRate). Standard formula.
        $expectancyR = ($achievedRr !== null) ? ($winRate * $achievedRr - (1 - $winRate)) : null;

        // Risk-of-ruin approximation (Van Tharp-style, fixed-fractional
        // risk): edge = expectancyR above; capital units the account can
        // absorb ≈ 100 / avg risk % per trade. RoR = ((1-edge)/(1+edge))^units
        // when edge > 0, else treated as certain ruin (100%). This is a
        // heuristic approximation for scoring purposes, not a rigorous
        // quant risk-of-ruin model — deliberately simple and explainable.
        $riskOfRuinPct = null;
        if ($expectancyR !== null && $avgRiskPct !== null && $avgRiskPct > 0) {
            if ($expectancyR <= 0) {
                $riskOfRuinPct = 100.0;
            } else {
                $capitalUnits = 100 / $avgRiskPct;
                $ratio = (1 - $expectancyR) / (1 + $expectancyR);
                $riskOfRuinPct = round(min(100, max(0, pow($ratio, $capitalUnits) * 100)), 2);
            }
        }

        // Coefficient of variation (stddev / mean) for risk% and achieved
        // R per trade — lower means more consistent execution.
        //
        // Achieved R must be normalized per-trade (each trade's own
        // max_potential_loss), not against one shared avgLoser dollar
        // figure — this account's trades can span wildly different dollar
        // scales (different account balances / different position sizes),
        // and dividing by a single fixed denominator mixed that scale
        // variance into what's supposed to measure R-multiple consistency.
        // Same per-trade R concept already established for Stage 3's
        // r_multiple in computeFourStageRaw() (engine.php) — reused here,
        // not a new formula. Trades missing max_potential_loss are
        // excluded from this sample rather than falling back to the
        // broken shared-denominator approach.
        //
        // CV is also only statistically meaningful for a same-signed
        // series (this is exactly why cv_risk_pct, always positive, is
        // valid as-is) — computing it directly across a mix of positive
        // (win) and negative (loss) R values is invalid: even a trader
        // with perfectly identical win sizes and perfectly identical loss
        // sizes every time (zero real variance) produces a large CV purely
        // because the series' mean sits near zero between the two signs.
        // Fixed by scoring win-R consistency and loss-R consistency
        // separately (each internally same-signed, so CV is valid on both)
        // and averaging the two — not a new metric, the same
        // coefficientOfVariation() already used for risk%, just applied to
        // two clean, same-signed input sets instead of one invalid mixed
        // one.
        $cvRiskPct = self::coefficientOfVariation($riskPcts);
        $winRs = []; $lossRs = [];
        foreach ($trades as $t) {
            $pnl = (float)$t['effective_pnl'];
            $maxLoss = ($t['max_potential_loss'] ?? null) !== null ? (float)$t['max_potential_loss'] : null;
            if ($maxLoss === null || $maxLoss <= 0) continue;
            $r = $pnl / $maxLoss;
            if ($pnl > 0) $winRs[] = $r;
            elseif ($pnl < 0) $lossRs[] = abs($r);
        }
        $cvWinR = self::coefficientOfVariation($winRs);
        $cvLossR = self::coefficientOfVariation($lossRs);
        $rConsistencyCvs = array_filter([$cvWinR, $cvLossR], fn($v) => $v !== null);
        $cvAchievedR = !empty($rConsistencyCvs) ? array_sum($rConsistencyCvs) / count($rConsistencyCvs) : null;

        return [
            'trades' => count($trades), 'wins' => $wins, 'losses' => $losses, 'breakevens' => $breakevens,
            'win_rate_pct' => round($winRate * 100, 1),
            'avg_winner' => round($avgWinner, 2), 'avg_loser' => round($avgLoser, 2),
            'avg_risk_pct' => $avgRiskPct !== null ? round($avgRiskPct, 2) : null,
            'achieved_rr' => $achievedRr !== null ? round($achievedRr, 2) : null,
            'expectancy_r' => $expectancyR !== null ? round($expectancyR, 3) : null,
            'risk_of_ruin_pct' => $riskOfRuinPct,
            'cv_risk_pct' => $cvRiskPct !== null ? round($cvRiskPct, 3) : null,
            'cv_achieved_r' => $cvAchievedR !== null ? round($cvAchievedR, 3) : null,
        ];
    }

    /**
     * Standard deviation / mean. Only valid for a same-signed series (all
     * inputs are: risk% is always positive, win-R/loss-R are pre-split by
     * sign — see computeRawMetrics()). Public (2026-08-08) — the single
     * source of truth for this formula, reused by
     * TradingArchiveEngine::aggregateGroup()'s pooled risk-CV computation
     * (docs/architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md §8b, Wave 4b)
     * instead of that class independently reimplementing the identical
     * stddev/mean math inline.
     */
    public static function coefficientOfVariation(array $values) {
        $n = count($values);
        if ($n < 2) return null;
        $mean = array_sum($values) / $n;
        if ($mean <= 0) return null;
        $variance = 0;
        foreach ($values as $v) $variance += pow($v - $mean, 2);
        $variance /= $n;
        $stddev = sqrt($variance);
        return abs($stddev / $mean);
    }

    private static function scoreRiskOfRuin($metrics) {
        if ($metrics['risk_of_ruin_pct'] === null) return 50.0; // insufficient data — neutral, not penalized
        return round(max(0, min(100, 100 - $metrics['risk_of_ruin_pct'])), 1);
    }

    private static function scoreEdgeQuality($metrics) {
        if ($metrics['expectancy_r'] === null) return 50.0;
        // expectancyR of 0 (breakeven) -> 50; +0.5R -> 100; -0.5R -> 0.
        return round(max(0, min(100, 50 + $metrics['expectancy_r'] * 100)), 1);
    }

    private static function scoreConsistency($metrics) {
        $cvs = array_filter([$metrics['cv_risk_pct'], $metrics['cv_achieved_r']], fn($v) => $v !== null);
        if (empty($cvs)) return 50.0;
        $avgCv = array_sum($cvs) / count($cvs);
        return round(max(0, min(100, 100 - min(1.0, $avgCv) * 100)), 1);
    }

    /** Chronological first-half vs second-half expectancy-in-R comparison. */
    private static function scoreDisciplineTrend($trades) {
        $n = count($trades);
        if ($n < 20) return 50.0; // too few to split meaningfully — neutral
        $mid = (int)floor($n / 2);
        $firstHalf = array_slice($trades, 0, $mid);
        $secondHalf = array_slice($trades, $mid);

        $expectancy = function($set) {
            $wins = 0; $losses = 0; $winPnls = []; $lossPnls = [];
            foreach ($set as $t) {
                $pnl = (float)$t['effective_pnl'];
                if ($pnl > 0) { $wins++; $winPnls[] = $pnl; }
                elseif ($pnl < 0) { $losses++; $lossPnls[] = $pnl; }
            }
            $decided = $wins + $losses;
            if ($decided === 0) return null;
            $winRate = $wins / $decided;
            $avgWinner = !empty($winPnls) ? array_sum($winPnls) / count($winPnls) : 0.0;
            $avgLoser = !empty($lossPnls) ? array_sum($lossPnls) / count($lossPnls) : 0.0;
            if ($avgLoser >= 0) return null;
            $rr = $avgWinner / abs($avgLoser);
            return $winRate * $rr - (1 - $winRate);
        };

        $firstE = $expectancy($firstHalf);
        $secondE = $expectancy($secondHalf);
        if ($firstE === null || $secondE === null) return 50.0;

        return round(max(0, min(100, 50 + ($secondE - $firstE) * 100)), 1);
    }

    private static function scoreToStars($score) {
        if ($score >= 90) return 5;
        if ($score >= 75) return 4;
        if ($score >= 55) return 3;
        if ($score >= 35) return 2;
        return 1;
    }

    /**
     * Word a trend direction the way a coach would say it out loud — in
     * terms of the real-world quantity that factor tracks, never the raw
     * 0-100 score. This matters: for Risk of Ruin, a rising SCORE means
     * risk of ruin is FALLING (good) — a generic "trending up" phrase
     * attached right after "risk of ruin is 91%" would read backwards
     * (sounds like risk is increasing when it's actually the score, i.e.
     * safety, improving). Each factor gets its own correctly-directional
     * phrasing instead of one ambiguous shared sentence.
     */
    private static function trendPhrase($factorKey, $trend) {
        $phrases = [
            'risk_of_ruin' => [
                'improving' => 'and that risk-of-ruin exposure has been shrinking over your recent trade history',
                'declining' => 'and that risk-of-ruin exposure has been growing over your recent trade history, worth attention now, before it compounds',
                'stable'    => 'and that exposure has held steady over your recent trade history',
            ],
            'edge_quality' => [
                'improving' => 'and the edge has been strengthening over your recent trade history',
                'declining' => 'and the edge has been weakening over your recent trade history, worth understanding why before assuming it will bounce back',
                'stable'    => 'and it has held steady over your recent trade history',
            ],
            'consistency' => [
                'improving' => 'and execution has been getting more consistent over your recent trade history',
                'declining' => 'and execution has been getting less consistent over your recent trade history',
                'stable'    => 'and consistency has held steady over your recent trade history',
            ],
        ];
        return $phrases[$factorKey][$trend] ?? ''; // insufficient_data — say nothing rather than guess
    }

    /**
     * Every factor gets real coaching (what it measures, current real
     * value, trend, what to do about it) — not just the weakest two.
     * 2026-07-28 (item 4): the weakest-2 "reasons" list from before is kept
     * (still useful as a short summary — e.g. a compact card subtitle) but
     * is no longer the only coaching surface; buildNarrative() below covers
     * all four unconditionally, per the explicit instruction that Edge
     * Health should explain what's improving, deteriorating, and worth
     * attention across every dimension, not just the bottom two.
     */
    private static function buildReasons($factors, $metrics) {
        $sorted = $factors;
        uasort($sorted, fn($a, $b) => $a['score'] <=> $b['score']);
        $weakest = array_slice($sorted, 0, 2, true);

        $reasons = [];
        foreach ($weakest as $key => $f) {
            $reasons[] = match ($key) {
                'risk_of_ruin' => $metrics['risk_of_ruin_pct'] !== null
                    ? "Estimated risk of ruin is " . number_format($metrics['risk_of_ruin_pct'], 1) . "% at your average risk per trade (" . ($metrics['avg_risk_pct'] ?? '-') . "%)."
                    : "Not enough consistent risk-sizing data to estimate risk of ruin.",
                'edge_quality' => $metrics['expectancy_r'] !== null
                    ? "Real expectancy is " . number_format($metrics['expectancy_r'], 2) . "R per trade (win rate " . $metrics['win_rate_pct'] . "%, achieved reward:risk " . ($metrics['achieved_rr'] ?? '-') . ":1)."
                    : "Not enough win/loss data yet to compute real expectancy.",
                'consistency' => ($metrics['cv_risk_pct'] !== null || $metrics['cv_achieved_r'] !== null)
                    ? "Risk sizing and/or realized R vary significantly trade-to-trade, and execution isn't yet repeatable."
                    : "Not enough data to assess execution consistency.",
                'discipline' => "Expectancy trend across your trade history is flat or declining, not improving.",
                default => $f['label'] . ' is the weakest factor.',
            };
        }
        return $reasons;
    }

    /**
     * Per-factor coaching sentences, one per factor, always all four —
     * this is the primary Edge Health surface as of the 2026-07-28
     * redesign. Each answers what happened / why it matters / what to do,
     * per the explicit "sound like an experienced performance coach"
     * instruction. Returned keyed the same as $factors so the caller can
     * render one narrative line directly beside each factor's score.
     */
    private static function buildNarrative($overall, $factors, $metrics, $tier) {
        // Joins a base clause with an optional trend clause, cleanly — when
        // the trend is 'insufficient_data' (trendPhrase() returns ''), this
        // must NOT leave a dangling " . " artifact (e.g. "around 100.0% .
        // That's..."), which is exactly what a naive string concatenation
        // produced before this was fixed.
        $withTrend = function($base, $factorKey) use ($factors) {
            $t = self::trendPhrase($factorKey, $factors[$factorKey]['trend']);
            return $t === '' ? "{$base}." : "{$base}, {$t}.";
        };

        $lines = [];

        $lines['risk_of_ruin'] = $metrics['risk_of_ruin_pct'] !== null
            ? $withTrend("At your real average risk per trade (" . ($metrics['avg_risk_pct'] ?? '-') . "%), the math puts long-run risk of ruin around " . number_format($metrics['risk_of_ruin_pct'], 1) . "%", 'risk_of_ruin') . " "
                . ($metrics['risk_of_ruin_pct'] < 5
                    ? "That's a comfortable margin: your position sizing isn't the thing that will end this strategy."
                    : ($metrics['risk_of_ruin_pct'] < 25
                        ? "That's a real number, not an alarming one, but it moves fast if either win rate or risk per trade slips, so it's worth watching rather than ignoring."
                        : "That's high enough to matter: either the risk taken per trade or the edge itself needs to improve before this position sizing is sustainable long-term."))
            : "Not enough consistent risk-sizing history yet to estimate risk of ruin with any confidence.";

        $lines['edge_quality'] = $metrics['expectancy_r'] !== null
            ? $withTrend("Real expectancy is " . number_format($metrics['expectancy_r'], 2) . "R per trade (win rate " . $metrics['win_rate_pct'] . "%, achieved reward:risk " . ($metrics['achieved_rr'] ?? '-') . ":1)", 'edge_quality') . " "
                . ($metrics['expectancy_r'] > 0.2
                    ? "That's a genuine, real edge: the strategy is making money on average, not just on its best trades."
                    : ($metrics['expectancy_r'] > 0
                        ? "That's a thin but real positive edge, small enough that a short losing stretch can feel like it's not working, even though the long-run math is still in your favor."
                        : "That's break-even or negative. Right now this strategy is not making money on average. Either the win rate or the reward:risk being achieved needs to move before more volume helps."))
            : "Not enough decided (win/loss) trades yet to compute real expectancy.";

        $lines['consistency'] = ($metrics['cv_risk_pct'] !== null || $metrics['cv_achieved_r'] !== null)
            ? $withTrend("Risk sizing and realized R-multiples " . ($factors['consistency']['score'] >= 70 ? "stay tight from trade to trade" : "swing noticeably from trade to trade"), 'consistency') . " "
                . ($factors['consistency']['score'] >= 70
                    ? "That repeatability is what makes the other numbers on this card trustworthy: a strategy executed the same way every time is easier to size correctly."
                    : "Real inconsistency here means the expectancy and risk-of-ruin figures above are noisier than they look: the same strategy played two different ways isn't really one strategy yet.")
            : "Not enough repeated risk-sizing data yet to assess execution consistency.";

        $lines['discipline'] = $factors['discipline']['trend'] === 'insufficient_data'
            ? "Not enough trade history yet to compare early execution against recent execution."
            : ($factors['discipline']['trend'] === 'improving'
                ? "Expectancy in your more recent trades is running ahead of your earlier trades: real behavioral improvement, not statistical noise."
                : ($factors['discipline']['trend'] === 'declining'
                    ? "Expectancy in your more recent trades has slipped compared to your earlier trades, worth a honest look at what's changed (sizing, setups taken, state of mind) before it becomes a pattern."
                    : "Expectancy has stayed roughly flat between your earlier and more recent trades: steady, not yet trending either direction."));

        // The ONE place sample confidence is allowed to appear, per explicit
        // instruction — everything above is about what the numbers show,
        // this is the only sentence about how much to trust them yet.
        $confidenceLine = match ($tier) {
            'single_trade'      => "One trade is a starting point, not a verdict: every score above will move a lot with the next few trades.",
            'very_small_sample' => "This is still a very small sample, so treat the scores above as an early read, not a final grade.",
            'small_sample'      => "The sample is still small enough that these scores can move meaningfully with the next handful of trades.",
            'developing'        => "Confidence in these numbers is building but not yet full: they'll keep stabilizing as more trades close.",
            'confident'         => "This sample is large enough that these scores reflect real, statistically stable behavior, not noise.",
            default             => "",
        };

        return ['factor_lines' => $lines, 'confidence_line' => $confidenceLine];
    }
}
