<?php
/**
 * ════════════════════════════════════════════════════════════════════
 * RiskPanelEngine — Active Coaching's replacement 3-card Risk Panel.
 * ────────────────────────────────────────────────────────────────────
 * Built 2026-08-17, replacing Right Now's old single, merged "Coaching
 * Recommendation" block (rnComputeRiskRecommendation(), still used
 * elsewhere — see its own doc comment — for the deeper "Full Coaching
 * Analysis" workspace only) with three deliberately INDEPENDENT systems,
 * per an explicit business spec (a real drawdown-tier sizing walkthrough):
 *
 *   1. overallLossRiskCard()  — static drawdown-tier position sizing.
 *   2. lossStreakCard()       — a pure consecutive-loss circuit breaker.
 *   3. dailyLossGuardCard()   — today's real loss budget only.
 *
 * THE CORE RULE THIS FILE ENFORCES: these three answer three genuinely
 * different questions and must never be blended into one number or one
 * multiplier. A trade's risk sizing tier does not change because of a
 * losing streak; a losing streak is not "worse" because the account also
 * happens to be near its daily limit. Each card computes its own status
 * from its own inputs only — see each method's own doc comment.
 *
 * SINGLE SOURCE OF TRUTH: every dollar figure this file touches (overall
 * drawdown %, daily drawdown $, phase-aware balance) is read from
 * PropFirmRuleEngine::compute() — never re-derived. This file only adds
 * the TIER/STATUS/COPY layer on top of numbers that engine already
 * computed. Live/personal accounts have no firm-declared limit at all, so
 * their equivalent is a clearly-labeled informal ceiling (see
 * liveOverallDrawdown()) — never presented as if it were a real rule.
 *
 * WORKFLOW SCOPING: cards 1 and 3 are deliberately account-level, dollar-
 * exposure math — never filtered by is_validation, same reasoning as
 * PropFirmRuleEngine/save-goal.php (a trader's real drawdown/daily-loss
 * exposure doesn't care which workflow labeled a given trade). Card 2 is
 * a BEHAVIORAL signal, so it must only ever see the trades of whichever
 * workflow is currently active — it never queries anything itself, it
 * only ever walks whatever $rows its caller hands it, and every real
 * caller in this app (rnBuildJourney()/rnBuildLiveSegment() in
 * journal.php) already scopes those rows via validationScopeSql() before
 * this file ever sees them. fetchScopedRowsForReview() below is the one
 * exception — a small, explicit, workflow-aware re-fetch used ONLY by the
 * server-side review-clearance endpoint, which has no page-rendered rows
 * to reuse.
 * ════════════════════════════════════════════════════════════════════
 */
require_once __DIR__ . '/PropFirmRuleEngine.php';

class RiskPanelEngine
{
    /** Loss-run thresholds, per the business spec — never re-tune ad hoc. */
    public const PAUSE_DAY_THRESHOLD = 3;
    public const PAUSE_2DAY_THRESHOLD = 5;
    public const FULL_STOP_THRESHOLD = 8;
    public const PROBATION_TRADES = 3;

    /** A trade with |pnl| below this is treated as a true breakeven — neither a win nor a loss for streak purposes. */
    private const FLAT_EPSILON = 0.005;

    // ────────────────────────────────────────────────────────────────
    // CARD 1 — Overall Loss Risk (static drawdown-tier sizing)
    // ────────────────────────────────────────────────────────────────

