<?php
namespace App\Nexus\Engine;

use App\Models\Market;
use App\Models\Sell;
use App\Models\User;
use App\Nexus\Models\NexusSession;
use App\Nexus\Models\NexusUserMetric;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

/**
 * NEXUS ENGINE - SellEvaluator
 *
 * The core of the NEXUS algorithm.
 * Full 9-step evaluation of every sell that has reached the 24-hour mark.
 * Sells processed in first-come-first-serve order (sell_time ASC).
 *
 * Cap Overhaul (March 20, 2026):
 *   - Step 4 runs BEFORE Steps 1 and 2 (closes buyback + first-timer bypass)
 *   - Live active_capital replaces dead monthly_capital
 *   - Rolling 30-day window with lazy reset (no scheduler needed)
 *   - Capital-weighted effective cap (log scale)
 *   - Daily + weekly budgets with randomized multipliers
 *   - Per-trade profit check (not just cumulative)
 *   - Quantity included in potential profit calculation
 *   - In-memory batch tracking prevents same-user same-batch race condition
 *   - Step 9: lossScore capped at session requirement (G1)
 *   - Step 9: capitalScore uses live active_capital with log scale (G4)
 *
 * Cap Tuning (March 21, 2026):
 *   - Step 3 redesigned as PROXIMITY BRAKE (only activates when weekly
 *     consumption exceeds loss_brake_threshold, default 60%)
 *   - Daily budget floor (proportional: max of $0.25 or base at 1.0x randomness)
 *   - Weekly budget floor (proportional: max of $1.00 or base at 1.0x randomness)
 *   - Randomness floors raised (daily 0.3->0.8, weekly 0.5->0.8) via config
 *
 * Deployed session schema:
 *   status: pending -> active -> processing -> completed
 *   criteria_order (JSON) = the rotation profile
 *   actual_start / actual_end = session open/close timestamps
 *   win_rate_target / win_rate_actual
 *   total_sells_evaluated / total_sells_approved / total_sells_rejected
 *
 * @package App\Nexus\Engine
 */
class SellEvaluator
{
    private EconomyGuardian $economy;
    private TradeExecutor $executor;

    public function __construct(EconomyGuardian $economy, TradeExecutor $executor)
    {
        $this->economy  = $economy;
        $this->executor = $executor;
    }

