<?php
// /traderalign/api/trades/validate.php
//
// POST /api/trades/validate
//
// Validates a potential trade against account risk limits and challenge rules.
// Receives account_id and risk_per_position, returns current state and projections.
//
// Request body:
//   { "account_id": "uuid", "risk_per_position": 65.80 }
//
// Early-exit response (personal account with current_balance <= 0 and lifecycle_state = 'active'):
//   { account_closed: true, outcome: 'CAPITAL_DEPLETED', message, account_type,
//     current_balance, starting_balance }
//   — trade validation does NOT run; account is auto-closed before any calculations.
//
// Response shape (normal path):
//   { success, current_balance, starting_balance, current_drawdown_pct, projected_drawdown_pct,
//     max_dd_pct, daily_loss_used_pct, daily_loss_projected_pct, daily_loss_limit,
//     today_losses, daily_dd_pct, profit_target_pct, profit_target_progress_pct,
//     lifecycle_state, phase, leverage, account_label, account_type,
//     breach_warning, breach_level, phase_completed, completed_phase, message,
//     boundary_warning, boundary_type, account_closed, outcome,
//     max_dd_limit_amount, breach_level_amount, peak_balance, max_loss_model }
//
// max_dd_limit_amount / breach_level_amount / peak_balance / max_loss_model
// (added 2026-08-17, Trailing full-system-integration pass) — the real,
// already-model-aware DOLLAR figures straight from PropFirmRuleEngine::
// compute()'s 'overall_drawdown' sub-array, unconditional (not gated on
// breach_warning like the legacy 'breach_level' field below, which nothing
// in calculator-source.html actually consumes). Before this, the
// Calculator's live Account Impact panel re-derived its own drawdown-limit
// dollar amount client-side as `starting_balance * max_dd_pct / 100` —
// correct ONLY for the Fixed model (anchored to starting balance). For a
// Trailing account the real limit is anchored to the account's PEAK
// balance instead, which the client has no way to know without this field
// — see calculator-source.html's renderAccountImpact()/updatePFStatus()
// for the consuming fix. null for a personal account or one with no
// challenge_accounts row, same null-safety as every other field here.
//
// Error responses:
//   400  { "error": "account_id and risk_per_position are required" }
//   401  (via $auth->requireAuth())
//   404  { "error": "account not found" }
//   500  { "success": false, "error": "..." }
//
// ── ERROR HANDLING (added) ────────────────────────────────────────────────
// Everything below the initial input validation is now wrapped in a single
// try/catch. Previously any $db->raw() call that threw (PDO is configured
// with ERRMODE_EXCEPTION — see config/connection setup) would escape as an
// uncaught PDOException: either a raw PHP fatal dumped as HTML, or a blank
// 500 body if display_errors is off in production. Either way the frontend's
// fetch(...).then(r => r.json()) fails to parse it and falls into .catch(),
// which — before this fix — left the "Checking account impact…" Live
// Account card stuck forever with no fallback. draft.php already wraps its
// write path the same way; this brings validate.php in line with that
// pattern so a query failure here returns clean, parseable JSON instead of
// silently breaking the popup.

require_once __DIR__ . '/../../src/PropFirmRuleEngine.php';

header('Content-Type: application/json');

$userId = $auth->requireAuth();

// ── 1. Validate request body ─────────────────────────────────────────────────
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
    http_response_code(400);
    echo json_encode(['success' => false, 'error' => 'Invalid request body']);
    exit;
}

$accountId = trim($input['account_id'] ?? '');
$riskPerPosition = (float)($input['risk_per_position'] ?? 0);

if ($accountId === '' || $riskPerPosition <= 0) {
    http_response_code(400);
    echo json_encode(['success' => false, 'error' => 'account_id and risk_per_position are required']);
    exit;
}