    /**
     * $overallDrawdown must be PropFirmRuleEngine::compute()'s own
     * 'overall_drawdown' sub-array (or the live-account equivalent from
     * liveOverallDrawdown() below) — never a re-derived figure.
     * $currentBalance is the account's own real current balance (prop:
     * $cm['phase_balance']; live: current equity) — needed only to state
     * the recovery target in dollars.
     */
    public static function overallLossRiskCard(float $currentBalance, array $overallDrawdown, bool $isProp): array
    {
        $limitPct = (float)($overallDrawdown['limit_percentage'] ?? 0);
        $curPct = (float)($overallDrawdown['current_percentage'] ?? 0);
        $currentAmount = (float)($overallDrawdown['current_amount'] ?? 0);
        $isBreached = $isProp && (!empty($overallDrawdown['is_breached']) || ($limitPct > 0 && $curPct >= $limitPct));
        $referenceBalance = $currentBalance + $currentAmount; // the balance a 0%-drawdown reading would show
        $referenceLabel = ($overallDrawdown['max_loss_model'] ?? 'fixed') === 'trailing' ? 'peak balance' : 'starting balance';
        if (!$isProp) $referenceLabel = 'peak equity';

        if ($limitPct <= 0) {
            return self::card('Overall Loss Risk', 'NOT SET', 'gray', 'N/A',
                'No drawdown limit is configured for this account.',
                "This account has no maximum drawdown rule on record.",
                [['label' => 'Current Balance', 'value' => '$' . number_format($currentBalance, 2)]]
            );
        }

        $halfLimit = $limitPct / 2;
        if ($isBreached) {
            $tier = 'breached'; $riskPct = 0.0;
            $statusLabel = 'BREACHED'; $color = 'red';
            $nextAction = $isProp
                ? 'STOP. Drawdown cap breached. Do not place trades.'
                : 'STOP. This account has fallen well below a safe range.';
        } elseif ($curPct <= 0.0001) {
            $tier = 'tier1'; $riskPct = 1.00; $statusLabel = 'HEALTHY'; $color = 'green';
            $nextAction = 'Trade at 1.00%. No drawdown restriction active.';
        } elseif ($curPct < $halfLimit) {
            $tier = 'tier2'; $riskPct = 0.50; $statusLabel = 'REDUCED SIZING'; $color = 'yellow';
            $nextAction = 'Trade at 0.50% until equity recovers above $' . number_format($referenceBalance, 2) . '.';
        } else {
            $tier = 'tier3'; $riskPct = 0.25; $statusLabel = 'DEFENSIVE SIZING'; $color = 'orange';
            $nextAction = 'Trade at 0.25%. You\'re in the second half of the drawdown ' . ($isProp ? 'cap.' : 'range.');
        }

        $headroomPct = max(0.0, $limitPct - $curPct);
        $capWord = $isProp ? 'cap' : 'ceiling';
        $subLine = sprintf(
            '%s%% drawdown from your $%s %s. %s%% headroom left before the %s%% %s.',
            number_format($curPct, 2), number_format($referenceBalance, 2), $referenceLabel,
            number_format($headroomPct, 2), number_format($limitPct, 2), $capWord
        );

        return self::card('Overall Loss Risk', $statusLabel, $color,
            $tier === 'breached' ? '0.00%' : number_format($riskPct, 2) . '%',
            $subLine, $nextAction,
            [
                ['label' => 'Current Balance', 'value' => '$' . number_format($currentBalance, 2)],
                ['label' => 'Recovery Target', 'value' => '$' . number_format($referenceBalance, 2)],
            ],
            ['tier' => $tier, 'recommended_risk_pct' => $riskPct]
        );
    }

    /**
     * Live/personal accounts have no firm-declared drawdown limit — this
     * produces the SAME shape overallLossRiskCard() expects, sourced from
     * the informal 20%-from-peak-equity ceiling already established
     * elsewhere in this app (rnComputeRiskRecommendation()'s own
     * documented convention) rather than a second, separately-invented
     * number.
     */
    public static function liveOverallDrawdown(float $equity, float $peakEquity): array
    {
        $limitPct = 20.0;
        $amount = max(0.0, $peakEquity - $equity);
        $pct = $peakEquity > 0 ? ($amount / $peakEquity) * 100 : 0.0;
        return [
            'current_amount' => round($amount, 2),
            'current_percentage' => round($pct, 2),
            'limit_percentage' => $limitPct,
            'limit_amount' => round($peakEquity * ($limitPct / 100), 2),
            'is_breached' => false, // personal accounts have no breach concept
            'max_loss_model' => 'fixed',
        ];
    }

    // ────────────────────────────────────────────────────────────────
    // CARD 3 — Daily Loss Guard (today only)
    // ────────────────────────────────────────────────────────────────

