<?php
/**
 * ════════════════════════════════════════════════════════════════════
 * TradeClassificationEngine — trade_classification, corrected to follow
 * Active Coaching's actual tier-based risk recommendation.
 * ────────────────────────────────────────────────────────────────────
 * Built 2026-08-18, replacing the sp_classify_trade stored procedure as
 * the sole writer of trades.trade_classification / drawdown_state_at_close
 * (see CLAUDE.md's Single Source of Truth history — sp_classify_trade was
 * previously the ONLY writer; this class is now the only writer).
 *
 * THE BUG THIS REPLACES: sp_classify_trade flagged 'revenge_trade'/
 * 'tilt_warning' whenever ANY loss existed among the last 10 closed
 * trades on the account — so a second consecutive loss almost always got
 * flagged, regardless of whether the trader actually did anything wrong.
 * It compared a trade's dollar P&L against ITS OWN planned max
 * loss/profit (a slippage/plan-adherence check), never against what
 * Active Coaching (src/RiskPanelEngine.php's Overall Loss Risk card)
 * actually recommends at that point in the account's real drawdown
 * state. Confirmed live: 99 of 321 real closed trades were flagged
 * tilt_warning, 17 revenge_trade — over-flagged, not a rare signal.
 *
 * THE CORRECTED RULE, per explicit business definitions (2026-08-18):
 *   - Revenge trading  = a LOSS followed by a trade that risks MORE than
 *     Active Coaching's tier-recommended risk % at that moment.
 *   - Impulsive trading = a WIN followed by a trade that risks MORE than
 *     the tier recommends (folded into the existing 'elevated_win'
 *     value — see below; not a distinct stored value).
 *   - Fear of trading  = a LOSS followed by a trade that risks
 *     EXCESSIVELY LESS than the tier recommends (repurposes the existing
 *     'tilt_warning' value, which previously just meant "any 2nd loss").
 *   - A single trade risked beyond the tier recommendation, with no
 *     preceding-loss context, is still flagged (loss_high_risk /
 *     elevated_win) — just not escalated to revenge_trade, since that
 *     specific label requires the loss-after-loss pattern.
 *   - FOMO/overtrading (too many trades in one day) is NOT a per-trade
 *     concept and does not live in trade_classification at all — see
 *     dashboard.php's pattern-alert block for that signal.
 *
 * THE 7 VALUES ARE UNCHANGED (disciplined_win / controlled_loss /
 * breakeven / elevated_win / loss_high_risk / tilt_warning /
 * revenge_trade) — per explicit decision, only the trigger logic behind
 * them changed. No CHECK-constraint migration, every existing consumer
 * (Reports/Trading Archive/Trader Profile/Colex/TradeGrading.php) keeps
 * working unmodified.
 *
 * SINGLE SOURCE OF TRUTH: every tier/recommended-risk% figure this class
 * produces comes from RiskPanelEngine::overallLossRiskCard() — the exact
 * same function Active Coaching's own Overall Loss Risk card calls —
 * never a re-derived threshold. For a PROP-FIRM trade this requires
 * reconstructing the account's phase-scoped balance/drawdown state AS IT
 * STOOD immediately before that specific trade closed (not today's live
 * state) — done via PropFirmRuleEngine::overallDrawdownFor(), the same
 * pure formula PropFirmRuleEngine::compute() itself uses, walked
 * chronologically over each phase's own real trade history (via
 * JourneySegmentation::partitionAccountJourney(), the same phase-
 * bucketing every other phase-aware computation in this app already
 * uses). For a LIVE/personal account, the equivalent walk uses
 * RiskPanelEngine::liveOverallDrawdown()'s informal 20%-of-peak-equity
 * ceiling, reconstructed the same "starting_balance + cumulative
 * effective_pnl, tracking a running peak" way
 * AnalyticsEngine::computeMetricsFromTrades() already does for its own
 * peak-equity-relative drawdown — not a third, independently-invented
 * equity-curve reconstruction.
 *
 * WORKFLOW SCOPING: the balance/drawdown/tier WALK is deliberately
 * unfiltered by is_validation (every real trade on the account moves its
 * real dollar exposure, validation or not — same reasoning already
 * established for PropFirmRuleEngine/RiskPanelEngine's Cards 1/3). The
 * "preceded by a win/loss" BEHAVIORAL context used for revenge_trade/
 * tilt_warning is workflow-scoped — a validation trade's "was the last
 * trade a loss" question only looks at the account's other VALIDATION
 * trades, and a main-workflow trade's only looks at other MAIN trades,
 * tracked as two independent running pointers through the same
 * chronological walk. Matches RiskPanelEngine's own Card 2 doc comment
 * exactly: dollar math stays account-wide, behavioral sequence context
 * stays workflow-scoped.
 *
 * TRANSACTIONAL CONSTRAINT — every public write method here takes an
 * explicit PDO connection rather than opening its own. dashboard.php's
 * resolve_trade_id (the sole lifecycle mutator) classifies a trade
 * INSIDE the same transaction that just wrote its challenge_pnl/status,
 * uncommitted — a second, independently-opened connection would not see
 * those writes yet (this is exactly why the old CALL sp_classify_trade
 * worked: it ran on the SAME $pdo). Never open a private connection here
 * the way PropFirmRuleEngine/RiskPanelEngine do for their read-only
 * purposes.
 * ════════════════════════════════════════════════════════════════════
 */