    /**
     * Evaluate all pending sells that have reached the 24-hour mark.
     *
     * @param NexusSession|null $session Most recent session for profile data
     * @return array ['evaluated' => int, 'won' => int, 'lost' => int, 'guaranteed' => int]
     */
    public function evaluateBatch(?NexusSession $session = null): array
    {
        $summary = ['evaluated' => 0, 'won' => 0, 'lost' => 0, 'guaranteed' => 0];

        $pendingSells = Sell::with(['market', 'product', 'ownership', 'user'])
            ->where('type', 'manual_sell')
            ->where('status', 0)
            ->where('sell_time', '<=', Carbon::now()->subHours(24))
            ->orderBy('sell_time', 'asc')
            ->get();

        if ($pendingSells->isEmpty()) {
            return $summary;
        }

        // Get most recent session for its profile
        if (! $session) {
            $session = NexusSession::whereIn('status', [
                NexusSession::STATUS_PROCESSING,
                NexusSession::STATUS_COMPLETED,
                NexusSession::STATUS_ACTIVE,
            ])->orderBy('id', 'desc')->first();
        }

        $sessionProfile = $session
            ? CriteriaRotator::getOrGenerateProfile($session)
            : CriteriaRotator::generateProfile(1);

        $batchContext = $this->buildBatchContext($pendingSells);

        // ── ORIGIN SESSION MAP ──
        // Trace each sell back to the session where its bid was placed.
        // sell → bid (same user_id + product_id, manual_bid, status=1) → nexus_session_id.
        // This ensures sell evaluation stats are attributed to the correct session.
        $sellIds   = $pendingSells->pluck('id')->toArray();
        $originMap = []; // sell_id => nexus_session_id
        if (! empty($sellIds)) {
            $origins = DB::table('sells as s')
                ->join('bids as b', function ($join) {
                    $join->on('b.user_id', '=', 's.user_id')
                        ->on('b.product_id', '=', 's.product_id')
                        ->where('b.type', '=', 'manual_bid')
                        ->where('b.status', '=', 1);
                })
                ->whereIn('s.id', $sellIds)
                ->whereNotNull('b.nexus_session_id')
                ->select('s.id as sell_id', 'b.nexus_session_id')
                ->get();
            foreach ($origins as $o) {
                $originMap[$o->sell_id] = $o->nexus_session_id;
            }
        }

        // Per-origin-session counters: [session_id => [evaluated, won, lost]]
        $sessionCounters = [];

        // Helper to attribute each sell outcome to its origin session
        $trackOutcome = function (int $sellId, string $type) use (&$sessionCounters, &$originMap, $session) {
            $sid = $originMap[$sellId] ?? ($session ? $session->id : null);
            if ($sid === null) {
                return;
            }

            if (! isset($sessionCounters[$sid])) {
                $sessionCounters[$sid] = ['evaluated' => 0, 'won' => 0, 'lost' => 0];
            }
            $sessionCounters[$sid]['evaluated']++;
            if ($type === 'won') {
                $sessionCounters[$sid]['won']++;
            } elseif ($type === 'lost') {
                $sessionCounters[$sid]['lost']++;
            }

        };

        $batchContext = $this->buildBatchContext($pendingSells);

        // ── P9 FIX: In-memory batch profit tracker per user ──
        // Prevents multiple sells from the same user all passing cap in one batch.
        // Tracks potential profit from sells that passed cap (whether buyback,
        // first-timer, or qualified pool). Conservative: some may not win in Step 9,
        // but this prevents the worst case of all winning and exceeding the budget.
        // Actual DB values (daily_profit, weekly_profit, monthly_profit) only update
        // for real winners via TradeExecutor.executeWin().
        $batchProfitTracker = [];

        // Phase 1: Evaluate each sell through steps
        $qualifiedSells      = [];
        $qualifiedMarketAsks = [];

        foreach ($pendingSells as $sell) {
            $summary['evaluated']++;

            // Set origin session on the sell so TradeExecutor can persist it
            $sell->nexus_session_id = $originMap[$sell->id] ?? ($session ? $session->id : null);

            if (! $sell->user || ! $sell->market || ! $sell->product) {
                $this->executor->executeLoss($sell, 'Missing product, market, or user data');
                $summary['lost']++;
                $trackOutcome($sell->id, 'lost');
                continue;
            }

            if ($sell->user->status == 0) {
                continue;
            }
            // Banned user, leave pending

            // ── CAP CHECK RUNS FIRST (before buyback and first-timer) ──
            // Closes P3 (buyback bypasses cap) and P4 (first-timer bypasses cap).
            // Both guarantees still work but must respect daily/weekly/monthly budgets.
            $batchExtra = $batchProfitTracker[$sell->user_id] ?? 0;
            $capResult  = $this->step4_ProfitCapCheck($sell, $batchExtra);
            if ($capResult !== null && $capResult->isRejected()) {
                $this->executor->executeLoss($sell, $capResult->reason);
                $this->logDecision($sell, $session, $capResult);
                $summary['lost']++;
                $trackOutcome($sell->id, 'lost');
                continue;
            }

            // G2 FIX: potential profit includes quantity
            $potentialProfit = ((float) $sell->amount - (float) $sell->initial_price)
             * max(1, (int) $sell->quantity);

            // Step 1: Buyback zone (guaranteed, but now paced by cap)
            $result = $this->step1_BuybackZone($sell, $batchContext);
            if ($result !== null) {
                if ($result->isApproved()) {
                    $this->executor->executeWin($sell, (float) $sell->amount);
                    $batchProfitTracker[$sell->user_id] = $batchExtra + $potentialProfit;
                    $summary['guaranteed']++;
                    $summary['won']++;
                    $trackOutcome($sell->id, 'won');
                }
                $this->logDecision($sell, $session, $result);
                continue;
            }

            // Step 2: First-time seller (guaranteed, but now paced by cap)
            $result = $this->step2_FirstTimeSeller($sell);
            if ($result !== null) {
                if ($result->isApproved()) {
                    $this->executor->executeWin($sell, (float) $sell->amount);
                    $batchProfitTracker[$sell->user_id] = $batchExtra + $potentialProfit;
                    $summary['guaranteed']++;
                    $summary['won']++;
                    $trackOutcome($sell->id, 'won');
                }
                $this->logDecision($sell, $session, $result);
                continue;
            }

            // Steps 3, 5-8 in rotated order (Step 4 removed -- already ran above)
            $stepOrder = $sessionProfile['step_order'] ?? [3, 4, 5, 6, 7, 8];
            $rejected  = false;
            $marketAsk = null;

            foreach ($stepOrder as $stepNumber) {
                if ($stepNumber === 4) {
                    continue;
                }
                // Already ran above

                $stepResult = null;

                switch ($stepNumber) {
                    case 3:
                        $stepResult = $this->step3_LossRequirement($sell, $sessionProfile);
                        break;
                    case 5:
                        $stepResult = $this->step5_SessionWinCapCheck($sell, $batchContext, $sessionProfile);
                        break;
                    case 6:
                        $marketAsk = $this->step6_DynamicMarketAsk($sell, $batchContext, $sessionProfile);
                        break;
                    case 7:
                        if ($marketAsk === null) {
                            $marketAsk = $this->step6_DynamicMarketAsk($sell, $batchContext, $sessionProfile);
                        }
                        $stepResult = $this->step7_RecessExcessCheck($sell, $marketAsk);
                        break;
                    case 8:
                        // Win rate calculated at batch level in step 9
                        break;
                }

                if ($stepResult !== null && $stepResult->isRejected()) {
                    $this->executor->executeLoss($sell, $stepResult->reason);
                    $this->logDecision($sell, $session, $stepResult);
                    $summary['lost']++;
                    $trackOutcome($sell->id, 'lost');
                    $rejected = true;
                    break;
                }
            }

            if ($rejected) {
                continue;
            }

            if ($marketAsk === null) {
                $marketAsk = $this->step6_DynamicMarketAsk($sell, $batchContext, $sessionProfile);
            }

            // Track potential profit for sells entering the winner pool
            $batchProfitTracker[$sell->user_id] = $batchExtra + $potentialProfit;

            $qualifiedSells[]               = $sell;
            $qualifiedMarketAsks[$sell->id] = $marketAsk;
        }

        // Phase 2: Step 9 -- Weighted winner selection
        if (! empty($qualifiedSells)) {
            $winRate = $this->economy->calculateWinRate($batchContext, $sessionProfile);

            $winners = $this->step9_WeightedSelection(
                $qualifiedSells, $winRate, $sessionProfile, $qualifiedMarketAsks
            );

            $winnerIds = array_map(fn($s) => $s->id, $winners);

            foreach ($qualifiedSells as $sell) {
                if (in_array($sell->id, $winnerIds)) {
                    $fillPrice = $this->determineFillPrice($sell, $qualifiedMarketAsks[$sell->id]);
                    $this->executor->executeWin($sell, $fillPrice);
                    $this->logDecision($sell, $session, EvaluationResult::approved(
                        'Selected in weighted winner pool',
                        ['fill_price' => $fillPrice, 'win_rate' => $winRate]
                    ));
                    $summary['won']++;
                    $trackOutcome($sell->id, 'won');
                } else {
                    $this->executor->executeLoss($sell, 'Not selected in winner pool (win rate: ' . $winRate . '%)');
                    $this->logDecision($sell, $session, EvaluationResult::rejected(
                        'Not selected in winner pool', ['win_rate' => $winRate]
                    ));
                    $summary['lost']++;
                    $trackOutcome($sell->id, 'lost');
                }
            }

            // Update win_rate_target on the evaluation session (for display)
            if ($session) {
                $session->update(['win_rate_target' => $winRate]);
            }
        }

        // ── UPDATE ORIGIN SESSION COUNTERS ──
        // Each sell's evaluation stats go to the session where its bid was placed,
        // not the most recent session. This ensures session history shows accurate
        // evaluated/approved/rejected counts per session.
        foreach ($sessionCounters as $originSessionId => $counts) {
            $originSession = NexusSession::find($originSessionId);
            if (! $originSession) {
                continue;
            }

            $newEval       = $originSession->total_sells_evaluated + $counts['evaluated'];
            $newApproved   = $originSession->total_sells_approved + $counts['won'];
            $newRejected   = $originSession->total_sells_rejected + $counts['lost'];
            $actualWinRate = $newEval > 0 ? round(($newApproved / $newEval) * 100, 2) : 0;

            // Query absolute sell totals for this session (idempotent across batches)
            $sellTotals = Sell::where('nexus_session_id', $originSessionId)
                ->whereIn('status', [1, 2])
                ->selectRaw('COALESCE(SUM(amount * quantity), 0) as sell_val, COALESCE(SUM(initial_price * quantity), 0) as bid_cost')
                ->first();

            $originSession->update([
                'total_sells_evaluated' => $newEval,
                'total_sells_approved'  => $newApproved,
                'total_sells_rejected'  => $newRejected,
                'win_rate_actual'       => $actualWinRate,
                'total_sell_value'      => round((float) ($sellTotals->sell_val ?? 0), 2),
                'total_bid_cost'        => round((float) ($sellTotals->bid_cost ?? 0), 2),
            ]);
        }

        // Mark session as completed if it was processing
        if ($session && $session->status === NexusSession::STATUS_PROCESSING) {
            $session->update(['status' => NexusSession::STATUS_COMPLETED]);
        }

        return $summary;
    }