    /**
     * $dailyDrawdown must be PropFirmRuleEngine::compute()'s own
     * 'daily_drawdown' sub-array, or null for an account with no such
     * concept (every live/personal account today). Never fabricates a
     * limit when one isn't configured — an absent daily rule is itself
     * shown, not hidden.
     */
    public static function dailyLossGuardCard(?array $dailyDrawdown): array
    {
        $limitAmount = (float)($dailyDrawdown['limit_amount'] ?? 0);
        if ($dailyDrawdown === null || $limitAmount <= 0) {
            return self::card('Daily Loss Guard', 'NOT SET', 'gray', 'N/A',
                'This account has no daily loss limit configured. Overall drawdown tiers (Card 1) still apply.',
                'No action needed. Nothing to track today.',
                []
            );
        }

        $lossToday = (float)$dailyDrawdown['current_amount'];
        $remaining = (float)($dailyDrawdown['remaining_amount'] ?? max(0.0, $limitAmount - $lossToday));
        $usedPct = $limitAmount > 0 ? ($lossToday / $limitAmount) * 100 : 0.0;
        $netPnl = (float)($dailyDrawdown['net_pnl'] ?? -$lossToday);
        $tradeCount = (int)($dailyDrawdown['trade_count'] ?? 0);

        if ($lossToday <= 0.005) {
            $statusLabel = 'CLEAR'; $color = 'green';
            $nextAction = 'No restriction. Trade your plan.';
        } elseif ($usedPct < 50) {
            $statusLabel = 'ON TRACK'; $color = 'green';
            $nextAction = 'No restriction. Trade your plan.';
        } elseif ($usedPct < 80) {
            $statusLabel = 'CAUTION'; $color = 'yellow';
            $nextAction = 'Past half your daily budget. Tighten setups to A+ only.';
        } elseif ($usedPct < 100) {
            $statusLabel = 'NEAR LIMIT'; $color = 'orange';
            $nextAction = 'Close to today\'s limit. One more losing trade may end your day.';
        } else {
            $statusLabel = 'LIMIT HIT'; $color = 'red';
            $nextAction = 'STOP. Daily loss limit reached. No more trades today.';
        }

        // Baseline disclosure (2026-08-18, trailing daily baseline fix) —
        // PropFirmRuleEngine::compute() now derives limit_amount from the
        // balance actually carried INTO today (starting balance plus any
        // prior trading days' own P&L on this phase), not the account's
        // permanently-fixed original size. Only mentioned when it
        // genuinely differs — a fresh phase with no prior-day trades has
        // baseline === starting balance, and the plain sub-line is
        // already fully accurate for that case.
        $baselineNote = !empty($dailyDrawdown['baseline_is_trailing']) && isset($dailyDrawdown['baseline_balance'])
            ? sprintf(' (%s%% of $%s, carried in from prior trading days.)', number_format((float)($dailyDrawdown['limit_percentage'] ?? 0), 2), number_format((float)$dailyDrawdown['baseline_balance'], 2))
            : '';
        $subLine = sprintf('$%s lost of $%s daily limit%s', number_format($lossToday, 2), number_format($limitAmount, 2), $baselineNote !== '' ? '.' . $baselineNote : '.');
        // Reset-time disclosure (2026-08-18) — the boundary this whole
        // card is measured against is only ever authoritative when the
        // trader explicitly configured a real reset time on this account
        // (challenge_accounts.rules.daily_reset_time/_timezone, read by
        // PropFirmRuleEngine::compute()). When it wasn't configured, the
        // window falls back to a plain calendar day (server time) — never
        // presented as if it were the firm's own real reset moment. This
        // must say so plainly rather than silently implying certainty.
        $resetNote = !empty($dailyDrawdown['reset_configured'])
            ? ' Resets at ' . $dailyDrawdown['reset_description'] . '.'
            : ' No reset time is configured for this account, so today\'s boundary is based on your equity movement for the calendar day so far. Verify the exact reset time on your prop firm\'s own dashboard.';

        $card = self::card('Daily Loss Guard', $statusLabel, $color,
            '$' . number_format(max(0.0, $remaining), 2),
            $subLine . $resetNote, $nextAction,
            [
                ['label' => 'Trades Closed Today', 'value' => (string)$tradeCount],
                ['label' => 'Realized P&L Today', 'value' => ($netPnl >= 0 ? '+' : '-') . '$' . number_format(abs($netPnl), 2)],
            ]
        );
        $card['big_unit'] = 'left today';
        return $card;
    }

