<?php

declare(strict_types=1);

namespace Instinctor\Services;

use Instinctor\Core\Config;
use Instinctor\Core\DB;
use Instinctor\Core\Auth;
use Instinctor\Core\CartSession;


if ( ! defined( 'ABSPATH' ) ) exit;

/**
 * Checkout Service - handles order creation and payment
 */
class CheckoutService
{
    private CartService $cart;

    public function __construct(CartService $cart)
    {
        $this->cart = $cart;
    }
    
    /**
     * Static helper to finalize an order from contexts without a cart (e.g. webhooks).
     */
    public static function finalize(int $orderId): bool
    {
        $instance = new self(new CartService());
        return $instance->finalizeOrder($orderId);
    }

    public function processCheckout(array $data): array
    {
        $cart = $this->cart->getCart();

        if (empty($cart['items'])) {
            return ['success' => false, 'error' => 'Cart is empty'];
        }

        // Validate billing data
        $required = ['email', 'first_name', 'last_name'];
        foreach ($required as $field) {
            if (empty($data['billing'][$field])) {
                return ['success' => false, 'error' => "Missing required field: $field"];
            }
        }

        // A recurring membership REQUIRES an account so the member can log in to
        // manage or cancel it — detect subscriptions before account handling.
        $subscriptionService = SubscriptionService::instance();
        $hasSubscription = $subscriptionService->cartHasSubscription($cart);

        // Handle account creation.
        // Guests must never inherit another user's session. Auth::userId() can resolve to
        // the most-recent active session via StandaloneDriver's CSRF-nonce fallback, which
        // would attribute a guest purchase to a logged-in admin and skip account creation.
        // Trust only an actual auth cookie here.
        $userId = !empty($_COOKIE['instinctor_auth']) ? Auth::userId() : 0;
        $autoCreatedAccount = false;
        $createdNewAccount = false;
        if (!$userId) {
            if (!empty($data['create_account']) && !empty($data['account_password'])) {
                // Customer opted in and set their own password.
                $userResult = \Instinctor\Core\UserService::registerCustomer(
                    $data['billing']['email'],
                    $data['account_password'],
                    $data['billing']['first_name'] ?? '',
                    $data['billing']['last_name']  ?? ''
                );
                if (!$userResult['ok']) {
                    return ['success' => false, 'error' => $userResult['error']];
                }
                $userId = (int) $userResult['user_id'];
                $createdNewAccount = true;
            } elseif ($hasSubscription) {
                // Memberships always get an account. Generate a password; the
                // member sets their own via the welcome email. Never block the sale.
                $userResult = \Instinctor\Core\UserService::registerCustomer(
                    $data['billing']['email'],
                    bin2hex(random_bytes(16)),
                    $data['billing']['first_name'] ?? '',
                    $data['billing']['last_name']  ?? ''
                );
                if (!empty($userResult['ok'])) {
                    $userId = (int) $userResult['user_id'];
                    $autoCreatedAccount = true;
                    $createdNewAccount = true;
                } else {
                    // Email already has an account — attach the membership to it.
                    $existingId = $this->findUserIdByEmail($data['billing']['email']);
                    if ($existingId) $userId = $existingId;
                }
            }
        }

        // Create or get customer
        $customerId = $this->getOrCreateCustomer($data['billing'], $userId);

        // Create order
        $order = $this->createOrder($cart, $customerId, $data);

        if (!$order['success']) {
            return $order;
        }

        // Process payment
        $paymentMethod = $data['payment_method'] ?? 'stripe';
        $skipPayment = $data['skip_payment'] ?? false;
        
        // Use final_total from order which includes gift card and loyalty adjustments
        $paymentTotal = $order['final_total'] ?? $cart['totals']['total'];
        
        // If total is zero (fully covered by gift card/loyalty), skip payment
        if ($paymentTotal <= 0) {
            $payment = [
                'success' => true,
                'transaction_id' => 'gc_loyalty_paid',
                'payment_status' => 'paid',
            ];
        } elseif ($skipPayment) {
            // Payment already processed (e.g., PayPal)
            $payment = [
                'success' => true,
                'transaction_id' => $data['paypal_order_id'] ?? null,
                'payment_status' => 'paid',
            ];
        } elseif ($hasSubscription && $paymentMethod === 'stripe') {
            // Handle subscription checkout via Stripe Checkout Session
            $payment = $this->processSubscriptionCheckout($cart, $customerId, $order['order_id'], $data);
        } elseif ($paymentMethod === 'stripe') {
            $payment = $this->processStripePayment($order['order_id'], $paymentTotal, $data);
        } elseif ($hasSubscription && $paymentMethod === 'authorizenet') {
            // Recurring membership via Authorize.net ARB (card on file + auto monthly billing)
            $payment = $this->processAuthorizenetSubscription($cart, $customerId, $order['order_id'], $data);
        } elseif ($paymentMethod === 'authorizenet') {
            $payment = $this->processAuthorizenetPayment($order['order_id'], $paymentTotal, $data);
        } elseif ($paymentMethod === 'cod' || $paymentMethod === 'bank_transfer') {
            // Offline payment — no gateway is charged. The order is placed but
            // stays Pending (awaiting payment); the owner marks it paid in the
            // store admin when the cash or bank transfer arrives, which finalizes
            // it (stock, loyalty, paid email). The 'offline' flag routes the
            // post-payment block below.
            $payment = ['success' => true, 'requires_action' => false, 'payment_status' => 'pending', 'transaction_id' => null, 'offline' => true];
        } else {
            // Demo mode or other - mark as paid
            $payment = ['success' => true, 'requires_action' => false, 'payment_status' => 'paid', 'transaction_id' => 'demo_' . wp_generate_password(12, false)];
        }

        if (!$payment['success']) {
            // Update order to failed
            $this->updateOrderStatus($order['order_id'], 'failed');
            return $payment;
        }

        // If payment needs confirmation (3D Secure, etc)
        if (!empty($payment['requires_action'])) {
            return [
                'success' => true,
                'requires_action' => true,
                'client_secret' => $payment['client_secret'] ?? null,
                'order_id' => $order['order_id'],
            ];
        }

        // If payment needs redirect (Stripe Checkout Session for subscriptions, PayPal)
        if (!empty($payment['requires_redirect'])) {
            return [
                'success' => true,
                'requires_redirect' => true,
                'checkout_url' => $payment['checkout_url'] ?? '',
                'session_id' => $payment['session_id'] ?? null,
                'subscription_id' => $payment['subscription_id'] ?? null,
                'order_id' => $order['order_id'],
            ];
        }

        if (!empty($payment['offline'])) {
            // Offline order (COD / bank transfer): leave it Pending (awaiting
            // payment). Record the method and send the order-received email
            // (which carries the pay instructions), but do NOT finalize —
            // stock, loyalty, and gift-card issuance wait until the owner
            // confirms payment and marks the order paid (see
            // AdminController::ajaxUpdateOrderStatus, which finalizes on that
            // transition). sendOrderConfirmation is idempotent so the buyer
            // isn't emailed twice.
            $this->updateOrderPayment($order['order_id'], $payment);
            $this->sendOrderConfirmation($order['order_id']);
        } else {
            // Payment successful
            $this->updateOrderStatus($order['order_id'], 'processing');
            $this->updateOrderPayment($order['order_id'], $payment);

            // Run all post-payment processing (stock, email, gift cards, loyalty, coupons, bookings)
            $this->finalizeOrder($order['order_id']);
        }

        // Membership account auto-created at checkout — send the welcome email
        // with a link to set a password and access the account.
        if (!empty($autoCreatedAccount)) {
            \Instinctor\Core\UserService::sendMembershipWelcome($data['billing']['email']);
        }

        // Log the buyer into the account created at checkout so the membership
        // confirmation and account are visible after the redirect. Only an account
        // WE created this request is auto-logged-in; an existing account matched by
        // email is never auto-logged-in (that would be account takeover).
        if (!empty($createdNewAccount) && $userId && !Auth::check()) {
            Auth::loginAs((int) $userId);
        }
        
        // Clear session data (gift card / loyalty applied during checkout)
        CartSession::unset('gift_card');
        CartSession::unset('loyalty');

        // Clear cart
        $this->cart->clear();

        // Get order received page URL. Membership buyers are already logged in,
        // so send them to their account with a welcome banner instead of the
        // generic order-received fallback (which would dump them on the homepage).
        if (!empty($hasSubscription)) {
            $redirectUrl = add_query_arg('welcome', !empty($autoCreatedAccount) ? 'setpw' : 'active', \Instinctor\Services\PageService::resolveUrl('account'));
        } else {
            $orderReceivedPageId = (int) Config::get('instinctor_order_received_page_id');
            $okArgs = ['order' => $order['order_id'], 'order_key' => $order['order_key'] ?? ''];
            $redirectUrl = $orderReceivedPageId
                ? add_query_arg($okArgs, \Instinctor\Services\PageService::getPageUrl($orderReceivedPageId))
                : home_url('/?' . http_build_query($okArgs));
        }

        return [
            'success' => true,
            'order_id' => $order['order_id'],
            'order_number' => $order['order_number'],
            'redirect' => $redirectUrl,
        ];
    }