    // ── THE 9 STEPS ──

    private function step1_BuybackZone(Sell $sell, array $batchContext): ?EvaluationResult
    {
        $margins    = $this->economy->calculateBuybackMargin($batchContext);
        $buybackMin = $sell->initial_price * (1 + $margins['min'] / 100);
        $buybackMax = $sell->initial_price * (1 + $margins['max'] / 100);

        if ($sell->amount >= $buybackMin && $sell->amount <= $buybackMax) {
            return EvaluationResult::guaranteed('Buyback zone hit', [
                'asking_price' => (float) $sell->amount,
                'buyback_min'  => round($buybackMin, 2),
                'buyback_max'  => round($buybackMax, 2),
            ]);
        }
        return null;
    }

    private function step2_FirstTimeSeller(Sell $sell): ?EvaluationResult
    {
        if (! NexusConfig::getBool('first_timer_guarantee', true)) {
            return null;
        }

        $monthYear = Carbon::now()->format('Y-m');
        $metric    = NexusUserMetric::getOrCreate($sell->user_id, $monthYear);

        if ($metric->first_sell_win_used) {
            return null;
        }

        $hasEverWon = Sell::where('user_id', $sell->user_id)->where('status', 1)->exists();
        if (! $hasEverWon) {
            // Write using actual column name (first_sell_granted), not the virtual
            // attribute (first_sell_win_used). Laravel's fill() checks $fillable before
            // calling mutators, and first_sell_win_used is not in $fillable.
            $metric->update(['first_sell_granted' => 1]);
            return EvaluationResult::guaranteed('First-time seller guarantee');
        }
        return null;
    }