require_once __DIR__ . '/PropFirmRuleEngine.php';
require_once __DIR__ . '/RiskPanelEngine.php';
require_once __DIR__ . '/JourneySegmentation.php';
require_once __DIR__ . '/../api/analytics/engine.php';

class TradeClassificationEngine
{
    /** A loss risked below this fraction of the tier-recommended % counts as "excessively less" (fear of trading), when it also follows a loss. Tunable — not independently statistically derived, chosen to sit well below the disciplined tolerance band (~10-20% relative) rather than overlapping it. */
    public const UNDERRISK_RATIO = 0.5;

    /** Matches sp_classify_trade's own pre-existing breakeven threshold exactly (|pnl| < $0.01) — unrelated to this fix, kept unchanged. */
    private const BREAKEVEN_EPSILON = 0.01;

    /**
     * The one comprehensive per-account computation. Returns
     * [tradeId => ['classification'=>.., 'drawdown_state_at_close'=>..,
     * 'reason'=>[...]]] for every real closed trade on this account
     * (both workflows). Empty array for an account with no closed trades
     * or that doesn't exist.
     */
    public static function classifyAccount(string $accountId, PDO $pdo): array
    {
        $stmt = $pdo->prepare("
            SELECT ta.account_type, ta.starting_balance, ta.lifecycle_state,
                   ca.rules, ca.challenge_type, ca.current_stage
            FROM trading_accounts ta
            LEFT JOIN challenge_accounts ca ON ca.account_id = ta.id
            WHERE ta.id = :aid
        ");
        $stmt->execute([':aid' => $accountId]);
        $acct = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$acct) return [];

        $isProp = ($acct['account_type'] ?? '') === 'prop_firm';
        $startingBalance = (float)($acct['starting_balance'] ?? 0);
        $pnlExpr = $isProp
            ? "COALESCE(challenge_pnl, broker_pnl, projected_pnl, 0)"
            : "COALESCE(broker_pnl, projected_pnl, 0)";

        $stmt = $pdo->prepare("
            SELECT id, closed_at, captured_at, is_validation, instrument, risk_pct,
                   drawdown_at_entry_pct, prop_firm_funded, prop_firm_phase, prop_phase_at_entry,
                   {$pnlExpr} AS effective_pnl
            FROM trades
            WHERE account_id = :aid AND status = 'closed' AND deleted_at IS NULL
            ORDER BY captured_at ASC, id ASC
        ");
        $stmt->execute([':aid' => $accountId]);
        $allTrades = $stmt->fetchAll(PDO::FETCH_ASSOC);
        if (empty($allTrades)) return [];

        if (!$isProp) {
            // Personal/live account — one single chronological walk, no
            // phase concept, no rules to resolve.
            return self::walkAndClassify($allTrades, $startingBalance, null, false, 'fixed');
        }

        $rules = !empty($acct['rules']) ? json_decode($acct['rules'], true) : [];
        $maxLossModel = strtolower(trim((string)($rules['max_loss_model'] ?? 'fixed')));
        if (!in_array($maxLossModel, PropFirmRuleEngine::MAX_LOSS_MODELS, true)) {
            $maxLossModel = 'fixed';
        }

        [, $currentLabel] = PropFirmRuleEngine::phaseFromLifecycleState(
            $acct['lifecycle_state'] ?? '', $acct['current_stage'] ?? null
        );
        $buckets = JourneySegmentation::partitionAccountJourney($allTrades, $acct['challenge_type'] ?? null, $currentLabel);

        $phaseKeyMap = ['Phase 1' => 'phase_1', 'Phase 2' => 'phase_2', 'Phase 3' => 'phase_3', 'Funded Account' => 'funded'];
        $results = [];
        foreach ($buckets as $label => $bucketTrades) {
            $phaseKey = $phaseKeyMap[$label] ?? 'evaluation';
            $thresholds = PropFirmRuleEngine::resolveRules($rules, $phaseKey);
            $results += self::walkAndClassify($bucketTrades, $startingBalance, $thresholds, true, $maxLossModel);
        }
        return $results;
    }

    /**
     * Convenience wrapper for the two live write sites
     * (dashboard.php's resolve_trade_id, api/mt5/reconcile.php) — computes
     * the account's FULL classification map (cheap, in-memory, no extra
     * queries beyond the one trade fetch above) but writes ONLY the one
     * requested trade. Deliberately does not rewrite every other trade on
     * the account on every single resolution — trade_classification is
     * still written exactly once, at the moment each trade itself
     * closes, same "sole write path, once, at close time" contract as
     * the stored procedure it replaces. Use classifyAccount() + a loop
     * over writeClassification() directly for a deliberate bulk
     * backfill (see scripts/backfill_trade_classification.php).
     */
    public static function classifyOneTrade(string $tradeId, PDO $pdo): void
    {
        $stmt = $pdo->prepare("SELECT account_id FROM trades WHERE id = :id");
        $stmt->execute([':id' => $tradeId]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$row || empty($row['account_id'])) return;

        $all = self::classifyAccount($row['account_id'], $pdo);
        if (isset($all[$tradeId])) {
            self::writeClassification($tradeId, $all[$tradeId], $pdo);
        }
    }

    public static function writeClassification(string $tradeId, array $result, PDO $pdo): void
    {
        $stmt = $pdo->prepare("
            UPDATE trades
               SET trade_classification = :c,
                   drawdown_state_at_close = :d,
                   classification_reason = :r
             WHERE id = :id
        ");
        $stmt->execute([
            ':c' => $result['classification'],
            ':d' => $result['drawdown_state_at_close'],
            ':r' => json_encode($result['reason']),
            ':id' => $tradeId,
        ]);
    }

    /**
     * Walks one chronological trade sequence (a whole live/personal
     * account, or one prop-firm phase bucket), computing each trade's
     * pre-trade tier state from the account/phase's own real running
     * balance and classifying against it. $thresholds is
     * PropFirmRuleEngine::resolveRules()'s return shape for prop
     * accounts, null for live/personal (RiskPanelEngine::
     * liveOverallDrawdown() needs no rules at all).
     */
    private static function walkAndClassify(array $trades, float $startingBalance, ?array $thresholds, bool $isProp, string $maxLossModel): array
    {
        $runningBalance = $startingBalance;
        $runningPeak = $startingBalance;
        // Two independent trackers — one per workflow — per this file's
        // own header comment on workflow-scoped behavioral context.
        $precedingByWorkflow = ['main' => null, 'validation' => null];

        $results = [];
        foreach ($trades as $t) {
            $effectivePnl = (float)($t['effective_pnl'] ?? 0);
            $actualRiskPct = (float)($t['risk_pct'] ?? 0);
            $wfKey = !empty($t['is_validation']) ? 'validation' : 'main';
            $precedingOutcome = $precedingByWorkflow[$wfKey];

            if ($isProp) {
                $ddReference = ($maxLossModel === 'trailing') ? $runningPeak : $startingBalance;
                $overallDrawdown = PropFirmRuleEngine::overallDrawdownFor(
                    $ddReference, $runningBalance, (float)($thresholds['max_overall_loss_pct'] ?? 10), $maxLossModel, $runningPeak
                );
            } else {
                $overallDrawdown = RiskPanelEngine::liveOverallDrawdown($runningBalance, $runningPeak);
            }
            $card = RiskPanelEngine::overallLossRiskCard($runningBalance, $overallDrawdown, $isProp);
            $tier = $card['tier'] ?? null;
            $recommendedRiskPct = $card['recommended_risk_pct'] ?? null;

            $classified = self::classifyOne($effectivePnl, $actualRiskPct, $tier, $recommendedRiskPct, $precedingOutcome, $t['instrument'] ?? null);
            $classified['drawdown_state_at_close'] = self::drawdownStateFromEntryPct($t['drawdown_at_entry_pct'] ?? null);
            $results[$t['id']] = $classified;

            $precedingByWorkflow[$wfKey] = $classified['outcome'];
            $runningBalance += $effectivePnl;
            if ($runningBalance > $runningPeak) $runningPeak = $runningBalance;
        }
        return $results;
    }

    /**
     * The pure per-trade decision tree. $tier/$recommendedRiskPct are
     * whatever RiskPanelEngine::overallLossRiskCard() produced for the
     * account's real balance immediately BEFORE this trade — never
     * re-derived here.
     *
     * Returns ['classification'=>.., 'outcome'=>'win'|'loss'|'breakeven',
     * 'reason'=>[...]] — 'outcome' feeds the NEXT trade's own
     * preceded-by-a-win/loss context, 'reason' is persisted verbatim into
     * trades.classification_reason for later display (see
     * public/journal.php's rewritten tooltip).
     */
    private static function classifyOne(float $pnl, float $actualRiskPct, ?string $tier, ?float $recommendedRiskPct, ?string $precedingOutcome, ?string $instrument): array
    {
        if (abs($pnl) < self::BREAKEVEN_EPSILON) {
            return [
                'classification' => 'breakeven',
                'outcome' => 'breakeven',
                'reason' => [
                    'tier' => $tier, 'recommended_risk_pct' => $recommendedRiskPct,
                    'actual_risk_pct' => round($actualRiskPct, 4), 'preceded_by' => $precedingOutcome,
                    'over_risked' => false, 'under_risked' => false, 'tolerance_pct' => null,
                ],
            ];
        }

        $overRisked = false;
        $underRisked = false;
        $tolerance = null;
        if ($recommendedRiskPct !== null) {
            $tolerance = AnalyticsEngine::calculatorRiskTolerance($recommendedRiskPct, $instrument);
            $overRisked = $actualRiskPct > ($recommendedRiskPct + $tolerance);
            $underRisked = $recommendedRiskPct > 0 && $actualRiskPct < ($recommendedRiskPct * self::UNDERRISK_RATIO);
        }

        $reason = [
            'tier' => $tier,
            'recommended_risk_pct' => $recommendedRiskPct,
            'actual_risk_pct' => round($actualRiskPct, 4),
            'preceded_by' => $precedingOutcome,
            'over_risked' => $overRisked,
            'under_risked' => $underRisked,
            'tolerance_pct' => $tolerance !== null ? round($tolerance, 4) : null,
        ];

        if ($pnl > 0) {
            $classification = $overRisked ? 'elevated_win' : 'disciplined_win';
            return ['classification' => $classification, 'outcome' => 'win', 'reason' => $reason];
        }

        // Loss. revenge_trade requires BOTH over-risked AND the
        // immediately preceding (same-workflow) trade being a loss —
        // this is the actual fix: a lone second loss with normal risk
        // sizing now falls through to controlled_loss, not
        // tilt_warning/revenge_trade.
        if ($overRisked && $precedingOutcome === 'loss') {
            $classification = 'revenge_trade';
        } elseif ($overRisked) {
            $classification = 'loss_high_risk';
        } elseif ($underRisked && $precedingOutcome === 'loss') {
            // Fear of trading — repurposes tilt_warning, per explicit
            // business definition ("lose, then risk excessively less").
            $classification = 'tilt_warning';
        } else {
            $classification = 'controlled_loss';
        }
        return ['classification' => $classification, 'outcome' => 'loss', 'reason' => $reason];
    }

    /**
     * Ported verbatim from sp_classify_trade's own v_dd_state logic —
     * unrelated to this fix (drawdown_at_entry_pct is a calculator-
     * captured snapshot field, not derived from Active Coaching), kept
     * byte-identical so this migration doesn't silently change a second,
     * unrequested piece of behavior.
     */
    private static function drawdownStateFromEntryPct($ddPct): ?string
    {
        if ($ddPct === null || $ddPct === '') return null;
        $v = (float)$ddPct;
        if ($v < 50) return 'safe';
        if ($v < 75) return 'warning';
        if ($v < 90) return 'danger';
        return 'critical';
    }
}
