<?php
// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.SafeRedirect.wp_redirect_wp_redirect, WordPress.WP.I18n.MissingTranslatorsComment, WordPress.WP.I18n.UnorderedPlaceholdersText, WordPress.DateTime.RestrictedFunctions.date_date, WordPress.WP.AlternativeFunctions.rand_mt_rand, WordPress.PHP.DevelopmentFunctions.error_log_error_log, Squiz.PHP.DiscouragedFunctions.Discouraged, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

if ( ! function_exists( 'yatoon_sanitize_setting' ) ) {
    function yatoon_sanitize_setting( $value ) {
        if (is_array($value)) {
            return array_map('yatoon_sanitize_setting', $value);
        }

        if (is_bool($value) || is_numeric($value)) {
            return $value;
        }

        return sanitize_textarea_field(wp_unslash($value));
    }
}

if ( ! function_exists( 'yatoon_square_availability_matches_requested_start' ) ) {
    /**
     * Square can return later openings inside the requested search window.
     * A later opening must never be treated as availability for the time the
     * customer selected (for example, 5:00 PM cannot satisfy 4:45 PM).
     */
    function yatoon_square_availability_matches_requested_start( $availability_start, $requested_start_timestamp ) {
        if ( empty( $availability_start ) ) {
            return false;
        }

        try {
            $availability_timestamp = ( new DateTimeImmutable( $availability_start ) )->getTimestamp();
        } catch ( Exception $e ) {
            return false;
        }

        return $availability_timestamp === (int) $requested_start_timestamp;
    }
}

class YATOON_Ajax {

    private $square_api;

    public function __construct() {
        // -- Per-location Square API init ---------------------------------------
        // When multi-location mode is on, resolve the Square Location ID from
        // the location passed in the request (availability checks, bookings).
        // Falls back to the global setting automatically via the constructor.
        $sq_location_id = '';
        if ( get_option( 'yatoon_multi_location_enabled', '0' ) === '1' ) {
            $raw_loc = isset( $_REQUEST['yatoon_location_id'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['yatoon_location_id'] ) ) : '';
            if ( $raw_loc !== '' ) {
                $sq_location_id = YATOON_Square_API::get_square_location_id_for( $raw_loc );
            }
        }
        $this->square_api = new YATOON_Square_API( $sq_location_id );

        // Frontend AJAX
        add_action('wp_ajax_yatoon_get_service_options', array($this, 'get_service_options'));
        add_action('wp_ajax_nopriv_yatoon_get_service_options', array($this, 'get_service_options'));

        add_action('wp_ajax_yatoon_get_available_staff', array($this, 'get_available_staff'));
        add_action('wp_ajax_nopriv_yatoon_get_available_staff', array($this, 'get_available_staff'));

        // Admin diagnostic
        add_action('wp_ajax_yatoon_diagnose_service', array($this, 'diagnose_service'));
        add_action('wp_ajax_yatoon_fix_variation_ids', array($this, 'fix_variation_ids'));

        add_action('wp_ajax_yatoon_get_available_times', array($this, 'get_available_times'));
        add_action('wp_ajax_nopriv_yatoon_get_available_times', array($this, 'get_available_times'));

        add_action('wp_ajax_yatoon_get_next_available', array($this, 'get_next_available'));
        add_action('wp_ajax_nopriv_yatoon_get_next_available', array($this, 'get_next_available'));

        add_action('wp_ajax_yatoon_create_booking', array($this, 'create_booking'));
        add_action('wp_ajax_nopriv_yatoon_create_booking', array($this, 'create_booking'));
        add_action('wp_ajax_yatoon_upload_design_reference', array($this, 'upload_design_reference'));
        add_action('wp_ajax_nopriv_yatoon_upload_design_reference', array($this, 'upload_design_reference'));
        add_action('yatoon_booking_created', array($this, 'finalize_design_reference'), 90, 1);
        add_action('yatoon_cleanup_design_uploads', array($this, 'cleanup_design_uploads'));
        if (!wp_next_scheduled('yatoon_cleanup_design_uploads')) {
            wp_schedule_event(time() + DAY_IN_SECONDS, 'daily', 'yatoon_cleanup_design_uploads');
        }

        // Admin: toggle staff online booking status
        add_action('wp_ajax_yatoon_toggle_staff_bookable', array($this, 'toggle_staff_bookable'));

        // Admin: save service order
        add_action('wp_ajax_yatoon_update_service_order', array($this, 'update_service_order'));
        add_action('wp_ajax_yatoon_rebuild_staff_services', array($this, 'rebuild_staff_services'));

        // Admin: reorder services within a multi-service booking group
        add_action('wp_ajax_yatoon_reorder_booking_group', array($this, 'reorder_booking_group'));

        // Frontend: send contact message
        add_action('wp_ajax_yatoon_send_contact_message', array($this, 'send_contact_message'));
        add_action('wp_ajax_nopriv_yatoon_send_contact_message', array($this, 'send_contact_message'));

        // Frontend: join waitlist
        add_action('wp_ajax_yatoon_join_waitlist', array($this, 'join_waitlist'));
        add_action('wp_ajax_nopriv_yatoon_join_waitlist', array($this, 'join_waitlist'));
        // Admin: debug bookings for a specific date
        add_action('wp_ajax_yatoon_debug_date_bookings', array($this, 'debug_date_bookings'));
        // Admin: query Square API directly for a date+service
        add_action('wp_ajax_yatoon_debug_square_availability', array($this, 'debug_square_availability'));
        // Admin: cancel a booking (update local DB + cancel in Square)
        add_action('wp_ajax_yatoon_cancel_booking', array($this, 'cancel_booking_admin'));
        // Admin: permanently delete a single booking from local DB
        add_action('wp_ajax_yatoon_delete_single_booking', array($this, 'delete_single_booking'));
        // Admin: sync bookings from Square (pull status updates)
        add_action('wp_ajax_yatoon_sync_bookings_from_square', array($this, 'sync_bookings_from_square'));

        // 4.6.2: per-day open-slot counts for the week/month calendar density
        // bars. Public read endpoint - nonce checked and rate limited inside
        // the handler, same as the other nopriv availability endpoints.
        add_action('wp_ajax_yatoon_get_availability_density', array($this, 'get_availability_density'));
        add_action('wp_ajax_nopriv_yatoon_get_availability_density', array($this, 'get_availability_density'));
    }

    /**
     * Send contact message (Text Us form)
     */
    public function send_contact_message() {
        check_ajax_referer('yatoon_frontend_nonce', 'nonce');

        $this->enforce_public_rate_limit('contact', 5, 10);

        $name    = isset($_POST['name']) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '';
        $phone   = isset($_POST['phone']) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : '';
        $message = isset($_POST['message']) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : '';

        if (empty($name) || empty($phone) || empty($message)) {
            wp_send_json_error(array('message' => __( 'All fields required', 'yatoon-booking-system' )));
        }

        $client_ip = $this->get_client_ip();

        // Save to database
        global $wpdb;
        $table = $wpdb->prefix . 'yatoon_contact_messages';
        yatoon_db_insert($table, array(
            'name'       => $name,
            'phone'      => $phone,
            'message'    => $message,
            'ip_address' => $client_ip,
            'created_at' => current_time('mysql'),
        ));

        // Send email notification
        $to      = get_option('yatoon_contact_email', '');
        $business_name = get_option('yatoon_business_name', 'Your Business');
        $subject = '[' . $business_name . '] New contact message from ' . $name;
        $body    = "Name: {$name}\n";
        $body   .= "Phone: {$phone}\n";
        $body   .= "Message:\n{$message}\n\n";
        $body   .= "---\n";
        $body   .= 'Sent from: ' . home_url() . "\n";
        $body   .= 'IP: ' . $client_ip;

        if (is_email($to)) {
            wp_mail($to, $subject, $body);
        }

        wp_send_json_success(array('message' => __( 'Message sent', 'yatoon-booking-system' )));
    }

    /**
     * Admin: save service order
     */
    public function update_service_order() {
        check_ajax_referer('yatoon_service_order', 'nonce');
        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => __( 'Unauthorized', 'yatoon-booking-system' )));
        }

        global $wpdb;
        $table = $wpdb->prefix . 'yatoon_services';
        $order = isset($_POST['order']) ? $_POST['order'] : array();

        foreach ($order as $item) {
            yatoon_db_update(
                $table,
                array('sort_order' => intval($item['sort_order'])),
                array('id' => intval($item['id']))
            );
        }

        wp_send_json_success(array('message' => __( 'Order saved', 'yatoon-booking-system' )));
    }

    /**
     * Admin: Swap the order of two services within a multi-service booking group.
     *
     * When swapped, both bookings in Square are cancelled and re-created with
     * the corrected sequential times. Locally, start_time / end_time and
     * service_order are updated to match.
     *
     * POST params:
     *   booking_id_a  - local ID of booking A (the one being moved)
     *   booking_id_b  - local ID of booking B (the one it swaps with)
     *   nonce         - yatoon_admin_nonce
     */
    public function reorder_booking_group() {
        check_ajax_referer('yatoon_admin_nonce', 'nonce');
        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => __( 'Unauthorized', 'yatoon-booking-system' )));
        }

        global $wpdb;
        $table_bookings  = $wpdb->prefix . 'yatoon_bookings';
        $table_services  = $wpdb->prefix . 'yatoon_services';
        $table_options   = $wpdb->prefix . 'yatoon_service_options';

        $id_a = intval($_POST['booking_id_a'] ?? 0);
        $id_b = intval($_POST['booking_id_b'] ?? 0);
        if (!$id_a || !$id_b || $id_a === $id_b) {
            wp_send_json_error(array('message' => __( 'Invalid booking IDs', 'yatoon-booking-system' )));
        }

        $bk_a = yatoon_db_get_row($wpdb->prepare("SELECT * FROM $table_bookings WHERE id=%d", $id_a));
        $bk_b = yatoon_db_get_row($wpdb->prepare("SELECT * FROM $table_bookings WHERE id=%d", $id_b));

        if (!$bk_a || !$bk_b) {
            wp_send_json_error(array('message' => __( 'Booking(s) not found', 'yatoon-booking-system' )));
        }
        if ($bk_a->group_booking_id !== $bk_b->group_booking_id || empty($bk_a->group_booking_id)) {
            wp_send_json_error(array('message' => __( 'Bookings are not in the same group', 'yatoon-booking-system' )));
        }

        // Load ALL bookings in this group, ordered by service_order
        $group_id   = $bk_a->group_booking_id;
        $group_rows = yatoon_db_get_results($wpdb->prepare(
            "SELECT * FROM $table_bookings WHERE group_booking_id=%s ORDER BY service_order ASC, start_time ASC",
            $group_id
        ));
        if (count($group_rows) < 2) {
            wp_send_json_error(array('message' => __( 'Group must have at least 2 bookings', 'yatoon-booking-system' )));
        }

        // Swap service_order values between A and B
        $order_a = intval($bk_a->service_order);
        $order_b = intval($bk_b->service_order);
        yatoon_db_update($table_bookings, array('service_order' => $order_b), array('id' => $id_a));
        yatoon_db_update($table_bookings, array('service_order' => $order_a), array('id' => $id_b));

        // Re-load group in new order
        $group_rows = yatoon_db_get_results($wpdb->prepare(
            "SELECT * FROM $table_bookings WHERE group_booking_id=%s ORDER BY service_order ASC",
            $group_id
        ));

        // Determine the earliest start time for the whole group (the anchor)
        $all_starts = array_map(fn( $r ) => strtotime($r->start_time), $group_rows);
        $anchor_ts  = min($all_starts);
        $anchor_dt  = new DateTime('@' . $anchor_ts);
        $timezone   = yatoon_timezone_string();
        $tz_obj     = new DateTimeZone($timezone);
        $anchor_dt->setTimezone($tz_obj);

        $platform = sanitize_key((string) get_option('yatoon_active_platform', 'local'));
        if (!in_array($platform, array('local', 'square', 'vagaro'), true)) {
            $platform = 'local';
        }

        // 4.6.2: batch-prefetch everything the loop below needs.
        //
        // What was wrong: the loop ran one SELECT for the service, one for the
        // duration-override option, one for the Square variation and one for
        // the staff row - per booking. A six-service group was 24 queries for
        // data that is four IN() lookups.
        //
        // The lookups themselves are unchanged: each map is keyed exactly the
        // way the per-row query was filtered, and the "first matching row wins"
        // semantics of the old LIMIT 1 queries is preserved by keeping only the
        // first row seen per booking_id in ascending bo.id order.
        $group_booking_ids = array();
        $group_service_ids = array();
        $group_staff_ids   = array();
        foreach ($group_rows as $bk) {
            $group_booking_ids[] = absint($bk->id);
            $group_service_ids[] = absint($bk->service_id);
            $group_staff_ids[]   = absint($bk->staff_id);
        }
        $group_booking_ids = array_values(array_unique(array_filter($group_booking_ids)));
        $group_service_ids = array_values(array_unique(array_filter($group_service_ids)));
        $group_staff_ids   = array_values(array_unique(array_filter($group_staff_ids)));

        $service_map = array();
        if (!empty($group_service_ids)) {
            $in = implode(',', $group_service_ids); // already absint()ed above
            foreach ((array) yatoon_db_get_results("SELECT * FROM $table_services WHERE id IN ($in)") as $row) {
                $service_map[ (int) $row->id ] = $row;
            }
        }

        $duration_option_map = array();
        if (!empty($group_booking_ids)) {
            $in = implode(',', $group_booking_ids);
            $rows = (array) yatoon_db_get_results(
                "SELECT bo.booking_id, o.duration_minutes
                   FROM {$wpdb->prefix}yatoon_booking_options bo
                   INNER JOIN $table_options o ON bo.option_id = o.id
                  WHERE bo.booking_id IN ($in) AND o.duration_minutes > 0
                  ORDER BY bo.id ASC"
            );
            foreach ($rows as $row) {
                $bid = (int) $row->booking_id;
                if (!isset($duration_option_map[ $bid ])) {
                    $duration_option_map[ $bid ] = $row;
                }
            }
        }

        $variation_option_map = array();
        $staff_map            = array();
        if ($platform === 'square') {
            if (!empty($group_booking_ids)) {
                $in = implode(',', $group_booking_ids);
                $rows = (array) yatoon_db_get_results(
                    "SELECT bo.booking_id, o.square_id, o.square_version
                       FROM {$wpdb->prefix}yatoon_booking_options bo
                       INNER JOIN $table_options o ON bo.option_id = o.id
                      WHERE bo.booking_id IN ($in) AND o.square_id != ''
                      ORDER BY bo.id ASC"
                );
                foreach ($rows as $row) {
                    $bid = (int) $row->booking_id;
                    if (!isset($variation_option_map[ $bid ])) {
                        $variation_option_map[ $bid ] = $row;
                    }
                }
            }
            if (!empty($group_staff_ids)) {
                $in = implode(',', $group_staff_ids);
                foreach ((array) yatoon_db_get_results("SELECT * FROM {$wpdb->prefix}yatoon_staff WHERE id IN ($in)") as $row) {
                    $staff_map[ (int) $row->id ] = $row;
                }
            }
        }

        // Recompute and update times for each booking in the new order
        $cumulative = 0;
        $updated    = array();
        foreach ($group_rows as $bk) {
            $svc = isset($service_map[ (int) $bk->service_id ]) ? $service_map[ (int) $bk->service_id ] : null;
            if (!$svc) { continue;
            }

            $dur = intval($svc->duration_minutes);
            // Check if this booking has an option that overrides duration
            $opt = isset($duration_option_map[ (int) $bk->id ]) ? $duration_option_map[ (int) $bk->id ] : null;
            if ($opt) { $dur = intval($opt->duration_minutes);
            }

            $new_start = clone $anchor_dt;
            if ($cumulative > 0) { $new_start->modify("+{$cumulative} minutes");
            }
            $new_end = clone $new_start;
            $new_end->modify("+{$dur} minutes");

            $start_str = $new_start->format('Y-m-d H:i:s');
            $end_str   = $new_end->format('Y-m-d H:i:s');

            // Update local DB
            yatoon_db_update($table_bookings,
                array('start_time' => $start_str, 'end_time' => $end_str),
                array('id' => $bk->id)
            );

            // Update Square booking if synced
            if ($platform === 'square' && !empty($bk->square_booking_id)) {
                // Cancel old Square booking
                $this->square_api->cancel_booking($bk->square_booking_id);

                // Get variation ID for this service
                $var_id  = null;
                $var_ver = null;
                // 4.6.2: served from the batch prefetch above (was one SELECT
                // per booking). Same filter, same first-row-wins semantics.
                $opt_row = isset($variation_option_map[ (int) $bk->id ]) ? $variation_option_map[ (int) $bk->id ] : null;
                if ($opt_row) { $var_id = $opt_row->square_id;
$var_ver = $opt_row->square_version; }
                if (!$var_id && !empty($svc->square_id)) { $var_id = $svc->square_id;
$var_ver = $svc->square_version ?? null; }

                if ($var_id) {
                    // 4.6.2: staff row also comes from the batch prefetch.
                    $stf = isset($staff_map[ (int) $bk->staff_id ]) ? $staff_map[ (int) $bk->staff_id ] : null;
                    $seg = array(
                        'duration_minutes'     => $dur,
                        'service_variation_id' => $var_id,
                        'team_member_id'       => $stf ? $stf->square_id : '',
                    );
                    if ($var_ver) { $seg['service_variation_version'] = intval($var_ver);
                    }

                    $new_sq_resp = $this->square_api->create_booking(array(
                        'location_id'          => $this->square_api->get_location_id(),
                        'customer_id'          => '', // will be empty - acceptable for reschedule
                        'start_at'             => $new_start->format('c'),
                        'appointment_segments' => array($seg),
                    ));

                    if (!is_wp_error($new_sq_resp) && isset($new_sq_resp['booking']['id'])) {
                        yatoon_db_update($table_bookings,
                            array('square_booking_id' => $new_sq_resp['booking']['id'], 'square_synced' => 1),
                            array('id' => $bk->id)
                        );
                    }
                }
            }

            $updated[] = array(
                'id'         => $bk->id,
                'start_time' => $start_str,
                'end_time'   => $end_str,
                'service_order' => $cumulative === 0 ? 0 : $cumulative,
            );

            $cumulative += $dur;
        }

        wp_send_json_success(array(
            'message' => __( 'Booking order updated successfully.', 'yatoon-booking-system' ),
            'group'   => $updated,
        ));
    }

    /**
     * Admin: Rebuild staff-service associations from Square booking profiles.
     * This re-runs the same logic as the cron sync but on-demand.
     * Fixes cases where yatoon_staff_services table is empty due to the variation-ID bug.
     */
    public function rebuild_staff_services() {
        check_ajax_referer('yatoon_admin_nonce', 'nonce');
        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => __( 'Unauthorized', 'yatoon-booking-system' )));
        }

        global $wpdb;
        $table_staff          = $wpdb->prefix . 'yatoon_staff';
        $table_staff_services = $wpdb->prefix . 'yatoon_staff_services';
        $table_options        = $wpdb->prefix . 'yatoon_service_options';
        $table_services       = $wpdb->prefix . 'yatoon_services';

        // Fetch booking profiles from Square
        $profiles_resp = $this->square_api->get_team_member_booking_profiles();
        if (is_wp_error($profiles_resp)) {
            wp_send_json_error(array('message' => sprintf( __( 'Failed to fetch Square booking profiles: %s', 'yatoon-booking-system' ), $profiles_resp->get_error_message() )));
        }

        $staff_service_map = array(); // square_staff_id => [square_variation_ids]
        if (isset($profiles_resp['team_member_booking_profiles'])) {
            foreach ($profiles_resp['team_member_booking_profiles'] as $profile) {
                if (!empty($profile['is_bookable']) && !empty($profile['team_member_id']) && !empty($profile['service_variation_ids'])) {
                    $staff_service_map[ $profile['team_member_id'] ] = $profile['service_variation_ids'];
                }
            }
        }

        $updated = 0;
        $skipped = 0;
        $preserved = 0;

        // Get all local staff
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $all_staff = yatoon_db_get_results("SELECT id, name, square_id FROM $table_staff WHERE is_bookable = 1");

        foreach ($all_staff as $staff_row) {
            $square_id = $staff_row->square_id;
            $new_service_ids = array();

            if (!empty($square_id) && isset($staff_service_map[ $square_id ]) && !empty($staff_service_map[ $square_id ])) {
                // Square has service data for this staff - use it
                foreach ($staff_service_map[ $square_id ] as $square_variation_id) {
                    $opt_row = yatoon_db_get_row($wpdb->prepare(
                        "SELECT service_id FROM $table_options WHERE square_id = %s LIMIT 1",
                        $square_variation_id
                    ));
                    if ($opt_row && $opt_row->service_id) {
                        $new_service_ids[] = intval($opt_row->service_id);
                    } else {
                        $svc_row = yatoon_db_get_row($wpdb->prepare(
                            "SELECT id FROM $table_services WHERE square_id = %s LIMIT 1",
                            $square_variation_id
                        ));
                        if ($svc_row) { $new_service_ids[] = intval($svc_row->id);
                        }
                    }
                }
                $new_service_ids = array_unique($new_service_ids);
            } else {
                // No authoritative Square assignment data: preserve the existing
                // mapping instead of falsely declaring this employee capable of
                // every active service.
                ++$preserved;
                ++$skipped;
                continue;
            }

            if (!empty($new_service_ids)) {
                yatoon_db_delete($table_staff_services, array('staff_id' => $staff_row->id));
                foreach ($new_service_ids as $svc_id) {
                    yatoon_db_insert($table_staff_services, array(
                        'staff_id'   => $staff_row->id,
                        'service_id' => $svc_id,
                    ), array('%d', '%d'));
                }
                ++$updated;
            } else {
                ++$skipped;
            }
        }

        $note = $preserved ? ' ' . sprintf( __( 'Preserved existing assignments for %d staff member(s) without explicit Square service data.', 'yatoon-booking-system' ), $preserved ) : '';
        wp_send_json_success(array(
            'message' => sprintf( __( 'Done. Updated %1$d staff member(s) with service assignments.%2$s %3$d skipped.', 'yatoon-booking-system' ), $updated, $note, $skipped ),
            'updated' => $updated,
            'skipped' => $skipped,
            'fallback' => false,
        ));
    }

    /**
     * Admin: toggle staff is_bookable status
     */
    public function toggle_staff_bookable() {
        check_ajax_referer('yatoon_toggle_staff', 'nonce');
        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => __( 'Unauthorized', 'yatoon-booking-system' )));
        }
        global $wpdb;
        $staff_id = intval($_POST['staff_id']);
        $bookable  = intval($_POST['bookable']) ? 1 : 0;
        $result = yatoon_db_update(
            $wpdb->prefix . 'yatoon_staff',
            array('is_bookable' => $bookable),
            array('id' => $staff_id)
        );
        if ($result !== false) {
            wp_send_json_success(array('bookable' => $bookable));
        } else {
            wp_send_json_error(array('message' => __( 'DB error', 'yatoon-booking-system' )));
        }
    }

    /**
     * Get service options
     */
    public function get_service_options() {
        check_ajax_referer('yatoon_frontend_nonce', 'nonce');

        $service_id = isset($_POST['service_id']) ? intval($_POST['service_id']) : 0;

        if (!$service_id) {
            wp_send_json_error(array('message' => __('Invalid service ID', 'yatoon-booking-system')));
        }

        global $wpdb;
        $table_name = $wpdb->prefix . 'yatoon_service_options';
        $table_services = $wpdb->prefix . 'yatoon_services';

        // Get base service price
        $base_service = yatoon_db_get_row($wpdb->prepare("
            SELECT price, allow_design_upload FROM $table_services WHERE id = %d
        ", $service_id));

        $base_price = $base_service ? floatval($base_service->price) : 0;

        // Get all options
        $options = yatoon_db_get_results($wpdb->prepare("
            SELECT * FROM $table_name 
            WHERE service_id = %d 
            ORDER BY sort_order ASC, id ASC
        ", $service_id));

        // Calculate absolute price for each option
        // Also find linked_service_id: if this option's square_id matches a service's square_id,
        // record that service's DB id so the frontend can detect duplicate bookings by ID.
        foreach ($options as $option) {
            $is_addon = ( ( $option->group_name ?? '' ) === 'Add-on' );
            // Add-ons: the entered price is the exact, literal price shown to customers.
            // Main/single options: the entered price is relative to the base service price.
            $option->absolute_price = $is_addon ? floatval($option->price_modifier) : ( $base_price + floatval($option->price_modifier) );
            $option->linked_service_id = null;
            $option->allow_design_upload = !empty($base_service->allow_design_upload) ? 1 : 0;
            if (!empty($option->square_id)) {
                $linked = yatoon_db_get_row($wpdb->prepare(
                    "SELECT linked.id, linked.allow_design_upload
                     FROM $table_services linked
                     LEFT JOIN $table_name source_option ON source_option.service_id = linked.id
                     WHERE linked.square_id = %s OR source_option.square_id = %s
                     ORDER BY linked.allow_design_upload DESC
                     LIMIT 1",
                    $option->square_id,
                    $option->square_id
                ));
                if ($linked) {
                    $option->linked_service_id = intval($linked->id);
                    $option->allow_design_upload = ( !empty($option->allow_design_upload) || !empty($linked->allow_design_upload) ) ? 1 : 0;
                }
            }
        }

        wp_send_json_success(array(
            'options'                     => $options,
            'base_price'                  => $base_price,
            'service_allow_design_upload' => !empty($base_service->allow_design_upload) ? 1 : 0,
        ));
    }

    /**
     * Get available staff
     */
    public function get_available_staff() {
        check_ajax_referer('yatoon_frontend_nonce', 'nonce');

        $service_id = isset($_POST['service_id']) ? intval($_POST['service_id']) : 0;

        if (!$service_id) {
            wp_send_json_error(array('message' => __('Invalid service ID', 'yatoon-booking-system')));
        }

        global $wpdb;
        $table_staff = $wpdb->prefix . 'yatoon_staff';
        $table_staff_services = $wpdb->prefix . 'yatoon_staff_services';
        $table_services = $wpdb->prefix . 'yatoon_services';

        // Get service name for logging
        $service = yatoon_db_get_row($wpdb->prepare(
            "SELECT name FROM $table_services WHERE id = %d", $service_id
        ));
        $service_name = $service ? $service->name : "Unknown ($service_id)";

        // Check if this service has specific staff assigned
        $service_staff = yatoon_db_get_results($wpdb->prepare("
            SELECT DISTINCT s.*
            FROM $table_staff s
            INNER JOIN $table_staff_services ss ON s.id = ss.staff_id
            WHERE s.is_active = 1
            AND s.is_bookable = 1
            AND ss.service_id = %d
            ORDER BY s.sort_order ASC, s.name ASC
        ", $service_id));

        if (!empty($service_staff)) {
            // Service has assigned staff - show only them
            $staff = $service_staff;
        } else {
            // Service has no staff assignment - show all bookable staff (fallback)
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $staff = yatoon_db_get_results("
                SELECT * FROM $table_staff
                WHERE is_active = 1 AND is_bookable = 1
                ORDER BY sort_order ASC, name ASC
            ");
        }

        $requested_location_id = absint( $_POST['yatoon_location_id'] ?? 0 );
        if ( class_exists( 'YATOON_Staff_Locations' ) ) {
            $staff = YATOON_Staff_Locations::filter_staff( (array) $staff, $service_id, $requested_location_id );
        }

        // Only add "Any staff" option when there are 2+ staff members
        if (count($staff) >= 2) {
            $any_staff = (object) array(
                'id' => 0,
                'name' => 'Any staff',
                'is_any_staff' => true,
                'is_active' => 1,
                'is_bookable' => 1,
            );
            array_unshift($staff, $any_staff);
        }

        // Enrich staff with avg rating and booking count from reviews
        $reviews_table  = $wpdb->prefix . 'yatoon_reviews';
        $bookings_table = $wpdb->prefix . 'yatoon_bookings';
        foreach ($staff as $member) {
            if (empty($member->id) || intval($member->id) === 0) { continue;
            }
            if ( class_exists( 'YATOON_Staff_Locations' ) ) {
                $location_rule = YATOON_Staff_Locations::rule_for( $member->id, $service_id, $requested_location_id );
                $member->price_override = $location_rule && null !== $location_rule->price_override ? (float) $location_rule->price_override : null;
            }
            $member->avg_rating = floatval(yatoon_db_get_var($wpdb->prepare(
                "SELECT AVG(r.rating) FROM $reviews_table r
                 INNER JOIN $bookings_table b ON b.id = r.booking_id
                 WHERE b.staff_id = %d AND r.status = 'approved'",
                intval($member->id)
            )));
            $member->review_count = intval(yatoon_db_get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM $reviews_table r
                 INNER JOIN $bookings_table b ON b.id = r.booking_id
                 WHERE b.staff_id = %d AND r.status = 'approved'",
                intval($member->id)
            )));
            $member->booking_count = intval(yatoon_db_get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM $bookings_table WHERE staff_id = %d AND status NOT IN ('cancelled','CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED','no_show')",
                intval($member->id)
            )));
        }

        wp_send_json_success(array('staff' => $staff));
    }

    /**
     * Get available time slots
     */
    public function get_available_times() {
        check_ajax_referer('yatoon_frontend_nonce', 'nonce');
        if ( function_exists( 'yatoon_public_rate_limit' ) ) {
            yatoon_public_rate_limit( 'available_times', 60, 1 );
        }

        $service_id    = isset($_POST['service_id']) ? intval($_POST['service_id'])        : 0;
        $staff_id      = isset($_POST['staff_id'])   ? intval($_POST['staff_id'])          : 0;
        $date          = isset($_POST['date'])        ? sanitize_text_field( wp_unslash( $_POST['date'] ) ) : '';
        $requested_location_id = absint( $_POST['yatoon_location_id'] ?? 0 );

        if ( $staff_id > 0 && class_exists( 'YATOON_Staff_Locations' ) && ! YATOON_Staff_Locations::is_assigned( $staff_id, $service_id, $requested_location_id ) ) {
            wp_send_json_error( array( 'message' => __( 'This staff member does not offer the service at the selected location.', 'yatoon-booking-system' ), 'no_availability' => true ) );
        }

        // -- Global closed dates check ------------------------------------------
        if ($date) {
            $closed_raw   = get_option('yatoon_closed_dates', '');
        $closed_label = get_option('yatoon_closed_label', __( 'We are closed on this date.', 'yatoon-booking-system' ));
            $closed_list  = array_filter(array_map('trim', explode("\n", $closed_raw)));
            if (in_array($date, $closed_list, true)) {
                wp_send_json_success(array(
                    'times'   => array(),
                    'message' => $closed_label,
                    'closed'  => true,
                ));
                return;
            }
        }
        // -- End closed dates ---------------------------------------------------
        $timezone      = yatoon_timezone_string();
        // Extra minutes from add-on options (e.g. Remove Acrylic = 20 min).
        // Square's availability API only accounts for the main variation duration,
        // so we must filter slots locally to ensure the full window (main + addons) is available.
        $addon_duration = isset($_POST['addon_duration']) ? max(0, intval($_POST['addon_duration'])) : 0;

        // Add-on option IDs - used to do a real-time multi-segment Square availability query
        // so Square itself validates the combined duration fits in the available window.
        $addon_option_ids = array();
        if (!empty($_POST['addon_option_ids'])) {
            $raw_addon = json_decode(stripslashes($_POST['addon_option_ids']), true);
            if (is_array($raw_addon)) {
                $addon_option_ids = array_map('intval', $raw_addon);
            }
        }

        // Total appointment duration hint (sum of all services' durations) sent from frontend.
        // Used by the latest-end-time cutoff filter below. 0 = unknown, fallback to per-service lookup.
        $total_duration_hint = isset($_POST['total_duration']) ? max(0, intval($_POST['total_duration'])) : 0;

        // -- Latest-end-time cutoff ---------------------------------------------
        // No appointment should end later than this time (configurable, default 8:00 PM).
        // Applied to every time-slot array before it is returned to the frontend.
        $latest_end_hour   = intval(get_option('yatoon_latest_end_hour', 20));   // 20 = 8 PM
        $latest_end_minute = intval(get_option('yatoon_latest_end_minute', 0));

        /**
         * Filter a times array, removing any slot whose end time (start + duration) is
         * after the configured latest-end cutoff.
         *
         * @param array $times          Array of ['time'=>'H:i', 'display'=>..., 'start_at'=>...].
         * @param int   $duration_min   Total appointment duration in minutes.
         * @param string $date          'Y-m-d' string for the day.
         * @return array Filtered times.
         */
        $apply_cutoff = function ( array $times, int $duration_min, string $slot_date ) use ( $latest_end_hour, $latest_end_minute, $timezone ) {
            if ($duration_min <= 0 || empty($times)) { return $times;
            }
            $tz_obj = new DateTimeZone( $timezone );
            $cutoff = new DateTime($slot_date . ' ' . str_pad($latest_end_hour, 2, '0', STR_PAD_LEFT) . ':' . str_pad($latest_end_minute, 2, '0', STR_PAD_LEFT) . ':00', $tz_obj);
            $filtered = array();
            foreach ($times as $slot) {
                try {
                    $start  = new DateTime($slot['start_at']);
                    $start->setTimezone($tz_obj);
                    $end    = clone $start;
                    $end->modify("+{$duration_min} minutes");
                    if ($end <= $cutoff) {
                        $filtered[] = $slot;
                    }
                } catch (Exception $e) {
                    $filtered[] = $slot; // keep on parse error
                }
            }
            return $filtered;
        };

        // -- Multi-staff intersection: when multiple staff are selected for a
        //    multi-service appointment, only show times ALL staff are free ------
        $all_staff_ids_raw = isset($_POST['all_staff_ids']) ? stripslashes($_POST['all_staff_ids']) : '';
        $all_staff_ids     = array();
        if (!empty($all_staff_ids_raw)) {
            $decoded = json_decode($all_staff_ids_raw, true);
            if (is_array($decoded) && count($decoded) > 1) {
                $all_staff_ids = array_map('intval', $decoded);
            }
        }
        // Also parse all_option_ids per staff (parallel array of option_ids JSON strings)
        $all_option_ids_raw = isset($_POST['all_option_ids']) ? stripslashes($_POST['all_option_ids']) : '';
        $all_option_ids_arr = array(); // array of arrays, one per staff
        if (!empty($all_option_ids_raw)) {
            $decoded = json_decode($all_option_ids_raw, true);
            if (is_array($decoded)) {
                $all_option_ids_arr = $decoded;
            }
        }

        // Per-staff service IDs - fallback when option variation lookup returns empty
        $all_service_ids_raw = isset($_POST['all_service_ids']) ? stripslashes($_POST['all_service_ids']) : '';
        $all_service_ids_arr = array(); // array of service_ids, one per staff (same index as all_staff_ids)
        if (!empty($all_service_ids_raw)) {
            $decoded = json_decode($all_service_ids_raw, true);
            if (is_array($decoded)) {
                $all_service_ids_arr = array_map('intval', $decoded);
            }
        }

        // Guest labels distinguish sequential services for one guest from
        // simultaneous services for guests booking together.
        $all_guest_labels_raw = isset($_POST['all_guest_labels']) ? stripslashes($_POST['all_guest_labels']) : '';
        $all_guest_labels_arr = array();
        if (!empty($all_guest_labels_raw)) {
            $decoded = json_decode($all_guest_labels_raw, true);
            if (is_array($decoded)) {
                $all_guest_labels_arr = array_map('sanitize_text_field', $decoded);
            }
        }

        $all_durations_raw = isset($_POST['all_durations']) ? stripslashes($_POST['all_durations']) : '';
        $all_durations_arr = array();
        if (!empty($all_durations_raw)) {
            $decoded = json_decode($all_durations_raw, true);
            if (is_array($decoded)) {
                $all_durations_arr = array_map('intval', $decoded);
            }
        }

        // Parse selected_option_ids early (also used in multi-staff block below)
        $selected_option_ids = array();
        if (!empty($_POST['option_ids'])) {
            $raw_opts = json_decode(stripslashes($_POST['option_ids']), true);
            if (is_array($raw_opts)) {
                $selected_option_ids = array_map('intval', $raw_opts);
            }
        }

        // A primary option that was accidentally grouped as "Add-on" used to be
        // sent in both option_ids and addon_option_ids by 4.6.0. That made the
        // Square request contain the same variation twice and produced a false
        // "No available times" result after a customer selected a staff member.
        // Keep the server defensive so cached copies of the old frontend cannot
        // reintroduce the duplicate segment.
        if ( ! empty( $selected_option_ids ) && ! empty( $addon_option_ids ) ) {
            $addon_option_ids = array_values( array_diff( $addon_option_ids, $selected_option_ids ) );
        }

        // -- Parse cart busy-staff windows sent from frontend -----------------
        // Format: [{ staff_id: int, start_at: ISO-string, duration: int-minutes }, ...]
        // Used to exclude already-occupied named staff from Any-staff candidate pools.
        $cart_busy_windows = array(); // staff_id => [ [startMs, endMs], ... ]
        if (!empty($_POST['cart_busy_staff'])) {
            $raw_busy = json_decode(stripslashes($_POST['cart_busy_staff']), true);
            if (is_array($raw_busy)) {
                foreach ($raw_busy as $w) {
                    $sid = isset($w['staff_id']) ? intval($w['staff_id']) : 0;
                    $dur = isset($w['duration']) ? intval($w['duration']) : 0;
                    if ($sid <= 0 || empty($w['start_at'])) { continue;
                    }
                    try {
                        $wStart = new DateTime($w['start_at']);
                        $wEnd   = clone $wStart;
                        $wEnd->modify("+{$dur} minutes");
                        if (!isset($cart_busy_windows[ $sid ])) { $cart_busy_windows[ $sid ] = array();
                        }
                        $cart_busy_windows[ $sid ][] = array(
                            'startTs' => $wStart->getTimestamp(),
                            'endTs'   => $wEnd->getTimestamp(),
                        );
                    } catch (Exception $e) {}
                }
            }
        }

        /**
         * Check if a staff member is busy (has a cart appointment) during a given window.
         * @param int $staff_id
         * @param int $slotStartTs  Unix timestamp of candidate slot start
         * @param int $durationMin  Duration of the service in minutes
         * @return bool
         */
        $is_staff_cart_busy = function ( $staff_id, $slotStartTs, $durationMin ) use ( $cart_busy_windows ) {
            if (empty($cart_busy_windows[ $staff_id ])) { return false;
            }
            $wEnd = $slotStartTs + $durationMin * 60;
            foreach ($cart_busy_windows[ $staff_id ] as $iv) {
                if ($slotStartTs < $iv['endTs'] && $wEnd > $iv['startTs']) { return true;
                }
            }
            return false;
        };

        if (!$service_id || !$date) {
            wp_send_json_error(array('message' => __('Missing required parameters', 'yatoon-booking-system')));
        }

        // -- Shared: reject dates in the past ---------------------------------
        $timezone_str_early = yatoon_timezone_string();
        $tz_now_early       = new DateTimeZone($timezone_str_early);
        $now_early          = new DateTime('now', $tz_now_early);
        $today_str_early    = $now_early->format('Y-m-d');

        if ($date < $today_str_early) {
            wp_send_json_error(array(
                'message'         => __('This date has already passed.', 'yatoon-booking-system'),
                'no_availability' => true,
            ));
        }

        // Route based on active platform
        $platform = sanitize_key((string) get_option('yatoon_active_platform', 'local'));
        if (!in_array($platform, array('local', 'square', 'vagaro'), true)) {
            $platform = 'local';
        }

        $external_closed_day_message = function () use ( $platform, $date, $timezone ) {
            static $checked = false;
            static $message = null;

            if ($checked) {
                return $message;
            }
            $checked = true;

            $closed_text = __('No online times are available for this date. We may be closed or fully booked. Please choose another date or contact us.', 'yatoon-booking-system');

            if ($platform === 'square' && is_object($this->square_api) && method_exists($this->square_api, 'is_location_closed_on')) {
                $square_closed = $this->square_api->is_location_closed_on($date, $timezone);
                if ($square_closed === true) {
                    $message = $closed_text;
                    return $message;
                }
            }

            if (in_array($platform, array('square', 'vagaro'), true)
                && class_exists('YATOON_Business_Hours')
                && get_option('yatoon_external_closed_day_fallback', '1') === '1'
                && YATOON_Business_Hours::get_day_hours($date) === false
            ) {
                $message = $closed_text;
            }

            return $message;
        };

        // -- VAGARO MODE -------------------------------------------------------
        if ($platform === 'vagaro') {
            if (!class_exists('YATOON_Vagaro_API')) {
                require_once SBS_PLUGIN_DIR . 'includes/class-yatoon-vagaro-api.php';
            }
            $vagaro = new YATOON_Vagaro_API();

            // Get the Vagaro service_id and employee_id for this booking
            global $wpdb;
            $svc = yatoon_db_get_row($wpdb->prepare(
                "SELECT square_id FROM {$wpdb->prefix}yatoon_services WHERE id = %d", $service_id
            )); // square_id column stores the external platform ID for all platforms
            $vagaro_service_id = $svc ? $svc->square_id : '';

            $vagaro_employee_id = null;
            if ($staff_id) {
                $stf = yatoon_db_get_row($wpdb->prepare(
                    "SELECT square_id FROM {$wpdb->prefix}yatoon_staff WHERE id = %d", $staff_id
                ));
                if ($stf) { $vagaro_employee_id = $stf->square_id;
                }
            }

            if (empty($vagaro_service_id)) {
                wp_send_json_error(array(
                    'message'         => __('Unable to check availability for this service. Please call us to book.', 'yatoon-booking-system'),
                    'no_availability' => true,
                ));
            }

            $resp = $vagaro->get_availability($vagaro_service_id, $vagaro_employee_id, $date);

            if (is_wp_error($resp)) {
                wp_send_json_error(array(
                    'message'         => ( $external_closed_day_message() ?: __('No available times on this date.', 'yatoon-booking-system') ),
                    'no_availability' => true,
                ));
            }

            // Vagaro returns times in response - normalise to our format
            $timezone   = yatoon_timezone_string();
            $times      = array();
            $slots_raw  = $resp['slots'] ?? $resp['availabilities'] ?? $resp['times'] ?? array();

            foreach ($slots_raw as $slot) {
                $time_str = $slot['startTime'] ?? $slot['start_at'] ?? $slot['time'] ?? '';
                if (!$time_str) { continue;
                }
                try {
                    $t = new DateTime($time_str);
                    $t->setTimezone(new DateTimeZone($timezone));
                    $times[] = array(
                        'time'     => $t->format('H:i'),
                        'display'  => $t->format('g:i A'),
                        'start_at' => $time_str,
                    );
                } catch (Exception $e) {}
            }

            if (empty($times)) {
                wp_send_json_error(array(
                    'message'         => ( $external_closed_day_message() ?: __('No available times on this date.', 'yatoon-booking-system') ),
                    'no_availability' => true,
                ));
            }

            wp_send_json_success(array(
                'times' => $times,
                'count' => count($times),
                'mode'  => 'vagaro',
            ));
        }

        // -- LOCAL MODE --------------------------------------------------------
        if ($platform === 'local') {
            $day_hours_local = YATOON_Business_Hours::get_day_hours($date);
            if (!$day_hours_local) {
                wp_send_json_error(array(
                    'message'         => __('We are closed on this day. Please choose another date.', 'yatoon-booking-system'),
                    'no_availability' => true,
                ));
            }

	            $local_duration_override = ( $total_duration_hint > 0 && empty($all_service_ids_arr) ) ? $total_duration_hint : null;
	            $slots = YATOON_Business_Hours::generate_slots($date, $service_id, $staff_id, $local_duration_override);

            if (!empty($all_staff_ids) && count($all_staff_ids) > 1 && !empty($all_service_ids_arr)) {
                global $wpdb;
                $local_tz_obj = new DateTimeZone( $timezone );
                $table_staff_services = $wpdb->prefix . 'yatoon_staff_services';
                $table_staff = $wpdb->prefix . 'yatoon_staff';
                $table_bookings = $wpdb->prefix . 'yatoon_bookings';

                $duration_for_service = function ( $idx, $svc_id ) use ( $wpdb, $all_durations_arr ) {
                    if (isset($all_durations_arr[ $idx ]) && intval($all_durations_arr[ $idx ]) > 0) {
                        return max(1, intval($all_durations_arr[ $idx ]));
                    }
                    $svc_dur = yatoon_db_get_var($wpdb->prepare(
                        "SELECT duration_minutes FROM {$wpdb->prefix}yatoon_services WHERE id = %d LIMIT 1",
                        intval($svc_id)
                    ));
                    return max(1, intval($svc_dur ?: 60));
                };

                // 4.6.1 availability cache.
                //
                // The closures below are called once per candidate staff member
                // per time slot per service segment. With a 2-service cart,
                // "any staff" across 4 staff and 18 slots that was ~84 queries;
                // with multi-location rules on it measured 618 for a single
                // page of times, none of them cached. Every one of those
                // answers is constant for the duration of one request, so they
                // are memoized here and the per-window booking lookup is
                // replaced by one query per (staff, day) whose result is then
                // scanned in memory.
                $availability_cache = array(
                    'staff'    => array(),
                    'hours'    => array(),
                    'bookings' => array(),
                );

                $eligible_local_staff = function ( $svc_id, $selected_staff_id ) use ( $wpdb, $table_staff_services, $table_staff, $requested_location_id, &$availability_cache ) {
                    $selected_staff_id = intval($selected_staff_id);
                    $memo_key = intval($svc_id) . '|' . $selected_staff_id;
                    if ( isset( $availability_cache['staff'][ $memo_key ] ) ) {
                        return $availability_cache['staff'][ $memo_key ];
                    }
                    if ($selected_staff_id > 0) {
                        $resolved = class_exists( 'YATOON_Staff_Locations' ) && ! YATOON_Staff_Locations::is_assigned( $selected_staff_id, $svc_id, $requested_location_id ) ? array() : array($selected_staff_id);
                        $availability_cache['staff'][ $memo_key ] = $resolved;
                        return $resolved;
                    }
                    $assigned = yatoon_db_get_col($wpdb->prepare(
                        "SELECT s.id
                           FROM $table_staff s
                           INNER JOIN $table_staff_services ss ON s.id = ss.staff_id
                          WHERE ss.service_id = %d
                            AND s.is_active = 1
                            AND s.is_bookable = 1
                          ORDER BY s.sort_order ASC, s.name ASC",
                        intval($svc_id)
                    ));
                    if (empty($assigned)) {
                        $assigned = yatoon_db_get_col(
                            "SELECT id FROM $table_staff WHERE is_active = 1 AND is_bookable = 1 ORDER BY sort_order ASC, name ASC"
                        );
                    }
                    $assigned = array_map('intval', (array) $assigned);
                    if ( class_exists( 'YATOON_Staff_Locations' ) ) {
                        $assigned = array_values( array_filter( $assigned, function ( $candidate ) use ( $svc_id, $requested_location_id ) {
                            return YATOON_Staff_Locations::is_assigned( $candidate, $svc_id, $requested_location_id );
                        } ) );
                    }
                    $availability_cache['staff'][ $memo_key ] = $assigned;
                    return $assigned;
                };

                $staff_window_is_free = function ( $staff_id, $service_for_window, DateTime $win_start, DateTime $win_end, array $assigned_windows ) use ( $wpdb, $table_bookings, $local_tz_obj, $requested_location_id, &$availability_cache ) {
                    if ( class_exists( 'YATOON_Staff_Locations' ) && ! YATOON_Staff_Locations::allows( $staff_id, $service_for_window, $requested_location_id, $win_start, $win_end ) ) {
                        return false;
                    }
                    $slot_date = $win_start->format('Y-m-d');
                    if ( ! array_key_exists( $slot_date, $availability_cache['hours'] ) ) {
                        $availability_cache['hours'][ $slot_date ] = YATOON_Business_Hours::get_day_hours($slot_date);
                    }
                    $day_hours = $availability_cache['hours'][ $slot_date ];
                    if (!$day_hours) {
                        return false;
                    }

                    $open_dt  = new DateTime($slot_date . ' ' . ( $day_hours['start'] ?? '09:00' ), $local_tz_obj);
                    $close_dt = new DateTime($slot_date . ' ' . ( $day_hours['end'] ?? '17:00' ), $local_tz_obj);
                    if ($win_start < $open_dt || $win_end > $close_dt) {
                        return false;
                    }

                    if (!empty($day_hours['break_start']) && !empty($day_hours['break_end'])) {
                        $break_start = new DateTime($slot_date . ' ' . $day_hours['break_start'], $local_tz_obj);
                        $break_end   = new DateTime($slot_date . ' ' . $day_hours['break_end'], $local_tz_obj);
                        if ($win_start < $break_end && $win_end > $break_start) {
                            return false;
                        }
                    }

                    foreach ($assigned_windows as $assigned) {
                        if (intval($assigned['staff_id']) === intval($staff_id) && $win_start < $assigned['end'] && $win_end > $assigned['start']) {
                            return false;
                        }
                    }

                    // One query per (staff member, calendar day) instead of one
                    // per candidate window. 'Y-m-d H:i:s' sorts lexically, so
                    // the overlap test below is exactly the SQL predicate it
                    // replaces.
                    $window_start_sql = $win_start->format('Y-m-d H:i:s');
                    $window_end_sql   = $win_end->format('Y-m-d H:i:s');
                    $day_from         = $win_start->format('Y-m-d') . ' 00:00:00';
                    $day_to           = $win_end->format('Y-m-d') . ' 23:59:59';
                    $bookings_key     = intval($staff_id) . '|' . $win_start->format('Y-m-d') . '|' . $win_end->format('Y-m-d');
                    if ( ! isset( $availability_cache['bookings'][ $bookings_key ] ) ) {
                        $availability_cache['bookings'][ $bookings_key ] = (array) yatoon_db_get_results($wpdb->prepare(
                            "SELECT start_time, end_time FROM $table_bookings
                              WHERE staff_id = %d
                                AND status NOT IN ('cancelled','cancelled_by_customer','CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED','no_show')
                                AND start_time < %s
                                AND end_time > %s",
                            intval($staff_id),
                            $day_to,
                            $day_from
                        ), ARRAY_A);
                    }
                    foreach ( $availability_cache['bookings'][ $bookings_key ] as $busy ) {
                        if ( (string) $busy['start_time'] < $window_end_sql && (string) $busy['end_time'] > $window_start_sql ) {
                            return false;
                        }
                    }

                    // 4.4.3: external calendar busy time (two-way Google sync).
                    if (YATOON_Business_Hours::has_external_busy_conflict(intval($staff_id), $win_start, $win_end)) {
                        return false;
                    }

                    return !YATOON_Business_Hours::has_staff_time_off_conflict(intval($staff_id), $win_start, $win_end, $local_tz_obj);
                };

                $segments = array();
                $guest_offsets = array();
                foreach ($all_service_ids_arr as $idx => $svc_id_for_segment) {
                    $svc_id_for_segment = intval($svc_id_for_segment);
                    if ($svc_id_for_segment <= 0) {
                        continue;
                    }
                    $guest_label = isset($all_guest_labels_arr[ $idx ]) && $all_guest_labels_arr[ $idx ] !== '' ? $all_guest_labels_arr[ $idx ] : 'Me';
                    $offset = isset($guest_offsets[ $guest_label ]) ? intval($guest_offsets[ $guest_label ]) : 0;
                    $duration = $duration_for_service($idx, $svc_id_for_segment);
                    $guest_offsets[ $guest_label ] = $offset + $duration;
                    $segments[] = array(
                        'service_id' => $svc_id_for_segment,
                        'staff_id'   => isset($all_staff_ids[ $idx ]) ? intval($all_staff_ids[ $idx ]) : 0,
                        'offset'     => $offset,
                        'duration'   => $duration,
                    );
                }

                $whole_cart_slot_is_available = function ( $slot ) use ( $segments, $eligible_local_staff, $staff_window_is_free, $local_tz_obj ) {
                    if (empty($segments)) {
                        return true;
                    }
                    try {
                        $slot_start = new DateTime($slot['start_at']);
                        $slot_start->setTimezone($local_tz_obj);
                    } catch (Exception $e) {
                        return false;
                    }

                    $windows = array();
                    foreach ($segments as $segment) {
                        $win_start = clone $slot_start;
                        if ($segment['offset'] > 0) {
                            $win_start->modify('+' . intval($segment['offset']) . ' minutes');
                        }
                        $win_end = clone $win_start;
                        $win_end->modify('+' . intval($segment['duration']) . ' minutes');
                        $candidates = $eligible_local_staff($segment['service_id'], $segment['staff_id']);
                        if (empty($candidates)) {
                            return false;
                        }
                        $windows[] = array(
                            'start'      => $win_start,
                            'end'        => $win_end,
                            'service_id' => $segment['service_id'],
                            'candidates' => $candidates,
                        );
                    }

                    $try_assign = function ( $idx, $assigned_windows ) use ( &$try_assign, $windows, $staff_window_is_free ) {
                        if ($idx >= count($windows)) {
                            return true;
                        }
                        foreach ($windows[ $idx ]['candidates'] as $candidate_staff_id) {
                            if (!$staff_window_is_free($candidate_staff_id, $windows[ $idx ]['service_id'], $windows[ $idx ]['start'], $windows[ $idx ]['end'], $assigned_windows)) {
                                continue;
                            }
                            $next_assigned = $assigned_windows;
                            $next_assigned[] = array(
                                'staff_id' => intval($candidate_staff_id),
                                'start'    => $windows[ $idx ]['start'],
                                'end'      => $windows[ $idx ]['end'],
                            );
                            if ($try_assign($idx + 1, $next_assigned)) {
                                return true;
                            }
                        }
                        return false;
                    };

                    return $try_assign(0, array());
                };

                $slots = array_values(array_filter($slots, $whole_cart_slot_is_available));
            }
            if ( class_exists( 'YATOON_Staff_Locations' ) && $staff_id > 0 ) {
                $duration_for_rule = $total_duration_hint > 0 ? $total_duration_hint : absint( yatoon_db_get_var( $wpdb->prepare( "SELECT duration_minutes FROM {$wpdb->prefix}yatoon_services WHERE id=%d", $service_id ) ) );
                $slots = YATOON_Staff_Locations::filter_slots( $slots, $staff_id, $service_id, $requested_location_id, $duration_for_rule, $date );
            }
            if (empty($slots)) {
                $msg = ( $date === $today_str_early )
                    ? __('No more available times today. Please choose another date.', 'yatoon-booking-system')
                    : __('No available times on this date. All slots may be booked.', 'yatoon-booking-system');
                wp_send_json_error(array('message' => $msg, 'no_availability' => true));
            }

            wp_send_json_success(array(
                'times' => $slots,
                'count' => count($slots),
                'mode'  => 'local',
            ));
        }

        // -- SQUARE MODE below - local business hours are NOT consulted --------

        // ------------------------------------------------------------
        // MULTI-STAFF INTERSECTION MODE (including Any Staff expansion)
        //
        // Handles two scenarios:
        // A) All staff are named -> find times ALL are free simultaneously
        // B) Some staff = "Any staff" (id=0) -> expand to all eligible staff,
        //    find a valid assignment where all services can be covered at once.
        //    e.g. Staff A is booked for svc1, "Any staff" for svc2:
        //    -> check if that staff (or any other available staff) can cover svc2
        //      at the same start time AFTER finishing svc1.
        //    Square handles sequential booking natively - if the staff has a slot
        //    at T that's long enough for BOTH services, Square will show it.
        //    But if the staff is only free for svc1's duration, the slot won't appear
        //    for svc2 - so "Any staff" must be resolved to a real staff member
        //    who has enough time, not just whoever Square returns for "null".
        // ------------------------------------------------------------
        if (!empty($all_staff_ids) && count($all_staff_ids) > 1) {
            global $wpdb;
            $table_staff_ms    = $wpdb->prefix . 'yatoon_staff';
            $table_options_ms  = $wpdb->prefix . 'yatoon_service_options';
            $table_ss_ms       = $wpdb->prefix . 'yatoon_staff_services';

            $timezone_ms   = yatoon_timezone_string();
            $tz_obj_ms     = new DateTimeZone($timezone_ms);
            $date_start_ms = ( new DateTime($date . ' 00:00:00', $tz_obj_ms) )->format('c');
            $date_end_ms   = ( new DateTime($date . ' 23:59:59', $tz_obj_ms) )->format('c');

            // -- Helper: fetch Square slots for one staff + one variation list -
            $get_staff_slots = function ( $sq_staff_id, $variations, $date_s, $date_e ) use ( $tz_obj_ms ) {
                $slots = array();
                foreach ($variations as $var_id) {
                    $resp = $this->square_api->search_availability($var_id, $date_s, $date_e, $sq_staff_id);
                    if (is_wp_error($resp) || !isset($resp['availabilities'])) { continue;
                    }
                    foreach ($resp['availabilities'] as $av) {
                        if (empty($av['start_at'])) { continue;
                        }
                        $t = new DateTime($av['start_at']);
                        $t->setTimezone($tz_obj_ms);
                        $key = $t->format('H:i');
                        if (!isset($slots[ $key ])) { $slots[ $key ] = $av['start_at'];
                        }
                    }
                    if (!empty($slots)) { break;
                    }
                }
                return $slots;
            };

            // -- Helper: get variation square_ids for a list of option DB IDs -
            $get_variations = function ( $opt_ids, $fallback_service_id ) use ( $wpdb, $table_options_ms ) {
                $vars = array();
                if (!empty($opt_ids)) {
                    $clean = array_map('intval', (array) $opt_ids);
                    $ph    = implode(',', array_fill(0, count($clean), '%d'));
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    $rows  = yatoon_db_get_results(
                        $wpdb->prepare("SELECT square_id FROM $table_options_ms WHERE id IN ($ph) AND square_id != ''", ...$clean)
                    );
                    foreach ($rows as $r) { $vars[] = $r->square_id;
                    }
                }
                if (empty($vars) && $fallback_service_id) {
                    $rows = yatoon_db_get_results($wpdb->prepare(
                        "SELECT square_id FROM $table_options_ms WHERE service_id = %d AND square_id != '' LIMIT 3",
                        $fallback_service_id
                    ));
                    foreach ($rows as $r) { $vars[] = $r->square_id;
                    }
                }
                return $vars;
            };

            // -- Helper: get all active bookable staff who can do a service ----
            // First tries yatoon_staff_services for precise assignment; if the table has no
            // entries for this service (e.g. after a sync that missed some services),
            // falls back to ALL active bookable staff so availability is never blocked by
            // a missing DB row - Square's own availability API is the true gatekeeper.
            $get_eligible_staff = function ( $svc_id ) use ( $wpdb, $table_staff_ms, $table_ss_ms ) {
                $assigned = yatoon_db_get_results($wpdb->prepare("
                    SELECT s.id, s.square_id, s.name
                    FROM $table_staff_ms s
                    INNER JOIN $table_ss_ms ss ON s.id = ss.staff_id
                    WHERE s.is_active = 1 AND s.is_bookable = 1 AND ss.service_id = %d
                    ORDER BY s.sort_order ASC, s.name ASC
                ", $svc_id));
                if (!empty($assigned)) { return $assigned;
                }
                // Fallback: yatoon_staff_services has no rows for this service.
                // Return all bookable staff - Square's availability API will filter
                // to only those who actually have the service in their booking profile.
                yatoon_safe_log("SBS get_eligible_staff: yatoon_staff_services empty for service_id=$svc_id - falling back to all bookable staff");
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                return yatoon_db_get_results(
                    "SELECT id, square_id, name FROM $table_staff_ms WHERE is_active=1 AND is_bookable=1 ORDER BY sort_order ASC, name ASC"
                );
            };

            $get_position_duration = function ( $opt_ids, $fallback_service_id ) use ( $wpdb ) {
                $opt_ids = is_array($opt_ids) ? $opt_ids : array();
                if (!empty($opt_ids)) {
                    $opt_dur = yatoon_db_get_var($wpdb->prepare(
                        "SELECT duration_minutes FROM {$wpdb->prefix}yatoon_service_options WHERE id = %d LIMIT 1",
                        intval($opt_ids[0])
                    ));
                    if ($opt_dur) {
                        return max(1, intval($opt_dur));
                    }
                }

                $svc_dur = yatoon_db_get_var($wpdb->prepare(
                    "SELECT duration_minutes FROM {$wpdb->prefix}yatoon_services WHERE id = %d LIMIT 1",
                    intval($fallback_service_id)
                ));

                return $svc_dur ? max(1, intval($svc_dur)) : 60;
            };

            // -- Check if any staff_id = 0 (Any staff) in the list -------------
            $has_any_staff = in_array(0, $all_staff_ids, true);

            if (!$has_any_staff) {
                // -------------------------------------------------------------
                // CASE A: All staff are explicitly named.
                // Group services by staff member, then query each staff with ALL
                // their services as one multi-segment request so Square validates
                // the combined duration (e.g. Staff: 40min + 30min = 70min)
                // against her actual availability window in a single API call.
                // -------------------------------------------------------------

                // Build per-guest segment lists. Each guest's services are sequential,
                // while separate guests must share the same appointment start time.
                $guest_segments = array(); // guest_label => [segment, ...]
                $guest_offsets  = array();
                $staff_windows  = array();
                foreach ($all_staff_ids as $s_idx => $s_id) {
                    $s_row = yatoon_db_get_row($wpdb->prepare("SELECT square_id FROM $table_staff_ms WHERE id = %d", $s_id));
                    if (!$s_row || empty($s_row->square_id)) { continue;
                    }
                    $s_opts = isset($all_option_ids_arr[ $s_idx ]) ? (array) $all_option_ids_arr[ $s_idx ] : array();
                    $s_fallback_svc = isset($all_service_ids_arr[ $s_idx ]) && $all_service_ids_arr[ $s_idx ] > 0
                        ? $all_service_ids_arr[ $s_idx ]
                        : $service_id;
                    $s_vars = $get_variations($s_opts, $s_fallback_svc);
                    if (empty($s_vars)) { continue;
                    }

                    $guest_label = isset($all_guest_labels_arr[ $s_idx ]) && $all_guest_labels_arr[ $s_idx ] !== ''
                        ? $all_guest_labels_arr[ $s_idx ]
                        : 'Me';
                    if (!isset($guest_offsets[ $guest_label ])) {
                        $guest_offsets[ $guest_label ] = 0;
                    }
                    $pos_offset   = intval($guest_offsets[ $guest_label ]);
                    $pos_duration = isset($all_durations_arr[ $s_idx ]) && intval($all_durations_arr[ $s_idx ]) > 0
                        ? max(1, intval($all_durations_arr[ $s_idx ]))
                        : $get_position_duration($s_opts, $s_fallback_svc);
                    $guest_offsets[ $guest_label ] += $pos_duration;

                    if (!isset($staff_windows[ $s_id ])) {
                        $staff_windows[ $s_id ] = array();
                    }
                    foreach ($staff_windows[ $s_id ] as $window) {
                        if ($pos_offset < $window['end'] && ( $pos_offset + $pos_duration ) > $window['start']) {
                            wp_send_json_error(array(
                                'message'         => __('One staff member cannot serve multiple guests at the same start time. Please choose different staff or Any staff.', 'yatoon-booking-system'),
                                'no_availability' => true,
                            ));
                        }
                    }
                    $staff_windows[ $s_id ][] = array(
                        'start' => $pos_offset,
                        'end'   => $pos_offset + $pos_duration,
                    );

                    if (!isset($guest_segments[ $guest_label ])) {
                        $guest_segments[ $guest_label ] = array();
                    }
                    foreach ($s_vars as $v) {
                        $guest_segments[ $guest_label ][] = array(
                            'variation_id'   => $v,
                            'team_member_id' => $s_row->square_id,
                        );
                    }
                }

                if (!empty($guest_segments)) {
                    $times_per_guest = array();
                    foreach ($guest_segments as $guest_label => $segments) {
                        $resp  = $this->square_api->search_availability_multi_segment($segments, $date_start_ms, $date_end_ms);
                        $slots = array();
                        if (!is_wp_error($resp) && isset($resp['availabilities'])) {
                            foreach ($resp['availabilities'] as $av) {
                                if (empty($av['start_at'])) { continue;
                                }
                                $t = new DateTime($av['start_at']);
                                $t->setTimezone($tz_obj_ms);
                                $key = $t->format('H:i');
                                if (!isset($slots[ $key ])) { $slots[ $key ] = $av['start_at'];
                                }
                            }
                        }
                        yatoon_safe_log("SBS Multi-Guest(A multi-seg) - guest=$guest_label segs=" . count($segments) . ' slots=' . count($slots));
                        $times_per_guest[ $guest_label ] = $slots;
                    }

                    // Intersect across guests so every guest can begin together.
                    $intersection = reset($times_per_guest);
                    foreach ($times_per_guest as $slots) {
                        $intersection = array_intersect_key($intersection, $slots);
                    }
                    ksort($intersection);

                    if (empty($intersection)) {
                        wp_send_json_error(array(
                            'message'         => ( $external_closed_day_message() ?: __('We could not find a time when all selected staff are available together.', 'yatoon-booking-system') ),
                            'no_availability' => true,
                        ));
                    }

                    $final_times = array();
                    foreach ($intersection as $key => $start_at) {
                        $t = new DateTime($start_at);
                        $t->setTimezone($tz_obj_ms);
                        $final_times[] = array('time' => $key, 'display' => $t->format('g:i A'), 'start_at' => $start_at);
                    }
                    $final_times = $apply_cutoff($final_times, $total_duration_hint, $date);
                    if (empty($final_times)) {
                        wp_send_json_error(array('message' => __('No available times - all remaining slots would end after our closing time.', 'yatoon-booking-system'), 'no_availability' => true));
                    }
                    wp_send_json_success(array('times' => $final_times, 'count' => count($final_times), 'mode' => 'square_multi_staff'));
                }
                // fall through to single-staff query if no staff_segments

            } else {
                // -------------------------------------------------------------
                // CASE B: Mixed - some staff named, at least one is "Any staff"
                //
                // SEQUENTIAL MODEL (mirrors Square's own logic):
                // Services run one after another in booking order. For booking
                // start time T:
                //   - Position 0 runs  T -> T+dur0          (staffA)
                //   - Position 1 runs  T+dur0 -> T+dur0+dur1 (staffB or staffA)
                //   - etc.
                //
                // For "Any staff" positions we enumerate all eligible staff
                // combinations and use Square's multi-segment API with the FULL
                // ordered segment list (named + any, in position order). Square
                // validates each staff's window at the correct sequential offset.
                //
                // This is exactly how Square's widget works - e.g. Dipping Powder
                // 11:15 (Staff A, 35 min) + Acrylic Fill-in 12:00 (Staff B, 40 min) ->
                // shows 11:15 as a valid slot.
                // -------------------------------------------------------------

                // Step 1: Parse each position - named vs any-staff
                // ordered_positions[s_idx] = ['type' => 'named'|'any', 'staff_id'=>...,
                //   'sq_id'=>..., 'variations'=>[...], 'duration'=>..., 'candidates'=>...]
                $ordered_positions = array();
                $has_named = false;

                foreach ($all_staff_ids as $s_idx => $s_id) {
                    $s_opts = isset($all_option_ids_arr[ $s_idx ]) ? (array) $all_option_ids_arr[ $s_idx ] : array();
                    $s_fallback_svc = isset($all_service_ids_arr[ $s_idx ]) && $all_service_ids_arr[ $s_idx ] > 0
                        ? $all_service_ids_arr[ $s_idx ]
                        : $service_id;
                    $s_vars = $get_variations($s_opts, $s_fallback_svc);
                    $pos_duration = isset($all_durations_arr[ $s_idx ]) && intval($all_durations_arr[ $s_idx ]) > 0
                        ? max(1, intval($all_durations_arr[ $s_idx ]))
                        : $get_position_duration($s_opts, $s_fallback_svc);

                    if ($s_id === 0) {
                        // Any staff position
                        $svc_for_any = $s_fallback_svc;
                        if (!empty($s_opts)) {
                            $opt_svc = yatoon_db_get_var($wpdb->prepare(
                                "SELECT service_id FROM {$wpdb->prefix}yatoon_service_options WHERE id = %d LIMIT 1",
                                intval($s_opts[0])
                            ));
                            if ($opt_svc) { $svc_for_any = intval($opt_svc);
                            }
                        }
                        $candidates = $get_eligible_staff($svc_for_any);
                        $ordered_positions[ $s_idx ] = array(
                            'type'       => 'any',
                            'variations' => $s_vars,
                            'duration'   => $pos_duration,
                            'candidates' => $candidates,
                            'service_id' => $svc_for_any,
                        );
                    } else {
                        // Named staff position
                        $s_row = yatoon_db_get_row($wpdb->prepare(
                            "SELECT id, square_id, name FROM {$wpdb->prefix}yatoon_staff WHERE id = %d", $s_id
                        ));
                        if (!$s_row || empty($s_row->square_id) || empty($s_vars)) { continue;
                        }
                        $has_named = true;
                        $ordered_positions[ $s_idx ] = array(
                            'type'      => 'named',
                            'staff_id'  => $s_id,
                            'sq_id'     => $s_row->square_id,
                            'name'      => $s_row->name,
                            'variations' => $s_vars,
                            'duration'  => $pos_duration,
                        );
                    }
                }

                if (empty($ordered_positions)) {
                    wp_send_json_error(array('message' => __('No valid services found.', 'yatoon-booking-system'), 'no_availability' => true));
                }

                // Step 2: Pre-compute start times for each position.
                // Same guest = sequential services. Different guests = same start time.
                // and filter out candidates who have DB conflicts at their actual time window.
                $guest_offsets = array();
                foreach ($ordered_positions as $s_idx => $pos) {
                    if ($pos['type'] === 'any') {
                        // We don't know exact start until user picks a time, so we use
                        // the DB check only at booking time (Pass2). At Step 4 we trust Square.
                        // The key fix is: don't allow same staff twice in simultaneous positions.
                        // For sequential (non-overlapping) positions, same staff is OK.
                    }
                    $guest_label = isset($all_guest_labels_arr[ $s_idx ]) && $all_guest_labels_arr[ $s_idx ] !== ''
                        ? $all_guest_labels_arr[ $s_idx ]
                        : 'Me';
                    if (!isset($guest_offsets[ $guest_label ])) {
                        $guest_offsets[ $guest_label ] = 0;
                    }
                    $ordered_positions[ $s_idx ]['guest_label'] = $guest_label;
                    $ordered_positions[ $s_idx ]['cum_offset']  = intval($guest_offsets[ $guest_label ]);
                    $guest_offsets[ $guest_label ] += intval($pos['duration'] ?? 60);
                }

                // Build all candidate combinations to query
                // For named positions, the staff is fixed.
                // For any-positions, enumerate candidates.
                // Separate into any-positions array (ordered by s_idx)
                $any_idxs = array();
                foreach ($ordered_positions as $s_idx => $pos) {
                    if ($pos['type'] === 'any') { $any_idxs[] = $s_idx;
                    }
                }

                // Build combination arrays for any-positions
                // Each combination: [s_idx => candidate_object, ...]
                $combinations = array(array()); // start with one empty combination
                foreach ($any_idxs as $s_idx) {
                    $pos = $ordered_positions[ $s_idx ];
                    $pos_offset = $pos['cum_offset'] ?? 0;
                    $new_combos = array();
                    foreach ($combinations as $combo) {
                        foreach ($pos['candidates'] as $cand) {
                            if (empty($cand->square_id)) { continue;
                            }
                            // For two any-positions at the SAME offset (simultaneous),
                            // do NOT allow the same staff twice.
                            // For sequential positions (different offsets), same staff is OK.
                            $conflict_with_same_offset = false;
                            foreach ($combo as $prev_idx => $prev_cand) {
                                $prev_offset = $ordered_positions[ $prev_idx ]['cum_offset'] ?? -1;
                                if ($prev_offset === $pos_offset && $prev_cand->square_id === $cand->square_id) {
                                    $conflict_with_same_offset = true;
                                    break;
                                }
                            }
                            if ($conflict_with_same_offset) { continue;
                            }
                            $new_combo = $combo;
                            $new_combo[ $s_idx ] = $cand;
                            $new_combos[] = $new_combo;
                        }
                    }
                    $combinations = $new_combos;
                }

                // Deduplicate combinations that would produce identical API queries
                // (same staff assignment for all positions in same order)
                $combo_keys_seen = array();
                $unique_combos = array();
                foreach ($combinations as $combo) {
                    $key_parts = array();
                    foreach ($ordered_positions as $s_idx => $pos) {
                        if ($pos['type'] === 'named') {
                            $key_parts[] = 'n' . $pos['sq_id'];
                        } else {
                            $cand = isset($combo[ $s_idx ]) ? $combo[ $s_idx ] : null;
                            $key_parts[] = 'a' . ( $cand ? $cand->square_id : '?' );
                        }
                    }
                    $ckey = implode('|', $key_parts);
                    if (!isset($combo_keys_seen[ $ckey ])) {
                        $combo_keys_seen[ $ckey ] = true;
                        $unique_combos[] = $combo;
                    }
                }

                // Step 3: For each unique combination, query Square per guest.
                // Each guest's services are sequential; different guests start together.
                $valid_slots           = array(); // 'H:i' => start_at
                $valid_slots_staff_map = array(); // 'H:i' => [s_idx => sq_staff_id]

                foreach ($unique_combos as $combo) {
                    $guest_segments = array();
                    $staff_windows  = array();
                    $slot_staff     = array();
                    $valid_combo    = true;

                    foreach ($ordered_positions as $s_idx => $pos) {
                        if (empty($pos['variations'])) {
                            $valid_combo = false;
break;
                        }
                        if ($pos['type'] === 'named') {
                            $sq_staff_id = $pos['sq_id'];
                        } else {
                            $cand = isset($combo[ $s_idx ]) ? $combo[ $s_idx ] : null;
                            if (!$cand || empty($cand->square_id)) { $valid_combo = false;
break; }
                            $sq_staff_id = $cand->square_id;
                        }

                        $offset   = intval($pos['cum_offset'] ?? 0);
                        $duration = intval($pos['duration'] ?? 60);
                        if (!isset($staff_windows[ $sq_staff_id ])) {
                            $staff_windows[ $sq_staff_id ] = array();
                        }
                        foreach ($staff_windows[ $sq_staff_id ] as $window) {
                            if ($offset < $window['end'] && ( $offset + $duration ) > $window['start']) {
                                $valid_combo = false;
                                break 2;
                            }
                        }
                        $staff_windows[ $sq_staff_id ][] = array('start' => $offset, 'end' => $offset + $duration);

                        $guest_label = isset($pos['guest_label']) && $pos['guest_label'] !== '' ? $pos['guest_label'] : 'Me';
                        if (!isset($guest_segments[ $guest_label ])) {
                            $guest_segments[ $guest_label ] = array();
                        }
                        foreach ($pos['variations'] as $variation_id) {
                            $guest_segments[ $guest_label ][] = array(
                                'variation_id'   => $variation_id,
                                'team_member_id' => $sq_staff_id,
                            );
                        }
                        $slot_staff[ $s_idx ] = $sq_staff_id;
                    }

                    if (!$valid_combo || empty($guest_segments)) { continue;
                    }

                    $combo_intersection = null;
                    foreach ($guest_segments as $guest_label => $segments) {
                        $resp = $this->square_api->search_availability_multi_segment($segments, $date_start_ms, $date_end_ms);
                        if (is_wp_error($resp) || !isset($resp['availabilities'])) {
                            $combo_intersection = array();
                            break;
                        }

                        $guest_slots = array();
                        foreach ($resp['availabilities'] as $av) {
                            if (empty($av['start_at'])) { continue;
                            }
                            $t = new DateTime($av['start_at']);
                            $t->setTimezone($tz_obj_ms);
                            $guest_slots[ $t->format('H:i') ] = $av['start_at'];
                        }

                        $combo_intersection = ( $combo_intersection === null )
                            ? $guest_slots
                            : array_intersect_key($combo_intersection, $guest_slots);
                        if (empty($combo_intersection)) { break;
                        }
                    }

                    if (empty($combo_intersection)) { continue;
                    }

                    foreach ($combo_intersection as $key => $start_at) {
                        // Always record this slot (from all combos) so ALL valid times are shown.
                        if (!isset($valid_slots[ $key ])) {
                            $valid_slots[ $key ] = $start_at;
                        }
                        if (!isset($valid_slots_staff_map[ $key ])) {
                            $valid_slots_staff_map[ $key ] = $slot_staff;
                        }
                    }
                }

                ksort($valid_slots);
                yatoon_safe_log('SBS Multi-Staff(B sequential) - combos=' . count($unique_combos) . ' valid slots=' . count($valid_slots));

                if (empty($valid_slots)) {
                    wp_send_json_error(array(
                        'message'         => ( $external_closed_day_message() ?: __('We could not find a time when all selected staff are available together.', 'yatoon-booking-system') ),
                        'no_availability' => true,
                    ));
                }

                $final_times = array();
                foreach ($valid_slots as $key => $start_at) {
                    $t = new DateTime($start_at);
                    $t->setTimezone($tz_obj_ms);
                    $entry = array('time' => $key, 'display' => $t->format('g:i A'), 'start_at' => $start_at);
                    if (isset($valid_slots_staff_map[ $key ])) {
                        $entry['staff_map'] = $valid_slots_staff_map[ $key ];
                    }
                    $final_times[] = $entry;
                }
                $final_times = $apply_cutoff($final_times, $total_duration_hint, $date);
                if (empty($final_times)) {
                    wp_send_json_error(array('message' => __('No available times - all remaining slots would end after our closing time.', 'yatoon-booking-system'), 'no_availability' => true));
                }
                wp_send_json_success(array('times' => $final_times, 'count' => count($final_times), 'mode' => 'square_any_staff'));
            }
            // fall through to single-staff query if no valid data
        }

        // -- Real-time Square API availability query -----------------------------
        global $wpdb;
        $table_services = $wpdb->prefix . 'yatoon_services';
        $table_staff    = $wpdb->prefix . 'yatoon_staff';

        $service = yatoon_db_get_row($wpdb->prepare("SELECT * FROM $table_services WHERE id = %d", $service_id));
        if (!$service) {
            wp_send_json_error(array('message' => __('Service not found', 'yatoon-booking-system')));
        }

        // CORRECT: yatoon_service_options.square_id = Square ITEM_VARIATION ID (needed for availability search)
        //          yatoon_services.square_id         = Square ITEM ID (parent, NOT usable for availability)
        $table_options = $wpdb->prefix . 'yatoon_service_options';

        // Parse the user-selected option IDs (sent from frontend Step 2)
        $selected_option_ids = array();
        if (!empty($_POST['option_ids'])) {
            $raw = json_decode(stripslashes($_POST['option_ids']), true);
            if (is_array($raw)) {
                $selected_option_ids = array_map('intval', $raw);
            }
        }

        // KEY FIX: If the user selected a specific option (e.g. "Acrylic Full Set" = 120 min),
        // we MUST query Square using ONLY that variation's ID.
        // Square's availability API uses the variation to determine required duration:
        // querying the wrong variation (e.g. "Acrylic Fill-in" = 40 min) returns slots that
        // don't have enough time for the actual service selected.
        //
        // Only fall back to "all variations for this service" when NO option was selected
        // (user didn't pick a sub-option, just the parent service).
        if (!empty($selected_option_ids)) {
            // User selected specific option(s) - use ONLY those variations.
            // ORDER BY sort_order ASC, id ASC ensures the primary (first) option comes first.
            $placeholders = implode(',', array_fill(0, count($selected_option_ids), '%d'));
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $all_variations = yatoon_db_get_results(
                $wpdb->prepare(
                    "SELECT id, square_id, name, duration_minutes FROM $table_options WHERE id IN ($placeholders) AND square_id != '' ORDER BY sort_order ASC, id ASC",
                    ...$selected_option_ids
                )
            );
            yatoon_safe_log('SBS Availability Debug - Using SELECTED option variation(s): ' . implode(', ', array_column((array) $all_variations, 'name')));
        } else {
            // No option selected - get the default/first variation for this service
            $all_variations = yatoon_db_get_results($wpdb->prepare(
                "SELECT id, square_id, name, duration_minutes FROM $table_options WHERE service_id = %d AND square_id != '' ORDER BY id ASC LIMIT 1",
                $service_id
            ));
            yatoon_safe_log('SBS Availability Debug - No option selected, using default variation: ' . ( !empty($all_variations) ? $all_variations[0]->name : 'NONE' ));
        }

        $variation_id = !empty($all_variations) ? $all_variations[0]->square_id : null;

        // -- KEY FIX: single-service multi-option ------------------------------
        // When the user picks multiple options under the SAME service (e.g. "Acrylic Full Set"
        // + "Acrylic on Toe" both stored as options of one service), $all_variations contains
        // more than one variation.  Square's availability API MUST be called with ONLY ONE
        // variation - the PRIMARY one (longest duration = the main service).
        //
        // The "break on first result" loop below was designed for "try multiple candidate
        // variations, use whichever Square knows about". But when TWO semantically different
        // variations are present (main + add-on), whichever happens to be first in the DB
        // order gets queried first; if that's the add-on (short duration, possibly no Square
        // availability on its own), it gets 0 results, falls through to the main - BUT if the
        // add-on DOES have some Square slots (e.g. it's also a standalone service), the break
        // fires on it and the main is never queried, returning wrong/no results.
        //
        // Correct behaviour: sort by duration DESC so the primary (longest) variation is
        // always [0], query ONLY that one, and accumulate the remaining durations into
        // $addon_duration for the local secondary filter.
        if (count($all_variations) > 1) {
            // Sort by duration_minutes DESC - primary service always has the longest duration
            usort($all_variations, function ( $a, $b ) {
                return intval($b->duration_minutes) - intval($a->duration_minutes);
            });
            $extra_duration = 0;
            foreach (array_slice((array) $all_variations, 1) as $extra_var) {
                $extra_duration += intval($extra_var->duration_minutes);
            }
            $addon_duration += $extra_duration;
            $all_variations = array($all_variations[0]); // only the primary (longest) variation
            yatoon_safe_log("SBS Availability Debug - multi-option same service: primary='{$all_variations[0]->name}' ({$all_variations[0]->duration_minutes}min), added {$extra_duration}min to addon_duration (now {$addon_duration}min)");
        }

        // Debug log
        yatoon_safe_log("SBS Availability Debug - service: {$service->name} (id={$service_id}), date={$date}, staff_id={$staff_id}");
        yatoon_safe_log("SBS Availability Debug - service square_id (ITEM): {$service->square_id}");
        yatoon_safe_log('SBS Availability Debug - selected_option_ids: ' . json_encode($selected_option_ids));
        yatoon_safe_log('SBS Availability Debug - variations found: ' . count($all_variations));
        foreach ($all_variations as $v) {
            yatoon_safe_log("  variation: id={$v->id}, name={$v->name}, square_id={$v->square_id}");
        }
        yatoon_safe_log('SBS Availability Debug - using variation_id: ' . ( $variation_id ?: 'NONE' ));

        // If no options exist, the service has no variations - cannot query Square availability
        if (empty($variation_id)) {
            // Square mode: no variation ID means we can't check Square - tell user to call
            wp_send_json_error(array(
                'message'         => __('Unable to check availability for this service. Please call us to book.', 'yatoon-booking-system'),
                'no_availability' => true,
            ));
        }

        $timezone   = yatoon_timezone_string();
        $tz_obj     = new DateTimeZone($timezone);
        $date_start = new DateTime($date . ' 00:00:00', $tz_obj);
        $date_end   = new DateTime($date . ' 23:59:59', $tz_obj);
        $start_date = $date_start->format('c');
        $end_date   = $date_end->format('c');

        // Build the list of Square team member IDs to query.
        // staff_id > 0  -> one specific staff member.
        // staff_id = 0  -> "Any staff": pass the explicit eligible team-member set to
        //                  Square in one request for the common single-service path.
        //                  Never pass null: that would include ineligible staff.
        $staff_square_ids_to_query = array();
        $sq_to_local_id = array(); // square_id -> local DB id, used for cart-busy check
        if ($staff_id > 0) {
            $s = yatoon_db_get_row($wpdb->prepare("SELECT square_id, name FROM $table_staff WHERE id = %d", $staff_id));
            if ($s && !empty($s->square_id)) {
                // Staff created manually use a local placeholder ID. Sending that
                // value to Square always returns zero availability even when the
                // employee is free. Do not disguise a configuration mismatch as a
                // fully-booked day.
                if ( 0 === strpos( (string) $s->square_id, 'manual_' ) || 0 === strpos( (string) $s->square_id, 'local-' ) ) {
                    wp_send_json_error( array(
                        'message'         => sprintf(
                            /* translators: %s: staff member name. */
                            __( '%s is a local staff record and is not linked to Square. Sync this employee from Square, or switch the booking platform to Local Only.', 'yatoon-booking-system' ),
                            $s->name ?: __( 'This employee', 'yatoon-booking-system' )
                        ),
                        'no_availability' => true,
                        'reason'          => 'staff_not_linked_to_square',
                    ) );
                }
                $staff_square_ids_to_query[] = trim( (string) $s->square_id );
            }
        } else {
            // Any staff: ONLY query staff who are assigned to this service.
            // Do NOT fall back to all staff - that causes staff who can't do the
            // service (e.g. Lisa for Acrylic) to bleed their free slots into results.
            $table_ss_sq  = $wpdb->prefix . 'yatoon_staff_services';
            $table_stf_sq = $wpdb->prefix . 'yatoon_staff';
            $eligible = yatoon_db_get_results($wpdb->prepare("
                SELECT st.id, st.square_id, st.name
                FROM $table_stf_sq st
                INNER JOIN $table_ss_sq ss ON st.id = ss.staff_id
                WHERE st.is_active = 1 AND st.is_bookable = 1 AND ss.service_id = %d
                ORDER BY st.sort_order ASC, st.name ASC
            ", $service_id));
            if (!empty($eligible)) {
                foreach ($eligible as $e) {
                    if ( class_exists( 'YATOON_Staff_Locations' ) && ! YATOON_Staff_Locations::is_assigned( $e->id, $service_id, $requested_location_id ) ) { continue;
                    }
                    if (!empty($e->square_id)) { $staff_square_ids_to_query[] = $e->square_id;
                    }
                }
            }
            yatoon_safe_log("SBS Availability Debug - Any staff for service_id=$service_id: found " . count($staff_square_ids_to_query) . ' eligible staff in yatoon_staff_services');
            // Build square_id -> local_id map for cart-busy check
            $sq_to_local_id = array();
            foreach ($eligible as $e) {
                if ( class_exists( 'YATOON_Staff_Locations' ) && ! YATOON_Staff_Locations::is_assigned( $e->id, $service_id, $requested_location_id ) ) { continue;
                }
                if (!empty($e->square_id)) { $sq_to_local_id[ $e->square_id ] = intval($e->id);
                }
            }

            // IMPORTANT: If yatoon_staff_services is empty for this service, we cannot safely
            // show availability - doing a fallback to ALL staff would include staff who
            // cannot perform this service, showing their free slots incorrectly.
            // Instead, return an error prompting the admin to rebuild service assignments.
            if (empty($staff_square_ids_to_query)) {
                wp_send_json_error(array(
                    'message'         => __('Staff-service assignments not configured. Please go to WP Admin -> Square Booking -> Staff and click "Rebuild Service Assignments" to fix availability.', 'yatoon-booking-system'),
                    'no_availability' => true,
                ));
            }
        }

        if (empty($staff_square_ids_to_query)) {
            wp_send_json_error(array(
                'message'         => __('No available staff for this service.', 'yatoon-booking-system'),
                'no_availability' => true,
            ));
        }

        yatoon_safe_log("SBS Availability Debug - start: $start_date  end: $end_date");

        // -- REVISED ANY-STAFF SLOT LOGIC -------------------------------------
        // For "Any staff" (staff_id = 0), a time slot is only available if AT LEAST
        // ONE eligible staff member is FREE at that time (not busy in the cart).
        //
        // Previous bug: $seen_times marked a slot as "done" even when skipped due to
        // cart-busy. So if Staff A's 3:30 was skipped (busy), Staff B's 3:30 still
        // passed through - even though in some cases only Staff A was eligible.
        //
        // Fix: collect per-slot data for ALL staff first, then decide per slot:
        //   - slot_free_staff[time]  = count of staff who are free at that time
        //   - slot_data[time]        = the start_at value for that slot
        // A slot is included in results only if slot_free_staff[time] >= 1.
        // ---------------------------------------------------------------------
        $slot_dur          = !empty($all_variations) ? intval($all_variations[0]->duration_minutes) : 60;
        $slot_free_staff    = array(); // time_key => count of free staff at that time
        $slot_start_at      = array(); // time_key => ISO start_at string
        $slot_free_staff_ids = array(); // time_key => [ square_staff_id, ... ]
        $variations_to_try = !empty($all_variations) ? $all_variations : array();

        // For named staff (staff_id > 0), build local_id map too so cart-busy works
        if ($staff_id > 0 && empty($sq_to_local_id) && !empty($staff_square_ids_to_query)) {
            $sq_id = $staff_square_ids_to_query[0];
            $row   = yatoon_db_get_row($wpdb->prepare("SELECT id FROM $table_staff WHERE square_id = %s LIMIT 1", $sq_id));
            if ($row) { $sq_to_local_id[ $sq_id ] = intval($row->id);
            }
        }

        // Helper: find a fallback bookable variation with similar duration. Cache by
        // duration within this request so a Square fallback never repeats the DB query.
        $fallback_cache = array();
        $find_bookable_fallback = function ( $target_duration_min ) use ( $wpdb, &$fallback_cache ) {
            $target_duration_min = intval( $target_duration_min );
            if ( isset( $fallback_cache[ $target_duration_min ] ) ) {
                return $fallback_cache[ $target_duration_min ];
            }
            $table_opts = $wpdb->prefix . 'yatoon_service_options';
            // Get variations with close duration (within 30 min), ordered by closest match
            $rows = yatoon_db_get_results($wpdb->prepare(
                "SELECT square_id, duration_minutes FROM {$table_opts}
                 WHERE square_id != '' AND duration_minutes BETWEEN %d AND %d
                 ORDER BY ABS(duration_minutes - %d) ASC LIMIT 10",
                max(0, $target_duration_min - 30), $target_duration_min + 30, $target_duration_min
            ));
            $fallback_cache[ $target_duration_min ] = $rows ?: array();
            return $fallback_cache[ $target_duration_min ];
        };

        // Resolve add-ons once. Previously this identical query ran once per staff.
        $addon_rows = array();
        if ( ! empty( $addon_option_ids ) ) {
            $table_opts_av = $wpdb->prefix . 'yatoon_service_options';
            $addon_ph      = implode(',', array_fill(0, count($addon_option_ids), '%d'));
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $addon_rows = yatoon_db_get_results(
                $wpdb->prepare(
                    "SELECT square_id, duration_minutes FROM $table_opts_av WHERE id IN ($addon_ph) AND square_id != ''",
                    ...$addon_option_ids
                )
            );
        }

        // The common Any Staff path becomes one Square request per variation instead
        // of one request per eligible employee. Multi-segment add-ons intentionally
        // remain per-staff so every segment is guaranteed to use the same employee.
        $batch_any_staff = 0 === $staff_id && empty( $addon_option_ids );
        $staff_queries = $batch_any_staff ? array( $staff_square_ids_to_query ) : $staff_square_ids_to_query;
        $batch_variation_id_for_recheck = '';

        foreach ($staff_queries as $staff_query) {
            $sq_staff_id = is_array( $staff_query ) ? '' : $staff_query;
            $local_id = $sq_staff_id && isset($sq_to_local_id[ $sq_staff_id ]) ? $sq_to_local_id[ $sq_staff_id ] : 0;
            foreach ($variations_to_try as $var) {
                $response_variation_id = $var->square_id;

                // -- If Add-on option IDs were sent, resolve their Square variation IDs
                // and use multi-segment availability query so Square validates the full
                // combined duration (main + addon) fits in a single continuous window.
                if (!empty($addon_option_ids)) {
                    if (!empty($addon_rows)) {
                        // Build multi-segment: [main, addon1, addon2, ...]
                        $multi_segments = array(array(
                            'variation_id'   => $var->square_id,
                            'team_member_id' => $sq_staff_id,
                        ),);
                        foreach ($addon_rows as $addon_row) {
                            $multi_segments[] = array(
                                'variation_id'   => $addon_row->square_id,
                                'team_member_id' => $sq_staff_id,
                            );
                        }
                        $response = $this->square_api->search_availability_multi_segment($multi_segments, $start_date, $end_date);
                        yatoon_safe_log("SBS Availability Debug - staff={$sq_staff_id} MULTI-SEGMENT (main+addon) query: " . ( is_wp_error($response) ? 'WP_ERROR: ' . $response->get_error_message() : ( isset($response['availabilities']) ? count($response['availabilities']) . ' slots' : 'no availabilities key' ) ));
                    } else {
                        // Addon has no square_id - fall back to single-segment + local filter
                        $response = $this->square_api->search_availability($var->square_id, $start_date, $end_date, $sq_staff_id);
                        yatoon_safe_log("SBS Availability Debug - staff={$sq_staff_id} variation='{$var->name}' (addon has no sq_id, single-segment): " . ( is_wp_error($response) ? 'WP_ERROR: ' . $response->get_error_message() : ( isset($response['availabilities']) ? count($response['availabilities']) . ' slots' : 'no availabilities key' ) ));
                    }
                } else {
                    $response = $this->square_api->search_availability($var->square_id, $start_date, $end_date, $staff_query);
                    $query_label = is_array( $staff_query ) ? 'eligible-set:' . count( $staff_query ) : $sq_staff_id;
                    yatoon_safe_log("SBS Availability Debug - staff={$query_label} variation='{$var->name}': " . ( is_wp_error($response) ? 'WP_ERROR: ' . $response->get_error_message() : ( isset($response['availabilities']) ? count($response['availabilities']) . ' slots' : 'no availabilities key' ) ));
                }

                // If this variation is not bookable on Square, try other variations with same duration
                if (!is_wp_error($response) && isset($response['errors'])) {
                    $is_not_bookable = false;
                    foreach ($response['errors'] as $err) {
                        if (!empty($err['detail']) && stripos($err['detail'], 'not bookable') !== false) {
                            $is_not_bookable = true;
                            break;
                        }
                    }
                    if ($is_not_bookable) {
                        yatoon_safe_log("SBS Availability - variation '{$var->name}' not bookable, trying fallback variations");
                        $fallbacks = $find_bookable_fallback(intval($var->duration_minutes));
                        foreach ($fallbacks as $fb) {
                            if ($fb->square_id === $var->square_id) { continue;
                            }
                            $fb_resp = $this->square_api->search_availability($fb->square_id, $start_date, $end_date, $staff_query);
                            if (!is_wp_error($fb_resp) && empty($fb_resp['errors']) && !empty($fb_resp['availabilities'])) {
                                yatoon_safe_log("SBS Availability - fallback variation {$fb->square_id} ({$fb->duration_minutes}min) returned " . count($fb_resp['availabilities']) . ' slots');
                                $response = $fb_resp;
                                $response_variation_id = $fb->square_id;
                                break;
                            }
                        }
                    }
                }

                if (is_wp_error($response)) { continue;
                }

                if (isset($response['availabilities']) && is_array($response['availabilities'])) {
                    foreach ($response['availabilities'] as $availability) {
                        if (empty($availability['start_at'])) { continue;
                        }
                        $t = new DateTime($availability['start_at']);
                        $t->setTimezone(new DateTimeZone($timezone));
                        $slot_key = $t->format('H:i');

                        // Record the start_at for this slot (first time we see it)
                        if (!isset($slot_start_at[ $slot_key ])) {
                            $slot_start_at[ $slot_key ] = $availability['start_at'];
                            $slot_free_staff[ $slot_key ] = 0;
                            $slot_free_staff_ids[ $slot_key ] = array();
                        }

                        // A batched Square response identifies the matching employee in
                        // appointment_segments. Union all matching eligible employees for
                        // this start time; named-staff responses retain the known ID.
                        $matched_staff_ids = array();
                        if ( is_array( $staff_query ) ) {
                            foreach ( (array) ( $availability['appointment_segments'] ?? array() ) as $segment ) {
                                $matched_id = sanitize_text_field( $segment['team_member_id'] ?? '' );
                                if ( $matched_id && in_array( $matched_id, $staff_query, true ) ) {
                                    $matched_staff_ids[] = $matched_id;
                                }
                            }
                            $matched_staff_ids = array_values( array_unique( $matched_staff_ids ) );
                        } elseif ( $sq_staff_id ) {
                            $matched_staff_ids[] = $sq_staff_id;
                        }

                        foreach ( $matched_staff_ids as $matched_staff_id ) {
                            $matched_local_id = $sq_to_local_id[ $matched_staff_id ] ?? $local_id;
                            if ( $matched_local_id > 0 && $is_staff_cart_busy($matched_local_id, $t->getTimestamp(), $slot_dur) ) {
                                continue;
                            }
                            $slot_free_staff_ids[ $slot_key ][] = $matched_staff_id;
                        }
                        $slot_free_staff_ids[ $slot_key ] = array_values( array_unique( $slot_free_staff_ids[ $slot_key ] ) );
                        $slot_free_staff[ $slot_key ] = count( $slot_free_staff_ids[ $slot_key ] );
                    }
                    if (!empty($response['availabilities'])) {
                        if ( $batch_any_staff ) {
                            $batch_variation_id_for_recheck = $response_variation_id;
                        }
                        break; // got slots from this variation for this staff - try next variation
                    }
                }
            }
        } // end foreach staff

        // Rare cart-overlay fallback: Square's multi-member ANY response may choose
        // one eligible employee for a time where several employees are available.
        // If that returned employee is occupied by another item in this customer's
        // current cart, individually recheck the remaining eligible employees only
        // for those unresolved times. This preserves the fast one-request path for
        // normal traffic without hiding a slot another qualified employee can take.
        if ( $batch_any_staff && $batch_variation_id_for_recheck ) {
            $unresolved_slot_keys = array();
            foreach ( $slot_start_at as $slot_key => $unused_start_at ) {
                if ( empty( $slot_free_staff_ids[ $slot_key ] ) ) {
                    $unresolved_slot_keys[ $slot_key ] = true;
                }
            }

            foreach ( $staff_square_ids_to_query as $fallback_staff_id ) {
                if ( empty( $unresolved_slot_keys ) ) {
                    break;
                }
                $fallback_local_id = isset( $sq_to_local_id[ $fallback_staff_id ] ) ? intval( $sq_to_local_id[ $fallback_staff_id ] ) : 0;
                $fallback_response = $this->square_api->search_availability(
                    $batch_variation_id_for_recheck,
                    $start_date,
                    $end_date,
                    $fallback_staff_id
                );
                if ( is_wp_error( $fallback_response ) || empty( $fallback_response['availabilities'] ) ) {
                    continue;
                }

                foreach ( $fallback_response['availabilities'] as $fallback_availability ) {
                    if ( empty( $fallback_availability['start_at'] ) ) {
                        continue;
                    }
                    $fallback_time = new DateTime( $fallback_availability['start_at'] );
                    $fallback_time->setTimezone( new DateTimeZone( $timezone ) );
                    $fallback_slot_key = $fallback_time->format( 'H:i' );
                    if ( empty( $unresolved_slot_keys[ $fallback_slot_key ] ) ) {
                        continue;
                    }
                    if ( $fallback_local_id > 0 && $is_staff_cart_busy( $fallback_local_id, $fallback_time->getTimestamp(), $slot_dur ) ) {
                        continue;
                    }
                    $slot_free_staff_ids[ $fallback_slot_key ] = array( $fallback_staff_id );
                    $slot_free_staff[ $fallback_slot_key ] = 1;
                    unset( $unresolved_slot_keys[ $fallback_slot_key ] );
                }
            }
        }

        // Build final available_times: only include slots where at least 1 staff is free
        $available_times = array();
        foreach ($slot_start_at as $slot_key => $start_at) {
            if (( $slot_free_staff[ $slot_key ] ?? 0 ) >= 1) {
                $t = new DateTime($start_at);
                $t->setTimezone(new DateTimeZone($timezone));
                $free_staff_ids = array_values(array_unique($slot_free_staff_ids[ $slot_key ] ?? array()));
                $available_time = array(
                    'time'          => $slot_key,
                    'display'       => $t->format('g:i A'),
                    'start_at'      => $start_at,
                    'free_staff_ids' => $free_staff_ids,
                );
                // Keep the Square staff member that made this Any Staff slot valid.
                // The checkout can then verify the same assignment instead of
                // performing a second, potentially different Any Staff selection.
                if (0 === $staff_id && !empty($free_staff_ids)) {
                    $available_time['staff_map'] = array(0 => $free_staff_ids[0]);
                }
                $available_times[] = $available_time;
            }
        }

        // Sort by time
        usort($available_times, function ( $a, $b ) {
 return strcmp($a['time'], $b['time']);
});

        // -- Secondary filter: full-duration conflict check using Square live data --
        // Square's availability API only validates the MAIN variation duration.
        // When add-ons are selected (e.g. Custom Design = 30 min extra), a slot
        // returned by Square as "free for 45 min" may actually be blocked at
        // 45+30=75 min because the next booking starts within that window.
        //
        // IMPORTANT: Filter per-staff. A slot is kept if at least one free staff
        // member has no conflict in the full window. Don't use global busy_windows
        // that would incorrectly block slots for other staff.
        if ($addon_duration > 0 && !empty($available_times)) {
            $timezone_filter  = yatoon_timezone_string();
            $tz_filter        = new DateTimeZone($timezone_filter);
            $total_duration   = 0;
            if (!empty($all_variations)) {
                $total_duration = intval($all_variations[0]->duration_minutes);
            }
            if (!$total_duration) { $total_duration = intval($service->duration_minutes ?? 60);
            }
            $total_duration += $addon_duration;

            // Fetch Square's live bookings for this day
            $sq_day_start = ( new DateTime($date . ' 00:00:00', $tz_filter) )->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z');
            $sq_day_end   = ( new DateTime($date . ' 23:59:59', $tz_filter) )->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z');
            $sq_live_bookings = $this->square_api->list_all_bookings($sq_day_start, $sq_day_end);

            // Build busy windows PER STAFF: [ staff_square_id => [ [start_ts, end_ts], ... ] ]
            $busy_by_staff = array();
            if (!empty($sq_live_bookings) && is_array($sq_live_bookings)) {
                foreach ($sq_live_bookings as $sq_bk) {
                    if (empty($sq_bk['start_at'])) { continue;
                    }
                    $status = strtoupper($sq_bk['status'] ?? '');
                    if (in_array($status, array('CANCELLED', 'NO_SHOW', 'CANCELLED_BY_SELLER', 'CANCELLED_BY_CUSTOMER'), true)) { continue;
                    }

                    // Get staff IDs for this booking
                    $bk_staff_ids = array();
                    if (!empty($sq_bk['appointment_segments'])) {
                        foreach ($sq_bk['appointment_segments'] as $seg) {
                            if (!empty($seg['team_member_id'])) { $bk_staff_ids[] = $seg['team_member_id'];
                            }
                        }
                    }

                    // Calculate booking duration
                    $bk_dur = 0;
                    if (!empty($sq_bk['appointment_segments'])) {
                        foreach ($sq_bk['appointment_segments'] as $seg) {
                            if (!empty($seg['duration_minutes'])) { $bk_dur += intval($seg['duration_minutes']);
                            }
                        }
                    }
                    if ($bk_dur <= 0) { $bk_dur = 60;
                    }

                    $bk_start_ts = ( new DateTime($sq_bk['start_at']) )->getTimestamp();
                    $bk_end_ts   = $bk_start_ts + ( $bk_dur * 60 );

                    // Add to each involved staff's busy list
                    foreach ($bk_staff_ids as $bk_sq_staff_id) {
                        if (!isset($busy_by_staff[ $bk_sq_staff_id ])) { $busy_by_staff[ $bk_sq_staff_id ] = array();
                        }
                        $busy_by_staff[ $bk_sq_staff_id ][] = array($bk_start_ts, $bk_end_ts);
                    }
                }
            }

            yatoon_safe_log("SBS Addon filter - total={$total_duration}min, staff_with_busy=" . count($busy_by_staff) . ', slots_before=' . count($available_times));

            // Filter slots: keep slot if at least one free staff has no conflict
            $filtered_times = array();
            foreach ($available_times as $slot) {
                $slot_start_ts  = ( new DateTime($slot['start_at']) )->getTimestamp();
                $slot_end_ts    = $slot_start_ts + ( $total_duration * 60 );
                $free_staff_ids = $slot['free_staff_ids'] ?? array();

                $has_free_staff = false;
                $selected_staff_id = '';
                foreach ($free_staff_ids as $sq_staff_id) {
                    $staff_busy = $busy_by_staff[ $sq_staff_id ] ?? array();
                    $conflict = false;
                    foreach ($staff_busy as $bw) {
                        if ($slot_start_ts < $bw[1] && $slot_end_ts > $bw[0]) {
                            $conflict = true;
                            break;
                        }
                    }
                    if (!$conflict) {
                        $has_free_staff = true;
                        $selected_staff_id = $sq_staff_id;
                        break;
                    }
                }

                if ($has_free_staff) {
                    if (0 === $staff_id && $selected_staff_id) {
                        $slot['staff_map'] = array(0 => $selected_staff_id);
                    }
                    $filtered_times[] = $slot;
                }
            }

            yatoon_safe_log('SBS Addon filter - slots_after=' . count($filtered_times));
            $available_times = $filtered_times;
        }

        // Square returned no availability for this date. Keep Square as the
        // source of available slots, but improve the closed-day message.
        if (empty($available_times)) {
            wp_send_json_error(array(
                'message'         => ( $external_closed_day_message() ?: __('No available times on this date.', 'yatoon-booking-system') ),
                'no_availability' => true,
            ));
        }

        // Apply latest-end-time cutoff - use total_duration if hint was sent,
        // otherwise fall back to the $total_duration already computed for addon filter
        $cutoff_dur = $total_duration_hint > 0 ? $total_duration_hint
                    : ( isset($total_duration) ? $total_duration : ( isset($main_duration) ? $main_duration : 0 ) );
        $available_times = $apply_cutoff($available_times, $cutoff_dur, $date);
        if (empty($available_times)) {
            wp_send_json_error(array('message' => __('No available times - all remaining slots would end after our closing time.', 'yatoon-booking-system'), 'no_availability' => true));
        }

        wp_send_json_success(array(
            'times' => $available_times,
            'count' => count($available_times),
            'mode'  => 'square',
        ));
    }

        /**
     * Generate default time slots
     */
    private function generate_default_times( $date ) {
        $times = array();
        $start_hour = 9; // 9 AM
        $end_hour = 18; // 6 PM
        $interval = 30; // 30-minute interval

        for ($hour = $start_hour; $hour < $end_hour; $hour++) {
            for ($minute = 0; $minute < 60; $minute += $interval) {
                $time = sprintf('%02d:%02d', $hour, $minute);
                $timestamp = strtotime($date . ' ' . $time);

                $times[] = array(
                    'time' => $time,
                    // Both sides use gmdate so the 'Z' suffix is honest and
                    // the pair no longer depends on PHP's default timezone
                    // happening to be UTC. $timestamp came from strtotime()
                    // on the same basis, so behaviour is unchanged.
                    'display' => gmdate('g:i A', $timestamp),
                    'start_at' => gmdate('Y-m-d\TH:i:s\Z', $timestamp),
                );
            }
        }

        return $times;
    }

    /**
     * Find next available date (searches forward up to 60 days)
     * Used by the "Go to next available" button
     */
    public function get_next_available() {
        check_ajax_referer('yatoon_frontend_nonce', 'nonce');

        $service_id = isset($_POST['service_id']) ? intval($_POST['service_id'])        : 0;
        $staff_id   = isset($_POST['staff_id'])   ? intval($_POST['staff_id'])          : 0;
        $from_date  = isset($_POST['from_date'])  ? sanitize_text_field( wp_unslash( $_POST['from_date'] ) ) : '';

        if (!$service_id || !$from_date) {
            wp_send_json_error(array('message' => __( 'Missing parameters', 'yatoon-booking-system' )));
        }

        $timezone_str = yatoon_timezone_string();
        $tz           = new DateTimeZone($timezone_str);
        $platform     = get_option('yatoon_active_platform', 'local');

        // Start searching from tomorrow
        $search_from = new DateTime($from_date, $tz);
        $search_from->modify('+1 day');
        $search_to   = ( clone $search_from )->modify('+59 days'); // 60-day window

        global $wpdb;

        // -- SQUARE MODE ----------------------------------------------------------
        if ($platform === 'square') {

            $table_opts_na = $wpdb->prefix . 'yatoon_service_options';
            $table_stf_na  = $wpdb->prefix . 'yatoon_staff';

            // Helper: build correct date chunks (Square max window = 32 days)
            // Returns array of ['start' => ISO8601, 'end' => ISO8601]
            $build_chunks = function ( $from_dt, $num_chunks = 2 ) {
                $chunks = array();
                for ($i = 0; $i < $num_chunks; $i++) {
                    $s = clone $from_dt;
                    $s->modify('+' . ( $i * 30 ) . ' days')->setTime(0, 0, 0);
                    $e = clone $s;
                    $e->modify('+29 days')->setTime(23, 59, 59);
                    $chunks[] = array('start' => $s->format('c'), 'end' => $e->format('c'));
                }
                return $chunks;
            };

            // Helper: resolve Square variation IDs from option DB IDs (or fallback to service default)
            // KEY FIX: If user selected specific options, use ONLY those variations.
            // Never add other variations as fallback - they have different durations
            // and would cause Square to return availability for the wrong service length.
            $resolve_vars = function ( $opt_ids, $svc_id ) use ( $wpdb, $table_opts_na ) {
                $vars = array();
                if (!empty($opt_ids)) {
                    $clean = array_map('intval', (array) $opt_ids);
                    $ph    = implode(',', array_fill(0, count($clean), '%d'));
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    $rows  = yatoon_db_get_results(
                        $wpdb->prepare("SELECT square_id FROM $table_opts_na WHERE id IN ($ph) AND square_id != ''", ...$clean)
                    );
                    foreach ($rows as $r) { $vars[] = $r->square_id;
                    }
                    // Do NOT add fallback here - user chose a specific option with a specific duration
                }
                // Only use default when no option was selected at all
                if (empty($vars) && $svc_id) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    $rows = yatoon_db_get_results(
                        $wpdb->prepare("SELECT square_id FROM $table_opts_na WHERE service_id = %d AND square_id != '' ORDER BY id ASC LIMIT 1", $svc_id)
                    );
                    foreach ($rows as $r) { $vars[] = $r->square_id;
                    }
                }
                return $vars;
            };

            // Helper: search all chunks for earliest date using single-service query
            $find_earliest_single = function ( $variation_ids, $sq_staff_id ) use ( $build_chunks, $search_from, $tz ) {
                $chunks   = $build_chunks($search_from, 2);
                $earliest = null;
                $disp     = null;
                foreach ($chunks as $chunk) {
                    if ($earliest !== null) { break;
                    }
                    foreach ($variation_ids as $var_id) {
                        $resp = $this->square_api->search_availability($var_id, $chunk['start'], $chunk['end'], $sq_staff_id);
                        if (is_wp_error($resp) || empty($resp['availabilities'])) { continue;
                        }
                        foreach ($resp['availabilities'] as $av) {
                            if (empty($av['start_at'])) { continue;
                            }
                            $t = new DateTime($av['start_at']);
                            $t->setTimezone($tz);
                            $d = $t->format('Y-m-d');
                            if ($earliest === null || $d < $earliest) {
                                $earliest = $d;
                                $disp     = $t->format('l, F j');
                            }
                        }
                        break; // first working variation is enough
                    }
                }
                return array($earliest, $disp);
            };

            // Helper: search all chunks for earliest date using multi-segment query
            $find_earliest_multi = function ( $segments ) use ( $build_chunks, $search_from, $tz ) {
                $chunks   = $build_chunks($search_from, 2);
                $earliest = null;
                $disp     = null;
                foreach ($chunks as $chunk) {
                    if ($earliest !== null) { break;
                    }
                    $resp = $this->square_api->search_availability_multi_segment($segments, $chunk['start'], $chunk['end']);
                    if (is_wp_error($resp) || empty($resp['availabilities'])) { continue;
                    }
                    foreach ($resp['availabilities'] as $av) {
                        if (empty($av['start_at'])) { continue;
                        }
                        $t = new DateTime($av['start_at']);
                        $t->setTimezone($tz);
                        $d = $t->format('Y-m-d');
                        if ($earliest === null || $d < $earliest) {
                            $earliest = $d;
                            $disp     = $t->format('l, F j');
                        }
                    }
                }
                return array($earliest, $disp);
            };

            // -- Parse multi-service params (sent from frontend for multi-service bookings) --
            $ms_staff_ids  = array();
            $ms_option_ids = array();
            $ms_guest_labels = array();
            $raw_sids = isset($_POST['all_staff_ids'])  ? stripslashes($_POST['all_staff_ids'])  : '';
            $raw_oids = isset($_POST['all_option_ids']) ? stripslashes($_POST['all_option_ids']) : '';
            $raw_guest_labels = isset($_POST['all_guest_labels']) ? stripslashes($_POST['all_guest_labels']) : '';
            if (!empty($raw_sids)) {
                $d = json_decode($raw_sids, true);
                if (is_array($d)) { $ms_staff_ids = array_map('intval', $d);
                }
            }
            if (!empty($raw_oids)) {
                $d = json_decode($raw_oids, true);
                if (is_array($d)) { $ms_option_ids = $d;
                }
            }
            if (!empty($raw_guest_labels)) {
                $d = json_decode($raw_guest_labels, true);
                if (is_array($d)) { $ms_guest_labels = array_map('sanitize_text_field', $d);
                }
            }

            // -- PATH A: Multi-service booking (2+ services in pendingServices) -------------
            if (count($ms_staff_ids) >= 2) {
                $has_any = in_array(0, $ms_staff_ids, true);

                // Helper: get service_id for a pending position (from option's service, fallback to $service_id)
                $get_svc_for_pos = function ( $opt_ids ) use ( $wpdb, $service_id ) {
                    if (!empty($opt_ids)) {
                        $first_opt = intval($opt_ids[0]);
                        if ($first_opt) {
                            $row = yatoon_db_get_var($wpdb->prepare(
                                "SELECT service_id FROM {$wpdb->prefix}yatoon_service_options WHERE id = %d LIMIT 1",
                                $first_opt
                            ));
                            if ($row) { return intval($row);
                            }
                        }
                    }
                    return $service_id;
                };

                if (!$has_any) {
                    // CASE A: All named staff - build one multi-segment query per guest.
                    // Same guest services remain sequential; different guests start together.
                    $guest_segs = array(); // guest_label => [segment, ...]
                    $guest_offsets = array();
                    $staff_windows = array();
                    foreach ($ms_staff_ids as $idx => $s_id) {
                        $s_row = yatoon_db_get_row($wpdb->prepare("SELECT square_id FROM $table_stf_na WHERE id = %d", $s_id));
                        if (!$s_row || empty($s_row->square_id)) { continue;
                        }
                        $opts   = isset($ms_option_ids[ $idx ]) ? (array) $ms_option_ids[ $idx ] : array();
                        $svc_id = $get_svc_for_pos($opts);
                        $vars   = $resolve_vars($opts, $svc_id);
                        if (empty($vars)) { continue;
                        }
                        $sq_sid = $s_row->square_id;
                        $guest_label = isset($ms_guest_labels[ $idx ]) && $ms_guest_labels[ $idx ] !== '' ? $ms_guest_labels[ $idx ] : 'Me';
                        if (!isset($guest_offsets[ $guest_label ])) { $guest_offsets[ $guest_label ] = 0;
                        }

                        $dur = 60;
                        if (!empty($opts)) {
                            $d = yatoon_db_get_var($wpdb->prepare(
                                "SELECT duration_minutes FROM {$wpdb->prefix}yatoon_service_options WHERE id = %d LIMIT 1",
                                intval($opts[0])
                            ));
                            if ($d) { $dur = intval($d);
                            }
                        }
                        $offset = intval($guest_offsets[ $guest_label ]);
                        $guest_offsets[ $guest_label ] += $dur;

                        if (!isset($staff_windows[ $sq_sid ])) { $staff_windows[ $sq_sid ] = array();
                        }
                        foreach ($staff_windows[ $sq_sid ] as $w) {
                            if ($offset < $w['end'] && ( $offset + $dur ) > $w['start']) {
                                wp_send_json_error(array('message' => __( 'No availability found in the next 60 days.', 'yatoon-booking-system' )));
                            }
                        }
                        $staff_windows[ $sq_sid ][] = array('start' => $offset, 'end' => $offset + $dur);

                        if (!isset($guest_segs[ $guest_label ])) { $guest_segs[ $guest_label ] = array();
                        }
                        $guest_segs[ $guest_label ][] = array('variation_id' => $vars[0], 'team_member_id' => $sq_sid);
                    }

                    if (!empty($guest_segs)) {
                        // Find the date where ALL guests have availability to begin together.
                        $per_guest_earliest = array();
                        foreach ($guest_segs as $guest_label => $segs) {
                            list($d, $dp) = $find_earliest_multi($segs);
                            $per_guest_earliest[ $guest_label ] = $d;
                        }
                        // All guests must be free on the same day, so use the latest
                        // individual earliest date as the first date worth showing.
                        $latest_date = null;
                        $latest_disp = null;
                        foreach ($per_guest_earliest as $guest_label => $d) {
                            if ($d === null) { $latest_date = null;
break; } // one staff has no avail
                            if ($latest_date === null || $d > $latest_date) {
                                $latest_date = $d;
                                $t = new DateTime($d . ' 00:00:00', $tz);
                                $latest_disp = $t->format('l, F j');
                            }
                        }
                        if ($latest_date) {
                            wp_send_json_success(array('date' => $latest_date, 'display' => $latest_disp));
                        }
                        wp_send_json_error(array('message' => __( 'No availability found in the next 60 days.', 'yatoon-booking-system' )));
                    }
                } else {
                    // CASE B: At least one "Any staff" - collect all any-service variations,
                    // then query each bookable staff member with ALL combined variations.
                    $all_vars_for_any = array();
                    foreach ($ms_staff_ids as $idx => $s_id) {
                        $opts   = isset($ms_option_ids[ $idx ]) ? (array) $ms_option_ids[ $idx ] : array();
                        $svc_id = $get_svc_for_pos($opts);
                        $v_list = $resolve_vars($opts, $svc_id);
                        if (!empty($v_list)) { $all_vars_for_any[] = $v_list[0];
                        }
                    }

                    if (!empty($all_vars_for_any)) {
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                        $all_candidates = yatoon_db_get_results(
                            "SELECT square_id, name FROM $table_stf_na WHERE is_active=1 AND is_bookable=1 AND square_id != '' ORDER BY sort_order ASC, name ASC"
                        );

                        $earliest_date = null;
                        $earliest_disp = null;

                        foreach ($all_candidates as $cand) {
                            // Build combined multi-segment: all services done by this one staff
                            $segs = array();
                            foreach ($all_vars_for_any as $vid) {
                                $segs[] = array('variation_id' => $vid, 'team_member_id' => $cand->square_id);
                            }
                            list($d, $dp) = $find_earliest_multi($segs);
                            if ($d !== null && ( $earliest_date === null || $d < $earliest_date )) {
                                $earliest_date = $d;
                                $earliest_disp = $dp;
                            }
                        }

                        if ($earliest_date) {
                            wp_send_json_success(array('date' => $earliest_date, 'display' => $earliest_disp));
                        }
                        wp_send_json_error(array('message' => __( 'No availability found in the next 60 days.', 'yatoon-booking-system' )));
                    }
                }
                // fall through to single-service logic if we couldn't resolve variations
            }

            // -- PATH B: Single-service booking ---------------------------------------------
            $staff_square_id = null;
            if ($staff_id) {
                $s_row = yatoon_db_get_row($wpdb->prepare("SELECT square_id FROM $table_stf_na WHERE id = %d", $staff_id));
                if ($s_row) { $staff_square_id = $s_row->square_id;
                }
            }

            // Collect variation IDs to try (selected options first, then all variations as fallback)
            $sel_opt_ids = array();
            if (!empty($_POST['option_ids'])) {
                $raw = json_decode(stripslashes($_POST['option_ids']), true);
                if (is_array($raw)) { $sel_opt_ids = array_map('intval', $raw);
                }
            }

            $all_variation_ids = array();
            if (!empty($sel_opt_ids)) {
                $ph   = implode(',', array_fill(0, count($sel_opt_ids), '%d'));
                $rows = yatoon_db_get_results($wpdb->prepare("SELECT square_id FROM $table_opts_na WHERE id IN ($ph) AND square_id != ''", ...$sel_opt_ids));
                foreach ($rows as $r) { $all_variation_ids[] = $r->square_id;
                }
            }
            // Always add all service variations as fallback
            $rows = yatoon_db_get_results($wpdb->prepare("SELECT square_id FROM $table_opts_na WHERE service_id = %d AND square_id != '' ORDER BY id ASC", $service_id));
            foreach ($rows as $r) {
                if (!in_array($r->square_id, $all_variation_ids)) { $all_variation_ids[] = $r->square_id;
                }
            }

            if (empty($all_variation_ids)) {
                wp_send_json_error(array('message' => __( 'No availability found. Please call us to book.', 'yatoon-booking-system' )));
            }

            list($earliest_date, $earliest_disp) = $find_earliest_single($all_variation_ids, $staff_square_id);

            if (!$earliest_date) {
                wp_send_json_error(array('message' => __( 'No availability found in the next 60 days.', 'yatoon-booking-system' )));
            }

            wp_send_json_success(array('date' => $earliest_date, 'display' => $earliest_disp));
        }

        // -- VAGARO MODE: try day-by-day (Vagaro API doesn't support multi-day range) --
        if ($platform === 'vagaro') {
            if (!class_exists('YATOON_Vagaro_API')) {
                require_once SBS_PLUGIN_DIR . 'includes/class-yatoon-vagaro-api.php';
            }
            $vagaro = new YATOON_Vagaro_API();
            $svc    = yatoon_db_get_row($wpdb->prepare(
                "SELECT square_id FROM {$wpdb->prefix}yatoon_services WHERE id = %d", $service_id
            ));
            $vagaro_service_id  = $svc ? $svc->square_id : null;
            $vagaro_employee_id = null;
            if ($staff_id) {
                $stf = yatoon_db_get_row($wpdb->prepare(
                    "SELECT square_id FROM {$wpdb->prefix}yatoon_staff WHERE id = %d", $staff_id
                ));
                if ($stf) { $vagaro_employee_id = $stf->square_id;
                }
            }

            if (!$vagaro_service_id) {
                wp_send_json_error(array('message' => __( 'No availability found. Please call us to book.', 'yatoon-booking-system' )));
            }

            $cursor   = clone $search_from;
            $max_days = 60;
            for ($i = 0; $i < $max_days; $i++) {
                $check_date = $cursor->format('Y-m-d');
                $resp = $vagaro->get_availability($vagaro_service_id, $vagaro_employee_id, $check_date);
                if (!is_wp_error($resp)) {
                    $slots_raw = $resp['slots'] ?? $resp['availabilities'] ?? $resp['times'] ?? array();
                    if (!empty($slots_raw)) {
                        wp_send_json_success(array(
                            'date'    => $check_date,
                            'display' => $cursor->format('l, F j'),
                        ));
                    }
                }
                $cursor->modify('+1 day');
            }
            wp_send_json_error(array('message' => __( 'No availability found in the next 60 days.', 'yatoon-booking-system' )));
        }

        // -- LOCAL MODE: fast - just check business hours + existing bookings --
        $cursor   = clone $search_from;
        $max_days = 60;
        for ($i = 0; $i < $max_days; $i++) {
            $check_date = $cursor->format('Y-m-d');
            $day_hours  = YATOON_Business_Hours::get_day_hours($check_date);
            if ($day_hours) {
                $slots = YATOON_Business_Hours::generate_slots($check_date, $service_id, $staff_id);
                if (!empty($slots)) {
                    wp_send_json_success(array(
                        'date'    => $check_date,
                        'display' => $cursor->format('l, F j'),
                    ));
                }
            }
            $cursor->modify('+1 day');
        }
        wp_send_json_error(array('message' => __( 'No availability found in the next 60 days.', 'yatoon-booking-system' )));
    }

    /**
     * Acquire the named locks that serialize local booking creation.
     *
     * WHY THIS CHANGED IN 4.4.3
     * -------------------------
     * The previous implementation keyed the lock on
     *
     *     start-time-to-the-minute + the set of service IDs
     *
     * which is not the resource being contended. The resource is the STAFF
     * MEMBER's calendar. Because the key included the service set and the
     * exact start minute, two requests only ever collided when they asked for
     * an identical time AND an identical service list -- the rarest case.
     * Every realistic double-booking slipped straight through:
     *
     *   A: service 1 (60m) with Anna at 10:00   -> lock "10:00|1"
     *   B: service 2 (30m) with Anna at 10:30   -> lock "10:30|2"
     *
     * Different keys, so both requests ran their availability check
     * concurrently, neither saw the other's uncommitted row, and both
     * inserted. Anna ended up double-booked.
     *
     * The fix is to serialize on the calendar day being written to. Booking
     * creation is low-throughput (a busy salon does hundreds a day, not
     * thousands a second) and the lock is only held across
     * validate-then-insert -- single-digit milliseconds -- so a per-day lock
     * costs nothing measurable and removes the entire class of bug rather
     * than narrowing it.
     *
     * A booking that straddles midnight touches two days, so both are locked,
     * always in ascending order to make deadlock impossible.
     *
     * CAVEAT: MySQL's GET_LOCK is scoped to a single server. On a multi-writer
     * cluster (Galera, Percona XtraDB, Aurora multi-master) it does NOT
     * serialize across nodes. The slot_guard unique index rejects identical
     * staff/start pairs, but SQL unique indexes cannot express arbitrary
     * interval overlap. Multi-writer installations therefore need database
     * routing that pins booking writes to one writer.
     *
     * @since 4.4.3
     * @param DateTime|string $start_at Booking start (site local time).
     * @param DateTime|string $end_at   Booking end (site local time).
     * @param int             $timeout  Seconds to wait per lock.
     * @return string[]|false Lock keys on success, false if a lock timed out.
     */
    private function acquire_booking_day_locks( $start_at, $end_at = null, $timeout = 8 ) {
        global $wpdb;

        $tz    = yatoon_timezone();
        $dates = array();
        foreach (array($start_at, $end_at) as $value) {
            if (empty($value)) {
                continue;
            }
            try {
                if ($value instanceof DateTimeInterface) {
                    $dt = new DateTime('@' . $value->getTimestamp());
                    $dt->setTimezone($tz);
                } else {
                    $dt = new DateTime((string) $value, $tz);
                }
                $dates[] = $dt->format('Y-m-d');
            } catch (Exception $e) {
                // An unparseable date would silently skip locking, which is
                // exactly the failure mode we are trying to remove. Fall back
                // to a constant bucket so the request is still serialized.
                $dates[] = 'unparseable';
            }
        }

        $dates = array_values(array_unique(array_filter($dates)));
        if (empty($dates)) {
            return false;
        }
        sort($dates); // ascending == consistent global ordering == no deadlock

        $acquired = array();
        foreach ($dates as $date) {
            $lock_key = 'yatoon_day_' . md5(home_url() . '|' . $date);
            $got      = (int) yatoon_db_get_var($wpdb->prepare(
                'SELECT GET_LOCK(%s, %d)',
                $lock_key,
                max(1, (int) $timeout)
            ));
            if ($got !== 1) {
                $this->release_booking_locks($acquired); // never hold a partial set
                return false;
            }
            $acquired[] = $lock_key;
        }

        return $acquired;
    }

    /**
     * Release locks taken by acquire_booking_day_locks().
     *
     * Safe to call twice: RELEASE_LOCK on a lock this connection no longer
     * holds is a no-op, and the guard array is emptied as we go.
     *
     * @since 4.4.3
     * @param string[]|string $lock_keys
     * @return void
     */
    public function release_booking_locks( $lock_keys ) {
        if (empty($lock_keys)) {
            return;
        }

        global $wpdb;
        foreach ((array) $lock_keys as $lock_key) {
            if (!$lock_key) {
                continue;
            }
            yatoon_db_get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_key));
        }
    }

    /**
     * BC shim: 4.4.2 and earlier registered this as a shutdown callback.
     *
     * A site that upgrades mid-request could still have the old callback
     * queued, so keep the name resolvable.
     *
     * @deprecated 4.4.3 Use release_booking_locks().
     * @param string $lock_key
     * @return void
     */
    private function release_local_booking_lock( $lock_key ) {
        $this->release_booking_locks($lock_key);
    }

    /**
     * Recheck the complete Square appointment immediately before creation.
     * This validates the exact requested start and every assigned segment as
     * one continuous appointment, reducing stale-slot and concurrent-write risk.
     */
    private function square_segments_available_at( $segments, $requested_start_at ) {
        if ( empty( $segments ) || empty( $requested_start_at ) ) {
            return false;
        }

        try {
            $start = new DateTimeImmutable( $requested_start_at );
        } catch ( Exception $e ) {
            return false;
        }

        $availability_segments = array();
        $total_duration = 0;
        foreach ( $segments as $segment ) {
            $variation_id = $segment['variation_id'] ?? ( $segment['service_variation_id'] ?? '' );
            $team_member_id = $segment['team_member_id'] ?? '';
            if ( empty( $variation_id ) || empty( $team_member_id ) ) {
                return false;
            }
            $availability_segments[] = array(
                'variation_id'  => $variation_id,
                'team_member_id' => $team_member_id,
            );
            $total_duration += max( 1, intval( $segment['duration_minutes'] ?? 1 ) );
        }

        $window_start = $start->setTimezone( new DateTimeZone( 'UTC' ) );
        $window_end = $start->modify( '+' . ( $total_duration + 61 ) . ' minutes' )->setTimezone( new DateTimeZone( 'UTC' ) );
        $start_iso = $window_start->format( 'Y-m-d\TH:i:s\Z' );
        $end_iso = $window_end->format( 'Y-m-d\TH:i:s\Z' );

        if ( count( $availability_segments ) > 1 ) {
            $response = $this->square_api->search_availability_multi_segment( $availability_segments, $start_iso, $end_iso );
        } else {
            $only = $availability_segments[0];
            $response = $this->square_api->search_availability( $only['variation_id'], $start_iso, $end_iso, $only['team_member_id'] );
        }

        if ( is_wp_error( $response ) || empty( $response['availabilities'] ) ) {
            return false;
        }

        foreach ( $response['availabilities'] as $availability ) {
            if ( yatoon_square_availability_matches_requested_start( $availability['start_at'] ?? '', $start->getTimestamp() ) ) {
                return true;
            }
        }

        return false;
    }

    private function resolve_design_reference_attachment( $attachment_id, $provided_token ) {
        $attachment_id = absint($attachment_id);
        if (!$attachment_id) {
            return array(
                'design_attachment_id' => null,
                'design_image_url'     => null,
                'design_upload_token'  => '',
            );
        }
        $url = wp_get_attachment_url($attachment_id);
        if (!$url || !wp_attachment_is_image($attachment_id)) {
            wp_send_json_error(array('message' => __('The selected design image is no longer available. Please choose it again.', 'yatoon-booking-system')));
        }
        $stored_token = (string) get_post_meta($attachment_id, '_yatoon_design_upload_token', true);
        $provided_token = sanitize_text_field((string) $provided_token);
        if ($stored_token === '' || $provided_token === '' || !hash_equals($stored_token, $provided_token)) {
            wp_send_json_error(array('message' => __('The design image authorization expired. Please choose the image again.', 'yatoon-booking-system')));
        }
        return array(
            'design_attachment_id' => $attachment_id,
            'design_image_url'     => esc_url_raw($url),
            'design_upload_token'  => $stored_token,
        );
    }

    private function service_allows_design_reference( $service_id, $option_ids ) {
        global $wpdb;
        $service_id = absint($service_id);
        if ($service_id && yatoon_db_get_var($wpdb->prepare(
            "SELECT allow_design_upload FROM {$wpdb->prefix}yatoon_services WHERE id = %d",
            $service_id
        ))) {
            return true;
        }
        $option_ids = array_values(array_unique(array_filter(array_map('absint', (array) $option_ids))));
        if (!$option_ids) {
            return false;
        }
        $placeholders = implode(',', array_fill(0, count($option_ids), '%d'));
        $args = $option_ids;
        $args[] = '%nail art%';
        $args[] = '%custom design%';
        return (bool) yatoon_db_get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->prefix}yatoon_service_options option_row
             LEFT JOIN {$wpdb->prefix}yatoon_services linked_service ON linked_service.square_id = option_row.square_id
             LEFT JOIN {$wpdb->prefix}yatoon_service_options source_option ON source_option.square_id = option_row.square_id AND source_option.id != option_row.id
             LEFT JOIN {$wpdb->prefix}yatoon_services source_service ON source_service.id = source_option.service_id
             WHERE option_row.id IN ($placeholders)
               AND (linked_service.allow_design_upload = 1 OR source_service.allow_design_upload = 1
                    OR LOWER(option_row.name) LIKE %s OR LOWER(option_row.name) LIKE %s)",
            $args
        ));
    }

    private function resolve_design_reference_upload() {
        $attachment_id = isset($_POST['design_attachment_id']) ? absint($_POST['design_attachment_id']) : 0;
        if ($attachment_id > 0) {
            $provided_token = isset($_POST['design_upload_token']) ? sanitize_text_field(wp_unslash($_POST['design_upload_token'])) : '';
            return $this->resolve_design_reference_attachment($attachment_id, $provided_token);
        }

        if (empty($_FILES['design_reference']) || empty($_FILES['design_reference']['tmp_name'])) {
            return array(
                'design_attachment_id' => null,
                'design_image_url'     => null,
                'design_upload_token'  => '',
            );
        }

        // 4.4.3: this endpoint is unauthenticated by design (customers upload a
        // reference photo before they have an account), so the ceiling on what
        // an abuser can write to disk is limit x size x IPs. 10 MB was more
        // headroom than a phone photo needs; 4 MB covers real usage and cuts
        // the worst case by 60%. Filterable for salons that want more.
        $max_bytes = (int) apply_filters('yatoon_design_upload_max_bytes', 4 * 1024 * 1024);
        $file = $_FILES['design_reference'];
        if (!empty($file['size']) && (int) $file['size'] > $max_bytes) {
            wp_send_json_error(array('message' => sprintf(
                /* translators: %s: maximum upload size, e.g. "4 MB". */
                __('Please upload a design image smaller than %s.', 'yatoon-booking-system'),
                size_format($max_bytes)
            ),));
        }

        $allowed_mimes = array(
            'jpg|jpeg|jpe' => 'image/jpeg',
            'png'          => 'image/png',
            'gif'          => 'image/gif',
            'webp'         => 'image/webp',
        );
        $checked = wp_check_filetype_and_ext($file['tmp_name'], $file['name'], $allowed_mimes);
        if (empty($checked['type']) || strpos($checked['type'], 'image/') !== 0) {
            wp_send_json_error(array('message' => __('Please upload a JPG, PNG, GIF, or WebP design image.', 'yatoon-booking-system')));
        }

        require_once ABSPATH . 'wp-admin/includes/file.php';
        require_once ABSPATH . 'wp-admin/includes/media.php';
        require_once ABSPATH . 'wp-admin/includes/image.php';

        $upload = wp_handle_upload($file, array(
            'test_form' => false,
            'mimes'     => $allowed_mimes,
        ));
        if (!empty($upload['error'])) {
            wp_send_json_error(array('message' => sprintf(
                /* translators: %s: Upload error message. */
                __('Design image upload failed: %s', 'yatoon-booking-system'),
                $upload['error']
            ),));
        }

        // Decode and re-encode before attachment creation. WordPress image
        // editors discard camera metadata (including GPS EXIF).
        $editor = wp_get_image_editor( $upload['file'] );
        $cleaned = is_wp_error( $editor ) ? $editor : $editor->save( $upload['file'] );
        if ( is_wp_error( $cleaned ) ) {
            wp_delete_file( $upload['file'] );
            wp_send_json_error(array('message' => __('The design image could not be safely processed. Please choose another image.', 'yatoon-booking-system')));
        }

        $attachment = array(
            'post_mime_type' => $upload['type'],
            'post_title'     => sanitize_file_name(pathinfo($upload['file'], PATHINFO_FILENAME)),
            'post_content'   => '',
            'post_status'    => 'inherit',
        );
        $attachment_id = wp_insert_attachment($attachment, $upload['file']);
        if (is_wp_error($attachment_id) || !$attachment_id) {
            wp_send_json_error(array('message' => __('Design image could not be saved to the media library.', 'yatoon-booking-system')));
        }
        $upload_token = wp_generate_password(32, false, false);
        update_post_meta($attachment_id, '_yatoon_design_upload_token', $upload_token);
        update_post_meta($attachment_id, '_yatoon_design_pending_at', time());

        $design_sizes = static function ( $sizes ) {
            return array_intersect_key((array) $sizes, array('thumbnail' => true, 'medium' => true));
        };
        add_filter('intermediate_image_sizes_advanced', $design_sizes, 999);
        $metadata = wp_generate_attachment_metadata($attachment_id, $upload['file']);
        remove_filter('intermediate_image_sizes_advanced', $design_sizes, 999);
        if (!is_wp_error($metadata) && !empty($metadata)) {
            wp_update_attachment_metadata($attachment_id, $metadata);
        }

        return array(
            'design_attachment_id' => absint($attachment_id),
            'design_image_url'     => esc_url_raw($upload['url']),
            'design_upload_token'  => $upload_token,
        );
    }

    public function upload_design_reference() {
        check_ajax_referer('yatoon_frontend_nonce', 'nonce');
        $this->enforce_public_rate_limit('design_upload', 8, 10);
        if (get_option('yatoon_allow_design_upload', '1') !== '1') {
            wp_send_json_error(array('message' => __('Design image uploads are currently disabled.', 'yatoon-booking-system')));
        }

        // 4.6.1: this is an unauthenticated write into the media library. It
        // used to check only the site-wide switch, never the per-service
        // "allow_design_upload" flag -- so any visitor could push 4 MB images
        // into wp-content/uploads and get back a public URL, on a salon that
        // had enabled the feature for one nail-art service. The upload card is
        // only rendered once a service or option that permits it is selected,
        // so a real client always has that context to send; no context means
        // the request did not come from the booking form.
        $design_service_id = isset($_POST['service_id']) ? absint(wp_unslash($_POST['service_id'])) : 0;
        $design_option_ids = isset($_POST['option_ids'])
            ? array_values(array_filter(array_map('absint', (array) wp_unslash($_POST['option_ids']))))
            : array();
        if (!$design_service_id && !$design_option_ids) {
            wp_send_json_error(array('message' => __('Please choose a service before uploading a reference photo.', 'yatoon-booking-system')));
        }
        if (!$this->service_allows_design_reference($design_service_id, $design_option_ids)) {
            wp_send_json_error(array('message' => __('This service does not accept reference photos.', 'yatoon-booking-system')));
        }

        if (empty($_FILES['design_reference']['tmp_name'])) {
            wp_send_json_error(array('message' => __('Please choose an image first.', 'yatoon-booking-system')));
        }
        $result = $this->resolve_design_reference_upload();
        wp_send_json_success(array(
            'design_attachment_id' => absint($result['design_attachment_id'] ?? 0),
            'design_image_url' => esc_url_raw($result['design_image_url'] ?? ''),
            'design_upload_token' => sanitize_text_field($result['design_upload_token'] ?? ''),
            'message' => __('Photo ready.', 'yatoon-booking-system'),
        ));
    }

    public function finalize_design_reference( $booking_data ) {
        $attachment_id = absint($booking_data['design_attachment_id'] ?? 0);
        if ($attachment_id) {
            delete_post_meta($attachment_id, '_yatoon_design_pending_at');
        }
        foreach ((array) ( $booking_data['design_references'] ?? array() ) as $design_reference) {
            $service_attachment_id = absint($design_reference['design_attachment_id'] ?? 0);
            if ($service_attachment_id) {
                delete_post_meta($service_attachment_id, '_yatoon_design_pending_at');
            }
        }
    }

    public function cleanup_design_uploads() {
        global $wpdb;
        $cutoff = time() - ( 2 * DAY_IN_SECONDS );
        // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
        $ids = get_posts(array(
            'post_type' => 'attachment',
            'post_status' => 'inherit',
            'posts_per_page' => 50,
            'fields' => 'ids',
            'meta_query' => array(array(
                'key' => '_yatoon_design_pending_at',
                'value' => $cutoff,
                'compare' => '<',
                'type' => 'NUMERIC',
            ),),
        ));
        foreach ($ids as $attachment_id) {
            $used = (int) yatoon_db_get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->prefix}yatoon_bookings WHERE design_attachment_id = %d",
                $attachment_id
            ));
            if (!$used) {
                wp_delete_attachment($attachment_id, true);
            } else {
                delete_post_meta($attachment_id, '_yatoon_design_pending_at');
            }
        }
    }

    /**
     * Create booking
     */
    public function create_booking() {
        check_ajax_referer('yatoon_frontend_nonce', 'nonce');

        // 4.5.4: a privacy-safe reference lets the customer and salon match a
        // visible failure to the corresponding log entry without exposing a
        // name, email, phone, token, or payment identifier.
        $client_reference = isset($_POST['client_reference'])
            ? strtoupper(preg_replace('/[^A-Z0-9-]/', '', sanitize_text_field(wp_unslash($_POST['client_reference']))))
            : '';
        if (strlen($client_reference) < 8 || strlen($client_reference) > 64) {
            $client_reference = 'YB-' . strtoupper(wp_generate_password(12, false, false));
        }
        $booking_error = static function ($message, $code, $recovery = 'contact_salon', $extra = array()) use ($client_reference) {
            $data = array_merge(array(
                'message'   => $message,
                'error_code'=> sanitize_key($code),
                'recovery'  => sanitize_key($recovery),
                'reference' => $client_reference,
                'retry_safe'=> false,
            ), is_array($extra) ? $extra : array());
            yatoon_safe_log(sprintf('Booking failure [%s] %s', $client_reference, sanitize_key($code)));
            wp_send_json_error($data);
        };

        // -- Layer 0: Bot traps (silent, zero friction for real visitors) ----
        // Honeypot: a field hidden off-screen that only non-visual bots fill in.
        if (!empty($_POST['website'])) {
            wp_send_json_error(array('message' => __('Unable to complete this booking. Please call us directly.', 'yatoon-booking-system')));
        }
        // Timing trap: real visitors take at least a couple seconds to read
        // the form and pick a service/staff/time before typing their details.
        // Scripts that fetch the page and POST immediately get caught here.
        $rendered_at = isset($_POST['form_rendered_at']) ? intval($_POST['form_rendered_at']) : 0;
        if ($rendered_at > 0) {
            $elapsed = time() - $rendered_at;
            $min_seconds = intval(get_option('yatoon_min_submit_seconds', 3));
            if ($elapsed >= 0 && $elapsed < $min_seconds) {
                wp_send_json_error(array('message' => __('Unable to complete this booking. Please call us directly.', 'yatoon-booking-system')));
            }
        }

        // Cloudflare Turnstile (optional - only enforced if configured in Settings > Protection)
        // Token is fetched fresh at submit time (execute mode). If the widget
        // never returned a token (script blocked, network hiccup, ad blocker),
        // we fail OPEN rather than block a real customer's booking - the
        // other layers (honeypot, timing, blacklist) still apply either way.
        // We only ever REJECT here when a token was actually returned and
        // Cloudflare says it's invalid.
        if (get_option('yatoon_turnstile_enabled', '0') === '1' && !empty(YATOON_Crypto::get_option('yatoon_turnstile_secret_key', ''))) {
            $ts_token = isset($_POST['cf_turnstile_response']) ? sanitize_text_field(wp_unslash($_POST['cf_turnstile_response'])) : '';
            $turnstile_fail_closed = get_option('yatoon_turnstile_fail_closed', '0') === '1';
            if (empty($ts_token) && $turnstile_fail_closed) {
                wp_send_json_error(array('message' => __('We could not verify this request. Please refresh the page and try again.', 'yatoon-booking-system')));
            }
            if (!empty($ts_token) && !$this->verify_turnstile($ts_token)) {
                wp_send_json_error(array('message' => __('We could not verify this request. Please refresh the page and try again.', 'yatoon-booking-system')));
            }
        }

        // Extend execution time: Square API calls (search_availability x staff + create_booking)
        // can take 10-25 seconds total. Default PHP limit (30s) causes silent timeouts.
        @set_time_limit(120);
        global $wpdb;

        // Determine initial booking status based on Auto-Confirm setting
        $initial_status = get_option('yatoon_auto_confirm_bookings', '1') === '1' ? 'confirmed' : 'pending';
        $is_test_booking = get_option('yatoon_test_booking_mode', '0') === '1'
            && current_user_can('manage_options')
            && !empty($_POST['yatoon_test_booking']);
        if ($is_test_booking) {
            $initial_status = 'pending';
        }
        if ( class_exists( 'YATOON_WooCommerce' ) && YATOON_WooCommerce::is_enabled() ) {
            $initial_status = 'pending';
        }

        // Common input
	        $service_id      = isset($_POST['service_id'])      ? intval($_POST['service_id'])              : 0;
	        $staff_id        = isset($_POST['staff_id'])        ? intval($_POST['staff_id'])                : 0;
	        $requested_staff_id = $staff_id;
	        $start_at        = isset($_POST['start_at'])        ? sanitize_text_field( wp_unslash( $_POST['start_at'] ) )   : '';
        $customer_name   = isset($_POST['customer_name'])   ? sanitize_text_field( wp_unslash( $_POST['customer_name'] ) ) : '';
        $customer_email  = isset($_POST['customer_email'])  ? sanitize_email( wp_unslash( $_POST['customer_email'] ) )  : '';
        $customer_phone  = isset($_POST['customer_phone'])  ? sanitize_text_field( wp_unslash( $_POST['customer_phone'] ) ) : '';

        // 4.6.1: create_booking is the most expensive public write in the
        // plugin -- it holds a day lock, calls Square, sends mail/SMS and can
        // occupy calendar time -- yet it was the ONLY public endpoint in this
        // file with no rate limit at all (contact 5/10min, design upload
        // 8/10min, waitlist 5/10min, available times 60/1min all had one).
        // The honeypot field and 3-second timing trap it relied on are trivial
        // for a script to satisfy, and Turnstile is off by default and
        // fails open when its verification endpoint is unreachable.
        // Two dimensions: per-IP catches a single flooding host, per-contact
        // catches a distributed flood aimed at one salon's inbox/SMS budget.
        // Staff creating bookings on a customer's behalf through the public
        // form are not the traffic shape this guards against, so anyone who
        // can already manage bookings in wp-admin is exempt.
        $yatoon_booking_is_privileged = is_user_logged_in()
            && ( current_user_can( 'yatoon_manage_bookings' ) || current_user_can( 'manage_options' ) );
        if ( ! $yatoon_booking_is_privileged ) {
            $this->enforce_public_rate_limit( 'create_booking', 8, 10 );
            $booking_contact_key = $customer_email !== ''
                ? strtolower( $customer_email )
                : YATOON_Request_Guard::normalize_phone_digits( $customer_phone );
            if ( '' !== $booking_contact_key ) {
                YATOON_Request_Guard::enforce( 'create_booking_contact', 6, 60, $booking_contact_key );
            }
        }

        $otp_channel     = isset($_POST['otp_channel'])     ? sanitize_key( wp_unslash( $_POST['otp_channel'] ) ) : '';
        $otp_verified_key = isset($_POST['otp_verified_key']) ? sanitize_text_field( wp_unslash( $_POST['otp_verified_key'] ) ) : '';
        $notes           = isset($_POST['notes'])           ? sanitize_textarea_field( wp_unslash( $_POST['notes'] ) )  : '';
        if ($is_test_booking) {
            $notes = trim("[Yatoon test booking]\n" . $notes);
        }
        $tip_amount      = isset($_POST['tip_amount'])      ? max(0, floatval($_POST['tip_amount'])) : 0;
        $gift_card_code  = isset($_POST['gift_card_code'])  ? strtoupper(sanitize_text_field(wp_unslash($_POST['gift_card_code']))) : '';
        $gift_card_amount = 0;

        // The usable amount is calculated from the final server-side total and
        // atomically redeemed only after a booking row exists.

        $custom_fields_raw = isset($_POST['custom_fields']) ? sanitize_textarea_field(wp_unslash($_POST['custom_fields'])) : '{}';
        $custom_fields_decoded = json_decode($custom_fields_raw, true);
        $custom_field_values = is_array($custom_fields_decoded) ? wp_json_encode($custom_fields_decoded) : null;
        $guest_label     = isset($_POST['guest_label'])     ? sanitize_text_field( wp_unslash( $_POST['guest_label'] ) ) : '';
        $guest_name      = isset($_POST['guest_name'])      ? sanitize_text_field( wp_unslash( $_POST['guest_name'] ) ) : '';
        $guest_phone     = isset($_POST['guest_phone'])     ? sanitize_text_field( wp_unslash( $_POST['guest_phone'] ) ) : '';
        $booking_group_id = isset($_POST['booking_group_id']) ? substr( sanitize_key( wp_unslash( $_POST['booking_group_id'] ) ), 0, 36 ) : '';
        $coupon_code     = isset($_POST['coupon_code'])     ? strtoupper(sanitize_text_field( wp_unslash( $_POST['coupon_code'] ) )) : '';
        $selected_options = isset($_POST['selected_options']) ? array_map('intval', (array) $_POST['selected_options']) : array();

        // Serialize repeated submissions of the same visible booking attempt.
        // This covers the dangerous case where Square accepted the first call
        // but the browser lost the response and submits the same cart again.
        $attempt_lock_key = 'yatoon_attempt_' . md5( home_url() . '|' . $client_reference );
        $attempt_lock_result = yatoon_db_get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $attempt_lock_key, 8 ) );
        if ( '0' === (string) $attempt_lock_result ) {
            $booking_error(
                __( 'This booking is still being confirmed. Please wait a moment before trying again.', 'yatoon-booking-system' ),
                'booking_attempt_busy',
                'retry',
                array( 'retry_safe' => true )
            );
        }
        if ( '1' === (string) $attempt_lock_result ) {
            register_shutdown_function( array( $this, 'release_booking_locks' ), $attempt_lock_key );
        } elseif ( function_exists( 'yatoon_safe_log' ) ) {
            // Some managed database services disable advisory locks. Square's
            // stable idempotency key and the persisted reference still protect
            // retries, so degrade gracefully instead of blocking every booking.
                yatoon_safe_log( __( 'Booking attempt advisory lock is unavailable; continuing with idempotency protection.', 'yatoon-booking-system' ) );
        }

        // The versioned migration verifies both the column and its lookup index.
        // Reading the autoloaded marker avoids a SHOW COLUMNS round-trip on every
        // public booking request. If a host blocked the migration, bookings keep
        // working without the new persistence field instead of failing closed.
        // 4.6.2: this was an exact equality against revision '4'. The manifest
        // revision has since moved to 5, 6 and now 7, so the test has been
        // silently false on every up-to-date site - which quietly disabled the
        // duplicate-safe booking retry path (client_reference was never written
        // and never looked up) on exactly the installs that had the column.
        // A ">= the revision that introduced the column" test is what was meant
        // and it survives future bumps. Revision 4 is when
        // yatoon_booking_attempt_reference_migration joined the manifest, and
        // the marker is only published once that column AND its index verify,
        // so any revision at or above 4 guarantees both exist.
        $reference_column_exists  = 4 <= (int) get_option( 'yatoon_schema_manifest_revision', 0 );
        $client_reference_fields = $reference_column_exists ? array( 'client_reference' => $client_reference ) : array();
        if ( $reference_column_exists ) {
            $existing_attempt = yatoon_db_get_row(
                $wpdb->prepare(
                    "SELECT b.*, s.name AS service_name
                       FROM {$wpdb->prefix}yatoon_bookings b
                       LEFT JOIN {$wpdb->prefix}yatoon_services s ON s.id = b.service_id
                      WHERE b.client_reference = %s
                      ORDER BY b.id ASC LIMIT 1",
                    $client_reference
                )
            );
            if ( $existing_attempt ) {
                $same_email = '' !== $customer_email && hash_equals( strtolower( (string) $existing_attempt->customer_email ), strtolower( $customer_email ) );
                $stored_phone = preg_replace( '/\D+/', '', (string) $existing_attempt->customer_phone );
                $given_phone  = preg_replace( '/\D+/', '', (string) $customer_phone );
                $same_phone = '' !== $given_phone && hash_equals( $stored_phone, $given_phone );
                if ( ! $same_email && ! $same_phone ) {
                    $booking_error( __( 'This booking reference does not match the supplied customer details.', 'yatoon-booking-system' ), 'booking_reference_mismatch' );
                }
                $existing_token = class_exists( 'Yatoon_Manage_Booking' ) ? Yatoon_Manage_Booking::create_token( (int) $existing_attempt->id ) : '';
                wp_send_json_success( array(
                    'message'      => __( 'This booking was already confirmed.', 'yatoon-booking-system' ),
                    'booking_id'   => (int) $existing_attempt->id,
                    'manage_url'   => $existing_token ? Yatoon_Manage_Booking::get_manage_url( $existing_token ) : '',
                    'calendar_url' => class_exists( 'YATOON_Calendar_Links' ) ? YATOON_Calendar_Links::get_calendar_url( (int) $existing_attempt->id, $existing_token ) : '',
                    'service_name' => (string) ( $existing_attempt->service_name ?? '' ),
                    'square_synced'=> ! empty( $existing_attempt->square_synced ),
                    'mode'         => sanitize_key( get_option( 'yatoon_active_platform', 'local' ) ),
                    'deduplicated' => true,
                    'reference'    => $client_reference,
                    'woocommerce_checkout_url' => class_exists( 'YATOON_WooCommerce' ) ? YATOON_WooCommerce::checkout_url( (int) $existing_attempt->id ) : '',
                ) );
            }
        }
        // The coupon is atomically claimed AFTER all validation passes, so a
        // failed-validation submission cannot increment the usage counter. See
        // claim_coupon() block further down (just before the platform branch).
        $claimed_coupon = null;
        $consent_enabled   = get_option('yatoon_consent_enabled', '0') === '1';
        $consent_accepted  = !empty($_POST['consent_accepted']) ? 1 : 0;
        $consent_signature = isset($_POST['consent_signature']) ? sanitize_text_field( wp_unslash( $_POST['consent_signature'] ) ) : '';
        $policy_enabled    = get_option('yatoon_policy_enabled', '0') === '1';
        $policy_require_ack = get_option('yatoon_policy_require_ack', '1') === '1';
        $policy_acknowledged = !empty($_POST['policy_acknowledged']) ? 1 : 0;

        // -- Online deposit verification (Pro only) ----------------------------
        $deposit_intent_id = isset($_POST['deposit_intent_id']) ? sanitize_text_field( wp_unslash( $_POST['deposit_intent_id'] ) ) : '';
        $square_payment_id = isset($_POST['square_payment_id']) ? sanitize_text_field( wp_unslash( $_POST['square_payment_id'] ) ) : '';
        $paypal_order_id   = isset($_POST['paypal_order_id']) ? strtoupper(sanitize_text_field( wp_unslash( $_POST['paypal_order_id'] ) )) : '';
        $deposit_fields    = array();
        $square_payments_enabled = class_exists('Yatoon_Square_Payments') && Yatoon_Square_Payments::is_enabled();
        $deposit_gateway = sanitize_key( get_option( 'yatoon_deposit_gateway', 'stripe' ) );
        $deposit_enabled_opt = !$square_payments_enabled
                               && get_option('yatoon_deposit_enabled', '0') === '1'
                               && 'stripe' === $deposit_gateway
                               && !empty(YATOON_Crypto::get_option('yatoon_deposit_stripe_secret', ''));
        $paypal_enabled_opt = !$square_payments_enabled
                              && 'paypal' === $deposit_gateway
                              && class_exists( 'Yatoon_PayPal' )
                              && Yatoon_PayPal::is_enabled();

        // 4.6.2: split "is a deposit required for THIS booking" (policy) from
        // "is a deposit gateway usable at all" (credentials), so per-customer
        // deposit rules - e.g. auto-requiring a deposit from a client with a
        // no-show history - have a supported extension point.
        //
        // Default behaviour is unchanged. $deposit_required starts as the exact
        // OR of the two flags computed above, and each branch below is still
        // additionally gated on its own gateway-readiness test, so with no
        // filter attached every combination evaluates identically to 4.6.1:
        //   - deposits off            -> $deposit_required false  -> both off
        //   - stripe deposits on      -> required true, stripe ready  -> on
        //   - paypal deposits on      -> required true, paypal ready  -> on
        //   - stripe on but no secret -> required false             -> off
        $deposit_stripe_ready = !$square_payments_enabled
                                && 'stripe' === $deposit_gateway
                                && !empty(YATOON_Crypto::get_option('yatoon_deposit_stripe_secret', ''));
        $deposit_paypal_ready = $paypal_enabled_opt;
        $deposit_required     = ( $deposit_enabled_opt || $paypal_enabled_opt );

        // The context payload is only assembled when something is listening, so
        // an unfiltered site pays no extra query for the client lookup.
        if ( has_filter( 'yatoon_require_deposit' ) ) {
            $deposit_client_id = 0;
            if ( '' !== $customer_email || '' !== $customer_phone ) {
                $deposit_client_id = (int) yatoon_db_get_var( $wpdb->prepare(
                    "SELECT id FROM {$wpdb->prefix}yatoon_clients
                      WHERE ( %s <> '' AND customer_email = %s )
                         OR ( %s <> '' AND customer_phone = %s )
                      ORDER BY id ASC LIMIT 1",
                    $customer_email,
                    $customer_email,
                    $customer_phone,
                    $customer_phone
                ) );
            }

            $total_amount = 0;
            if ( class_exists( 'Yatoon_Stripe' ) ) {
                $maybe_total  = Yatoon_Stripe::calculate_total_from_request( $_POST );
                $total_amount = is_wp_error( $maybe_total ) ? 0 : (float) $maybe_total;
            }

            /**
             * Filter whether this booking requires a deposit.
             *
             * @since 4.6.2
             * @param bool  $deposit_required Current decision from site settings.
             * @param array $context          client_id, email, phone, service_id, amount.
             */
            $deposit_required = (bool) apply_filters( 'yatoon_require_deposit', $deposit_required, array(
                'client_id'  => $deposit_client_id,
                'email'      => $customer_email,
                'phone'      => $customer_phone,
                'service_id' => $service_id,
                'amount'     => $total_amount,
            ) );
        }

        $deposit_enabled_opt = ( $deposit_required && $deposit_stripe_ready );
        $paypal_enabled_opt  = ( $deposit_required && $deposit_paypal_ready );

        if ( $square_payments_enabled ) {
            $booking_total = Yatoon_Square_Payments::calculate_total_from_request( $_POST );
            if ( is_wp_error( $booking_total ) ) {
                wp_send_json_error( array( 'message' => $booking_total->get_error_message() ) );
            }
            if ( $booking_total > 0 ) {
                if ( empty( $square_payment_id ) ) {
                    wp_send_json_error( array( 'message' => __('A Square payment is required to complete this booking.', 'yatoon-booking-system') ) );
                }
                $square_reference = 'square:' . $square_payment_id;
                $existing_square_payment_booking = yatoon_db_get_var(
                    $wpdb->prepare(
                        "SELECT id FROM {$wpdb->prefix}yatoon_bookings WHERE deposit_intent_id = %s LIMIT 1",
                        $square_reference
                    )
                );
                if ( $existing_square_payment_booking ) {
                    wp_send_json_error( array( 'message' => __('This Square payment has already been used for a booking.', 'yatoon-booking-system') ) );
                }
                $expected_cents = Yatoon_Square_Payments::calculate_payment_cents( $booking_total );
                $verify = Yatoon_Square_Payments::verify_payment( $square_payment_id, $expected_cents );
                if ( is_wp_error( $verify ) ) {
                    wp_send_json_error( array( 'message' => $verify->get_error_message() ) );
                }
                $deposit_amount = class_exists( 'Yatoon_Stripe' )
                    ? Yatoon_Stripe::from_stripe_amount( $verify['amount'] ?? 0 )
                    : round( ( $verify['amount'] ?? 0 ) / 100, 2 ); // Free build ships no Stripe class.
                $deposit_fields = array(
                    'deposit_paid'      => 1,
                    'deposit_intent_id' => $square_reference,
                    'deposit_amount'    => $deposit_amount,
                    'payment_status'    => 'deposit_paid',
                );
            }
        }
        if ( $deposit_enabled_opt ) {
            // calculate_total_from_request() now factors in active package /
            // membership / coupon discounts, so $booking_total reflects the
            // actual amount due at the salon. If discounts cover the whole
            // booking, skip the deposit step entirely.
            $booking_total = Yatoon_Stripe::calculate_total_from_request( $_POST );
            if ( is_wp_error( $booking_total ) ) {
                wp_send_json_error( array( 'message' => $booking_total->get_error_message() ) );
            }
            if ( $booking_total > 0 ) {
                if ( empty( $deposit_intent_id ) ) {
                    wp_send_json_error( array( 'message' => __('A deposit payment is required to complete this booking.', 'yatoon-booking-system') ) );
                }
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                $existing_deposit_booking = yatoon_db_get_var(
                    $wpdb->prepare(
                        "SELECT id FROM {$wpdb->prefix}yatoon_bookings WHERE deposit_intent_id = %s LIMIT 1",
                        $deposit_intent_id
                    )
                );
                if ( $existing_deposit_booking ) {
                    wp_send_json_error( array( 'message' => __('This deposit payment has already been used for a booking.', 'yatoon-booking-system') ) );
                }
                $verify = Yatoon_Stripe::verify_payment( $deposit_intent_id, Yatoon_Stripe::calculate_deposit_cents( $booking_total ) );
                if ( is_wp_error( $verify ) ) {
                    wp_send_json_error( array( 'message' => $verify->get_error_message() ) );
                }
                $deposit_amount = class_exists( 'Yatoon_Stripe' )
                    ? Yatoon_Stripe::from_stripe_amount( $verify['amount'] ?? 0 )
                    : round( ( $verify['amount'] ?? 0 ) / 100, 2 ); // Free build ships no Stripe class.
                $deposit_fields = array(
                    'deposit_paid'      => 1,
                    'deposit_intent_id' => $deposit_intent_id,
                    'deposit_amount'    => $deposit_amount,
                    'payment_status'    => 'deposit_paid',
                );
            }
        }
        if ( $paypal_enabled_opt ) {
            $booking_total = Yatoon_Stripe::calculate_total_from_request( $_POST );
            if ( is_wp_error( $booking_total ) ) {
                wp_send_json_error( array( 'message' => $booking_total->get_error_message() ) );
            }
            if ( $booking_total > 0 ) {
                if ( empty( $paypal_order_id ) ) {
                    wp_send_json_error( array( 'message' => __('A PayPal payment is required to complete this booking.', 'yatoon-booking-system') ) );
                }
                $paypal_reference = 'paypal:' . $paypal_order_id;
                $existing_paypal_booking = yatoon_db_get_var(
                    $wpdb->prepare(
                        "SELECT id FROM {$wpdb->prefix}yatoon_bookings WHERE deposit_intent_id LIKE %s LIMIT 1",
                        $wpdb->esc_like( $paypal_reference ) . '%'
                    )
                );
                if ( $existing_paypal_booking ) {
                    wp_send_json_error( array( 'message' => __('This PayPal payment has already been used for a booking.', 'yatoon-booking-system') ) );
                }
                $verify = Yatoon_PayPal::verify_payment( $paypal_order_id, Yatoon_Stripe::calculate_deposit_cents( $booking_total ) );
                if ( is_wp_error( $verify ) ) {
                    wp_send_json_error( array( 'message' => $verify->get_error_message() ) );
                }
                $deposit_fields = array(
                    'deposit_paid'      => 1,
                    'deposit_intent_id' => $paypal_reference . ':' . sanitize_text_field( $verify['capture_id'] ?? '' ),
                    'deposit_amount'    => (float) ( $verify['amount'] ?? 0 ),
                    'payment_status'    => 'deposit_paid',
                );
            }
        }

        $booking_service_ids = array();
        if (!empty($_POST['services_json'])) {
            $posted_services = json_decode(wp_unslash($_POST['services_json']), true);
            if (is_array($posted_services)) {
                foreach ($posted_services as $posted_service) {
                    if (!empty($posted_service['service_id'])) {
                        $booking_service_ids[] = intval($posted_service['service_id']);
                    }
                }
            }
        }
        if (empty($booking_service_ids) && $service_id) {
            $booking_service_ids[] = $service_id;
        }
        $booking_service_ids = array_values(array_unique(array_filter($booking_service_ids)));

        $service_requires_policy = false;
        $service_requires_consent = false;
        if (!empty($booking_service_ids)) {
            $placeholders = implode(',', array_fill(0, count($booking_service_ids), '%d'));
            $service_rules = yatoon_db_get_results($wpdb->prepare(
                "SELECT id, require_policy, require_consent FROM {$wpdb->prefix}yatoon_services WHERE id IN ($placeholders)",
                $booking_service_ids
            ));
            foreach ((array) $service_rules as $service_rule) {
                if (!empty($service_rule->require_policy)) {
                    $service_requires_policy = true;
                }
                if (!empty($service_rule->require_consent)) {
                    $service_requires_consent = true;
                }
            }
        }
        $policy_ack_required = ( $policy_enabled && $policy_require_ack ) || $service_requires_policy;
        $policy_should_save = $policy_enabled || $service_requires_policy;
        $consent_required = $consent_enabled || $service_requires_consent;

        if (!$service_id || !$start_at || !$customer_name || !$customer_email || !$customer_phone) {
            wp_send_json_error(array('message' => __('Please fill in all required fields (including phone number)', 'yatoon-booking-system')));
        }
        $requires_email_otp = get_option('yatoon_verify_email_otp', '0') === '1';
        $requires_sms_otp   = get_option('yatoon_verify_sms_otp', '0') === '1';
        if ($requires_email_otp || $requires_sms_otp) {
            $required_channel = $requires_sms_otp ? 'sms' : 'email';
            $otp_channel = in_array($otp_channel, array('email', 'sms'), true) ? $otp_channel : $required_channel;
            if ($otp_channel !== $required_channel) {
                wp_send_json_error(array('message' => __('Please complete booking verification before confirming.', 'yatoon-booking-system')));
            }
            // 4.4.3: validate the random receipt token instead of a hash the
            // caller could compute for themselves. See
            // yatoon_otp_receipt_is_valid().
            if (!yatoon_otp_receipt_is_valid($otp_verified_key, $required_channel, $customer_email, $customer_phone)) {
                wp_send_json_error(array('message' => __('Please complete booking verification before confirming.', 'yatoon-booking-system')));
            }
        }
        if ($policy_ack_required && !$policy_acknowledged) {
            wp_send_json_error(array('message' => __('Please review and acknowledge the cancellation / no-show policy before booking.', 'yatoon-booking-system')));
        }
        if ($consent_required && ( !$consent_accepted || $consent_signature === '' )) {
            wp_send_json_error(array('message' => __('Please read and sign the consent form before booking.', 'yatoon-booking-system')));
        }

        $design_reference_fields = array(
            'design_attachment_id' => null,
            'design_image_url'     => null,
            'design_upload_token'  => '',
        );

        $consent_booking_fields = array_merge( array(
            'consent_accepted'      => $consent_required ? $consent_accepted : 0,
            'consent_signature'     => $consent_required ? $consent_signature : null,
            'consent_signed_at'     => ( $consent_required && $consent_accepted ) ? current_time('mysql', true) : null,
            'consent_text_snapshot' => $consent_required ? wp_strip_all_tags(get_option('yatoon_consent_text', '')) : null,
            'policy_acknowledged'   => $policy_should_save ? $policy_acknowledged : 0,
            'policy_acknowledged_at'=> ( $policy_should_save && $policy_acknowledged ) ? current_time('mysql', true) : null,
            'policy_text_snapshot'  => $policy_should_save ? wp_strip_all_tags(get_option('yatoon_policy_text', '')) : null,
            'booking_timezone'      => yatoon_timezone_string(),
            'location_id'           => absint( $_POST['yatoon_location_id'] ?? 0 ) ?: null,
        ), $deposit_fields, $design_reference_fields );

        // ------------------------------------------------------------
        // ANTI-ABUSE PROTECTION LAYER
        // ------------------------------------------------------------

        // -- Layer 1: Phone number format validation -----------------------
        $ybs_phone_required = get_option('yatoon_phone_required','1') === '1';
        $ybs_phone_mode     = get_option('yatoon_phone_mode','us_only');
        $phone_digits = preg_replace('/\D/', '', $customer_phone);

        if ($ybs_phone_mode === 'us_only') {
            // US-only: accept 10-digit or 11-digit starting with 1
            if (strlen($phone_digits) === 11 && substr($phone_digits, 0, 1) === '1') {
                $phone_digits = substr($phone_digits, 1);
            }
            if ($ybs_phone_required && strlen($phone_digits) !== 10) {
                wp_send_json_error(array('message' => __('Please enter a valid 10-digit US phone number.', 'yatoon-booking-system')));
            }
            // -- US number quality checks ----------------------------------
            if ( strlen($phone_digits) === 10 ) {
                $area_code = substr($phone_digits, 0, 3);
                $exchange  = substr($phone_digits, 3, 3);
                // Area code cannot start with 0 or 1
                if ( in_array($area_code[0], array('0','1'), true) ) {
                    wp_send_json_error(array('message' => __('Please enter a valid US phone number.', 'yatoon-booking-system')));
                }
                // Exchange cannot start with 0 or 1
                if ( in_array($exchange[0], array('0','1'), true) ) {
                    wp_send_json_error(array('message' => __('Please enter a valid US phone number.', 'yatoon-booking-system')));
                }
                // Reject all-same digits (e.g. 1111111111, 0000000000)
                if ( strlen(count_chars($phone_digits, 3)) === 1 ) {
                    wp_send_json_error(array('message' => __('Please enter a valid US phone number.', 'yatoon-booking-system')));
                }
                // Reject known fake/sequential numbers
                $fake_numbers = array('1234567890','0123456789','9876543210','5555555555','1231231234','0000000000','1111111111','2222222222','3333333333','4444444444','6666666666','7777777777','8888888888','9999999999');
                if ( in_array($phone_digits, $fake_numbers, true) ) {
                    wp_send_json_error(array('message' => __('Please enter a valid US phone number.', 'yatoon-booking-system')));
                }
            }
            $customer_phone_normalized = $phone_digits;
        } else {
            // International mode: accept E.164 (+countrycode + digits) or any digit string >= 6 digits
            // If submitted as E.164 (starts with +), keep as-is; otherwise accept raw digits
            if (strlen($customer_phone) > 0 && $customer_phone[0] === '+') {
                // E.164 format from intl-tel-input - store full value, normalize last 10 digits for blacklist checks
                if ($ybs_phone_required && strlen($phone_digits) < 6) {
                    wp_send_json_error(array('message' => __('Please enter a valid phone number.', 'yatoon-booking-system')));
                }
                $customer_phone_normalized = strlen($phone_digits) >= 10 ? substr($phone_digits, -10) : $phone_digits;
            } else {
                if ($ybs_phone_required && strlen($phone_digits) < 6) {
                    wp_send_json_error(array('message' => __('Please enter a valid phone number.', 'yatoon-booking-system')));
                }
                $customer_phone_normalized = strlen($phone_digits) >= 10 ? substr($phone_digits, -10) : $phone_digits;
            }
        }

        // -- Layer 1.5: Phone authenticity check (Twilio Lookup, optional) --
        // Confirms the number is a real, dialable number (and optionally that
        // it's a mobile line that can receive SMS) - no extra step for the
        // customer, this runs silently in the background of their submit.
        if (get_option('yatoon_phone_lookup_enabled', '0') === '1' && !empty($customer_phone) && class_exists('YATOON_Twilio')) {
            $lookup = YATOON_Twilio::lookup_validate($customer_phone);
            if ($lookup['checked'] && !$lookup['valid']) {
                wp_send_json_error(array('message' => __('Please enter a valid, active phone number.', 'yatoon-booking-system')));
            }
            if ($lookup['checked'] && get_option('yatoon_phone_lookup_block_voip', '0') === '1' && $lookup['line_type'] === 'nonFixedVoip') {
                wp_send_json_error(array('message' => __('Please enter a mobile or landline phone number (not a VOIP number).', 'yatoon-booking-system')));
            }
        }

        // -- Layer 2: Blacklist check (phone, email, IP) --------------------
        $client_ip        = $this->get_client_ip();
        $blacklist_phones = array_filter(array_map('trim', explode("\n", get_option('yatoon_blacklist_phones', ''))));
        $blacklist_emails = array_filter(array_map('trim', explode("\n", get_option('yatoon_blacklist_emails', ''))));
        $blacklist_ips    = array_filter(array_map('trim', explode("\n", get_option('yatoon_blacklist_ips', ''))));

        if (in_array($customer_phone_normalized, array_map(fn( $p ) => preg_replace('/\D/', '', $p), $blacklist_phones), true)) {
            wp_send_json_error(array('message' => __('Unable to complete this booking. Please call us directly.', 'yatoon-booking-system')));
        }
        if (in_array(strtolower($customer_email), array_map('strtolower', $blacklist_emails), true)) {
            wp_send_json_error(array('message' => __('Unable to complete this booking. Please call us directly.', 'yatoon-booking-system')));
        }
        if (!empty($client_ip) && in_array($client_ip, $blacklist_ips, true)) {
            wp_send_json_error(array('message' => __('Unable to complete this booking. Please call us directly.', 'yatoon-booking-system')));
        }

        // -- Layer 3: IP rate limiting -------------------------------------
        if (!empty($client_ip)) {
            $rate_key     = 'yatoon_rate_' . md5($client_ip);
            $rate_data    = get_transient($rate_key);
            $rate_count   = $rate_data ? intval($rate_data) : 0;
            $rate_limit   = intval(get_option('yatoon_rate_limit_per_ip', 10));
            $rate_minutes = intval(get_option('yatoon_rate_limit_minutes', 10));
            if ($rate_count >= $rate_limit) {
                wp_send_json_error(array('message' => sprintf(
                    /* translators: %d: Number of minutes before another booking attempt is allowed. */
                    __('Too many booking attempts. Please wait %d minutes and try again, or call us directly.', 'yatoon-booking-system'),
                    $rate_minutes
                ),));
            }
            set_transient($rate_key, $rate_count + 1, $rate_minutes * MINUTE_IN_SECONDS);
        }

        // -- Layer 4: Per-phone future booking limit ------------------------
        // A phone number may not have more than N upcoming bookings.
        // Set to 0 in Protection settings to disable this check entirely.
        {
            global $wpdb;
            $tbl_bk_chk  = $wpdb->prefix . 'yatoon_bookings';
            $max_future  = intval(get_option('yatoon_max_future_bookings_per_phone', 0));

            if ($max_future > 0) {
                // DB stores start_time in UTC - compare against UTC now
                $now_str_chk = gmdate('Y-m-d H:i:s');

                // Auto-sync: mark past appointments as completed so they never block new bookings
                yatoon_db_query($wpdb->prepare(
                    "UPDATE $tbl_bk_chk
                        SET status = 'completed'
                      WHERE end_time < %s
                        AND status NOT IN ('cancelled','no_show','completed')",
                    $now_str_chk
                ));

                // Count future confirmed/pending bookings for this phone.
                // BUG FIX (v4.4.3): the previous query used REGEXP_REPLACE (only
                // available on MySQL 8+ / MariaDB 10.0.5+) and LIKE %digits%
                // which falsely matched any phone whose last digits happened to
                // contain the same substring. We now strip the common formatting
                // chars in pure SQL and compare the last 10 digits exactly.
                $future_count = yatoon_db_get_var($wpdb->prepare(
                    "SELECT COUNT(*) FROM $tbl_bk_chk
                      WHERE RIGHT(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(customer_phone,'-',''),' ',''),'(',''),')',''),'+',''),'.',''),'\t',''), 10) = %s
                        AND start_time > %s
                        AND status NOT IN ('cancelled','no_show','completed')",
                    $customer_phone_normalized,
                    $now_str_chk
                ));
                if (intval($future_count) >= $max_future) {
                    wp_send_json_error(array('message' => sprintf(
                        /* translators: %d: Number of the customer's upcoming appointments. */
                        __('You already have %d upcoming appointments. Please cancel an existing appointment before booking a new one, or call us.', 'yatoon-booking-system'),
                        intval($future_count)
                    ),));
                }
            }
        }
        // ------------------------------------------------------------
        // END ANTI-ABUSE PROTECTION
        // ------------------------------------------------------------

        global $wpdb;
        $design_upload_service_ids = array();
        $design_upload_option_ids = $selected_options;
        $design_upload_services = array();
        $design_reference_by_service = array();
        if (!empty($_POST['services_json'])) {
            $design_upload_services = json_decode(wp_unslash($_POST['services_json']), true);
            if (is_array($design_upload_services)) {
                foreach ($design_upload_services as $design_upload_service) {
                    if (!empty($design_upload_service['service_id'])) {
                        $design_upload_service_ids[] = absint($design_upload_service['service_id']);
                    }
                    if (!empty($design_upload_service['option_ids']) && is_array($design_upload_service['option_ids'])) {
                        $design_upload_option_ids = array_merge(
                            $design_upload_option_ids,
                            array_map('absint', $design_upload_service['option_ids'])
                        );
                    }
                }
            }
        }
        if (empty($design_upload_service_ids) && $service_id) {
            $design_upload_service_ids[] = absint($service_id);
        }
        $design_upload_service_ids = array_values(array_unique(array_filter($design_upload_service_ids)));
        $design_upload_option_ids = array_values(array_unique(array_filter($design_upload_option_ids)));
        $design_upload_allowed = false;
        if (!empty($design_upload_service_ids)) {
            $design_placeholders = implode(',', array_fill(0, count($design_upload_service_ids), '%d'));
            $design_upload_allowed = (bool) yatoon_db_get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->prefix}yatoon_services
                 WHERE id IN ($design_placeholders) AND allow_design_upload = 1",
                $design_upload_service_ids
            ));
        }
        if (!$design_upload_allowed && !empty($design_upload_option_ids)) {
            $design_option_placeholders = implode(',', array_fill(0, count($design_upload_option_ids), '%d'));
            $design_option_query_args = $design_upload_option_ids;
            $design_option_query_args[] = '%nail art%';
            $design_option_query_args[] = '%custom design%';
            $design_upload_allowed = (bool) yatoon_db_get_var($wpdb->prepare(
                "SELECT COUNT(*)
                 FROM {$wpdb->prefix}yatoon_service_options option_row
                 LEFT JOIN {$wpdb->prefix}yatoon_services linked_service
                    ON linked_service.square_id = option_row.square_id
                 LEFT JOIN {$wpdb->prefix}yatoon_service_options source_option
                    ON source_option.square_id = option_row.square_id
                   AND source_option.id != option_row.id
                 LEFT JOIN {$wpdb->prefix}yatoon_services source_service
                    ON source_service.id = source_option.service_id
                 WHERE option_row.id IN ($design_option_placeholders)
                   AND (
                        linked_service.allow_design_upload = 1
                        OR source_service.allow_design_upload = 1
                        OR LOWER(option_row.name) LIKE %s
                        OR LOWER(option_row.name) LIKE %s
                   )",
                $design_option_query_args
            ));
        }
        if (get_option('yatoon_allow_design_upload', '1') === '1' && is_array($design_upload_services)) {
            foreach ($design_upload_services as $design_index => $design_upload_service) {
                $attachment_id = absint($design_upload_service['design_attachment_id'] ?? 0);
                if (!$attachment_id) {
                    continue;
                }
                $posted_service_id = absint($design_upload_service['service_id'] ?? 0);
                $posted_option_ids = array_map('absint', (array) ( $design_upload_service['option_ids'] ?? array() ));
                if (!$this->service_allows_design_reference($posted_service_id, $posted_option_ids)) {
                    wp_send_json_error(array('message' => __('A reference photo was attached to a service that does not allow design uploads.', 'yatoon-booking-system')));
                }
                $design_reference_by_service[ $design_index ] = $this->resolve_design_reference_attachment(
                    $attachment_id,
                    sanitize_text_field((string) ( $design_upload_service['design_upload_token'] ?? '' ))
                );
            }
        }
        if (get_option('yatoon_allow_design_upload', '1') === '1' && $design_upload_allowed) {
            if ($design_reference_by_service && count($design_upload_services) <= 1) {
                $design_reference_fields = reset($design_reference_by_service);
                $consent_booking_fields  = array_merge($consent_booking_fields, $design_reference_fields);
            } elseif (!$design_reference_by_service) {
                $design_reference_fields = $this->resolve_design_reference_upload();
                $consent_booking_fields  = array_merge($consent_booking_fields, $design_reference_fields);
            }
        }

        $table_services        = $wpdb->prefix . 'yatoon_services';
        $table_staff           = $wpdb->prefix . 'yatoon_staff';
        $table_bookings        = $wpdb->prefix . 'yatoon_bookings';
        $table_booking_options = $wpdb->prefix . 'yatoon_booking_options';

        $service = yatoon_db_get_row($wpdb->prepare("SELECT * FROM $table_services WHERE id = %d", $service_id));
        if (!$service) {
            wp_send_json_error(array('message' => __('Invalid service selection', 'yatoon-booking-system')));
        }

        // Staff: local mode must stay inside WordPress. Square availability checks
        // only run when the active platform is Square.
        $platform = sanitize_key((string) get_option('yatoon_active_platform', 'local'));
        if (!in_array($platform, array('local', 'square', 'vagaro'), true)) {
            $platform = 'local';
        }
        $staff = null;
        if ($platform !== 'square') {
            if ($staff_id > 0) {
                $staff = yatoon_db_get_row($wpdb->prepare(
                    "SELECT * FROM $table_staff WHERE id = %d AND is_active = 1 AND is_bookable = 1",
                    $staff_id
                ));
            }

            if (!$staff) {
                $table_ss_local = $wpdb->prefix . 'yatoon_staff_services';
                $eligible_local = yatoon_db_get_results($wpdb->prepare(
                    "SELECT s.* FROM $table_staff s
                     INNER JOIN $table_ss_local ss ON s.id = ss.staff_id
                     WHERE ss.service_id = %d AND s.is_active = 1 AND s.is_bookable = 1
                     ORDER BY s.sort_order ASC, s.name ASC",
                    $service_id
                ));

                if (empty($eligible_local)) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
                    $eligible_local = yatoon_db_get_results(
                        "SELECT * FROM $table_staff WHERE is_active = 1 AND is_bookable = 1 ORDER BY sort_order ASC, name ASC"
                    );
                }

                if (!empty($eligible_local)) {
                    $staff = $eligible_local[0];
                    $staff_id = intval($staff->id);
                }
            }
        }
        if ($platform === 'square' && $staff_id === 0) {
            $staff_map_json_req = isset($_POST['staff_map_json']) ? wp_unslash($_POST['staff_map_json']) : '';
            $staff_map_req = $staff_map_json_req ? json_decode($staff_map_json_req, true) : null;
            if (is_array($staff_map_req) && !empty($staff_map_req[0])) {
                $mapped_square_staff_id = sanitize_text_field($staff_map_req[0]);
                $table_staff_services_map = $wpdb->prefix . 'yatoon_staff_services';
                $mapped_staff = yatoon_db_get_row($wpdb->prepare(
                    "SELECT s.* FROM $table_staff s
                     INNER JOIN $table_staff_services_map ss ON s.id = ss.staff_id
                     WHERE s.square_id = %s AND ss.service_id = %d
                       AND s.is_active = 1 AND s.is_bookable = 1 LIMIT 1",
                    $mapped_square_staff_id,
                    $service_id
                ));
                if ($mapped_staff) {
                    $staff = $mapped_staff;
                    $staff_id = intval($mapped_staff->id);
                }
            }
        }
        if ($platform === 'square' && $staff_id > 0) {
            $staff = yatoon_db_get_row($wpdb->prepare("SELECT * FROM $table_staff WHERE id = %d", $staff_id));
            // For named staff, verify with Square they are actually available (catches blocked/off days)
            if ($staff) {
                $ns_var_id = null;
                $table_opts_ns = $wpdb->prefix . 'yatoon_service_options';
                if (!empty($selected_options)) {
                    $ids_ns = implode(',', array_map('intval', $selected_options));
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    $ns_var = yatoon_db_get_row("SELECT square_id FROM $table_opts_ns WHERE id IN ($ids_ns) AND square_id != '' ORDER BY CASE WHEN group_name = 'Add-on' THEN 1 ELSE 0 END, sort_order ASC, id ASC LIMIT 1");
                    if ($ns_var) { $ns_var_id = $ns_var->square_id;
                    }
                }
                if (!$ns_var_id) {
                    $ns_var = yatoon_db_get_row($wpdb->prepare(
                        "SELECT square_id FROM $table_opts_ns WHERE service_id=%d AND square_id != '' LIMIT 1",
                        $service_id
                    ));
                    if ($ns_var) { $ns_var_id = $ns_var->square_id;
                    }
                }
                if ($ns_var_id) {
                    $ns_start_dt = new DateTime($start_at);
                    $ns_start_ts = $ns_start_dt->getTimestamp();
                    $ns_dur      = intval($service->duration_minutes ?: 60);
                    $ns_win_s    = clone $ns_start_dt;
$ns_win_s->setTimezone(new DateTimeZone('UTC'));
                    $ns_win_e    = clone $ns_start_dt;
$ns_win_e->modify('+' . ( $ns_dur + 61 ) . ' minutes')->setTimezone(new DateTimeZone('UTC'));
                    $ns_s_iso    = $ns_win_s->format('Y-m-d\TH:i:s\Z');
                    $ns_e_iso    = $ns_win_e->format('Y-m-d\TH:i:s\Z');

                    // Resolve add-on variation IDs for multi-segment verification
                    $ns_addon_var_ids = array();
                    if (!empty($selected_options)) {
                        $table_opts_ns2 = $wpdb->prefix . 'yatoon_service_options';
                        $ns_opt_ids_str = implode(',', array_map('intval', $selected_options));
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                        $ns_addon_rows = yatoon_db_get_results(
                            "SELECT square_id FROM $table_opts_ns2
                             WHERE id IN ($ns_opt_ids_str) AND square_id != ''
                               AND group_name = 'Add-on'"
                        );
                        foreach ((array) $ns_addon_rows as $ns_ar) {
                            $ns_addon_var_ids[] = $ns_ar->square_id;
                        }
                    }

                    if (!empty($ns_addon_var_ids)) {
                        // Multi-segment: main + add-ons for this specific staff
                        $ns_segs = array(array('variation_id' => $ns_var_id, 'team_member_id' => $staff->square_id));
                        foreach ($ns_addon_var_ids as $ns_av_id) {
                            $ns_segs[] = array('variation_id' => $ns_av_id, 'team_member_id' => $staff->square_id);
                        }
                        $ns_resp = $this->square_api->search_availability_multi_segment($ns_segs, $ns_s_iso, $ns_e_iso);
                    } else {
                        $ns_resp = $this->square_api->search_availability($ns_var_id, $ns_s_iso, $ns_e_iso, $staff->square_id);
                    }
                    $ns_ok = false;
                    if (!is_wp_error($ns_resp) && !empty($ns_resp['availabilities'])) {
                        foreach ($ns_resp['availabilities'] as $ns_av) {
                            if (empty($ns_av['start_at'])) { continue;
                            }
                            if ( yatoon_square_availability_matches_requested_start( $ns_av['start_at'], $ns_start_ts ) ) {
                                $ns_ok = true;
break;
                            }
                        }
                    }
                    if (!$ns_ok) {
                        $booking_error(
                            __('Sorry, the selected staff member is not available at this time. Please choose a different time or staff.', 'yatoon-booking-system'),
                            'staff_unavailable',
                            'edit_appointment',
                            array('no_staff' => true)
                        );
                        return;
                    }
                }
            }
        }
        if ($platform === 'square' && !$staff) {
            // Any-staff: ask Square directly who is available at this time.
            // Square knows working hours, personal events (Busy blocks), and existing bookings.
            // We trust Square's answer completely - same principle as Pass2.
            $table_ss_sg = $wpdb->prefix . 'yatoon_staff_services';
            $eligible_sg = yatoon_db_get_results($wpdb->prepare(
                "SELECT s.* FROM $table_staff s
                 INNER JOIN $table_ss_sg ss ON s.id = ss.staff_id
                 WHERE ss.service_id = %d AND s.is_active=1 AND s.is_bookable=1
                 ORDER BY s.sort_order ASC, s.name ASC",
                $service_id
            ));
            if (empty($eligible_sg)) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                $eligible_sg = yatoon_db_get_results(
                    "SELECT * FROM $table_staff WHERE is_active=1 AND is_bookable=1 ORDER BY sort_order ASC, name ASC"
                );
            }

            // Get variation ID for this service (needed for search_availability)
            // The column is 'square_id' in yatoon_service_options (not 'square_variation_id')
            $sg_var_id = null;
            $table_opts_sg = $wpdb->prefix . 'yatoon_service_options';
            if (!empty($selected_options)) {
                $ids_sg = implode(',', array_map('intval', $selected_options));
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                $sg_var = yatoon_db_get_row("SELECT square_id FROM $table_opts_sg WHERE id IN ($ids_sg) AND square_id != '' ORDER BY CASE WHEN group_name = 'Add-on' THEN 1 ELSE 0 END, sort_order ASC, id ASC LIMIT 1");
                if ($sg_var) { $sg_var_id = $sg_var->square_id;
                }
            }
            if (!$sg_var_id) {
                $sg_var = yatoon_db_get_row($wpdb->prepare(
                    "SELECT square_id FROM $table_opts_sg WHERE service_id=%d AND square_id != '' LIMIT 1",
                    $service_id
                ));
                if ($sg_var) { $sg_var_id = $sg_var->square_id;
                }
            }

            // Build query window: [start_at, start_at + dur + 61min] in UTC ISO format
            $sg_svc_dur = intval($service->duration_minutes ?: 60);
            $sg_start_dt = new DateTime($start_at);
            $sg_start_ts = $sg_start_dt->getTimestamp();
            $sg_win_s = clone $sg_start_dt;
$sg_win_s->setTimezone(new DateTimeZone('UTC'));
            $sg_win_e = clone $sg_start_dt;
$sg_win_e->modify('+' . ( $sg_svc_dur + 61 ) . ' minutes')->setTimezone(new DateTimeZone('UTC'));
            $sg_s_iso = $sg_win_s->format('Y-m-d\TH:i:s\Z');
            $sg_e_iso = $sg_win_e->format('Y-m-d\TH:i:s\Z');

            if ($sg_var_id) {
                // Resolve add-on variation IDs for multi-segment check (if any add-ons selected)
                $sg_addon_var_ids = array();
                if (!empty($selected_options)) {
                    $table_opts_sg2 = $wpdb->prefix . 'yatoon_service_options';
                    $sg_opt_ids_str = implode(',', array_map('intval', $selected_options));
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    $sg_addon_rows = yatoon_db_get_results(
                        "SELECT square_id, duration_minutes FROM $table_opts_sg2
                         WHERE id IN ($sg_opt_ids_str) AND square_id != ''
                           AND group_name = 'Add-on'"
                    );
                    foreach ((array) $sg_addon_rows as $sg_addon_row) {
                        $sg_addon_var_ids[] = $sg_addon_row->square_id;
                    }
                }

                // Step 1: Query Square without specifying a team member to get the whitelist
                // of truly available staff (respects working hours + All-Day Personal Events).
                // If add-ons are present, use multi-segment so Square validates the full window.
                if (!empty($sg_addon_var_ids)) {
                    $sg_any_segs = array(array('variation_id' => $sg_var_id));
                    foreach ($sg_addon_var_ids as $addon_vid) {
                        $sg_any_segs[] = array('variation_id' => $addon_vid);
                    }
                    $sg_any_resp = $this->square_api->search_availability_multi_segment($sg_any_segs, $sg_s_iso, $sg_e_iso);
                } else {
                    $sg_any_resp = $this->square_api->search_availability($sg_var_id, $sg_s_iso, $sg_e_iso);
                }
                $sq_available_ids = array();
                if (!is_wp_error($sg_any_resp) && !empty($sg_any_resp['availabilities'])) {
                    foreach ($sg_any_resp['availabilities'] as $sg_av) {
                        if (empty($sg_av['start_at'])) { continue;
                        }
                        if ( yatoon_square_availability_matches_requested_start( $sg_av['start_at'], $sg_start_ts ) ) {
                            foreach ($sg_av['appointment_segments'] ?? array() as $seg) {
                                $tid = $seg['team_member_id'] ?? '';
                                if ($tid) { $sq_available_ids[ $tid ] = true;
                                }
                            }
                        }
                    }
                }
                yatoon_safe_log('SBS single-svc: Square available team IDs' . ( !empty($sg_addon_var_ids) ? ' (multi-seg)' : '' ) . ': ' . implode(', ', array_keys($sq_available_ids)));

                // Step 2: Ask Square directly for each eligible staff, skip if not in whitelist
                foreach ($eligible_sg as $cand_sg) {
                    if (empty($cand_sg->square_id)) { continue;
                    }

                    // Must be in the whitelist - filters out All-Day Blocked staff
                    if (!empty($sq_available_ids) && !isset($sq_available_ids[ $cand_sg->square_id ])) {
                        yatoon_safe_log("SBS single-svc: {$cand_sg->name} - blocked (not in Square available list)");
                        continue;
                    }

                    // If add-ons present, verify with multi-segment query per staff
                    if (!empty($sg_addon_var_ids)) {
                        $sg_cand_segs = array(array('variation_id' => $sg_var_id, 'team_member_id' => $cand_sg->square_id));
                        foreach ($sg_addon_var_ids as $addon_vid) {
                            $sg_cand_segs[] = array('variation_id' => $addon_vid, 'team_member_id' => $cand_sg->square_id);
                        }
                        $sg_resp = $this->square_api->search_availability_multi_segment($sg_cand_segs, $sg_s_iso, $sg_e_iso);
                    } else {
                        $sg_resp = $this->square_api->search_availability($sg_var_id, $sg_s_iso, $sg_e_iso, $cand_sg->square_id);
                    }
                    if (is_wp_error($sg_resp) || empty($sg_resp['availabilities'])) {
                        yatoon_safe_log("SBS single-svc: {$cand_sg->name} - Square says not available");
                        continue;
                    }
                    foreach ($sg_resp['availabilities'] as $sg_av) {
                        if (empty($sg_av['start_at'])) { continue;
                        }
                        if ( yatoon_square_availability_matches_requested_start( $sg_av['start_at'], $sg_start_ts ) ) {
                            $staff = $cand_sg;
                            yatoon_safe_log("SBS single-svc: {$cand_sg->name} - Square confirmed available");
                            break 2;
                        }
                    }
                    yatoon_safe_log("SBS single-svc: {$cand_sg->name} - no slot near requested time");
                }
            } else {
                // No variation ID - fallback to first eligible (Square will reject if unavailable)
                $staff = !empty($eligible_sg) ? $eligible_sg[0] : null;
                if ($staff) { yatoon_safe_log("SBS single-svc: no variation ID, fallback to {$staff->name}");
                }
            }
        }
        if (!$staff) {
            $booking_error(
                __('No staff available for this service. Please choose another time or staff.', 'yatoon-booking-system'),
                'no_qualified_staff',
                'edit_appointment',
                array('no_staff' => true)
            );
        }

        // Service option price/duration
        $booking_location_id = absint( $_POST['yatoon_location_id'] ?? 0 );
        $total_price    = $requested_staff_id > 0 && class_exists( 'YATOON_Staff_Locations' )
            ? YATOON_Staff_Locations::price_for( $staff->id, $service_id, $booking_location_id, $service->price )
            : floatval($service->price);
        $total_duration = intval($service->duration_minutes);

        if (!empty($selected_options)) {
            $table_options = $wpdb->prefix . 'yatoon_service_options';
            $ids_str = implode(',', array_map('intval', $selected_options));
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $options = yatoon_db_get_results("SELECT * FROM $table_options WHERE id IN ($ids_str) ORDER BY sort_order ASC, id ASC");
            if (!empty($options)) {
                $main_opt_set = false;
                foreach ($options as $opt) {
                    $is_addon = ( isset($opt->group_name) && $opt->group_name === 'Add-on' );
                    if (!$is_addon && !$main_opt_set) {
                        // Main option: sets the base duration (replaces service default)
                        if (intval($opt->duration_minutes) > 0) {
                            $total_duration = intval($opt->duration_minutes);
                        }
                        $total_price += floatval($opt->price_modifier);
                        $main_opt_set = true;
                    } elseif ($is_addon) {
                        // Add-on (e.g. Custom Design): add duration and price on top
                        $total_duration += intval($opt->duration_minutes);
                        $total_price    += floatval($opt->price_modifier);
                    }
                }
                // Fallback: if no main option found (all tagged as addon), process first as main
                if (!$main_opt_set && !empty($options)) {
                    $total_price += floatval($options[0]->price_modifier);
                    if (intval($options[0]->duration_minutes) > 0) {
                        $total_duration = intval($options[0]->duration_minutes);
                    }
                }
            }
        }

        $site_tz = yatoon_timezone();
        $start_time = new DateTime($start_at, $site_tz);
        $start_time->setTimezone($site_tz);
        $end_time   = clone $start_time;
        $end_time->modify("+{$total_duration} minutes");

        // Dynamic pricing is calculated from trusted database prices and the
        // validated appointment context. The same calculator is also used by
        // payment-intent creation, so the charged and stored totals cannot
        // diverge when peak/off-peak rules are active.
        if ( class_exists( 'YATOON_Dynamic_Pricing' ) ) {
            $dynamic_quote = YATOON_Dynamic_Pricing::quote(
                $total_price,
                array(
                    'service_id'  => $service_id,
                    'staff_id'    => $staff->id,
                    'location_id' => $booking_location_id,
                    'start_at'    => $start_time->format( 'Y-m-d H:i:s' ),
                )
            );
            $total_price = (float) $dynamic_quote['total'];
        }

        $rule_platform = sanitize_key( (string) get_option( 'yatoon_active_platform', 'local' ) );
        $staff_location_allowed = ! class_exists( 'YATOON_Staff_Locations' )
            || ( 'local' === $rule_platform
                ? YATOON_Staff_Locations::allows( $staff->id, $service_id, $booking_location_id, $start_time, $end_time )
                : YATOON_Staff_Locations::is_assigned( $staff->id, $service_id, $booking_location_id ) );
        if ( ! $staff_location_allowed ) {
            wp_send_json_error( array( 'message' => __( 'The selected staff member is not available for this service at this location.', 'yatoon-booking-system' ) ) );
        }
        if ( 'local' === $rule_platform && class_exists( 'YATOON_Group_Appointments' ) && YATOON_Group_Appointments::staff_has_session_conflict( $staff->id, $start_time, $end_time ) ) {
            wp_send_json_error( array( 'message' => __( 'The selected staff member is leading a group session at this time.', 'yatoon-booking-system' ) ) );
        }
        if ( class_exists( 'YATOON_Microsoft_Graph' ) && YATOON_Microsoft_Graph::is_busy( $start_time, $end_time, $staff->id ) ) {
            wp_send_json_error( array( 'message' => __( 'That time is busy in Outlook Calendar. Please choose another time.', 'yatoon-booking-system' ) ) );
        }

        $platform = sanitize_key((string) get_option('yatoon_active_platform', 'local'));
        if (!in_array($platform, array('local', 'square', 'vagaro'), true)) {
            $platform = 'local';
        }
        if ($is_test_booking && $platform !== 'local') {
            wp_send_json_error(array(
                'message' => __('Test booking mode creates local WordPress bookings only. Switch Booking Platform to Local Only before running a test booking.', 'yatoon-booking-system'),
            ));
        }

        // -- Serialize local booking creation on the calendar day(s) touched.
        //
        // 4.4.3: this used to key the lock on start-minute + service-ID set,
        // which failed to serialize the cases that actually double-book (same
        // staff, overlapping window, different service or different start).
        // See acquire_booking_day_locks() for the full explanation.
        //
        // The lock is taken here -- before staff assignment -- so that the
        // whole "pick a staff member, confirm they're free, insert the row"
        // sequence runs as one critical section. Taking it later would leave
        // the check-then-insert gap open, which is the bug we are closing.
        $local_booking_lock_key = false;
        if ($platform === 'local') {
            $local_booking_lock_key = $this->acquire_booking_day_locks($start_time, $end_time);
            if (!$local_booking_lock_key) {
                wp_send_json_error(array(
                    'message' => __('That time is being booked right now. Please wait a few seconds and try again.', 'yatoon-booking-system'),
                ));
            }
            // GET_LOCK is released when the connection closes, but persistent
            // connections (and long-running FPM workers) can keep it alive, so
            // release explicitly on every exit path including wp_send_json_*.
            register_shutdown_function(array($this, 'release_booking_locks'), $local_booking_lock_key);
        }

        // -- Atomically claim the coupon now that all validation has passed.
        //    Concurrent bookings cannot blow past usage_limit because
        //    claim_coupon() does the limit check + increment in one SQL.
        //    On any failure path BELOW this point that exits without inserting
        //    a booking row, release_coupon() must be called (see insert error
        //    branches and the wrapper at the end of create_booking).
        if ( $coupon_code !== '' && class_exists( 'YATOON_Growth_Modules' ) ) {
            $claimed_coupon = YATOON_Growth_Modules::claim_coupon( $coupon_code );
            if ( ! $claimed_coupon ) {
                wp_send_json_error( array(
                    'message' => __( 'This coupon is no longer available (expired or usage limit reached). Please remove it and try again.', 'yatoon-booking-system' ),
                ) );
            }
        }

        // -- Staff overlap check (single-service) ------------------------------
        //
        // 4.4.3: this block used to carry the comment "Prevents double-bookings
        // when two customers submit simultaneously" while the body did nothing
        // but write a log line and fall through. It prevented nothing, and the
        // comment actively misled anyone auditing this path.
        //
        // The behaviour now depends on who owns the calendar:
        //
        //   local  -> our database IS the source of truth. A conflict here is
        //             real and MUST block, otherwise we knowingly write a
        //             double booking. We are inside the day lock at this
        //             point, so this read cannot race an unseen insert.
        //   square -> Square owns the calendar and our mirror can legitimately
        //             be stale (e.g. cancelled in Square, not yet synced).
        //             Log and let the Square API reject it if it disagrees.
        if (!empty($staff->id)) {
            $table_bk_sg = $wpdb->prefix . 'yatoon_bookings';
            $ov_s = $start_time->format('Y-m-d H:i:s');
            $ov_e = $end_time->format('Y-m-d H:i:s');
            $ov_conflict = yatoon_db_get_var($wpdb->prepare(
                "SELECT id FROM $table_bk_sg
                 WHERE staff_id = %d
	                   AND status NOT IN ('cancelled','cancelled_by_customer','CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED','no_show')
                   AND start_time < %s
                   AND end_time   > %s
                 LIMIT 1",
                intval($staff->id), $ov_e, $ov_s
            ));
            if ($ov_conflict) {
                if ($platform === 'local') {
                    if ($claimed_coupon && class_exists('YATOON_Growth_Modules')) {
                        YATOON_Growth_Modules::release_coupon($coupon_code);
                    }
                    yatoon_safe_log("Yatoon overlap: blocked double booking for staff {$staff->name} at {$ov_s}-{$ov_e} (conflicts with booking {$ov_conflict}).");
                    wp_send_json_error(array(
                        'message' => __('Sorry, that time slot was just taken. Please choose another time.', 'yatoon-booking-system'),
                    ));
                }
                // Square mode: our mirror may be stale; Square is the arbiter.
                yatoon_safe_log("Yatoon overlap: DB conflict for staff {$staff->name} at {$ov_s}-{$ov_e}, proceeding - Square is the final arbiter.");
            }
        }
        // end single-service overlap check

        // Local / Vagaro: validate slot -> store locally -> send email
        // -- VAGARO MODE ------------------------------------------------------
        if ( $platform === 'vagaro' ) {
            if ( ! class_exists( 'YATOON_Vagaro_API' ) ) {
                require_once SBS_PLUGIN_DIR . 'includes/class-yatoon-vagaro-api.php';
            }
            $vagaro = new YATOON_Vagaro_API();
            global $wpdb;

            $customer_name  = sanitize_text_field( wp_unslash( $_POST['customer_name']  ?? '' ) );
            $customer_email = sanitize_email(       wp_unslash( $_POST['customer_email'] ?? '' ) );
            $customer_phone = sanitize_text_field( wp_unslash( $_POST['customer_phone'] ?? '' ) );
            $service_id     = absint( $_POST['service_id'] ?? 0 );
            $staff_id       = absint( $_POST['staff_id']   ?? 0 );
            $start_at       = sanitize_text_field( wp_unslash( $_POST['start_at'] ?? '' ) );
            $notes          = sanitize_textarea_field( wp_unslash( $_POST['notes'] ?? '' ) );

            $svc   = yatoon_db_get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}yatoon_services WHERE id = %d", $service_id ) ); // phpcs:ignore
            $staff = yatoon_db_get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}yatoon_staff WHERE id = %d", $staff_id ) );     // phpcs:ignore

            if ( ! $svc ) {
                wp_send_json_error( array( 'message' => __( 'Service not found.', 'yatoon-booking-system' ) ) );
            }

            $vagaro_service_id  = $svc->square_id ?? '';
            $vagaro_employee_id = $staff ? ( $staff->square_id ?? '' ) : '';

            if ( empty( $vagaro_service_id ) ) {
                wp_send_json_error( array( 'message' => __( 'This service is not linked to Vagaro. Please contact us.', 'yatoon-booking-system' ) ) );
            }

            // Parse start time
            $start_dt = new DateTime( $start_at, yatoon_timezone() );
            $start_dt->setTimezone( yatoon_timezone() );
            $end_dt   = clone $start_dt;
            $end_dt->modify( '+' . intval( $svc->duration_minutes ) . ' minutes' );

            // Build Vagaro appointment payload
            $vagaro_payload = array(
                'serviceId'   => $vagaro_service_id,
                'startTime'   => $start_dt->format( 'Y-m-d\TH:i:s' ),
                'endTime'     => $end_dt->format( 'Y-m-d\TH:i:s' ),
                'clientName'  => $customer_name,
                'clientEmail' => $customer_email,
                'clientPhone' => $customer_phone,
                'notes'       => $notes,
            );
            if ( $vagaro_employee_id ) {
                $vagaro_payload['employeeId'] = $vagaro_employee_id;
            }

            $vagaro_resp = $vagaro->create_booking( $vagaro_payload );

            if ( is_wp_error( $vagaro_resp ) ) {
                wp_send_json_error( array( 'message' => $vagaro_resp->get_error_message() ) );
            }

            $vagaro_booking_id = $vagaro_resp['id'] ?? $vagaro_resp['appointmentId'] ?? '';

            // Save to local DB
            $vagaro_net     = max(0, round((float) $total_price, 2));
            $vagaro_payable = YATOON_Tax::payable_total( $vagaro_net, $service_id );
            $gift_card_amount = yatoon_gift_card_available_amount($gift_card_code, $vagaro_payable);
            yatoon_db_insert( $wpdb->prefix . 'yatoon_bookings', array_merge( array( // phpcs:ignore
                'square_booking_id' => $vagaro_booking_id,
                'customer_name'     => $customer_name,
                'customer_email'    => $customer_email,
                'customer_phone'    => $customer_phone,
                'service_id'        => $service_id,
                'staff_id'          => $staff_id,
                'start_time'        => $start_dt->format( 'Y-m-d H:i:s' ),
                'end_time'          => $end_dt->format( 'Y-m-d H:i:s' ),
                'status'            => $initial_status,
                'notes'             => $notes,
                'total_price'       => max(0, round($vagaro_payable - $gift_card_amount, 2)),
                'square_synced'     => 1,
            ), $client_reference_fields, $consent_booking_fields, YATOON_Tax::booking_fields( $vagaro_net, $service_id ) ) );
            $local_booking_id = $wpdb->insert_id;
            if ($gift_card_amount > 0 && $local_booking_id) {
                $gift_card_amount = yatoon_redeem_gift_card($gift_card_code, $vagaro_payable, $local_booking_id);
                yatoon_db_update($wpdb->prefix . 'yatoon_bookings', array(
                    'total_price' => max(0, round($vagaro_payable - $gift_card_amount, 2)),
                ), array('id' => $local_booking_id));
            }

            // Generate manage token
            $manage_token = '';
            if ( class_exists( 'Yatoon_Manage_Booking' ) && $local_booking_id ) {
                $manage_token = Yatoon_Manage_Booking::create_token( $local_booking_id );
            }

            $booking_data = array(
                'booking_id'    => $local_booking_id,
                'customer_name' => $customer_name, 'customer_email' => $customer_email,
                'customer_phone'=> $customer_phone, 'service_name'   => $svc->name,
                'staff_name'    => $staff ? $staff->name : '', 'start_time' => $start_dt->format('Y-m-d H:i:s'),
                'end_time'      => $end_dt->format('Y-m-d H:i:s'), 'total_price' => max(0, round($vagaro_payable - $gift_card_amount, 2)),
                'notes'         => $notes, 'manage_token'  => $manage_token, 'selected_options' => array(),
            ) + $consent_booking_fields;
            if ( get_option( 'yatoon_send_confirmation_email', '1' ) === '1' && $customer_email ) {
                $this->send_confirmation_email( $booking_data, $svc, $staff );
            }
            do_action( 'yatoon_booking_created', $booking_data );

            wp_send_json_success( array(
                'booking_id'    => $local_booking_id,
                'vagaro_id'     => $vagaro_booking_id,
                'manage_token'  => $manage_token,
                'manage_url'    => $manage_token ? Yatoon_Manage_Booking::get_manage_url( $manage_token ) : '',
                'service_name'  => $svc->name ?? '',
                'woocommerce_checkout_url' => class_exists('YATOON_WooCommerce') ? YATOON_WooCommerce::checkout_url($local_booking_id) : '',
                'design_attachment_id' => $design_reference_fields['design_attachment_id'] ?? 0,
                'design_image_url' => $design_reference_fields['design_image_url'] ?? '',
                'design_upload_token' => $design_reference_fields['design_upload_token'] ?? '',
                'message'       => __( 'Booking confirmed!', 'yatoon-booking-system' ),
            ) );
        }

        if ($platform !== 'square') {
            // Local mode booking flow - mirrors Square flow but uses local DB
            // ------------------------------------------------------------
            $timezone_local  = yatoon_timezone_string();
            $tz_obj_local    = new DateTimeZone($timezone_local);
            $start_time_obj  = new DateTime($start_at, $tz_obj_local);

            $table_options_local = $wpdb->prefix . 'yatoon_service_options';
            $table_ss_local      = $wpdb->prefix . 'yatoon_staff_services';

            // Helper: get eligible staff for a service
            $get_eligible_local = function ( $svc_id ) use ( $wpdb, $table_staff, $table_ss_local, $start_at, $booking_location_id ) {
                $assigned = yatoon_db_get_results($wpdb->prepare(
                    "SELECT s.* FROM $table_staff s
                     INNER JOIN $table_ss_local ss ON s.id = ss.staff_id
                     WHERE ss.service_id = %d AND s.is_active=1 AND s.is_bookable=1
                     ORDER BY s.sort_order ASC, s.name ASC",
                    $svc_id
                ));
                if (empty($assigned)) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
                    $assigned = yatoon_db_get_results("SELECT * FROM $table_staff WHERE is_active=1 AND is_bookable=1 ORDER BY sort_order ASC, name ASC");
                }
                if ( class_exists( 'YATOON_Staff_Locations' ) ) {
                    $assigned = YATOON_Staff_Locations::filter_staff( (array) $assigned, $svc_id, $booking_location_id );
                }
                return class_exists('YATOON_Salon_Operations') ? YATOON_Salon_Operations::rank_staff((array) $assigned, $svc_id, $start_at) : $assigned;
            };

            $services_json_local  = isset($_POST['services_json']) ? wp_unslash($_POST['services_json']) : '';
            $multi_services_local = $services_json_local ? json_decode($services_json_local, true) : null;

            // ===== MULTI-SERVICE LOCAL BOOKING ===============================
            if ($multi_services_local && is_array($multi_services_local) && count($multi_services_local) > 1) {

                // -- Staff assignment -----------------------------------------
                $assigned_local       = array();
                // Track staff usage per guest_label so different guests can share
                // the same staff member (they start at the same time, not sequentially).
                $used_staff_per_guest = array(); // guest_label => [staff_id, ...]

                // Pass 1: named staff
                foreach ($multi_services_local as $idx => $svc_data) {
                    $svc_stf     = intval($svc_data['staff_id'] ?? 0);
                    $guest_label = isset($svc_data['guest_label']) && $svc_data['guest_label'] !== '' ? $svc_data['guest_label'] : 'Me';
                    if ($svc_stf > 0) {
                        $stf_row = yatoon_db_get_row($wpdb->prepare("SELECT * FROM $table_staff WHERE id = %d AND is_active=1 AND is_bookable=1", $svc_stf));
                        if ($stf_row) {
                            $assigned_local[ $idx ] = $stf_row;
                            $used_staff_per_guest[ $guest_label ][] = intval($stf_row->id);
                        }
                    }
                }

                // Pass 2: any staff - prefer unused staff, but never assign a staff
                // to two services whose time windows overlap (across any guest).
                // Pre-compute offset+duration per index so we can check overlaps.
	                {
	                    $pos_dur_local = array();
	                    $guest_off_tmp = array();
                    foreach ($multi_services_local as $idx => $svc_data) {
                        $gl  = isset($svc_data['guest_label']) && $svc_data['guest_label'] !== '' ? $svc_data['guest_label'] : 'Me';
                        $sid = intval($svc_data['service_id'] ?? 0);
                        $opts = array_map('intval', (array) ( $svc_data['option_ids'] ?? array() ));
	                        $dur = intval(yatoon_db_get_var($wpdb->prepare("SELECT duration_minutes FROM $table_services WHERE id = %d", $sid)) ?: 60);
	                        if (!empty($opts)) {
	                            $ids_pl = implode(',', array_map('intval', $opts));
	                            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	                            $opt_rows = yatoon_db_get_results("SELECT * FROM $table_options_local WHERE id IN ($ids_pl) ORDER BY sort_order ASC, id ASC");
	                            $main_set = false;
	                            foreach ($opt_rows as $opt_row) {
	                                $is_addon = ( isset($opt_row->group_name) && $opt_row->group_name === 'Add-on' );
	                                if (!$is_addon && !$main_set) {
	                                    if (intval($opt_row->duration_minutes) > 0) { $dur = intval($opt_row->duration_minutes);
                                        }
	                                    $main_set = true;
	                                } elseif ($is_addon) {
	                                    $dur += intval($opt_row->duration_minutes);
	                                }
	                            }
	                            if (!$main_set && !empty($opt_rows) && intval($opt_rows[0]->duration_minutes) > 0) {
	                                $dur = intval($opt_rows[0]->duration_minutes);
	                            }
	                        }
	                        $dur = max(1, $dur);
                        $off = $guest_off_tmp[ $gl ] ?? 0;
                        $pos_dur_local[ $idx ] = array('offset' => $off, 'duration' => $dur, 'guest_label' => $gl);
	                        $guest_off_tmp[ $gl ] = $off + $dur;
	                    }
	                }

                $local_candidate_available_for_window = function ( $candidate_staff_id, $offset_min, $duration_min, $resource_service_id = 0 ) use ( $wpdb, $table_bookings, $start_time_obj, $tz_obj_local, $booking_location_id ) {
	                    $candidate_staff_id = intval($candidate_staff_id);
	                    if ($candidate_staff_id <= 0) { return false;
                        }

	                    $svc_start_check = clone $start_time_obj;
	                    if (intval($offset_min) > 0) { $svc_start_check->modify('+' . intval($offset_min) . ' minutes');
                        }
                    $svc_end_check = clone $svc_start_check;
                    $svc_end_check->modify('+' . max(1, intval($duration_min)) . ' minutes');

                    if ( class_exists( 'YATOON_Staff_Locations' ) && ! YATOON_Staff_Locations::allows( $candidate_staff_id, $resource_service_id, $booking_location_id, $svc_start_check, $svc_end_check ) ) { return false;
                    }

	                    $slot_date = $svc_start_check->format('Y-m-d');
	                    $day_hours = YATOON_Business_Hours::get_day_hours($slot_date);
	                    if (!$day_hours) { return false;
                        }

	                    $open_dt  = new DateTime($slot_date . ' ' . ( $day_hours['start'] ?? '09:00' ), $tz_obj_local);
	                    $close_dt = new DateTime($slot_date . ' ' . ( $day_hours['end'] ?? '17:00' ), $tz_obj_local);
	                    if ($svc_start_check < $open_dt || $svc_end_check > $close_dt) { return false;
                        }

	                    if (!empty($day_hours['break_start']) && !empty($day_hours['break_end'])) {
	                        $break_start = new DateTime($slot_date . ' ' . $day_hours['break_start'], $tz_obj_local);
	                        $break_end   = new DateTime($slot_date . ' ' . $day_hours['break_end'], $tz_obj_local);
	                        if ($svc_start_check < $break_end && $svc_end_check > $break_start) { return false;
                            }
	                    }

	                    $conflict = yatoon_db_get_var($wpdb->prepare(
	                        "SELECT id FROM $table_bookings
	                         WHERE staff_id = %d
	                           AND status NOT IN ('cancelled','cancelled_by_customer','CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED','no_show')
	                           AND start_time < %s
	                           AND end_time   > %s
	                         LIMIT 1",
	                        $candidate_staff_id,
	                        $svc_end_check->format('Y-m-d H:i:s'),
	                        $svc_start_check->format('Y-m-d H:i:s')
	                    ));
	                    if ($conflict) { return false;
                        }
	                    if ($resource_service_id && class_exists('YATOON_Resources') && !YATOON_Resources::is_available($resource_service_id, $svc_start_check, $svc_end_check)) { return false;
                        }

	                    // 4.4.3: external calendar busy time (two-way Google sync).
	                    if (YATOON_Business_Hours::has_external_busy_conflict($candidate_staff_id, $svc_start_check, $svc_end_check)) { return false;
                        }

	                    return !YATOON_Business_Hours::has_staff_time_off_conflict($candidate_staff_id, $svc_start_check, $svc_end_check, $tz_obj_local);
	                };

	                $candidate_sets_local = array();
	                foreach ($multi_services_local as $idx => $svc_data) {
	                    $svc_id  = intval($svc_data['service_id'] ?? 0);
	                    $svc_stf = intval($svc_data['staff_id'] ?? 0);
	                    $candidates = array();
	                    if ($svc_stf > 0) {
	                        if (isset($assigned_local[ $idx ])) {
	                            $candidates[] = $assigned_local[ $idx ];
	                        }
	                    } else {
	                        $candidates = $get_eligible_local($svc_id);
	                    }
	                    $candidate_sets_local[ $idx ] = $candidates;
	                }

	                $assignment_is_available = function ( $candidate, $idx, $current_assignment ) use ( $pos_dur_local, $local_candidate_available_for_window, $multi_services_local ) {
	                    $cid = intval($candidate->id);
	                    $cur_off = $pos_dur_local[ $idx ]['offset'] ?? 0;
	                    $cur_dur = $pos_dur_local[ $idx ]['duration'] ?? 60;
	                    $cur_end = $cur_off + $cur_dur;
	                    $resource_service_id = intval($multi_services_local[ $idx ]['service_id'] ?? 0);
	                    if (!$local_candidate_available_for_window($cid, $cur_off, $cur_dur, $resource_service_id)) {
	                        return false;
	                    }
	                    foreach ($current_assignment as $prev_idx => $prev_stf) {
	                        if (!$prev_stf || intval($prev_stf->id) !== $cid) {
	                            continue;
	                        }
	                        $prev_off = $pos_dur_local[ $prev_idx ]['offset'] ?? 0;
	                        $prev_end = $prev_off + ( $pos_dur_local[ $prev_idx ]['duration'] ?? 60 );
	                        if ($prev_off < $cur_end && $prev_end > $cur_off) {
	                            return false;
	                        }
	                    }
	                    return true;
	                };

	                $assigned_local = array();
	                $assign_indexes_local = array_keys($multi_services_local);
	                usort($assign_indexes_local, function ( $a, $b ) use ( $candidate_sets_local ) {
	                    return count($candidate_sets_local[ $a ] ?? array()) <=> count($candidate_sets_local[ $b ] ?? array());
	                });
	                $try_assign_local = function ( $pos, $current_assignment ) use ( &$try_assign_local, $assign_indexes_local, $candidate_sets_local, $assignment_is_available ) {
	                    if ($pos >= count($assign_indexes_local)) {
	                        return $current_assignment;
	                    }
	                    $idx = $assign_indexes_local[ $pos ];
	                    foreach (( $candidate_sets_local[ $idx ] ?? array() ) as $candidate) {
	                        if (!$assignment_is_available($candidate, $idx, $current_assignment)) {
	                            continue;
	                        }
	                        $next_assignment = $current_assignment;
	                        $next_assignment[ $idx ] = $candidate;
	                        $result = $try_assign_local($pos + 1, $next_assignment);
	                        if ($result !== false) {
	                            return $result;
	                        }
	                    }
	                    return false;
	                };
	                $assignment_result_local = $try_assign_local(0, array());
	                if ($assignment_result_local === false) {
	                    wp_send_json_error(array('message' => __('Sorry, that time slot is no longer available. Please choose another time.', 'yatoon-booking-system')));
	                }
	                $assigned_local = $assignment_result_local;

                // ===== Phase 1: Calculate timing + validate ALL slots before saving ===
                // Track time used per guest_label (guests run in parallel, services within
                // a guest run sequentially on their assigned staff).
                $guest_time_used_local    = array(); // guest_label => minutes offset from start
                $cumulative_min_local     = 0;
                $growth_reserved_packages = array();
                $prepared_bookings        = array();

                foreach ($multi_services_local as $idx => $svc_data) {
                    $svc_id   = intval($svc_data['service_id'] ?? 0);
                    $svc_opts = array_map('intval', (array) ( $svc_data['option_ids'] ?? array() ));

                    $svc_row = yatoon_db_get_row($wpdb->prepare("SELECT * FROM $table_services WHERE id = %d", $svc_id));
                    if (!$svc_row) { continue;
                    }

                    $stf_row = $assigned_local[ $idx ] ?? null;
                    if (!$stf_row) {
                        $fallback = $get_eligible_local($svc_id);
                        $stf_row  = !empty($fallback) ? $fallback[0] : null;
                    }
                    if (!$stf_row) { continue;
                    }

                    // Duration from options
                    $svc_dur   = intval($svc_row->duration_minutes);
                    $svc_price = ! empty( $svc_data['staff_id'] ) && class_exists( 'YATOON_Staff_Locations' )
                        ? YATOON_Staff_Locations::price_for( $stf_row->id, $svc_id, $booking_location_id, $svc_row->price )
                        : floatval($svc_row->price);
                    if (!empty($svc_opts)) {
                        $ids_pl = implode(',', array_map('intval', $svc_opts));
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                        $all_opts = yatoon_db_get_results("SELECT * FROM $table_options_local WHERE id IN ($ids_pl) ORDER BY sort_order ASC, id ASC");
                        $main_set = false;
                        foreach ($all_opts as $opt_row) {
                            $is_addon = ( isset($opt_row->group_name) && $opt_row->group_name === 'Add-on' );
                            if (!$is_addon && !$main_set) {
                                if (intval($opt_row->duration_minutes) > 0) { $svc_dur = intval($opt_row->duration_minutes);
                                }
                                $svc_price += floatval($opt_row->price_modifier);
                                $main_set = true;
                            } elseif ($is_addon) {
                                $svc_dur   += intval($opt_row->duration_minutes);
                                $svc_price += floatval($opt_row->price_modifier);
                            }
                        }
                    }

                    // Timing: services within the same guest run sequentially;
                    // services for different guests run in parallel from the same start time.
                    $guest_label  = isset($svc_data['guest_label']) && $svc_data['guest_label'] !== '' ? $svc_data['guest_label'] : 'Me';
                    $guest_offset = $guest_time_used_local[ $guest_label ] ?? 0;
                    $svc_start    = clone $start_time_obj;
                    if ($guest_offset > 0) { $svc_start->modify("+{$guest_offset} minutes");
                    }
                    $svc_end = clone $svc_start;
                    $svc_end->modify("+{$svc_dur} minutes");

                    if ( class_exists( 'YATOON_Dynamic_Pricing' ) ) {
                        $quote = YATOON_Dynamic_Pricing::quote( $svc_price, array( 'service_id' => $svc_id, 'staff_id' => $stf_row->id, 'location_id' => $booking_location_id, 'start_at' => $svc_start->format( 'Y-m-d H:i:s' ) ) );
                        $svc_price = (float) $quote['total'];
                    }

                    if ( class_exists( 'YATOON_Staff_Locations' ) && ! YATOON_Staff_Locations::allows( $stf_row->id, $svc_id, $booking_location_id, $svc_start, $svc_end ) ) {
                        /* translators: %s: service name. */
                        wp_send_json_error( array( 'message' => sprintf( __( '%s is not offered by the selected staff member at this location and time.', 'yatoon-booking-system' ), $svc_row->name ) ) );
                    }
                    if ( class_exists( 'YATOON_Microsoft_Graph' ) && YATOON_Microsoft_Graph::is_busy( $svc_start, $svc_end, $stf_row->id ) ) {
                        wp_send_json_error( array( 'message' => __( 'That time is busy in Outlook Calendar. Please choose another time.', 'yatoon-booking-system' ) ) );
                    }

                    // Validate slot: check business hours + existing DB bookings,
                    // but also check in-memory prepared_bookings (same staff, overlapping time)
                    // so that within-group bookings don't block each other.
                    $date_for_slot = $svc_start->format('Y-m-d');
                    $day_hours     = YATOON_Business_Hours::get_day_hours($date_for_slot);
                    if (!$day_hours) {
                        wp_send_json_error(array('message' => sprintf(
                            /* translators: %s: Service name. */
                            __('Sorry, %s is not available on that date. Please choose another time.', 'yatoon-booking-system'),
                            $svc_row->name
                        ),));
                    }
                    // Check existing DB conflicts for this staff
                    $db_booked  = YATOON_Business_Hours::get_booked_slots_public($date_for_slot, intval($stf_row->id));
                    $db_conflict = false;
                    foreach ($db_booked as $b) {
                        $bs = new DateTime($b->start_time, $tz_obj_local);
                        $be = new DateTime($b->end_time,   $tz_obj_local);
                        if ($svc_start < $be && $svc_end > $bs) { $db_conflict = true;
break; }
                    }
                    // Check in-memory conflicts (other bookings in this same group for the same staff)
                    $mem_conflict = false;
                    foreach ($prepared_bookings as $pb_check) {
                        if (intval($pb_check['stf_row']->id) !== intval($stf_row->id)) { continue;
                        }
                        if ($svc_start < $pb_check['svc_end'] && $svc_end > $pb_check['svc_start']) {
                            $mem_conflict = true;
break;
                        }
                    }
                    if ($db_conflict || $mem_conflict) {
                        wp_send_json_error(array('message' => sprintf(
                            /* translators: %s: Service name. */
                            __('Sorry, the time slot for %s is no longer available. Please choose another time.', 'yatoon-booking-system'),
                            $svc_row->name
                        ),));
                    }

                    $guest_time_used_local[ $guest_label ] = $guest_offset + $svc_dur;
                    $cumulative_min_local = max($cumulative_min_local, $guest_time_used_local[ $guest_label ]);

                    $design_fields = $design_reference_by_service[ $idx ] ?? array(
                        'design_attachment_id' => null,
                        'design_image_url'     => null,
                        'design_upload_token'  => '',
                    );
                    $prepared_bookings[] = compact('svc_row', 'stf_row', 'svc_id', 'svc_opts', 'svc_dur', 'svc_price', 'svc_start', 'svc_end', 'design_fields');
                }

                if (empty($prepared_bookings)) {
                    wp_send_json_error(array('message' => __('Failed to create booking. Please try again.', 'yatoon-booking-system')));
                }

                // -- Phase 2: All slots valid - save all bookings.
                $first_booking_id       = null;
                $all_services_info      = array();
                $group_booking_id_local = $booking_group_id ? $booking_group_id : wp_generate_uuid4();
                $service_order_local    = 0;

                if ( false === $wpdb->query( 'START TRANSACTION' ) ) {
                    $booking_error( __( 'The booking database could not start a safe transaction. Please try again.', 'yatoon-booking-system' ), 'booking_transaction_unavailable', 'retry', array( 'retry_safe' => true ) );
                }

                foreach ($prepared_bookings as $pb) {
                    $svc_row   = $pb['svc_row'];
                    $stf_row   = $pb['stf_row'];
                    $svc_id    = $pb['svc_id'];
                    $svc_opts  = $pb['svc_opts'];
                    $svc_price = $pb['svc_price'];
                    $svc_start = $pb['svc_start'];
                    $svc_end   = $pb['svc_end'];
                    // A checkout tip belongs to the group, not to every service
                    // row. Store it on the primary row only so reports/orders do
                    // not multiply it by the number of booked services.
                    $row_tip_amount = 0 === $service_order_local ? $tip_amount : 0.0;

                    $booking_notes   = $notes;
                    $booking_benefit = array();
                    if (class_exists('YATOON_Growth_Modules')) {
                        $booking_benefit = YATOON_Growth_Modules::apply_customer_benefits_to_total($svc_price, $customer_email, $customer_phone, $growth_reserved_packages);
                        if (!empty($booking_benefit['type'])) {
                            $svc_price     = floatval($booking_benefit['total']);
                            $booking_notes = trim($booking_notes . "\n" . $booking_benefit['note']);
                        }
                    }
                    if ($claimed_coupon && $svc_price > 0) {
                        $is_fixed_coupon = ( $claimed_coupon->discount_type === 'fixed' );
                        if (!$is_fixed_coupon || $service_order_local === 0) {
                            $coupon_discount = YATOON_Growth_Modules::compute_coupon_discount_for($claimed_coupon, $svc_price);
                            if ($coupon_discount > 0) {
                                $svc_price     = max(0, round($svc_price - $coupon_discount, 2));
                                $booking_notes = trim($booking_notes . "\n" . sprintf('Coupon %s applied: -$%s', $coupon_code, number_format($coupon_discount, 2)));
                            }
                        }
                    }

                    $booking_fields_for_row = $consent_booking_fields;
                    if ($service_order_local > 0) {
                        unset($booking_fields_for_row['deposit_paid'], $booking_fields_for_row['deposit_intent_id'], $booking_fields_for_row['deposit_amount']);
                        $booking_fields_for_row['payment_status'] = 'unpaid';
                    }
                    $insert_result = yatoon_db_insert($table_bookings, array_merge(array(
                        'square_booking_id'  => null,
                        'customer_name'      => $customer_name,
                        'customer_email'     => $customer_email,
                        'customer_phone'     => $customer_phone,
                        'service_id'         => $svc_id,
                        'staff_id'           => $stf_row->id,
                        'start_time'         => $svc_start->format('Y-m-d H:i:s'),
                        'end_time'           => $svc_end->format('Y-m-d H:i:s'),
                        'status'             => $initial_status,
                        'notes'              => $booking_notes,
                        'tip_amount'         => $row_tip_amount,
                        'custom_field_values'=> $custom_field_values,
                        'total_price'        => YATOON_Tax::payable_total($svc_price, $svc_id) + $row_tip_amount,
                        'square_synced'      => 0,
                        'payment_status'     => 'unpaid',
                        'group_booking_id'   => $group_booking_id_local,
                        'service_order'      => $service_order_local,
                        'is_any_staff'       => 0,
                        // NOTE: slot_guard is a STORED GENERATED column -- MySQL
                        // derives it from staff_id/start_time/status/square_booking_id
                        // on every write, so it must NOT be assigned here.
                    ), $client_reference_fields, $booking_fields_for_row, $pb['design_fields'], YATOON_Tax::booking_fields($svc_price, $svc_id)));

                    if ($insert_result === false) {
                        $wpdb->query( 'ROLLBACK' );
                        if (class_exists('YATOON_Growth_Modules')) {
                            if ($claimed_coupon) { YATOON_Growth_Modules::release_coupon($coupon_code);
                            }
                        }
                        // A slot_guard collision means another request won the
                        // race for this staff member and start time. Say so,
                        // rather than reporting a generic failure.
                        if (yatoon_last_error_is_duplicate_key()) {
                            wp_send_json_error(array('message' => __('Sorry, that time slot was just taken. Please choose another time.', 'yatoon-booking-system')));
                        }
                        wp_send_json_error(array('message' => __('Failed to create booking. Please try again.', 'yatoon-booking-system')));
                    }

                    $local_booking_id = $wpdb->insert_id;
                    if (class_exists('YATOON_Growth_Modules') && !empty($booking_benefit['type'])) {
                        YATOON_Growth_Modules::record_booking_benefit($booking_benefit, $local_booking_id);
                    }
                    if ($first_booking_id === null) { $first_booking_id = $local_booking_id;
                    }
                    ++$service_order_local;

                    if (!empty($svc_opts)) {
                        foreach ($svc_opts as $opt_id) {
                            if ( false === yatoon_db_insert($table_booking_options, array('booking_id' => $local_booking_id, 'option_id' => $opt_id)) ) {
                                $wpdb->query( 'ROLLBACK' );
                                if ( class_exists( 'YATOON_Growth_Modules' ) && $claimed_coupon ) {
                                    YATOON_Growth_Modules::release_coupon( $coupon_code );
                                }
                                $booking_error( __( 'The booking options could not be saved. Nothing was booked; please try again.', 'yatoon-booking-system' ), 'local_booking_options_failed', 'retry', array( 'retry_safe' => true ) );
                            }
                        }
                    }

                    $all_services_info[] = array(
                        'service'    => $svc_row,
                        'staff'      => $stf_row,
                        'start_time' => $svc_start->format('Y-m-d H:i:s'),
                        'end_time'   => $svc_end->format('Y-m-d H:i:s'),
                        'price'      => $svc_price,
                        'option_ids' => array_values( array_map( 'intval', (array) $svc_opts ) ),
                    );
                }

	                if (empty($all_services_info)) {
	                    $wpdb->query( 'ROLLBACK' );
	                    wp_send_json_error(array('message' => __('Failed to create booking. Please try again.', 'yatoon-booking-system')));
	                }

	                if ($initial_status === 'confirmed') {
	                    yatoon_db_update(
	                        $table_bookings,
	                        array('status' => 'confirmed'),
	                        array('group_booking_id' => $group_booking_id_local),
	                        array('%s'),
	                        array('%s')
	                    );
	                }

	                if ( false === $wpdb->query( 'COMMIT' ) ) {
	                    $wpdb->query( 'ROLLBACK' );
	                    $booking_error( __( 'WordPress could not confirm whether the booking was saved. Check Bookings before trying again.', 'yatoon-booking-system' ), 'local_booking_commit_unknown', 'check_booking', array( 'outcome_uncertain' => true ) );
	                }

	                if ($coupon_code && class_exists('YATOON_Growth_Modules')) {
                    YATOON_Growth_Modules::mark_coupon_used($coupon_code);
                }

                $group_total = (float) yatoon_db_get_var($wpdb->prepare(
                    "SELECT COALESCE(SUM(total_price),0) FROM {$table_bookings} WHERE group_booking_id = %s",
                    $group_booking_id_local
                ));
                $multi_gift_card_amount = yatoon_redeem_gift_card($gift_card_code, $group_total, $first_booking_id);
                if ($multi_gift_card_amount > 0) {
                    $first_total = (float) yatoon_db_get_var($wpdb->prepare("SELECT total_price FROM {$table_bookings} WHERE id = %d", $first_booking_id));
                    yatoon_db_update($table_bookings, array(
                        'total_price' => max(0, round($first_total - $multi_gift_card_amount, 2)),
                    ), array('id' => $first_booking_id));
                    $group_total = max(0, round($group_total - $multi_gift_card_amount, 2));
                }

                // Manage token for first booking
                $manage_token = Yatoon_Manage_Booking::create_token($first_booking_id);
                $manage_url   = $manage_token ? Yatoon_Manage_Booking::get_manage_url($manage_token) : '';
                $calendar_url = YATOON_Calendar_Links::get_calendar_url($first_booking_id, $manage_token ?? '');

                // Build combined service name for email
                $combined_svc_names = array_map(function ( $si ) {
 return $si['service']->name;
}, $all_services_info);
                $combined_svc_obj   = clone $all_services_info[0]['service'];
                $combined_svc_obj->name = implode(', ', $combined_svc_names);

                $booking_data_multi = array(
                    'customer_name'      => $customer_name,
                    'customer_email'     => $customer_email,
                    'customer_phone'     => $customer_phone,
                    'start_time'         => $all_services_info[0]['start_time'],
                    'end_time'           => end($all_services_info)['end_time'],
                    'total_price'        => $group_total,
                    'tip_amount'         => $tip_amount,
                    'custom_field_values'=> $custom_field_values,
                    'notes'              => $notes,
                    'services_info'      => $all_services_info,
                    'manage_token'       => $manage_token,
                    'booking_id'         => $first_booking_id,
                    'manage_url'         => $manage_url,
                    'calendar_url'       => $calendar_url,
                ) + $consent_booking_fields;
                $booking_data_multi['duration_minutes'] = yatoon_effective_booking_duration_minutes( $booking_data_multi, $combined_svc_obj );
                $combined_svc_obj->duration_minutes = $booking_data_multi['duration_minutes'];
                $booking_data_multi['design_references'] = array_values($design_reference_by_service);

                if (get_option('yatoon_send_confirmation_email', '1') === '1') {
                    $this->send_confirmation_email($booking_data_multi, $combined_svc_obj, $all_services_info[0]['staff']);
                }
                $this->notify_admin_new_booking($booking_data_multi, $combined_svc_obj, $all_services_info[0]['staff']);
                YATOON_Twilio::send_customer_confirmation($booking_data_multi, $combined_svc_obj, $all_services_info[0]['staff']);
                YATOON_Twilio::send_admin_alert($booking_data_multi, $combined_svc_obj, $all_services_info[0]['staff']);
                if ( class_exists( 'YATOON_WhatsApp' ) ) {
                    YATOON_WhatsApp::send_customer_confirmation($booking_data_multi, $combined_svc_obj, $all_services_info[0]['staff']);
                    YATOON_WhatsApp::send_admin_alert($booking_data_multi, $combined_svc_obj, $all_services_info[0]['staff']);
                }

                do_action('yatoon_booking_created', $booking_data_multi);

                if ( class_exists( 'YATOON_PWA' ) ) { YATOON_PWA::send_push_to_admins(array(
                    'title'      => 'New Booking',
                    'body'       => $customer_name . ' - ' . $all_services_info[0]['service']->name . ' at ' . yatoon_format_local('g:i A', $all_services_info[0]['start_time']),
                    'url'        => admin_url('admin.php?page=yatoon-booking-system'),
                    'booking_id' => $first_booking_id,
                )); }

                wp_send_json_success(array(
                    'message'      => __('Booking created successfully!', 'yatoon-booking-system'),
                    'booking_id'   => $first_booking_id,
                    'manage_url'   => $manage_url,
                    'service_name' => $combined_svc_obj->name ?? '',
                    'calendar_url' => $calendar_url,
                    'square_synced'=> false,
                    'mode'         => 'local',
                    'woocommerce_checkout_url' => class_exists('YATOON_WooCommerce') ? YATOON_WooCommerce::checkout_url($first_booking_id) : '',
                    'design_attachment_id' => $design_reference_fields['design_attachment_id'] ?? 0,
                    'design_image_url' => $design_reference_fields['design_image_url'] ?? '',
                    'design_upload_token' => $design_reference_fields['design_upload_token'] ?? '',
                ));
            }

	            // ===== SINGLE-SERVICE LOCAL BOOKING ==============================
	            // Validate the final service window using the actual selected option/add-on duration.
	            $local_slot_is_available = function ( $candidate_staff_id ) use ( $wpdb, $table_bookings, $start_time, $end_time, $tz_obj_local, $service_id ) {
	                $candidate_staff_id = intval($candidate_staff_id);
	                if ($candidate_staff_id <= 0) { return false;
                    }

	                $slot_date = $start_time->format('Y-m-d');
	                $day_hours = YATOON_Business_Hours::get_day_hours($slot_date);
	                if (!$day_hours) { return false;
                    }

	                $open_dt  = new DateTime($slot_date . ' ' . ( $day_hours['start'] ?? '09:00' ), $tz_obj_local);
	                $close_dt = new DateTime($slot_date . ' ' . ( $day_hours['end'] ?? '17:00' ), $tz_obj_local);
	                if ($start_time < $open_dt || $end_time > $close_dt) { return false;
                    }

	                if (!empty($day_hours['break_start']) && !empty($day_hours['break_end'])) {
	                    $break_start = new DateTime($slot_date . ' ' . $day_hours['break_start'], $tz_obj_local);
	                    $break_end   = new DateTime($slot_date . ' ' . $day_hours['break_end'], $tz_obj_local);
	                    if ($start_time < $break_end && $end_time > $break_start) { return false;
                        }
	                }

	                $conflict = yatoon_db_get_var($wpdb->prepare(
	                    "SELECT id FROM $table_bookings
	                     WHERE staff_id = %d
	                       AND status NOT IN ('cancelled','cancelled_by_customer','CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED','no_show')
	                       AND start_time < %s
	                       AND end_time   > %s
	                     LIMIT 1",
	                    $candidate_staff_id,
	                    $end_time->format('Y-m-d H:i:s'),
	                    $start_time->format('Y-m-d H:i:s')
	                ));
	                if ($conflict) { return false;
                    }
	                if (class_exists('YATOON_Resources') && !YATOON_Resources::is_available($service_id, $start_time, $end_time)) { return false;
                    }

	                // 4.4.3: the staff member's own external calendar (two-way
	                // Google sync). Without this the slot would be hidden in the
	                // picker but still accepted on submit.
	                if (YATOON_Business_Hours::has_external_busy_conflict($candidate_staff_id, $start_time, $end_time)) { return false;
                    }

	                return !YATOON_Business_Hours::has_staff_time_off_conflict($candidate_staff_id, $start_time, $end_time, $tz_obj_local);
	            };

	            if (intval($requested_staff_id) === 0) {
	                $eligible_for_slot = yatoon_db_get_results($wpdb->prepare(
	                    "SELECT s.* FROM $table_staff s
	                     INNER JOIN $table_ss_local ss ON s.id = ss.staff_id
	                     WHERE ss.service_id = %d AND s.is_active = 1 AND s.is_bookable = 1
	                     ORDER BY s.sort_order ASC, s.name ASC",
	                    $service_id
	                ));
                    if (empty($eligible_for_slot)) {
                        $eligible_for_slot = yatoon_db_get_results(
                            "SELECT * FROM $table_staff WHERE is_active = 1 AND is_bookable = 1 ORDER BY sort_order ASC, name ASC"
                        );
                    }
                    if (class_exists('YATOON_Salon_Operations')) {
                        $eligible_for_slot = YATOON_Salon_Operations::rank_staff((array) $eligible_for_slot, $service_id, $start_at);
                    }

	                $staff = null;
	                foreach ((array) $eligible_for_slot as $candidate_staff) {
	                    if ($local_slot_is_available(intval($candidate_staff->id))) {
	                        $staff = $candidate_staff;
	                        $staff_id = intval($candidate_staff->id);
	                        break;
	                    }
	                }
	            }

	            $slot_staff_id = !empty($staff->id) ? intval($staff->id) : $staff_id;
	            if (!$local_slot_is_available($slot_staff_id)) {
	                wp_send_json_error(array('message' => __('Sorry, that time slot is no longer available. Please choose another time.', 'yatoon-booking-system')));
	            }

            $booking_notes   = $notes;
            $booking_benefit = array();
            if (class_exists('YATOON_Growth_Modules')) {
                $booking_benefit = YATOON_Growth_Modules::apply_customer_benefits_to_total($total_price, $customer_email, $customer_phone);
                if (!empty($booking_benefit['type'])) {
                    $total_price   = floatval($booking_benefit['total']);
                    $booking_notes = trim($booking_notes . "\n" . $booking_benefit['note']);
                }
            }
            if ($claimed_coupon && $total_price > 0) {
                $coupon_discount = YATOON_Growth_Modules::compute_coupon_discount_for($claimed_coupon, $total_price);
                if ($coupon_discount > 0) {
                    $total_price   = max(0, round($total_price - $coupon_discount, 2));
                    $booking_notes = trim($booking_notes . "\n" . sprintf('Coupon %s applied: -$%s', $coupon_code, number_format($coupon_discount, 2)));
                }
            }

            $booking_payable  = YATOON_Tax::payable_total($total_price, $service_id) + $tip_amount;
            $gift_card_amount = yatoon_gift_card_available_amount($gift_card_code, $booking_payable);
            $result = yatoon_db_insert($table_bookings, array_merge(array(
                'square_booking_id'  => null,
                'customer_name'      => $customer_name,
                'customer_email'     => $customer_email,
                'customer_phone'     => $customer_phone,
                'service_id'         => $service_id,
                'staff_id'           => $staff->id,
                'start_time'         => $start_time->format('Y-m-d H:i:s'),
                'end_time'           => $end_time->format('Y-m-d H:i:s'),
                'status'             => $initial_status,
                'notes'              => $booking_notes,
                'tip_amount'         => $tip_amount,
                'custom_field_values'=> $custom_field_values,
                'total_price'        => max(0, round($booking_payable - $gift_card_amount, 2)),
                'square_synced'      => 0,
                'payment_status'     => 'unpaid',
                // Preserve what the customer selected. The runtime assignment
                // above replaces staff_id=0 with the qualified employee who
                // will perform the service, but that must not erase Any Staff.
                'is_any_staff'       => ( (int) $requested_staff_id === 0 ) ? 1 : 0,
                'group_booking_id'   => $booking_group_id ? $booking_group_id : null,
                // NOTE: slot_guard is a STORED GENERATED column -- MySQL derives
                // it from staff_id/start_time/status/square_booking_id on every
                // write, so it must NOT be assigned here.
            ), $client_reference_fields, $consent_booking_fields, YATOON_Tax::booking_fields($total_price, $service_id)));

            if ($result === false) {
                if (class_exists('YATOON_Growth_Modules')) {
                    if (!empty($booking_benefit['type'])) { YATOON_Growth_Modules::release_customer_benefit($booking_benefit);
                    }
                    if ($claimed_coupon) { YATOON_Growth_Modules::release_coupon($coupon_code);
                    }
                }
                // A slot_guard collision means another request won the race for
                // this staff member and start time. Say so, rather than
                // reporting a generic failure.
                if (yatoon_last_error_is_duplicate_key()) {
                    wp_send_json_error(array('message' => __('Sorry, that time slot was just taken. Please choose another time.', 'yatoon-booking-system')));
                }
                wp_send_json_error(array('message' => __('Failed to create booking. Please try again.', 'yatoon-booking-system')));
            }

            $booking_id = $wpdb->insert_id;
            if ($gift_card_amount > 0) {
                $gift_card_amount = yatoon_redeem_gift_card($gift_card_code, $booking_payable, $booking_id);
                yatoon_db_update($table_bookings, array(
                    'total_price' => max(0, round($booking_payable - $gift_card_amount, 2)),
                ), array('id' => $booking_id));
                $booking_notes = trim($booking_notes . "\n" . sprintf('Gift card %s applied: -%s', $gift_card_code, YATOON_Tax::format_money($gift_card_amount)));
                yatoon_db_update($table_bookings, array('notes' => $booking_notes), array('id' => $booking_id));
            }
            if (class_exists('YATOON_Growth_Modules') && !empty($booking_benefit['type'])) {
                YATOON_Growth_Modules::record_booking_benefit($booking_benefit, $booking_id);
            }

            $manage_token = Yatoon_Manage_Booking::create_token($booking_id);
            $booking_data = array_merge(array(
                'customer_name'  => $customer_name,
                'customer_email' => $customer_email,
                'customer_phone' => $customer_phone,
                'start_time'     => $start_time->format('Y-m-d H:i:s'),
                'end_time'       => $end_time->format('Y-m-d H:i:s'),
                'total_price'    => max(0, round($booking_payable - $gift_card_amount, 2)),
                'notes'          => $booking_notes,
                'manage_token'   => $manage_token,
                'booking_id'     => $booking_id,
                'manage_url'     => $manage_token ? Yatoon_Manage_Booking::get_manage_url($manage_token) : '',
                'calendar_url'   => YATOON_Calendar_Links::get_calendar_url($booking_id, $manage_token ?? ''),
            ), $consent_booking_fields);

            if ($coupon_code && class_exists('YATOON_Growth_Modules')) {
                YATOON_Growth_Modules::mark_coupon_used($coupon_code);
            }

            if (!empty($selected_options)) {
                foreach ($selected_options as $opt_id) {
                    yatoon_db_insert($table_booking_options, array('booking_id' => $booking_id, 'option_id' => $opt_id));
                }
            }

            if (get_option('yatoon_send_confirmation_email', '1') === '1') {
                $this->send_confirmation_email($booking_data, $service, $staff);
            }

            $this->notify_admin_new_booking($booking_data, $service, $staff);
            YATOON_Twilio::send_customer_confirmation($booking_data, $service, $staff);
            YATOON_Twilio::send_admin_alert($booking_data, $service, $staff);
            if ( class_exists( 'YATOON_WhatsApp' ) ) {
                YATOON_WhatsApp::send_customer_confirmation($booking_data, $service, $staff);
                YATOON_WhatsApp::send_admin_alert($booking_data, $service, $staff);
            }

            if (YATOON_Google_Calendar::is_enabled()) {
                $google_event_id = YATOON_Google_Calendar::add_event($booking_data, $service, $staff);
                if ($google_event_id) {
                    yatoon_db_update($table_bookings, array('google_event_id' => $google_event_id), array('id' => $booking_id));
                }
            }

            do_action('yatoon_booking_created', array_merge($booking_data, array('booking_id' => $booking_id)));

            if ( class_exists( 'YATOON_PWA' ) ) { YATOON_PWA::send_push_to_admins(array(
                'title'      => 'New Booking',
                'body'       => $customer_name . ' - ' . $service->name . ' at ' . yatoon_format_local('g:i A', $booking_data['start_time']),
                'url'        => admin_url('admin.php?page=yatoon-booking-system'),
                'booking_id' => $booking_id,
            )); }

            wp_send_json_success(array(
                'message'      => __('Booking created successfully!', 'yatoon-booking-system'),
                'booking_id'   => $booking_id,
                'manage_url'   => $booking_data['manage_url'],
                'calendar_url' => $booking_data['calendar_url'],
                'service_name' => $service->name ?? '',
                'square_synced'=> false,
                'mode'         => 'local',
                'woocommerce_checkout_url' => class_exists('YATOON_WooCommerce') ? YATOON_WooCommerce::checkout_url($booking_id) : '',
                'design_attachment_id' => $design_reference_fields['design_attachment_id'] ?? 0,
                'design_image_url' => $design_reference_fields['design_image_url'] ?? '',
                'design_upload_token' => $design_reference_fields['design_upload_token'] ?? '',
            ));
        }

        // Full Square API booking flow
        if ($platform !== 'square') {
            wp_send_json_error(array('message' => __('Booking could not be completed. Please refresh and try again.', 'yatoon-booking-system')));
        }
        if (!$this->square_api || !is_object($this->square_api)) {
            wp_send_json_error(array('message' => __('Square is not connected. Please switch to Local mode or reconnect Square.', 'yatoon-booking-system')));
        }
        // ------------------------------------------------------------
        // Get variation ID - use MAIN (non-addon) option for Square variation.
        // Add-on options (e.g. Custom Design) are NOT Square variations;
        // they are sent as separate appointment_segments with their own duration.
        $selected_variation_id       = null;
        $selected_variation_version  = null;
        $selected_variation_duration = intval($service->duration_minutes); // main service duration ONLY (no addons)
        $addon_names_for_note        = array(); // collect add-on names for seller_note

        if (!empty($selected_options)) {
            $table_options_sq  = $wpdb->prefix . 'yatoon_service_options';
            $sq_ids_str        = implode(',', array_map('intval', $selected_options));
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $sq_all_opts       = yatoon_db_get_results(
                "SELECT * FROM $table_options_sq WHERE id IN ($sq_ids_str) ORDER BY CASE WHEN group_name = 'Add-on' THEN 1 ELSE 0 END, sort_order ASC, id ASC"
            );
            foreach ($sq_all_opts as $sq_opt) {
                $is_addon_sq = ( isset($sq_opt->group_name) && $sq_opt->group_name === 'Add-on' );
                if (!$is_addon_sq && $selected_variation_id === null) {
                    $selected_variation_id      = $sq_opt->square_id;
                    $selected_variation_version = $sq_opt->square_version ?? null;
                    // Use main option's own duration if set
                    if (intval($sq_opt->duration_minutes) > 0) {
                        $selected_variation_duration = intval($sq_opt->duration_minutes);
                    }
                } elseif ($is_addon_sq) {
                    $addon_names_for_note[] = $sq_opt->name;
                }
            }
        }
        if (!$selected_variation_id) {
            $selected_variation_id      = $service->square_id;
            $selected_variation_version = isset($service->square_version) ? $service->square_version : null;
        }

        // Square customer
        $customer_id     = null;
        $customer_search = $customer_email ? $this->square_api->search_customers($customer_email) : null;
        if (!is_wp_error($customer_search) && isset($customer_search['customers'][0])) {
            $customer_id = $customer_search['customers'][0]['id'];
        } else {
            $phone_search = $customer_phone ? $this->square_api->search_customers(null, $customer_phone) : null;
            if (!is_wp_error($phone_search) && isset($phone_search['customers'][0])) {
                $customer_id = $phone_search['customers'][0]['id'];
            }
        }
        if (!$customer_id) {
            $name_parts     = explode(' ', $customer_name, 2);
            $phone_digits   = preg_replace('/[^0-9]/', '', $customer_phone);
            $formatted_phone = strlen($phone_digits) === 10 ? '+1' . $phone_digits : '+' . $phone_digits;
            $customer_resp  = $this->square_api->create_customer(array(
                'given_name'  => $name_parts[0],
                'family_name' => $name_parts[1] ?? '',
                'email'       => $customer_email,
                'phone'       => $formatted_phone,
            ));
            if (!is_wp_error($customer_resp) && isset($customer_resp['customer']['id'])) {
                $customer_id = $customer_resp['customer']['id'];
            }
        }

        if (!$customer_id) {
            wp_send_json_error(array('message' => __('Failed to create customer in Square', 'yatoon-booking-system')));
        }

        // Get version (if missing)
        if (empty($selected_variation_version)) {
            $cat = $this->square_api->get_catalog_object($selected_variation_id);
            if (!is_wp_error($cat) && isset($cat['object']['version'])) {
                $selected_variation_version = $cat['object']['version'];
            }
        }

        $timezone        = yatoon_timezone_string();
        $start_time_obj  = new DateTime($start_at);
        $start_time_obj->setTimezone(new DateTimeZone($timezone));
        $formatted_start = $start_time_obj->format('c');

        // -- Square multi-service booking: create one booking per service ----------
        // NOTE: Square Bookings API does NOT support start_at_minutes_offset.
        // The correct approach is to create a separate booking for each service,
        // each with its own correctly-computed start_at time.
        // --------------------------------------------------------------------------

        $services_json  = isset($_POST['services_json']) ? wp_unslash( $_POST['services_json'] ) : '';
        $multi_services = $services_json ? json_decode( $services_json, true ) : null;

        if ($multi_services && is_array($multi_services) && count($multi_services) > 1) {
            // -- Build eligible-staff helper --------------------------------------
            $table_options_tb = $wpdb->prefix . 'yatoon_service_options';
            $table_ss         = $wpdb->prefix . 'yatoon_staff_services';

            $get_eligible_for_service = function ( $svc_id ) use ( $wpdb, $table_staff, $table_ss, $booking_location_id, $start_at ) {
                $assigned = yatoon_db_get_results($wpdb->prepare(
                    "SELECT s.* FROM $table_staff s
                     INNER JOIN $table_ss ss ON s.id = ss.staff_id
                     WHERE ss.service_id = %d AND s.is_active = 1 AND s.is_bookable = 1
                     ORDER BY s.sort_order ASC, s.name ASC",
                    $svc_id
                ));
                $had_service_assignments = ! empty( $assigned );
                if ( class_exists( 'YATOON_Staff_Locations' ) ) {
                    $assigned = YATOON_Staff_Locations::filter_staff( (array) $assigned, $svc_id, $booking_location_id );
                }
                if ( $had_service_assignments ) {
                    return class_exists( 'YATOON_Salon_Operations' ) ? YATOON_Salon_Operations::rank_staff( (array) $assigned, $svc_id, $start_at ) : $assigned;
                }
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                $fallback_staff = yatoon_db_get_results(
                    "SELECT * FROM $table_staff WHERE is_active=1 AND is_bookable=1 ORDER BY sort_order ASC, name ASC"
                );
                $fallback_staff = class_exists( 'YATOON_Staff_Locations' ) ? YATOON_Staff_Locations::filter_staff( (array) $fallback_staff, $svc_id, $booking_location_id ) : $fallback_staff;
                return class_exists( 'YATOON_Salon_Operations' ) ? YATOON_Salon_Operations::rank_staff( (array) $fallback_staff, $svc_id, $start_at ) : $fallback_staff;
            };

            // -- Assign staff to each service -------------------------------------
            $assigned_staff_ids = array();
            $used_staff_sq_ids  = array();
            $is_any_staff_idx   = array(); // tracks which indices are "any staff" (staff_id = 0)

            // Pass 1: named staff
            $staff_map_json_initial = isset($_POST['staff_map_json']) ? wp_unslash($_POST['staff_map_json']) : '';
            $staff_map_initial = $staff_map_json_initial ? json_decode($staff_map_json_initial, true) : null;
            foreach ($multi_services as $idx => $svc_data) {
                $svc_stf = intval($svc_data['staff_id'] ?? 0);
                if ($svc_stf > 0) {
                    $stf_row = yatoon_db_get_row($wpdb->prepare("SELECT * FROM $table_staff WHERE id = %d", $svc_stf));
                    if ($stf_row) {
                        $assigned_staff_ids[ $idx ] = $stf_row;
                        $used_staff_sq_ids[]      = $stf_row->square_id;
                    }
                } elseif (is_array($staff_map_initial) && !empty($staff_map_initial[ $idx ])) {
                    $mapped_square_staff_id = sanitize_text_field($staff_map_initial[ $idx ]);
                    $stf_row = yatoon_db_get_row($wpdb->prepare(
                        "SELECT * FROM $table_staff WHERE square_id = %s AND is_active = 1 AND is_bookable = 1 LIMIT 1",
                        $mapped_square_staff_id
                    ));
                    if ($stf_row) {
                        $assigned_staff_ids[ $idx ] = $stf_row;
                        $is_any_staff_idx[ $idx ]   = true;
                        $used_staff_sq_ids[]      = $stf_row->square_id;
                    }
                }
            }
            // Pass 2: "Any staff" - assign staff using Square real-time availability API.
            //
            // Strategy: for each any-staff position, query Square's search_availability
            // with the EXACT time window for that service (accounting for sequential offsets).
            // Square has real-time data - no DB end_time stale issue.
            //
            // Window trick: Square requires >=61min window, so we query
            // [svc_start - 1min, svc_start + 61min] and check if svc_start appears.
            {
                $staff_map_json    = isset($_POST['staff_map_json']) ? $_POST['staff_map_json'] : '';
                $staff_map_from_sq = $staff_map_json ? json_decode($staff_map_json, true) : null;
                $timezone_p2       = yatoon_timezone_string();
                $tz_obj_p2         = new DateTimeZone($timezone_p2);

                // Pre-compute cumulative offsets per person. In Square group bookings,
                // different guests must start together; only one person's own
                // services are sequential.
                $pos_durations_p2  = array();
                $guest_offsets_p2   = array();
                foreach ($multi_services as $idx => $svc_data) {
                    $p_guest_label = isset($svc_data['guest_label']) && $svc_data['guest_label'] !== ''
                        ? sanitize_text_field($svc_data['guest_label'])
                        : 'Me';
                    $p_svc_id = intval($svc_data['service_id'] ?? 0);
                    $p_opts   = array_map('intval', (array) ( $svc_data['option_ids'] ?? array() ));
                    $p_dur    = 0;
                    $p_main_dur = 0;
                    $p_addon_dur = 0;
                    if (!empty($p_opts)) {
                        $p_ids = implode(',', array_map('intval', $p_opts));
                        $p_rows = yatoon_db_get_results(
                            "SELECT duration_minutes, group_name FROM {$table_options_tb} WHERE id IN ($p_ids)"
                        );
                        foreach ($p_rows as $p_row) {
                            if ((string) $p_row->group_name === 'Add-on') {
                                $p_addon_dur += max(0, intval($p_row->duration_minutes));
                            } elseif ($p_main_dur === 0 && intval($p_row->duration_minutes) > 0) {
                                $p_main_dur = intval($p_row->duration_minutes);
                            }
                        }
                        $p_dur = $p_main_dur + $p_addon_dur;
                    }
                    if (!$p_dur && $p_svc_id) {
                        $d = yatoon_db_get_var($wpdb->prepare(
                            "SELECT duration_minutes FROM $table_services WHERE id = %d", $p_svc_id
                        ));
                        if ($d) { $p_dur = intval($d);
                        }
                    }
                    if (!isset($guest_offsets_p2[ $p_guest_label ])) {
                        $guest_offsets_p2[ $p_guest_label ] = 0;
                    }
                    $pos_durations_p2[ $idx ] = array(
                        'duration'    => $p_dur ?: 60,
                        'offset'      => $guest_offsets_p2[ $p_guest_label ],
                        'guest_label' => $p_guest_label,
                    );
                    $guest_offsets_p2[ $p_guest_label ] += ( $p_dur ?: 60 );
                }

                foreach ($multi_services as $idx => $svc_data) {
                    if (isset($assigned_staff_ids[ $idx ])) { continue; // already named
                    }

                    $svc_id   = intval($svc_data['service_id'] ?? 0);
                    $svc_opts = array_map('intval', (array) ( $svc_data['option_ids'] ?? array() ));
                    $offset   = $pos_durations_p2[ $idx ]['offset'] ?? 0;
                    $dur      = $pos_durations_p2[ $idx ]['duration'] ?? 60;

                    // Compute exact start time for this service
                    $svc_start = clone $start_time_obj;
                    if ($offset > 0) { $svc_start->modify("+{$offset} minutes");
                    }

                    // Square requires >=61min window.
                    // Start window exactly at svc_start so Square checks if staff can BEGIN
                    // a service of $dur minutes at this time (respects their next appointment).
                    // End = svc_start + dur + 61min (satisfies Square minimum window).
                    $sq_win_s = clone $svc_start;
                    $sq_win_e = clone $svc_start;
                    $sq_win_e->modify('+' . ( $dur + 61 ) . ' minutes');
                    $sq_win_s->setTimezone(new DateTimeZone('UTC'));
                    $sq_win_e->setTimezone(new DateTimeZone('UTC'));
                    $sq_s_iso = $sq_win_s->format('Y-m-d\TH:i:s\Z');
                    $sq_e_iso = $sq_win_e->format('Y-m-d\TH:i:s\Z');

                    // Resolve variation ID for this service
                    $var_id = null;
                    if (!empty($svc_opts)) {
                        $v = yatoon_db_get_var($wpdb->prepare(
                            "SELECT square_id FROM {$table_options_tb} WHERE id = %d AND square_id != '' LIMIT 1",
                            $svc_opts[0]
                        ));
                        if ($v) { $var_id = $v;
                        }
                    }
                    if (!$var_id && $svc_id) {
                        $v = yatoon_db_get_var($wpdb->prepare(
                            "SELECT square_id FROM {$table_options_tb} WHERE service_id = %d AND square_id != '' LIMIT 1",
                            $svc_id
                        ));
                        if ($v) { $var_id = $v;
                        }
                    }

                    $target_time_key = $svc_start->setTimezone($tz_obj_p2)->format('H:i');
                    $chosen = null;

                    // Get eligible staff for this service
                    $eligible = $get_eligible_for_service($svc_id);

                    // Build candidate list: prefer unused staff first, then already-used
                    $not_used     = array();
                    $already_used = array();
                    // Also exclude staff already assigned in THIS booking at overlapping times
                    foreach ($eligible as $cand) {
                        $skip = false;
                        foreach ($assigned_staff_ids as $prev_idx => $prev_stf) {
                            if (!$prev_stf || $prev_stf->id != $cand->id) { continue;
                            }
                            // Check if time windows overlap
                            $prev_off = $pos_durations_p2[ $prev_idx ]['offset'] ?? 0;
                            $prev_dur = $pos_durations_p2[ $prev_idx ]['duration'] ?? 60;
                            $prev_end_off = $prev_off + $prev_dur;
                            $cur_end_off  = $offset + $dur;
                            if ($prev_off < $cur_end_off && $prev_end_off > $offset) {
                                $skip = true;
break; // overlapping window - can't use same staff
                            }
                        }
                        if ($skip) { continue;
                        }
                        if (in_array($cand->square_id, $used_staff_sq_ids, true)) {
                            $already_used[] = $cand;
                        } else {
                            $not_used[] = $cand;
                        }
                    }

                    // Step 1: Query Square without specifying a team member.
                    // Square returns ALL truly available staff for this time, respecting
                    // working hours AND personal events (Busy / All-Day blocks).
                    // This gives us a whitelist - only staff on this list can be assigned.
                    $svc_start_ts = $svc_start->getTimestamp();
                    $p2_any_resp  = $var_id ? $this->square_api->search_availability($var_id, $sq_s_iso, $sq_e_iso) : null;
                    $p2_available_ids = array();
                    if ($p2_any_resp && !is_wp_error($p2_any_resp) && !empty($p2_any_resp['availabilities'])) {
                        foreach ($p2_any_resp['availabilities'] as $p2_av) {
                            if (empty($p2_av['start_at'])) { continue;
                            }
                            if ( yatoon_square_availability_matches_requested_start( $p2_av['start_at'], $svc_start_ts ) ) {
                                foreach ($p2_av['appointment_segments'] ?? array() as $p2_seg) {
                                    $tid = $p2_seg['team_member_id'] ?? '';
                                    if ($tid) { $p2_available_ids[ $tid ] = true;
                                    }
                                }
                            }
                        }
                    }
                    // Log raw availabilities to see actual Square response structure
                    if (!empty($p2_any_resp['availabilities'])) {
                        foreach (array_slice($p2_any_resp['availabilities'], 0, 3) as $dbg_av) {
                            yatoon_safe_log('SBS Pass2 RAW av: start=' . ( $dbg_av['start_at'] ?? '?' ) . ' segments=' . json_encode($dbg_av['appointment_segments'] ?? 'MISSING'));
                        }
                    }
                    yatoon_safe_log("SBS Pass2: idx=$idx Square available IDs at {$target_time_key}: " . implode(', ', array_keys($p2_available_ids)));

                    // Step 2: Prefer a qualified staff member who is not already used
                    // in this booking, then reuse staff only for non-overlapping work.
                    // whether they are available - AND confirm they appear in the whitelist above.
                    // The whitelist check filters out All-Day Blocked staff that Square's
                    // per-staff query may incorrectly return as available.
                    foreach (array_merge($not_used, $already_used) as $candidate) {
                        if (empty($candidate->square_id) || !$var_id) { continue;
                        }

                        // Must be in the whitelist (Square any-staff query)
                        if (!empty($p2_available_ids) && !isset($p2_available_ids[ $candidate->square_id ])) {
                            yatoon_safe_log("SBS Pass2: idx=$idx {$candidate->name} - blocked (not in Square available list)");
                            continue;
                        }

                        // Ask Square directly for this staff member
                        $resp = $this->square_api->search_availability($var_id, $sq_s_iso, $sq_e_iso, $candidate->square_id);
                        if (is_wp_error($resp) || empty($resp['availabilities'])) {
                            yatoon_safe_log("SBS Pass2: idx=$idx {$candidate->name} - Square says not available");
                            continue;
                        }
                        foreach ($resp['availabilities'] as $av) {
                            if (empty($av['start_at'])) { continue;
                            }
                            if ( yatoon_square_availability_matches_requested_start( $av['start_at'], $svc_start_ts ) ) {
                                $chosen = $candidate;
                                yatoon_safe_log("SBS Pass2: idx=$idx -> {$chosen->name} (Square confirmed at {$target_time_key})");
                                break 2;
                            }
                        }
                        yatoon_safe_log("SBS Pass2: idx=$idx {$candidate->name} - no exact slot at {$target_time_key}");
                    }

                    if (!$chosen) {
                        wp_send_json_error(array(
                            'message'  => __('No staff available for one of the services. Please choose a different time.', 'yatoon-booking-system'),
                            'no_staff' => true,
                        ));
                        return;
                    }

                    $assigned_staff_ids[ $idx ] = $chosen;
                    $is_any_staff_idx[ $idx ]   = true;
                    if (!in_array($chosen->square_id, $used_staff_sq_ids, true)) {
                        $used_staff_sq_ids[] = $chosen->square_id;
                    }
                }
            }

            // Phase 1: Collect all segments + local metadata (no API calls yet)
            $segments_to_create = array(); // each entry: [seg, svc_row, stf_row, svc_start, svc_end, svc_price, svc_opts, svc_id, is_any_staff]
            $guest_cumulative_min = array();
            $growth_reserved_packages = array();

            foreach ($multi_services as $idx => $svc_data) {
                $guest_label = isset($svc_data['guest_label']) && $svc_data['guest_label'] !== ''
                    ? sanitize_text_field($svc_data['guest_label'])
                    : 'Me';
                $svc_id   = intval($svc_data['service_id'] ?? 0);
                $svc_opts = array_map('intval', (array) ( $svc_data['option_ids'] ?? array() ));

                $svc_row = yatoon_db_get_row($wpdb->prepare("SELECT * FROM $table_services WHERE id = %d", $svc_id));
                if (!$svc_row) { continue;
                }

                $stf_row = $assigned_staff_ids[ $idx ] ?? null;
                if (!$stf_row) {
                    $fallback = $get_eligible_for_service($svc_id);
                    $stf_row  = !empty($fallback) ? $fallback[0] : null;
                }
                if (!$stf_row) { continue;
                }

                // Resolve variation ID and duration
                // Separate Main option (has Square variation, single-select) from
                // Add-on options (multi-select, duration added on top of main).
                $var_id      = null;
                $var_dur     = intval($svc_row->duration_minutes);
                $var_ver     = null;
                $addon_extra = 0; // total extra minutes from Add-on options
                $addon_segments = array();

                if (!empty($svc_opts)) {
                    $ids_pl = implode(',', array_map('intval', $svc_opts));
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    $all_opts = yatoon_db_get_results(
                        "SELECT * FROM $table_options_tb WHERE id IN ($ids_pl) ORDER BY CASE WHEN group_name = 'Add-on' THEN 1 ELSE 0 END, sort_order ASC, id ASC"
                    );
                    // Main option = group_name IS NULL or '' (single-select)
                    // Add-on option = group_name = 'Add-on'
                    $main_opt = null;
                    foreach ($all_opts as $o) {
                        $is_addon = ( isset($o->group_name) && $o->group_name === 'Add-on' );
                        if (!$is_addon && !$main_opt) {
                            $main_opt = $o; // first non-addon = main
                        }
                        if ($is_addon) {
                            $addon_extra += intval($o->duration_minutes); // add addon duration
                            // Multi-service bookings used to omit add-ons entirely from
                            // Square. Preserve each catalog-backed add-on as its own segment,
                            // just like the single-service booking path does.
                            if (!empty($o->square_id)) {
                                $addon_ver = !empty($o->square_version) ? intval($o->square_version) : null;
                                if (!$addon_ver) {
                                    $addon_cat = $this->square_api->get_catalog_object($o->square_id);
                                    if (!is_wp_error($addon_cat) && isset($addon_cat['object']['version'])) {
                                        $addon_ver = intval($addon_cat['object']['version']);
                                        yatoon_db_update($table_options_tb, array('square_version' => $addon_ver), array('id' => $o->id));
                                    }
                                }
                                if ($addon_ver) {
                                    $addon_segments[] = array(
                                        'duration_minutes'          => max(1, intval($o->duration_minutes)),
                                        'service_variation_id'      => $o->square_id,
                                        'service_variation_version' => $addon_ver,
                                        'team_member_id'            => $stf_row->square_id,
                                    );
                                } else {
                                    yatoon_safe_log("Yatoon: Add-on {$o->id} ({$o->name}) has no Square catalog version; it cannot be sent as a segment.");
                                }
                            }
                        }
                    }
                    if ($main_opt) {
                        $var_id  = $main_opt->square_id;
                        $var_ver = $main_opt->square_version ?? null;
                        if (intval($main_opt->duration_minutes) > 0) {
                            $var_dur = intval($main_opt->duration_minutes);
                        }
                    }
                }
                $var_dur_sq = $var_dur;              // Square segment duration (main only)
                $var_dur   += $addon_extra;            // local total = main + addons
                if (!$var_id) {
                    $opt = yatoon_db_get_row($wpdb->prepare(
                        "SELECT square_id, square_version FROM $table_options_tb WHERE service_id=%d AND square_id!='' LIMIT 1", $svc_id
                    ));
                    if ($opt) { $var_id = $opt->square_id;
$var_ver = $opt->square_version ?? null; }
                }
                if (!$var_id && !empty($svc_row->square_id)) {
                    $var_id  = $svc_row->square_id;
                    $var_ver = $svc_row->square_version ?? null;
                }
                if (!$var_id) {
                    yatoon_safe_log("Yatoon: No Square variation ID for service {$svc_id} ({$svc_row->name})");
                    if (!isset($guest_cumulative_min[ $guest_label ])) {
                        $guest_cumulative_min[ $guest_label ] = 0;
                    }
                    $guest_cumulative_min[ $guest_label ] += $var_dur;
                    continue;
                }

                // Get version if missing
                if (empty($var_ver)) {
                    $cat = $this->square_api->get_catalog_object($var_id);
                    if (!is_wp_error($cat) && isset($cat['object']['version'])) {
                        $var_ver = intval($cat['object']['version']);
                    }
                }

                // Calculate this service's start/end time
                if (!isset($guest_cumulative_min[ $guest_label ])) {
                    $guest_cumulative_min[ $guest_label ] = 0;
                }
                $svc_start = clone $start_time_obj;
                if ($guest_cumulative_min[ $guest_label ] > 0) {
                    $svc_start->modify("+{$guest_cumulative_min[$guest_label]} minutes");
                }
                $svc_end = clone $svc_start;
                $svc_end->modify("+{$var_dur} minutes"); // includes addon time

                if ( class_exists( 'YATOON_Staff_Locations' ) && ! YATOON_Staff_Locations::is_assigned( $stf_row->id, $svc_id, $booking_location_id ) ) {
                    /* translators: %s: service name. */
                    wp_send_json_error( array( 'message' => sprintf( __( '%s is not offered by the selected staff member at this location.', 'yatoon-booking-system' ), $svc_row->name ) ) );
                }

                // Build segment - Square uses the variation's own duration (no addon padding)
                $seg = array(
                    'duration_minutes'     => $var_dur_sq,
                    'service_variation_id' => $var_id,
                    'team_member_id'       => $stf_row->square_id,
                );
                if ($var_ver) { $seg['service_variation_version'] = intval($var_ver);
                }
                $square_segments = array_merge(array($seg), $addon_segments);

                // Resolve price: sum main option modifier + all addon modifiers
                $svc_price = ! empty( $svc_data['staff_id'] ) && class_exists( 'YATOON_Staff_Locations' )
                    ? YATOON_Staff_Locations::price_for( $stf_row->id, $svc_id, $booking_location_id, $svc_row->price )
                    : floatval($svc_row->price);
                if (!empty($svc_opts)) {
                    $ids_pl = implode(',', array_map('intval', $svc_opts));
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    $price_opts = yatoon_db_get_results(
                        "SELECT price_modifier FROM $table_options_tb WHERE id IN ($ids_pl)"
                    );
                    foreach ($price_opts as $po) { $svc_price += floatval($po->price_modifier);
                    }
                }

                if ( class_exists( 'YATOON_Dynamic_Pricing' ) ) {
                    $quote = YATOON_Dynamic_Pricing::quote( $svc_price, array( 'service_id' => $svc_id, 'staff_id' => $stf_row->id, 'location_id' => $booking_location_id, 'start_at' => $svc_start->format( 'Y-m-d H:i:s' ) ) );
                    $svc_price = (float) $quote['total'];
                }
                if ( class_exists( 'YATOON_Microsoft_Graph' ) && YATOON_Microsoft_Graph::is_busy( $svc_start, $svc_end, $stf_row->id ) ) {
                    wp_send_json_error( array( 'message' => __( 'That time is busy in Outlook Calendar. Please choose another time.', 'yatoon-booking-system' ) ) );
                }

                $booking_notes = $notes;
                $booking_benefit = array();
                if (class_exists('YATOON_Growth_Modules')) {
                    $booking_benefit = YATOON_Growth_Modules::apply_customer_benefits_to_total($svc_price, $customer_email, $customer_phone, $growth_reserved_packages);
                    if (!empty($booking_benefit['type'])) {
                        $svc_price = floatval($booking_benefit['total']);
                        $booking_notes = trim($booking_notes . "\n" . $booking_benefit['note']);
                    }
                }
                if ($claimed_coupon && $svc_price > 0) {
                    $is_fixed_coupon = ( $claimed_coupon->discount_type === 'fixed' );
                    if (!$is_fixed_coupon || intval($idx) === 0) {
                        $coupon_discount = YATOON_Growth_Modules::compute_coupon_discount_for($claimed_coupon, $svc_price);
                        if ($coupon_discount > 0) {
                            $svc_price = max(0, round($svc_price - $coupon_discount, 2));
                            $booking_notes = trim($booking_notes . "\n" . sprintf('Coupon %s applied: -$%s', $coupon_code, number_format($coupon_discount, 2)));
                        }
                    }
                }
                $is_any_staff = isset($is_any_staff_idx[ $idx ]) ? true : false;
                $design_fields = $design_reference_by_service[ $idx ] ?? array(
                    'design_attachment_id' => null,
                    'design_image_url'     => null,
                    'design_upload_token'  => '',
                );
                $segments_to_create[] = compact('seg', 'square_segments', 'svc_row', 'stf_row', 'svc_start', 'svc_end', 'svc_price', 'svc_opts', 'svc_id', 'is_any_staff', 'booking_notes', 'booking_benefit', 'guest_label', 'design_fields');
                $guest_cumulative_min[ $guest_label ] += $var_dur;
            }

            if (empty($segments_to_create)) {
                wp_send_json_error(array('message' => __('Failed to resolve services. Please ensure services are synced with Square.', 'yatoon-booking-system')));
            }

            // -- Advisory overlap check for all assigned staff (Square mode) --
            //
            // Runs AFTER staff assignment (incl. any-staff) and BEFORE the
            // Square API call. This is DIAGNOSTIC ONLY -- it logs, it does not
            // block, because in Square mode Square owns the calendar and is
            // the authority on whether a slot is free.
            //
            // 4.4.3: the previous comment claimed this "prevents two
            // concurrent bookings from landing on the same staff member". It
            // never did. Real enforcement for locally-owned calendars now
            // lives in the local branch, inside the day lock.
            {
                $table_bk_ov = $wpdb->prefix . 'yatoon_bookings';
                foreach ($segments_to_create as $entry_ov) {
                    $ov_staff_id = isset($entry_ov['stf_row']->id) ? intval($entry_ov['stf_row']->id) : 0;
                    if (!$ov_staff_id) { continue;
                    }
                    $ov_start = $entry_ov['svc_start']->format('Y-m-d H:i:s');
                    $ov_end   = $entry_ov['svc_end']->format('Y-m-d H:i:s');
                    $conflict_id = yatoon_db_get_var($wpdb->prepare(
                        "SELECT id FROM $table_bk_ov
                         WHERE staff_id = %d
	                           AND status NOT IN ('cancelled','cancelled_by_customer','CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED','no_show')
                           AND start_time < %s
                           AND end_time   > %s
                         LIMIT 1",
                        $ov_staff_id, $ov_end, $ov_start
                    ));
                    if ($conflict_id) {
                        // Square owns this calendar, so our mirror can be
                        // legitimately stale (e.g. cancelled in Square but not
                        // yet synced here). Record it and let the Square API
                        // reject the booking if it actually disagrees.
                        $stf_nm = $entry_ov['stf_row']->name ?? 'A staff member';
                        yatoon_safe_log("Yatoon overlap: DB conflict for staff {$stf_nm} at {$ov_start}-{$ov_end} (booking id={$conflict_id}), proceeding - Square is the final arbiter.");
                    }
                }
            }
            // end overlap check

            // Phase 2: Create one Square booking per person. Square does not support
            // start_at offsets, and Me/Guest appointments must remain separate so
            // they can begin at the same time with different staff.
            $segments_by_guest = array();
            foreach ($segments_to_create as $entry) {
                $entry_guest_label = isset($entry['guest_label']) && $entry['guest_label'] !== '' ? $entry['guest_label'] : 'Me';
                if (!isset($segments_by_guest[ $entry_guest_label ])) {
                    $segments_by_guest[ $entry_guest_label ] = array();
                }
                $segments_by_guest[ $entry_guest_label ][] = $entry;
            }

            $created_square_booking_ids = array();
            $group_booking_id = $booking_group_id ? $booking_group_id : wp_generate_uuid4();
            if ( false === $wpdb->query( 'START TRANSACTION' ) ) {
                $booking_error( __( 'The booking database could not start a safe transaction. Please try again.', 'yatoon-booking-system' ), 'booking_transaction_unavailable', 'retry', array( 'retry_safe' => true ) );
            }
            foreach ($segments_by_guest as $booking_guest_label => $guest_entries) {
                $all_segs = array();
                foreach ($guest_entries as $guest_entry) {
                    $entry_segments = !empty($guest_entry['square_segments']) ? $guest_entry['square_segments'] : array($guest_entry['seg']);
                    $all_segs = array_merge($all_segs, $entry_segments);
                }
                $sq_payload = array(
                    'location_id'          => $this->square_api->get_location_id(),
                    'customer_id'          => $customer_id,
                    'start_at'             => $guest_entries[0]['svc_start']->format('c'),
                    'appointment_segments' => $all_segs,
                );
                if ($notes) { $sq_payload['customer_note'] = $notes;
                }
                // Build seller note - include guest and "any available" info.
                $any_staff_service_names = array();
                foreach ($guest_entries as $entry) {
                    if (!empty($entry['is_any_staff'])) {
                        $any_staff_service_names[] = $entry['svc_row']->name;
                    }
                }
                $seller_note = 'Online booking by ' . $customer_name;
                if ($booking_guest_label !== 'Me') {
                    $seller_note .= '. Guest: ' . $booking_guest_label;
                }
                if (!empty($any_staff_service_names)) {
                    $seller_note .= '. Booked with any available staff: ' . implode(', ', $any_staff_service_names);
                }
                $sq_payload['seller_note'] = $seller_note;

                // Final live safety lock: the complete appointment must still be
                // available for this exact start before anything is written.
                // Square may return a later opening in the search window; that
                // later opening must never satisfy the customer's chosen time.
                if ( ! $this->square_segments_available_at( $all_segs, $sq_payload['start_at'] ) ) {
                    $wpdb->query( 'ROLLBACK' );
                    foreach ( $created_square_booking_ids as $created_square_booking_id ) {
                        $this->square_api->cancel_booking( $created_square_booking_id );
                    }
                    if ( class_exists( 'YATOON_Growth_Modules' ) ) {
                        foreach ( $segments_to_create as $rb_entry ) {
                            if ( ! empty( $rb_entry['booking_benefit']['type'] ) ) {
                                YATOON_Growth_Modules::release_customer_benefit( $rb_entry['booking_benefit'] );
                            }
                        }
                        if ( $claimed_coupon ) {
                            YATOON_Growth_Modules::release_coupon( $coupon_code );
                        }
                    }
                    wp_send_json_error( array( 'message' => __( 'No staff available for one of the services. Please choose a different time.', 'yatoon-booking-system' ) ) );
                }

                $guest_idempotency_key = 'yatoon-booking-' . substr(
                    hash( 'sha256', $client_reference . '|' . $booking_guest_label . '|' . wp_json_encode( $sq_payload ) ),
                    0,
                    48
                );
                $sq_resp = $this->square_api->create_booking( $sq_payload, $guest_idempotency_key );
                if (is_wp_error($sq_resp) || !isset($sq_resp['booking']['id'])) {
                    $wpdb->query( 'ROLLBACK' );
                    foreach ($created_square_booking_ids as $created_square_booking_id) {
                        $this->square_api->cancel_booking($created_square_booking_id);
                    }
                    // Roll back EVERY package visit / coupon claim we reserved
                    // earlier in this multi-service loop, otherwise the customer
                    // permanently loses balance for a booking that never existed.
                    if (class_exists('YATOON_Growth_Modules')) {
                        foreach ($segments_to_create as $rb_entry) {
                            if (!empty($rb_entry['booking_benefit']['type'])) {
                                YATOON_Growth_Modules::release_customer_benefit($rb_entry['booking_benefit']);
                            }
                        }
                        if ($claimed_coupon) { YATOON_Growth_Modules::release_coupon($coupon_code);
                        }
                    }
                    if (is_wp_error($sq_resp)) {
                        $booking_error(
                            __('Square did not return a final booking result. Check My Appointments or Square before trying again.', 'yatoon-booking-system'),
                            'square_result_unknown',
                            'check_booking',
                            array('outcome_uncertain' => true)
                        );
                    }
                    $sq_err = isset($sq_resp['errors']) ? implode(' ', array_column($sq_resp['errors'], 'detail')) : '';
                    $booking_error(
                        __('Booking not confirmed by Square. ', 'yatoon-booking-system') . $sq_err,
                        'square_rejected_booking',
                        'edit_appointment'
                    );
                }
            $sq_booking_id    = $sq_resp['booking']['id'];
                $created_square_booking_ids[] = $sq_booking_id;

                // Phase 3: Save each service segment to local DB. Segments for the
                // same person share a Square booking ID; other guests get their own.
                foreach ($guest_entries as $entry) {
                $booking_fields_for_row = $consent_booking_fields;
                if ($service_order_idx > 0) {
                    unset($booking_fields_for_row['deposit_paid'], $booking_fields_for_row['deposit_intent_id'], $booking_fields_for_row['deposit_amount']);
                    $booking_fields_for_row['payment_status'] = 'unpaid';
                }
                $row = array_merge( array(
                    'square_booking_id' => $sq_booking_id,
                    'customer_name'     => $customer_name,
                    'customer_email'    => $customer_email,
                    'customer_phone'    => $customer_phone,
                    'service_id'        => $entry['svc_id'],
                    'staff_id'          => $entry['stf_row']->id,
                    'start_time'        => $entry['svc_start']->format('Y-m-d H:i:s'),
                    'end_time'          => $entry['svc_end']->format('Y-m-d H:i:s'),
                    'status'            => $initial_status,
                    'notes'             => $entry['booking_notes'] ?? $notes,
                    'total_price'       => YATOON_Tax::payable_total( $entry['svc_price'], $entry['svc_id'] ),
                    'square_synced'     => 1,
                    'payment_status'    => 'unpaid',
                    'is_any_staff'      => isset($entry['is_any_staff']) ? 1 : 0,
                    'group_booking_id'  => $group_booking_id,
                    'service_order'     => $service_order_idx,
                ), $client_reference_fields, $booking_fields_for_row, $entry['design_fields'], YATOON_Tax::booking_fields( $entry['svc_price'], $entry['svc_id'] ) );
                $row_inserted = yatoon_db_insert($table_bookings, $row);
                if ( false === $row_inserted ) {
                    $wpdb->query( 'ROLLBACK' );
                    foreach ( $created_square_booking_ids as $created_square_booking_id ) {
                        $this->square_api->cancel_booking( $created_square_booking_id );
                    }
                    if ( class_exists( 'YATOON_Growth_Modules' ) ) {
                        foreach ( $segments_to_create as $rb_entry ) {
                            if ( ! empty( $rb_entry['booking_benefit']['type'] ) ) {
                                YATOON_Growth_Modules::release_customer_benefit( $rb_entry['booking_benefit'] );
                            }
                        }
                        if ( $claimed_coupon ) {
                            YATOON_Growth_Modules::release_coupon( $coupon_code );
                        }
                    }
                    $booking_error(
                        __( 'Square accepted the appointment, but the local booking record could not be saved. Automatic cancellation was requested; check Square before trying again.', 'yatoon-booking-system' ),
                        'local_booking_insert_failed',
                        'check_booking',
                        array( 'outcome_uncertain' => true )
                    );
                }
                $local_id = $wpdb->insert_id;
                if (class_exists('YATOON_Growth_Modules') && !empty($entry['booking_benefit']['type'])) {
                    YATOON_Growth_Modules::record_booking_benefit($entry['booking_benefit'], $local_id);
                }
                if ($first_booking_id === null) { $first_booking_id = $local_id;
                }
                ++$service_order_idx;

                if (!empty($entry['svc_opts'])) {
                    foreach ($entry['svc_opts'] as $opt_id) {
                        if ( false === yatoon_db_insert($table_booking_options, array('booking_id' => $local_id, 'option_id' => $opt_id)) ) {
                            $wpdb->query( 'ROLLBACK' );
                            foreach ( $created_square_booking_ids as $created_square_booking_id ) {
                                $this->square_api->cancel_booking( $created_square_booking_id );
                            }
                            if ( class_exists( 'YATOON_Growth_Modules' ) ) {
                                foreach ( $segments_to_create as $rb_entry ) {
                                    if ( ! empty( $rb_entry['booking_benefit']['type'] ) ) {
                                        YATOON_Growth_Modules::release_customer_benefit( $rb_entry['booking_benefit'] );
                                    }
                                }
                                if ( $claimed_coupon ) {
                                    YATOON_Growth_Modules::release_coupon( $coupon_code );
                                }
                            }
                            $booking_error(
                                __( 'Square accepted the appointment, but its service options could not be saved. Automatic cancellation was requested; check Square before trying again.', 'yatoon-booking-system' ),
                                'local_booking_options_failed',
                                'check_booking',
                                array( 'outcome_uncertain' => true )
                            );
                        }
                    }
                }

                $all_services_info[] = array(
                    'service'    => $entry['svc_row'],
                    'staff'      => $entry['stf_row'],
                    'start_time' => $entry['svc_start']->format('Y-m-d H:i:s'),
                    'end_time'   => $entry['svc_end']->format('Y-m-d H:i:s'),
                    'price'      => YATOON_Tax::payable_total( $entry['svc_price'], $entry['svc_id'] ),
                    'option_ids' => array_values( array_map( 'intval', (array) $entry['svc_opts'] ) ),
                );
                }
                // Square can normalize catalog durations. Re-read the returned
                // segment sequence immediately so local rows match Square from
                // the moment the booking is created, not only after cron runs.
                if (!empty($sq_resp['booking'])) {
                    yatoon_reconcile_square_booking_segments($sq_resp['booking']);
                }
            }

            if (empty($all_services_info)) {
                $wpdb->query( 'ROLLBACK' );
                wp_send_json_error(array('message' => __('Failed to create any bookings. Please ensure services are synced with Square.', 'yatoon-booking-system')));
            }

            if ( false === $wpdb->query( 'COMMIT' ) ) {
                $wpdb->query( 'ROLLBACK' );
                $booking_error(
                    __( 'Square accepted the appointment, but WordPress could not confirm the local save. Check Square and Bookings before trying again.', 'yatoon-booking-system' ),
                    'local_booking_commit_unknown',
                    'check_booking',
                    array( 'outcome_uncertain' => true )
                );
            }

            $square_group_total = array_sum(array_column($all_services_info, 'price'));
            $square_group_gift  = yatoon_redeem_gift_card($gift_card_code, $square_group_total, $first_booking_id);
            if ($square_group_gift > 0) {
                $first_total = (float) yatoon_db_get_var($wpdb->prepare("SELECT total_price FROM {$table_bookings} WHERE id = %d", $first_booking_id));
                yatoon_db_update($table_bookings, array(
                    'total_price' => max(0, round($first_total - $square_group_gift, 2)),
                ), array('id' => $first_booking_id));
                $square_group_total = max(0, round($square_group_total - $square_group_gift, 2));
            }

            // Send confirmation email for multi-service
            $booking_data_multi = array(
                'customer_name'  => $customer_name,
                'customer_email' => $customer_email,
                'customer_phone' => $customer_phone,
                'start_time'     => $all_services_info[0]['start_time'],
                'end_time'       => end($all_services_info)['end_time'],
                'total_price'    => $square_group_total,
                'notes'          => $notes,
                'services_info'  => $all_services_info,
                'manage_token'   => $first_booking_id ? Yatoon_Manage_Booking::create_token( $first_booking_id ) : '',
                'booking_id'     => $first_booking_id,
            ) + $consent_booking_fields;
            $booking_data_multi['manage_url'] = ! empty( $booking_data_multi['manage_token'] )
                ? Yatoon_Manage_Booking::get_manage_url( $booking_data_multi['manage_token'] )
                : '';
            $booking_data_multi['design_references'] = array_values($design_reference_by_service);
            $booking_data_multi['calendar_url'] = YATOON_Calendar_Links::get_calendar_url( $first_booking_id, $booking_data_multi['manage_token'] ?? '' );
            if ($coupon_code && class_exists('YATOON_Growth_Modules')) {
                YATOON_Growth_Modules::mark_coupon_used($coupon_code);
            }
            // Build combined service name for multi-service email
            $combined_service_names_sq = array_map(function ( $si ) {
 return $si['service']->name;
}, $all_services_info);
            $combined_service_obj_sq = clone $all_services_info[0]['service'];
            $combined_service_obj_sq->name = implode(', ', $combined_service_names_sq);
            $booking_data_multi['duration_minutes'] = yatoon_effective_booking_duration_minutes( $booking_data_multi, $combined_service_obj_sq );
            $combined_service_obj_sq->duration_minutes = $booking_data_multi['duration_minutes'];

            if (get_option('yatoon_send_confirmation_email', '1') === '1') {
                $this->send_confirmation_email($booking_data_multi, $combined_service_obj_sq, $all_services_info[0]['staff']);
            }
            $this->notify_admin_new_booking($booking_data_multi, $combined_service_obj_sq, $all_services_info[0]['staff']);
            YATOON_Twilio::send_customer_confirmation($booking_data_multi, $combined_service_obj_sq, $all_services_info[0]['staff']);
            YATOON_Twilio::send_admin_alert($booking_data_multi, $combined_service_obj_sq, $all_services_info[0]['staff']);
            if ( class_exists( 'YATOON_WhatsApp' ) ) {
                YATOON_WhatsApp::send_customer_confirmation($booking_data_multi, $combined_service_obj_sq, $all_services_info[0]['staff']);
                YATOON_WhatsApp::send_admin_alert($booking_data_multi, $combined_service_obj_sq, $all_services_info[0]['staff']);
            }

            // Schedule reminder + review request emails
            do_action( 'yatoon_booking_created', array_merge( $booking_data_multi, array( 'booking_id' => $first_booking_id ) ) );

            wp_send_json_success(array(
                'message'       => __('Booking created successfully!', 'yatoon-booking-system'),
                'booking_id'    => $first_booking_id,
                'manage_url'    => $booking_data_multi['manage_url'],
                'calendar_url'  => $booking_data_multi['calendar_url'],
                'service_name'  => $combined_service_obj_sq->name ?? '',
                'square_synced' => true,
                'mode'          => 'square',
                'woocommerce_checkout_url' => class_exists('YATOON_WooCommerce') ? YATOON_WooCommerce::checkout_url($first_booking_id) : '',
                'design_attachment_id' => $design_reference_fields['design_attachment_id'] ?? 0,
                'design_image_url' => $design_reference_fields['design_image_url'] ?? '',
                'design_upload_token' => $design_reference_fields['design_upload_token'] ?? '',
            ));
        }

        // -- Single-service Square booking (original logic) -----------------------
        // Main service segment
        $seg = array(
            'duration_minutes'     => intval($selected_variation_duration),
            'service_variation_id' => $selected_variation_id,
            'team_member_id'       => $staff->square_id,
        );
        if ($selected_variation_version) {
            $seg['service_variation_version'] = intval($selected_variation_version);
        }

        // Build all appointment segments: main + any Add-on options that have a Square variation ID
        $all_single_segs = array($seg);
        if (!empty($selected_options)) {
            $table_options_addon = $wpdb->prefix . 'yatoon_service_options';
            $addon_ids_str = implode(',', array_map('intval', $selected_options));
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $addon_opts = yatoon_db_get_results(
                "SELECT * FROM $table_options_addon WHERE id IN ($addon_ids_str) ORDER BY sort_order ASC, id ASC"
            );
            foreach ($addon_opts as $addon_opt) {
                $is_addon_seg = ( isset($addon_opt->group_name) && $addon_opt->group_name === 'Add-on' );
                if ($is_addon_seg && !empty($addon_opt->square_id)) {
                    // Get version - required by Square API
                    $addon_ver = !empty($addon_opt->square_version) ? intval($addon_opt->square_version) : null;
                    if (empty($addon_ver)) {
                        $cat = $this->square_api->get_catalog_object($addon_opt->square_id);
                        if (!is_wp_error($cat) && isset($cat['object']['version'])) {
                            $addon_ver = intval($cat['object']['version']);
                            // Cache it back to DB so we don't need to fetch next time
                            yatoon_db_update(
                                $wpdb->prefix . 'yatoon_service_options',
                                array('square_version' => $addon_ver),
                                array('id' => $addon_opt->id)
                            );
                        }
                    }
                    if (empty($addon_ver)) {
                        yatoon_safe_log("Yatoon: Could not get square_version for Add-on option {$addon_opt->id} ({$addon_opt->name}), skipping segment.");
                        continue;
                    }
                    // Add-on has a Square variation - add as a separate segment with same staff
                    $addon_seg = array(
                        'duration_minutes'          => intval($addon_opt->duration_minutes) ?: 15,
                        'service_variation_id'      => $addon_opt->square_id,
                        'service_variation_version' => $addon_ver,
                        'team_member_id'            => $staff->square_id,
                    );
                    $all_single_segs[] = $addon_seg;
                }
            }
        }

        $booking_payload = array(
            'location_id'          => $this->square_api->get_location_id(),
            'customer_id'          => $customer_id,
            'start_at'             => $formatted_start,
            'appointment_segments' => $all_single_segs,
        );
        if ($notes) { $booking_payload['customer_note'] = $notes;
        }
        // Add seller_note - flag if staff was auto-assigned (any available)
        $single_seller_note = 'Online booking by ' . $customer_name;
        if (!empty($addon_names_for_note)) {
            $single_seller_note .= '. Add-ons: ' . implode(', ', $addon_names_for_note);
        }
        // staff_id now contains the concrete Square team member selected from
        // the live availability response. Use the original request to retain
        // the customer's Any Staff choice in Square's merchant-facing note.
        if ((int) $requested_staff_id === 0) {
            $single_seller_note .= '. Booked with any available staff: ' . $service->name;
        }
        $booking_payload['seller_note'] = $single_seller_note;

        // Recheck the complete service + add-on duration at the exact requested
        // start immediately before creation. This closes the race where a later
        // Square opening (for example 5:00 PM) was accepted for 4:45 PM.
        if ( ! $this->square_segments_available_at( $all_single_segs, $formatted_start ) ) {
            if ( class_exists( 'YATOON_Growth_Modules' ) && $claimed_coupon ) {
                YATOON_Growth_Modules::release_coupon( $coupon_code );
            }
            wp_send_json_error( array( 'message' => __( 'Sorry, the selected staff member is not available at this time. Please choose a different time or staff.', 'yatoon-booking-system' ) ) );
        }

        $booking_idempotency_key = 'yatoon-booking-' . substr(
            hash( 'sha256', $client_reference . '|single|' . wp_json_encode( $booking_payload ) ),
            0,
            48
        );
        $square_response   = $this->square_api->create_booking( $booking_payload, $booking_idempotency_key );
        $square_booking_id = null;

        if (is_wp_error($square_response)) {
            wp_send_json_error(array('message' => __('Failed to create booking in Square: ', 'yatoon-booking-system') . $square_response->get_error_message()));
        }
        if (isset($square_response['booking']['id'])) {
            $square_booking_id = $square_response['booking']['id'];
        } else {
            $sq_err = '';
            if (isset($square_response['errors'])) {
                $sq_err = ' ' . implode(' ', array_column($square_response['errors'], 'detail'));
            }
            wp_send_json_error(array('message' => __('Booking created but no confirmation ID returned', 'yatoon-booking-system') . $sq_err));
        }

        $booking_notes = $notes;
        $booking_benefit = array();
        if (class_exists('YATOON_Growth_Modules')) {
            $booking_benefit = YATOON_Growth_Modules::apply_customer_benefits_to_total($total_price, $customer_email, $customer_phone);
            if (!empty($booking_benefit['type'])) {
                $total_price = floatval($booking_benefit['total']);
                $booking_notes = trim($booking_notes . "\n" . $booking_benefit['note']);
            }
        }
        if ($claimed_coupon && $total_price > 0) {
            $coupon_result = array(
                'valid'    => true,
                'discount' => YATOON_Growth_Modules::compute_coupon_discount_for($claimed_coupon, $total_price),
            );
            if ($coupon_result['discount'] > 0) {
                $total_price = max(0, round($total_price - floatval($coupon_result['discount']), 2));
                $booking_notes = trim($booking_notes . "\n" . sprintf('Coupon %s applied: -$%s', $coupon_code, number_format($coupon_result['discount'], 2)));
            }
        }

        // Save locally. Gift cards reduce the amount due; the balance itself is
        // redeemed under a row lock immediately after this row has an ID.
        $square_booking_payable = YATOON_Tax::payable_total( max(0, round((float) $total_price, 2)), $service_id );
        $gift_card_amount       = yatoon_gift_card_available_amount($gift_card_code, $square_booking_payable);
        $booking_data = array_merge( array(
            'square_booking_id' => $square_booking_id,
            'customer_name'     => $customer_name,
            'customer_email'    => $customer_email,
            'customer_phone'    => $customer_phone,
            'service_id'        => $service_id,
            'staff_id'          => $staff->id,
            'start_time'        => $start_time->format('Y-m-d H:i:s'),
            'end_time'          => $end_time->format('Y-m-d H:i:s'),
            'status'            => $initial_status,
            'notes'             => $booking_notes,
            'total_price'       => max(0, round($square_booking_payable - $gift_card_amount, 2)),
            'square_synced'     => 1,
            'payment_status'    => 'unpaid',
            'is_any_staff'      => ( (int) $requested_staff_id === 0 ) ? 1 : 0,
            'group_booking_id'  => $booking_group_id ? $booking_group_id : null,
        ), $client_reference_fields, $consent_booking_fields, YATOON_Tax::booking_fields( $total_price, $service_id ) );
        $result = yatoon_db_insert($table_bookings, $booking_data);
        if ($result === false) {
            // Log the real MySQL error so this can actually be diagnosed next
            // time, instead of just "Failed to save booking" with no trace.
            if (function_exists('yatoon_safe_log')) {
                yatoon_safe_log( 'Booking insert failed: ' . $wpdb->last_error . ' | fields: ' . implode( ',', array_keys( $booking_data ) ) );
            }
            // Square booking already created but local insert failed: roll back
            // the local benefit + coupon reservations so the customer balance
            // is not silently consumed. (The Square booking remains and admin
            // will see it on next sync; this is the lesser of two evils.)
            if (class_exists('YATOON_Growth_Modules')) {
                if (!empty($booking_benefit['type'])) { YATOON_Growth_Modules::release_customer_benefit($booking_benefit);
                }
                if ($claimed_coupon) { YATOON_Growth_Modules::release_coupon($coupon_code);
                }
            }
            $booking_error(
                __( 'Square confirmed the appointment, but WordPress could not save it locally. Use Try again to recover the same appointment safely.', 'yatoon-booking-system' ),
                'local_booking_insert_failed',
                'retry',
                array( 'retry_safe' => true )
            );
        }
        $booking_id = $wpdb->insert_id;
        if ($gift_card_amount > 0) {
            $gift_card_amount = yatoon_redeem_gift_card($gift_card_code, $square_booking_payable, $booking_id);
            $booking_data['total_price'] = max(0, round($square_booking_payable - $gift_card_amount, 2));
            $booking_data['notes'] = trim($booking_notes . "\n" . sprintf('Gift card %s applied: -%s', $gift_card_code, YATOON_Tax::format_money($gift_card_amount)));
            yatoon_db_update($table_bookings, array(
                'total_price' => $booking_data['total_price'],
                'notes'       => $booking_data['notes'],
            ), array('id' => $booking_id));
        }
        if (class_exists('YATOON_Growth_Modules') && !empty($booking_benefit['type'])) {
            YATOON_Growth_Modules::record_booking_benefit($booking_benefit, $booking_id);
        }
        if ($coupon_code && class_exists('YATOON_Growth_Modules')) {
            YATOON_Growth_Modules::mark_coupon_used($coupon_code);
        }

        // Generate manage token for customer self-service
        $booking_data['manage_token'] = Yatoon_Manage_Booking::create_token( $booking_id );
        $booking_data['booking_id'] = $booking_id;
        $booking_data['manage_url'] = ! empty( $booking_data['manage_token'] )
            ? Yatoon_Manage_Booking::get_manage_url( $booking_data['manage_token'] )
            : '';
        $booking_data['calendar_url'] = YATOON_Calendar_Links::get_calendar_url( $booking_id, $booking_data['manage_token'] ?? '' );

        if (!empty($selected_options)) {
            foreach ($selected_options as $opt_id) {
                yatoon_db_insert($table_booking_options, array('booking_id'=>$booking_id,'option_id'=>$opt_id));
            }
        }

        if (get_option('yatoon_send_confirmation_email', '1') === '1') {
            $this->send_confirmation_email($booking_data, $service, $staff);
        }

        YATOON_Twilio::send_customer_confirmation($booking_data, $service, $staff);
        YATOON_Twilio::send_admin_alert($booking_data, $service, $staff);
        if ( class_exists( 'YATOON_WhatsApp' ) ) {
            YATOON_WhatsApp::send_customer_confirmation($booking_data, $service, $staff);
            YATOON_WhatsApp::send_admin_alert($booking_data, $service, $staff);
        }

        if (YATOON_Google_Calendar::is_enabled()) {
            $google_event_id = YATOON_Google_Calendar::add_event($booking_data, $service, $staff);
            if ($google_event_id) {
                yatoon_db_update($table_bookings, array('google_event_id'=>$google_event_id), array('id'=>$booking_id), array('%s'), array('%d'));
            }
        }

        // Schedule reminder + review request emails
        do_action( 'yatoon_booking_created', array_merge( $booking_data, array( 'booking_id' => $booking_id ) ) );

        wp_send_json_success(array(
            'message'      => __('Booking created successfully!', 'yatoon-booking-system'),
            'booking_id'   => $booking_id,
            'manage_url'   => $booking_data['manage_url'],
            'calendar_url' => $booking_data['calendar_url'],
            'service_name' => $service->name ?? '',
            'square_synced'=> true,
            'mode'         => 'square',
            'woocommerce_checkout_url' => class_exists('YATOON_WooCommerce') ? YATOON_WooCommerce::checkout_url($booking_id) : '',
            'design_attachment_id' => $design_reference_fields['design_attachment_id'] ?? 0,
            'design_image_url' => $design_reference_fields['design_image_url'] ?? '',
            'design_upload_token' => $design_reference_fields['design_upload_token'] ?? '',
        ));
    }

    /**
     * Build the customer-facing service composition used by emails.
     * Multi-service bookings keep their option IDs per segment so variations
     * and add-ons (for example "Fill-in + Standard Nail Art") are not lost.
     */
    private function notification_service_details( $booking, $fallback_service ) {
        global $wpdb;

        $entries = ! empty( $booking['services_info'] ) && is_array( $booking['services_info'] )
            ? $booking['services_info']
            : array( array(
                'service'    => $fallback_service,
                'start_time' => $booking['start_time'] ?? '',
                'end_time'   => $booking['end_time'] ?? '',
                'option_ids' => $booking['selected_options'] ?? array(),
            ) );

        $lines = array();
        $all_option_names = array();
        foreach ( $entries as $entry ) {
            $entry_service = $entry['service'] ?? $fallback_service;
            $service_name  = is_object( $entry_service ) && ! empty( $entry_service->name )
                ? $entry_service->name
                : __( 'Service', 'yatoon-booking-system' );
            $ids = array_values( array_unique( array_filter( array_map( 'absint', (array) ( $entry['option_ids'] ?? array() ) ) ) ) );
            $names = array();

            if ( $ids ) {
                $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
                $rows = yatoon_db_get_results( $wpdb->prepare(
                    "SELECT id, name FROM {$wpdb->prefix}yatoon_service_options WHERE id IN ($placeholders)",
                    ...$ids
                ) );
                $names_by_id = array();
                foreach ( (array) $rows as $row ) {
                    $names_by_id[ (int) $row->id ] = $row->name;
                }
                foreach ( $ids as $id ) {
                    if ( ! empty( $names_by_id[ $id ] ) ) {
                        $names[] = $names_by_id[ $id ];
                        $all_option_names[] = $names_by_id[ $id ];
                    }
                }
            }

            $line = $service_name;
            if ( $names ) {
                $line .= ' - ' . implode( ' + ', array_values( array_unique( $names ) ) );
            }
            $segment_duration = yatoon_effective_booking_duration_minutes( $entry, $entry_service );
            if ( $segment_duration > 0 ) {
                $line .= ' (' . $segment_duration . ' min)';
            }
            $lines[] = $line;
        }

        return array(
            'lines'        => array_values( array_unique( $lines ) ),
            'service_name' => implode( '; ', array_values( array_unique( $lines ) ) ),
            'option_names' => implode( ', ', array_values( array_unique( $all_option_names ) ) ),
        );
    }

    /**
     * Email the business owner about a new booking
     */
    private function notify_admin_new_booking( $booking, $service, $staff ) {
        $admin_email = get_option('yatoon_contact_email', '');
        if (empty($admin_email)) { return;
        }

        $business_name = get_option('yatoon_business_name', 'Your Business');
        $start         = new DateTime($booking['start_time']);
        $duration      = yatoon_effective_booking_duration_minutes( $booking, $service );
        $details       = $this->notification_service_details( $booking, $service );
        $subject       = 'New Booking: ' . $service->name . ' - ' . $start->format('M j, Y g:i A');

        $body  = 'New booking received!

';
        $body .= "Customer:  {$booking['customer_name']}
";
        $body .= "Email:     {$booking['customer_email']}
";
        $body .= "Phone:     {$booking['customer_phone']}
";
        $body .= "Services:\n";
        foreach ( $details['lines'] as $service_line ) {
            $body .= "  - {$service_line}\n";
        }
        $body .= "Staff:     {$staff->name}
";
        $body .= 'Date/Time: ' . $start->format('l, F j, Y') . ' at ' . $start->format('g:i A') . '
';
        $body .= "Duration:  {$duration} min
";
        $body .= "Price:     \${$booking['total_price']}
";
        if (!empty($booking['notes'])) {
            $body .= "Notes:     {$booking['notes']}
";
        }
        if (!empty($booking['design_image_url'])) {
            $body .= "Design reference: {$booking['design_image_url']}
";
        }
        $body .= '
View in admin: ' . admin_url('admin.php?page=sbs-bookings') . '
';
        $body .= "
  - {$business_name} Booking System";

        wp_mail($admin_email, $subject, $body);
    }

        /**
     * Send confirmation email
     */
    private function send_confirmation_email( $booking, $service, $staff ) {
        $to = $booking['customer_email'];

        // Get custom subject
        $subject = get_option('yatoon_email_subject', 'Booking Confirmation - {service_name}');

        $start_time = new DateTime($booking['start_time']);
        $end_time   = new DateTime($booking['end_time']);
        $duration_minutes = yatoon_effective_booking_duration_minutes( $booking, $service );

        // Extract staff first name
        $staff_name       = $staff ? $staff->name : 'Your stylist';
        $staff_first_name = $this->get_first_name($staff_name);
        $business_name    = get_option('yatoon_business_name', get_bloginfo('name'));
        $business_phone   = get_option('yatoon_business_phone', get_option('yatoon_contact_phone', ''));
        $business_address = get_option('yatoon_business_address', get_option('sbs_business_address', ''));
        $business_website = get_option('yatoon_business_website', get_option('sbs_business_website', home_url('/')));
        if ( empty( $business_website ) ) { $business_website = home_url('/'); }
        $business_email   = get_option('yatoon_contact_email', '');
        $primary_color    = get_option('yatoon_primary_color', '#ec4899');
        $service_details  = $this->notification_service_details( $booking, $service );

        // Choose email template style
        $template_style = get_option('yatoon_email_template_style', 'plain'); // plain | modern | minimal | branded

        // Get custom template
        $template = get_option('yatoon_email_template',
'Dear {customer_name},

Your booking has been confirmed.

Service:    {service_name}
Option:     {option_names}
Staff:      {staff_name}
Date:       {date}
Time:       {time}
Duration:   {duration} minutes
Price:      ${total_price}

Payment: Please pay at the salon after your service. Thank you.

Notes: {notes}

Need to reschedule or cancel?
Use your self-service link (valid 7 days):
{manage_link}

Add this appointment to your calendar:
{calendar_link}

Thank you for choosing {business_name}.
We look forward to seeing you.

Salon details:
{business_address}
Phone: {business_phone}
Website: {business_website}

Best regards,
{business_name}');

        // Collect option names
        global $wpdb;
        $option_names = $service_details['option_names'];
        if ( empty( $option_names ) && !empty($booking['selected_options'])) {
            $ids = array_map('intval', (array) $booking['selected_options']);
            if ($ids) {
                $placeholders = implode(',', array_fill(0, count($ids), '%d'));
                $opts = yatoon_db_get_col($wpdb->prepare(
                    "SELECT name FROM {$wpdb->prefix}yatoon_service_options WHERE id IN ($placeholders)",
                    ...$ids
                ));
                $option_names = implode(', ', $opts);
            }
        }
        if (empty($option_names)) { $option_names = '-';
        }

        // Replace all variables
        $variables = array(
            '{customer_name}',
            '{customer_first_name}',
            '{customer_email}',
            '{customer_phone}',
            '{service_name}',
            '{option_names}',
            '{staff_name}',
            '{staff_first_name}',
            '{date_time}',
            '{date}',
            '{time}',
            '{duration}',
            '{end_time}',
            '{total_price}',
            '{notes}',
            '{business_name}',
            '{business_phone}',
            '{business_address}',
            '{business_website}',
            '{business_email}',
            '{manage_link}',
            '{calendar_link}',
        );
        $name_parts = explode(' ', $booking['customer_name'], 2);

        // Build manage-booking link.
        // Local Only: include our self-service URL.
        // Square / Vagaro: those platforms send their own confirmation emails with
        // built-in cancel/reschedule links, so we show a note instead so customers
        // are not confused by two conflicting sets of links.
        // Generate manage link for all platforms (local, Square, Vagaro)
        $manage_link_text = '';
        if ( ! empty( $booking['manage_token'] ) && class_exists( 'Yatoon_Manage_Booking' ) ) {
            $manage_link_text = Yatoon_Manage_Booking::get_manage_url( $booking['manage_token'] );
        }
        $calendar_link_text = '';
        if ( ! empty( $booking['booking_id'] ) && ! empty( $booking['manage_token'] ) ) {
            $calendar_link_text = YATOON_Calendar_Links::get_calendar_url( $booking['booking_id'], $booking['manage_token'] );
        }

        $replacements = array(
            $booking['customer_name'],
            $name_parts[0],
            $booking['customer_email'],
            $booking['customer_phone'],
            $service_details['service_name'],
            $option_names,
            $staff_name,
            $staff_first_name,
            $start_time->format('F j, Y g:i A'),
            $start_time->format('F j, Y'),
            $start_time->format('g:i A'),
            $duration_minutes,
            $end_time->format('g:i A'),
            number_format($booking['total_price'], 2),
            $booking['notes'] ?: '-',
            $business_name,
            $business_phone ?: '-',
            $business_address ?: '-',
            $business_website,
            $business_email,
            $manage_link_text,
            $calendar_link_text ?: '-',
        );

        // Subject replacement
        $subject = str_replace($variables, $replacements, $subject);
        $message_body = str_replace($variables, $replacements, $template);

        // Keep self-service links visible even when an older custom email template is still saved.
        $supplemental_lines = array();
        if ( $business_address && strpos( $template, '{business_address}' ) === false ) {
            $supplemental_lines[] = 'Address: ' . $business_address;
        }
        if ( $business_phone && strpos( $template, '{business_phone}' ) === false ) {
            $supplemental_lines[] = 'Phone: ' . $business_phone;
        }
        if ( $business_website && strpos( $template, '{business_website}' ) === false ) {
            $supplemental_lines[] = 'Website: ' . $business_website;
        }
        if ( $manage_link_text && strpos( $template, '{manage_link}' ) === false ) {
            $supplemental_lines[] = 'Manage, reschedule, or cancel: ' . $manage_link_text;
        }
        if ( $calendar_link_text && strpos( $template, '{calendar_link}' ) === false ) {
            $supplemental_lines[] = 'Add to calendar: ' . $calendar_link_text;
        }
        // WhatsApp link
        if (get_option('yatoon_whatsapp_enabled', '0') === '1') {
            $wa_number = preg_replace('/[^0-9]/', '', get_option('yatoon_whatsapp_number', get_option('yatoon_business_phone', '')));
        $wa_prefill = get_option('yatoon_whatsapp_prefill', __( 'Hi! I just booked an appointment.', 'yatoon-booking-system' ));
            if ($wa_number) {
                $supplemental_lines[] = 'Message us on WhatsApp: https://wa.me/' . $wa_number . '?text=' . rawurlencode($wa_prefill);
            }
        }
        if ( ! empty( $supplemental_lines ) ) {
            $message_body .= "\n\nSalon and appointment links:\n" . implode("\n", $supplemental_lines);
        }

        // Wrap in HTML template if style is not plain
        if ($template_style !== 'plain') {
            $message_body = $this->wrap_email_html($message_body, $template_style, $primary_color, $business_name);
            $headers = array('Content-Type: text/html; charset=UTF-8');
        } else {
            $headers = array();
        }

        wp_mail($to, $subject, $message_body, $headers);

        // Also notify admin if enabled
        if (get_option('yatoon_email_notify_admin', '0') === '1') {
            $admin_subject = '[New Booking] ' . $booking['customer_name'] . ' - ' . $service->name . ' on ' . $start_time->format('M j, Y g:i A');
            $admin_body = "New booking received!\n\n"
                . 'Customer: ' . $booking['customer_name'] . "\n"
                . 'Email: ' . $booking['customer_email'] . "\n"
                . 'Phone: ' . $booking['customer_phone'] . "\n"
                . 'Services: ' . $service_details['service_name'] . "\n"
                . 'Option: ' . $option_names . "\n"
                . 'Staff: ' . $staff_name . "\n"
                . 'Date/Time: ' . $start_time->format('F j, Y g:i A') . "\n"
                . 'Price: $' . number_format($booking['total_price'], 2) . "\n"
                . 'Notes: ' . ( $booking['notes'] ?: '-' ) . "\n";
            wp_mail($business_email, $admin_subject, $admin_body);
        }
    }

    /**
     * Wrap plain-text email body in an HTML email template
     */
    private function wrap_email_html( $body_text, $style, $primary_color, $business_name ) {
        // Convert plain text to simple HTML paragraphs
        $html_body = nl2br(esc_html($body_text));

        if ($style === 'modern') {
            return '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0;padding:0;background:#f4f4f5;font-family:\'Helvetica Neue\',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 16px;">
  <tr><td align="center">
    <table width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.08);">
      <tr><td style="background:' . esc_attr($primary_color) . ';padding:32px 40px;text-align:center;">
        ' . ( get_option('yatoon_business_logo') ? '<img src="' . esc_url(get_option('yatoon_business_logo')) . '" alt="' . esc_attr($business_name) . '" style="height:60px;max-width:200px;object-fit:contain;display:block;margin:0 auto 16px;" />' : '' ) . '
        <h1 style="color:#fff;margin:0;font-size:28px;font-weight:700;">' . esc_html($business_name) . '</h1>
          <p style="color:rgba(255,255,255,.85);margin:8px 0 0;font-size:16px;">Booking Confirmed</p>
      </td></tr>
      <tr><td style="padding:40px;color:#374151;font-size:15px;line-height:1.7;">' . $html_body . '</td></tr>
      <tr><td style="background:#f9fafb;padding:24px 40px;text-align:center;border-top:1px solid #e5e7eb;">
        ' . ( YATOON_License::is_pro() ? '' : '<p style="margin:0;color:#9ca3af;font-size:13px;">Powered by Yatoon Booking System</p>' ) . '
      </td></tr>
    </table>
  </td></tr>
</table></body></html>';
        } elseif ($style === 'minimal') {
            return '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="margin:0;padding:40px 16px;background:#fff;font-family:Georgia,serif;color:#1f2937;max-width:560px;margin:auto;">
' . ( get_option('yatoon_business_logo') ? '<img src="' . esc_url(get_option('yatoon_business_logo')) . '" alt="' . esc_attr($business_name) . '" style="height:50px;max-width:180px;object-fit:contain;display:block;margin:0 auto 16px;" />' : '<h2 style="color:' . esc_attr($primary_color) . ';border-bottom:2px solid ' . esc_attr($primary_color) . ';padding-bottom:12px;">' . esc_html($business_name) . '</h2>' ) . '
<div style="line-height:1.8;font-size:15px;">' . $html_body . '</div>
<p style="margin-top:40px;font-size:12px;color:#9ca3af;border-top:1px solid #e5e7eb;padding-top:16px;">Yatoon Booking System</p>
</body></html>';
        } elseif ($style === 'branded') {
            return '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0;padding:0;background:#1f2937;font-family:\'Helvetica Neue\',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1f2937;padding:40px 16px;">
  <tr><td align="center">
    <table width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#111827;border-radius:16px;overflow:hidden;">
      <tr><td style="padding:40px;text-align:center;border-bottom:1px solid #374151;">
        ' . ( get_option('yatoon_business_logo') ? '<img src="' . esc_url(get_option('yatoon_business_logo')) . '" alt="' . esc_attr($business_name) . '" style="height:56px;max-width:180px;object-fit:contain;display:block;margin:0 auto 12px;filter:brightness(0) invert(1);" />' : '' ) . '
        <div style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' . esc_attr($primary_color) . ';margin-right:8px;"></div>
        <span style="color:#fff;font-size:22px;font-weight:700;">' . esc_html($business_name) . '</span>
      </td></tr>
      <tr><td style="padding:40px;color:#d1d5db;font-size:15px;line-height:1.8;">' . $html_body . '</td></tr>
      <tr><td style="padding:24px 40px;text-align:center;border-top:1px solid #374151;">
        <p style="margin:0;color:#4b5563;font-size:12px;">Yatoon Booking System</p>
      </td></tr>
    </table>
  </td></tr>
</table></body></html>';
        }

        // Fallback - plain text
        return $body_text;
    }

    /**
     * Extract first name
     */
    private function get_first_name( $full_name ) {
        if (empty($full_name)) {
            return '';
        }
        // Extract first word
        $parts = preg_split('/\s+/', trim($full_name));
        return $parts[0];
    }

    private function get_client_ip() {
        return YATOON_Request_Guard::client_ip();
    }

    /**
     * Keep raw booking/API diagnostics unavailable on normal production sites.
     * Administrators may opt in temporarily with WP_DEBUG or the explicit
     * YATOON_ENABLE_ADMIN_DIAGNOSTICS constant in wp-config.php.
     */
    private function admin_diagnostics_enabled() {
        $wp_debug = defined( 'WP_DEBUG' ) && true === WP_DEBUG;
        $explicit = defined( 'YATOON_ENABLE_ADMIN_DIAGNOSTICS' ) && true === YATOON_ENABLE_ADMIN_DIAGNOSTICS;
        return $wp_debug || $explicit;
    }

    /** Validate an exact calendar date before it reaches DateTime or SQL. */
    private function is_valid_diagnostic_date( $date ) {
        if ( ! is_string( $date ) || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
            return false;
        }
        $parsed = DateTimeImmutable::createFromFormat( '!Y-m-d', $date );
        $errors = DateTimeImmutable::getLastErrors();
        return false !== $parsed && ( false === $errors || ( 0 === $errors['warning_count'] && 0 === $errors['error_count'] ) ) && $parsed->format( 'Y-m-d' ) === $date;
    }

    /**
     * Verify a Cloudflare Turnstile token server-side.
     * Invisible to real visitors - only blocks requests that never solved
     * the challenge a real browser runs automatically in the background.
     */
    private function verify_turnstile( $token ) {
        $secret = YATOON_Crypto::get_option('yatoon_turnstile_secret_key', '');
        if (empty($secret) || empty($token)) {
            return false;
        }
        // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Explicitly configured Turnstile verification API, not static asset offloading.
        $response = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', array(
            'body'    => array(
                'secret'   => $secret,
                'response' => $token,
                'remoteip' => $this->get_client_ip(),
            ),
            'timeout' => 10,
        ));
        if (is_wp_error($response)) {
            // Fail open on network errors - don't block real customers because
            // Cloudflare's endpoint had a hiccup.
            return get_option('yatoon_turnstile_fail_closed', '0') !== '1';
        }
        $body = json_decode(wp_remote_retrieve_body($response), true);
        return !empty($body['success']);
    }

    private function normalize_phone_digits( $phone ) {
        return YATOON_Request_Guard::normalize_phone_digits( $phone );
    }

    private function enforce_public_rate_limit( $scope, $limit = 5, $minutes = 10 ) {
        YATOON_Request_Guard::enforce( $scope, $limit, $minutes );
    }

    /**
     * Join waitlist
     */
    public function join_waitlist() {
        check_ajax_referer('yatoon_frontend_nonce', 'nonce');

        $this->enforce_public_rate_limit('waitlist', 5, 10);

        $name           = isset($_POST['name']) ? sanitize_text_field(wp_unslash($_POST['name'])) : '';
        $email          = isset($_POST['email']) ? sanitize_email(wp_unslash($_POST['email'])) : '';
        $phone          = isset($_POST['phone']) ? sanitize_text_field(wp_unslash($_POST['phone'])) : '';
        $preferred_date = isset($_POST['preferred_date']) ? sanitize_text_field(wp_unslash($_POST['preferred_date'])) : '';
        $preferred_time = isset($_POST['preferred_time']) ? sanitize_text_field(wp_unslash($_POST['preferred_time'])) : 'any';
        $flexible_dates = isset($_POST['flexible_dates']) ? 1 : 0;
        $flexible_times = isset($_POST['flexible_times']) ? 1 : 0;
        $notes          = isset($_POST['notes']) ? sanitize_textarea_field(wp_unslash($_POST['notes'])) : '';

        // Multi-select services - sanitize array
        $service_ids = array();
        if (!empty($_POST['service_ids']) && is_array($_POST['service_ids'])) {
            $service_ids = array_map('intval', $_POST['service_ids']);
            $service_ids = array_filter($service_ids); // remove zeros
        }
        // Fallback: legacy single service_id field
        if (empty($service_ids) && !empty($_POST['service_id'])) {
            $service_ids = array(intval($_POST['service_id']));
        }

        // Validation
        $errors = array();
        if (empty($name)) {           $errors[] = 'name is required';
        }
        if (empty($email)) {          $errors[] = 'email is required';
        }
        if (empty($phone)) {          $errors[] = 'phone is required';
        }
        if (empty($service_ids)) {    $errors[] = 'please select at least one service';
        }
        if (empty($preferred_date)) { $errors[] = 'preferred date is required';
        }

        if (!empty($errors)) {
            wp_send_json_error(array('message' => ucfirst(implode(', ', $errors)) . '.'));
        }

        global $wpdb;
        $table = $wpdb->prefix . 'yatoon_waitlist';

        $phone_digits = $this->normalize_phone_digits($phone);
        $duplicate = yatoon_db_get_var($wpdb->prepare(
            "SELECT id FROM $table
             WHERE service_id = %d
               AND preferred_date = %s
               AND status = 'active'
               AND (LOWER(customer_email) = LOWER(%s)
                    OR RIGHT(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(customer_phone,'-',''),' ',''),'(',''),')',''),'+',''),'.',''),'\t',''), 10) = %s)
             LIMIT 1",
            $service_ids[0],
            $preferred_date,
            $email,
            $phone_digits
        ));
        if ($duplicate) {
            wp_send_json_error(array('message' => __('You are already on the waitlist for this date and service.', 'yatoon-booking-system')));
        }

        // Look up service names for email/notes
        $placeholders  = implode(',', array_fill(0, count($service_ids), '%d'));
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $service_rows  = yatoon_db_get_results(
            $wpdb->prepare("SELECT id, name FROM {$wpdb->prefix}yatoon_services WHERE id IN ($placeholders)", ...$service_ids)
        );
        $service_names = wp_list_pluck($service_rows, 'name');

        // Store as one row per service (keeps schema clean) or one combined row
        // We'll store one row with service_ids as comma-separated in notes, and first service_id as primary
        $primary_service_id = $service_ids[0];
        $services_note = 'Services: ' . implode(', ', $service_names);
        if (count($service_ids) > 1) {
            $notes = $services_note . ( $notes ? "\n\n" . $notes : '' );
        }

        $result = yatoon_db_insert($table, array(
            'customer_name'  => $name,
            'customer_email' => $email,
            'customer_phone' => $phone,
            'service_id'     => $primary_service_id,
            'staff_id'       => null,
            'preferred_date' => $preferred_date,
            'preferred_time' => $preferred_time,
            'flexible_dates' => $flexible_dates,
            'flexible_times' => $flexible_times,
            'notes'          => $notes,
            'status'         => 'active',
        ));

        if ($result) {
            // Customer confirmation email
            $this->send_waitlist_confirmation_v2($name, $email, $service_names, $preferred_date, $preferred_time);
            // Admin notification
            $this->notify_admin_waitlist_v2($name, $email, $phone, $service_names, $preferred_date, $preferred_time, $notes);

            wp_send_json_success(array('message' => __( 'Successfully joined waitlist', 'yatoon-booking-system' )));
        } else {
            wp_send_json_error(array('message' => __( 'Database error. Please try again.', 'yatoon-booking-system' )));
        }
    }

    /**
     * Enhanced waitlist customer confirmation (multi-service)
     */
    private function send_waitlist_confirmation_v2( $name, $email, $service_names, $date, $time_pref ) {
        $business_name  = get_option('yatoon_business_name', 'Your Business');
        $business_email = get_option('yatoon_contact_email', '');
        $subject        = "You're on our waitlist - $business_name";

        $time_labels = array(
            'morning'       => 'Morning (10am - 12pm)',
            'afternoon'     => 'Afternoon (12pm - 4pm)',
            'late_afternoon'=> 'Late Afternoon (4pm - 7pm)',
            'any'           => 'Any time',
        );
        $time_label = $time_labels[ $time_pref ] ?? 'Any time';
        $services_str = implode(', ', $service_names);
        $date_fmt = yatoon_format_local('F j, Y', $date);

        $message  = "Hi $name,\n\n";
        $message .= "Thank you for joining our waitlist!\n\n";
        $message .= "---------------------\n";
        $message .= "Service(s):     $services_str\n";
        $message .= "Preferred Date: $date_fmt\n";
        $message .= "Preferred Time: $time_label\n";
        $message .= "---------------------\n\n";
        $message .= "We'll contact you as soon as a spot opens up.\n\n";
        $message .= "Best regards,\n$business_name";

        $headers = array();
        if ($business_email) {
            $headers[] = 'From: ' . $business_name . ' <' . $business_email . '>';
        }
        wp_mail($email, $subject, $message, $headers);
    }

    /**
     * Enhanced waitlist admin notification (multi-service)
     */
    private function notify_admin_waitlist_v2( $name, $email, $phone, $service_names, $date, $time_pref, $notes ) {
        $business_name  = get_option('yatoon_business_name', 'Your Business');
        $admin_email    = get_option('yatoon_contact_email', '');
        if (empty($admin_email)) { return;
        }

        $time_labels = array(
            'morning'       => 'Morning (10am - 12pm)',
            'afternoon'     => 'Afternoon (12pm - 4pm)',
            'late_afternoon'=> 'Late Afternoon (4pm - 7pm)',
            'any'           => 'Any time',
        );
        $time_label   = $time_labels[ $time_pref ] ?? 'Any time';
        $services_str = implode(', ', $service_names);
        $date_fmt     = yatoon_format_local('F j, Y', $date);

        $subject  = "New Waitlist Request - $name ($business_name)";
        $message  = "New waitlist request received:\n\n";
        $message .= "Customer:      $name\n";
        $message .= "Email:         $email\n";
        $message .= "Phone:         $phone\n";
        $message .= "Service(s):    $services_str\n";
        $message .= "Preferred Date: $date_fmt\n";
        $message .= "Preferred Time: $time_label\n";
        if ($notes) {
            $message .= "\nNotes:\n$notes\n";
        }
            $message .= "\n" . __( 'Check your admin panel for details.', 'yatoon-booking-system' );

        wp_mail($admin_email, $subject, $message);
    }

    /**
     * Send waitlist confirmation email to customer
     */
    private function send_waitlist_confirmation( $name, $email, $service_id, $date ) {
        global $wpdb;
        $service = yatoon_db_get_row($wpdb->prepare(
            "SELECT name FROM {$wpdb->prefix}yatoon_services WHERE id = %d",
            $service_id
        ));

        $to = $email;
        $business_name = get_option('yatoon_business_name', 'Your Business');
        $subject = 'You\'re on our waitlist - ' . $business_name;
        $message = sprintf(
            "Hi %s,\n\nThank you for joining our waitlist!\n\nService: %s\nPreferred Date: %s\n\nWe'll contact you as soon as a spot becomes available.\n\nBest regards,\n%s Team",
            $name,
            $service->name,
            yatoon_format_local('F j, Y', $date),
            $business_name
        );

        wp_mail($to, $subject, $message);
    }

    /**
     * Notify admin of a new waitlist request
     */
    private function notify_admin_waitlist( $name, $email, $phone, $service_id, $date, $notes = '' ) {
        global $wpdb;
        $service = yatoon_db_get_row($wpdb->prepare(
            "SELECT name FROM {$wpdb->prefix}yatoon_services WHERE id = %d",
            $service_id
        ));

        $admin_email = get_option('yatoon_contact_email', '');
        $business_name = get_option('yatoon_business_name', 'Your Business');
        $subject = 'New Waitlist Request - ' . $business_name;

        // Build email content with full notes
        $message = "New waitlist request:\n\n";
        $message .= 'Customer: ' . $name . "\n";
        $message .= 'Email: ' . $email . "\n";
        $message .= 'Phone: ' . $phone . "\n";
        $message .= 'Service: ' . $service->name . "\n";
        $message .= 'Preferred Date: ' . yatoon_format_local('F j, Y', $date) . "\n\n";

        if (!empty($notes)) {
            $message .= "Customer Notes:\n";
            $message .= "-------------------\n";
            $message .= $notes . "\n";
            $message .= "-------------------\n\n";
        }

        $message .= 'Check your admin panel for details.';

        wp_mail($admin_email, $subject, $message);
    }


    /**
     * Admin diagnostic: show raw yatoon_bookings rows for a given date.
     * Helps identify bad end_time / timezone records causing false conflicts.
     * Usage: POST to admin-ajax.php with action=yatoon_debug_date_bookings&date=2026-02-24
     */

    /**
     * Admin diagnostic: directly query Square availability API for a specific date + service variation.
     * Shows raw slots returned by Square, staff schedules, and why certain times are unavailable.
     */

    /**
     * Admin: cancel a booking - updates local DB status to cancelled and cancels in Square.
     */

    /**
     * Admin: permanently delete a single booking row from local yatoon_bookings.
     * Does NOT call Square - use cancel first if booking is active in Square.
     */

    /**
     * Admin: Pull latest booking statuses from Square and update local yatoon_bookings.
     *
     * What it does:
     * 1. Fetch all Square bookings for the past 30 days + next 60 days
     * 2. For each local booking with a square_booking_id, update status to match Square
     * 3. For Square bookings not in local DB, optionally insert them
     * 4. Mark local bookings NOT found in Square (e.g. deleted) as cancelled
     *
     * This fixes the "No available times" false positive caused by cancelled Square
     * bookings still showing as "confirmed" in the local DB.
     */
    public function sync_bookings_from_square() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => __( 'Permission denied', 'yatoon-booking-system' )));
        }
        check_ajax_referer('yatoon_admin_nonce', 'nonce');

        global $wpdb;
        $table          = $wpdb->prefix . 'yatoon_bookings';
        $table_staff    = $wpdb->prefix . 'yatoon_staff';
        $table_services = $wpdb->prefix . 'yatoon_services';
        $table_opts     = $wpdb->prefix . 'yatoon_service_options';
        $timezone       = yatoon_timezone_string();
        $tz_obj         = new DateTimeZone($timezone);

        // Date range: past 30 days to future 60 days
        $utc      = new DateTimeZone( 'UTC' );
        $start_at = ( new DateTime('-30 days', $tz_obj) )->setTimezone( $utc )->format('Y-m-d\TH:i:s\Z');
        $end_at   = ( new DateTime('+60 days', $tz_obj) )->setTimezone( $utc )->format('Y-m-d\TH:i:s\Z');

        // Pull all bookings from Square
        $square_bookings = $this->square_api->list_all_bookings($start_at, $end_at);

        if (empty($square_bookings)) {
            wp_send_json_success(array(
                'message'  => __( 'Square returned 0 bookings in the date range. Local DB unchanged.', 'yatoon-booking-system' ),
                'updated'  => 0,
                'inserted' => 0,
                'cancelled'=> 0,
            ));
        }

        // Build a map: square_booking_id => Square booking object
        $sq_map = array();
        foreach ($square_bookings as $sb) {
            if (!empty($sb['id'])) { $sq_map[ $sb['id'] ] = $sb;
            }
        }

        // Map Square status -> local status
        $status_map = array(
            'ACCEPTED'   => 'confirmed',
            'PENDING'    => 'pending',
            'CANCELLED_BY_CUSTOMER'  => 'cancelled',
            'CANCELLED_BY_SELLER'    => 'cancelled',
            'DECLINED'   => 'cancelled',
            'NO_SHOW'    => 'no_show',
        );

        $updated   = 0;
        $inserted  = 0;
        $cancelled = 0;

        // Get all local bookings that have a Square ID in our date window
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $local_rows = yatoon_db_get_results(
            "SELECT id, square_booking_id, status, start_time, end_time,
                    customer_name, customer_email, customer_phone FROM $table
             WHERE square_booking_id IS NOT NULL AND square_booking_id != ''
               AND start_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
               AND start_time <= DATE_ADD(NOW(), INTERVAL 60 DAY)"
        );

        $local_sq_ids = array();
        $customer_cache = array();
        $reconciled_square_ids = array();
        foreach ($local_rows as $reconcile_local) {
            $reconcile_id = (string) $reconcile_local->square_booking_id;
            if (isset($reconciled_square_ids[ $reconcile_id ]) || !isset($sq_map[ $reconcile_id ])) { continue;
            }
            $reconciled_square_ids[ $reconcile_id ] = true;
            $updated += yatoon_reconcile_square_booking_segments($sq_map[ $reconcile_id ]);
        }
        foreach ($local_rows as $local) {
            if (empty($local->square_booking_id)) { continue;
            }
            $local_sq_ids[] = $local->square_booking_id;

            if (!isset($sq_map[ $local->square_booking_id ])) {
                // Not found in Square at all - mark as cancelled if still active
                if (!in_array($local->status, array('cancelled','no_show'))) {
                    yatoon_db_update($table, array('status' => 'cancelled'), array('id' => $local->id));
                    ++$cancelled;
                }
                continue;
            }

            $sq_booking  = $sq_map[ $local->square_booking_id ];
            $sq_status   = isset($sq_booking['status']) ? $sq_booking['status'] : '';
            $local_new   = isset($status_map[ $sq_status ]) ? $status_map[ $sq_status ] : null;

            // Recompute end_time from Square segment data (fixes stale/zero end_time in DB)
            $update_fields = array();
            $sq_customer_id = $sq_booking['customer_id'] ?? '';
            $needs_customer = !$local->customer_name
                || strcasecmp(trim($local->customer_name), 'Square Customer') === 0
                || ( !$local->customer_email && !$local->customer_phone );
            if ($sq_customer_id && $needs_customer) {
                if (!array_key_exists($sq_customer_id, $customer_cache)) {
                    $customer_cache[ $sq_customer_id ] = $this->square_api->get_customer($sq_customer_id);
                }
                $sq_customer = $customer_cache[ $sq_customer_id ];
                if ($sq_customer) {
                    $real_name = trim(( $sq_customer['given_name'] ?? '' ) . ' ' . ( $sq_customer['family_name'] ?? '' ));
                    if ($real_name) { $update_fields['customer_name'] = $real_name;
                    }
                    if (!empty($sq_customer['email_address'])) { $update_fields['customer_email'] = $sq_customer['email_address'];
                    }
                    if (!empty($sq_customer['phone_number'])) { $update_fields['customer_phone'] = $sq_customer['phone_number'];
                    }
                }
            }
            if ($local_new && $local_new !== $local->status) {
                $update_fields['status'] = $local_new;
            }
            if (!empty($update_fields)) {
                yatoon_db_update($table, $update_fields, array('id' => $local->id));
                ++$updated;
            }
        }

        // Also check for Square bookings NOT in local DB at all -> insert them
        foreach ($square_bookings as $sb) {
            if (empty($sb['id'])) { continue;
            }
            if (in_array($sb['id'], $local_sq_ids)) { continue; // already handled above
            }

            // Skip cancelled bookings - no point inserting them
            $sq_status  = $sb['status'] ?? '';
            $local_status = isset($status_map[ $sq_status ]) ? $status_map[ $sq_status ] : 'confirmed';
            if ($local_status === 'cancelled') { continue;
            }

            // Try to map Square booking fields to local DB
            if (empty($sb['appointment_segments'])) { continue;
            }
            $seg       = $sb['appointment_segments'][0];
            $var_id    = $seg['service_variation_id'] ?? '';
            $sq_staff  = $seg['team_member_id'] ?? '';
            $start_at_sq = $sb['start_at'] ?? '';
            if (!$var_id || !$start_at_sq) { continue;
            }

            // Look up local IDs
            $opt = yatoon_db_get_row($wpdb->prepare("SELECT id, service_id, duration_minutes FROM $table_opts WHERE square_id=%s LIMIT 1", $var_id));
            if (!$opt) { continue;
            }
            $staff = yatoon_db_get_row($wpdb->prepare("SELECT id FROM $table_staff WHERE square_id=%s LIMIT 1", $sq_staff));
            if (!$staff) { continue;
            }

            $start_dt = new DateTime($start_at_sq);
            $start_dt->setTimezone($tz_obj);
            $end_dt   = clone $start_dt;

            // Duration: prefer Square segment value, fall back to local option/service duration.
            // Sum ALL segments so multi-service bookings get correct total end_time.
            $total_dur_min = 0;
            if (!empty($sb['appointment_segments'])) {
                foreach ($sb['appointment_segments'] as $seg_item) {
                    $seg_dur = intval($seg_item['duration_minutes'] ?? 0);
                    if ($seg_dur > 0) {
                        $total_dur_min += $seg_dur;
                    } else {
                        // Fall back to local option duration for this segment
                        $seg_var = $seg_item['service_variation_id'] ?? '';
                        if ($seg_var) {
                            $seg_opt = yatoon_db_get_var($wpdb->prepare(
                                "SELECT duration_minutes FROM $table_opts WHERE square_id=%s LIMIT 1", $seg_var
                            ));
                            if ($seg_opt) { $total_dur_min += intval($seg_opt);
                            }
                        }
                    }
                }
            }
            // Final fallback: use the first segment's local option duration
            if ($total_dur_min <= 0) { $total_dur_min = max(intval($opt->duration_minutes), 30);
            }
            $end_dt->modify("+{$total_dur_min} minutes");

            $customer = $sb['customer_note'] ?? '';
            $sq_customer = null;
            $sq_customer_id = $sb['customer_id'] ?? '';
            if ($sq_customer_id) {
                if (!array_key_exists($sq_customer_id, $customer_cache)) {
                    $customer_cache[ $sq_customer_id ] = $this->square_api->get_customer($sq_customer_id);
                }
                $sq_customer = $customer_cache[ $sq_customer_id ];
                if ($sq_customer) {
                    $customer = trim(( $sq_customer['given_name'] ?? '' ) . ' ' . ( $sq_customer['family_name'] ?? '' )) ?: $customer;
                }
            }

            yatoon_db_insert($table, array(
                'square_booking_id' => $sb['id'],
                'customer_name'     => $customer ?: 'Square Customer',
                'customer_email'    => $sq_customer['email_address'] ?? '',
                'customer_phone'    => $sq_customer['phone_number'] ?? '',
                'service_id'        => $opt->service_id,
                'staff_id'          => $staff->id,
                'location_id'       => yatoon_location_id_for_external( $sb['location_id'] ?? '' ) ?: null,
                'booking_timezone'  => yatoon_timezone_string( yatoon_location_id_for_external( $sb['location_id'] ?? '' ) ?: null, 'square' ),
                'start_time'        => $start_dt->format('Y-m-d H:i:s'),
                'end_time'          => $end_dt->format('Y-m-d H:i:s'),
                'status'            => $local_status,
                'square_synced'     => 1,
                'total_price'       => 0,
            ));
            $new_bk_id = $wpdb->insert_id;
            // Insert booking_options for each segment so service names show correctly
            if ( $new_bk_id && !empty($sb['appointment_segments']) ) {
                foreach ($sb['appointment_segments'] as $seg_item2) {
                    $sv2 = $seg_item2['service_variation_id'] ?? '';
                    if (!$sv2) { continue;
                    }
                    $so2 = yatoon_db_get_row($wpdb->prepare("SELECT id FROM $table_opts WHERE square_id=%s LIMIT 1", $sv2)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                    if ($so2) { yatoon_db_insert($table_booking_options, array('booking_id'=>$new_bk_id,'option_id'=>$so2->id)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                    }                }
            }
            ++$inserted;
        }

        wp_send_json_success(array(
            'message'   => sprintf(
            __( 'Sync complete: %d status(es) updated, %d new booking(s) inserted, %d removed-from-Square marked cancelled.', 'yatoon-booking-system' ),
                $updated, $inserted, $cancelled
            ),
            'updated'   => $updated,
            'inserted'  => $inserted,
            'cancelled' => $cancelled,
        ));
    }

    public function delete_single_booking() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => __( 'Permission denied', 'yatoon-booking-system' )));
            return;
        }
        check_ajax_referer('yatoon_delete_bookings', 'nonce');

        global $wpdb;
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        if (!$id) {
            wp_send_json_error(array('message' => __( 'Missing booking ID', 'yatoon-booking-system' )));
        }

        $table = $wpdb->prefix . 'yatoon_bookings';
        $table_opts = $wpdb->prefix . 'yatoon_booking_options';

        // Delete associated booking options first
        yatoon_db_delete($table_opts, array('booking_id' => $id));
        // Delete the booking row
        $deleted = yatoon_db_delete($table, array('id' => $id));

        if ($deleted === false) {
            wp_send_json_error(array('message' => __( 'Database delete failed', 'yatoon-booking-system' )));
        }
        wp_send_json_success(array('message' => sprintf(
            /* translators: %d: booking ID. */
            __('Booking #%d deleted.', 'yatoon-booking-system'),
            $id
        ),));
    }

    public function cancel_booking_admin() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => __( 'Permission denied', 'yatoon-booking-system' )));
        }
        check_ajax_referer('yatoon_cancel_booking', 'nonce');

        global $wpdb;
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        if (!$id) {
            wp_send_json_error(array('message' => __( 'Missing booking ID', 'yatoon-booking-system' )));
        }

        $table = $wpdb->prefix . 'yatoon_bookings';

        // Get the booking (and all siblings in same group)
        $booking = yatoon_db_get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $id));
        if (!$booking) {
            wp_send_json_error(array('message' => __( 'Booking not found', 'yatoon-booking-system' )));
        }

        // Collect all IDs to cancel (solo or entire group)
        $ids_to_cancel = array();
        if (!empty($booking->group_booking_id)) {
            $group_rows = yatoon_db_get_results($wpdb->prepare(
                "SELECT id, square_booking_id FROM $table WHERE group_booking_id = %s",
                $booking->group_booking_id
            ));
            foreach ($group_rows as $g) { $ids_to_cancel[] = $g;
            }
        } else {
            $ids_to_cancel[] = $booking;
        }

        $sq_errors = array();
        foreach ($ids_to_cancel as $bk) {
            // Cancel in Square if synced
            if (!empty($bk->square_booking_id)) {
                // Get current version from Square
                $sq_resp = $this->square_api->get_booking($bk->square_booking_id);
                $version = null;
                if (!is_wp_error($sq_resp) && isset($sq_resp['booking']['version'])) {
                    $version = intval($sq_resp['booking']['version']);
                }
                $cancel_resp = $this->square_api->cancel_booking($bk->square_booking_id, $version);
                if (is_wp_error($cancel_resp)) {
                    $sq_errors[] = $cancel_resp->get_error_message();
                }
            }
            // Update local DB status to cancelled
            yatoon_db_update($table, array('status' => 'cancelled'), array('id' => intval($bk->id)));
        }

        if (!empty($sq_errors)) {
            wp_send_json_success(array(
                'message'   => sprintf( __( 'Booking cancelled locally. Square warning: %s', 'yatoon-booking-system' ), implode('; ', $sq_errors) ),
                'cancelled' => count($ids_to_cancel),
            ));
        } else {
            // notify_method: 'none' | 'email' | 'sms' | 'both'. Falls back to the legacy
            // boolean notify_customer (checkbox) so any cached/old JS keeps working.
            $notify_method = isset($_POST['notify_method']) ? sanitize_key(wp_unslash($_POST['notify_method'])) : '';
            if (!in_array($notify_method, array('none', 'email', 'sms', 'both', 'whatsapp', 'email_whatsapp', 'all'), true)) {
                $notify_method = ( !empty($_POST['notify_customer']) && intval($_POST['notify_customer']) === 1 ) ? 'email' : 'none';
            }

            if ($notify_method !== 'none') {
                // Re-fetch the primary booking with service/staff names joined in - the
                // group-booking query above only selects id/square_booking_id, and the
                // raw bookings row never has service_name/staff_name columns.
                $primary_id = intval($ids_to_cancel[0]->id);
                $primary    = yatoon_db_get_row($wpdb->prepare(
                    "SELECT b.*, s.name AS service_name, st.name AS staff_name
                     FROM $table b
                     LEFT JOIN {$wpdb->prefix}yatoon_services s ON b.service_id = s.id
                     LEFT JOIN {$wpdb->prefix}yatoon_staff st ON b.staff_id = st.id
                     WHERE b.id = %d LIMIT 1",
                    $primary_id
                ));

                if ($primary) {
                    $notify_data = array(
                        'customer_name'  => $primary->customer_name  ?? '',
                        'customer_email' => $primary->customer_email ?? '',
                        'customer_phone' => $primary->customer_phone ?? '',
                        'service_name'   => $primary->service_name   ?? '',
                        'staff_name'     => $primary->staff_name     ?? '',
                        'start_time'     => $primary->start_time     ?? '',
                        'end_time'       => $primary->end_time       ?? '',
                        'total_price'    => $primary->total_price    ?? 0,
                        'notes'          => $primary->notes          ?? '',
                    );

                    if (in_array($notify_method, array('email', 'both', 'email_whatsapp', 'all'), true) && class_exists('YATOON_Notifications')) {
                        $notif = new YATOON_Notifications();
                        $notif->send_cancel_email($notify_data);
                    }
                    if (in_array($notify_method, array('sms', 'both', 'all'), true) && class_exists('YATOON_Twilio')) {
                        YATOON_Twilio::send_cancel_sms($notify_data);
                    }
                    if ( class_exists('YATOON_WhatsApp') && in_array($notify_method, array('whatsapp', 'email_whatsapp', 'all'), true) ) {
                        YATOON_WhatsApp::send_cancel($notify_data);
                    }
                }
            }
            wp_send_json_success(array(
                'message'   => __( 'Booking cancelled successfully.', 'yatoon-booking-system' ),
                'cancelled' => count($ids_to_cancel),
            ));
        }
    }

    public function debug_square_availability() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__( 'Permission denied', 'yatoon-booking-system' ));
        }
        check_ajax_referer('yatoon_admin_nonce', 'nonce');
        if ( ! $this->admin_diagnostics_enabled() ) {
            wp_send_json_error( array( 'message' => __( 'Admin diagnostics are disabled on this site.', 'yatoon-booking-system' ) ) );
        }
        global $wpdb;
        // 4.6.2: date() formats in PHP's default timezone, which WordPress sets
        // to UTC on virtually every host. On a US West Coast salon anything
        // after 5pm local made this diagnostic default to *tomorrow*, so the
        // page reported "no bookings" for a day that had not started yet.
        // current_time() resolves against the site timezone instead.
        $date       = isset($_POST['date'])       ? sanitize_text_field( wp_unslash( $_POST['date'] ) )  : current_time('Y-m-d');
        $service_id = isset($_POST['service_id']) ? intval($_POST['service_id'])         : 0;
        if ( ! $this->is_valid_diagnostic_date( $date ) || $service_id < 1 ) {
            wp_send_json_error( array( 'message' => __( 'Please provide a valid date and service.', 'yatoon-booking-system' ) ) );
        }

        $timezone = yatoon_timezone_string();
        $tz_obj   = new DateTimeZone($timezone);
        $date_start = ( new DateTime($date . ' 00:00:00', $tz_obj) )->format('c');
        $date_end   = ( new DateTime($date . ' 23:59:59', $tz_obj) )->format('c');

        // Get all eligible staff for this service
        $staff_list = yatoon_db_get_results($wpdb->prepare(
            "SELECT st.id, st.name, st.square_id
             FROM {$wpdb->prefix}yatoon_staff st
             INNER JOIN {$wpdb->prefix}yatoon_staff_services ss ON st.id = ss.staff_id
             WHERE st.is_active=1 AND st.is_bookable=1 AND ss.service_id=%d
             ORDER BY st.sort_order ASC, st.name ASC",
            $service_id
        ));
        if (empty($staff_list)) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $staff_list = yatoon_db_get_results(
                "SELECT id, name, square_id FROM {$wpdb->prefix}yatoon_staff WHERE is_active=1 AND is_bookable=1 ORDER BY sort_order ASC, name ASC"
            );
        }

        // Get all variations for this service
        $variations = yatoon_db_get_results($wpdb->prepare(
            "SELECT id, name, square_id, duration_minutes, group_name FROM {$wpdb->prefix}yatoon_service_options WHERE service_id=%d AND square_id!='' ORDER BY sort_order ASC, id ASC",
            $service_id
        ));

        $service = yatoon_db_get_row($wpdb->prepare("SELECT name FROM {$wpdb->prefix}yatoon_services WHERE id=%d", $service_id));

        $results_per_staff = array();
        foreach ($staff_list as $staff) {
            if (empty($staff->square_id)) { continue;
            }
            $staff_result = array(
                'staff_id'    => $staff->id,
                'staff_name'  => $staff->name,
                'staff_sq_id' => $staff->square_id,
                'variations'  => array(),
            );
            foreach ($variations as $var) {
                $resp = $this->square_api->search_availability($var->square_id, $date_start, $date_end, $staff->square_id);
                $slots = array();
                $sq_error = null;
                if (is_wp_error($resp)) {
                    $sq_error = $resp->get_error_message();
                } elseif (isset($resp['availabilities'])) {
                    foreach ($resp['availabilities'] as $av) {
                        if (empty($av['start_at'])) { continue;
                        }
                        $t = new DateTime($av['start_at']);
                        $t->setTimezone($tz_obj);
                        $slots[] = $t->format('g:i A');
                    }
                } elseif (isset($resp['errors'])) {
                    $sq_error = json_encode($resp['errors']);
                }
                $staff_result['variations'][] = array(
                    'var_name'       => $var->name,
                    'var_sq_id'      => $var->square_id,
                    'duration_min'   => $var->duration_minutes,
                    'group_name'     => $var->group_name,
                    'slots_count'    => count($slots),
                    'slots'          => $slots,
                    'error'          => $sq_error,
                );
            }
            $results_per_staff[] = $staff_result;
        }

        wp_send_json_success(array(
            'date'             => $date,
            'service_id'       => $service_id,
            'service_name'     => $service ? $service->name : "id=$service_id",
            'timezone'         => $timezone,
            'staff_count'      => count($results_per_staff),
            'variation_count'  => count($variations),
            'results_per_staff'=> $results_per_staff,
        ));
    }

    public function debug_date_bookings() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__( 'Permission denied', 'yatoon-booking-system' ));
        }
        check_ajax_referer('yatoon_admin_nonce', 'nonce');
        if ( ! $this->admin_diagnostics_enabled() ) {
            wp_send_json_error( array( 'message' => __( 'Admin diagnostics are disabled on this site.', 'yatoon-booking-system' ) ) );
        }
        global $wpdb;
        // 4.6.2: same UTC-vs-site-timezone defect as debug_square_availability().
        // date('Y-m-d') used PHP's default (UTC) timezone, so late-afternoon
        // admins in negative UTC offsets were shown tomorrow's bookings by
        // default. current_time() uses the site timezone.
        $date = isset($_POST['date']) ? sanitize_text_field( wp_unslash( $_POST['date'] ) ) : current_time('Y-m-d');
        if ( ! $this->is_valid_diagnostic_date( $date ) ) {
            wp_send_json_error( array( 'message' => __( 'Please provide a valid date.', 'yatoon-booking-system' ) ) );
        }
        $timezone  = yatoon_timezone_string();
        $tz_obj    = new DateTimeZone($timezone);
        $tz_utc    = new DateTimeZone('UTC');

        // All bookings on this date (not cancelled/no_show)
        // Show ALL records regardless of status - this reveals "confirmed" records
        // that should have been cancelled but weren't updated in the local DB.
        $rows = yatoon_db_get_results($wpdb->prepare(
            "SELECT b.id, b.square_booking_id, b.customer_name, b.service_id, b.staff_id,
                    b.start_time, b.end_time, b.status, b.square_synced,
                    s.name AS service_name, st.name AS staff_name
             FROM {$wpdb->prefix}yatoon_bookings b
             LEFT JOIN {$wpdb->prefix}yatoon_services s  ON b.service_id = s.id
             LEFT JOIN {$wpdb->prefix}yatoon_staff   st ON b.staff_id   = st.id
             WHERE DATE(b.start_time) = %s
             ORDER BY b.status ASC, b.start_time ASC",
            $date
        ));

        $result = array();
        foreach ($rows as $r) {
            // Parse stored time (assumed local tz) and compute UTC
            try {
                $start_local = new DateTime($r->start_time, $tz_obj);
                $end_local   = new DateTime($r->end_time,   $tz_obj);
                $start_utc   = clone $start_local;
$start_utc->setTimezone($tz_utc);
                $end_utc     = clone $end_local;
$end_utc->setTimezone($tz_utc);
                $duration_min = round(( $end_local->getTimestamp() - $start_local->getTimestamp() ) / 60);
            } catch (Exception $e) {
                $start_utc = $end_utc = null;
                $duration_min = '?';
            }
            $result[] = array(
                'id'               => $r->id,
                'square_booking_id'=> $r->square_booking_id,
                'customer'         => $r->customer_name,
                'service'          => $r->service_name  ?: "id={$r->service_id}",
                'staff'            => $r->staff_name    ?: "id={$r->staff_id}",
                'status'           => $r->status,
                'square_synced'    => $r->square_synced,
                'start_time_raw'   => $r->start_time,
                'end_time_raw'     => $r->end_time,
                'duration_min'     => $duration_min,
                'start_local'      => $start_local ? $start_local->format('Y-m-d H:i:s T') : 'parse error',
                'end_local'        => $end_local   ? $end_local->format('Y-m-d H:i:s T')   : 'parse error',
                'start_utc'        => $start_utc   ? $start_utc->format('Y-m-d H:i:s') . ' UTC' : 'parse error',
                'end_utc'          => $end_utc     ? $end_utc->format('Y-m-d H:i:s') . ' UTC'   : 'parse error',
                'flag'             => ( $duration_min !== '?' && ( $duration_min <= 0 || $duration_min > 480 ) )
                                        ? 'Warning: SUSPICIOUS DURATION'
                                        : ( in_array($r->status, array('confirmed','pending')) ? 'BLOCKS AVAILABILITY' : 'OK not blocking' ),
            );
        }

        wp_send_json_success(array(
            'date'          => $date,
            'timezone'      => $timezone,
            'booking_count' => count($result),
            'bookings'      => $result,
            'note'          => 'start_time/end_time in DB are stored in local timezone (' . $timezone . '). If any end_time looks wrong or duration is 0 or >480min, that record is causing the conflict.',
        ));
    }

    /**
     * Admin diagnostic: test Square availability search for a service
     */
    public function diagnose_service() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__( 'Permission denied', 'yatoon-booking-system' ));
        }
        check_ajax_referer('yatoon_admin_nonce', 'nonce');

        $service_id = isset($_POST['service_id']) ? intval($_POST['service_id']) : 0;
        if (!$service_id) { wp_send_json_error(__( 'Missing service_id', 'yatoon-booking-system' ));
        }

        global $wpdb;
        $service = yatoon_db_get_row($wpdb->prepare(
            "SELECT * FROM {$wpdb->prefix}yatoon_services WHERE id = %d", $service_id
        ));
        if (!$service) { wp_send_json_error(__( 'Service not found', 'yatoon-booking-system' ));
        }

        $options = yatoon_db_get_results($wpdb->prepare(
            "SELECT * FROM {$wpdb->prefix}yatoon_service_options WHERE service_id = %d ORDER BY sort_order ASC, id ASC", $service_id
        ));

        $result = array(
            'service'             => array(
                'id'        => $service->id,
                'name'      => $service->name,
                'square_id' => $service->square_id,
                'price'     => $service->price,
            ),
            'variations'          => array(),
            'availability_tests'  => array(),
            'catalog_object'      => null,
            'correct_ids_found'   => array(),
        );

        foreach ($options as $opt) {
            $result['variations'][] = array(
                'id'             => $opt->id,
                'name'           => $opt->name,
                'square_id'      => $opt->square_id,
                'price_modifier' => $opt->price_modifier,
                'duration'       => $opt->duration_minutes,
            );
        }

        $tz       = yatoon_timezone();
        $tomorrow = new DateTime('tomorrow', $tz);
        $start    = ( clone $tomorrow )->setTime(0,0,0)->format('c');
        $end      = ( clone $tomorrow )->setTime(23,59,59)->format('c');

        foreach ($options as $opt) {
            if (empty($opt->square_id)) { continue;
            }
            $resp = $this->square_api->search_availability($opt->square_id, $start, $end, null);
            $test = array(
                'variation_name'  => $opt->name,
                'variation_sq_id' => $opt->square_id,
                'date_tested'     => $tomorrow->format('Y-m-d'),
            );
            if (is_wp_error($resp)) {
                $test['error'] = $resp->get_error_message();
            } else {
                $test['slots_found'] = isset($resp['availabilities']) ? count($resp['availabilities']) : 0;
                if (isset($resp['errors'])) { $test['sq_errors'] = $resp['errors'];
                }
            }
            $result['availability_tests'][] = $test;
        }

        // Fetch catalog object to see ALL its variation IDs and test each one
        $cat_resp = $this->square_api->get_catalog_object($service->square_id);
        if (!is_wp_error($cat_resp) && isset($cat_resp['object'])) {
            $obj = $cat_resp['object'];
            $variations_from_catalog = array();
            if (isset($obj['item_data']['variations'])) {
                foreach ($obj['item_data']['variations'] as $v) {
                    $vname = isset($v['item_variation_data']['name']) ? $v['item_variation_data']['name'] : '?';
                    $vprice = isset($v['item_variation_data']['price_money']['amount']) ? $v['item_variation_data']['price_money']['amount']/100 : 0;
                    $variations_from_catalog[] = array(
                        'id'    => $v['id'],
                        'name'  => $vname,
                        'price' => $vprice,
                    );
                    // Test this ID against Appointments API
                    $avail = $this->square_api->search_availability($v['id'], $start, $end, null);
                    if (!is_wp_error($avail) && isset($avail['availabilities']) && count($avail['availabilities']) > 0) {
                        $result['correct_ids_found'][] = array(
                            'variation_name' => $vname,
                            'correct_sq_id'  => $v['id'],
                            'slots'          => count($avail['availabilities']),
                        );
                    }
                }
            }
            $result['catalog_object'] = array(
                'type'       => $obj['type'],
                'variations' => $variations_from_catalog,
            );
        } elseif (is_wp_error($cat_resp)) {
            $result['catalog_error'] = $cat_resp->get_error_message();
        }

        wp_send_json_success($result);
    }

    /**
     * Admin: Auto-fix incorrect variation IDs in the database
     * Called after diagnose finds correct_ids_found
     */
    public function fix_variation_ids() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__( 'Permission denied', 'yatoon-booking-system' ));
        }
        check_ajax_referer('yatoon_admin_nonce', 'nonce');

        $service_id = isset($_POST['service_id']) ? intval($_POST['service_id']) : 0;
        $fixes_raw  = isset($_POST['fixes']) ? stripslashes($_POST['fixes']) : '';
        $fixes      = json_decode($fixes_raw, true);

        if (!$service_id || empty($fixes)) {
            wp_send_json_error(__( 'Missing parameters', 'yatoon-booking-system' ));
        }

        global $wpdb;
        $table = $wpdb->prefix . 'yatoon_service_options';

        // Get all stored options for this service
        $stored = yatoon_db_get_results($wpdb->prepare(
            "SELECT * FROM $table WHERE service_id = %d ORDER BY id ASC", $service_id
        ));

        $updated = 0;
        // Match by position order: fix[0] -> stored[0], fix[1] -> stored[1] etc.
        foreach ($fixes as $i => $fix) {
            if (!isset($stored[ $i ])) { continue;
            }
            $opt = $stored[ $i ];
            $new_sq_id = sanitize_text_field($fix['correct_sq_id']);
            $result = yatoon_db_update(
                $table,
                array('square_id' => $new_sq_id),
                array('id' => $opt->id),
                array('%s'),
                array('%d')
            );
            if ($result !== false) { ++$updated;
            }
        }

        wp_send_json_success(array('updated' => $updated));
    }

    /**
     * 4.6.2: how many slots are still open on each day of a range.
     *
     * Why this exists: the calendar could only ask for one day at a time, so
     * the week/month density bars were always empty and a customer had to click
     * every single day to find out which ones had space.
     *
     * Reuses YATOON_Business_Hours::generate_slots() - the same primitive
     * get_available_times() calls for the local platform - rather than
     * reimplementing the scheduling rules, so business hours, breaks, staff
     * eligibility, staff time-off, existing bookings and the lead-time cutoff
     * all stay in one place. yatoon_get_next_openings_handler() already scans
     * forward across days the same way.
     *
     * Response: { density: { 'YYYY-MM-DD': <int open slots>, ... } }
     */
    public function get_availability_density() {
        check_ajax_referer('yatoon_frontend_nonce', 'nonce');

        // Cheap to request, expensive to answer: a 31-day scan touches business
        // hours and bookings for every day, so it is rate limited like the
        // other public read endpoints.
        $this->enforce_public_rate_limit('availability_density', 30, 10);

        $start_date  = isset($_POST['start_date']) ? sanitize_text_field( wp_unslash( $_POST['start_date'] ) ) : '';
        $end_date    = isset($_POST['end_date'])   ? sanitize_text_field( wp_unslash( $_POST['end_date'] ) )   : '';
        $staff_id    = isset($_POST['staff_id'])    ? absint($_POST['staff_id'])    : 0;
        $location_id = isset($_POST['location_id']) ? absint($_POST['location_id']) : 0;

        if ( ! $this->is_ymd_date( $start_date ) || ! $this->is_ymd_date( $end_date ) ) {
            wp_send_json_error(array('message' => __( 'Please provide a valid date range.', 'yatoon-booking-system' )));
        }

        // service_ids accepts an array or a comma-separated string.
        $service_ids_raw = isset($_POST['service_ids']) ? wp_unslash( $_POST['service_ids'] ) : array();
        if ( ! is_array( $service_ids_raw ) ) {
            $service_ids_raw = explode( ',', (string) $service_ids_raw );
        }
        // Drop anything that is not a scalar before absint() sees it - a nested
        // array in the request must not reach intval().
        $service_ids_raw = array_filter( $service_ids_raw, 'is_scalar' );
        $service_ids = array_values( array_unique( array_filter( array_map( 'absint', $service_ids_raw ) ) ) );
        // Bounded so one request cannot fan out into an unbounded number of
        // per-service scans.
        $service_ids = array_slice( $service_ids, 0, 5 );

        if ( empty( $service_ids ) ) {
            wp_send_json_error(array('message' => __( 'Please select a service first.', 'yatoon-booking-system' )));
        }

        $timezone = yatoon_timezone_string( $location_id ?: null );
        try {
            $tz = new DateTimeZone( $timezone );
        } catch ( Exception $e ) {
            $tz = new DateTimeZone( 'UTC' );
        }

        try {
            $start = new DateTime( $start_date . ' 00:00:00', $tz );
            $end   = new DateTime( $end_date . ' 00:00:00', $tz );
        } catch ( Exception $e ) {
            wp_send_json_error(array('message' => __( 'Please provide a valid date range.', 'yatoon-booking-system' )));
        }

        if ( $end < $start ) {
            $end = clone $start;
        }

        // Hard span cap. Truncating rather than erroring keeps a mis-sized
        // month view working, and stops a crafted request from asking for a
        // ten-year scan.
        $max_days  = 31;
        $span_days = (int) $start->diff( $end )->days + 1;
        if ( $span_days > $max_days ) {
            $span_days = $max_days;
            $end       = clone $start;
            $end->modify( '+' . ( $max_days - 1 ) . ' days' );
        }

        $cache_key = 'yatoon_density_' . md5( wp_json_encode( array(
            $start->format('Y-m-d'),
            $end->format('Y-m-d'),
            $staff_id,
            $location_id,
            $service_ids,
            $timezone,
            YATOON_VERSION,
            // Bumped on every booking create / cancel / reschedule, so a fresh
            // booking does not leave a stale density bar behind.
            yatoon_availability_cache_version(),
        ) ) );

        $cached = get_transient( $cache_key );
        if ( is_array( $cached ) ) {
            wp_send_json_success( array( 'density' => $cached, 'cached' => true ) );
        }

        $density = array();
        $cursor  = clone $start;
        for ( $i = 0; $i < $span_days; $i++ ) {
            $density[ $cursor->format('Y-m-d') ] = 0;
            $cursor->modify('+1 day');
        }

        // Square and Vagaro own their own availability; deriving it from local
        // business hours would show numbers the booking step then contradicts.
        // yatoon_get_next_openings_handler() makes the same call.
        $platform = get_option('yatoon_active_platform', 'local');
        if ( 'local' !== $platform || ! class_exists('YATOON_Business_Hours') ) {
            set_transient( $cache_key, $density, 3 * MINUTE_IN_SECONDS );
            wp_send_json_success( array( 'density' => $density, 'supported' => false ) );
        }

        // Same global closed-date list get_available_times() honours.
        $closed_list = array_filter( array_map( 'trim', explode( "\n", (string) get_option('yatoon_closed_dates', '') ) ) );

        foreach ( array_keys( $density ) as $day ) {
            if ( in_array( $day, $closed_list, true ) ) {
                continue; // stays 0
            }

            // For a multi-service cart every requested service has to fit, so
            // the day's open times are the intersection across services. With
            // the usual single service this is simply the slot count.
            $day_times = null;
            foreach ( $service_ids as $service_id ) {
                if ( $staff_id > 0 && class_exists( 'YATOON_Staff_Locations' )
                    && ! YATOON_Staff_Locations::is_assigned( $staff_id, $service_id, $location_id ) ) {
                    $day_times = array();
                    break;
                }

                $slots = YATOON_Business_Hours::generate_slots( $day, $service_id, $staff_id );
                $times = array();
                foreach ( (array) $slots as $slot ) {
                    if ( isset( $slot['time'] ) ) {
                        $times[] = $slot['time'];
                    }
                }

                $day_times = ( null === $day_times ) ? $times : array_intersect( $day_times, $times );
                if ( empty( $day_times ) ) {
                    break;
                }
            }

            $density[ $day ] = is_array( $day_times ) ? count( $day_times ) : 0;
        }

        set_transient( $cache_key, $density, 3 * MINUTE_IN_SECONDS );
        wp_send_json_success( array( 'density' => $density ) );
    }

    /**
     * 4.6.2: strict Y-m-d validation for public date input.
     *
     * strtotime() accepts things like "now" and "+1 year", which is not what a
     * calendar should be allowed to send.
     *
     * @param string $value Candidate date.
     * @return bool
     */
    private function is_ymd_date( $value ) {
        $value = (string) $value;
        if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) {
            return false;
        }
        $parts = explode( '-', $value );
        return checkdate( (int) $parts[1], (int) $parts[2], (int) $parts[0] );
    }
}

/**
 * 4.6.2: version counter used to invalidate cached availability answers.
 *
 * The density transients key on this, so a booking write invalidates every
 * cached range at once instead of waiting out the TTL. The bump is hooked to
 * the booking lifecycle actions below, which covers the write paths in other
 * files too; the few-minute TTL stays as a backstop for any write path that
 * does not fire one of them (a direct SQL edit, an admin status change made
 * without the action).
 *
 * @return int
 */
function yatoon_availability_cache_version() {
    return (int) get_option( 'yatoon_availability_version', 1 );
}

/**
 * 4.6.2: invalidate every cached availability answer.
 *
 * @return void
 */
function yatoon_bump_availability_cache_version() {
    update_option( 'yatoon_availability_version', yatoon_availability_cache_version() + 1, false );
}

// 4.6.2: hook the bump onto the existing booking lifecycle actions rather than
// onto create_booking() alone - those actions already fire from the customer
// portal, the REST API, recurring bookings and the Square sync cron, so the
// density cache invalidates no matter which path wrote the booking.
add_action( 'yatoon_booking_created',     'yatoon_bump_availability_cache_version', 5 );
add_action( 'yatoon_booking_cancelled',   'yatoon_bump_availability_cache_version', 5 );
add_action( 'yatoon_booking_rescheduled', 'yatoon_bump_availability_cache_version', 5 );

// -------------------------------------------------------------
// Additional AJAX handlers (hooked via add_action in admin class)
// -------------------------------------------------------------

/**
 * AJAX: Google Calendar OAuth callback
 */
add_action('wp_ajax_yatoon_google_cal_callback', function () {
    if (!current_user_can('manage_options')) { wp_die(__( 'Permission denied', 'yatoon-booking-system' ));
    }
    if ( ! class_exists( 'YATOON_License' ) || ! YATOON_License::is_pro() ) {
        wp_die(__( 'Google Calendar sync requires an active Pro license.', 'yatoon-booking-system' ));
    }
    $state = sanitize_text_field($_GET['state'] ?? '');
    if ( empty($state) || !YATOON_Google_Calendar::verify_oauth_state($state) ) {
        wp_die(__( 'Invalid OAuth state.', 'yatoon-booking-system' ));
    }

    $code = sanitize_text_field($_GET['code'] ?? '');
    if (empty($code)) {
        wp_die(__( 'No authorization code received from Google.', 'yatoon-booking-system' ));
    }

    $result = YATOON_Google_Calendar::exchange_code($code);
    if ($result) {
        wp_safe_redirect(admin_url('admin.php?page=yatoon-booking-system&tab=integrations&google_connected=1'));
    } else {
        wp_safe_redirect(admin_url('admin.php?page=yatoon-booking-system&tab=integrations&google_error=1'));
    }
    exit;
});

/**
 * AJAX: Create / update a staff member (manual CRUD)
 */
/**
 * Keep the per-employee sign-in allow lists in step with the Staff editor.
 *
 * Google and email/SMS code sign-in were previously only reachable from the
 * separate "Identity & Sign-in" screen, so nobody editing an employee could
 * tell whether that employee could actually sign in. The Staff editor now
 * owns the same two options; this keeps both screens writing one source of
 * truth instead of drifting apart.
 *
 * @param int  $staff_id Employee row ID.
 * @param bool $google   Allow Google sign-in for this employee.
 * @param bool $otp      Allow email/SMS code sign-in for this employee.
 */
function yatoon_sync_staff_login_authorization( $staff_id, $google, $otp ) {
    $staff_id = absint( $staff_id );
    if ( ! $staff_id ) {
        return;
    }
    foreach ( array(
        'yatoon_staff_google_login_ids' => (bool) $google,
        'yatoon_staff_otp_login_ids'    => (bool) $otp,
    ) as $option => $allowed ) {
        $ids = array_values( array_unique( array_filter( array_map( 'absint', (array) get_option( $option, array() ) ) ) ) );
        $has = in_array( $staff_id, $ids, true );
        if ( $allowed === $has ) {
            continue;
        }
        if ( $allowed ) {
            $ids[] = $staff_id;
        } else {
            $ids = array_values( array_diff( $ids, array( $staff_id ) ) );
        }
        update_option( $option, $ids, false );
    }
}

add_action('wp_ajax_yatoon_save_staff', function () {
    if (!current_user_can('manage_options')) { wp_send_json_error(__( 'Permission denied', 'yatoon-booking-system' ));
    }
    check_ajax_referer('yatoon_admin_nonce', 'nonce');

    global $wpdb;
    $table = $wpdb->prefix . 'yatoon_staff';
    static $staff_columns = null;

    $id        = intval($_POST['staff_id'] ?? 0);
    $name      = sanitize_text_field($_POST['name'] ?? '');
    $email     = sanitize_email($_POST['email'] ?? '');
    $bio       = sanitize_textarea_field($_POST['bio'] ?? '');
    $sort_order = isset($_POST['sort_order']) && '' !== trim((string) wp_unslash($_POST['sort_order']))
        ? min(9999, max(0, intval($_POST['sort_order'])))
        : null;
    $image_url = isset($_POST['image_url']) ? esc_url_raw(wp_unslash($_POST['image_url'])) : '';
    $calendar_color = isset($_POST['calendar_color']) ? sanitize_hex_color(wp_unslash($_POST['calendar_color'])) : '';
    if (!$calendar_color) {
        $calendar_color = '';
    }
    $is_active = intval($_POST['is_active'] ?? 1);
    $is_bookable = intval($_POST['is_bookable'] ?? 1);
    $time_off_rules = isset($_POST['time_off_rules']) ? wp_unslash($_POST['time_off_rules']) : '';
    $portal_role = isset($_POST['portal_role']) ? sanitize_key(wp_unslash($_POST['portal_role'])) : 'staff';
    if (!in_array($portal_role, array('staff', 'manager', 'owner'), true)) {
        $portal_role = 'staff';
    }
    $portal_permissions_raw = isset($_POST['portal_permissions']) ? sanitize_text_field(wp_unslash($_POST['portal_permissions'])) : 'view_schedule';
    $portal_permissions = function_exists('yatoon_normalize_staff_portal_permissions')
        ? yatoon_normalize_staff_portal_permissions($portal_permissions_raw)
        : 'view_schedule';

    // Portal sign-in methods, edited inline on the Staff screen. Absent keys
    // mean "this form did not manage the field" so older callers stay safe.
    $manages_login = isset($_POST['manages_login_methods']) && '1' === (string) $_POST['manages_login_methods'];
    $allow_google  = ! empty($_POST['allow_google_login']);
    $allow_otp     = ! empty($_POST['allow_otp_login']);

    if (empty($name)) {
        wp_send_json_error(array('message' => __( 'Staff name is required.', 'yatoon-booking-system' )));
    }

    if ($staff_columns === null) {
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $columns = yatoon_db_get_col("DESC $table", 0);
        $staff_columns = is_array($columns) ? $columns : array();
    }

    $data = array(
        'name'        => $name,
        'email'       => $email,
        'bio'         => $bio,
        'is_active'   => $is_active,
        'is_bookable' => $is_bookable,
    );

    $formats = array('%s','%s','%s','%d','%d');
    if (in_array('image_url', $staff_columns, true)) {
        $data['image_url'] = $image_url;
        $formats[] = '%s';
    }

    if (in_array('calendar_color', $staff_columns, true)) {
        $data['calendar_color'] = $calendar_color;
        $formats[] = '%s';
    }

    if (in_array('sort_order', $staff_columns, true)) {
        if (null === $sort_order && $id <= 0) {
            $sort_order = 1 + intval(yatoon_db_get_var("SELECT MAX(sort_order) FROM $table"));
        }
        if (null !== $sort_order) {
            $data['sort_order'] = $sort_order;
            $formats[] = '%d';
        }
    }

    if (in_array('portal_permissions', $staff_columns, true)) {
        $data['portal_permissions'] = $portal_permissions;
        $formats[] = '%s';
    }
    if (in_array('portal_role', $staff_columns, true)) {
        $data['portal_role'] = $portal_role;
        $formats[] = '%s';
    }

    if ($id > 0) {
        $result = yatoon_db_update($table, $data, array('id' => $id), $formats, array('%d'));
        if (class_exists('YATOON_Business_Hours')) {
            update_option(
                YATOON_Business_Hours::get_staff_time_off_option_key($id),
                YATOON_Business_Hours::normalize_staff_time_off_rules($time_off_rules)
            );
        }
        if ($manages_login) {
            yatoon_sync_staff_login_authorization($id, $allow_google, $allow_otp);
        }
        wp_send_json_success(array('message' => __( 'Staff updated.', 'yatoon-booking-system' ), 'id' => $id));
    } else {
        // New staff member
        $current_staff_count = (int) yatoon_db_get_var( "SELECT COUNT(*) FROM $table" );
        if ( class_exists( 'YATOON_License' ) && ! YATOON_License::check_limit( 'staff', $current_staff_count + 1 ) ) {
            wp_send_json_error( array(
                'message' => __( 'Unlimited staff requires an active Pro license.', 'yatoon-booking-system' ),
            ) );
        }

        $manual_external_id = 'manual_' . wp_generate_password(12, false, false);

        if (in_array('square_id', $staff_columns, true)) {
            $data['square_id'] = $manual_external_id;
        }

        if (in_array('square_staff_id', $staff_columns, true)) {
            $data['square_staff_id'] = $manual_external_id;
        }

        $result = yatoon_db_insert($table, $data);
        if ($result === false) {
            wp_send_json_error(array('message' => sprintf( __( 'Database error: %s', 'yatoon-booking-system' ), $wpdb->last_error )));
        }
        if (class_exists('YATOON_Business_Hours')) {
            update_option(
                YATOON_Business_Hours::get_staff_time_off_option_key($wpdb->insert_id),
                YATOON_Business_Hours::normalize_staff_time_off_rules($time_off_rules)
            );
        }
        if ($manages_login) {
            yatoon_sync_staff_login_authorization($wpdb->insert_id, $allow_google, $allow_otp);
        }
        wp_send_json_success(array('message' => __( 'Staff member added.', 'yatoon-booking-system' ), 'id' => $wpdb->insert_id));
    }
});

/** Save the Pro Any Staff strategy from the Staff page. */
add_action('wp_ajax_yatoon_save_staff_assignment_strategy', function () {
    if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => __('Permission denied.', 'yatoon-booking-system')));
    }
    check_ajax_referer('yatoon_admin_nonce', 'nonce');
    if (!function_exists('yatoon_is_pro') || !yatoon_is_pro() || !is_dir(YATOON_PLUGIN_DIR . 'includes/pro')) {
        wp_send_json_error(array('message' => __('This assignment strategy requires Pro.', 'yatoon-booking-system')));
    }
    $strategy = sanitize_key(wp_unslash($_POST['assignment_strategy'] ?? 'service_order'));
    $allowed = array('service_order', 'round_robin', 'least_loaded', 'preferred_staff', 'random');
    if (!in_array($strategy, $allowed, true)) {
        wp_send_json_error(array('message' => __('Invalid assignment strategy.', 'yatoon-booking-system')));
    }
    update_option('yatoon_assignment_strategy', $strategy, false);
    wp_send_json_success(array('message' => __('Assignment strategy saved.', 'yatoon-booking-system')));
});

/**
 * AJAX: Delete a staff member
 */
add_action('wp_ajax_yatoon_delete_staff', function () {
    if (!current_user_can('manage_options')) { wp_send_json_error(__( 'Permission denied', 'yatoon-booking-system' ));
    }
    check_ajax_referer('yatoon_admin_nonce', 'nonce');

    global $wpdb;
    $staff_ids = array();
    if (isset($_POST['staff_ids']) && is_array($_POST['staff_ids'])) {
        $staff_ids = array_map('intval', wp_unslash($_POST['staff_ids']));
    } elseif (isset($_POST['staff_id'])) {
        $staff_ids = array(intval($_POST['staff_id']));
    }

    $staff_ids = array_values(array_unique(array_filter($staff_ids)));
    if (empty($staff_ids)) { wp_send_json_error(array('message' => __( 'Invalid staff ID', 'yatoon-booking-system' )));
    }

    $deleted = 0;
    foreach ($staff_ids as $id) {
        yatoon_db_delete($wpdb->prefix . 'yatoon_staff_services', array('staff_id' => $id), array('%d'));
        yatoon_db_delete($wpdb->prefix . 'yatoon_staff_time_off_requests', array('staff_id' => $id), array('%d'));
        $result = yatoon_db_delete($wpdb->prefix . 'yatoon_staff', array('id' => $id), array('%d'));
        if (class_exists('YATOON_Business_Hours')) {
            delete_option(YATOON_Business_Hours::get_staff_time_off_option_key($id));
        }
        if ($result) {
            $deleted++;
        }
    }

    if ($deleted > 0) {
        wp_send_json_success(array(
            'message' => sprintf(
                /* translators: %d: number of deleted staff members */
                _n('%d staff member deleted.', '%d staff members deleted.', $deleted, 'yatoon-booking-system'),
                $deleted
            ),
            'deleted' => $deleted,
        ));
    }

    wp_send_json_error(array('message' => __( 'No staff members were deleted.', 'yatoon-booking-system' )));
});

/**
 * AJAX: Get single staff member for editing
 */
add_action('wp_ajax_yatoon_get_staff_member', function () {
    if (!current_user_can('manage_options')) { wp_send_json_error(__( 'Permission denied', 'yatoon-booking-system' ));
    }
    check_ajax_referer('yatoon_admin_nonce', 'nonce');

    global $wpdb;
    $id = intval($_GET['staff_id'] ?? 0);
    if (!$id) { wp_send_json_error(__( 'Invalid ID', 'yatoon-booking-system' ));
    }

    $member = yatoon_db_get_row($wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}yatoon_staff WHERE id = %d",
        $id
    ));

    if (!$member) { wp_send_json_error(__( 'Not found', 'yatoon-booking-system' ));
    }
    wp_send_json_success(array('staff' => $member));
});

/**
 * AJAX: Register new settings for integrations
 */
add_action('admin_init', function () {
    // Twilio
    register_setting('yatoon_settings', 'yatoon_twilio_enabled', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_twilio_account_sid', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_twilio_auth_token', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_twilio_from_number', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_twilio_admin_number', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_twilio_customer_sms', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_twilio_admin_sms', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_twilio_reminder_sms', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_twilio_customer_template', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_twilio_admin_template', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_twilio_reminder_template', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    // Google Calendar
    register_setting('yatoon_settings', 'yatoon_google_cal_enabled', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_google_cal_client_id', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_google_cal_client_secret', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_google_cal_refresh_token', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_google_cal_calendar_id', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_google_cal_send_invites', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    // Email template
    register_setting('yatoon_settings', 'yatoon_email_template_style', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_email_notify_admin', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    // Extra brand colors
    register_setting('yatoon_brand_settings', 'yatoon_accent_color', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_brand_settings', 'sbs_button_radius', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_brand_settings', 'sbs_card_radius', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_brand_settings', 'sbs_font_family', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_brand_settings', 'sbs_readmore_color', array('sanitize_callback' => 'sanitize_hex_color'));
    register_setting('yatoon_brand_settings', 'sbs_heading_font', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_brand_settings', 'sbs_heading_color', array('sanitize_callback' => 'sanitize_hex_color'));
    register_setting('yatoon_brand_settings', 'sbs_heading_size', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_brand_settings', 'sbs_main_title_size', array('sanitize_callback' => 'yatoon_sanitize_setting'));
}, 20); // priority 20 so it runs after the main admin class

// Phone & Verification settings registration
add_action('admin_init', function () {
    register_setting('yatoon_settings', 'yatoon_phone_required',       array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_phone_mode',           array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_phone_preset_country', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_verify_email_otp',     array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_verify_sms_otp',       array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_otp_expiry_minutes',   array('sanitize_callback' => 'yatoon_sanitize_setting'));
}, 25);

function yatoon_staff_portal_permission_keys() {
    return array(
        'view_schedule',
        'view_all_schedules',
        'update_status',
        'create_booking',
        'edit_booking',
        'cancel_booking',
        'view_clients',
        'manage_clients',
        'send_messages',
        'checkout',
        'request_time_off',
        'approve_time_off',
    );
}

function yatoon_staff_portal_role_permissions( $role ) {
    $staff = array(
        'view_schedule',
        'update_status',
        'view_clients',
        'send_messages',
        'request_time_off',
    );
    $manager = array_merge($staff, array(
        'view_all_schedules',
        'create_booking',
        'edit_booking',
        'cancel_booking',
        'manage_clients',
        'checkout',
        'approve_time_off',
    ));

    if ($role === 'owner') {
        return yatoon_staff_portal_permission_keys();
    }
    return $role === 'manager' ? array_values(array_unique($manager)) : $staff;
}

function yatoon_normalize_staff_portal_permissions( $raw ) {
    $allowed = yatoon_staff_portal_permission_keys();
    $parts   = is_array($raw) ? $raw : explode(',', (string) $raw);
    $clean   = array();

    foreach ($parts as $part) {
        $key = sanitize_key(trim((string) $part));
        if (in_array($key, $allowed, true)) {
            $clean[] = $key;
        }
    }

    $clean[] = 'view_schedule';
    $clean   = array_values(array_unique($clean));

    return implode(',', $clean);
}

function yatoon_staff_portal_permissions_migration() {
    global $wpdb;
    $table = $wpdb->prefix . 'yatoon_staff';

    $exists = yatoon_db_get_var("SHOW TABLES LIKE '{$table}'"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
    if (!$exists) {
        return;
    }

    $has_permissions = yatoon_db_get_var("SHOW COLUMNS FROM {$table} LIKE 'portal_permissions'"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
    if (!$has_permissions) {
        yatoon_db_query("ALTER TABLE {$table} ADD COLUMN portal_permissions varchar(255) DEFAULT 'view_schedule,update_status,view_clients,send_messages,request_time_off' AFTER sort_order"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
    }
    $has_role = yatoon_db_get_var("SHOW COLUMNS FROM {$table} LIKE 'portal_role'"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
    if (!$has_role) {
        yatoon_db_query("ALTER TABLE {$table} ADD COLUMN portal_role varchar(20) DEFAULT 'staff' AFTER sort_order"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
    }
    $has_calendar_color = yatoon_db_get_var("SHOW COLUMNS FROM {$table} LIKE 'calendar_color'"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
    if (!$has_calendar_color) {
        yatoon_db_query("ALTER TABLE {$table} ADD COLUMN calendar_color varchar(20) DEFAULT '' AFTER image_url"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
    }
}

/**
 * AJAX: Send OTP (email or SMS) for booking verification
 */
add_action('wp_ajax_yatoon_send_otp',        'yatoon_send_otp_handler');
add_action('wp_ajax_nopriv_yatoon_send_otp', 'yatoon_send_otp_handler');
if ( ! function_exists( 'yatoon_public_rate_limit' ) ) {
    function yatoon_public_rate_limit( $scope, $limit = 5, $minutes = 10, $contact = '' ) {
        YATOON_Request_Guard::enforce( $scope, $limit, $minutes, $contact );
    }
}

function yatoon_send_otp_handler() {
    check_ajax_referer('yatoon_frontend_nonce', 'nonce');

    $channel = isset($_POST['channel']) ? sanitize_text_field(wp_unslash($_POST['channel'])) : 'email';
    $email   = isset($_POST['email'])   ? sanitize_email(wp_unslash($_POST['email']))           : '';
    $phone   = isset($_POST['phone'])   ? sanitize_text_field(wp_unslash($_POST['phone']))       : '';
    $name    = isset($_POST['name'])    ? sanitize_text_field(wp_unslash($_POST['name']))        : '';
    yatoon_public_rate_limit( 'otp_send', 3, 15, $email ?: $phone );

    if ($channel === 'email' && empty($email)) {
        wp_send_json_error(array('message' => __('Email address is required to send OTP.', 'yatoon-booking-system')));
    }
    if ($channel === 'sms' && empty($phone)) {
        wp_send_json_error(array('message' => __('Phone number is required to send OTP.', 'yatoon-booking-system')));
    }

    // SECURITY (4.4.3): SMS costs the site owner real money on every send, and
    // the per-contact limit above does nothing against an attacker who simply
    // supplies a different phone number each time -- the classic "SMS pumping"
    // toll-fraud pattern, where the attacker earns a share of the termination
    // fees the salon pays. Cap total outbound verification SMS per hour so the
    // worst case is a bounded bill instead of an open tap.
    if ($channel === 'sms') {
        $sms_cap = (int) apply_filters('yatoon_otp_sms_hourly_cap', 60);
        if ($sms_cap > 0) {
            $cap_key = 'yatoon_otp_sms_hourly';
            $sent_this_hour = YATOON_Request_Guard::consume_counter($cap_key, HOUR_IN_SECONDS);
            if ($sent_this_hour > $sms_cap) {
            yatoon_safe_log(sprintf( __( 'Yatoon: hourly verification-SMS cap (%d) reached; further SMS suppressed.', 'yatoon-booking-system' ), $sms_cap));
                wp_send_json_error(array('message' => __('SMS verification is temporarily unavailable. Please choose email verification instead.', 'yatoon-booking-system')));
            }
        }
    }

    // Generate 6-digit OTP
    $otp     = str_pad((string) wp_rand(0, 999999), 6, '0', STR_PAD_LEFT);
    $expiry  = intval(get_option('yatoon_otp_expiry_minutes', 10));
    $expires = time() + ( $expiry * 60 );
    $key     = md5($channel . $email . $phone);

    // Store transiently (per-channel, per-contact)
    set_transient('yatoon_otp_' . $key, array(
        'otp_hash' => hash( 'sha256', $otp ),
        'expires'  => $expires,
        'attempts' => 0,
    ), $expiry * 60 + 30);

    $biz = get_option('yatoon_business_name', get_bloginfo('name'));

    if ($channel === 'email') {
        $subject = sprintf(
            /* translators: %s: Business name. */
            __('Your booking code for %s', 'yatoon-booking-system'),
            $biz
        );
        $body    = sprintf(
            /* translators: 1: Customer name, 2: Verification code, 3: Expiry in minutes, 4: Business name. */
            __('Hi %1$s,

Your verification code is: %2$s

This code expires in %3$d minutes.

%4$s', 'yatoon-booking-system'),
            $name ?: __('there', 'yatoon-booking-system'), $otp, $expiry, $biz
        );
        $sent = wp_mail($email, $subject, $body);
        if (!$sent) {
            wp_send_json_error(array('message' => __('Failed to send verification email. Please try again.', 'yatoon-booking-system')));
        }
        wp_send_json_success(array('message' => sprintf(
            /* translators: %s: Customer email address. */
            __('A 6-digit code was sent to %s', 'yatoon-booking-system'),
            $email
        ),));
    } else {
        // SMS via Twilio
        if (!get_option('yatoon_twilio_enabled') || !get_option('yatoon_twilio_account_sid')) {
            wp_send_json_error(array('message' => __('SMS verification requires Twilio to be configured in Settings -> SMS.', 'yatoon-booking-system')));
        }
        $sid    = get_option('yatoon_twilio_account_sid');
        $token  = YATOON_Crypto::get_option('yatoon_twilio_auth_token');
        $from   = get_option('yatoon_twilio_from_number');
        $msg    = sprintf(
            /* translators: 1: Business name, 2: Verification code, 3: Expiry in minutes. */
            __('Your %1$s booking code: %2$s (expires in %3$d min)', 'yatoon-booking-system'),
            $biz,
            $otp,
            $expiry
        );
        $url    = "https://api.twilio.com/2010-04-01/Accounts/{$sid}/Messages.json";
        $resp   = wp_remote_post($url, array(
            'headers' => array('Authorization' => 'Basic ' . base64_encode("{$sid}:{$token}")),
            'body'    => array('From' => $from, 'To' => $phone, 'Body' => $msg),
        ));
        if (is_wp_error($resp) || wp_remote_retrieve_response_code($resp) >= 400) {
            wp_send_json_error(array('message' => __('Failed to send SMS. Please check Twilio settings.', 'yatoon-booking-system')));
        }
        wp_send_json_success(array('message' => sprintf(
            /* translators: %s: Customer phone number. */
            __('A 6-digit code was sent via SMS to %s', 'yatoon-booking-system'),
            $phone
        ),));
    }
}

/**
 * AJAX: Verify OTP entered by customer
 */
add_action('wp_ajax_yatoon_verify_otp',        'yatoon_verify_otp_handler');
add_action('wp_ajax_nopriv_yatoon_verify_otp', 'yatoon_verify_otp_handler');
function yatoon_verify_otp_handler() {
    check_ajax_referer('yatoon_frontend_nonce', 'nonce');

    $channel  = isset($_POST['channel']) ? sanitize_text_field(wp_unslash($_POST['channel'])) : 'email';
    $email    = isset($_POST['email'])   ? sanitize_email(wp_unslash($_POST['email']))         : '';
    $phone    = isset($_POST['phone'])   ? sanitize_text_field(wp_unslash($_POST['phone']))     : '';
    $code     = isset($_POST['code'])    ? sanitize_text_field(wp_unslash($_POST['code']))      : '';
    yatoon_public_rate_limit( 'otp_verify', 8, 15, $email ?: $phone );

    $key      = md5($channel . $email . $phone);
    $stored   = get_transient('yatoon_otp_' . $key);

    if (!$stored || !is_array($stored)) {
        wp_send_json_error(array('message' => __('Verification code expired or not found. Please request a new code.', 'yatoon-booking-system')));
    }
    if (time() > $stored['expires']) {
        delete_transient('yatoon_otp_' . $key);
        wp_send_json_error(array('message' => __('Verification code has expired. Please request a new one.', 'yatoon-booking-system')));
    }
    $expected_hash = isset( $stored['otp_hash'] ) ? (string) $stored['otp_hash'] : hash( 'sha256', (string) ( $stored['otp'] ?? '' ) );
    if ( ! hash_equals( $expected_hash, hash( 'sha256', $code ) ) ) {
        $stored['attempts'] = absint( $stored['attempts'] ?? 0 ) + 1;
        if ( $stored['attempts'] >= 5 ) {
            delete_transient('yatoon_otp_' . $key);
            wp_send_json_error(array('message' => __('Too many incorrect attempts. Please request a new code.', 'yatoon-booking-system')));
        }
        set_transient('yatoon_otp_' . $key, $stored, max( 1, (int) $stored['expires'] - time() ) );
        wp_send_json_error(array('message' => __('Incorrect code. Please try again.', 'yatoon-booking-system')));
    }
    delete_transient('yatoon_otp_' . $key);

    // OTP is correct.
    //
    // SECURITY (4.4.3): the verified marker used to be
    // md5( channel + email + phone + 'verified' ) -- a value anyone who knew
    // the customer's email or phone number could compute, stored in a
    // site-wide transient for an hour. Whoever went first unlocked the
    // "verified" state for everybody: a second party submitting the same
    // contact details sailed straight past verification without ever
    // receiving a code.
    //
    // The receipt is now an unguessable random token. The contact details it
    // was issued for are stored ALONGSIDE it (not derived from it), so the
    // booking endpoint can still confirm the verified identity matches the
    // one being booked, but only the browser that actually received the code
    // holds the token.
    $receipt = wp_generate_password( 32, false, false );
    set_transient(
        'yatoon_otp_receipt_' . hash( 'sha256', $receipt ),
        array(
            'channel' => $channel,
            'email'   => $email,
            'phone'   => $phone,
        ),
        HOUR_IN_SECONDS
    );
    delete_transient('yatoon_otp_' . $key);

    wp_send_json_success(array('message' => __('Verified!', 'yatoon-booking-system'), 'verified_key' => $receipt));
}

/**
 * Confirm an OTP receipt token was issued to this exact contact.
 *
 * @since 4.4.3
 * @param string $receipt Token returned by yatoon_verify_otp_handler().
 * @param string $channel 'email' or 'sms'.
 * @param string $email
 * @param string $phone
 * @return bool
 */
function yatoon_otp_receipt_is_valid( $receipt, $channel, $email, $phone ) {
    $receipt = (string) $receipt;
    if ( '' === $receipt ) {
        return false;
    }

    $stored = get_transient( 'yatoon_otp_receipt_' . hash( 'sha256', $receipt ) );
    if ( ! is_array( $stored ) ) {
        return false;
    }

    if ( ! hash_equals( (string) ( $stored['channel'] ?? '' ), (string) $channel ) ) {
        return false;
    }

    // Whichever channel was verified must match the contact being booked.
    if ( 'sms' === $channel ) {
        $a = YATOON_Request_Guard::normalize_phone_digits( $stored['phone'] ?? '' );
        $b = YATOON_Request_Guard::normalize_phone_digits( $phone );
        return ( '' !== $a && hash_equals( $a, $b ) );
    }

    $a = strtolower( trim( (string) ( $stored['email'] ?? '' ) ) );
    $b = strtolower( trim( (string) $email ) );
    return ( '' !== $a && hash_equals( $a, $b ) );
}

/**
 * AJAX: Bulk-fetch "next available opening" for multiple services
 * Lightweight check: searches forward up to 14 days, returns first day with at least one open slot.
 * Cached per-service for 5 minutes via transient.
 *
 * Request:  POST service_ids[] = array of service IDs
 * Response: { service_id: { label: "Today 3:00 pm", urgent: bool } | null }
 */
add_action('wp_ajax_yatoon_get_next_openings',        'yatoon_get_next_openings_handler');
add_action('wp_ajax_nopriv_yatoon_get_next_openings', 'yatoon_get_next_openings_handler');
function yatoon_get_next_openings_handler() {
    check_ajax_referer('yatoon_frontend_nonce', 'nonce');

    if (!class_exists('YATOON_Business_Hours')) {
        wp_send_json_success(array('openings' => array()));
    }

    $service_ids = isset($_POST['service_ids']) ? (array) $_POST['service_ids'] : array();
    $service_ids = array_slice(array_values(array_unique(array_filter(array_map('intval', $service_ids)))), 0, 24);
    if (empty($service_ids)) {
        wp_send_json_success(array('openings' => array()));
    }

    // Don't run on Square/Vagaro - those need real API calls and are too slow for cards.
    // For non-local platforms we return empty (cards just won't show the opening hint).
    $platform = get_option('yatoon_active_platform', 'local');
    if ($platform !== 'local') {
        wp_send_json_success(array('openings' => array()));
    }

    $tz_str   = yatoon_timezone_string();
    try { $tz = new DateTimeZone($tz_str);
} catch (Exception $e) { $tz = new DateTimeZone('UTC'); }
    $now      = new DateTime('now', $tz);
    $today_ts = (int) $now->format('U');

    $openings = array();
    $batch_key_ids = $service_ids;
    sort($batch_key_ids);
    $batch_cache_key = 'yatoon_next_open_batch_' . md5(implode(',', $batch_key_ids) . '|' . $tz_str . '|' . YATOON_VERSION);
    $batch_cached = get_transient($batch_cache_key);
    if (is_array($batch_cached)) {
        wp_send_json_success(array('openings' => $batch_cached, 'cached' => true));
    }

    foreach ($service_ids as $svc_id) {
        $cache_key = 'yatoon_next_open_' . md5($svc_id . '|' . $tz_str . '|' . YATOON_VERSION);
        $cached    = get_transient($cache_key);
        if ($cached !== false) {
            $openings[ $svc_id ] = $cached;
            continue;
        }

        $found_label = null;
        $found_urgent = false;

        // Search forward up to 14 days
        for ($d = 0; $d < 14; $d++) {
            $check = clone $now;
            $check->modify('+' . $d . ' days');
            $date_str = $check->format('Y-m-d');

            $slots = YATOON_Business_Hours::generate_slots($date_str, $svc_id, 0);
            if (!is_array($slots) || empty($slots)) { continue;
            }

            // Find first slot that's still in the future (today's slots may have already passed)
            foreach ($slots as $slot) {
                // generate_slots returns array of times like '09:00' or full datetime objects depending on impl
                $slot_time = is_array($slot) ? ( $slot['time'] ?? $slot['start'] ?? '' ) : $slot;
                if (!$slot_time) { continue;
                }

                // Build a DateTime for slot
                if (strpos($slot_time, 'T') !== false || strpos($slot_time, ' ') !== false) {
                    try { $slot_dt = new DateTime($slot_time, $tz);
} catch (Exception $e) { continue; }
                } else {
                    try { $slot_dt = new DateTime($date_str . ' ' . $slot_time, $tz);
} catch (Exception $e) { continue; }
                }

                if ($slot_dt->getTimestamp() <= $today_ts + 1800) { continue; // need at least 30 min lead time
                }

                $time_label = $slot_dt->format('g:i a');
                if ($d === 0) {
                    $found_label  = sprintf(
                        /* translators: %s: Available appointment time. */
                        __('Today %s', 'yatoon-booking-system'),
                        $time_label
                    );
                    $found_urgent = true;
                } elseif ($d === 1) {
                    $found_label = sprintf(
                        /* translators: %s: Available appointment time. */
                        __('Tomorrow %s', 'yatoon-booking-system'),
                        $time_label
                    );
                } else {
                    $found_label = $slot_dt->format('D, M j ' . 'g:i a');
                }
                break 2;
            }
        }

        $result = $found_label ? array('label' => $found_label, 'urgent' => $found_urgent) : null;
        set_transient($cache_key, $result, 5 * MINUTE_IN_SECONDS);
        $openings[ $svc_id ] = $result;
    }

    set_transient($batch_cache_key, $openings, 5 * MINUTE_IN_SECONDS);
    wp_send_json_success(array('openings' => $openings));
}

// Register the new option
add_action('admin_init', function () {
    register_setting('yatoon_settings', 'yatoon_show_ratings_on_cards', array('sanitize_callback' => 'yatoon_sanitize_setting'));
    register_setting('yatoon_settings', 'yatoon_show_next_opening',     array('sanitize_callback' => 'yatoon_sanitize_setting'));
}, 26);
// phpcs:enable

// -- Staff Portal PIN management ------------------------------------------------
/**
 * Save the salon-wide staff PIN length from the Staff editor.
 *
 * This is a global setting living inside a per-employee editor, so it is
 * saved on its own the moment it changes rather than riding along with the
 * employee record - cancelling the employee editor must not quietly leave a
 * changed PIN length behind, and saving an employee must not quietly change
 * one either.
 */
add_action('wp_ajax_yatoon_set_staff_pin_length', function () {
    check_ajax_referer('yatoon_set_staff_pin', 'nonce');
    if (!current_user_can('manage_options')) {
        wp_send_json_error(array('message' => __('Permission denied.', 'yatoon-booking-system')));
    }
    $length = absint($_POST['pin_length'] ?? 0);
    if (!in_array($length, array(4, 6), true)) {
        wp_send_json_error(array('message' => __('PIN length must be 4 or 6 digits.', 'yatoon-booking-system')));
    }
    update_option('yatoon_staff_pin_length', $length, false);
    wp_send_json_success(array('pin_length' => $length));
});

add_action('wp_ajax_yatoon_set_staff_pin', function () {
    check_ajax_referer('yatoon_set_staff_pin', 'nonce');
    if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => __( 'Permission denied.', 'yatoon-booking-system' )));
    }

    global $wpdb;
    $staff_id = absint($_POST['staff_id'] ?? 0);
    $pin      = preg_replace('/\D/', '', sanitize_text_field( wp_unslash( $_POST['pin'] ?? '' ) ) );

    if (!$staff_id) { wp_send_json_error(array('message' => __( 'Invalid staff ID.', 'yatoon-booking-system' )));
    }

    // Verify staff exists
    $exists = yatoon_db_get_var($wpdb->prepare(
        "SELECT id FROM {$wpdb->prefix}yatoon_staff WHERE id = %d LIMIT 1", $staff_id
    )); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
    if (!$exists) { wp_send_json_error(array('message' => __( 'Staff not found.', 'yatoon-booking-system' )));
    }

    // 4.4.3: keep the O(1) login lookup column in step with the PIN.
    if (class_exists('YATOON_Staff_Portal')) {
        YATOON_Staff_Portal::ensure_pin_lookup_column();
    }

    if ($pin === '') {
        // Remove PIN
        yatoon_db_update($wpdb->prefix . 'yatoon_staff', array('portal_pin' => '', 'portal_pin_lookup' => ''), array('id' => $staff_id), array('%s', '%s'), array('%d')); // phpcs:ignore
        wp_send_json_success(array('message' => __('PIN removed.', 'yatoon-booking-system')));
    }

    // 4.4.3: default raised from 4 to 6 digits. The staff portal login asks
    // for a PIN and nothing else, so every PIN shares one namespace across the
    // whole salon -- with N staff, a random guess hits SOMEONE with
    // probability N/10^length. At 4 digits and 50 staff that is 1-in-200 per
    // attempt. 6 digits puts it back in a sane place.
    $pin_length = in_array( (int) get_option( 'yatoon_staff_pin_length', 6 ), array( 4, 6 ), true ) ? (int) get_option( 'yatoon_staff_pin_length', 6 ) : 6;
    if (strlen($pin) !== $pin_length) {
        wp_send_json_error(array('message' => sprintf(
            /* translators: %d: required number of PIN digits. */
            __('PIN must be %d digits.', 'yatoon-booking-system'),
            $pin_length
        ),));
    }

    // Reject trivially guessable PINs (0000, 1234, 111111, ...).
    if (preg_match('/^(\d)\1*$/', $pin) || strpos('01234567890', $pin) !== false || strpos('09876543210', $pin) !== false) {
        wp_send_json_error(array('message' => __('Please choose a less predictable PIN - no repeated or sequential digits.', 'yatoon-booking-system')));
    }

    $lookup = hash_hmac('sha256', $pin, wp_salt('auth'));

    // 4.4.3: reject PIN collisions. Login matches on PIN alone, so two staff
    // sharing a PIN meant whoever the query returned first silently won -- the
    // other person would log in as their colleague. That is an identity mix-up,
    // not just an inconvenience, so it has to be blocked at write time.
    $collision = yatoon_db_get_var($wpdb->prepare(
        "SELECT name FROM {$wpdb->prefix}yatoon_staff
          WHERE id <> %d AND portal_pin_lookup = %s AND portal_pin <> '' LIMIT 1",
        $staff_id,
        $lookup
    )); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
    if ($collision) {
        wp_send_json_error(array('message' => __('That PIN is already used by another staff member. Please choose a different one.', 'yatoon-booking-system')));
    }

    $hashed = wp_hash_password($pin);
    yatoon_db_update($wpdb->prefix . 'yatoon_staff', array('portal_pin' => $hashed, 'portal_pin_lookup' => $lookup), array('id' => $staff_id), array('%s', '%s'), array('%d')); // phpcs:ignore
    wp_send_json_success(array('message' => __('PIN saved.', 'yatoon-booking-system')));
});

// -- Save Square API settings directly --------------------------------------
add_action('wp_ajax_yatoon_save_square_api_settings', function () {
    check_ajax_referer('yatoon_save_square_api', 'nonce');
    if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => __( 'Permission denied.', 'yatoon-booking-system' )));
    }

    $token = sanitize_text_field(wp_unslash($_POST['access_token'] ?? ''));
    $loc   = sanitize_text_field(wp_unslash($_POST['location_id']  ?? ''));
    $env   = sanitize_text_field(wp_unslash($_POST['environment']   ?? 'production'));

    if ($token) { YATOON_Crypto::update_option('yatoon_square_access_token', $token);
    }
    if ($loc) {   update_option('yatoon_square_location_id',  $loc);
    }
    update_option('yatoon_square_environment', in_array($env, array('production','sandbox'), true) ? $env : 'production');

    wp_send_json_success(array('message' => __( 'Square API settings saved.', 'yatoon-booking-system' )));
});

// -- Save ALL settings via AJAX ----------------------------------------------
// This bypasses options.php which can be blocked by Hostinger WAF
add_action('wp_ajax_yatoon_save_all_settings', function () {
    check_ajax_referer('yatoon_save_all_settings', 'nonce');
    if (!current_user_can('manage_options')) {
        wp_send_json_error(array('message' => __( 'Permission denied.', 'yatoon-booking-system' )));
    }

    // Per-tab field definitions - only save fields belonging to the current tab
    $tab_fields = array(
        'general' => array(
            'yatoon_active_platform', 'yatoon_booking_template', 'yatoon_show_advanced_menu', 'yatoon_test_booking_mode', 'yatoon_square_environment', 'sbs_square_environment',
            'yatoon_square_access_token', 'yatoon_square_location_id',
            'yatoon_vagaro_api_key', 'yatoon_vagaro_client_secret', 'yatoon_vagaro_region', 'yatoon_vagaro_business_id', 'yatoon_vagaro_timezone',
            'yatoon_auto_sync_enabled', 'yatoon_sync_interval',
            'yatoon_booking_sync_enabled', 'yatoon_booking_sync_interval',
            'yatoon_phone_mode', 'yatoon_phone_required', 'yatoon_phone_preset_country',
            'yatoon_verify_email_otp', 'yatoon_verify_sms_otp', 'yatoon_otp_expiry_minutes',
            'yatoon_staff_pin_length', 'yatoon_staff_idle_lock_minutes',
            'yatoon_policy_enabled', 'yatoon_policy_require_ack',
            'yatoon_policy_title', 'yatoon_policy_text', 'yatoon_policy_ack_label',
            'yatoon_consent_enabled', 'yatoon_consent_title', 'yatoon_consent_text',
            'yatoon_consent_signature_label',
            'yatoon_waitlist_enabled', 'yatoon_waitlist_auto_notify', 'yatoon_waitlist_claim_minutes',
            'yatoon_multi_appointment', 'yatoon_max_guests_together',
            'yatoon_multi_location_enabled', 'yatoon_booking_immersive_mode',
            'yatoon_show_next_opening', 'yatoon_show_availability_source_note',
            'yatoon_availability_help_text', 'yatoon_latest_end_hour',
            'yatoon_show_success_next', 'yatoon_success_next_title',
            'yatoon_success_action_note', 'yatoon_success_followup_text',
            'yatoon_success_deposit_text', 'yatoon_success_pay_later_text',
            'yatoon_rebooking_nudge_text', 'yatoon_rebooking_nudge_btn',
            'yatoon_show_venue_hero', 'yatoon_venue_kicker',
            'yatoon_venue_deposit_label', 'yatoon_venue_pay_later_label',
            'yatoon_tip_enabled', 'yatoon_tip_label', 'yatoon_tip_presets',
            'yatoon_gift_card_enabled', 'yatoon_enable_coupons',
            'yatoon_show_portal_banner', 'yatoon_portal_banner_bg',
            'yatoon_portal_banner_text', 'yatoon_portal_banner_prompt',
            'yatoon_portal_url', 'yatoon_portal_label', 'yatoon_portal_new_tab',
            'yatoon_portal_login_title', 'yatoon_portal_login_subtitle',
            'yatoon_portal_login_brand', 'yatoon_portal_btn_label',
            'yatoon_portal_primary_color', 'yatoon_book_new_url',
            'yatoon_portal_signin_label', 'yatoon_portal_signin_bg',
            'yatoon_portal_signin_text', 'yatoon_portal_signin_panel_bg',
            'yatoon_portal_email_signin_label', 'yatoon_portal_sms_signin_label',
            'yatoon_show_ratings_on_cards', 'yatoon_public_rating_label',
            'yatoon_payment_request_url', 'yatoon_square_checkout_url',
            'yatoon_square_app_url', 'yatoon_square_checkout_note',
            'yatoon_square_payments_enabled', 'yatoon_square_application_id',
            'yatoon_square_payment_mode', 'yatoon_square_payment_methods',
            'yatoon_square_country_code',
            'yatoon_deposit_enabled',
            'yatoon_deposit_amount', 'yatoon_deposit_type',
            'yatoon_deposit_stripe_key', 'yatoon_deposit_stripe_secret',
            'yatoon_stripe_webhook_secret',
            'yatoon_deposit_gateway', 'yatoon_paypal_environment',
            'yatoon_paypal_client_id', 'yatoon_paypal_client_secret',
            'yatoon_whatsapp_enabled', 'yatoon_whatsapp_number',
            'yatoon_whatsapp_prefill', 'yatoon_whatsapp_label',
            'yatoon_text_us_enabled', 'yatoon_textus_bg_color', 'yatoon_textus_text_color',
            'yatoon_pwa_enabled', 'yatoon_banner_bg_color',
            'yatoon_banner_btn_color', 'yatoon_banner_text_color',
            'yatoon_contact_phone', 'yatoon_contact_email',
            'yatoon_business_address', 'yatoon_business_phone', 'yatoon_business_website',
        ),
        'hours' => array(
            'sbs_header_offset', 'sbs_slot_interval', 'yatoon_header_offset', 'yatoon_slot_interval',
            'yatoon_booking_immersive_mode', 'yatoon_latest_end_hour',
            'yatoon_max_guests_together', 'yatoon_multi_appointment',
        ),
        'email' => array(
            'yatoon_send_confirmation_email', 'yatoon_email_notify_admin',
            'yatoon_email_subject', 'yatoon_email_template', 'yatoon_email_template_style',
            'yatoon_manage_booking_page_id', 'yatoon_staff_portal_page_id', 'yatoon_manage_token_days',
            'yatoon_manage_min_hours_before', 'yatoon_manage_allow_reschedule',
            'yatoon_manage_notify_admin_cancel', 'yatoon_manage_notify_admin_reschedule',
            'yatoon_reminder_enabled', 'yatoon_reminder_hours_before',
            'yatoon_reminder_subject', 'yatoon_reminder_template',
            'yatoon_review_enabled', 'yatoon_review_hours_after',
            'yatoon_review_subject', 'yatoon_review_template', 'yatoon_review_google_link',
            'yatoon_cancel_subject', 'yatoon_cancel_template',
            'yatoon_reschedule_subject', 'yatoon_reschedule_template',
            'yatoon_no_show_followup_enabled', 'yatoon_no_show_followup_hours_after',
            'yatoon_no_show_followup_subject', 'yatoon_no_show_followup_template',
        ),
        'sms' => array(
            'yatoon_twilio_enabled', 'yatoon_twilio_account_sid', 'yatoon_twilio_auth_token',
            'yatoon_twilio_from_number', 'yatoon_twilio_admin_number',
            'yatoon_twilio_customer_sms', 'yatoon_twilio_customer_template',
            'yatoon_twilio_admin_sms', 'yatoon_twilio_admin_template',
            'yatoon_twilio_reminder_sms', 'yatoon_twilio_reminder_template',
            'yatoon_twilio_logs',
            'yatoon_whatsapp_cloud_enabled', 'yatoon_whatsapp_phone_number_id',
            'yatoon_whatsapp_access_token', 'yatoon_whatsapp_api_version',
            'yatoon_whatsapp_language', 'yatoon_whatsapp_customer_confirmation',
            'yatoon_whatsapp_confirmation_template', 'yatoon_whatsapp_admin_alert',
            'yatoon_whatsapp_admin_number', 'yatoon_whatsapp_admin_template',
            'yatoon_whatsapp_reminder_enabled', 'yatoon_whatsapp_reminder_template',
            'yatoon_whatsapp_customer_updates', 'yatoon_whatsapp_cancel_template',
            'yatoon_whatsapp_reschedule_template', 'yatoon_whatsapp_test_template',
        ),
        'calendar' => array(
            'yatoon_google_cal_enabled', 'yatoon_google_cal_calendar_id',
            'yatoon_google_cal_client_id', 'yatoon_google_cal_client_secret',
            'yatoon_google_cal_refresh_token', 'yatoon_google_cal_send_invites',
        ),
        'colors' => array(
            'yatoon_business_name', 'yatoon_business_logo', 'yatoon_business_logo_id',
            'sbs_business_logo', 'sbs_business_logo_id',
            'yatoon_primary_color', 'yatoon_secondary_color', 'yatoon_body_text_color',
            'yatoon_price_color', 'yatoon_button_radius', 'yatoon_card_radius',
            'yatoon_font_family', 'sbs_button_radius', 'sbs_card_radius', 'sbs_font_family',
            'sbs_readmore_color', 'sbs_heading_font', 'sbs_heading_color',
            'sbs_heading_size', 'sbs_main_title_size', 'yatoon_cat_tab_size', 'sbs_cat_tab_size',
        ),
    );

    // Get current tab from POST
    $active_tab = sanitize_key( $_POST['active_tab'] ?? 'general' );
    // Use tab-specific fields if available, otherwise use all allowed fields
    $tab_allowed = isset( $tab_fields[ $active_tab ] ) ? $tab_fields[ $active_tab ] : null;

    // All allowed option keys - complete list from settings.php
    $allowed = array(
        // Square / Platform
        'yatoon_active_platform', 'yatoon_booking_template', 'yatoon_show_advanced_menu', 'yatoon_test_booking_mode', 'yatoon_square_environment', 'sbs_square_environment',
        'yatoon_square_access_token', 'yatoon_square_location_id',
        'yatoon_vagaro_api_key', 'yatoon_vagaro_client_secret', 'yatoon_vagaro_region', 'yatoon_vagaro_business_id', 'yatoon_vagaro_timezone',
        // Sync
        'yatoon_auto_sync_enabled', 'yatoon_sync_interval',
        'yatoon_booking_sync_enabled', 'yatoon_booking_sync_interval',
        // Email
        'yatoon_send_confirmation_email', 'yatoon_email_notify_admin',
        'yatoon_email_subject', 'yatoon_email_template', 'yatoon_email_template_style',
        // Manage booking
        'yatoon_manage_booking_page_id', 'yatoon_staff_portal_page_id', 'yatoon_manage_token_days',
        'yatoon_manage_min_hours_before', 'yatoon_manage_allow_reschedule',
        'yatoon_manage_notify_admin_cancel', 'yatoon_manage_notify_admin_reschedule',
        // Reminder / Review
        'yatoon_reminder_enabled', 'yatoon_reminder_hours_before',
        'yatoon_reminder_subject', 'yatoon_reminder_template',
        'yatoon_review_enabled', 'yatoon_review_hours_after',
        'yatoon_review_subject', 'yatoon_review_template', 'yatoon_review_google_link',
        // No-show followup
        'yatoon_no_show_followup_enabled', 'yatoon_no_show_followup_hours_after',
        'yatoon_no_show_followup_subject', 'yatoon_no_show_followup_template',
        // Cancel / Reschedule emails
        'yatoon_cancel_subject', 'yatoon_cancel_template',
        'yatoon_reschedule_subject', 'yatoon_reschedule_template',
        // Business info
        'yatoon_business_name', 'yatoon_contact_phone', 'yatoon_contact_email',
        'yatoon_business_address', 'yatoon_business_phone', 'yatoon_business_website',
        'yatoon_business_logo', 'yatoon_business_logo_id',
        'sbs_business_address', 'sbs_business_logo', 'sbs_business_logo_id',
        'sbs_business_phone', 'sbs_business_website',
        // Brand & Colors
        'yatoon_primary_color', 'yatoon_secondary_color', 'yatoon_body_text_color',
        'yatoon_price_color', 'yatoon_portal_primary_color', 'yatoon_banner_bg_color',
        'yatoon_banner_btn_color', 'yatoon_banner_text_color',
        'yatoon_textus_bg_color', 'yatoon_textus_text_color',
        'yatoon_button_radius', 'yatoon_card_radius', 'yatoon_font_family',
        'yatoon_header_offset', 'yatoon_slot_interval',
        'sbs_button_radius', 'sbs_card_radius', 'sbs_font_family',
        'sbs_readmore_color', 'sbs_heading_font', 'sbs_heading_color',
        'sbs_heading_size', 'sbs_main_title_size', 'yatoon_cat_tab_size', 'sbs_cat_tab_size',
        'sbs_header_offset', 'sbs_slot_interval',
        // Checkout options
        'yatoon_tip_enabled', 'yatoon_tip_label', 'yatoon_tip_presets',
        'yatoon_gift_card_enabled', 'yatoon_enable_coupons',
        // Phone / OTP
        'yatoon_phone_mode', 'yatoon_phone_required', 'yatoon_phone_preset_country',
        'yatoon_verify_email_otp', 'yatoon_verify_sms_otp', 'yatoon_otp_expiry_minutes',
        // Staff portal security
        'yatoon_staff_pin_length', 'yatoon_staff_idle_lock_minutes',
        // Policy / Consent
        'yatoon_policy_enabled', 'yatoon_policy_require_ack',
        'yatoon_policy_title', 'yatoon_policy_text', 'yatoon_policy_ack_label',
        'yatoon_consent_enabled', 'yatoon_consent_title', 'yatoon_consent_text',
        'yatoon_consent_signature_label',
        // Waitlist
        'yatoon_waitlist_enabled', 'yatoon_waitlist_auto_notify', 'yatoon_waitlist_claim_minutes',
        // Booking options
        'yatoon_multi_appointment', 'yatoon_max_guests_together',
        'yatoon_multi_location_enabled', 'yatoon_booking_immersive_mode',
        'yatoon_show_next_opening', 'yatoon_show_availability_source_note',
        'yatoon_availability_help_text', 'yatoon_slot_interval',
        'yatoon_latest_end_hour',
        // Success page
        'yatoon_show_success_next', 'yatoon_success_next_title',
        'yatoon_success_action_note', 'yatoon_success_followup_text',
        'yatoon_success_deposit_text', 'yatoon_success_pay_later_text',
        'yatoon_rebooking_nudge_text', 'yatoon_rebooking_nudge_btn',
        // Portal
        'yatoon_portal_url', 'yatoon_portal_label', 'yatoon_portal_new_tab',
        'yatoon_portal_login_title', 'yatoon_portal_login_subtitle',
        'yatoon_portal_login_brand', 'yatoon_portal_btn_label',
        'yatoon_show_portal_banner', 'yatoon_portal_banner_bg',
        'yatoon_portal_banner_text', 'yatoon_portal_banner_prompt',
        'yatoon_portal_signin_label', 'yatoon_portal_signin_bg',
        'yatoon_portal_signin_text', 'yatoon_portal_signin_panel_bg',
        'yatoon_portal_email_signin_label', 'yatoon_portal_sms_signin_label',
        'yatoon_book_new_url',
        // Venue / Hero
        'yatoon_show_venue_hero', 'yatoon_venue_kicker',
        'yatoon_venue_deposit_label', 'yatoon_venue_pay_later_label',
        // Reviews
        'yatoon_show_ratings_on_cards', 'yatoon_public_rating_label',
        // Deposit gateways
        'yatoon_deposit_enabled', 'yatoon_deposit_amount', 'yatoon_deposit_type',
        'yatoon_deposit_stripe_key', 'yatoon_deposit_stripe_secret',
        'yatoon_stripe_webhook_secret', 'yatoon_payment_request_url',
        'yatoon_deposit_gateway', 'yatoon_paypal_environment',
        'yatoon_paypal_client_id', 'yatoon_paypal_client_secret',
        'yatoon_square_checkout_url', 'yatoon_square_app_url',
        'yatoon_square_checkout_note',
        'yatoon_square_payments_enabled', 'yatoon_square_application_id',
        'yatoon_square_payment_mode', 'yatoon_square_payment_methods',
        'yatoon_square_country_code',
        // WhatsApp / Text Us
        'yatoon_whatsapp_enabled', 'yatoon_whatsapp_number', 'yatoon_whatsapp_prefill',
        'yatoon_whatsapp_label', 'yatoon_text_us_enabled',
        // Google Calendar
        'yatoon_google_cal_enabled', 'yatoon_google_cal_calendar_id',
        'yatoon_google_cal_client_id', 'yatoon_google_cal_client_secret',
        'yatoon_google_cal_refresh_token', 'yatoon_google_cal_send_invites',
        // Twilio / SMS
        'yatoon_twilio_enabled', 'yatoon_twilio_account_sid', 'yatoon_twilio_auth_token',
        'yatoon_twilio_from_number', 'yatoon_twilio_admin_number',
        'yatoon_twilio_customer_sms', 'yatoon_twilio_customer_template',
        'yatoon_twilio_admin_sms', 'yatoon_twilio_admin_template',
        'yatoon_twilio_reminder_sms', 'yatoon_twilio_reminder_template',
        'yatoon_twilio_logs',
        // Meta WhatsApp Cloud API
        'yatoon_whatsapp_cloud_enabled', 'yatoon_whatsapp_phone_number_id',
        'yatoon_whatsapp_access_token', 'yatoon_whatsapp_api_version',
        'yatoon_whatsapp_language', 'yatoon_whatsapp_customer_confirmation',
        'yatoon_whatsapp_confirmation_template', 'yatoon_whatsapp_admin_alert',
        'yatoon_whatsapp_admin_number', 'yatoon_whatsapp_admin_template',
        'yatoon_whatsapp_reminder_enabled', 'yatoon_whatsapp_reminder_template',
        'yatoon_whatsapp_customer_updates', 'yatoon_whatsapp_cancel_template',
        'yatoon_whatsapp_reschedule_template', 'yatoon_whatsapp_test_template',
        'yatoon_whatsapp_logs',
        // PWA
        'yatoon_pwa_enabled',
    );

    // Keys that should be deleted (not saved as empty) so defaults kick in
    $template_keys = array(
        'yatoon_email_template', 'yatoon_email_subject',
        'yatoon_reminder_template', 'yatoon_reminder_subject',
        'yatoon_review_template', 'yatoon_review_subject',
        'yatoon_cancel_template', 'yatoon_cancel_subject',
        'yatoon_reschedule_template', 'yatoon_reschedule_subject',
        'yatoon_no_show_followup_template', 'yatoon_no_show_followup_subject',
        'yatoon_policy_text', 'yatoon_policy_title', 'yatoon_policy_ack_label',
        'yatoon_consent_text', 'yatoon_consent_title', 'yatoon_consent_signature_label',
        'yatoon_twilio_customer_template', 'yatoon_twilio_admin_template',
        'yatoon_twilio_reminder_template',
        'yatoon_whatsapp_confirmation_template', 'yatoon_whatsapp_admin_template',
        'yatoon_whatsapp_reminder_template', 'yatoon_whatsapp_cancel_template',
        'yatoon_whatsapp_reschedule_template', 'yatoon_whatsapp_test_template',
        'yatoon_success_next_title', 'yatoon_success_pay_later_text',
        'yatoon_success_deposit_text', 'yatoon_success_followup_text',
        'yatoon_success_action_note', 'yatoon_rebooking_nudge_text',
        'yatoon_rebooking_nudge_btn', 'yatoon_venue_kicker',
        'yatoon_venue_pay_later_label', 'yatoon_venue_deposit_label',
        'yatoon_tip_label', 'yatoon_whatsapp_label', 'yatoon_whatsapp_prefill',
        'yatoon_portal_login_title', 'yatoon_portal_login_subtitle',
        'yatoon_portal_btn_label', 'yatoon_portal_banner_text', 'yatoon_portal_signin_label',
        'yatoon_portal_email_signin_label', 'yatoon_portal_sms_signin_label',
    );

    // Fields that should NEVER be overwritten with empty (passwords, secrets, tokens, platform settings)
    $protected_keys = array(
        // Platform selection - only present in General tab
        'yatoon_active_platform', 'yatoon_booking_template', 'yatoon_show_advanced_menu', 'yatoon_test_booking_mode', 'yatoon_square_environment', 'sbs_square_environment',
        // API credentials
        'yatoon_square_access_token', 'yatoon_square_location_id',
        'yatoon_vagaro_api_key', 'yatoon_vagaro_client_secret', 'yatoon_vagaro_region', 'yatoon_vagaro_business_id', 'yatoon_vagaro_timezone',
        // Payment
        'yatoon_deposit_stripe_key', 'yatoon_deposit_stripe_secret',
        'yatoon_stripe_webhook_secret',
        'yatoon_paypal_client_id', 'yatoon_paypal_client_secret',
        'yatoon_square_application_id',
        // SMS
        'yatoon_twilio_account_sid', 'yatoon_twilio_auth_token',
        'yatoon_twilio_from_number', 'yatoon_twilio_admin_number',
        // WhatsApp
        'yatoon_whatsapp_phone_number_id', 'yatoon_whatsapp_access_token',
        'yatoon_whatsapp_admin_number',
        // Google Calendar
        'yatoon_google_cal_client_id', 'yatoon_google_cal_client_secret',
        'yatoon_google_cal_refresh_token', 'yatoon_google_cal_calendar_id',
        // Sync settings
        'yatoon_booking_sync_interval', 'yatoon_sync_interval',
        // Business identity
        'yatoon_business_logo', 'yatoon_business_logo_id',
        'sbs_business_logo', 'sbs_business_logo_id',
    );

    $saved = 0;
    foreach ($allowed as $key) {
        // Skip fields not belonging to current tab
        if ( $tab_allowed !== null && ! in_array( $key, $tab_allowed, true ) ) {
            continue;
        }
        if (isset($_POST[ $key ])) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            $raw = wp_unslash($_POST[ $key ]);
            if (is_array($raw)) {
                $val = array_values(array_filter(array_map('sanitize_key', $raw)));
            } else {
                $val = sanitize_textarea_field($raw);
            }
            if ($key === 'yatoon_staff_pin_length') {
                $val = in_array((int) $val, array(4, 6), true) ? (string) (int) $val : '4';
            }

            // Never overwrite protected keys with empty value
            if ($val === '' && in_array($key, $protected_keys, true)) {
                continue; // skip - keep existing value
            }
            // For template/text fields: if empty, delete option so defaults are used
            if ($val === '' && in_array($key, $template_keys, true)) {
                delete_option($key);
            } elseif ( YATOON_Crypto::is_sensitive( $key ) ) {
                YATOON_Crypto::update_option($key, $val);
            } elseif ( function_exists( 'yatoon_option_wants_autoload' )
                && ! yatoon_option_wants_autoload( $key, is_array( $val ) ? null : strlen( (string) $val ) ) ) {
                // 4.6.2: autoload is now decided by yatoon_option_wants_autoload()
                // (class-yatoon-activator.php), the same predicate the upgrade
                // migration uses, instead of by membership in $template_keys.
                //
                // What was wrong: $template_keys exists to answer "delete this
                // when it is submitted empty so the built-in default comes
                // back", and it is full of short front-end copy -
                // yatoon_tip_label, yatoon_venue_kicker, yatoon_portal_btn_label,
                // yatoon_success_next_title and ~30 more. Reusing that list to
                // decide autoload pulled all of them out of alloptions, so a
                // booking page that reads dozens of these labels paid one extra
                // SELECT per label. That is slower than never having demoted
                // anything. $template_keys is left untouched for its original
                // delete-when-empty job above.
                update_option( $key, $val, false );
            } else {
                update_option($key, $val);
            }
            $saved++;
        } else {
            // Unchecked checkboxes - only clear if it's a known checkbox key
            $checkboxes = array(
                'yatoon_send_confirmation_email', 'yatoon_email_notify_admin',
                'yatoon_reminder_enabled', 'yatoon_review_enabled',
                'yatoon_tip_enabled', 'yatoon_gift_card_enabled', 'yatoon_enable_coupons',
                'yatoon_multi_location_enabled', 'yatoon_auto_sync_enabled',
                'yatoon_booking_sync_enabled', 'yatoon_pwa_enabled', 'yatoon_show_advanced_menu', 'yatoon_test_booking_mode',
                'yatoon_whatsapp_enabled', 'yatoon_text_us_enabled',
                'yatoon_deposit_enabled', 'yatoon_square_payments_enabled', 'yatoon_no_show_followup_enabled',
                'yatoon_google_cal_enabled', 'yatoon_google_cal_send_invites',
                'yatoon_manage_allow_reschedule', 'yatoon_manage_notify_admin_cancel',
                'yatoon_manage_notify_admin_reschedule', 'yatoon_phone_required',
                'yatoon_verify_email_otp', 'yatoon_verify_sms_otp',
                'yatoon_policy_enabled', 'yatoon_policy_require_ack',
                'yatoon_consent_enabled', 'yatoon_waitlist_enabled',
                'yatoon_waitlist_auto_notify', 'yatoon_waitlist_claim_minutes', 'yatoon_multi_appointment',
                'yatoon_show_next_opening', 'yatoon_show_availability_source_note',
                'yatoon_show_success_next', 'yatoon_show_venue_hero',
                'yatoon_show_portal_banner', 'yatoon_portal_new_tab',
                'yatoon_show_ratings_on_cards', 'yatoon_booking_immersive_mode',
                'yatoon_twilio_enabled', 'yatoon_twilio_customer_sms',
                'yatoon_twilio_admin_sms', 'yatoon_twilio_reminder_sms', 'yatoon_twilio_logs',
                'yatoon_whatsapp_cloud_enabled', 'yatoon_whatsapp_customer_confirmation',
                'yatoon_whatsapp_admin_alert', 'yatoon_whatsapp_reminder_enabled',
                'yatoon_whatsapp_customer_updates',
            );
            if (in_array($key, $checkboxes, true)) {
                update_option($key, '0');
            }
        }
    }

    // Reschedule booking sync if interval changed
    if (class_exists('YATOON_Cron')) {
        YATOON_Cron::maybe_reschedule_booking_sync();
    }

    wp_send_json_success(array('message' => __( 'Settings saved.', 'yatoon-booking-system' ), 'count' => $saved));
});

// -- Restore default templates ------------------------------------------------
add_action('wp_ajax_yatoon_restore_default_templates', function () {
    check_ajax_referer('yatoon_restore_defaults', 'nonce');
    if (!current_user_can('manage_options')) { wp_send_json_error();
    }

    // Delete template/text options so get_option() falls back to built-in defaults
    $reset_keys = array(
        // Email templates
        'yatoon_email_template', 'yatoon_email_subject',
        'yatoon_reminder_template', 'yatoon_reminder_subject',
        'yatoon_review_template', 'yatoon_review_subject',
        'yatoon_cancel_template', 'yatoon_cancel_subject',
        'yatoon_reschedule_template', 'yatoon_reschedule_subject',
        'yatoon_no_show_followup_template', 'yatoon_no_show_followup_subject',
        // Policy / Consent
        'yatoon_policy_text', 'yatoon_policy_title', 'yatoon_policy_ack_label',
        'yatoon_consent_text', 'yatoon_consent_title', 'yatoon_consent_signature_label',
        // SMS templates
        'yatoon_twilio_customer_template', 'yatoon_twilio_admin_template',
        'yatoon_twilio_reminder_template',
        // WhatsApp templates
        'yatoon_whatsapp_confirmation_template', 'yatoon_whatsapp_admin_template',
        'yatoon_whatsapp_reminder_template', 'yatoon_whatsapp_cancel_template',
        'yatoon_whatsapp_reschedule_template', 'yatoon_whatsapp_test_template',
        // Success page text fields (have defaults in get_option second arg)
        'yatoon_success_next_title', 'yatoon_success_pay_later_text',
        'yatoon_success_deposit_text', 'yatoon_success_followup_text',
        'yatoon_success_action_note', 'yatoon_rebooking_nudge_text',
        'yatoon_rebooking_nudge_btn', 'yatoon_availability_help_text',
        // Venue labels
        'yatoon_venue_kicker', 'yatoon_venue_pay_later_label', 'yatoon_venue_deposit_label',
        // Portal labels
        'yatoon_portal_login_title', 'yatoon_portal_login_subtitle',
        'yatoon_portal_btn_label', 'yatoon_portal_banner_text', 'yatoon_portal_banner_prompt',
        'yatoon_portal_signin_label', 'yatoon_portal_signin_bg', 'yatoon_portal_signin_text', 'yatoon_portal_signin_panel_bg',
        'yatoon_portal_email_signin_label', 'yatoon_portal_sms_signin_label',
        // Tip
        'yatoon_tip_label', 'yatoon_tip_presets',
        // WhatsApp
        'yatoon_whatsapp_label', 'yatoon_whatsapp_prefill',
    );
    foreach ($reset_keys as $key) {
        delete_option($key);
    }
    wp_send_json_success(array('message' => __( 'Default templates restored. Reload the page to see them.', 'yatoon-booking-system' )));
});