    /**
     * Finalize order after payment is confirmed.
     * Handles: gift cards, loyalty points, coupons, bookings, stock, email, abandoned cart.
     * Idempotent: safe to call multiple times (checks finalized_at flag in order meta).
     */
    public function finalizeOrder(int $orderId): bool
    {
        $ordersTable = DB::prefix() . 'instinctor_orders';
        $itemsTable = DB::prefix() . 'instinctor_order_items';
        
        // Load order
        $order = DB::get_row(DB::prepare(
            "SELECT * FROM {$ordersTable} WHERE id = %d", $orderId
        ));
        
        if (!$order) {
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
            error_log("Instinctor finalizeOrder: order {$orderId} not found");
            return false;
        }
        
        // Idempotency check — skip if already finalized
        $meta = json_decode($order->meta ?: '{}', true);
        if (!empty($meta['finalized_at'])) {
            return true; // Already done
        }
        
        // Ensure order is in a processing state (callers should have set payment info already)
        if (!in_array($order->status, ['processing', 'paid', 'completed'], true)) {
            $this->updateOrderStatus($orderId, 'processing');
        }
        
        $customerId = (int) $order->customer_id;
        
        // Load order items
        $items = DB::get_results(DB::prepare(
            "SELECT * FROM {$itemsTable} WHERE order_id = %d", $orderId
        ), ARRAY_A);
        
        // Decode item meta for bookings
        foreach ($items as &$item) {
            $item['meta'] = json_decode($item['meta'] ?: '{}', true);
        }
        unset($item);
        
        // 1. Gift card deduction
        if (!empty($meta['gift_card_id']) && !empty($meta['gift_card_amount']) && $meta['gift_card_amount'] > 0) {
            try {
                GiftCardService::useBalance(
                    (int) $meta['gift_card_id'],
                    (float) $meta['gift_card_amount'],
                    $orderId
                );
            } catch (\Exception $e) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log("Instinctor finalizeOrder: gift card error for order {$orderId}: " . $e->getMessage());
            }
        }
        