    /**
     * Step 3: Loss Requirement (PROXIMITY BRAKE -- redesigned March 21, 2026)
     *
     * This is NOT a permanent gate on every sell. It is a pacing brake that
     * only activates when the user is consuming their weekly budget too fast.
     *
     * Flow:
     *   1. User trades normally. Wins/losses from regular evaluation pipeline.
     *   2. Weekly profit accumulates. No loss requirement blocking them.
     *   3. Weekly consumption hits threshold (e.g. 60% of weekly budget).
     *   4. NOW loss requirement activates: user needs X losses before next win.
     *   5. This creates gaps between wins, spreading remaining budget across
     *      remaining days instead of burning it all at once.
     *
     * Below the threshold: step passes unconditionally. Users trade freely.
     * Above the threshold: dynamic loss requirement (2-8, rotated per session).
     *
     * The loss requirement number is dynamic per session (CriteriaRotator picks
     * a different value each session from the admin range). Users cannot predict
     * how many losses they need week to week.
     */
    private function step3_LossRequirement(Sell $sell, array $sessionProfile): ?EvaluationResult
    {
        $metric = NexusUserMetric::getOrCreate($sell->user_id);

        $weeklyBudget = (float) $metric->weekly_budget;
        $weeklyProfit = (float) $metric->weekly_profit;

        // No weekly budget yet (first trade of the week) -- skip loss check
        if ($weeklyBudget <= 0) {
            return null;
        }

        // Check weekly consumption ratio against brake threshold
        $brakeThreshold   = NexusConfig::getFloat('loss_brake_threshold', 0.6);
        $consumptionRatio = $weeklyProfit / $weeklyBudget;

        // Below threshold: user is pacing fine, no loss requirement
        if ($consumptionRatio < $brakeThreshold) {
            return null;
        }

        // Above threshold: enforce dynamic loss requirement to pace remaining budget
        $required = $sessionProfile['effective_loss_requirement'] ?? 5;
        $actual   = (int) $metric->consecutive_losses;

        if ($actual < $required) {
            $needed = $required - $actual;
            return EvaluationResult::rejected(
                "Need {$needed} more loss(es) before qualifying",
                [
                    'consecutive_losses' => $actual,
                    'required'           => $required,
                    'weekly_consumption' => round($consumptionRatio * 100, 1),
                    'brake_threshold'    => round($brakeThreshold * 100, 1),
                ]
            );
        }
        return null;
    }