    // ────────────────────────────────────────────────────────────────
    // CARD 2 — Loss Streak (circuit breaker)
    // ────────────────────────────────────────────────────────────────

    /**
     * $rows must be the caller's already-fetched, chronological (oldest
     * first) trade rows for the CURRENT phase/segment, shape
     * [['trade' => [...], 'pnl' => float, 'balance' => float], ...] —
     * exactly what rnBuildJourney()/rnBuildLiveSegment() already produce.
     * No new query — every field this method needs (pnl, closed_at,
     * captured_at) is already on those rows.
     *
     * $latestReview is the most recent risk_streak_reviews row for this
     * account (['cleared_at' => ..., ...]) or null.
     *
     * THE COUNTER RULE (non-negotiable, per the business spec): a pause
     * never resets the streak — only a real WIN does. A breakeven trade
     * resets it neutrally (matches the identical convention already
     * established elsewhere in this codebase — see
     * TradingArchiveEngine::segmentMetrics()'s own "breakevens reset the
     * streak neutrally" comment). Pause windows (day / 2-day / manual
     * review) are checkpoints WITHIN one ongoing streak, never separate
     * counters of their own.
     */
    public static function lossStreakCard(array $rows, ?array $latestReview = null, ?\DateTimeImmutable $now = null): array
    {
        $now = $now ?? new \DateTimeImmutable('now');

        $seq = [];
        foreach ($rows as $r) {
            $pnl = (float)($r['pnl'] ?? 0.0);
            $dateStr = $r['trade']['closed_at'] ?? $r['trade']['captured_at'] ?? null;
            $seq[] = ['pnl' => $pnl, 'date' => $dateStr ? new \DateTimeImmutable($dateStr) : null];
        }

        if (empty($seq)) {
            return self::card('Loss Streak', 'CLEAR', 'green', '0',
                'No trades recorded yet on this segment.', 'No action needed.',
                [], ['streak' => 0, 'worst_streak' => 0, 'probation_remaining' => 0]
            ) + ['big_unit' => 'consecutive losses'];
        }

        $streak = 0;
        $worst = 0;
        $worstPriorToRun = 0; // worst streak on record BEFORE the currently-open run started
        $lastWinAt = null;
        $manualReviewOwed = false;
        $pauseActiveNow = false;
        $pauseKind = null;    // 'day' | '2day' | null
        $mostRecentResolution = null; // \DateTimeImmutable|null — when the LATEST applicable pause actually lifted (auto or manually cleared)

        foreach ($seq as $t) {
            if ($t['pnl'] > self::FLAT_EPSILON) {
                // WIN — the only thing that ever resets the counter.
                $worstPriorToRun = $worst;
                $streak = 0;
                $lastWinAt = $t['date'];
                $manualReviewOwed = false;
                $pauseActiveNow = false;
                $pauseKind = null;
                continue;
            }
            if ($t['pnl'] < -self::FLAT_EPSILON) {
                $streak++;
                if ($streak > $worst) $worst = $streak;

                if ($streak === self::PAUSE_DAY_THRESHOLD && $t['date'] !== null) {
                    $resolved = (new \DateTimeImmutable($t['date']->format('Y-m-d')))->modify('+1 day');
                    if ($resolved <= $now) { $mostRecentResolution = $resolved; $pauseActiveNow = false; }
                    else { $pauseActiveNow = true; $pauseKind = 'day'; $mostRecentResolution = null; }
                }
                if ($streak === self::PAUSE_2DAY_THRESHOLD && $t['date'] !== null) {
                    $resolved = $t['date']->modify('+2 days');
                    if ($resolved <= $now) { $mostRecentResolution = $resolved; $pauseActiveNow = false; }
                    else { $pauseActiveNow = true; $pauseKind = '2day'; $mostRecentResolution = null; }
                }
                if ($streak >= self::FULL_STOP_THRESHOLD) {
                    $manualReviewOwed = true;
                    $mostRecentResolution = null;
                    if ($latestReview && !empty($latestReview['cleared_at']) && $t['date'] !== null) {
                        $clearedAt = new \DateTimeImmutable($latestReview['cleared_at']);
                        if ($clearedAt >= $t['date']) {
                            $manualReviewOwed = false;
                            $mostRecentResolution = $clearedAt;
                        }
                    }
                }
                continue;
            }
            // Breakeven — resets neutrally, same as a win, but isn't itself a "win" for lastWinAt.
            $worstPriorToRun = $worst;
            $streak = 0;
            $manualReviewOwed = false;
            $pauseActiveNow = false;
            $pauseKind = null;
        }

        $probationRemaining = 0;
        if ($mostRecentResolution !== null) {
            $tradesSince = 0;
            foreach ($seq as $t) {
                if ($t['date'] !== null && $t['date'] > $mostRecentResolution) $tradesSince++;
            }
            $probationRemaining = max(0, self::PROBATION_TRADES - $tradesSince);
        }

        // ── Status pill — keyed on the real streak count, matching the
        // spec's literal table, with the elapsed-time branches it
        // explicitly calls for applied consistently across all three
        // thresholds (not just the 5-7 range the spec spelled out).
        if ($streak <= 2) {
            $statusLabel = 'CLEAR'; $color = 'green';
        } elseif ($streak === 3) {
            [$statusLabel, $color] = $pauseActiveNow ? ['PAUSED FOR TODAY', 'yellow'] : ['STATE CHECK', 'yellow'];
        } elseif ($streak === 4) {
            $statusLabel = 'STATE CHECK'; $color = 'yellow';
        } elseif ($streak <= 7) {
            [$statusLabel, $color] = $pauseActiveNow ? ['PAUSED, 2 DAYS', 'orange'] : ['CAUTION', 'orange'];
        } else {
            [$statusLabel, $color] = $manualReviewOwed ? ['FULL STOP', 'red'] : ['REVIEWED, CAUTION', 'orange'];
        }

        // ── Next-action line — the spec's own literal wording where it
        // gave one, a plain, consistent extension where it didn't. Kept
        // to psychological/discipline coaching only, never technical
        // market analysis (2026-08-18 correction — an earlier draft told
        // the trader to "check for HTF chop or a thin session," which is
        // market-analysis advice, not something this app knows about the
        // trader's own strategy or is entitled to instruct on).
        if ($streak <= 2) {
            $nextAction = 'No action needed.';
        } elseif ($streak === 3) {
            $nextAction = $pauseActiveNow
                ? 'Stop trading for the rest of today, even if another session is open.'
                : 'Today\'s pause has passed. Do a quick self-check before your next trade.';
        } elseif ($streak === 4) {
            $nextAction = 'Do a quick self-check before your next trade. Not a mandatory stop.';
        } elseif ($streak <= 7) {
            if ($pauseActiveNow) {
                $nextAction = 'Stop for 2 full days. This is logged as a Streak Event.';
            } elseif ($streak > $worstPriorToRun) {
                $nextAction = 'Beyond your worst recorded streak. Trade cautiously.';
            } else {
                $nextAction = 'The 2-day pause has passed. Trade cautiously and watch for further deterioration.';
            }
        } else {
            $nextAction = $manualReviewOwed
                ? 'Full stop. Do not re-enter until you complete a manual review (confirm there was no data or platform issue, your own trading rules were followed, and you are calm and clear-headed).'
                : 'Review completed. Trade with extreme caution. You\'re well past a normal losing stretch.';
        }

        $subLine = $lastWinAt !== null
            ? sprintf('Since your last win on %s. Your worst streak on record is %d.', $lastWinAt->format('j M Y'), $worst)
            : sprintf('You haven\'t recorded a winning trade yet. Your worst streak on record is %d.', $worst);

        $secondary = [
            ['label' => 'Last Win', 'value' => $lastWinAt !== null ? $lastWinAt->format('j M Y') : 'N/A'],
        ];
        if ($probationRemaining > 0) {
            $secondary[] = ['label' => 'Probation Trades Left', 'value' => (string)$probationRemaining];
        }

        $card = self::card('Loss Streak', $statusLabel, $color, (string)$streak, $subLine, $nextAction, $secondary, [
            'streak' => $streak, 'worst_streak' => $worst,
            'manual_review_owed' => $manualReviewOwed,
            'probation_remaining' => $probationRemaining,
        ]);
        $card['big_unit'] = $streak === 1 ? 'consecutive loss' : 'consecutive losses';
        if ($probationRemaining > 0) {
            $card['probation_note'] = sprintf(
                'Probation: trade at half your normal tier size for the next %d trade%s.',
                $probationRemaining, $probationRemaining === 1 ? '' : 's'
            );
        }
        return $card;
    }