try {

    // ── 2. Fetch trading_accounts row ────────────────────────────────────────
    // Combined with the old step 2b's columns (current_balance, lifecycle_state)
    // into this single query — both queries hit trading_accounts for the same
    // id/user_id, so the second round-trip was redundant. No behavior change,
    // just one fewer query.
    //
    // 2026-08-19 security fix (workflow isolation): added workflow_context
    // scoping — same rule as api/trades/draft.php's identical fix (see that
    // file's comment for the full explanation). Without this, a trader could
    // validate (and, via the auto-close branch below, even mutate) an
    // account belonging to their OTHER workflow context.
    $validateWorkflowState = ValidationService::getWorkflowState($userId, $db);
    $rows = $db->raw(
        'SELECT id, starting_balance, current_balance, account_type, lifecycle_state
           FROM trading_accounts
          WHERE id = :id
            AND user_id = :uid
            AND workflow_context = :wf
          LIMIT 1',
        [':id' => $accountId, ':uid' => $userId, ':wf' => $validateWorkflowState]
    );

    if (empty($rows)) {
        http_response_code(404);
        echo json_encode(['success' => false, 'error' => 'account not found']);
        exit;
    }

    $account            = $rows[0];
    $startingBalance    = (float) $account['starting_balance'];
    $accountType        = $account['account_type'] ?? null;
    $liveCurrentBalance = isset($account['current_balance']) ? (float) $account['current_balance'] : null;
    $liveLifecycleState = $account['lifecycle_state'] ?? null;

    // ── 2b. Early-exit: live account with depleted balance ───────────────────
    // For personal accounts only. If the balance is at or below zero AND the
    // account is still marked active, auto-close it and return immediately —
    // do not proceed with validation.
    if ($accountType === 'personal'
        && $liveCurrentBalance !== null
        && $liveCurrentBalance <= 0
        && $liveLifecycleState === 'active') {

        // Auto-close the account
        $db->raw(
            "UPDATE trading_accounts
                SET lifecycle_state      = 'closed',
                    current_outcome      = 'CAPITAL_DEPLETED',
                    boundary_event       = 'ACCOUNT_CLOSED',
                    boundary_occurred_at = NOW(),
                    closed_at            = NOW()
              WHERE id      = :id
                AND user_id = :uid",
            [':id' => $accountId, ':uid' => $userId]
        );

        // Insert lifecycle event
        $db->raw(
            'INSERT INTO lifecycle_events (id, account_id, account_type, event_type, occurred_at)
             VALUES (UUID(), :account_id, :account_type, :event_type, NOW())',
            [
                ':account_id'   => $accountId,
                ':account_type' => 'live',
                ':event_type'   => 'ACCOUNT_CLOSED',
            ]
        );

        echo json_encode([
            'success'          => true,
            'account_closed'   => true,
            'outcome'          => 'CAPITAL_DEPLETED',
            'message'          => 'Account closed: balance depleted to $0',
            'account_type'     => $accountType,
            'current_balance'  => round($liveCurrentBalance, 2),
            'starting_balance' => round($startingBalance, 2),
        ]);
        exit;
    }

    // ── 3. Compute real balance from trade history ───────────────────────────
    $tradeHistory = $db->raw(
        'SELECT COALESCE(SUM(COALESCE(broker_pnl, projected_pnl, 0)), 0) AS total_pnl
           FROM trades
          WHERE account_id = :aid
            AND user_id = :uid
            AND status = :status
            AND deleted_at IS NULL',
        [
            ':aid'    => $accountId,
            ':uid'    => $userId,
            ':status' => 'closed'
        ]
    );
    $realPnl = (float)($tradeHistory[0]['total_pnl'] ?? 0);
    $realCurrentBalance = $startingBalance + $realPnl;

    // ── 4. Fetch challenge_accounts row ──────────────────────────────────────
    $challengeRows = $db->raw(
        'SELECT current_stage,
                lifecycle_state,
                challenge_type,
                rules,
                leverage,
                nickname,
                prop_firm_name
           FROM challenge_accounts
          WHERE account_id = :id
          LIMIT 1',
        [':id' => $accountId]
    );
    $challenge = $challengeRows[0] ?? null;

    // Parse rules JSON
    $rules = [];
    if ($challenge && !empty($challenge['rules'])) {
        $rules = json_decode($challenge['rules'], true) ?: [];
    }

    $challengeType = $challenge ? normalizeChallengeType($challenge['challenge_type'] ?? null, $rules) : null;
    $currentStage = $challenge['current_stage'] ?? null;

    // ── 4b. Resolve the account's current lifecycle state ───────────────────
    // lifecycle_state is the live source of truth (phase_1_active, phase_2_active,
    // funded_active, phase_1_complete, phase_2_complete, breached, closed,
    // archived). current_stage is the legacy column and is no longer read here —
    // it can go stale relative to lifecycle_state after promotion/funding events.
    $lifecycleState = $challenge['lifecycle_state'] ?? $liveLifecycleState;
    $isInstantFunding = isInstantFundingChallenge($challengeType, $rules);
    $isFundedActive = isFundedStage($lifecycleState, $currentStage);
    $isChallengePhase = isActiveChallengePhase($challengeType, $lifecycleState, $currentStage, $rules);
    $displayLabel = $challenge
        ? resolvePropFirmDisplayLabel($lifecycleState, $challengeType, $currentStage, $rules)
        : null;

    // ── 4c. Prop-firm accounts: PropFirmRuleEngine is the single source of
    // truth for phase-aware balance, drawdown, daily-loss, breach, and
    // pass-progress from here on — never re-derived locally. Closes two
    // real, confirmed bugs (docs/architecture/SINGLE_SOURCE_OF_TRUTH_AUDIT.md
    // §1i/§4/§6/§20, the audit's #1-2-ranked live findings after the MT5
    // gap): (a) this endpoint used to sum whole-ledger, uncapped broker_pnl/
    // projected_pnl as "current balance" for prop-firm accounts — a direct
    // P&L Rule violation and not phase-reset-aware, so a Phase-2 account
    // could show Phase 1's carried-over balance; (b) this endpoint's own
    // rules-cascade read ONLY flat rules[...] keys (`max_dd_pct`/
    // `daily_dd_pct`) with a default of 0 — so any account whose rules JSON
    // stores only phase-specific keys (the documented normal case for real
    // two/three-step accounts — see CLAUDE.md) silently disabled breach
    // detection entirely: `if ($maxDdPct > 0 && ...)` never ran. A trader
    // could see zero breach warning in the Calculator on a trade that
    // Drafts/Dashboard would immediately mark breached on save.
    //
    // Personal (live) accounts have no challenge_accounts row and are
    // untouched by this — $realCurrentBalance (the whole-ledger,
    // uncapped-broker_pnl sum computed above) IS the correct current
    // balance for a personal account per the P&L Rule; it is only wrong
    // when used for a prop-firm account, which is exactly the case this
    // block corrects.
    $propFirmMetrics = null;
    if ($challenge) {
        $pfr = PropFirmRuleEngine::compute($accountId);
        if (!isset($pfr['error'])) {
            $propFirmMetrics = $pfr;
            $realCurrentBalance = $propFirmMetrics['phase_balance'];
        }
    }

    // ── 5. Fetch today's realized losses ─────────────────────────────────────
    $today = date('Y-m-d');
    $lossRows = $db->raw(
        'SELECT COALESCE(SUM(COALESCE(broker_pnl, projected_pnl, 0)), 0) AS total_losses
           FROM trades
          WHERE account_id = :aid
            AND user_id = :uid
            AND COALESCE(broker_pnl, projected_pnl, 0) < 0
            AND DATE(closed_at) = :today
            AND status = :status
            AND deleted_at IS NULL',
        [
            ':aid'    => $accountId,
            ':uid'    => $userId,
            ':today'  => $today,
            ':status' => 'closed'
        ]
    );
    $todayLosses = abs((float)($lossRows[0]['total_losses'] ?? 0));
    if ($propFirmMetrics) {
        // Real, challenge_pnl-aware "today's realized loss" — the query
        // above (broker_pnl/projected_pnl, closed_at-scoped) is a P&L Rule
        // violation for a prop-firm account; PropFirmRuleEngine's version
        // is capped/effective P&L, captured_at-scoped, matching the exact
        // figure Drafts/Dashboard already use for the same account.
        $todayLosses = $propFirmMetrics['daily_drawdown']['current_amount'];
    }

    // ── 6. Extract challenge rule values ─────────────────────────────────────
    // PropFirmRuleEngine::resolveRules()'s phase-cascading thresholds
    // replace this file's own former flat-key-only reads (which defaulted
    // to 0 — see the 4c comment above for why that silently disabled
    // breach detection). For a personal/live account (no challenge row),
    // these all correctly stay 0 — no challenge concept applies.
    if ($propFirmMetrics) {
        $maxDdPct   = (float)$propFirmMetrics['phase']['max_overall_loss_pct'];
        $dailyDdPct = (float)$propFirmMetrics['phase']['max_daily_loss_pct'];
        // Funded/Instant Funding accounts have no profit target — same
        // override this file already applied, now layered on top of the
        // engine's own resolved value rather than a locally re-derived one.
        $profitTargetPct = ($isFundedActive || $isInstantFunding)
            ? 0.0
            : (float)$propFirmMetrics['phase']['profit_target_pct'];
    } else {
        $maxDdPct = 0.0;
        $dailyDdPct = 0.0;
        $profitTargetPct = 0.0;
    }

    // ── 7. Compute metrics ───────────────────────────────────────────────────
    if ($propFirmMetrics) {
        // Phase-anchored, clamped-at-zero — the canonical figure, matching
        // getChallengeMetrics()['overall_drawdown']['current_percentage'].
        $currentDrawdownPct = (float)$propFirmMetrics['overall_drawdown']['current_percentage'];
    } else {
        // Personal accounts: unchanged, unclamped/signed whole-ledger
        // drawdown — a genuinely different, correct concept for an
        // account with no phase/breach boundary at all.
        $currentDrawdownPct = 0.0;
        if ($startingBalance > 0) {
            $currentDrawdownPct = (($startingBalance - $realCurrentBalance) / $startingBalance) * 100;
        }
    }

    // Maximum Loss Model (2026-08-18, found during end-to-end Trailing
    // verification) — $currentDrawdownPct above is already correctly
    // peak-anchored for a Trailing account (via PropFirmRuleEngine's
    // overall_drawdown.current_percentage), but this projection was still
    // unconditionally dividing the hypothetical trade's risk by
    // $startingBalance — mixing a peak-relative base % with a starting-
    // balance-relative increment. $ddReferenceBalance mirrors
    // PropFirmRuleEngine::compute()'s own model dispatch exactly (peak_balance
    // for trailing, startingBalance otherwise — personal accounts have no
    // challenge concept and fall through to the unchanged starting-balance
    // case, same as before).
    $ddReferenceBalance = $startingBalance;
    if ($propFirmMetrics && ($propFirmMetrics['overall_drawdown']['max_loss_model'] ?? 'fixed') === 'trailing') {
        $ddReferenceBalance = (float)$propFirmMetrics['overall_drawdown']['peak_balance'];
    }
    $projectedDrawdownPct = $currentDrawdownPct;
    if ($ddReferenceBalance > 0) {
        $projectedDrawdownPct = $currentDrawdownPct + (($riskPerPosition / $ddReferenceBalance) * 100);
    }

    $dailyLossLimit = 0.0;
    $dailyLossUsedPct = 0.0;
    $dailyLossProjectedPct = 0.0;

    if ($propFirmMetrics) {
        $dailyLossLimit = (float)$propFirmMetrics['daily_drawdown']['limit_amount'];
    } elseif ($dailyDdPct > 0) {
        $dailyLossLimit = $realCurrentBalance * ($dailyDdPct / 100);
    }
    if ($dailyLossLimit > 0) {
        $dailyLossUsedPct = ($todayLosses / $dailyLossLimit) * 100;
        $dailyLossProjectedPct = (($todayLosses + $riskPerPosition) / $dailyLossLimit) * 100;
    }

    // Canonical, already-capped-at-100 progress toward the phase's real
    // profit target — replaces this file's own re-derivation (which used
    // to divide by a locally-resolved, possibly-zero-defaulted target).
    $profitTargetProgressPct = 0.0;
    if ($propFirmMetrics && !$isFundedActive && !$isInstantFunding) {
        $profitTargetProgressPct = (float)$propFirmMetrics['distance_to_pass']['progress_percentage'];
    }

    // ── 8. Phase completion detection (read-only — never writes lifecycle) ───
    // Lifecycle changes only happen when the trader confirms at Draft Outcome
    // (dashboard.php) or promotes via Add Account (create.php promote mode).
    $phaseCompleted = false;
    $completedPhase = null;
    $completionMessage = null;

    $isPassed = $propFirmMetrics ? (bool)($propFirmMetrics['distance_to_pass']['is_passed'] ?? false) : false;
    if (!$isFundedActive && !$isInstantFunding && $isPassed && $challenge) {
        if ($lifecycleState === 'phase_1_active') {
            $phaseCompleted = true;
            $completedPhase = 'phase_1';
            $completionMessage = 'Phase 1 profit target reached! Confirm Passed Phase at Draft Outcome, then visit Add Account to promote.';
        } elseif ($lifecycleState === 'phase_2_active') {
            $phaseCompleted = true;
            $completedPhase = 'phase_2';
            $completionMessage = 'Phase 2 profit target reached! Confirm Passed Phase at Draft Outcome, then visit Add Account to promote.';
        } elseif ($lifecycleState === 'phase_3_active') {
            $phaseCompleted = true;
            $completedPhase = 'phase_3';
            $completionMessage = 'Phase 3 profit target reached! Confirm Passed Phase at Draft Outcome, then visit Add Account to promote.';
        }
    }

    // ── 9. Breach detection (prop firm accounts) ─────────────────────────────
    // PropFirmRuleEngine::breachStatus() — the real breach_level_amount
    // (phase-cascaded, never a flat-key-defaulted-to-0 value) plus a
    // would_breach check against this specific hypothetical post-trade
    // balance. This is the exact check that used to silently never fire
    // for any account whose rules JSON only carried phase-specific keys.
    $breachWarning = false;
    $breachLevel = 0.0;

    if ($propFirmMetrics) {
        $breachLevel = (float)$propFirmMetrics['overall_drawdown']['breach_level_amount'];
        $projectedBalanceAfterLoss = $realCurrentBalance - $riskPerPosition;
        $breach = PropFirmRuleEngine::breachStatus($accountId, $projectedBalanceAfterLoss);
        $breachWarning = (bool)($breach['would_breach'] ?? false);
    }

    // ── 10. Live account boundary detection ──────────────────────────────────
    // Only runs for personal (live) accounts. Prop firm logic above is untouched.
    $boundaryWarning = false;
    $boundaryType    = null;
    $accountClosed   = false;
    $accountOutcome  = null;

    if ($accountType === 'personal') {
        $projectedBalanceAfterTrade = $realCurrentBalance - $riskPerPosition;

        // Case A: account is already at or below zero — auto-close it
        if ($realCurrentBalance <= 0) {
            $db->raw(
                "UPDATE trading_accounts
                    SET lifecycle_state        = 'closed',
                        current_outcome        = 'CAPITAL_DEPLETED',
                        boundary_event         = 'ACCOUNT_CLOSED',
                        boundary_occurred_at   = NOW(),
                        closed_at              = NOW()
                  WHERE id      = :id
                    AND user_id = :uid",
                [':id' => $accountId, ':uid' => $userId]
            );

            $db->raw(
                'INSERT INTO lifecycle_events (id, account_id, account_type, event_type, occurred_at)
                 VALUES (UUID(), :account_id, :account_type, :event_type, NOW())',
                [
                    ':account_id'   => $accountId,
                    ':account_type' => 'live',
                    ':event_type'   => 'ACCOUNT_CLOSED',
                ]
            );

            $accountClosed  = true;
            $accountOutcome = 'CAPITAL_DEPLETED';

        // Case B: this trade would push the balance to zero or below — warn
        } elseif ($projectedBalanceAfterTrade <= 0) {
            $boundaryWarning = true;
            $boundaryType    = 'CAPITAL_DEPLETED';
        }
    }

    // ── 11. Build account label ────────────────────────────────────────────
    $accountLabel = null;
    if ($challenge) {
        $accountLabel = $challenge['nickname'] ?: $challenge['prop_firm_name'] ?: null;
    }

    // ── 12. Build and return response ────────────────────────────────────────
    $response = [
        'success'                     => true,
        'current_balance'             => round($realCurrentBalance, 2),
        'starting_balance'            => round($startingBalance, 2),
        'current_drawdown_pct'        => round($currentDrawdownPct, 2),
        'projected_drawdown_pct'      => round($projectedDrawdownPct, 2),
        'max_dd_pct'                  => round($maxDdPct, 2),
        'daily_loss_used_pct'         => round($dailyLossUsedPct, 1),
        'daily_loss_projected_pct'    => round($dailyLossProjectedPct, 1),
        'daily_loss_limit'            => round($dailyLossLimit, 2),
        'today_losses'                => round($todayLosses, 2),
        'daily_dd_pct'                => round($dailyDdPct, 2),
        'profit_target_pct'           => round($profitTargetPct, 2),
        'profit_target_progress_pct'  => round($profitTargetProgressPct, 1),
        // lifecycle_state is the live source of truth. "phase" is kept as a
        // backwards-compatible alias for any existing code that reads that
        // field — both are set to the same lifecycle_state value, no more
        // reading from the legacy current_stage column.
        'lifecycle_state'             => $lifecycleState,
        'phase'                       => $lifecycleState,
        'current_stage'               => $currentStage,
        'challenge_type'              => $challengeType,
        'display_label'               => $displayLabel,
        'is_instant_funding'          => $isInstantFunding,
        'is_challenge_phase'          => $isChallengePhase,
        'leverage'                    => $challenge ? $challenge['leverage'] : null,
        'account_label'               => $accountLabel,
        'account_type'                => $accountType,
        // Model-aware dollar figures — see the file-header comment above
        // for why these are unconditional and what they close. All null
        // when there's no prop-firm challenge context (personal account).
        'max_dd_limit_amount'         => $propFirmMetrics ? round((float)$propFirmMetrics['overall_drawdown']['limit_amount'], 2) : null,
        'breach_level_amount'         => $propFirmMetrics ? round((float)$propFirmMetrics['overall_drawdown']['breach_level_amount'], 2) : null,
        'peak_balance'                => $propFirmMetrics ? round((float)$propFirmMetrics['overall_drawdown']['peak_balance'], 2) : null,
        'max_loss_model'              => $propFirmMetrics ? $propFirmMetrics['overall_drawdown']['max_loss_model'] : null,
    ];

    // Prop firm: breach warning
    if ($breachWarning) {
        $response['breach_warning'] = true;
        $response['breach_level']   = round($breachLevel, 2);
    }

    // Prop firm: phase completion
    if ($phaseCompleted) {
        $response['phase_completed'] = true;
        $response['completed_phase'] = $completedPhase;
        $response['message']         = $completionMessage;
    }

    // Live account: capital boundary warning (trade would deplete account)
    if ($boundaryWarning) {
        $response['boundary_warning'] = true;
        $response['boundary_type']    = $boundaryType;
        $response['message']          = 'This trade will deplete your account to $0 or below.';
    }

    // Live account: account already at zero — auto-closed
    if ($accountClosed) {
        $response['account_closed'] = true;
        $response['outcome']        = $accountOutcome;
    }

    echo json_encode($response);

} catch (\Throwable $e) {
    // Any SQL/PDO failure (or anything else) in the block above lands here
    // instead of escaping as an uncaught exception. Returns clean, parseable
    // JSON with a 500 status so the frontend's .catch() fires predictably
    // and can dismiss the Live Account card's loading state instead of it
    // hanging on a response that never arrives as valid JSON.
    http_response_code(500);
    echo json_encode(['success' => false, 'error' => 'Unable to validate trade. Please try again.']);
    exit;
}