    /**
     * Step 4: Profit Cap Check (COMPLETE REWRITE -- Cap Overhaul March 20, 2026)
     *
     * Replaces the non-functional original that read dead monthly_capital (always 0).
     *
     * Three-ceiling system: monthly cap + weekly budget + daily budget.
     * All three must pass. Capital-weighted effective cap via log scale.
     * Lazy 30-day rolling reset (no scheduler needed).
     *
     * Solves: P1 (dead monthly_capital), P2 (no reset), P5 (cumulative-only check),
     *         P6 (calendar month unfair), P7 (no pacing), P8 (flat cap),
     *         P10 (ICTP interaction), G2 (quantity in potential_profit).
     *
     * @param Sell  $sell       The sell being evaluated
     * @param float $batchExtra Cumulative potential profit already approved for this
     *                          user in the current evaluation batch (P9 fix)
     * @return EvaluationResult|null null = passed, EvaluationResult = rejected
     */
    private function step4_ProfitCapCheck(Sell $sell, float $batchExtra = 0): ?EvaluationResult
    {
        $user   = $sell->user;
        $metric = NexusUserMetric::getOrCreate($sell->user_id);

        // ── 1. LAZY RESET CHECK ──
        // First-ever trade: start the 30-day cycle.
        // Cycle expired: reset all profit counters inline. No scheduler needed.
        $capPeriod = NexusConfig::getInt('cap_period_days', 30);

        if ($metric->metrics_reset_at === null) {
            $metric->update(['metrics_reset_at' => now()]);
            $metric->refresh();
        } else {
            $daysSinceReset = (int) Carbon::now()->diffInDays($metric->metrics_reset_at);
            if ($daysSinceReset >= $capPeriod) {
                $metric->update([
                    'monthly_profit'   => 0,
                    'weekly_profit'    => 0,
                    'weekly_budget'    => null,
                    'week_started_at'  => null,
                    'daily_profit'     => 0,
                    'daily_budget'     => null,
                    'day_date'         => null,
                    'metrics_reset_at' => now(),
                ]);
                $metric->refresh();

                NexusAuditLogger::logAlgorithmDecision(
                    null, $sell->user_id, 'metrics_monthly_reset', 'user',
                    $sell->user_id, 'approved', 'Cap cycle reset (lazy, 30-day window expired)', [
                        'days_since_reset' => $daysSinceReset,
                        'cap_period'       => $capPeriod,
                    ]
                );
            }
        }

        // ── 2. CALCULATE ACTIVE CAPITAL (live, never stored) ──
        // Replaces dead monthly_capital. Handles deposits, withdrawals, recycled capital.
        $activeCapital = NexusUserMetric::calculateActiveCapital($user);
        if ($activeCapital <= 0) {
            return null;
        }
        // Nothing to cap

        // ── 3. CALCULATE EFFECTIVE CAP (capital-weighted log scale) ──
        // Admin sets one number (e.g. 10%). Engine derives effective per-user cap.
        // Higher capital = lower percentage. Log scale prevents whales from earning
        // disproportionate absolute profit.
        $adminMax = NexusConfig::getFloat('monthly_profit_cap_percent', 10.0);
        $floor    = max(1.0, $adminMax - 3.0);

        $logMin = log10(50);      // Platform minimum ($50 ICTP)
        $logMax = log10(1000000); // Scale ceiling ($1M)
        $logCap = log10(max($activeCapital, 50));
        $ratio  = ($logMax > $logMin) ? ($logCap - $logMin) / ($logMax - $logMin) : 0;
        $ratio  = max(0.0, min(1.0, $ratio));

        $effectiveCapPercent = $adminMax - ($ratio * ($adminMax - $floor));
        $monthlyCapAmount    = $activeCapital * ($effectiveCapPercent / 100);

        // Store for dashboard visibility
        $metric->updateQuietly(['effective_cap_percent' => round($effectiveCapPercent, 2)]);

        // ── 4. POTENTIAL PROFIT (per-trade, with quantity -- G2 fix) ──
        $potentialProfit = ((float) $sell->amount - (float) $sell->initial_price)
         * max(1, (int) $sell->quantity);

        // ── 5. MONTHLY CAP CHECK ──
        // P5 fix: checks if THIS trade would push over, not just cumulative.
        $currentMonthly = (float) $metric->monthly_profit + $batchExtra;
        if ($currentMonthly + $potentialProfit > $monthlyCapAmount) {
            return EvaluationResult::rejected('Monthly profit cap reached', [
                'current_profit'        => round($currentMonthly, 2),
                'potential_profit'      => round($potentialProfit, 2),
                'cap_amount'            => round($monthlyCapAmount, 2),
                'effective_cap_percent' => round($effectiveCapPercent, 2),
                'active_capital'        => round($activeCapital, 2),
            ]);
        }

        // ── 6. WEEKLY BUDGET CHECK ──
        $weeklyProfit = (float) $metric->weekly_profit;
        $weeklyBudget = $metric->weekly_budget;

        if (! $metric->week_started_at || Carbon::now()->diffInDays($metric->week_started_at) >= 7) {
            // New week within the cycle
            $weeklyBase   = $monthlyCapAmount / 4.3;
            $wMin         = NexusConfig::getFloat('weekly_pace_randomness_min', 0.5);
            $wMax         = NexusConfig::getFloat('weekly_pace_randomness_max', 1.8);
            $wRand        = $wMin + (mt_rand() / mt_getrandmax()) * ($wMax - $wMin);
            $weeklyBudget = round($weeklyBase * $wRand, 2);

            // Proportional floor: the larger of (a) a small absolute minimum or
            // (b) the base budget at 1.0x randomness. This prevents bad randomness
            // rolls from creating unusable budgets, WITHOUT inflating the budget
            // beyond what the monthly cap can sustain. A flat $5 floor on a $5
            // monthly cap would let users burn 100% in week 1.
            $weeklyAbsMin = NexusConfig::getFloat('weekly_budget_floor', 1.00);
            $weeklyBudget = max($weeklyBudget, $weeklyAbsMin, round($weeklyBase, 2));

            $weeklyProfit = 0;

            $metric->update([
                'weekly_profit'   => 0,
                'weekly_budget'   => $weeklyBudget,
                'week_started_at' => now(),
            ]);
        }

        if ($weeklyBudget !== null && ($weeklyProfit + $batchExtra + $potentialProfit) > $weeklyBudget) {
            return EvaluationResult::rejected('Weekly trading limit reached', [
                'weekly_profit'    => round($weeklyProfit + $batchExtra, 2),
                'potential_profit' => round($potentialProfit, 2),
                'weekly_budget'    => round((float) $weeklyBudget, 2),
            ]);
        }

        // ── 7. DAILY BUDGET CHECK ──
        $dailyProfit = (float) $metric->daily_profit;
        $dailyBudget = $metric->daily_budget;

        $today   = Carbon::today()->toDateString();
        $dayDate = $metric->day_date ? $metric->day_date->toDateString() : null;

        if ($dayDate !== $today) {
            // New day
            $dailyBase   = $monthlyCapAmount / 30;
            $dMin        = NexusConfig::getFloat('daily_pace_randomness_min', 0.3);
            $dMax        = NexusConfig::getFloat('daily_pace_randomness_max', 2.0);
            $dRand       = $dMin + (mt_rand() / mt_getrandmax()) * ($dMax - $dMin);
            $dailyBudget = round($dailyBase * $dRand, 2);

            // Proportional floor: same logic as weekly. The larger of (a) a small
            // absolute minimum or (b) the base budget at 1.0x randomness.
            // $50 user: base = $0.17, floor = max($0.25, $0.17) = $0.25/day.
            // $1000 user: base = $3.03, floor = max($0.25, $3.03) = $3.03/day.
            $dailyAbsMin = NexusConfig::getFloat('daily_budget_floor', 0.25);
            $dailyBudget = max($dailyBudget, $dailyAbsMin, round($dailyBase, 2));

            $dailyProfit = 0;

            $metric->update([
                'daily_profit' => 0,
                'daily_budget' => $dailyBudget,
                'day_date'     => $today,
            ]);
        }

        if ($dailyBudget !== null && ($dailyProfit + $batchExtra + $potentialProfit) > $dailyBudget) {
            return EvaluationResult::rejected('Daily trading limit reached', [
                'daily_profit'     => round($dailyProfit + $batchExtra, 2),
                'potential_profit' => round($potentialProfit, 2),
                'daily_budget'     => round((float) $dailyBudget, 2),
            ]);
        }

        // All three ceilings passed
        return null;
    }