    // ────────────────────────────────────────────────────────────────
    // Manual review clearance — the one piece of state Card 2 can't
    // derive purely from trade history + the clock.
    // ────────────────────────────────────────────────────────────────

    /** Most recent risk_streak_reviews row for this account, or null. */
    public static function latestReview(string $accountId, $db): ?array
    {
        $rows = $db->raw(
            "SELECT id, streak_at_review, notes, cleared_at FROM risk_streak_reviews WHERE account_id = :aid ORDER BY cleared_at DESC LIMIT 1",
            [':aid' => $accountId]
        );
        return $rows[0] ?? null;
    }

    /**
     * Workflow-aware re-fetch used ONLY by the server-side review-
     * clearance endpoint (api/journal/clear-streak-review.php), which has
     * no page-rendered $rows to reuse. Prop: reuses
     * PropFirmRuleEngine::currentPhaseTrades() (the same phase-scoped set
     * Card 1's own drawdown is anchored to) filtered in PHP by workflow.
     * Personal: the account's whole real trade history, same filter.
     * Never a re-derivation of JourneySegmentation's phase logic.
     */
    public static function fetchScopedRowsForReview(string $accountId, string $userId, $db): array
    {
        $account = $db->selectById('trading_accounts', $accountId);
        if (!$account) return [];
        $workflowState = ValidationService::getWorkflowState($userId, $db);
        $wantValidation = $workflowState === 'validation';

        if (($account['account_type'] ?? '') === 'prop_firm') {
            $trades = PropFirmRuleEngine::currentPhaseTrades($accountId);
        } else {
            $cond = validationScopeSql($workflowState, 't');
            $trades = $db->raw(
                "SELECT id, closed_at, captured_at, is_validation, COALESCE(broker_pnl, projected_pnl, 0) AS effective_pnl
                 FROM trades t WHERE t.account_id = :aid AND t.user_id = :uid AND t.status = 'closed' AND t.deleted_at IS NULL
                   AND {$cond}
                 ORDER BY t.captured_at ASC",
                [':aid' => $accountId, ':uid' => $userId]
            );
        }

        $rows = [];
        foreach ($trades as $t) {
            $isVal = !empty($t['is_validation']);
            if ($isVal !== $wantValidation) continue; // currentPhaseTrades() is unscoped — filter here
            $rows[] = ['trade' => $t, 'pnl' => (float)($t['effective_pnl'] ?? 0.0)];
        }
        return $rows;
    }

    // ────────────────────────────────────────────────────────────────

    private static function card(string $title, string $statusLabel, string $color, string $bigValue, string $subLine, string $nextAction, array $secondary, array $extra = []): array
    {
        return [
            'title' => $title,
            'status_label' => $statusLabel,
            'status_color' => $color, // green | yellow | orange | red | gray
            'big_value' => $bigValue,
            'big_unit' => null,
            'sub_line' => $subLine,
            'next_action' => $nextAction,
            'secondary' => $secondary,
        ] + $extra;
    }
}
