<?php
/**
 * سودانا — ReservationEngine v1
 *
 * Manages the full lifecycle of a pre-approved execution plan:
 *   hold() → [user confirms] → activate() → [tasks run] → complete() / cancel()
 *
 * CRITICAL FINANCIAL RULES:
 * - hold()     = record only, NO debit
 * - activate() = debit reserved_sdg (pessimistic)
 * - complete() = refund(reserved - actual) if positive
 * - cancel()   = full refund if activated, nothing if still pending
 */
require_once __DIR__ . '/Logger.php';
require_once __DIR__ . '/PricingEngine.php';

class ReservationEngine {

    // ── Hold: create reservation (no debit) ─────────────────────
    /**
     * @param int    $userId
     * @param array  $estimatedPlan  From TaskEstimator::analyze()
     * @param int    $ttlMinutes
     * @return array { reservation_id, confirmation_token, expires_at, reserved_sdg }
     */
    /**
     * ATOMIC hold with row-lock to prevent race conditions.
     *
     * Race problem without this:
     *   Thread A: canAffordEstimate → available=10000 → OK
     *   Thread B: canAffordEstimate → available=10000 → OK (same wallet, same moment)
     *   Thread A: hold → reserved_sdg=8000
     *   Thread B: hold → reserved_sdg=8000  ← OVERBOOKING (total 16000 > 10000)
     *
     * Solution: BEGIN TRANSACTION → SELECT wallet FOR UPDATE (locks row)
     *   → re-verify balance (with latest reserved) → INSERT → COMMIT
     *   Thread B must WAIT for Thread A's COMMIT before it can read the wallet.
     */
    public static function hold(int $userId, array $estimatedPlan, int $ttlMinutes = 30): array {
        $reservationId = 'RES_' . bin2hex(random_bytes(8));
        $token         = 'EST_' . bin2hex(random_bytes(16));
        $expiresAt     = date('Y-m-d H:i:s', time() + ($ttlMinutes * 60));
        $reservedSdg   = (int)($estimatedPlan['cost']['total_sdg'] ?? 0);

        try {
            db()->beginTransaction();

            // ── ROW LOCK: wallet + credit for this user ─────────────
            // This serializes concurrent requests for the same user.
            // Any other hold() for this user MUST wait until we COMMIT.
            $wLock = db()->prepare("SELECT balance FROM wallet WHERE user_id=? LIMIT 1 FOR UPDATE");
            $wLock->execute([$userId]);
            $walletBalance = (int)($wLock->fetchColumn() ?? 0);

            $cLock = db()->prepare(
                "SELECT COALESCE(remaining_sdg,0) FROM subscription_credits
                 WHERE user_id=? AND period_month=MONTH(NOW()) AND period_year=YEAR(NOW())
                 LIMIT 1 FOR UPDATE"
            );
            $cLock->execute([$userId]);
            $creditBalance = (int)($cLock->fetchColumn() ?? 0);

            // ── RE-VERIFY balance with latest reserved (post-lock) ──
            // This is the definitive check — no concurrent hold can change these
            // values until we release the lock at COMMIT.
            $alreadyReserved = (int)db()->prepare(
                "SELECT COALESCE(SUM(reserved_sdg),0) FROM task_reservations
                 WHERE user_id=? AND status IN ('pending','active') AND expires_at>NOW()"
            )->execute([$userId]) ? db()->query(
                "SELECT COALESCE(SUM(reserved_sdg),0) FROM task_reservations
                 WHERE user_id=$userId AND status IN ('pending','active') AND expires_at>NOW()"
            )->fetchColumn() : 0;

            $available = max(0, ($walletBalance + $creditBalance) - $alreadyReserved);

            if ($available < $reservedSdg) {
                db()->rollBack();
                throw new \RuntimeException(
                    "atomic_hold_insufficient_funds: need={$reservedSdg} available={$available}"
                );
            }

            // ── Max active reservations check (within lock) ─────────
            $maxActive = (int)PricingEngine::getSetting('max_active_reservations', 3);
            $activeCount = (int)db()->prepare(
                "SELECT COUNT(*) FROM task_reservations
                 WHERE user_id=? AND status IN ('pending','active') AND expires_at>NOW()"
            )->execute([$userId]) ? db()->query(
                "SELECT COUNT(*) FROM task_reservations
                 WHERE user_id=$userId AND status IN ('pending','active') AND expires_at>NOW()"
            )->fetchColumn() : 0;

            if ($activeCount >= $maxActive) {
                db()->rollBack();
                throw new \RuntimeException("too_many_reservations: active={$activeCount} max={$maxActive}");
            }

            // ── INSERT reservation (still inside transaction) ────────
            db()->prepare(
                "INSERT INTO task_reservations
                 (reservation_id, user_id, trace_id, estimated_plan, price_snapshot,
                  reserved_sdg, status, confirmation_token, tasks_total, reserved_at, expires_at)
                 VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, NOW(), ?)"
            )->execute([
                $reservationId,
                $userId,
                Logger::traceId(),
                json_encode($estimatedPlan, JSON_UNESCAPED_UNICODE),
                json_encode($estimatedPlan['price_snapshot'] ?? [], JSON_UNESCAPED_UNICODE),
                $reservedSdg,
                $token,
                count($estimatedPlan['tasks'] ?? []),
                $expiresAt,
            ]);

            // Link estimation log within same transaction
            if (!empty($estimatedPlan['estimation_log_id'])) {
                db()->prepare(
                    "UPDATE task_estimation_log SET reservation_id=?, was_approved=1 WHERE id=?"
                )->execute([$reservationId, $estimatedPlan['estimation_log_id']]);
            }

            // ── COMMIT releases the wallet lock ─────────────────────
            db()->commit();

        } catch (\RuntimeException $e) {
            // Already rolled back
            Logger::warn('reservation_hold_rejected', ['error' => $e->getMessage()]);
            throw $e;
        } catch (\Throwable $e) {
            db()->rollBack();
            Logger::error('reservation_hold_failed', ['error' => $e->getMessage()]);
            throw new \RuntimeException('reservation_hold_failed: ' . $e->getMessage());
        }