    private function step5_SessionWinCapCheck(Sell $sell, array $batchContext, array $sessionProfile = []): ?EvaluationResult
    {
        // Dynamic per session (same pattern as loss requirement).
        // Falls back to flat nexus_config if profile has no value (pre-update sessions).
        $maxWins  = $sessionProfile['effective_wins_per_session'] ?? NexusConfig::getInt('max_wins_per_session', 4);
        $userWins = $batchContext['session_win_counts'][$sell->user_id] ?? 0;

        if ($userWins >= $maxWins) {
            return EvaluationResult::rejected(
                "Session win cap reached ({$userWins}/{$maxWins})",
                ['wins' => $userWins, 'cap' => $maxWins]
            );
        }
        return null;
    }

    private function step6_DynamicMarketAsk(Sell $sell, array $batchContext, array $sessionProfile): float
    {
        return $this->economy->calculateMarketAsk(
            $sell->market, (float) $sell->initial_price, $batchContext, $sessionProfile
        );
    }

    private function step7_RecessExcessCheck(Sell $sell, float $marketAsk): ?EvaluationResult
    {
        $askingPrice   = (float) $sell->amount;
        $recessPercent = (float) ($sell->recess_percent ?? 0);
        $excessPercent = (float) ($sell->excess_percent ?? 0);

        $recessFloor   = $askingPrice - ($recessPercent / 100 * $askingPrice);
        $excessCeiling = $askingPrice + ($excessPercent / 100 * $askingPrice);

        if ($marketAsk < $recessFloor || $marketAsk > $excessCeiling) {
            return EvaluationResult::rejected('Market price outside risk tolerance', [
                'market_ask'     => round($marketAsk, 2),
                'asking_price'   => $askingPrice,
                'recess_floor'   => round($recessFloor, 2),
                'excess_ceiling' => round($excessCeiling, 2),
            ]);
        }
        return null;
    }

