<?php
/**
 * ════════════════════════════════════════════════════════════════════
 * PropFirmRuleEngine — Single Source of Truth for Phase-Aware Prop-Firm
 * Boundary Math
 * ────────────────────────────────────────────────────────────────────
 * Built 2026-08-07, per docs/architecture/SINGLE_SOURCE_OF_TRUTH_PROPOSAL.md
 * §1a, in response to docs/architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md's
 * findings — the single largest concentration of duplicated computation
 * found in that audit: five-plus independent implementations of "what is
 * this account's phase-aware balance / drawdown / breach status / pass
 * progress," several of them actively disagreeing (see the audit's
 * Consolidated List, items #2/#3/#6/#8/#13/#19/#24).
 *
 * THE ONE RULE THIS CLASS EXISTS TO ENFORCE: a prop-firm account's
 * "current balance" for any active/completed challenge phase is that
 * phase's own fresh account_size plus only that phase's own real closed
 * trades' effective P&L — never the whole-account, all-phases-blended
 * running ledger. See CLAUDE.md's "Single Source of Truth Rule (Core
 * Account Metrics)" for the full incident history this class is the
 * direct architectural response to.
 *
 * PHASE DETERMINATION — the single most-repeated bug class in the
 * incident this class fixes: `trading_accounts.lifecycle_state` is the
 * ONLY input ever used to determine which phase an account is actively
 * progressing through. `challenge_accounts.current_stage` is read in
 * exactly one place below (as a last-resort fallback for genuinely
 * terminal/unmapped lifecycle states — breached, closed, archived —
 * where phase math is moot anyway and current_stage cannot be
 * "dangerously stale" the way it can for an account still actively
 * being promoted through phases). It is NEVER read as the primary or
 * even secondary signal for an account that could still be mid-
 * evaluation. This mirrors the exact, deliberate design already
 * verified correct in AnalyticsEngine::getChallengeMetrics() as of the
 * 2026-08-07 fix — ported here, not reinvented.
 *
 * DESIGN: one comprehensive private computation (compute()) does the
 * real work in a single pass; every public method is a thin slice of
 * that SAME result. This is deliberate — it makes it structurally
 * impossible for two of this class's own methods to disagree with each
 * other, since they're never two separate formulas, only two different
 * views onto one shared computation.
 *
 * ── SINGLE SOURCE OF TRUTH CONSOLIDATION (2026-08-17) ──────────────────
 * As of this date, this is genuinely the ONLY implementation of the
 * phase-aware breach/pass boundary formula anywhere in the codebase.
 * AnalyticsEngine::getChallengeMetrics() — previously an independently-
 * maintained ~250-line duplicate of this exact math, and critically the
 * one dashboard.php's resolve_trade_id (the sole lifecycle mutator) and
 * drafts.php's live pre-check both called — is now a thin wrapper around
 * this class. Every consumer in the app (Calculator, Dashboard, Drafts,
 * Journal, Trading Archive, Trading Center, Colex) reads phase/balance/
 * drawdown/breach/pass figures that ultimately come from ONE call to
 * compute() below. See scripts/verify_prop_firm_rule_engine.php for the
 * golden-value regression that proves this.
 *
 * ── MAXIMUM LOSS MODEL (2026-08-17) ─────────────────────────────────────
 * A prop-firm account's overall-drawdown breach floor is no longer
 * assumed to be Fixed (anchored to the phase's own starting_balance
 * forever). `challenge_accounts.rules.max_loss_model` (stored per
 * account, not per phase — a firm's chosen drawdown MODEL doesn't vary
 * phase to phase, only its percentage does, which is already phase-aware
 * via resolveRules()) selects between:
 *   - 'fixed' (default, including for every pre-existing account whose
 *     rules JSON has no such key at all — byte-identical to this class's
 *     original, only-ever-Fixed behavior): breachLevel = startingBalance
 *     * (1 - pct). Never moves.
 *   - 'trailing': breachLevel = peakBalance * (1 - pct), where
 *     peakBalance is the phase's own real running high-water mark,
 *     walked chronologically over the SAME closed-trade sequence
 *     fetchCurrentPhaseTrades() already returns for phase_balance —
 *     starts at startingBalance (so a fresh phase's floor equals Fixed's
 *     floor until the first new high is set) and can only ever rise as
 *     the phase's own balance closes at a new high. This is "realized
 *     balance," not live floating/unrealized equity — this app has no
 *     live floating-equity concept anywhere (no price feed), so a
 *     peak-of-realized-balance is the only basis this engine CAN compute,
 *     and it's what feeds every "current balance" figure elsewhere in
 *     this app already.
 * A third model ("Smart/Step") was considered and deliberately omitted —
 * not an established prop-firm drawdown model, per explicit instruction —
 * so only 'fixed' and 'trailing' are recognized values; anything else
 * (including a stale or malformed rules-JSON value) normalizes to
 * 'fixed'. See the dispatch point inside compute() below. Every other
 * consumer of overall_drawdown/distance_to_breach is unaffected in
 * shape — only the number changes for a Trailing account.
 *
 * ── CONSISTENCY RULE (2026-08-17) ───────────────────────────────────────
 * Deliberately NOT part of compute()'s breach/pass math — a firm's
 * consistency requirement (e.g. "no single day's profit may exceed 20%
 * of total profit") is evaluative, not boundary-defining: real firms
 * typically disqualify a day's excess profit from payout eligibility,
 * they don't breach the account outright the instant it's crossed. See
 * consistencyStatus() — a fully separate public method with its own
 * rules-JSON keys (consistency_method, consistency_max_pct), reusing
 * fetchCurrentPhaseTrades() and compute()'s own current_profit_amount,
 * never touching breachLevel/distance_to_pass.
 *
 * ── DAILY DRAWDOWN RESET TIME (2026-08-17) ──────────────────────────────
 * `challenge_accounts.rules.daily_reset_time` ("HH:MM") +
 * `.daily_reset_timezone` (a real IANA zone name, e.g.
 * "America/New_York") let compute() measure "today's" P&L against the
 * firm's own real reset moment instead of a bare calendar day. When
 * either key is missing, behavior is byte-identical to before this
 * feature existed (DATE(captured_at) = CURDATE(), server-local) — this
 * is the explicit, honest "we don't actually know the firm's reset
 * moment" fallback per instruction, not a fabricated guess. See the
 * reset-time block inside compute() for why this can't be done with a
 * naive PHP `date()` comparison in THIS environment specifically (a
 * real, measured discrepancy between PHP's date.timezone ini setting and
 * MySQL's own SYSTEM timezone) or with MySQL's own CONVERT_TZ() to a
 * named zone (this install's mysql.time_zone_name table is unpopulated,
 * so named-zone conversion silently returns NULL inside SQL).
 *
 * WHAT THIS CLASS DOES NOT OWN: whole-account, all-phases-blended
 * balance/equity (that's `trading_accounts.current_balance`, correct
 * and intentional for personal/live accounts and for
 * AnalyticsEngine::getAccountMetrics()'s general-ledger purposes —
 * never a substitute for this class's phase-scoped balance on a
 * prop-firm account still mid-evaluation). Peak-equity-relative
 * drawdown (a genuinely different, valid stat — "worst this account
 * has ever drawn down from its own historical peak" — owned by
 * AnalyticsEngine::getAccountMetrics()'s max_drawdown_pct, not
 * duplicated here). Win-rate/expectancy/streaks/scores — all owned
 * elsewhere (AnalyticsEngine, EdgeHealthEngine respectively).
 * ════════════════════════════════════════════════════════════════════
 */