        // 2. Loyalty points deduction
        if (!empty($meta['loyalty_points_used']) && $meta['loyalty_points_used'] > 0 && $customerId) {
            try {
                LoyaltyService::redeemPoints($customerId, (int) $meta['loyalty_points_used'], $orderId);
            } catch (\Exception $e) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log("Instinctor finalizeOrder: loyalty redeem error for order {$orderId}: " . $e->getMessage());
            }
        }
        
        // 3. Award loyalty points for purchase
        if (Config::get('instinctor_loyalty_enabled', false) && $customerId) {
            try {
                LoyaltyService::addPoints($customerId, 'purchase', $orderId, (float) $order->total);
            } catch (\Exception $e) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log("Instinctor finalizeOrder: loyalty award error for order {$orderId}: " . $e->getMessage());
            }
        }
        
        // 4. Record coupon usage
        if (!empty($meta['coupon_code'])) {
            try {
                $p = DB::prefix();
                $couponService = new CouponService();
                // Look up coupon ID from code
                $coupon = DB::get_row(DB::prepare(
                    "SELECT id FROM {$p}instinctor_coupons WHERE code = %s",
                    $meta['coupon_code']
                ));
                if ($coupon) {
                    $billing = json_decode($order->billing_address ?: '{}', true);
                    $couponService->recordUsage(
                        (int) $coupon->id,
                        $orderId,
                        $customerId,
                        $billing['email'] ?? null,
                        (float) ($meta['coupon_discount'] ?? 0)
                    );
                }
            } catch (\Exception $e) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log("Instinctor finalizeOrder: coupon error for order {$orderId}: " . $e->getMessage());
            }
        }
        
        // 5. Process bookings
        if (!empty($items)) {
            try {
                $this->processOrderBookings($orderId, $items, $customerId);
            } catch (\Exception $e) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log("Instinctor finalizeOrder: booking error for order {$orderId}: " . $e->getMessage());
            }
        }
        
        // 6. Reduce stock
        if (!empty($items)) {
            $this->reduceStock($items);
        }
        
        // 7. Send confirmation email
        $this->sendOrderConfirmation($orderId);
        
        // 9. Update customer stats (total_spent, order_count)
        $this->incrementCustomerStats($orderId);
        
        // Mark as finalized (idempotency flag)
        $meta['finalized_at'] = current_time('mysql');
        DB::update($ordersTable, ['meta' => json_encode($meta)], ['id' => $orderId]);
        
        // Fire action for extensions
        do_action('instinctor_order_finalized', $orderId);
        
        return true;
    }

    public function createPaymentIntent(float $amount, string $currency = 'usd'): array
    {
        $stripeSecret = Config::get('instinctor_stripe_secret_key');
        
        if (!$stripeSecret) {
            return ['success' => false, 'error' => 'Stripe not configured'];
        }

        $response = wp_remote_post('https://api.stripe.com/v1/payment_intents', [
            'headers' => [
                'Authorization' => 'Bearer ' . $stripeSecret,
                'Content-Type' => 'application/x-www-form-urlencoded',
            ],
            'body' => [
                'amount' => (int) ($amount * 100), // Convert to cents
                'currency' => strtolower($currency),
                'automatic_payment_methods' => ['enabled' => 'true'],
            ],
        ]);

        if (is_wp_error($response)) {
            return ['success' => false, 'error' => $response->get_error_message()];
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);

        if (!empty($body['error'])) {
            return ['success' => false, 'error' => $body['error']['message']];
        }

        return [
            'success' => true,
            'client_secret' => $body['client_secret'],
            'payment_intent_id' => $body['id'],
        ];
    }

    public function confirmPayment(int $orderId, string $paymentIntentId): array
    {
        $p = DB::prefix();
        // Verify the payment intent belongs to this order
        $storedTxnId = DB::get_var(DB::prepare(
            "SELECT transaction_id FROM {$p}instinctor_orders WHERE id = %d",
            $orderId
        ));
        
        if (!$storedTxnId || $storedTxnId !== $paymentIntentId) {
            return ['success' => false, 'error' => 'Payment verification failed'];
        }
        
        $stripeSecret = Config::get('instinctor_stripe_secret_key');
        
        $response = wp_remote_get("https://api.stripe.com/v1/payment_intents/$paymentIntentId", [
            'headers' => [
                'Authorization' => 'Bearer ' . $stripeSecret,
            ],
        ]);

        if (is_wp_error($response)) {
            return ['success' => false, 'error' => $response->get_error_message()];
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);

        if (($body['status'] ?? '') === 'succeeded') {
            $this->updateOrderStatus($orderId, 'processing');
            $this->updateOrderPayment($orderId, [
                'transaction_id' => $paymentIntentId,
                'payment_status' => 'paid',
            ]);
            
            // Run all post-payment processing (stock, email, gift cards, loyalty, coupons, bookings)
            $this->finalizeOrder($orderId);
            
            // Clear cart and session
            $this->cart->clear();
            CartSession::unset('gift_card');
            CartSession::unset('loyalty');

            return ['success' => true, 'order_id' => $orderId];
        }

        return ['success' => false, 'error' => 'Payment not completed'];
    }

    private function processStripePayment(int $orderId, float $amount, array $data): array
    {
        $stripeSecret = Config::get('instinctor_stripe_secret_key');
        
        if (!$stripeSecret) {
            // Demo mode - simulate successful payment
            return [
                'success' => true,
                'transaction_id' => 'demo_' . wp_generate_password(16, false),
                'payment_status' => 'paid',
            ];
        }

        // Create payment intent for client-side confirmation
        $currency = Config::get('instinctor_currency', 'USD');
        $intent = $this->createPaymentIntent($amount, $currency);

        if (!$intent['success']) {
            return $intent;
        }

        // Store intent ID on order
        DB::update(
            DB::prefix() . 'instinctor_orders',
            ['transaction_id' => $intent['payment_intent_id']],
            ['id' => $orderId]
        );

        return [
            'success' => true,
            'requires_action' => true,
            'client_secret' => $intent['client_secret'],
        ];
    }

    /**
     * Process payment via Authorize.net Accept.js opaque data.
     * Client-side Accept.js tokenizes card data; we receive the nonce
     * and charge it server-side with a createTransactionRequest call.
     */
    private function processAuthorizenetPayment(int $orderId, float $amount, array $data): array
    {
        $apiLoginId     = Config::get('instinctor_authorizenet_api_login_id');
        $transactionKey = Config::get('instinctor_authorizenet_transaction_key');
        $sandbox        = (bool) Config::get('instinctor_authorizenet_sandbox', true);

        if (!$apiLoginId || !$transactionKey) {
            return ['success' => false, 'error' => 'Payment gateway not configured'];
        }

        $nonce      = sanitize_text_field($data['authorizenet_nonce']      ?? '');
        $descriptor = sanitize_text_field($data['authorizenet_descriptor'] ?? '');

        if (!$nonce || !$descriptor) {
            return ['success' => false, 'error' => 'Payment token missing. Please try again.'];
        }

        $billing  = $data['billing'] ?? [];
        $endpoint = $sandbox
            ? 'https://apitest.authorize.net/xml/v1/request.api'
            : 'https://api.authorize.net/xml/v1/request.api';

        $payload = [
            'createTransactionRequest' => [
                'merchantAuthentication' => [
                    'name'           => $apiLoginId,
                    'transactionKey' => $transactionKey,
                ],
                'transactionRequest' => [
                    'transactionType' => 'authCaptureTransaction',
                    'amount'          => number_format($amount, 2, '.', ''),
                    'payment'         => [
                        'opaqueData' => [
                            'dataDescriptor' => $descriptor,
                            'dataValue'      => $nonce,
                        ],
                    ],
                    'billTo' => [
                        'firstName' => sanitize_text_field($billing['first_name'] ?? ''),
                        'lastName'  => sanitize_text_field($billing['last_name']  ?? ''),
                        'address'   => sanitize_text_field($billing['address_1']  ?? ''),
                        'city'      => sanitize_text_field($billing['city']        ?? ''),
                        'state'     => sanitize_text_field($billing['state']       ?? ''),
                        'zip'       => sanitize_text_field($billing['postcode']    ?? ''),
                        'country'   => sanitize_text_field($billing['country']     ?? ''),
                    ],
                ],
            ],
        ];

        $response = wp_remote_post($endpoint, [
            'headers' => ['Content-Type' => 'application/json'],
            'body'    => wp_json_encode($payload),
            'timeout' => 30,
        ]);

        if (is_wp_error($response)) {
            return ['success' => false, 'error' => $response->get_error_message()];
        }

        // Authorize.net responses may begin with a UTF-8 BOM — strip it
        $body   = ltrim(wp_remote_retrieve_body($response), "\xEF\xBB\xBF");
        $result = json_decode($body, true);

        $txn          = $result['transactionResponse'] ?? [];
        $responseCode = $txn['responseCode'] ?? null;

        if ($responseCode === '1') {
            return [
                'success'        => true,
                'transaction_id' => $txn['transId'] ?? '',
                'payment_status' => 'paid',
            ];
        }

        // Declined or error — return the most descriptive message available
        $errorText = $txn['errors'][0]['errorText']
            ?? $txn['messages']['message'][0]['description']
            ?? $result['messages']['message'][0]['text']
            ?? 'Payment declined';

        return ['success' => false, 'error' => $errorText];
    }

    /**
     * Recurring membership via Authorize.net Automated Recurring Billing (ARB).
     *
     * The Accept.js nonce is single-use, so we first store the card as a
     * Customer Profile (CIM). That gives us a customerProfileId +
     * paymentProfileId we can bill every month AND that the customer can
     * update later from their account. Then we create one ARB subscription
     * per subscription line item: monthly, starting today, ongoing.
     */
    /** Resolve an existing standalone user id by email (0 if none). */
    private function findUserIdByEmail(string $email): int
    {
        $row = DB::get_row(DB::prepare(
            "SELECT id FROM `" . DB::prefix() . "instinctor_users` WHERE email = %s LIMIT 1",
            strtolower(trim($email))
        ));
        return $row ? (int) $row->id : 0;
    }

    private function processAuthorizenetSubscription(array $cart, int $customerId, int $orderId, array $data): array
    {
        $apiLoginId     = Config::get('instinctor_authorizenet_api_login_id');
        $transactionKey = Config::get('instinctor_authorizenet_transaction_key');
        $sandbox        = (bool) Config::get('instinctor_authorizenet_sandbox', true);

        if (!$apiLoginId || !$transactionKey) {
            return ['success' => false, 'error' => 'Payment gateway not configured'];
        }

        $billing  = $data['billing'] ?? [];
        $endpoint = $sandbox
            ? 'https://apitest.authorize.net/xml/v1/request.api'
            : 'https://api.authorize.net/xml/v1/request.api';
        $auth = ['name' => $apiLoginId, 'transactionKey' => $transactionKey];

        $useSaved = !empty($data['use_saved_card']);
        if ($useSaved) {
            // Returning member paying with their card on file: reuse the existing CIM
            // profile (vaulted at Authorize.net) instead of tokenizing a new card. Look
            // it up server-side from the member's own subscription — never trust a
            // client-supplied profile id.
            $p = DB::prefix();
            $existing = DB::get_row(DB::prepare(
                "SELECT meta FROM `{$p}instinctor_subscriptions` WHERE customer_id = %d AND payment_method = 'authorizenet' ORDER BY id DESC LIMIT 1",
                $customerId
            ));
            $em = $existing ? json_decode($existing->meta, true) : [];
            $customerProfileId = $em['an_customer_profile_id'] ?? null;
            $paymentProfileId  = $em['an_payment_profile_id'] ?? null;
            if (!$customerProfileId || !$paymentProfileId) {
                return ['success' => false, 'error' => 'No saved card found. Please enter your card details.'];
            }
        } else {
            $nonce      = sanitize_text_field($data['authorizenet_nonce']      ?? '');
            $descriptor = sanitize_text_field($data['authorizenet_descriptor'] ?? '');
            if (!$nonce || !$descriptor) {
                return ['success' => false, 'error' => 'Payment token missing. Please try again.'];
            }

            $billTo = [
                'firstName' => sanitize_text_field($billing['first_name'] ?? ''),
                'lastName'  => sanitize_text_field($billing['last_name']  ?? ''),
                'address'   => sanitize_text_field($billing['address_1']  ?? ''),
                'city'      => sanitize_text_field($billing['city']       ?? ''),
                'state'     => sanitize_text_field($billing['state']      ?? ''),
                'zip'       => sanitize_text_field($billing['postcode']   ?? ''),
                'country'   => sanitize_text_field($billing['country']    ?? ''),
            ];

            // 1) Store the card as a CIM customer profile.
            $profileReq = ['createCustomerProfileRequest' => [
                'merchantAuthentication' => $auth,
                'profile' => [
                    'merchantCustomerId' => substr((string) $customerId, 0, 20),
                    'email'              => sanitize_email($billing['email'] ?? ''),
                    'paymentProfiles'    => [
                        'customerType' => 'individual',
                        'billTo'       => $billTo,
                        'payment'      => ['opaqueData' => ['dataDescriptor' => $descriptor, 'dataValue' => $nonce]],
                    ],
                ],
                'validationMode' => 'none', // No billing address collected; ARB charge is the real validation. Avoids AVS-required-field failures on profile creation.
            ]];

            $pr = $this->anPost($endpoint, $profileReq);
            $prd = $pr['data'];
            $customerProfileId = $prd['customerProfileId'] ?? null;
            $paymentProfileId  = is_array($prd['customerPaymentProfileIdList'] ?? null)
                ? ($prd['customerPaymentProfileIdList'][0] ?? null)
                : null;

            // Duplicate profile (E00039): a profile already exists for this card/email.
            if (!$customerProfileId) {
                $code = $prd['messages']['message'][0]['code'] ?? '';
                $text = $prd['messages']['message'][0]['text'] ?? '';
                if ($code === 'E00039' && preg_match('/(\d{6,})/', $text, $m)) {
                    $customerProfileId = $m[1];
                    $getResp = $this->anPost($endpoint, ['getCustomerProfileRequest' => [
                        'merchantAuthentication' => $auth,
                        'customerProfileId'      => $customerProfileId,
                    ]]);
                    $paymentProfileId = $getResp['data']['profile']['paymentProfiles'][0]['customerPaymentProfileId'] ?? null;
                }
            }

            if (!$customerProfileId || !$paymentProfileId) {
                return ['success' => false, 'error' => $pr['error'] ?: 'Could not store payment method. Please check your card details.'];
            }
        }

        // 2) One ARB subscription per subscription line item.
        $subSvc  = SubscriptionService::instance();
        $items   = $subSvc->getSubscriptionItems($cart);
        $today   = gmdate('Y-m-d');
        $created = [];

        foreach ($items as $it) {
            $amount = number_format(((float) $it['price']) * max(1, (int) $it['quantity']), 2, '.', '');
            $sched  = $this->anInterval((string) $it['period'], (int) $it['interval_count']);
            $name   = substr((string) ($it['product']->name ?? 'Membership'), 0, 50);

            $subReq = ['ARBCreateSubscriptionRequest' => [
                'merchantAuthentication' => $auth,
                'subscription' => [
                    'name' => $name,
                    'paymentSchedule' => [
                        'interval'         => ['length' => $sched['length'], 'unit' => $sched['unit']],
                        'startDate'        => $today,
                        'totalOccurrences' => 9999,
                    ],
                    'amount'  => $amount,
                    'profile' => [
                        'customerProfileId'        => (string) $customerProfileId,
                        'customerPaymentProfileId' => (string) $paymentProfileId,
                    ],
                ],
            ]];

            $sr = $this->anPost($endpoint, $subReq);
            // A just-created CIM profile is not always immediately visible to the ARB
            // subsystem, which returns E00040 ('The record cannot be found.'). Retry with
            // a short backoff to let the new profile propagate before giving up.
            $arbTries = 0;
            while (!$sr['ok']
                && ($sr['data']['messages']['message'][0]['code'] ?? '') === 'E00040'
                && $arbTries < 5) {
                sleep(2);
                $sr = $this->anPost($endpoint, $subReq);
                $arbTries++;
            }
            if (!$sr['ok']) {
                return ['success' => false, 'error' => $sr['error']];
            }
            $arbId = $sr['data']['subscriptionId'] ?? null;

            DB::insert(DB::prefix() . 'instinctor_subscriptions', [
                'customer_id'          => $customerId,
                'product_id'           => (int) $it['product_id'],
                'external_id'          => (string) $arbId,
                'price'                => (float) $it['price'],
                'period'               => (string) $it['period'],
                'interval_count'       => (int) $it['interval_count'],
                'status'               => 'active',
                'payment_method'       => 'authorizenet',
                'current_period_start' => gmdate('Y-m-d H:i:s'),
                'current_period_end'   => $this->anPeriodEnd((string) $it['period'], (int) $it['interval_count']),
                'meta'                 => wp_json_encode([
                    'an_customer_profile_id' => (string) $customerProfileId,
                    'an_payment_profile_id'  => (string) $paymentProfileId,
                    'order_id'               => $orderId,
                ]),
                'created_at'           => current_time('mysql'),
            ]);
            $created[] = $arbId;
        }

        if (empty($created)) {
            return ['success' => false, 'error' => 'No subscription items to bill'];
        }

        return [
            'success'         => true,
            'transaction_id'  => (string) $created[0],
            'payment_status'  => 'paid',
            'subscription_id' => $created[0],
        ];
    }

    /**
     * POST a JSON request to Authorize.net and normalize the result.
     * Returns ['ok'=>bool, 'error'=>string, 'data'=>array].
     */
    private function anPost(string $endpoint, array $payload): array
    {
        $response = wp_remote_post($endpoint, [
            'headers' => ['Content-Type' => 'application/json'],
            'body'    => wp_json_encode($payload),
            'timeout' => 30,
        ]);
        if (is_wp_error($response)) {
            return ['ok' => false, 'error' => $response->get_error_message(), 'data' => []];
        }
        $body = ltrim(wp_remote_retrieve_body($response), "\xEF\xBB\xBF");
        $data = json_decode($body, true) ?: [];
        $resultCode = $data['messages']['resultCode'] ?? 'Error';
        if ($resultCode === 'Ok') {
            return ['ok' => true, 'error' => '', 'data' => $data];
        }
        $err = $data['messages']['message'][0]['text'] ?? 'Authorize.net request failed';
        return ['ok' => false, 'error' => $err, 'data' => $data];
    }

    /**
     * Map our billing period to an Authorize.net ARB interval.
     * Months: length 1-12. Days: length 7-365.
     */
    private function anInterval(string $period, int $count): array
    {
        $count = max(1, $count);
        switch ($period) {
            case 'weekly': return ['length' => min(365, max(7, 7 * $count)), 'unit' => 'days'];
            case 'yearly': return ['length' => 12, 'unit' => 'months'];
            case 'monthly':
            default:       return ['length' => min(12, $count), 'unit' => 'months'];
        }
    }

    /** Period-end datetime from now for the local subscription record. */
    private function anPeriodEnd(string $period, int $count): string
    {
        $count = max(1, $count);
        $mod = ['weekly' => "+{$count} week", 'monthly' => "+{$count} month", 'yearly' => '+1 year'][$period] ?? "+{$count} month";
        return gmdate('Y-m-d H:i:s', strtotime($mod));
    }

    /**
     * Process subscription checkout via Stripe Checkout Session
     */
    private function processSubscriptionCheckout(array $cart, int $customerId, int $orderId, array $data): array
    {
        $subscriptionService = SubscriptionService::instance();
        
        // Get or create Stripe customer
        $stripeCustomerId = $subscriptionService->getOrCreateStripeCustomer($customerId, $data['billing']);
        
        if (!$stripeCustomerId) {
            // Demo mode - create local subscription only
            $subscriptionItems = $subscriptionService->getSubscriptionItems($cart);
            
            foreach ($subscriptionItems as $item) {
                $period = $item['period'];
                $periodEnd = new \DateTime();
                
                switch ($period) {
                    case 'weekly': $periodEnd->modify('+1 week'); break;
                    case 'yearly': $periodEnd->modify('+1 year'); break;
                    default: $periodEnd->modify('+1 month');
                }

                DB::insert(DB::prefix() . 'instinctor_subscriptions', [
                    'customer_id' => $customerId,
                    'product_id' => $item['product_id'],
                    'price' => $item['price'],
                    'period' => $period,
                    'status' => 'active',
                    'current_period_start' => current_time('mysql'),
                    'current_period_end' => $periodEnd->format('Y-m-d H:i:s'),
                    'created_at' => current_time('mysql')
                ]);
            }

            return [
                'success' => true,
                'transaction_id' => 'demo_sub_' . wp_generate_password(12, false),
                'payment_status' => 'paid'
            ];
        }

        // Get subscription items
        $subscriptionItems = $subscriptionService->getSubscriptionItems($cart);
        
        if (empty($subscriptionItems)) {
            return ['success' => false, 'error' => 'No subscription items found'];
        }

        // Build success/cancel URLs
        $successUrl = add_query_arg([
            'instinctor_checkout' => 'subscription_success',
            'order_id' => $orderId,
            'session_id' => '{CHECKOUT_SESSION_ID}'
        ], home_url('/'));
        
        $cancelUrl = add_query_arg([
            'instinctor_checkout' => 'cancelled',
            'order_id' => $orderId
        ], \Instinctor\Services\PageService::resolveUrl('checkout'));

        // Create Stripe Checkout Session for subscription
        $result = $subscriptionService->createSubscriptionCheckout(
            $stripeCustomerId,
            $subscriptionItems,
            $orderId,
            $successUrl,
            $cancelUrl
        );

        if ($result['success']) {
            return [
                'success' => true,
                'requires_redirect' => true,
                'checkout_url' => $result['checkout_url'],
                'session_id' => $result['session_id']
            ];
        }

        return $result;
    }

    private function getOrCreateCustomer(array $billing, ?int $userId = null): int
    {
        $table = DB::prefix() . 'instinctor_customers';

        $email = sanitize_email($billing['email']);
        $userId = $userId ?: Auth::userId();

        // Email is unique per owner, not per install (0058). Checkout runs in
        // the vendor scope today (products carry no owner_id), where this is
        // "owner_id IS NULL" -- but an unscoped lookup could match another
        // tenant's client row and hang this order on it.
        $existing = DB::get_row(DB::prepare(
            "SELECT id, user_id FROM $table WHERE email = %s
               AND " . \Instinctor\Core\Tenant::scopeSql('owner_id'),
            $email
        ));

        if ($existing) {
            // Update billing info; never overwrite an established user_id link
            $updateData = [
                'first_name'      => sanitize_text_field($billing['first_name']),
                'last_name'       => sanitize_text_field($billing['last_name']),
                'phone'           => sanitize_text_field($billing['phone'] ?? ''),
                'billing_address' => json_encode($billing),
            ];
            if (empty($existing->user_id) && $userId) {
                $updateData['user_id'] = $userId;
            }
            DB::update($table, $updateData, ['id' => $existing->id]);

            return (int) $existing->id;
        }

        // Create new customer
        DB::insert($table, [
            'user_id' => $userId ?: null,
            'email' => $email,
            'first_name' => sanitize_text_field($billing['first_name']),
            'last_name' => sanitize_text_field($billing['last_name']),
            'phone' => sanitize_text_field($billing['phone'] ?? ''),
            'billing_address' => json_encode($billing),
            'owner_id' => \Instinctor\Core\Tenant::currentOwnerId(),
        ]);

        $customerId = (int) DB::insert_id();
        
        // Send welcome email to new customers
        $customerName = trim($billing['first_name'] . ' ' . $billing['last_name']);
        EmailService::send('customer_welcome', $email, [
            'customer_name' => $customerName,
            'customer_first_name' => $billing['first_name'],
            'customer_email' => $email,
        ]);
        
        // Award loyalty signup points if enabled
        LoyaltyService::awardForSignup($customerId);

        return $customerId;
    }

    // createUserAccount() removed 2026-06-27 — dead code. The checkout flow creates
    // storefront accounts via UserService::registerCustomer(); this method was never
    // called and only existed to be flagged by the WP.org scanner (wp_create_user /
    // wp_update_user). Account creation lives in one place (UserService) now.

    private function createOrder(array $cart, int $customerId, array $data): array
    {
        $ordersTable = DB::prefix() . 'instinctor_orders';
        $itemsTable = DB::prefix() . 'instinctor_order_items';

        // Placeholder order number — updated to actual ID after insert
        $orderNumber = 'CT-PENDING';

        // Get gift card and loyalty from session
        $giftCard = CartSession::get('gift_card');
        $loyalty  = CartSession::get('loyalty');

        // Calculate final totals with gift card and loyalty
        $giftCardAmount = $giftCard ? min($giftCard['amount_to_use'], $cart['totals']['total']) : 0;
        $loyaltyDiscount = $loyalty ? min($loyalty['discount'], $cart['totals']['total'] - $giftCardAmount) : 0;
        $finalTotal = max(0, $cart['totals']['total'] - $giftCardAmount - $loyaltyDiscount);

        // Optional scheduled fulfillment time (future pickup/delivery) and gratuity
        // (restaurant tipping). Both are restaurant-vertical features: the generic
        // default is none, and the restaurant path below fills them in. The two
        // initializers stay generic so the order insert (tip_total/scheduled_for)
        // and receipt remain valid after the vertical block is stripped from the
        // free build. Scheduled time stores only a real, non-past datetime (else
        // ASAP/NULL); tip is non-negative, rounded, capped as a garbage guard, and
        // added on top of the post-discount total, never reduced by coupons/loyalty.
        $scheduledFor = null;
        $tipAmount    = 0.0;
        

        // Insert order
        DB::insert($ordersTable, [
            'order_number' => $orderNumber,
            'customer_id' => $customerId,
            'status' => 'pending',
            'currency' => Config::get('instinctor_currency', 'USD'),
            'subtotal' => $cart['totals']['subtotal'],
            'tax_total' => $cart['totals']['tax'],
            'shipping_total' => $cart['totals']['shipping'],
            'tip_total' => $tipAmount,
            'discount_total' => ($cart['totals']['discount'] ?? 0) + $giftCardAmount + $loyaltyDiscount,
            'total' => $finalTotal,
            'payment_method' => sanitize_text_field($data['payment_method'] ?? 'stripe'),
            'payment_status' => 'pending',
            'billing_address' => json_encode($data['billing']),
            'shipping_address' => json_encode($data['shipping'] ?? $data['billing']),
            'customer_note' => sanitize_textarea_field($data['notes'] ?? ''),
            'scheduled_for' => $scheduledFor,
            'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '',
            'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '',
            'meta' => json_encode([
                'gift_card_id' => $giftCard['id'] ?? null,
                'gift_card_code' => $giftCard['code'] ?? null,
                'gift_card_amount' => $giftCardAmount,
                'loyalty_points_used' => $loyalty['points'] ?? 0,
                'loyalty_discount' => $loyaltyDiscount,
                'coupon_code' => $cart['coupon']['code'] ?? null,
                'coupon_discount' => $cart['totals']['discount'] ?? 0,
                'bundle_discount' => $cart['totals']['bundle_discount'] ?? 0,
                'bundle_discounts' => $cart['bundle_discounts'] ?? [],
                'order_key' => wp_generate_password(32, false),
            ]),
        ]);

        $orderId = DB::insert_id();

        if (!$orderId) {
            return ['success' => false, 'error' => 'Failed to create order'];
        }

        // Generate order number from actual ID (avoids race condition with MAX(id))
        $orderNumber = 'CT-' . str_pad((string) $orderId, 5, '0', STR_PAD_LEFT);
        DB::update($ordersTable, ['order_number' => $orderNumber], ['id' => $orderId]);

        // Insert order items
        foreach ($cart['items'] as $item) {
            DB::insert($itemsTable, [
                'order_id' => $orderId,
                'product_id' => $item['product_id'],
                'variation_id' => $item['variation_id'] ?? null,
                'name' => $item['name'],
                'quantity' => $item['quantity'],
                'price' => $item['price'],
                'subtotal' => $item['price'] * $item['quantity'],
                'total' => $item['price'] * $item['quantity'],
                'meta' => json_encode($item['meta'] ?? []),
            ]);
        }

        // Store gift card and loyalty info for post-payment processing
        $metaArr = json_decode(DB::get_var(DB::prepare(
            "SELECT meta FROM {$ordersTable} WHERE id = %d", $orderId
        )) ?: '{}', true) ?: [];
        return [
            'success' => true,
            'order_id' => $orderId,
            'order_number' => $orderNumber,
            'order_key' => $metaArr['order_key'] ?? '',
            'gift_card' => $giftCard,
            'loyalty' => $loyalty,
            'final_total' => $finalTotal,
        ];
    }

    private function getCustomerIdFromOrder(int $orderId): ?int
    {
        $p = DB::prefix();
        return (int) DB::get_var(DB::prepare(
            "SELECT customer_id FROM {$p}instinctor_orders WHERE id = %d",
            $orderId
        )) ?: null;
    }

    private function updateOrderStatus(int $orderId, string $status): void
    {
        DB::update(
            DB::prefix() . 'instinctor_orders',
            [
                'status' => $status,
                'completed_at' => $status === 'completed' ? current_time('mysql') : null,
            ],
            ['id' => $orderId]
        );
    }

    private function updateOrderPayment(int $orderId, array $payment): void
    {
        $update = [];
        
        // Only set fields that are explicitly provided
        if (array_key_exists('transaction_id', $payment) && $payment['transaction_id'] !== null) {
            $update['transaction_id'] = $payment['transaction_id'];
        }
        if (isset($payment['payment_status'])) {
            $update['payment_status'] = $payment['payment_status'];
        }
        
        if (!empty($update)) {
            DB::update(DB::prefix() . 'instinctor_orders', $update, ['id' => $orderId]);
        }
    }
    
    /**
     * Increment customer total_spent and order_count.
     * Should only be called once per order (finalizeOrder handles this).
     */
    private function incrementCustomerStats(int $orderId): void
    {
        $p = DB::prefix();
        $order = DB::get_row(DB::prepare(
            "SELECT customer_id, total FROM {$p}instinctor_orders WHERE id = %d",
            $orderId
        ));

        if ($order) {
            DB::query(DB::prepare(
                "UPDATE {$p}instinctor_customers 
                 SET total_spent = total_spent + %f, order_count = order_count + 1 
                 WHERE id = %d",
                $order->total,
                $order->customer_id
            ));
        }
    }

    private function reduceStock(array $items): void
    {
        $table = DB::prefix() . 'instinctor_products';

        foreach ($items as $item) {
            $affected = DB::query(DB::prepare(
                "UPDATE $table SET stock_quantity = stock_quantity - %d 
                 WHERE id = %d AND manage_stock = 1 AND stock_quantity >= %d",
                $item['quantity'],
                $item['product_id'],
                $item['quantity']
            ));
            
            // If insufficient stock, reduce to zero and log warning
            if ($affected === 0) {
                $product = DB::get_row(DB::prepare(
                    "SELECT name, stock_quantity, manage_stock FROM $table WHERE id = %d",
                    $item['product_id']
                ));
                
                if ($product && $product->manage_stock && $product->stock_quantity > 0) {
                    // Partial stock available — reduce to zero
                    DB::update($table, ['stock_quantity' => 0], ['id' => $item['product_id']]);
                    // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                    error_log(sprintf(
                        'Instinctor: Partial stock reduction for product #%d (%s), ordered %d, had %d',
                        $item['product_id'],
                        $product->name ?? '',
                        $item['quantity'],
                        $product->stock_quantity
                    ));
                }
            }
        }
    }

    private function sendOrderConfirmation(int $orderId): void
    {
        $p = DB::prefix();
        
        $order = DB::get_row(DB::prepare(
            "SELECT o.*, c.email, c.first_name, c.last_name 
             FROM {$p}instinctor_orders o
             JOIN {$p}instinctor_customers c ON o.customer_id = c.id
             WHERE o.id = %d",
            $orderId
        ));

        if (!$order || !$order->email) {
            return;
        }

        // Idempotent: an offline order (COD / bank transfer) sends this at
        // placement and would hit it again when the owner marks it paid
        // (finalizeOrder). Send the confirmation only once.
        $confMeta = json_decode($order->meta ?: '{}', true);
        if (!empty($confMeta['confirmation_sent'])) {
            return;
        }

        // Restaurant order-status text: "received". Self-gating (restaurant +
        // opt-in + a customer phone); a no-op on every other store.
        

        $items = DB::get_results(DB::prepare(
            "SELECT * FROM {$p}instinctor_order_items WHERE order_id = %d",
            $orderId
        ));

        // Build order details HTML for email
        $orderDetails = $this->buildOrderDetailsHtml($items, $order);
        $customerName = trim($order->first_name . ' ' . $order->last_name);

        // Send customer confirmation using EmailService template
        EmailService::send('order_confirmation', $order->email, [
            'order_number' => $order->order_number,
            'order_date' => date_i18n(Config::get('date_format', 'Y-m-d'), strtotime($order->created_at)),
            'order_total' => CurrencyService::formatPrice($order->total),
            'order_details' => $orderDetails,
            'customer_name' => $customerName,
            'customer_first_name' => $order->first_name,
            'customer_email' => $order->email,
        ]);

        // Send admin notification using EmailService template
        $admin_email = Config::get('admin_email', '');
        if ($admin_email) {
            EmailService::send('new_order_admin', $admin_email, [
                'order_number' => $order->order_number,
                'order_date' => date_i18n(Config::get('date_format', 'Y-m-d'), strtotime($order->created_at)),
                'order_total' => CurrencyService::formatPrice($order->total),
                'order_details' => $orderDetails,
                'customer_name' => $customerName,
                'customer_email' => $order->email,
            ]);
        }

        // Mark sent so a later finalize (offline order marked paid) does not
        // re-email the buyer.
        $confMeta['confirmation_sent'] = 1;
        DB::update($p . 'instinctor_orders', ['meta' => wp_json_encode($confMeta)], ['id' => $orderId]);
    }

    /**
     * Build HTML order details for emails
     */
    private function buildOrderDetailsHtml(array $items, object $order): string
    {
        $html = '<table style="width:100%;border-collapse:collapse;margin:15px 0">';
        $html .= '<tr style="background:#f5f5f5"><th style="padding:10px;text-align:left;border-bottom:1px solid #ddd">Item</th><th style="padding:10px;text-align:center;border-bottom:1px solid #ddd">Qty</th><th style="padding:10px;text-align:right;border-bottom:1px solid #ddd">Price</th></tr>';

        // Scheduled fulfillment time, shown to both the customer and the owner.
        

        foreach ($items as $item) {
            $html .= '<tr>';
            $html .= '<td style="padding:10px;border-bottom:1px solid #eee">' . esc_html($item->name);
            
            // Include booking info if present
            $itemMeta = json_decode($item->meta ?? '{}', true);
            if (!empty($itemMeta['booking_date']) && !empty($itemMeta['booking_time'])) {
                $bookingDate = date_i18n(Config::get('date_format', 'Y-m-d'), strtotime($itemMeta['booking_date']));
                $html .= '<br><small style="color:#666">📅 ' . esc_html($bookingDate . ' at ' . $itemMeta['booking_time']) . '</small>';
            }
            
            // Include variation info
            if (!empty($itemMeta['variation_name'])) {
                $html .= '<br><small style="color:#666">' . esc_html($itemMeta['variation_name']) . '</small>';
            }
            
            $html .= '</td>';
            $html .= '<td style="padding:10px;text-align:center;border-bottom:1px solid #eee">' . intval($item->quantity) . '</td>';
            $html .= '<td style="padding:10px;text-align:right;border-bottom:1px solid #eee">' . CurrencyService::formatPrice($item->total) . '</td>';
            $html .= '</tr>';
        }
        
        // Subtotal, shipping, tax, total
        $html .= '<tr><td colspan="2" style="padding:10px;text-align:right"><strong>Subtotal</strong></td><td style="padding:10px;text-align:right">' . CurrencyService::formatPrice($order->subtotal) . '</td></tr>';
        
        if ($order->shipping_total > 0) {
            $html .= '<tr><td colspan="2" style="padding:10px;text-align:right">Shipping</td><td style="padding:10px;text-align:right">' . CurrencyService::formatPrice($order->shipping_total) . '</td></tr>';
        }
        
        if ($order->tax_total > 0) {
            $html .= '<tr><td colspan="2" style="padding:10px;text-align:right">Tax</td><td style="padding:10px;text-align:right">' . CurrencyService::formatPrice($order->tax_total) . '</td></tr>';
        }

        

        if ($order->discount_total > 0) {
            $html .= '<tr><td colspan="2" style="padding:10px;text-align:right;color:#16a34a">Discount</td><td style="padding:10px;text-align:right;color:#16a34a">-' . CurrencyService::formatPrice($order->discount_total) . '</td></tr>';
        }
        
        $html .= '<tr style="background:#f5f5f5"><td colspan="2" style="padding:10px;text-align:right"><strong>Total</strong></td><td style="padding:10px;text-align:right"><strong>' . CurrencyService::formatPrice($order->total) . '</strong></td></tr>';
        $html .= '</table>';
        
        return $html;
    }

    /**
     * Process bookings for bookable products in order
     */
    private function processOrderBookings(int $orderId, array $cartItems, int $customerId): void
    {
        $p = DB::prefix();
        $bookingService = BookingService::instance();
        
        foreach ($cartItems as $item) {
            // Get booking data from item meta
            $meta = $item['meta'] ?? [];
            $bookingDate = $meta['booking_date'] ?? null;
            $bookingTime = $meta['booking_time'] ?? null;
            
            // Check if this item has booking data
            if (empty($bookingDate) || empty($bookingTime)) {
                continue;
            }

            // Check if product is bookable
            if (!$bookingService->isProductBookable($item['product_id'])) {
                continue;
            }

            // Create the booking
            $result = $bookingService->createBooking([
                'product_id' => $item['product_id'],
                'customer_id' => $customerId,
                'order_id' => $orderId,
                'date' => $bookingDate,
                'time' => $bookingTime,
                'note' => $meta['booking_note'] ?? '',
                'created_via' => 'checkout'
            ]);

            if ($result['success']) {
                // Update order item meta with booking ID
                DB::query(DB::prepare(
                    "UPDATE {$p}instinctor_order_items 
                     SET meta = JSON_SET(COALESCE(meta, '{}'), '$.booking_id', %d)
                     WHERE order_id = %d AND product_id = %d",
                    $result['booking_id'],
                    $orderId,
                    $item['product_id']
                ));
            }
        }
    }
}