    /**
     * Step 9: Weighted Winner Selection
     *
     * G1 fix: lossScore capped at session's effective_loss_requirement.
     *         Users cannot game the system by accumulating 50 deliberate losses.
     * G4 fix: capitalScore uses live active_capital (log scale) instead of dead monthly_capital.
     *         The 20% capital weight now actually contributes to scoring.
     * Performance: pre-compute active capitals and capped losses in normalization pass,
     *              reuse in scoring pass. Eliminates double NexusUserMetric queries.
     */
    private function step9_WeightedSelection(
        array $qualifiedSells, float $winRate, array $sessionProfile, array $marketAsks
    ): array {
        if (empty($qualifiedSells)) {
            return [];
        }

        $winnerCount = (int) floor(count($qualifiedSells) * $winRate / 100);
        $winnerCount = max(1, min($winnerCount, count($qualifiedSells)));

        $lossWeight     = $sessionProfile['loss_weight'] ?? 0.4;
        $capitalWeight  = $sessionProfile['capital_weight'] ?? 0.2;
        $marginWeight   = $sessionProfile['margin_weight'] ?? 0.2;
        $orderingWeight = $sessionProfile['ordering_weight'] ?? 0.1;
        $noiseFactor    = $sessionProfile['noise_factor'] ?? 0.1;
        $noiseSeed      = $sessionProfile['noise_seed'] ?? 0;

        // G1: cap losses at the session's loss requirement to prevent gaming
        $effectiveLossReq = (int) ($sessionProfile['effective_loss_requirement'] ?? 5);

        mt_srand($noiseSeed + 9999);

        // Normalization pass: pre-compute per-user values (also fixes POT-02 N+1)
        $maxLosses          = 1;
        $maxCapital         = 1;
        $userCappedLosses   = [];
        $userActiveCapitals = [];
        $userMetrics        = [];

        foreach ($qualifiedSells as $sell) {
            $uid = $sell->user_id;
            if (! isset($userMetrics[$uid])) {
                $userMetrics[$uid] = NexusUserMetric::getOrCreate($uid);
                // G1: cap consecutive_losses at session requirement
                $userCappedLosses[$uid] = min((int) $userMetrics[$uid]->consecutive_losses, $effectiveLossReq);
                // G4: use live active_capital instead of dead monthly_capital
                $userActiveCapitals[$uid] = NexusUserMetric::calculateActiveCapital($sell->user);
            }
            $maxLosses  = max($maxLosses, $userCappedLosses[$uid]);
            $maxCapital = max($maxCapital, $userActiveCapitals[$uid]);
        }

        // Score each sell
        $scored    = [];
        $order     = 1;
        $logMaxCap = $maxCapital > 1 ? log10($maxCapital) : 1;

        foreach ($qualifiedSells as $sell) {
            $uid = $sell->user_id;

            // G1: capped loss score (no benefit beyond session requirement)
            $lossScore = $maxLosses > 0 ? $userCappedLosses[$uid] / $maxLosses : 0;

            // G4: log-scale capital score (prevents whales from dominating)
            $userCap      = $userActiveCapitals[$uid];
            $capitalScore = ($logMaxCap > 0 && $userCap > 0)
                ? log10(max($userCap, 1)) / $logMaxCap
                : 0;

            $askingPrice    = (float) $sell->amount;
            $initialPrice   = (float) $sell->initial_price;
            $marginDistance = $initialPrice > 0 ? ($askingPrice - $initialPrice) / $initialPrice : 1;
            $marginScore    = max(0, 1 - $marginDistance);

            $orderingScore = 1 / $order;
            $noiseScore    = mt_rand(0, 10000) / 10000;

            $totalScore = ($lossScore * $lossWeight)
                 + ($capitalScore * $capitalWeight)
                 + ($marginScore * $marginWeight)
                 + ($orderingScore * $orderingWeight)
                 + ($noiseScore * $noiseFactor);

            $scored[] = ['sell' => $sell, 'score' => $totalScore];
            $order++;
        }

        mt_srand();

        usort($scored, fn($a, $b) => $b['score'] <=> $a['score']);

        $winners = [];
        for ($i = 0; $i < $winnerCount && $i < count($scored); $i++) {
            $winners[] = $scored[$i]['sell'];
        }
        return $winners;
    }