require_once __DIR__ . '/JourneySegmentation.php';

class PropFirmRuleEngine
{
    private static ?PDO $pdo = null;

    /** Recognized challenge_accounts.rules.max_loss_model values. Anything else is treated as 'fixed'. */
    public const MAX_LOSS_MODELS = ['fixed', 'trailing'];

    private static function getConnection(): PDO
    {
        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;
    }

    /**
     * Pure, stateless — the ONE rules-JSON threshold-resolution cascade.
     * Takes an already-decoded rules array and a normalized phase key
     * ('phase_1'/'evaluation', 'phase_2'/'verification', 'phase_3',
     * 'funded', or anything else for the flat-rules fallback) and
     * returns the three real percentages for that phase. No DB access —
     * safe to call directly from any caller that already has a rules
     * array in hand (e.g. a future repoint of
     * AccountPicker::apChallengeRulesSummary()) without needing a
     * second query.
     *
     * Every branch, key-fallback order, and hardcoded default below is
     * ported verbatim from AnalyticsEngine::getChallengeMetrics()'s
     * already-live-verified cascade (confirmed against real two/three-
     * step accounts' actual rules JSON shape) — not re-derived from
     * scratch.
     *
     * Deliberately does NOT resolve max_loss_model — the drawdown MODEL
     * (Fixed/Trailing) is a whole-account setting, not phase-specific,
     * unlike the percentages this method resolves. See
     * compute()'s own read of $rules['max_loss_model'] directly.
     */
    public static function resolveRules(array $rules, string $phase): array
    {
        if ($phase === 'phase_1' || $phase === 'evaluation') {
            return [
                'profit_target_pct'   => (float)($rules['phase_1_profit_target'] ?? $rules['phase_1_target_pct'] ?? $rules['profit_target_pct'] ?? $rules['target_pct'] ?? 8),
                'max_daily_loss_pct'  => (float)($rules['phase_1_daily_loss'] ?? $rules['daily_dd_pct'] ?? 5),
                'max_overall_loss_pct' => (float)($rules['phase_1_max_loss'] ?? $rules['max_dd_pct'] ?? 10),
            ];
        }
        if ($phase === 'phase_2' || $phase === 'verification') {
            return [
                'profit_target_pct'   => (float)($rules['phase_2_profit_target'] ?? $rules['phase_2_target_pct'] ?? $rules['profit_target_pct'] ?? $rules['target_pct'] ?? 5),
                'max_daily_loss_pct'  => (float)($rules['phase_2_daily_loss'] ?? $rules['daily_dd_pct'] ?? 5),
                'max_overall_loss_pct' => (float)($rules['phase_2_max_loss'] ?? $rules['max_dd_pct'] ?? 10),
            ];
        }
        if ($phase === 'phase_3') {
            return [
                'profit_target_pct'   => (float)($rules['phase_3_profit_target'] ?? $rules['phase_3_target_pct'] ?? $rules['profit_target_pct'] ?? $rules['target_pct'] ?? 5),
                'max_daily_loss_pct'  => (float)($rules['phase_3_daily_loss'] ?? $rules['daily_dd_pct'] ?? 5),
                'max_overall_loss_pct' => (float)($rules['phase_3_max_loss'] ?? $rules['max_dd_pct'] ?? 10),
            ];
        }
        if ($phase === 'funded') {
            return [
                'profit_target_pct'   => (float)($rules['funded_profit_target'] ?? $rules['profit_target_pct'] ?? 0),
                'max_daily_loss_pct'  => (float)($rules['funded_daily_loss'] ?? $rules['daily_dd_pct'] ?? 5),
                'max_overall_loss_pct' => (float)($rules['funded_max_loss'] ?? $rules['max_dd_pct'] ?? 10),
            ];
        }
        // Unrecognized phase (should only reach here via the terminal-state
        // current_stage fallback in phaseFromLifecycleState() below) — flat
        // rules only, matching getChallengeMetrics()'s existing else-branch.
        return [
            'profit_target_pct'   => (float)($rules['profit_target_pct'] ?? $rules['target_pct'] ?? 8),
            'max_daily_loss_pct'  => (float)($rules['daily_dd_pct'] ?? 5),
            'max_overall_loss_pct' => (float)($rules['max_dd_pct'] ?? 10),
        ];
    }