        Logger::info('reservation_held', [
            'reservation_id' => $reservationId,
            'user_id'        => $userId,
            'reserved_sdg'   => $reservedSdg,
            'expires_at'     => $expiresAt,
        ]);

        return [
            'reservation_id'   => $reservationId,
            'confirmation_token' => $token,
            'reserved_sdg'     => $reservedSdg,
            'expires_at'       => $expiresAt,
        ];
    }

    // ── Activate: token → debit → executing ─────────────────────
    /**
     * Validates the token, checks price snapshot TTL, debits wallet.
     * Atomic: uses WHERE status='pending' to prevent double-activation.
     *
     * @param string $token  confirmation_token from hold()
     * @return array { reservation_id, reserved_sdg, plan, price_snapshot }
     * @throws RuntimeException on invalid/expired token
     */
    public static function activate(string $token): array {
        $token = preg_replace('/[^A-Za-z0-9_]/', '', $token);

        // Load reservation
        $st = db()->prepare(
            "SELECT * FROM task_reservations
             WHERE confirmation_token=? AND status='pending' AND expires_at>NOW()
             LIMIT 1 FOR UPDATE"
        );
        $st->execute([$token]);
        $res = $st->fetch();

        if (!$res) {
            throw new \RuntimeException('invalid_or_expired_token');
        }

        // Check price snapshot TTL
        $snapshot = json_decode($res['price_snapshot'], true);
        $lockedAt = strtotime($snapshot['locked_at'] ?? '');
        $ttl      = (int)($snapshot['ttl_seconds'] ?? 1800);

        if ($lockedAt && (time() - $lockedAt) > $ttl) {
            // Auto-cancel — prices expired
            self::cancel($res['reservation_id'], 'price_snapshot_expired');
            throw new \RuntimeException('price_snapshot_expired');
        }

        // Atomic status transition: pending → active
        $updated = db()->prepare(
            "UPDATE task_reservations
             SET status='active', activated_at=NOW(), confirmation_token=NULL
             WHERE reservation_id=? AND status='pending'"
        )->execute([$res['reservation_id']]);

        if (!$updated) {
            throw new \RuntimeException('reservation_already_activated');
        }

        // Debit reserved amount (pessimistic)
        require_once __DIR__ . '/UsageMeter.php';
        $debit = UsageMeter::debit(
            (int)$res['user_id'],
            (int)$res['reserved_sdg'],
            'reservation',
            $res['reservation_id']
        );

        if (!$debit['ok']) {
            // Debit failed — roll back to pending
            db()->prepare(
                "UPDATE task_reservations SET status='pending', activated_at=NULL WHERE reservation_id=?"
            )->execute([$res['reservation_id']]);
            throw new \RuntimeException('debit_failed: ' . ($debit['error'] ?? ''));
        }

        // Log the debit with reservation_id
        try {
            db()->prepare(
                "UPDATE wallet_transactions
                 SET reservation_id=?
                 WHERE user_id=? AND type='debit' AND reason='reservation'
                 ORDER BY id DESC LIMIT 1"
            )->execute([$res['reservation_id'], $res['user_id']]);
        } catch (\Throwable $ignored) {}

        Logger::info('reservation_activated', [
            'reservation_id' => $res['reservation_id'],
            'user_id'        => $res['user_id'],
            'debited_sdg'    => $res['reserved_sdg'],
        ]);

        return [
            'reservation_id'  => $res['reservation_id'],
            'user_id'         => (int)$res['user_id'],
            'reserved_sdg'    => (int)$res['reserved_sdg'],
            'plan'            => json_decode($res['estimated_plan'], true),
            'price_snapshot'  => $snapshot,
            'tasks_total'     => (int)$res['tasks_total'],
        ];
    }

    // ── Complete: record actual + refund difference ───────────────
    /**
     * @param string $reservationId
     * @param int    $actualSdg      Real cost after all tasks
     * @param array  $executionLog   Per-task result details
     */
    public static function complete(string $reservationId, int $actualSdg, array $executionLog = []): void {
        $res = self::getById($reservationId);
        if (!$res) return;

        $reservedSdg = (int)$res['reserved_sdg'];
        $difference  = $reservedSdg - $actualSdg;
        $refunded    = 0;

        // Refund positive difference
        if ($difference > 0) {
            require_once __DIR__ . '/UsageMeter.php';
            $refundResult = UsageMeter::refund((int)$res['user_id'], $difference, $reservationId);
            $refunded = $refundResult['ok'] ? $difference : 0;

            Logger::info('reservation_refund', [
                'reservation_id' => $reservationId,
                'refunded_sdg'   => $refunded,
            ]);
        }

        // Finalize reservation record
        db()->prepare(
            "UPDATE task_reservations
             SET status='completed', actual_sdg=?, refunded_sdg=?,
                 tasks_completed=?, execution_log=?, completed_at=NOW()
             WHERE reservation_id=?"
        )->execute([
            $actualSdg,
            $refunded,
            count(array_filter($executionLog, fn($t) => ($t['status'] ?? '') === 'done')),
            json_encode($executionLog, JSON_UNESCAPED_UNICODE),
            $reservationId,
        ]);

        // Update estimation_log with actual data for Adaptive Feedback Loop
        try {
            $reserved = max(1, $reservedSdg);
            $accuracyPct = round(($actualSdg / $reserved) * 100, 2);

            db()->prepare(
                "UPDATE task_estimation_log
                 SET actual_sdg=?, accuracy_pct=?, was_executed=1,
                     actual_plan=?, completed_at=NOW()
                 WHERE reservation_id=?"
            )->execute([
                $actualSdg,
                $accuracyPct,
                json_encode($executionLog, JSON_UNESCAPED_UNICODE),
                $reservationId,
            ]);
        } catch (\Throwable $e) {
            Logger::warn('estimation_log_update_failed', ['error' => $e->getMessage()]);
        }

        Logger::info('reservation_completed', [
            'reservation_id' => $reservationId,
            'reserved_sdg'   => $reservedSdg,
            'actual_sdg'     => $actualSdg,
            'refunded_sdg'   => $refunded,
        ]);
    }

    // ── Cancel ───────────────────────────────────────────────────
    public static function cancel(string $reservationId, string $reason = ''): void {
        $res = self::getById($reservationId);
        if (!$res) return;

        $status = $res['status'];

        // If active, return the reserved amount
        if ($status === 'active') {
            require_once __DIR__ . '/UsageMeter.php';
            UsageMeter::refund((int)$res['user_id'], (int)$res['reserved_sdg'], $reservationId);
        }

        db()->prepare(
            "UPDATE task_reservations
             SET status='cancelled', cancel_reason=?, cancelled_at=NOW(),
                 confirmation_token=NULL
             WHERE reservation_id=? AND status IN ('pending','active')"
        )->execute([$reason, $reservationId]);

        Logger::info('reservation_cancelled', [
            'reservation_id' => $reservationId,
            'reason'         => $reason,
            'was_active'     => $status === 'active',
        ]);
    }

    // ── Expire old pending reservations (called by cron) ─────────
    public static function expireOld(): int {
        $expired = 0;
        try {
            $st = db()->query(
                "SELECT reservation_id, status, user_id, reserved_sdg
                 FROM task_reservations
                 WHERE status IN ('pending','active') AND expires_at < NOW()
                 LIMIT 50"
            );
            while ($row = $st->fetch()) {
                self::cancel($row['reservation_id'], 'ttl_expired');
                $expired++;
            }
        } catch (\Throwable $e) {
            Logger::error('expire_reservations_failed', ['error' => $e->getMessage()]);
        }
        return $expired;
    }

    // ── Helpers ──────────────────────────────────────────────────
    public static function getById(string $reservationId): ?array {
        try {
            $st = db()->prepare("SELECT * FROM task_reservations WHERE reservation_id=? LIMIT 1");
            $st->execute([$reservationId]);
            return $st->fetch() ?: null;
        } catch (\Throwable $e) { return null; }
    }

    public static function countActive(int $userId): int {
        try {
            $st = db()->prepare(
                "SELECT COUNT(*) FROM task_reservations
                 WHERE user_id=? AND status IN ('pending','active') AND expires_at>NOW()"
            );
            $st->execute([$userId]);
            return (int)$st->fetchColumn();
        } catch (\Throwable $e) { return 0; }
    }

    /** Sum of reserved_sdg for pending/active (non-expired) reservations */
    public static function sumActiveReserved(int $userId): int {
        try {
            $st = db()->prepare(
                "SELECT COALESCE(SUM(reserved_sdg), 0)
                 FROM task_reservations
                 WHERE user_id=? AND status IN ('pending','active') AND expires_at>NOW()"
            );
            $st->execute([$userId]);
            return (int)$st->fetchColumn();
        } catch (\Throwable $e) { return 0; }
    }
}