    private function determineFillPrice(Sell $sell, float $marketAsk): float
    {
        return round($marketAsk, 2);
    }

    private function buildBatchContext(Collection $pendingSells): array
    {
        $totalSellValue = $pendingSells->sum(fn($s) => (float) $s->amount * $s->quantity);
        $totalBidCost   = $pendingSells->sum(fn($s) => (float) $s->initial_price * $s->quantity);

        $sellCountByMarket = $pendingSells->groupBy('market_id')
            ->map(fn($g) => $g->count())->toArray();
        $avgSellByMarket = $pendingSells->groupBy('market_id')
            ->map(fn($g) => $g->avg('amount'))->toArray();

        $sessionWinCounts = Sell::where('type', 'manual_sell')->where('status', 1)
            ->whereDate('updated_at', Carbon::today())
            ->selectRaw('user_id, COUNT(*) as win_count')
            ->groupBy('user_id')->pluck('win_count', 'user_id')->toArray();

        return [
            'total_sell_value'     => $totalSellValue,
            'total_bid_cost'       => max(1, $totalBidCost),
            'total_sell_count'     => $pendingSells->count(),
            'sell_count_by_market' => $sellCountByMarket,
            'avg_sell_by_market'   => $avgSellByMarket,
            'session_win_counts'   => $sessionWinCounts,
            'evaluation_timestamp' => Carbon::now(),
        ];
    }

    private function logDecision(Sell $sell, ?NexusSession $session, EvaluationResult $result): void
    {
        NexusAuditLogger::logAlgorithmDecision(
            $session?->id, $sell->user_id, 'sell_evaluated', 'sell',
            $sell->id, $result->decision, $result->reason, $result->scoreData
        );
    }
}