    /**
     * lifecycle_state (+ a terminal-state-only current_stage fallback) ->
     * [normalized phase key, JourneySegmentation phase label]. See this
     * file's own header comment for why current_stage is read here at
     * all, and why that's safe rather than a reintroduction of the bug
     * this class exists to close.
     *
     * Widened private -> public 2026-08-18 so
     * src/TradeClassificationEngine.php can reuse this SAME mapping when
     * reconstructing an account's full phase-bucket history for
     * classification purposes, rather than hand-duplicating the
     * lifecycle_state -> phase-label match a second time. Pure/stateless
     * — no behavior change for any existing caller.
     */
    public static function phaseFromLifecycleState(string $lifecycleState, ?string $currentStageColumn): array
    {
        $map = [
            'phase_1_active' => 'phase_1', 'phase_1_complete' => 'phase_1',
            'phase_2_active' => 'phase_2', 'phase_2_complete' => 'phase_2',
            'phase_3_active' => 'phase_3', 'phase_3_complete' => 'phase_3',
            'funded_active'  => 'funded',
        ];
        $stage = $map[$lifecycleState] ?? ($currentStageColumn ?? 'evaluation');
        $label = match (true) {
            str_contains($lifecycleState, 'phase_1') => 'Phase 1',
            str_contains($lifecycleState, 'phase_2') => 'Phase 2',
            str_contains($lifecycleState, 'phase_3') => 'Phase 3',
            $lifecycleState === 'funded_active'       => 'Funded Account',
            default                                    => 'Current Phase',
        };
        return [$stage, $label];
    }

    /**
     * Fetches this account's closed trades that belong to its CURRENT
     * real phase bucket (via JourneySegmentation, with the
     * array_key_last() safety check), ordered chronologically by
     * captured_at. This is the ONE place "which trades are actually in
     * play for this phase right now" is derived — both compute() (for
     * phase_balance and, as of the Maximum Loss Model work, peak-balance
     * tracking) and consistencyStatus() (for day-grouped profit
     * analysis) call this instead of each re-implementing the
     * JourneySegmentation walk + safety check themselves.
     *
     * Returns [] when the account has no closed trades yet in its
     * current phase — including "zero trades ever" and "freshly
     * promoted, only the old phase's bucket exists" — both correctly
     * mean "nothing to walk," per JourneySegmentation's own
     * array_key_last() safety check.
     *
     * No is_validation filter on the QUERY itself — same account-level-
     * math reasoning as every other query in this class (see compute()'s
     * own comment on this): a real dollar-drawdown/balance figure must
     * reflect every real trade on the account, validation or not. The
     * `is_validation` column is still SELECTed (added 2026-08-17 for
     * RiskPanelEngine's Loss Streak card — a genuinely different,
     * BEHAVIORAL question from "what is this account's real balance,"
     * which correctly does need to know which workflow each trade
     * belongs to) so a caller that DOES need to filter by workflow can
     * do so in PHP without a second, duplicate query.
     */
    private static function fetchCurrentPhaseTrades(string $accountId, ?string $challengeType, string $phaseLabel): array
    {
        $db = self::getConnection();
        $stmt = $db->prepare(
            "SELECT id, closed_at, captured_at, prop_firm_funded, prop_firm_phase, prop_phase_at_entry, is_validation,
                    COALESCE(challenge_pnl, broker_pnl, projected_pnl, 0) AS effective_pnl
             FROM trades
             WHERE account_id = :accountId AND status = 'closed' AND deleted_at IS NULL
             ORDER BY captured_at ASC"
        );
        $stmt->execute([':accountId' => $accountId]);
        $trades = $stmt->fetchAll(PDO::FETCH_ASSOC);
        if (empty($trades)) return [];

        $buckets = JourneySegmentation::partitionAccountJourney($trades, $challengeType, $phaseLabel);
        $lastLabel = array_key_last($buckets);
        if ($lastLabel !== $phaseLabel) return [];
        return $buckets[$lastLabel];
    }

    /**
     * Pure, stateless — the ONE overall-drawdown formula (Maximum Loss
     * Model dispatch, limit/breach/remaining math). Extracted 2026-08-18
     * so a caller that needs this SAME formula for a HYPOTHETICAL/
     * historical balance (e.g. src/TradeClassificationEngine.php's
     * pre-trade tier reconstruction — "what would Card 1's tier have
     * been right before this specific trade closed, walking the phase's
     * real trade history up to that point") can reuse it exactly,
     * instead of re-deriving the breach-level/limit-amount math a second
     * time. compute() below now calls this instead of inlining the
     * formula — zero behavior change for compute()'s own live callers
     * (confirmed via re-running scripts/verify_prop_firm_rule_engine.php
     * after this refactor).
     */
    public static function overallDrawdownFor(float $ddReferenceBalance, float $currentBalance, float $maxOverallLossPct, string $maxLossModel, float $peakBalance): array
    {
        $overallLossLimit = $ddReferenceBalance * ($maxOverallLossPct / 100);
        $currentOverallDd = max(0, $ddReferenceBalance - $currentBalance);
        $remainingOverallDd = max(0, $overallLossLimit - $currentOverallDd);
        $breachLevel = $ddReferenceBalance * (1 - $maxOverallLossPct / 100);

        return [
            'current_amount' => round($currentOverallDd, 2),
            'limit_amount' => round($overallLossLimit, 2),
            'remaining_amount' => round($remainingOverallDd, 2),
            'breach_level_amount' => round($breachLevel, 2),
            'current_percentage' => round($ddReferenceBalance > 0 ? ($currentOverallDd / $ddReferenceBalance) * 100 : 0, 2),
            'limit_percentage' => $maxOverallLossPct,
            'max_loss_model' => $maxLossModel,
            'peak_balance' => round($peakBalance, 2),
        ];
    }

    /**
     * The one comprehensive computation. Every public method below is a
     * thin slice of this same result — never a second, independent
     * formula. Returns ['error' => ...] if the account has no
     * challenge_accounts row (not a prop-firm account, or not found).
     */
    public static function compute(string $accountId): array
    {
        $db = self::getConnection();

        $stmt = $db->prepare("
            SELECT ca.rules, ca.challenge_type, ca.current_stage,
                   ta.starting_balance, ta.account_type, ta.lifecycle_state
            FROM challenge_accounts ca
            JOIN trading_accounts ta ON ca.account_id = ta.id
            WHERE ca.account_id = :accountId
        ");
        $stmt->execute([':accountId' => $accountId]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$row) {
            return ['error' => 'Challenge account not found'];
        }

        $rules = !empty($row['rules']) ? json_decode($row['rules'], true) : [];
        $lifecycleState = $row['lifecycle_state'] ?? '';
        [$currentStage, $phaseLabel] = self::phaseFromLifecycleState($lifecycleState, $row['current_stage'] ?? null);
        $startingBalance = (float)($row['starting_balance'] ?? 0);

        // Phase-reset-aware balance (and, as of the Maximum Loss Model
        // work, the phase's own real running peak balance — the high-
        // water mark a Trailing floor tracks): fresh startingBalance +
        // only this phase's own real closed trades' effective P&L,
        // walked chronologically. A freshly-promoted account with zero
        // real trades yet in its new phase falls back to a fresh
        // startingBalance (and a peak equal to it) — never a stale
        // prior-phase bucket, via fetchCurrentPhaseTrades()'s own
        // array_key_last() safety check.
        $currentPhaseTrades = self::fetchCurrentPhaseTrades($accountId, $row['challenge_type'] ?? null, $phaseLabel);
        $currentBalance = $startingBalance;
        $peakBalance = $startingBalance;
        if (!empty($currentPhaseTrades)) {
            $running = $startingBalance;
            $peak = $startingBalance;
            foreach ($currentPhaseTrades as $t) {
                $running += (float)$t['effective_pnl'];
                if ($running > $peak) $peak = $running;
            }
            $currentBalance = $running;
            $peakBalance = $peak;
        }

        $thresholds = self::resolveRules($rules, $currentStage);
        $profitTargetPct = $thresholds['profit_target_pct'];
        $maxDailyLossPct = $thresholds['max_daily_loss_pct'];
        $maxOverallLossPct = $thresholds['max_overall_loss_pct'];

        // Profit Target — deliberately UNAFFECTED by the Maximum Loss
        // Model. Always anchored to the phase's own fresh startingBalance,
        // never the current/peak balance — a target doesn't move because
        // the account gained or lost money, per explicit business rule.
        $profitNeeded = $startingBalance * ($profitTargetPct / 100);
        $currentProfit = $currentBalance - $startingBalance;
        $distanceToPassAmount = max(0, $profitNeeded - $currentProfit);
        $distanceToPassPct = $startingBalance > 0 ? ($distanceToPassAmount / $startingBalance) * 100 : 0;
        $progressPct = $profitNeeded > 0 ? min(100, max(0, ($currentProfit / $profitNeeded) * 100)) : 100;

        // ── Daily Drawdown reset window ─────────────────────────────────
        // Reset-time-aware when the account has configured a real reset
        // moment (daily_reset_time "HH:MM" + daily_reset_timezone, a real
        // IANA zone name); calendar-day (server-local midnight, via
        // MySQL's own CURDATE()) otherwise — byte-identical to this
        // class's original, only-ever-calendar-day behavior. Per the
        // explicit business rule: an unconfigured reset stays honestly
        // "we don't actually know," never a guessed default.
        //
        // Why this can't be a naive PHP date() comparison in THIS
        // environment: PHP's date.timezone ini setting and MySQL's own
        // SYSTEM timezone are NOT guaranteed to agree — a real, measured
        // 1-hour discrepancy exists between them on this install (PHP:
        // Europe/Berlin: MySQL SYSTEM: an OS timezone with no DST). Every
        // stored captured_at/NOW() is in MySQL's own SYSTEM-zone wall
        // clock, so this whole computation anchors to the DB's own NOW()
        // (never PHP's date()/time()) and converts back to the DB's
        // SYSTEM-zone wall-clock string before it's ever used in a query.
        //
        // Why this can't be done with MySQL's own CONVERT_TZ() to a named
        // zone: this install's mysql.time_zone_name table is unpopulated
        // (confirmed empty), so CONVERT_TZ(x, 'SYSTEM', 'America/New_York')
        // silently returns NULL — MySQL can only convert to/from its own
        // SYSTEM zone and a FIXED numeric UTC offset, never a named IANA
        // zone. The IANA-zone-aware part of this computation (which
        // correctly handles a zone's own DST rules) therefore has to
        // happen in PHP, which carries its own full, independent tzdata —
        // MySQL is only ever asked to convert between its own SYSTEM zone
        // and a fixed '+00:00' offset, both of which it can always
        // resolve regardless of the missing named-zone tables.
        $resetTimeRaw = trim((string)($rules['daily_reset_time'] ?? ''));
        $resetTz = trim((string)($rules['daily_reset_timezone'] ?? ''));
        $dailyResetConfigured = false;
        $dailyWindowStart = null;
        $dailyResetDescription = 'Not configured, using calendar day (server time)';

        if ($resetTimeRaw !== '' && $resetTz !== '' && preg_match('/^(\d{1,2}):(\d{2})$/', $resetTimeRaw, $m)) {
            $rh = (int)$m[1];
            $rm = (int)$m[2];
            if ($rh >= 0 && $rh <= 23 && $rm >= 0 && $rm <= 59) {
                try {
                    $traderTz = new DateTimeZone($resetTz);

                    $utcNowRow = $db->query("SELECT CONVERT_TZ(NOW(), 'SYSTEM', '+00:00') AS u")->fetch(PDO::FETCH_ASSOC);
                    if (empty($utcNowRow['u'])) {
                        throw new \RuntimeException('CONVERT_TZ unavailable');
                    }
                    $dbNowUtc = new DateTime($utcNowRow['u'], new DateTimeZone('UTC'));

                    $nowInTraderTz = (clone $dbNowUtc)->setTimezone($traderTz);
                    $todayReset = (clone $nowInTraderTz)->setTime($rh, $rm, 0);
                    if ($nowInTraderTz < $todayReset) {
                        // The reset for "today" (in the trader's zone) hasn't
                        // happened yet — the current trading day began at
                        // YESTERDAY's reset moment.
                        $todayReset->modify('-1 day');
                    }
                    $resetUtcStr = (clone $todayReset)->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');

                    $dbLocalStmt = $db->prepare("SELECT CONVERT_TZ(:u, '+00:00', 'SYSTEM') AS l");
                    $dbLocalStmt->execute([':u' => $resetUtcStr]);
                    $dbLocalRow = $dbLocalStmt->fetch(PDO::FETCH_ASSOC);

                    if (!empty($dbLocalRow['l'])) {
                        $dailyWindowStart = $dbLocalRow['l'];
                        $dailyResetConfigured = true;
                        $dailyResetDescription = sprintf('%02d:%02d %s', $rh, $rm, $resetTz);
                    }
                } catch (\Throwable $e) {
                    // Invalid timezone string, or CONVERT_TZ genuinely
                    // unavailable — fail safe to the existing calendar-day
                    // behavior rather than error out the whole account-
                    // risk computation over a bad/unresolvable config value.
                    $dailyResetConfigured = false;
                }
            }
        }

        // This method is only ever called for prop-firm accounts, so daily
        // P&L is measured against challenge_pnl (the firm's challenge
        // accounting), falling back to broker_pnl/projected_pnl when
        // challenge_pnl hasn't been backfilled for a trade — same cascade
        // as getAccountMetrics()/reports.php's $pnlExpr. No is_validation
        // filter, for the same account-level reason as the phase-balance
        // query above.
        $dailyPnlExpr = ($row['account_type'] ?? '') === 'prop_firm'
            ? "COALESCE(challenge_pnl, broker_pnl, projected_pnl, 0)"
            : "COALESCE(broker_pnl, projected_pnl, 0)";

        // trade_count/daily_pnl (signed) added 2026-08-17 for the Daily
        // Loss Guard card's secondary stat row ("Trades closed today" /
        // "Realized P&L today") — same query, same window, no second
        // query: the window-resolution logic above (reset-time-aware vs.
        // calendar-day) is genuinely nontrivial (see this file's own
        // header comment on why it can't be a naive PHP date() compare),
        // so any caller that needs "today" scoped the same way this
        // class's own daily-loss math already is must read these two new
        // fields rather than re-deriving the window itself.
        if ($dailyResetConfigured) {
            $stmt = $db->prepare("
                SELECT SUM({$dailyPnlExpr}) as daily_pnl, COUNT(*) as trade_count
                FROM trades
                WHERE account_id = :accountId
                AND status = 'closed'
                AND captured_at >= :windowStart
            ");
            $stmt->execute([':accountId' => $accountId, ':windowStart' => $dailyWindowStart]);
        } else {
            // No real firm-declared reset moment on record for this
            // account — same explicit "we don't actually know" fallback
            // as the reset-window block above (never a guessed default).
            // $dailyWindowStart still needs a value here (today's
            // calendar-day boundary, in the DB's own SYSTEM-zone wall
            // clock, matching how captured_at is stored) so the trailing
            // daily-baseline calculation below has a real boundary to
            // partition "before today" vs "today" against — without this,
            // the baseline walk further down would have no window to
            // anchor to and would silently fall back to the old, wrong,
            // whole-phase-starting-balance behavior for every account
            // that hasn't configured a reset time.
            $todayRow = $db->query("SELECT CONCAT(CURDATE(), ' 00:00:00') AS ws")->fetch(PDO::FETCH_ASSOC);
            $dailyWindowStart = $todayRow['ws'] ?? null;

            $stmt = $db->prepare("
                SELECT SUM({$dailyPnlExpr}) as daily_pnl, COUNT(*) as trade_count
                FROM trades
                WHERE account_id = :accountId
                AND status = 'closed'
                AND DATE(captured_at) = CURDATE()
            ");
            $stmt->execute([':accountId' => $accountId]);
        }
        $dailyResult = $stmt->fetch(PDO::FETCH_ASSOC);
        $currentDailyPnl = (float)($dailyResult['daily_pnl'] ?? 0);
        $currentDailyTradeCount = (int)($dailyResult['trade_count'] ?? 0);

        // ── Trailing daily baseline (2026-08-18 fix) ─────────────────────
        // BUSINESS RULE, confirmed explicitly: the daily-loss percentage
        // must apply against the balance actually carried INTO today —
        // startingBalance plus every one of this phase's own real closed
        // trades from BEFORE today's reset window — never the phase's
        // permanently-fixed startingBalance alone. A trader who is up
        // $250 across prior days genuinely has a bigger daily cushion
        // today (4% of $5,250 = $210) than the account's original $5,000
        // size would imply (4% of $5,000 = $200) — the old formula
        // silently used the smaller, wrong number forever, regardless of
        // how many profitable (or losing) days had already passed.
        //
        // Reuses $currentPhaseTrades (already fetched above for the
        // phase-balance/peak walk, single source, no second query) —
        // walks the SAME chronological list, summing only the trades that
        // closed before $dailyWindowStart. A brand-new phase with zero
        // prior-day trades correctly collapses to
        // dailyBaselineBalance === startingBalance (byte-identical to the
        // old formula for exactly that one case — day one of a phase, or
        // any day where nothing at all happened before today).
        //
        // Deliberately NOT an intraday high-water-mark ("trailing from
        // today's own peak, reached after this morning's winning trades")
        // — that's a different, non-standard interpretation this business
        // rule's own worked example ($5,250 x 4% = $210) does not
        // describe, and conflating the two would double-count the same
        // "trailing" word for two different mechanisms (this account's
        // OWN separate Maximum Loss Model already owns the true
        // intraday/multi-day peak-chasing concept — see overallDrawdownFor()
        // above — deliberately independent of this daily baseline).
        $dailyBaselineBalance = $startingBalance;
        if ($dailyWindowStart !== null && !empty($currentPhaseTrades)) {
            foreach ($currentPhaseTrades as $t) {
                if ((string)$t['captured_at'] < $dailyWindowStart) {
                    $dailyBaselineBalance += (float)$t['effective_pnl'];
                }
            }
        }

        $dailyLossLimit = $dailyBaselineBalance * ($maxDailyLossPct / 100);
        $currentDailyDd = $currentDailyPnl < 0 ? abs($currentDailyPnl) : 0;
        $remainingDailyDd = max(0, $dailyLossLimit - $currentDailyDd);
        $dailyDdPct = $dailyBaselineBalance > 0 ? ($currentDailyDd / $dailyBaselineBalance) * 100 : 0;

        // ── Maximum Loss Model dispatch ─────────────────────────────────
        // See this file's own header comment for the full model
        // definitions. $ddReferenceBalance is the ONE balance the whole
        // overall-drawdown block below is anchored to — startingBalance
        // for Fixed, peakBalance for Trailing (the only two recognized
        // models). For a Fixed account this is always exactly startingBalance
        // (peakBalance is never referenced), so every formula below is
        // byte-identical to this class's original, pre-Maximum-Loss-Model
        // behavior whenever max_loss_model is absent or 'fixed'.
        $maxLossModel = strtolower(trim((string)($rules['max_loss_model'] ?? 'fixed')));
        if (!in_array($maxLossModel, self::MAX_LOSS_MODELS, true)) {
            $maxLossModel = 'fixed';
        }
        $ddReferenceBalance = ($maxLossModel === 'trailing') ? $peakBalance : $startingBalance;
        $overallDrawdown = self::overallDrawdownFor($ddReferenceBalance, $currentBalance, $maxOverallLossPct, $maxLossModel, $peakBalance);
        $breachLevel = $overallDrawdown['breach_level_amount'];

        $distanceToBreach = $currentBalance - $breachLevel;
        $distanceToBreachPct = $ddReferenceBalance > 0 ? ($distanceToBreach / $ddReferenceBalance) * 100 : 0;
        $isNearBreach = $ddReferenceBalance > 0 ? $distanceToBreach < ($ddReferenceBalance * 0.02) : false;

        $excessProfitAfterPass = $currentProfit > $profitNeeded ? $currentProfit - $profitNeeded : 0;
        $excessLossAfterBreach = $currentBalance < $breachLevel ? $breachLevel - $currentBalance : 0;
        $isBreached = $excessLossAfterBreach > 0;

        return [
            'account_id'      => $accountId,
            'current_stage'   => $currentStage,
            'phase_label'     => $phaseLabel,
            'account_type'    => $row['account_type'],
            'lifecycle_state' => $lifecycleState,
            // Added 2026-08-17 — additive only, lets currentPhaseTrades()
            // below reuse this one compute() call instead of running its
            // own second challenge_type lookup.
            'challenge_type'  => $row['challenge_type'] ?? null,
            'starting_balance' => round($startingBalance, 2),
            // The one correct, phase-reset-aware current balance for
            // breach/pass boundary math. Never account_metrics.current_balance
            // (AnalyticsEngine::getAccountMetrics()'s whole-ledger figure) —
            // that one is correct for Reports/Dashboard/Colex's broader
            // "lifetime ledger" purposes, wrong for phase-boundary math.
            'phase_balance'   => round($currentBalance, 2),

            'phase' => [
                'name' => $currentStage,
                'profit_target_pct' => $profitTargetPct,
                'max_daily_loss_pct' => $maxDailyLossPct,
                'max_overall_loss_pct' => $maxOverallLossPct,
            ],

            'distance_to_pass' => [
                'amount' => round($distanceToPassAmount, 2),
                'percentage' => round($distanceToPassPct, 2),
                'target_profit_amount' => round($profitNeeded, 2),
                'current_profit_amount' => round($currentProfit, 2),
                'progress_percentage' => round($progressPct, 1),
                'is_passed' => $currentProfit >= $profitNeeded && $profitNeeded > 0,
            ],

            'daily_drawdown' => [
                'current_amount' => round($currentDailyDd, 2),
                'limit_amount' => round($dailyLossLimit, 2),
                'remaining_amount' => round($remainingDailyDd, 2),
                'current_percentage' => round($dailyDdPct, 2),
                'limit_percentage' => $maxDailyLossPct,
                'is_breached' => $currentDailyDd > $dailyLossLimit,
                // Whether a real firm-declared reset moment is in effect
                // for the window above, and a plain-language description
                // of it — so a UI can honestly disclose "resets at 5:00 PM
                // America/New_York" vs. "not configured, using calendar
                // day" rather than implying certainty either way.
                'reset_configured' => $dailyResetConfigured,
                'reset_description' => $dailyResetDescription,
                // Added 2026-08-17 (Risk Panel rebuild, Daily Loss Guard
                // card) — signed net P&L for the SAME window (never just
                // the loss portion current_amount above already is) and
                // how many trades closed inside it.
                'net_pnl' => round($currentDailyPnl, 2),
                'trade_count' => $currentDailyTradeCount,
                // Added 2026-08-18 (trailing daily baseline fix) — the
                // real dollar balance the % limit above was actually
                // computed against (startingBalance plus every one of
                // this phase's own trades closed BEFORE today's reset
                // window), plus whether that differs from the phase's
                // fixed starting balance — so a UI can state plainly
                // "today's $210 limit is 4% of $5,250 (carried in from
                // prior trading days), not the account's original $5,000"
                // instead of implying the limit is a permanent, unmoving
                // number.
                'baseline_balance' => round($dailyBaselineBalance, 2),
                'baseline_is_trailing' => round($dailyBaselineBalance, 2) !== round($startingBalance, 2),
            ],

            // max_drawdown_percentage (the peak-equity-relative all-time
            // figure) is deliberately NOT included here — it's owned by
            // AnalyticsEngine::getAccountMetrics(), a genuinely different
            // stat this class doesn't compute. Callers that need both
            // (e.g. getChallengeMetrics()'s thin-wrapper merge) source it
            // from that separate call, exactly as today.
            'overall_drawdown' => $overallDrawdown,

            'distance_to_breach' => [
                'amount' => round($distanceToBreach, 2),
                'percentage' => round($distanceToBreachPct, 2),
                'is_near_breach' => $isNearBreach,
                'warning_level' => $isNearBreach ? 'danger' : ($distanceToBreachPct < 5 ? 'warning' : 'safe'),
            ],

            'excess_amounts' => [
                'excess_profit_after_pass' => round($excessProfitAfterPass, 2),
                'excess_loss_after_breach' => round($excessLossAfterBreach, 2),
                'is_breached' => $isBreached,
                'has_excess_profit' => $excessProfitAfterPass > 0,
            ],
        ];
    }

    /** Slice of compute() — phase-aware balance only. */
    public static function phaseAwareBalance(string $accountId): array
    {
        $r = self::compute($accountId);
        if (isset($r['error'])) return $r;
        return [
            'balance' => $r['phase_balance'],
            'starting_balance' => $r['starting_balance'],
            'phase_label' => $r['phase_label'],
            'current_stage' => $r['current_stage'],
            'account_type' => $r['account_type'],
        ];
    }

    /**
     * Public slice of compute() — the current phase's own real closed
     * trades (chronological), the exact same set phase_balance is walked
     * from. Added 2026-08-17 for src/RiskPanelEngine.php's Loss Streak
     * card, which needs the real trades (not just the aggregated P&L
     * total) to compute a consecutive-loss run — never a second,
     * independent phase-partitioning query (see this file's own header
     * comment on why that duplication is exactly the incident class this
     * class exists to prevent). Each row carries `is_validation` (see
     * fetchCurrentPhaseTrades()'s own comment) so a workflow-aware caller
     * can filter in PHP without a second query; this method itself
     * applies no such filter, matching every other method in this class.
     */
    public static function currentPhaseTrades(string $accountId): array
    {
        $r = self::compute($accountId);
        if (isset($r['error'])) return [];
        return self::fetchCurrentPhaseTrades($accountId, $r['challenge_type'], $r['phase_label']);
    }

    /** Slice of compute() — drawdown and daily-loss figures only. */
    public static function drawdownAndDailyLoss(string $accountId): array
    {
        $r = self::compute($accountId);
        if (isset($r['error'])) return $r;
        return [
            'overall_drawdown' => $r['overall_drawdown'],
            'daily_drawdown' => $r['daily_drawdown'],
            'distance_to_breach' => $r['distance_to_breach'],
        ];
    }

    /**
     * Slice of compute() — breach status only. Pass $balanceAfterTrade
     * (a hypothetical post-trade balance) to also get a `would_breach`
     * boolean for a pre-trade check, without writing anything.
     */
    public static function breachStatus(string $accountId, ?float $balanceAfterTrade = null): array
    {
        $r = self::compute($accountId);
        if (isset($r['error'])) return $r;
        $breachLevel = $r['overall_drawdown']['breach_level_amount'];
        $result = [
            'is_breached' => $r['excess_amounts']['is_breached'],
            'breach_level_amount' => $breachLevel,
            'excess_loss_after_breach' => $r['excess_amounts']['excess_loss_after_breach'],
        ];
        if ($balanceAfterTrade !== null) {
            $result['would_breach'] = $balanceAfterTrade <= $breachLevel;
        }
        return $result;
    }

    /** Slice of compute() — pass/target progress only. */
    public static function passProgress(string $accountId): array
    {
        $r = self::compute($accountId);
        if (isset($r['error'])) return $r;
        return $r['distance_to_pass'] + ['excess_profit_after_pass' => $r['excess_amounts']['excess_profit_after_pass']];
    }

    /**
     * Consistency Score — a real analytics metric, always computed for
     * this phase whenever it has genuine positive profit, independent of
     * whether the firm has configured an enforceable Consistency RULE.
     * Formula: (this phase's single biggest calendar day's realized
     * profit) / (this phase's total real net profit so far) x 100 — the
     * one method given a concrete worked definition,
     * 'biggest_day_over_total_profit'.
     *
     * Corrected 2026-08-18, per explicit clarification: the SCORE and the
     * RULE are two separate things. "Consistency Rule" (rules.
     * consistency_method / consistency_max_pct) is an OPTIONAL account
     * requirement a firm may or may not enforce — when absent, that means
     * optional ENFORCEMENT, not optional CALCULATION. The score itself is
     * shown for both Fixed and Trailing accounts alike whenever there's
     * real profit to measure it against (the day-grouping below reads
     * this phase's own real closed trades via fetchCurrentPhaseTrades(),
     * the same phase-scoped set every other per-phase figure in this
     * class already uses — the Maximum Loss Model, which only changes how
     * the DRAWDOWN floor is anchored, has no bearing on this formula at
     * all, so it is already correct for both models without any
     * model-specific branch).
     *
     * "Total Profit" here is this phase's own current_profit_amount
     * (compute()'s own figure — currentBalance - startingBalance for the
     * current phase), the plain, already-established meaning of "how
     * much has this account made" everywhere else in this app. This is
     * an explicit interpretation choice (a real prop firm's own rule
     * text could instead mean gross profit, or a fixed evaluation-period
     * total) — flagged here rather than silently assumed to be the only
     * possible reading.
     *
     * current_score_pct is null (never a fabricated 0%) whenever total
     * profit isn't positive — a break-even-or-worse phase has no real
     * "share of profit" ratio to speak of, and showing 0% would misread
     * as "perfectly consistent" for the wrong reason. Callers must render
     * an honest "not available yet" state for the null case, never a
     * percentage.
     */
    public static function consistencyStatus(string $accountId): array
    {
        $r = self::compute($accountId);
        if (isset($r['error'])) return $r;

        $db = self::getConnection();
        $stmt = $db->prepare("SELECT rules, challenge_type FROM challenge_accounts WHERE account_id = :accountId");
        $stmt->execute([':accountId' => $accountId]);
        $challengeRow = $stmt->fetch(PDO::FETCH_ASSOC);
        $rules = (!empty($challengeRow['rules'])) ? json_decode($challengeRow['rules'], true) : [];

        $method = $rules['consistency_method'] ?? null;
        $maxPct = (isset($rules['consistency_max_pct']) && $rules['consistency_max_pct'] !== '')
            ? (float)$rules['consistency_max_pct']
            : null;
        // A rule is genuinely "configured" only when both a recognized
        // method AND a max % are present — an unrecognized method value
        // is flagged (never silently treated as a real, enforceable rule)
        // but the score itself is still computed regardless, since the
        // score is a universal metric, not something the rule's own
        // validity gates.
        $ruleConfigured = $method !== null && $maxPct !== null && $method === 'biggest_day_over_total_profit';
        $ruleError = ($method !== null && $maxPct !== null && $method !== 'biggest_day_over_total_profit')
            ? "Unrecognized consistency_method: {$method}" : null;

        $phaseTrades = self::fetchCurrentPhaseTrades($accountId, $challengeRow['challenge_type'] ?? null, $r['phase_label']);

        $byDay = [];
        foreach ($phaseTrades as $t) {
            $day = substr((string)$t['captured_at'], 0, 10);
            $byDay[$day] = ($byDay[$day] ?? 0.0) + (float)$t['effective_pnl'];
        }

        $biggestDayAmount = 0.0;
        $biggestDayDate = null;
        foreach ($byDay as $day => $amount) {
            if ($amount > $biggestDayAmount) {
                $biggestDayAmount = $amount;
                $biggestDayDate = $day;
            }
        }

        $totalProfit = (float)$r['distance_to_pass']['current_profit_amount'];
        $currentScorePct = $totalProfit > 0 ? round(($biggestDayAmount / $totalProfit) * 100, 2) : null;
        $isCompliant = ($ruleConfigured && $currentScorePct !== null) ? ($currentScorePct <= $maxPct) : null;

        return [
            // True whenever a real percentage was computed (totalProfit >
            // 0) — the ONLY thing a display layer should check before
            // rendering a percentage at all.
            'score_available' => $currentScorePct !== null,
            'current_score_pct' => $currentScorePct,
            'biggest_day_amount' => round($biggestDayAmount, 2),
            'biggest_day_date' => $biggestDayDate,
            'total_profit_amount' => round($totalProfit, 2),
            // Rule (enforcement) fields — null/false when no real
            // requirement is configured. is_compliant is null (not true)
            // when there's no rule to be compliant WITH, never used to
            // imply a pass/fail verdict that doesn't exist.
            'rule_configured' => $ruleConfigured,
            'rule_error' => $ruleError,
            'method' => $method,
            'max_allowed_pct' => $maxPct,
            'is_compliant' => $isCompliant,
            'phase_label' => $r['phase_label'],
        ];
    }
}
