<?php
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound
// 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
/**
 * Yatoon Booking - Customer Self-Service (Cancel / Reschedule)
 *
 * Flow:
 *  1. After booking, confirmation email includes a "Manage My Booking" link
 *     with a secure token: /manage-booking/?yatoon_token=XXXX
 *  2. Token is valid for N days (configurable, default 7).
 *  3. Page renders: booking details + Cancel button + Reschedule button.
 *  4. Cancel: marks the full appointment group cancelled, fires notification.
 *  5. Reschedule: shows available slots for same service/staff, lets
 *     customer pick a new time, saves it, fires notification.
 *
 * Shortcode: [yatoon_manage_booking]
 * Add this shortcode to any page (e.g. "Manage Booking").
 * Set the page URL in Settings -> Email -> Manage Booking Page.
 */

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

class Yatoon_Manage_Booking {

    // --- Singleton -----------------------------------------------------------

    private static $instance = null;
    public static function get_instance() {
        if ( self::$instance === null ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {
        add_shortcode( 'yatoon_manage_booking', array( $this, 'render_shortcode' ) );

        // AJAX handlers (nopriv = unauthenticated customers)
        add_action( 'wp_ajax_nopriv_yatoon_customer_cancel',     array( $this, 'ajax_cancel' ) );
        add_action( 'wp_ajax_yatoon_customer_cancel',            array( $this, 'ajax_cancel' ) );
        add_action( 'wp_ajax_nopriv_yatoon_customer_reschedule', array( $this, 'ajax_reschedule' ) );
        add_action( 'wp_ajax_yatoon_customer_reschedule',        array( $this, 'ajax_reschedule' ) );
        add_action( 'wp_ajax_nopriv_yatoon_get_reschedule_slots', array( $this, 'ajax_get_reschedule_slots' ) );
        add_action( 'wp_ajax_yatoon_get_reschedule_slots',        array( $this, 'ajax_get_reschedule_slots' ) );
        add_action( 'wp_ajax_nopriv_yatoon_get_manage_services',   array( $this, 'ajax_get_manage_services' ) );
        add_action( 'wp_ajax_yatoon_get_manage_services',          array( $this, 'ajax_get_manage_services' ) );
        add_action( 'wp_ajax_nopriv_yatoon_get_manage_service_staff', array( $this, 'ajax_get_manage_service_staff' ) );
        add_action( 'wp_ajax_yatoon_get_manage_service_staff',        array( $this, 'ajax_get_manage_service_staff' ) );
        add_action( 'wp_ajax_nopriv_yatoon_customer_change_services', array( $this, 'ajax_change_services' ) );
        add_action( 'wp_ajax_yatoon_customer_change_services',        array( $this, 'ajax_change_services' ) );

        // Add manage_token column on activation (handled in activator, but safe to ensure here too)
        add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) );
        add_action( 'template_redirect', array( $this, 'maybe_redirect_token_to_manage_page' ), 1 );
    }

    private function get_request_token() {
        $token = sanitize_text_field( wp_unslash( $_GET['yatoon_token'] ?? '' ) );
        if ( empty( $token ) ) {
            $token = sanitize_text_field( wp_unslash( $_GET['token'] ?? '' ) );
        }
        return $token;
    }

    private function parse_booking_datetime( $datetime, DateTimeZone $local_tz ) {
        if ( empty( $datetime ) ) {
            return null;
        }

        try {
            return new DateTime( $datetime, $local_tz );
        } catch ( Exception $e ) {
            return null;
        }
    }

    /**
     * Resolve the timezone that owned the booking when it was created.
     *
     * Booking times are stored as local wall-clock strings, so changing the
     * WordPress timezone or active integration later must not reinterpret an
     * existing appointment in a different zone.
     */
    private function get_booking_timezone( $booking ) {
        $snapshot = trim( (string) ( $booking->booking_timezone ?? '' ) );
        if ( $snapshot !== '' ) {
            try {
                return new DateTimeZone( $snapshot );
            } catch ( Exception $e ) {
                // Fall through for legacy rows with a missing/invalid snapshot.
            }
        }

        return yatoon_timezone( $booking->location_id ?? null, $this->get_active_platform() );
    }

    // --- Token helpers -------------------------------------------------------

    /**
     * Generate and save a manage token for a booking.
     * Called right after a booking is created.
     *
     * @param int|string $booking_id  DB row id
     * @return string  The generated token
     */
    public static function create_token( $booking_id ) {
        // Generate manage token for all booking modes (local, Square, Vagaro)
        global $wpdb;
        $token   = bin2hex( random_bytes( 24 ) ); // 48-char hex
        // A blank settings submission can persist this option as 0 even
        // though the HTML field has min=1. Never create an immediately
        // expired link; keep the server-side contract within the UI limits.
        $days    = intval( get_option( 'yatoon_manage_token_days', 7 ) );
        $days    = ( $days >= 1 && $days <= 30 ) ? $days : 7;
        // NOTE: deliberately UTC. This value is read back with strtotime()
        // (line ~218), which also parses in PHP's default zone, so writing
        // and reading agree. Switching only this side to site-local time
        // would shift every manage-link expiry by the site's UTC offset.
        $expires = gmdate( 'Y-m-d H:i:s', strtotime( "+{$days} days" ) );

        yatoon_db_update(
            $wpdb->prefix . 'yatoon_bookings',
            array(
                'manage_token'         => $token,
                'manage_token_expires' => $expires,
            ),
            array( 'id' => intval( $booking_id ) ),
            array( '%s', '%s' ),
            array( '%d' )
        );

        return $token;
    }

    /**
     * Build the manage-booking URL for a token.
     */
    public static function get_manage_url( $token ) {
        $base = self::get_manage_page_url();
        return add_query_arg( 'yatoon_token', rawurlencode( $token ), $base );
    }

    public static function get_manage_page_url() {
        $page_id = intval( get_option( 'yatoon_manage_booking_page_id', 0 ) );
        if ( $page_id > 0 && get_post_status( $page_id ) ) {
            $url = get_permalink( $page_id );
            if ( $url ) {
                return $url;
            }
        }

        $page_id = self::find_manage_page_id();
        if ( $page_id > 0 ) {
            update_option( 'yatoon_manage_booking_page_id', $page_id );
            $url = get_permalink( $page_id );
            if ( $url ) {
                return $url;
            }
        }

        return home_url( '/' );
    }

    private static function find_manage_page_id() {
        global $wpdb;

        $like = '%' . $wpdb->esc_like( '[yatoon_manage_booking' ) . '%';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $page_id = absint( yatoon_db_get_var( $wpdb->prepare(
            "SELECT ID FROM {$wpdb->posts}
             WHERE post_type = 'page'
               AND post_status IN ('publish','private')
               AND post_content LIKE %s
             ORDER BY post_status = 'publish' DESC, ID DESC
             LIMIT 1",
            $like
        ) ) );
        if ( $page_id > 0 ) {
            return $page_id;
        }

        foreach ( array( 'yatoon_manage_booking', 'yatoon-manage-booking', 'manage-booking', 'manage-appointment', 'my-booking' ) as $slug ) {
            $page = get_page_by_path( $slug );
            if ( $page && in_array( $page->post_status, array( 'publish', 'private' ), true ) ) {
                return absint( $page->ID );
            }
        }

        return 0;
    }

    public function maybe_redirect_token_to_manage_page() {
        $token = $this->get_request_token();
        if ( empty( $token ) || is_admin() || wp_doing_ajax() ) {
            return;
        }

        // This page shows live booking status (confirmed/cancelled) that can
        // change between visits, so it must never be served from a full-page
        // cache (browser, LiteSpeed, WP Rocket, etc.) or customers will see
        // stale status after cancelling/rescheduling.
        if ( ! defined( 'DONOTCACHEPAGE' ) ) {
            define( 'DONOTCACHEPAGE', true );
        }
        nocache_headers();
        if ( ! empty( $_SERVER['SERVER_SOFTWARE'] ) && stripos( $_SERVER['SERVER_SOFTWARE'], 'litespeed' ) !== false ) {
            header( 'X-LiteSpeed-Cache-Control: no-cache' );
        }
        add_filter( 'litespeed_control_set_nocache', '__return_true' );

        $page_id = self::find_manage_page_id();
        if ( $page_id <= 0 || is_page( $page_id ) ) {
            return;
        }

        $target = add_query_arg( 'yatoon_token', rawurlencode( $token ), get_permalink( $page_id ) );
        if ( $target ) {
            wp_safe_redirect( $target );
            exit;
        }
    }

    /**
     * Look up a booking by token (checks expiry and status).
     *
     * @return object|null  Booking row or null if invalid/expired/cancelled.
     */
    private function get_booking_by_token( $token ) {
        global $wpdb;
        if ( empty( $token ) || strlen( $token ) < 40 ) { return null;
        }

        $token   = sanitize_text_field( $token );
        // yatoon-release-checks: expiry-checked-in-php
        // 4.6.2: the expiry IS enforced, just below, and deliberately not in the
        // WHERE clause. An already-cancelled booking stays readable after its
        // token expires so that a customer following a stale link out of a
        // cancellation email sees "this was cancelled" instead of a dead error
        // page. That makes the expiry and the cancelled-status test one
        // decision, which a single SQL condition cannot express. Keep the two
        // halves together: if the status test below is ever removed, the expiry
        // belongs back in this query.
        $booking = yatoon_db_get_row( $wpdb->prepare(
            "SELECT b.*, s.name AS service_name, s.duration_minutes,
                    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 b.manage_token = %s
             LIMIT 1",
            $token
        ) );

        if ( ! $booking ) { return null;
        }
        $is_cancelled = $this->is_cancelled_status( $booking->status );
        if ( ! $is_cancelled && $booking->manage_token_expires && strtotime( $booking->manage_token_expires ) < time() ) { return null;
        }
        // Already cancelled appointments stay viewable so customers are not left at a dead link.
        return $booking;
    }

    private function is_cancelled_status( $status ) {
        $normalized = strtolower( trim( (string) $status ) );
        return in_array( $normalized, array( 'cancelled', 'canceled', 'cancelled_by_customer', 'cancelled_by_seller', 'canceled_by_customer', 'canceled_by_seller', 'declined', 'no_show' ), true );
    }

    private function get_booking_by_id( $booking_id ) {
        global $wpdb;

        $booking_id = absint( $booking_id );
        if ( ! $booking_id ) {
            return null;
        }

        return yatoon_db_get_row( $wpdb->prepare(
            "SELECT b.*, s.name AS service_name, s.duration_minutes,
                    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 b.id = %d
             LIMIT 1",
            $booking_id
        ) );
    }

    private function get_active_platform() {
        return sanitize_key( (string) get_option( 'yatoon_active_platform', get_option( 'yatoon_booking_platform', 'local' ) ) );
    }

    private function extract_external_booking_status( $response, $platform ) {
        if ( ! is_array( $response ) ) {
            return '';
        }

        if ( $platform === 'square' ) {
            return strtoupper( (string) ( $response['booking']['status'] ?? '' ) );
        }

        $candidates = array(
            $response['status'] ?? '',
            $response['appointment']['status'] ?? '',
            $response['data']['status'] ?? '',
            $response['booking']['status'] ?? '',
            $response['appointmentStatus'] ?? '',
            $response['appointment_status'] ?? '',
        );

        foreach ( $candidates as $candidate ) {
            if ( $candidate !== '' && $candidate !== null ) {
                return strtoupper( (string) $candidate );
            }
        }

        return '';
    }

    private function external_status_is_cancelled( $status ) {
        $normalized = strtoupper( trim( (string) $status ) );
        return in_array( $normalized, array( 'CANCELLED', 'CANCELED', 'CANCELLED_BY_CUSTOMER', 'CANCELED_BY_CUSTOMER', 'CANCELLED_BY_SELLER', 'CANCELED_BY_SELLER', 'DECLINED', 'NO_SHOW' ), true );
    }

    private function sync_cancelled_external_booking_rows( $external_booking_id ) {
        global $wpdb;

        $external_booking_id = (string) $external_booking_id;
        if ( $external_booking_id === '' ) {
            return;
        }

        $data = array( 'status' => 'cancelled' );
        $formats = array( '%s' );
        if ( $this->booking_column_exists( 'cancelled_at' ) ) {
            $data['cancelled_at'] = current_time( 'mysql' );
            $formats[] = '%s';
        }

        yatoon_db_update(
            $wpdb->prefix . 'yatoon_bookings',
            $data,
            array( 'square_booking_id' => $external_booking_id ),
            $formats,
            array( '%s' )
        );
    }

    private function fetch_external_booking_response( $platform, $external_booking_id ) {
        if ( $platform === 'square' ) {
            if ( ! class_exists( 'YATOON_Square_API' ) ) {
                return null;
            }
            $api = new YATOON_Square_API();
            return $api->get_booking( $external_booking_id );
        } elseif ( $platform === 'vagaro' ) {
            if ( ! class_exists( 'YATOON_Vagaro_API' ) || ! method_exists( 'YATOON_Vagaro_API', 'get_booking' ) ) {
                return null;
            }
            $api = new YATOON_Vagaro_API();
            return $api->get_booking( $external_booking_id );
        }

        return null;
    }

    private function fetch_external_booking_status( $platform, $external_booking_id ) {
        $response = $this->fetch_external_booking_response( $platform, $external_booking_id );

        if ( is_wp_error( $response ) ) {
            yatoon_safe_log( 'Manage booking external status refresh failed for ' . $platform . ': ' . $response->get_error_message() );
            return '';
        }

        return $this->extract_external_booking_status( $response, $platform );
    }

    private function refresh_external_status_if_needed( $booking ) {
        if ( ! $booking ) {
            return $booking;
        }

        $platform = $this->get_active_platform();
        if ( $platform === 'local' || $platform === '' || ! in_array( $platform, array( 'square', 'vagaro' ), true ) ) {
            return $booking;
        }

        $rows = $this->get_related_bookings( $booking, false );
        $external_ids = array();
        foreach ( $rows as $row ) {
            if ( ! empty( $row->square_booking_id ) ) {
                $external_ids[ (string) $row->square_booking_id ] = true;
            }
        }

        if ( empty( $external_ids ) ) {
            return $booking;
        }

        $changed = false;
        foreach ( array_keys( $external_ids ) as $external_id ) {
            $external_response = $this->fetch_external_booking_response( $platform, $external_id );
            if ( is_wp_error( $external_response ) ) {
                yatoon_safe_log( 'Manage booking external status refresh failed for ' . $platform . ': ' . $external_response->get_error_message() );
                continue;
            }
            $external_status = $this->extract_external_booking_status( $external_response, $platform );
            if ( $this->external_status_is_cancelled( $external_status ) ) {
                $this->sync_cancelled_external_booking_rows( $external_id );
                $changed = true;
                continue;
            }

            // This page is the customer's source of truth for their own
            // appointment, so don't rely solely on the periodic sync/webhook
            // to catch service, staff, or time edits made directly in Square
            // (or a change-services request that Square accepted but which,
            // for whatever reason, never made it back into the local row).
            // Re-check the live Square booking every time this page loads and
            // pull any such edits in immediately.
            if ( $platform === 'square' && ! empty( $external_response['booking'] ) && function_exists( 'yatoon_reconcile_square_booking_segments' ) ) {
                $rows_changed = yatoon_reconcile_square_booking_segments( $external_response['booking'] );
                if ( $rows_changed ) {
                    $changed = true;
                }
            }
        }

        return $changed ? ( $this->get_booking_by_id( $booking->id ) ?: $booking ) : $booking;
    }

    private function booking_column_exists( $column ) {
        static $columns = null;
        global $wpdb;

        if ( $columns === null ) {
            $columns = array();
            $rows = yatoon_db_get_results( "SHOW COLUMNS FROM {$wpdb->prefix}yatoon_bookings" );
            foreach ( (array) $rows as $row ) {
                if ( ! empty( $row->Field ) ) {
                    $columns[ $row->Field ] = true;
                }
            }
        }

        return isset( $columns[ $column ] );
    }

    private function get_related_bookings( $booking, $active_only = false ) {
        global $wpdb;

        if ( ! $booking ) {
            return array();
        }

        $rows = array();
        $table = esc_sql( $wpdb->prefix . 'yatoon_bookings' );
        $status_sql = $active_only
            ? " AND LOWER(COALESCE(b.status,'')) NOT IN ('cancelled','canceled','cancelled_by_customer','cancelled_by_seller','canceled_by_customer','canceled_by_seller','declined','no_show')"
            : '';

        if ( ! empty( $booking->group_booking_id ) ) {
            $rows = yatoon_db_get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
                "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.group_booking_id = %s {$status_sql}
                 ORDER BY b.service_order ASC, b.start_time ASC, b.id ASC",
                $booking->group_booking_id
            ) );
        }

        // Legacy / Square fallback: older multi-guest Square bookings may have
        // accidentally received different group IDs. Same customer + same start
        // time is the safest way to reunite those appointment rows.
        if ( count( $rows ) <= 1 && ! empty( $booking->start_time ) && ( ! empty( $booking->customer_email ) || ! empty( $booking->customer_phone ) ) ) {
            $fallback_rows = yatoon_db_get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
                "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.start_time = %s
                   AND ((b.customer_email <> '' AND b.customer_email = %s) OR (b.customer_phone <> '' AND b.customer_phone = %s))
                   {$status_sql}
                 ORDER BY b.service_order ASC, b.start_time ASC, b.id ASC",
                $booking->start_time,
                (string) $booking->customer_email,
                (string) $booking->customer_phone
            ) );
            if ( ! empty( $fallback_rows ) ) {
                $rows = array_merge( $rows, $fallback_rows );
            }
        }

        if ( empty( $rows ) && ( ! $active_only || ! $this->is_cancelled_status( $booking->status ) ) ) {
            $rows = array( $booking );
        }

        $unique = array();
        foreach ( $rows as $row ) {
            $unique[ intval( $row->id ) ] = $row;
        }

        return array_values( $unique );
    }

    /** Return customer-visible add-ons such as Custom Design / Nail Art. */
    private function get_booking_addon_labels( $booking_id, $service_name = '' ) {
        global $wpdb;
        $options = yatoon_db_get_results( $wpdb->prepare(
            "SELECT o.name, o.group_name
             FROM {$wpdb->prefix}yatoon_booking_options bo
             INNER JOIN {$wpdb->prefix}yatoon_service_options o ON o.id = bo.option_id
             WHERE bo.booking_id = %d
             ORDER BY bo.id ASC",
            absint( $booking_id )
        ) );
        $labels = array();
        foreach ( (array) $options as $option ) {
            $name = trim( (string) ( $option->name ?? '' ) );
            if ( $name === '' || 0 === strcasecmp( $name, trim( (string) $service_name ) ) ) { continue;
            }
            $is_addon = 0 === strcasecmp( trim( (string) ( $option->group_name ?? '' ) ), 'Add-on' )
                || preg_match( '/(?:custom\s*design|nail\s*art)/i', $name );
            if ( $is_addon ) { $labels[] = $name;
            }
        }
        return array_values( array_unique( $labels ) );
    }

    // --- Shortcode -----------------------------------------------------------

    public function enqueue_assets() {
        $token = $this->get_request_token();
        if ( empty( $token ) ) { return;
        }
        $manage_js_rel  = '../assets/js/manage-booking' . YATOON_ASSET_SUFFIX . '.js';
        $manage_css_rel = '../assets/css/manage-booking' . YATOON_ASSET_SUFFIX . '.css';
        $manage_js_path = plugin_dir_path( __FILE__ ) . $manage_js_rel;
        $manage_css_path = plugin_dir_path( __FILE__ ) . $manage_css_rel;
        $manage_js_ver  = file_exists( $manage_js_path ) ? (string) filemtime( $manage_js_path ) : SBS_VERSION;
        $manage_css_ver = file_exists( $manage_css_path ) ? (string) filemtime( $manage_css_path ) : SBS_VERSION;
        wp_enqueue_script(
            'yatoon-manage-booking',
            plugins_url( $manage_js_rel, __FILE__ ),
            array( 'jquery' ),
            $manage_js_ver,
            true
        );
        yatoon_set_script_i18n( 'yatoon-manage-booking' );
        wp_localize_script( 'yatoon-manage-booking', 'yatoonManage', array(
            'ajaxurl' => admin_url( 'admin-ajax.php' ),
            'nonce'   => wp_create_nonce( 'yatoon_manage_booking' ),
            'token'   => $token,
            'bookUrl' => esc_url_raw( get_option( 'yatoon_book_new_url', '' ) ?: home_url( '/' ) ),
            'portalUrl' => esc_url_raw( class_exists( 'YATOON_Customer_Portal' ) ? YATOON_Customer_Portal::portal_url() : get_option( 'yatoon_portal_url', '' ) ),
            'i18n'    => array(
                'cancel_confirm'     => __( 'Are you sure you want to cancel this entire appointment? This cannot be undone.', 'yatoon-booking-system' ),
                'reschedule_confirm' => __( 'Confirm reschedule to the selected time?', 'yatoon-booking-system' ),
                'loading'            => __( 'Loading...', 'yatoon-booking-system' ),
                'select_slot'        => __( 'Please select a new time slot.', 'yatoon-booking-system' ),
                'change_services'    => __( 'Change Services', 'yatoon-booking-system' ),
                'whole_service'      => __( 'Whole service', 'yatoon-booking-system' ),
            ),
        ) );
        wp_enqueue_style(
            'yatoon-manage-booking',
            plugins_url( $manage_css_rel, __FILE__ ),
            array(),
            $manage_css_ver
        );
        wp_enqueue_style(
            'yatoon-apple-polish',
            plugins_url( '../assets/css/apple-polish' . YATOON_ASSET_SUFFIX . '.css', __FILE__ ),
            array( 'yatoon-manage-booking' ),
            SBS_VERSION
        );
        wp_enqueue_style(
            'yatoon-design-system',
            plugins_url( '../assets/css/yatoon-design-system' . YATOON_ASSET_SUFFIX . '.css', __FILE__ ),
            array( 'yatoon-apple-polish' ),
            SBS_VERSION
        );
        wp_add_inline_style( 'yatoon-apple-polish', yatoon_apple_polish_inline_css() );
    }

    public function render_shortcode( $atts ) {
        $token = $this->get_request_token();

        if ( empty( $token ) ) {
            return '<div class="yatoon-manage-wrap"><p>' . esc_html__( 'No booking token provided. Please use the link from your confirmation email.', 'yatoon-booking-system' ) . '</p></div>';
        }

        $booking = $this->get_booking_by_token( $token );

        if ( ! $booking ) {
            return '<div class="yatoon-manage-wrap"><p class="yatoon-manage-error">' . esc_html__( 'This link is invalid or has expired. Please contact us directly to make changes.', 'yatoon-booking-system' ) . '</p></div>';
        }
        $booking = $this->refresh_external_status_if_needed( $booking );

        $business_name  = get_option( 'yatoon_business_name', get_bloginfo( 'name' ) );
        $business_phone = get_option( 'yatoon_contact_phone', '' );
        $primary_color  = get_option( 'yatoon_primary_color', '#ec4899' );

        $local_tz   = $this->get_booking_timezone( $booking );
        $start_dt   = $this->parse_booking_datetime( $booking->start_time, $local_tz );
        $end_dt     = $this->parse_booking_datetime( $booking->end_time, $local_tz );

        // Fetch the full booking group so customers see and cancel the complete appointment.
        $group_bookings        = $this->get_related_bookings( $booking, false );
        $active_group_bookings = $this->get_related_bookings( $booking, true );
        $display_bookings      = ! empty( $active_group_bookings ) ? $active_group_bookings : $group_bookings;

        if ( ! $start_dt || ! $end_dt ) {
            return '<div class="yatoon-manage-wrap"><p class="yatoon-manage-error">' . esc_html__( 'This booking could not be loaded because its date or time is invalid.', 'yatoon-booking-system' ) . '</p></div>';
        }

        // A multi-service appointment spans every related row. The token may
        // point at the first row, whose end_time only covers that one service,
        // so derive the customer-facing date/time from the complete group.
        foreach ( $display_bookings as $display_booking ) {
            $display_start = $this->parse_booking_datetime( $display_booking->start_time ?? '', $local_tz );
            $display_end   = $this->parse_booking_datetime( $display_booking->end_time ?? '', $local_tz );
            if ( $display_start && $display_start < $start_dt ) {
                $start_dt = $display_start;
            }
            if ( $display_end && $display_end > $end_dt ) {
                $end_dt = $display_end;
            }
        }

        $is_cancelled    = empty( $active_group_bookings );
        $is_past         = $start_dt < new DateTime( 'now', $local_tz );
        $can_cancel      = ! $is_cancelled && ! $is_past;
        $reschedule_on   = get_option( 'yatoon_manage_allow_reschedule', '1' ) === '1';
        $can_reschedule  = $can_cancel && $reschedule_on;
        $can_change_services = $can_cancel && ( ! function_exists( 'yatoon_is_pro' ) || yatoon_is_pro() );
        $display_status  = $is_cancelled ? 'cancelled' : ( ! empty( $active_group_bookings[0]->status ) ? $active_group_bookings[0]->status : $booking->status );
        $can_pick_cancel = $can_cancel && count( $active_group_bookings ) > 1;

        // Lead time check
        $min_hours = intval( get_option( 'yatoon_manage_min_hours_before', 2 ) );
        if ( $min_hours > 0 ) {
            $cutoff = new DateTime( 'now', $local_tz );
            $cutoff->modify( "+{$min_hours} hours" );
            if ( $start_dt <= $cutoff ) {
                $can_cancel     = false;
                $can_reschedule = false;
                $can_change_services = false;
            }
        }

        $min_reschedule_dt = new DateTime( 'now', $local_tz );
        $min_reschedule_dt->setTime( 0, 0, 0 );
        $min_reschedule_dt->modify( '+1 day' );
        $max_reschedule_dt = clone $min_reschedule_dt;
        $max_reschedule_dt->modify( '+59 days' );
        $default_reschedule_dt = clone $start_dt;
        $default_reschedule_dt->setTime( 0, 0, 0 );
        if ( $default_reschedule_dt < $min_reschedule_dt ) {
            $default_reschedule_dt = clone $min_reschedule_dt;
        } elseif ( $default_reschedule_dt > $max_reschedule_dt ) {
            $default_reschedule_dt = clone $max_reschedule_dt;
        }

        ob_start();
        ?>
        <div class="yatoon-manage-wrap" style="--yatoon-primary:<?php echo esc_attr( $primary_color ); ?>">

            <div class="yatoon-manage-card">
                <div class="yatoon-manage-header">
                    <h2><?php echo esc_html( $business_name ); ?></h2>
                    <p class="yatoon-manage-subtitle"><?php esc_html_e( 'Manage Your Appointment', 'yatoon-booking-system' ); ?></p>
                </div>

                <div class="yatoon-manage-details">
                    <div class="yatoon-manage-row"><span class="ymr-label"><?php esc_html_e( 'Services', 'yatoon-booking-system' ); ?></span><span class="ymr-value">
                        <?php foreach ( $display_bookings as $idx => $group_item ) : ?>
                            <?php
                            $group_start = $this->parse_booking_datetime( $group_item->start_time, $local_tz );
                            $group_end   = $this->parse_booking_datetime( $group_item->end_time, $local_tz );
                            $addon_labels = $this->get_booking_addon_labels( $group_item->id, $group_item->service_name ?? '' );
                            ?>
                            <span class="yatoon-manage-service-row" data-booking-id="<?php echo esc_attr( intval( $group_item->id ) ); ?>" data-service-id="<?php echo esc_attr( intval( $group_item->service_id ) ); ?>" style="display:block;margin-bottom:6px;">
                                <?php if ( $can_pick_cancel && ! $this->is_cancelled_status( $group_item->status ) ) : ?>
                                    <label class="yatoon-cancel-choice">
                                        <input type="checkbox" class="yatoon-cancel-item" value="<?php echo esc_attr( intval( $group_item->id ) ); ?>" checked>
                                        <span>
                                <?php endif; ?>
                                <?php echo esc_html( ( $idx + 1 ) . '. ' . ( $group_item->service_name ?? '-' ) ); ?>
                                <?php if ( $addon_labels ) : ?>
                                    <?php echo esc_html( ' + ' . implode( ' + ', $addon_labels ) ); ?>
                                <?php endif; ?>
                                <?php if ( ! empty( $group_item->staff_name ) ) : ?>
                                    <?php echo esc_html( ' - ' . $group_item->staff_name ); ?>
                                <?php endif; ?>
                                <?php if ( $group_start && $group_end ) : ?>
                                    <small style="display:block;color:#6b7280;"><?php echo esc_html( $group_start->format( 'g:i A' ) . ' - ' . $group_end->format( 'g:i A' ) ); ?></small>
                                <?php endif; ?>
                                <?php if ( $can_pick_cancel && ! $this->is_cancelled_status( $group_item->status ) ) : ?>
                                        </span>
                                    </label>
                                <?php endif; ?>
                            </span>
                        <?php endforeach; ?>
                    </span></div>
                    <div class="yatoon-manage-row"><span class="ymr-label"><?php esc_html_e( 'Date', 'yatoon-booking-system' ); ?></span><span class="ymr-value"><?php echo esc_html( $start_dt->format( 'F j, Y' ) ); ?></span></div>
                    <div class="yatoon-manage-row"><span class="ymr-label"><?php esc_html_e( 'Time', 'yatoon-booking-system' ); ?></span><span class="ymr-value"><?php echo esc_html( $start_dt->format( 'g:i A' ) . ' - ' . $end_dt->format( 'g:i A' ) ); ?></span></div>
                    <div class="yatoon-manage-row"><span class="ymr-label"><?php esc_html_e( 'Name', 'yatoon-booking-system' ); ?></span><span class="ymr-value"><?php echo esc_html( $booking->customer_name ); ?></span></div>
                    <div class="yatoon-manage-row"><span class="ymr-label"><?php esc_html_e( 'Status', 'yatoon-booking-system' ); ?></span><span class="ymr-value yatoon-status-<?php echo esc_attr( strtolower( $display_status ) ); ?>"><?php echo esc_html( ucfirst( str_replace( '_', ' ', $display_status ) ) ); ?></span></div>
                </div>

                <?php if ( $is_cancelled ) : ?>
                    <div class="yatoon-manage-notice yatoon-notice-info">
                        <?php esc_html_e( 'This appointment has been cancelled.', 'yatoon-booking-system' ); ?>
                        <?php if ( $business_phone ) : ?>
                            <?php echo esc_html__( 'To book again, call us at ', 'yatoon-booking-system' ) . '<a href="tel:' . esc_attr( $business_phone ) . '">' . esc_html( $business_phone ) . '</a>'; ?>
                        <?php endif; ?>
                    </div>
                <?php elseif ( $is_past ) : ?>
                    <div class="yatoon-manage-notice yatoon-notice-info">
                        <?php esc_html_e( 'This appointment has already passed.', 'yatoon-booking-system' ); ?>
                    </div>
                <?php elseif ( ! $can_cancel && $min_hours > 0 ) : ?>
                    <div class="yatoon-manage-notice yatoon-notice-warning">
                        <?php /* translators: %s: Minimum notice in hours. */ printf( esc_html__( 'Changes must be made at least %s hour(s) before your appointment. Please call us directly.', 'yatoon-booking-system' ), esc_html( (string) $min_hours ) ); ?>
                        <?php if ( $business_phone ) { echo ' <a href="tel:' . esc_attr( $business_phone ) . '">' . esc_html( $business_phone ) . '</a>';} ?>
                    </div>
                <?php else : ?>

                    <div class="yatoon-manage-actions" id="yatoon-manage-actions">
                        <?php if ( $can_reschedule ) : ?>
                            <button type="button" class="yatoon-btn yatoon-btn-primary" id="yatoon-reschedule-btn">
                                <?php esc_html_e( 'Reschedule', 'yatoon-booking-system' ); ?>
                            </button>
                        <?php endif; ?>
                        <?php if ( $can_change_services ) : ?>
                            <button type="button" class="yatoon-btn yatoon-btn-outline" id="yatoon-change-services-btn" aria-expanded="false" aria-controls="yatoon-change-services-panel">
                                <?php esc_html_e( 'Change Services', 'yatoon-booking-system' ); ?>
                            </button>
                        <?php endif; ?>
                        <?php if ( $can_cancel ) : ?>
                            <button type="button" class="yatoon-btn yatoon-btn-outline" id="yatoon-cancel-btn">
                                <?php echo esc_html( $can_pick_cancel ? __( 'Cancel Selected Items', 'yatoon-booking-system' ) : __( 'Cancel Appointment', 'yatoon-booking-system' ) ); ?>
                            </button>
                        <?php endif; ?>
                    </div>

                    <?php if ( $can_change_services ) : ?>
                    <div id="yatoon-change-services-panel" style="display:none;" class="yatoon-reschedule-panel yatoon-change-services-panel" aria-labelledby="yatoon-change-services-heading">
                        <h3 id="yatoon-change-services-heading"><?php esc_html_e( 'Change Services', 'yatoon-booking-system' ); ?></h3>
                        <p class="yatoon-change-services-help"><?php esc_html_e( 'Choose replacement services for this appointment. To remove a service, use Cancel Selected Items.', 'yatoon-booking-system' ); ?></p>
                        <div id="yatoon-change-services-list"></div>
                        <div class="yatoon-reschedule-confirm-row">
                            <button type="button" class="yatoon-btn yatoon-btn-sm" id="yatoon-add-service-row-btn"><?php esc_html_e( 'Add Service', 'yatoon-booking-system' ); ?></button>
                            <button type="button" class="yatoon-btn yatoon-btn-primary" id="yatoon-save-services-btn"><?php esc_html_e( 'Save Services', 'yatoon-booking-system' ); ?></button>
                            <button type="button" class="yatoon-btn yatoon-btn-ghost" id="yatoon-cancel-services-btn"><?php esc_html_e( 'Back', 'yatoon-booking-system' ); ?></button>
                        </div>
                    </div>
                    <?php endif; ?>

                    <!-- Reschedule panel (hidden by default) -->
                    <?php if ( $can_reschedule ) : ?>
                    <div id="yatoon-reschedule-panel" style="display:none;" class="yatoon-reschedule-panel">
                        <h3><?php esc_html_e( 'Choose a New Date & Time', 'yatoon-booking-system' ); ?></h3>
                        <div class="yatoon-reschedule-date-row">
                            <label for="yatoon-reschedule-date"><?php esc_html_e( 'Date', 'yatoon-booking-system' ); ?></label>
                            <input type="date" id="yatoon-reschedule-date"
                                   value="<?php echo esc_attr( $default_reschedule_dt->format( 'Y-m-d' ) ); ?>"
                                   min="<?php echo esc_attr( $min_reschedule_dt->format( 'Y-m-d' ) ); ?>"
                                   max="<?php echo esc_attr( $max_reschedule_dt->format( 'Y-m-d' ) ); ?>">
                            <button type="button" class="yatoon-btn yatoon-btn-sm" id="yatoon-load-slots-btn">
                                <?php esc_html_e( 'Show Available Times', 'yatoon-booking-system' ); ?>
                            </button>
                        </div>
                        <div id="yatoon-slots-wrap" style="display:none;">
                            <div id="yatoon-slots-list" class="yatoon-slots-list"></div>
                            <div class="yatoon-reschedule-confirm-row" id="yatoon-reschedule-confirm-row" style="display:none;">
                                <button type="button" class="yatoon-btn yatoon-btn-primary" id="yatoon-confirm-reschedule-btn">
                                    <?php esc_html_e( 'Confirm Reschedule', 'yatoon-booking-system' ); ?>
                                </button>
                            </div>
                        </div>
                        <div id="yatoon-slots-msg" class="yatoon-manage-notice" style="display:none;"></div>
                        <div class="yatoon-reschedule-confirm-row">
                            <button type="button" class="yatoon-btn yatoon-btn-ghost" id="yatoon-cancel-reschedule-btn">
                                ← <?php esc_html_e( 'Back to appointment', 'yatoon-booking-system' ); ?>
                            </button>
                        </div>
                    </div>
                    <?php endif; ?>

                    <div id="yatoon-manage-msg" class="yatoon-manage-notice" role="status" aria-live="polite" style="display:none;"></div>

                <?php endif; ?>

                <div class="yatoon-manage-footer">
                    <?php $portal_url = class_exists( 'YATOON_Customer_Portal' ) ? YATOON_Customer_Portal::portal_url() : get_option( 'yatoon_portal_url', '' ); ?>
                    <?php if ( $portal_url ) : ?>
                        <p><a class="yatoon-btn yatoon-btn-ghost yatoon-manage-return" href="<?php echo esc_url( $portal_url ); ?>">← <?php esc_html_e( 'Back to My Appointments', 'yatoon-booking-system' ); ?></a></p>
                    <?php endif; ?>
                    <?php if ( $business_phone ) : ?>
                        <p><?php esc_html_e( 'Need help? Call us:', 'yatoon-booking-system' ); ?> <a href="tel:<?php echo esc_attr( $business_phone ); ?>"><?php echo esc_html( $business_phone ); ?></a></p>
                    <?php endif; ?>
                </div>

            </div>

        </div>
        <?php
        return ob_get_clean();
    }

    // --- AJAX: Change services ----------------------------------------------

    public function ajax_get_manage_services() {
        check_ajax_referer( 'yatoon_manage_booking', 'nonce' );
        if ( function_exists( 'yatoon_is_pro' ) && ! yatoon_is_pro() ) {
            wp_send_json_error( array( 'message' => __( 'This feature requires Yatoon Pro.', 'yatoon-booking-system' ) ) );
        }
        if ( function_exists( 'yatoon_public_rate_limit' ) ) {
            yatoon_public_rate_limit( 'manage_get_services', 20, 10 );
        }
        global $wpdb;

        $token   = sanitize_text_field( wp_unslash( $_POST['token'] ?? '' ) );
        $booking = $this->get_booking_by_token( $token );
        if ( ! $booking ) {
            wp_send_json_error( array( 'message' => __( 'Invalid or expired link.', 'yatoon-booking-system' ) ) );
        }
        $booking = $this->refresh_external_status_if_needed( $booking );

        if ( $this->is_cancelled_status( $booking->status ) ) {
            wp_send_json_error( array( 'message' => __( 'This appointment is already cancelled.', 'yatoon-booking-system' ) ) );
        }

        $services = class_exists( 'YATOON_Manage_Service_Catalog' )
            ? YATOON_Manage_Service_Catalog::choices()
            : array();

        // Staff is intentionally not offered as a customer-editable choice here.
        // Not every staff member can perform every service, and showing the
        // full staff list invited change requests that were doomed to fail
        // (Square rejects a service/staff pairing the staff member doesn't
        // offer). ajax_change_services() keeps the original staff member when
        // they can still do the newly chosen service, and otherwise picks an
        // available staff member who can - see find_staff_for_service_window().
        $rows = $this->get_related_bookings( $booking, true );
        $current = array();
        foreach ( $rows as $row ) {
            $current[] = array(
                'booking_id'   => intval( $row->id ),
                'service_id'   => intval( $row->service_id ),
                'staff_id'     => intval( $row->staff_id ),
                'service_name' => (string) ( $row->service_name ?? '' ),
                'staff_name'   => (string) ( $row->staff_name ?? '' ),
                'option_id'    => class_exists( 'YATOON_Manage_Service_Catalog' ) ? YATOON_Manage_Service_Catalog::current_main_option_id( $row->id ) : 0,
            );
        }

        wp_send_json_success( array(
            'services' => array_values( (array) $services ),
            'current' => $current,
        ) );
    }

    /** Return staff who can perform each draft service and are free for its exact window. */
    public function ajax_get_manage_service_staff() {
        check_ajax_referer( 'yatoon_manage_booking', 'nonce' );
        if ( function_exists( 'yatoon_is_pro' ) && ! yatoon_is_pro() ) {
            wp_send_json_error( array( 'message' => __( 'This feature requires Yatoon Pro.', 'yatoon-booking-system' ) ) );
        }
        if ( function_exists( 'yatoon_public_rate_limit' ) ) {
            yatoon_public_rate_limit( 'manage_service_staff', 30, 10 );
        }
        global $wpdb;
        $token = sanitize_text_field( wp_unslash( $_POST['token'] ?? '' ) );
        $booking = $this->get_booking_by_token( $token );
        if ( ! $booking ) {
            wp_send_json_error( array( 'message' => __( 'Invalid or expired link.', 'yatoon-booking-system' ) ) );
        }
        $booking = $this->refresh_external_status_if_needed( $booking );
        $rows = $this->get_related_bookings( $booking, true );
        if ( empty( $rows ) ) {
            wp_send_json_error( array( 'message' => __( 'This appointment is already cancelled.', 'yatoon-booking-system' ) ) );
        }
        $items_raw = isset( $_POST['items'] ) ? wp_unslash( $_POST['items'] ) : '';
        if ( ! is_string( $items_raw ) || strlen( $items_raw ) > 32768 ) {
            wp_send_json_error( array( 'message' => __( 'The service list is invalid. Please refresh and try again.', 'yatoon-booking-system' ) ) );
        }
        $items = json_decode( $items_raw, true );
        if ( ! is_array( $items ) || empty( $items ) || count( $items ) > 20 ) {
            wp_send_json_error( array( 'message' => __( 'Please choose at least one service.', 'yatoon-booking-system' ) ) );
        }
        $tz = $this->get_booking_timezone( $booking );
        $cursor = $this->parse_booking_datetime( $rows[0]->start_time, $tz );
        if ( ! $cursor ) {
            wp_send_json_error( array( 'message' => __( 'This booking has an invalid scheduled time.', 'yatoon-booking-system' ) ) );
        }

        $platform = $this->get_active_platform();
        $profiles_by_staff = array();
        $has_explicit_profile_assignments = false;
        $square_busy = array();
        if ( 'square' === $platform && class_exists( 'YATOON_Square_API' ) ) {
            $api = new YATOON_Square_API();
            $profiles = $api->get_team_member_booking_profiles();
            if ( is_wp_error( $profiles ) ) {
                wp_send_json_error( array( 'message' => __( 'Square staff availability could not be checked right now. Please try again.', 'yatoon-booking-system' ) ) );
            }
            foreach ( (array) ( $profiles['team_member_booking_profiles'] ?? array() ) as $profile ) {
                $profile_ids = array_map( 'strval', (array) ( $profile['service_variation_ids'] ?? array() ) );
                if ( $profile_ids ) { $has_explicit_profile_assignments = true;
                }
                $profiles_by_staff[ (string) ( $profile['team_member_id'] ?? '' ) ] = array_fill_keys( $profile_ids, true );
            }
            $day_start = ( clone $cursor )->setTime( 0, 0 )->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d\TH:i:s\Z' );
            $day_end = ( clone $cursor )->setTime( 23, 59, 59 )->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d\TH:i:s\Z' );
            $live_bookings = array();
            $live_cursor = null;
            $live_pages = 0;
            do {
                $live = $api->list_bookings( $day_start, $day_end, $live_cursor );
                if ( is_wp_error( $live ) ) {
                    wp_send_json_error( array( 'message' => __( 'Square staff availability could not be checked right now. Please try again.', 'yatoon-booking-system' ) ) );
                }
                $live_bookings = array_merge( $live_bookings, (array) ( $live['bookings'] ?? array() ) );
                $live_cursor = (string) ( $live['cursor'] ?? '' );
                ++$live_pages;
            } while ( $live_cursor && $live_pages < 10 );
            $own_square_ids = array_fill_keys( array_filter( array_map( static function ( $row ) {
 return (string) ( $row->square_booking_id ?? '' );
}, $rows ) ), true );
            foreach ( $live_bookings as $live_booking ) {
                if ( isset( $own_square_ids[ (string) ( $live_booking['id'] ?? '' ) ] ) || in_array( strtoupper( (string) ( $live_booking['status'] ?? '' ) ), array( 'CANCELLED_BY_CUSTOMER', 'CANCELLED_BY_SELLER', 'DECLINED', 'NO_SHOW' ), true ) || empty( $live_booking['start_at'] ) ) { continue;
                }
                try { $live_cursor = new DateTime( $live_booking['start_at'] );
} catch ( Exception $e ) { continue; }
                foreach ( (array) ( $live_booking['appointment_segments'] ?? array() ) as $segment ) {
                    $duration = max( 1, absint( $segment['duration_minutes'] ?? 0 ) );
                    $live_end = ( clone $live_cursor )->modify( "+{$duration} minutes" );
                    $square_staff_id = (string) ( $segment['team_member_id'] ?? '' );
                    if ( $square_staff_id ) { $square_busy[ $square_staff_id ][] = array( clone $live_cursor, $live_end );
                    }
                    $live_cursor = clone $live_end;
                }
            }
        }

        $active_by_id = array();
        foreach ( $rows as $row ) { $active_by_id[ absint( $row->id ) ] = $row;
        }
        $square_ids = array_values( array_unique( array_filter( array_map( static function ( $row ) {
            return ! empty( $row->square_booking_id ) ? (string) $row->square_booking_id : '';
        }, $rows ) ) ) );
        $is_split_square = 'square' === $platform && count( $square_ids ) > 1;
        $square_cursors = array();
        if ( $is_split_square ) {
            foreach ( $rows as $row ) {
                $source_id = (string) ( $row->square_booking_id ?? '' );
                if ( '' === $source_id || isset( $square_cursors[ $source_id ] ) ) { continue;
                }
                $source_start = $this->parse_booking_datetime( $row->start_time, $tz );
                if ( $source_start ) { $square_cursors[ $source_id ] = $source_start;
                }
            }
        }
        $result = array();
        foreach ( array_values( $items ) as $index => $item ) {
            $service = class_exists( 'YATOON_Manage_Service_Catalog' ) ? YATOON_Manage_Service_Catalog::resolve( absint( $item['service_id'] ?? 0 ), absint( $item['option_id'] ?? 0 ) ) : null;
            if ( ! $service ) {
                $result[] = array( 'index' => $index, 'staff' => array(), 'message' => __( 'Choose a valid service first.', 'yatoon-booking-system' ) );
                continue;
            }
            $old = $active_by_id[ absint( $item['booking_id'] ?? 0 ) ] ?? null;
            $source_id = (string) ( $old->square_booking_id ?? '' );
            $item_start = clone $cursor;
            if ( $is_split_square && $source_id && isset( $square_cursors[ $source_id ] ) ) {
                $item_start = clone $square_cursors[ $source_id ];
            }
            $end = ( clone $item_start )->modify( '+' . max( 1, absint( $service->duration_minutes ) ) . ' minutes' );
            if ( class_exists( 'YATOON_Change_Service_Safety' ) && ! YATOON_Change_Service_Safety::within_open_hours( $item_start, $end ) ) {
                $result[] = array( 'index' => $index, 'staff' => array(), 'message' => __( 'This service would go past business hours or overlap a break. Choose a shorter service, remove another service, or reschedule first.', 'yatoon-booking-system' ) );
                if ( $is_split_square && $source_id ) { $square_cursors[ $source_id ] = clone $end;
                } else { $cursor = clone $end;
                }
                continue;
            }
            $staff_rows = yatoon_db_get_results( $wpdb->prepare(
                "SELECT DISTINCT st.* FROM {$wpdb->prefix}yatoon_staff st
                 INNER JOIN {$wpdb->prefix}yatoon_staff_services ss ON ss.staff_id=st.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",
                absint( $service->id )
            ) );
            if ( 'square' === $platform || empty( $staff_rows ) ) {
                $staff_rows = yatoon_db_get_results(
                    "SELECT * FROM {$wpdb->prefix}yatoon_staff
                     WHERE is_active=1 AND is_bookable=1
                     ORDER BY sort_order ASC, name ASC"
                );
            }
            $available = array();
            foreach ( (array) $staff_rows as $staff ) {
                if ( ! $this->staff_window_available( absint( $staff->id ), $item_start, $end, $rows ) ) { continue;
                }
                if ( 'square' === $platform ) {
                    $sq_staff = (string) ( $staff->square_id ?? '' );
                    $variation = (string) ( $service->selected_option_square_id ?? '' );
                    if ( ! $sq_staff || ! $variation ) { continue;
                    }
                    // Older/limited Square booking-profile responses can omit
                    // service_variation_ids. Treat an explicit non-empty list
                    // as authoritative, but do not turn missing metadata into
                    // a false "this employee cannot do the service" result.
                    $profile_variations = $profiles_by_staff[ $sq_staff ] ?? array();
                    $locally_assigned = $this->staff_can_do_service( absint( $staff->id ), absint( $service->id ) );
                    if ( $has_explicit_profile_assignments && ! isset( $profile_variations[ $variation ] ) ) { continue;
                    }
                    if ( ! $has_explicit_profile_assignments && ! $locally_assigned ) { continue;
                    }
                    $busy = false;
                    foreach ( (array) ( $square_busy[ $sq_staff ] ?? array() ) as $window ) {
                        $candidate_start = ( clone $item_start )->setTimezone( new DateTimeZone( 'UTC' ) );
                        $candidate_end = ( clone $end )->setTimezone( new DateTimeZone( 'UTC' ) );
                        if ( $window[0] < $candidate_end && $window[1] > $candidate_start ) { $busy = true;
break; }
                    }
                    if ( $busy ) { continue;
                    }
                    // list_bookings() above is the authoritative conflict set
                    // after excluding every Square booking in this customer
                    // group. search_availability() cannot express that
                    // exclusion and therefore falsely hid staff assigned to a
                    // companion booking that is being edited in the same save.
                }
                $available[] = array( 'id' => absint( $staff->id ), 'name' => (string) $staff->name );
            }
            $result[] = array(
                'index'   => $index,
                'staff'   => $available,
                'message' => $available ? '' : __( 'No qualified staff member is free for this service at that time. Choose Any staff, another service, or reschedule first.', 'yatoon-booking-system' ),
            );
            if ( $is_split_square && $source_id ) { $square_cursors[ $source_id ] = clone $end;
            } else { $cursor = clone $end;
            }
        }
        wp_send_json_success( array( 'rows' => $result, 'source' => 'square' === $platform ? 'square_live' : 'local_live' ) );
    }

    public function ajax_change_services() {
        check_ajax_referer( 'yatoon_manage_booking', 'nonce' );
        if ( function_exists( 'yatoon_is_pro' ) && ! yatoon_is_pro() ) {
            wp_send_json_error( array( 'message' => __( 'This feature requires Yatoon Pro.', 'yatoon-booking-system' ) ) );
        }
        if ( function_exists( 'yatoon_public_rate_limit' ) ) {
            yatoon_public_rate_limit( 'manage_change_services', 6, 10 );
        }
        global $wpdb;

        $token   = sanitize_text_field( wp_unslash( $_POST['token'] ?? '' ) );
        $booking = $this->get_booking_by_token( $token );
        if ( ! $booking ) {
            wp_send_json_error( array( 'message' => __( 'Invalid or expired link.', 'yatoon-booking-system' ) ) );
        }
        $booking = $this->refresh_external_status_if_needed( $booking );

        $change_lock = '';
        if ( class_exists( 'YATOON_Change_Service_Safety' ) ) {
            $change_lock = YATOON_Change_Service_Safety::lock_name( $booking );
            if ( ! YATOON_Change_Service_Safety::acquire( $change_lock ) ) {
                wp_send_json_error( array( 'message' => __( 'Another change is being processed for this appointment. Please wait a moment and try again.', 'yatoon-booking-system' ) ) );
            }
        }

        $rows = $this->get_related_bookings( $booking, true );
        if ( empty( $rows ) ) {
            wp_send_json_error( array( 'message' => __( 'This appointment is already cancelled.', 'yatoon-booking-system' ) ) );
        }

        $local_tz = $this->get_booking_timezone( $booking );
        $start_dt = $this->parse_booking_datetime( $rows[0]->start_time, $local_tz );
        if ( ! $start_dt ) {
            wp_send_json_error( array( 'message' => __( 'This booking has an invalid scheduled time.', 'yatoon-booking-system' ) ) );
        }

        $min_hours = intval( get_option( 'yatoon_manage_min_hours_before', 2 ) );
        if ( $min_hours > 0 ) {
            $cutoff = new DateTime( 'now', $local_tz );
            $cutoff->modify( "+{$min_hours} hours" );
            if ( $start_dt <= $cutoff ) {
                wp_send_json_error( array( 'message' => sprintf(
                    /* translators: %d: Minimum notice in hours. */
                    __( 'Changes must be made at least %d hour(s) in advance. Please call us directly.', 'yatoon-booking-system' ),
                    $min_hours
                ), ) );
            }
        }

        $platform = $this->get_active_platform();

        // 4.6.1: changing services changes the appointment's duration, which
        // means it claims (or frees) calendar time. The per-appointment change
        // lock above only stops two edits to the SAME booking; it shares no
        // namespace with the day lock every other write path uses, so a service
        // change could still race a new booking for the freed/claimed minutes.
        // Locking the appointment's calendar day puts this path in the same
        // serialization domain as create_booking, REST and the staff portal.
        $day_locks = class_exists( 'YATOON_Booking_Lock' )
            ? YATOON_Booking_Lock::acquire( $start_dt )
            : array();
        if ( false === $day_locks ) {
            if ( $change_lock && class_exists( 'YATOON_Change_Service_Safety' ) ) {
                YATOON_Change_Service_Safety::release( $change_lock );
            }
            wp_send_json_error( array( 'message' => __( 'That calendar is being updated. Please try again.', 'yatoon-booking-system' ) ) );
        }
        if ( $day_locks ) {
            // Shutdown release keeps this correct across the ~20 exit points
            // this handler already has, without threading a release into each.
            register_shutdown_function( array( 'YATOON_Booking_Lock', 'release' ), $day_locks );
        }

        $items_raw = isset( $_POST['items'] ) ? wp_unslash( $_POST['items'] ) : '';
        if ( is_string( $items_raw ) && strlen( $items_raw ) > 32768 ) {
            wp_send_json_error( array( 'message' => __( 'Too many services were submitted. Please refresh and try again.', 'yatoon-booking-system' ) ) );
        }
        $items = is_string( $items_raw ) ? json_decode( $items_raw, true ) : $items_raw;
        if ( ! is_array( $items ) || empty( $items ) ) {
            wp_send_json_error( array( 'message' => __( 'Please choose at least one service.', 'yatoon-booking-system' ) ) );
        }
        if ( count( $items ) > 20 ) {
            wp_send_json_error( array( 'message' => __( 'Too many services were submitted. Please refresh and try again.', 'yatoon-booking-system' ) ) );
        }

        $active_by_id = array();
        foreach ( $rows as $row ) {
            $active_by_id[ intval( $row->id ) ] = $row;
        }

        $requested = array();
        foreach ( $items as $item ) {
            $booking_id = absint( $item['booking_id'] ?? 0 );
            $service_id = absint( $item['service_id'] ?? 0 );
            $option_id  = absint( $item['option_id'] ?? 0 );
            $staff_id   = absint( $item['staff_id'] ?? 0 );
            if ( ! $service_id ) {
                continue;
            }
            if ( $booking_id && ! isset( $active_by_id[ $booking_id ] ) ) {
                continue;
            }
            $requested[] = array(
                'booking_id' => $booking_id,
                'service_id' => $service_id,
                'option_id'  => $option_id,
                'staff_id'   => $staff_id,
            );
        }

        if ( empty( $requested ) ) {
            wp_send_json_error( array( 'message' => __( 'Please choose at least one service.', 'yatoon-booking-system' ) ) );
        }

        $square_ids = array_values( array_unique( array_filter( array_map( static function ( $row ) {
            return ! empty( $row->square_booking_id ) ? (string) $row->square_booking_id : '';
        }, $rows ) ) ) );
        $is_split_square = $platform === 'square' && count( $square_ids ) > 1;

        $prepared = array();
        $cursor = clone $start_dt;
        $square_cursors = array();
        if ( $is_split_square ) {
            foreach ( $rows as $row ) {
                $source_id = (string) ( $row->square_booking_id ?? '' );
                if ( '' === $source_id || isset( $square_cursors[ $source_id ] ) ) { continue;
                }
                $source_start = $this->parse_booking_datetime( $row->start_time, $local_tz );
                if ( $source_start ) { $square_cursors[ $source_id ] = $source_start;
                }
            }
        }
        foreach ( $requested as $idx => $request_item ) {
            $service = class_exists( 'YATOON_Manage_Service_Catalog' )
                ? YATOON_Manage_Service_Catalog::resolve( $request_item['service_id'], $request_item['option_id'] )
                : $this->get_active_service( $request_item['service_id'] );
            if ( ! $service ) {
                wp_send_json_error( array( 'message' => __( 'One of the selected services is no longer available.', 'yatoon-booking-system' ) ) );
            }

            $existing = $request_item['booking_id'] ? $active_by_id[ $request_item['booking_id'] ] : null;
            if ( $is_split_square && ! $existing ) {
                YATOON_Change_Service_Safety::release( $change_lock );
                wp_send_json_error( array( 'message' => __( 'Add new services separately for this multi-staff appointment. Existing services can still be replaced here.', 'yatoon-booking-system' ) ) );
            }
            $entry_start = clone $cursor;
            if ( $is_split_square && $existing ) {
                $source_id = (string) ( $existing->square_booking_id ?? '' );
                if ( $source_id && isset( $square_cursors[ $source_id ] ) ) { $entry_start = clone $square_cursors[ $source_id ];
                }
            }
            $duration = max( 1, intval( $service->duration_minutes ) );
            $end = ( clone $entry_start )->modify( "+{$duration} minutes" );
            $requested_staff_id = absint( $request_item['staff_id'] ?? 0 );
            $preferred_staff_id = $requested_staff_id ?: ( $existing ? intval( $existing->staff_id ) : 0 );

            $prepared[] = array(
                'booking_id' => intval( $request_item['booking_id'] ),
                'service'    => $service,
                'option_id'  => absint( $service->selected_option_id ?? 0 ),
                'staff'      => null,
                'requested_staff_id' => $requested_staff_id,
                'preferred_staff_id' => $preferred_staff_id,
                'start'      => clone $entry_start,
                'end'        => $end,
                'order'      => $idx,
                'location_id'=> absint( $existing->location_id ?? ( $rows[0]->location_id ?? 0 ) ),
                'source_square_booking_id' => (string) ( $existing->square_booking_id ?? '' ),
                'square_booking_id'        => (string) ( $existing->square_booking_id ?? '' ),
            );
            if ( $is_split_square && ! empty( $source_id ) ) { $square_cursors[ $source_id ] = clone $end;
            } else { $cursor = clone $end;
            }
        }

        $prepared = $this->assign_staff_to_prepared_services( $prepared, $rows, 'square' === $platform );
        if ( is_wp_error( $prepared ) ) {
            YATOON_Change_Service_Safety::release( $change_lock );
            wp_send_json_error( array( 'message' => $prepared->get_error_message() ) );
        }

        if ( class_exists( 'YATOON_Change_Service_Safety' ) ) {
            $safety = YATOON_Change_Service_Safety::validate_prepared( $prepared, $rows, $platform );
            if ( is_wp_error( $safety ) ) {
                YATOON_Change_Service_Safety::release( $change_lock );
                wp_send_json_error( array( 'message' => $safety->get_error_message() ) );
            }
        }

        $square_booking_id_for_save = '';
        $applied_square_plans = array();

        if ( $platform === 'square' && ! empty( $square_ids ) ) {
            if ( count( $square_ids ) > 1 ) {
                $rows_by_square = array();
                foreach ( $rows as $row ) { $rows_by_square[ (string) $row->square_booking_id ][] = $row;
                }
                $prepared_by_square = array();
                foreach ( $prepared as $entry ) {
                    $source_id = (string) ( $entry['source_square_booking_id'] ?? '' );
                    if ( $source_id === '' || ! isset( $rows_by_square[ $source_id ] ) ) {
                        YATOON_Change_Service_Safety::release( $change_lock );
                        wp_send_json_error( array( 'message' => __( 'This service could not be matched to its Square appointment. Please refresh and try again.', 'yatoon-booking-system' ) ) );
                    }
                    $prepared_by_square[ $source_id ][] = $entry;
                }
                $changed_square_ids = array();
                foreach ( $prepared_by_square as $source_id => $source_prepared ) {
                    if ( ! $this->services_change_is_noop( $source_prepared, $rows_by_square[ $source_id ] ) ) { $changed_square_ids[] = $source_id;
                    }
                }
                if ( empty( $changed_square_ids ) ) {
                    YATOON_Change_Service_Safety::release( $change_lock );
                    wp_send_json_success( array( 'message' => __( 'Your appointment services are already up to date.', 'yatoon-booking-system' ) ) );
                }
                // Square has no transaction spanning multiple booking IDs.
                // Prepare and validate every child first, then apply them as a
                // single customer action. If a later update fails, restore all
                // attempted children before touching the local database.
                $change_plans = array();
                foreach ( $changed_square_ids as $changed_square_id ) {
                    $changed_rows = $rows_by_square[ $changed_square_id ];
                    $changed_start = $prepared_by_square[ $changed_square_id ][0]['start'] ?? null;
                    $prepared_plan = $changed_start instanceof DateTime
                        ? $this->sync_square_changed_services( $changed_square_id, $prepared_by_square[ $changed_square_id ], $changed_start, $changed_rows, false, $changed_square_ids )
                        : new WP_Error( 'invalid_square_start', __( 'This Square appointment has an invalid start time.', 'yatoon-booking-system' ) );
                    if ( is_wp_error( $prepared_plan ) || empty( $prepared_plan['plan'] ) ) {
                        YATOON_Change_Service_Safety::release( $change_lock );
                        wp_send_json_error( array( 'message' => is_wp_error( $prepared_plan ) ? $prepared_plan->get_error_message() : __( 'Square could not prepare this appointment change. Nothing was changed.', 'yatoon-booking-system' ) ) );
                    }
                    $change_plans[] = $prepared_plan['plan'];
                }

                foreach ( $change_plans as $plan ) {
                    $sync_result = $this->apply_square_change_plan( $plan );
                    if ( is_wp_error( $sync_result ) ) {
                        $rollback_ok = true;
                        // A transport failure may have committed even when
                        // verification also failed, so restore that attempted
                        // child too. A definite Square rejection/version
                        // conflict must not overwrite a newer external edit.
                        $error_data = (array) $sync_result->get_error_data();
                        $rollback_plans = ! empty( $error_data['ambiguous'] )
                            ? array_merge( $applied_square_plans, array( $plan ) )
                            : $applied_square_plans;
                        foreach ( array_reverse( $rollback_plans ) as $rollback_plan ) {
                            if ( ! $this->rollback_square_change_plan( $rollback_plan ) ) { $rollback_ok = false;
                            }
                        }
                        if ( ! $rollback_ok ) {
                            $this->save_change_request_and_notify( $booking, $rows, $prepared, $sync_result->get_error_message() );
                            YATOON_Change_Service_Safety::release( $change_lock );
                            wp_send_json_error( array( 'message' => __( 'Square may have partially changed this appointment and automatic recovery could not be confirmed. The salon has been notified; please do not retry.', 'yatoon-booking-system' ) ) );
                        }
                        YATOON_Change_Service_Safety::release( $change_lock );
                        wp_send_json_error( array( 'message' => __( 'Square could not update every part of this appointment. The original appointment was restored and nothing was changed locally.', 'yatoon-booking-system' ) ) );
                    }
                    $applied_square_plans[] = $plan;
                }
            } else {
                if ( $this->services_change_is_noop( $prepared, $rows ) ) {
                    YATOON_Change_Service_Safety::release( $change_lock );
                    wp_send_json_success( array(
                        'message' => __( 'Your appointment services are already up to date.', 'yatoon-booking-system' ),
                    ) );
                }
                $square_booking_id_for_save = $square_ids[0];
                $sync_result = $this->sync_square_changed_services( $square_ids[0], $prepared, $start_dt, $rows );
                if ( is_wp_error( $sync_result ) ) {
                    if ( $sync_result->get_error_code() === 'square_update_failed' ) {
                        $this->save_change_request_and_notify( $booking, $rows, $prepared, $sync_result->get_error_message() );
                        YATOON_Change_Service_Safety::release( $change_lock );
                        wp_send_json_success( array(
                            'message' => __( 'Your change request has been sent. The salon will confirm the update shortly.', 'yatoon-booking-system' ),
                        ) );
                    }
                    YATOON_Change_Service_Safety::release( $change_lock );
                    wp_send_json_error( array( 'message' => $sync_result->get_error_message() ) );
                }
                if ( is_array( $sync_result ) && ! empty( $sync_result['square_booking_id'] ) ) {
                    $square_booking_id_for_save = (string) $sync_result['square_booking_id'];
                }
                foreach ( $prepared as &$prepared_entry ) { $prepared_entry['square_booking_id'] = $square_booking_id_for_save;
                }
                unset( $prepared_entry );
            }
        } elseif ( $platform === 'vagaro' && ! empty( $square_ids ) ) {
            wp_send_json_error( array(
                'message' => __( 'Changing services for Vagaro appointments is not available online yet. Please contact us for help.', 'yatoon-booking-system' ),
            ) );
        }

        // Re-check immediately before the local write. This closes the window
        // between the first availability lookup and saving the changed rows.
        if ( class_exists( 'YATOON_Change_Service_Safety' ) ) {
            $safety = YATOON_Change_Service_Safety::validate_prepared( $prepared, $rows, $platform );
            if ( is_wp_error( $safety ) ) {
                foreach ( array_reverse( $applied_square_plans ) as $rollback_plan ) {
                    $this->rollback_square_change_plan( $rollback_plan );
                }
                YATOON_Change_Service_Safety::release( $change_lock );
                wp_send_json_error( array( 'message' => $safety->get_error_message() ) );
            }
        }

        $this->save_changed_service_rows( $booking, $rows, $prepared, ( $platform === 'square' && ! empty( $square_booking_id_for_save ) ) ? $square_booking_id_for_save : '' );
        $verify_result = $this->verify_changed_service_rows( $prepared );
        if ( is_wp_error( $verify_result ) ) {
            if ( class_exists( 'YATOON_Change_Service_Safety' ) ) {
                YATOON_Change_Service_Safety::release( $change_lock );
            }
            wp_send_json_error( array( 'message' => $verify_result->get_error_message() ) );
        }

        if ( class_exists( 'YATOON_Change_Service_Safety' ) ) {
            YATOON_Change_Service_Safety::release( $change_lock );
        }

        wp_send_json_success( array(
            'message' => __( 'Your appointment services have been updated.', 'yatoon-booking-system' ),
        ) );
    }

    private function get_active_service( $service_id ) {
        global $wpdb;
        return yatoon_db_get_row( $wpdb->prepare(
            "SELECT * FROM {$wpdb->prefix}yatoon_services WHERE id = %d AND is_active = 1 LIMIT 1",
            absint( $service_id )
        ) );
    }

    private function services_change_is_noop( array $prepared, array $old_rows ) {
        if ( count( $prepared ) !== count( $old_rows ) ) {
            return false;
        }

        $old_by_id = array();
        foreach ( $old_rows as $old_row ) {
            $old_by_id[ intval( $old_row->id ) ] = $old_row;
        }

        foreach ( $prepared as $entry ) {
            $booking_id = intval( $entry['booking_id'] ?? 0 );
            if ( ! $booking_id || ! isset( $old_by_id[ $booking_id ] ) ) {
                return false;
            }

            $old_row = $old_by_id[ $booking_id ];
            if (
                intval( $old_row->service_id ) !== intval( $entry['service']->id )
                || intval( $old_row->staff_id ) !== intval( $entry['staff']->id )
                || ( class_exists( 'YATOON_Manage_Service_Catalog' )
                    && YATOON_Manage_Service_Catalog::current_main_option_id( $booking_id ) !== absint( $entry['option_id'] ?? 0 ) )
            ) {
                return false;
            }
        }

        return true;
    }

    private function square_staff_ids_for_variation( $variation_id ) {
        if ( ! $variation_id || ! class_exists( 'YATOON_Square_API' ) ) { return null;
        }
        $api = new YATOON_Square_API();
        $response = $api->get_team_member_booking_profiles();
        if ( is_wp_error( $response ) ) { return null;
        }
        $has_assignments = false;
        $allowed = array();
        foreach ( (array) ( $response['team_member_booking_profiles'] ?? array() ) as $profile ) {
            $ids = array_map( 'strval', (array) ( $profile['service_variation_ids'] ?? array() ) );
            if ( ! $ids ) { continue;
            }
            $has_assignments = true;
            if ( in_array( (string) $variation_id, $ids, true ) && ! empty( $profile['team_member_id'] ) ) {
                $allowed[ (string) $profile['team_member_id'] ] = true;
            }
        }
        return $has_assignments ? $allowed : null;
    }

    private function find_staff_for_service_window( $service_id, DateTime $start, DateTime $end, array $group_rows, $preferred_staff_id = 0, $strict_preferred = false, $allow_square_preferred = false, $variation_id = '' ) {
        $candidates = $this->staff_candidates_for_service_window( $service_id, $start, $end, $group_rows, $preferred_staff_id, $strict_preferred, $allow_square_preferred, $variation_id );
        return $candidates ? $candidates[0] : null;
    }

    /** Return every qualified/free candidate, preserving the preferred employee first. */
    private function staff_candidates_for_service_window( $service_id, DateTime $start, DateTime $end, array $group_rows, $preferred_staff_id = 0, $strict_preferred = false, $allow_square_preferred = false, $variation_id = '' ) {
        global $wpdb;

        $allowed_square_staff = $allow_square_preferred ? $this->square_staff_ids_for_variation( $variation_id ) : null;
        $is_eligible = function ( $staff ) use ( $allowed_square_staff, $service_id ) {
            if ( is_array( $allowed_square_staff ) ) { return isset( $allowed_square_staff[ (string) ( $staff->square_id ?? '' ) ] );
            }
            return $this->staff_can_do_service( absint( $staff->id ?? 0 ), $service_id );
        };
        $candidates = array();
        if ( $preferred_staff_id ) {
            $preferred = yatoon_db_get_row( $wpdb->prepare(
                "SELECT * FROM {$wpdb->prefix}yatoon_staff WHERE id = %d AND is_active = 1 AND is_bookable = 1 LIMIT 1",
                $preferred_staff_id
            ) );
            if ( $preferred && $is_eligible( $preferred ) ) {
                $candidates[] = $preferred;
            }
        }

        if ( $strict_preferred ) {
            if ( empty( $candidates ) ) {
                return array();
            }
            $preferred = $candidates[0];
            return $this->staff_window_available( intval( $preferred->id ), $start, $end, $group_rows ) ? array( $preferred ) : array();
        }

        $assigned = yatoon_db_get_results( $wpdb->prepare(
            "SELECT st.* 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",
            absint( $service_id )
        ) );
        foreach ( (array) $assigned as $staff ) {
            if ( ( ! $preferred_staff_id || intval( $staff->id ) !== intval( $preferred_staff_id ) ) && $is_eligible( $staff ) ) {
                $candidates[] = $staff;
            }
        }

        if ( empty( $candidates ) ) {
            $fallback = yatoon_db_get_results(
                "SELECT * FROM {$wpdb->prefix}yatoon_staff WHERE is_active = 1 AND is_bookable = 1 ORDER BY sort_order ASC, name ASC"
            );
            foreach ( (array) $fallback as $staff ) { if ( $is_eligible( $staff ) ) { $candidates[] = $staff;
            }
            }
        }

        $seen = array();
        $available = array();
        foreach ( (array) $candidates as $staff ) {
            if ( isset( $seen[ intval( $staff->id ) ] ) ) {
                continue;
            }
            $seen[ intval( $staff->id ) ] = true;
            if ( $this->staff_window_available( intval( $staff->id ), $start, $end, $group_rows ) ) {
                $available[] = $staff;
            }
        }

        return $available;
    }

    /**
     * Assign all Any-staff services together instead of independently choosing
     * the first candidate for every row. The most constrained service is
     * assigned first and backtracking finds a non-overlapping combination.
     */
    private function assign_staff_to_prepared_services( array $prepared, array $group_rows, $square_mode = false ) {
        $candidate_sets = array();
        foreach ( $prepared as $index => $entry ) {
            $service = $entry['service'];
            $requested_staff_id = absint( $entry['requested_staff_id'] ?? 0 );
            $preferred_staff_id = $requested_staff_id ?: absint( $entry['preferred_staff_id'] ?? 0 );
            $candidates = $this->staff_candidates_for_service_window(
                absint( $service->id ?? 0 ),
                $entry['start'],
                $entry['end'],
                $group_rows,
                $preferred_staff_id,
                $requested_staff_id > 0,
                $square_mode,
                (string) ( $service->selected_option_square_id ?? '' )
            );
            if ( empty( $candidates ) ) {
                return new WP_Error( 'no_staff_for_service', sprintf(
                    /* translators: %s: Service name. */
                    __( 'No available staff can take %s at the current appointment time. Please reschedule first or contact us for help.', 'yatoon-booking-system' ),
                    (string) ( $service->name ?? '' )
                ) );
            }
            $candidate_sets[ $index ] = $candidates;
        }

        $assigned = class_exists( 'YATOON_Change_Service_Safety' )
            ? YATOON_Change_Service_Safety::choose_staff_assignment( $prepared, $candidate_sets )
            : false;
        if ( false === $assigned ) {
            return new WP_Error( 'staff_assignment_conflict', __( 'The selected services cannot be covered by the available staff at the same time. Choose a specific employee for one service, shorten the services, or reschedule.', 'yatoon-booking-system' ) );
        }
        foreach ( $assigned as $index => $staff ) { $prepared[ $index ]['staff'] = $staff;
        }
        return $prepared;
    }

    private function staff_can_do_service( $staff_id, $service_id ) {
        global $wpdb;
        $assigned = yatoon_db_get_var( $wpdb->prepare(
            "SELECT id FROM {$wpdb->prefix}yatoon_staff_services WHERE staff_id = %d AND service_id = %d LIMIT 1",
            absint( $staff_id ),
            absint( $service_id )
        ) );
        return (bool) $assigned;
    }

    private function staff_window_available( $staff_id, DateTime $start, DateTime $end, array $group_rows ) {
        global $wpdb;
        $group_ids = array_map( static function ( $row ) {
            return intval( $row->id );
        }, $group_rows );
        $exclude_sql = '';
        if ( ! empty( $group_ids ) ) {
            $exclude_sql = ' AND id NOT IN (' . implode( ',', array_map( 'intval', $group_ids ) ) . ')';
        }

        $conflict = yatoon_db_get_var( $wpdb->prepare(
            "SELECT id FROM {$wpdb->prefix}yatoon_bookings
             WHERE staff_id = %d
               AND LOWER(COALESCE(status,'')) NOT IN ('cancelled','canceled','cancelled_by_customer','cancelled_by_seller','canceled_by_customer','canceled_by_seller','declined','no_show')
               AND start_time < %s
               AND end_time > %s
               {$exclude_sql}
             LIMIT 1",
            absint( $staff_id ),
            $end->format( 'Y-m-d H:i:s' ),
            $start->format( 'Y-m-d H:i:s' )
        ) );

        if ( $conflict ) {
            return false;
        }

        // A staff member with zero other bookings on a given day is trivially
        // "available" by the check above alone, even if they simply don't
        // work that day (time off, day off, etc.). The real booking form
        // already excludes staff on time off via YATOON_Business_Hours; this
        // path must use the same check, or Change Services can silently
        // hand a service to someone who isn't scheduled to work at all.
        if ( class_exists( 'YATOON_Business_Hours' ) ) {
            $local_tz = $start->getTimezone();
            if ( YATOON_Business_Hours::has_staff_time_off_conflict( $staff_id, $start, $end, $local_tz ) ) {
                return false;
            }
        }

        return true;
    }

    /**
     * Return slots that can fit every service row in an appointment group.
     */
    private function get_reschedule_slots_for_booking( $booking, $date ) {
        $rows = $this->get_related_bookings( $booking, true );
        if ( empty( $rows ) ) {
            return array();
        }

        $tz       = $this->get_booking_timezone( $booking );
        $base_row = $rows[0];
        $base_old = $this->parse_booking_datetime( $base_row->start_time, $tz );
        if ( ! $base_old ) {
            return array();
        }

        $exclude_ids = array_map( static function ( $row ) {
            return intval( $row->id );
        }, $rows );
        $slots = Yatoon_Business_Hours::get_available_slots_for_date(
            $date,
            intval( $base_row->service_id ),
            intval( $base_row->staff_id ),
            $exclude_ids,
            $tz
        );

        if ( count( $rows ) <= 1 ) {
            return $slots;
        }

        return array_values( array_filter( $slots, function ( $slot ) use ( $rows, $base_old, $tz ) {
            $new_base = $this->parse_booking_datetime( $slot['datetime'] ?? '', $tz );
            if ( ! $new_base ) {
                return false;
            }
            foreach ( $rows as $row ) {
                $old_start = $this->parse_booking_datetime( $row->start_time, $tz );
                $old_end   = $this->parse_booking_datetime( $row->end_time, $tz );
                if ( ! $old_start || ! $old_end ) {
                    return false;
                }
                $new_start = ( clone $new_base )->modify( ( $old_start->getTimestamp() - $base_old->getTimestamp() ) . ' seconds' );
                $new_end   = ( clone $new_start )->modify( ( $old_end->getTimestamp() - $old_start->getTimestamp() ) . ' seconds' );
                if ( ! $this->staff_window_available( intval( $row->staff_id ), $new_start, $new_end, $rows ) ) {
                    return false;
                }
            }
            return true;
        } ) );
    }

    private function get_square_staff_bookable_variation_ids( $square_api, $staff_square_id ) {
        $staff_square_id = (string) $staff_square_id;
        if ( $staff_square_id === '' || ! method_exists( $square_api, 'get_team_member_booking_profiles' ) ) {
            return array();
        }

        $profiles = $square_api->get_team_member_booking_profiles();
        if ( is_wp_error( $profiles ) || empty( $profiles['team_member_booking_profiles'] ) ) {
            return array();
        }

        foreach ( (array) $profiles['team_member_booking_profiles'] as $profile ) {
            if ( (string) ( $profile['team_member_id'] ?? '' ) !== $staff_square_id ) {
                continue;
            }
            return array_values( array_filter( array_map( 'strval', (array) ( $profile['service_variation_ids'] ?? array() ) ) ) );
        }

        return array();
    }

    private function add_square_variation_candidate( array &$candidates, $square_api, $variation_id, $version = 0, $duration = 0 ) {
        $variation_id = (string) $variation_id;
        if ( $variation_id === '' ) {
            return;
        }

        $cat = $square_api->get_catalog_object( $variation_id );
        if ( ! is_wp_error( $cat ) && isset( $cat['object']['type'] ) && $cat['object']['type'] === 'ITEM' ) {
            foreach ( (array) ( $cat['object']['item_data']['variations'] ?? array() ) as $variation ) {
                if ( ! empty( $variation['id'] ) ) {
                    $candidates[ (string) $variation['id'] ] = array(
                        'id'       => (string) $variation['id'],
                        'version'  => intval( $variation['version'] ?? 0 ),
                        'duration' => max( 1, intval( $duration ) ),
                    );
                }
            }
            return;
        }

        if ( ! $version && ! is_wp_error( $cat ) && isset( $cat['object']['version'] ) ) {
            $version = intval( $cat['object']['version'] );
        }

        $candidates[ $variation_id ] = array(
            'id'       => $variation_id,
            'version'  => intval( $version ),
            'duration' => max( 1, intval( $duration ) ),
        );
    }

    private function get_square_variation_for_service( $service, $square_api, $booking_id = 0, $staff_square_id = '', $preferred_variation_id = '' ) {
        global $wpdb;

        if ( ! $service ) {
            return null;
        }

        $candidates = array();
        if ( $preferred_variation_id !== '' ) {
            $this->add_square_variation_candidate( $candidates, $square_api, $preferred_variation_id, 0, intval( $service->duration_minutes ) );
        }

        if ( $booking_id ) {
            $booking_options = yatoon_db_get_results( $wpdb->prepare(
                "SELECT o.square_id, o.square_version, o.duration_minutes
                 FROM {$wpdb->prefix}yatoon_booking_options bo
                 INNER JOIN {$wpdb->prefix}yatoon_service_options o ON o.id = bo.option_id
                 WHERE bo.booking_id = %d
                   AND o.service_id = %d
                   AND o.square_id != ''
                   AND (o.group_name IS NULL OR o.group_name = '' OR o.group_name != 'Add-on')
                 ORDER BY bo.id ASC
                 LIMIT 5",
                absint( $booking_id ),
                intval( $service->id )
            ) );
            foreach ( (array) $booking_options as $option ) {
                $this->add_square_variation_candidate( $candidates, $square_api, $option->square_id, $option->square_version ?? 0, $option->duration_minutes ?: $service->duration_minutes );
            }
        }

        $options = yatoon_db_get_results( $wpdb->prepare(
            "SELECT square_id, square_version, duration_minutes
             FROM {$wpdb->prefix}yatoon_service_options
             WHERE service_id = %d
               AND square_id != ''
             ORDER BY CASE WHEN group_name = 'Add-on' THEN 1 ELSE 0 END, sort_order ASC, id ASC
             LIMIT 20",
            intval( $service->id )
        ) );
        foreach ( (array) $options as $option ) {
            $this->add_square_variation_candidate( $candidates, $square_api, $option->square_id, $option->square_version ?? 0, $option->duration_minutes ?: $service->duration_minutes );
        }

        if ( ! empty( $service->square_id ) ) {
            $this->add_square_variation_candidate( $candidates, $square_api, $service->square_id, $service->square_version ?? 0, $service->duration_minutes );
        }

        if ( empty( $candidates ) ) {
            return null;
        }

        $allowed_ids = $this->get_square_staff_bookable_variation_ids( $square_api, $staff_square_id );
        if ( $preferred_variation_id !== '' && isset( $candidates[ $preferred_variation_id ] ) ) {
            if ( empty( $allowed_ids ) || in_array( $preferred_variation_id, $allowed_ids, true ) ) {
                return $candidates[ $preferred_variation_id ];
            }
            return new WP_Error( 'square_staff_service_not_bookable', __( 'The selected staff member is not enabled for that service in Square. Please choose a different staff member or contact us for help.', 'yatoon-booking-system' ) );
        }

        if ( ! empty( $allowed_ids ) ) {
            $allowed = array_fill_keys( $allowed_ids, true );
            foreach ( $candidates as $candidate_id => $candidate ) {
                if ( isset( $allowed[ $candidate_id ] ) ) {
                    return $candidate;
                }
            }
            return new WP_Error( 'square_staff_service_not_bookable', __( 'The selected staff member is not enabled for that service in Square. Please choose a different staff member or contact us for help.', 'yatoon-booking-system' ) );
        }

        return reset( $candidates );
    }

    private function clean_square_segment_for_update( array $segment ) {
        $allowed = array(
            'duration_minutes'          => true,
            'service_variation_id'      => true,
            'service_variation_version' => true,
            'team_member_id'            => true,
        );

        $clean = array();
        foreach ( $segment as $key => $value ) {
            if ( isset( $allowed[ $key ] ) ) {
                $clean[ $key ] = $value;
            }
        }

        return $clean;
    }

    /**
     * Confirm that Square already contains the exact service/staff schedule
     * requested by Change Services. This recovers the common ambiguous-write
     * case where Square committed a PUT but WordPress received a timeout or an
     * unusable response and would otherwise create a false pending request.
     */
    private function square_booking_matches_segments( array $booking, array $expected_segments ) {
        $actual_segments = isset( $booking['appointment_segments'] ) && is_array( $booking['appointment_segments'] )
            ? array_values( $booking['appointment_segments'] )
            : array();
        $expected_segments = array_values( $expected_segments );

        if ( count( $actual_segments ) !== count( $expected_segments ) ) {
            return false;
        }

        foreach ( $expected_segments as $index => $expected ) {
            $actual = $actual_segments[ $index ] ?? array();
            if (
                (string) ( $actual['service_variation_id'] ?? '' ) !== (string) ( $expected['service_variation_id'] ?? '' )
                || (string) ( $actual['team_member_id'] ?? '' ) !== (string) ( $expected['team_member_id'] ?? '' )
                || intval( $actual['duration_minutes'] ?? 0 ) !== intval( $expected['duration_minutes'] ?? 0 )
            ) {
                return false;
            }
        }

        return true;
    }

    private function recover_applied_square_change( $square_api, $square_booking_id, array $expected_segments, $update_response = null ) {
        $booking = is_array( $update_response ) && ! empty( $update_response['booking'] )
            ? $update_response['booking']
            : null;

        if ( ! is_array( $booking ) || ! $this->square_booking_matches_segments( $booking, $expected_segments ) ) {
            $verify_response = $square_api->get_booking( $square_booking_id );
            $booking = ! is_wp_error( $verify_response ) && ! empty( $verify_response['booking'] )
                ? $verify_response['booking']
                : null;
        }

        if ( ! is_array( $booking ) || ! $this->square_booking_matches_segments( $booking, $expected_segments ) ) {
            return false;
        }

        return true;
    }

    private function square_errors_to_message( $errors ) {
        $parts = array();
        foreach ( (array) $errors as $error ) {
            if ( ! is_array( $error ) ) {
                continue;
            }
            $piece = $error['detail'] ?? $error['code'] ?? $error['field'] ?? '';
            if ( ! empty( $error['field'] ) && strpos( (string) $piece, (string) $error['field'] ) === false ) {
                $piece = $error['field'] . ': ' . $piece;
            }
            if ( $piece !== '' ) {
                $parts[] = $piece;
            }
        }
        return implode( '; ', array_filter( $parts ) );
    }

    private function build_square_booking_payload_from_response( array $booking, array $segments, DateTime $start_dt ) {
        $start_utc = ( clone $start_dt )->setTimezone( new DateTimeZone( 'UTC' ) );
        $payload = array(
            'location_id'          => $booking['location_id'] ?? get_option( 'yatoon_square_location_id' ),
            'customer_id'          => $booking['customer_id'] ?? '',
            'start_at'             => $start_utc->format( 'Y-m-d\TH:i:s\Z' ),
            'appointment_segments' => $segments,
        );

        foreach ( array( 'customer_note', 'seller_note' ) as $field ) {
            if ( ! empty( $booking[ $field ] ) ) {
                $payload[ $field ] = $booking[ $field ];
            }
        }

        return array_filter( $payload, static function ( $value ) {
            return $value !== '' && $value !== null;
        } );
    }

    /** Final live Square conflict check, excluding every booking changed in this save. */
    private function square_staff_windows_free( $square_api, array $prepared, $current_square_booking_ids ) {
        if ( empty( $prepared ) || ! method_exists( $square_api, 'list_bookings' ) ) { return true;
        }
        $first = clone $prepared[0]['start'];
        $last = clone $prepared[ count( $prepared ) - 1 ]['end'];
        $range_start = ( clone $first )->setTime( 0, 0 )->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d\TH:i:s\Z' );
        $range_end = ( clone $last )->setTime( 23, 59, 59 )->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d\TH:i:s\Z' );
        $bookings = array();
        $cursor = null;
        $pages = 0;
        do {
            $response = $square_api->list_bookings( $range_start, $range_end, $cursor );
            if ( is_wp_error( $response ) ) {
                return new WP_Error( 'square_availability_check_failed', __( 'Square could not confirm this opening right now, so the appointment was not changed. Try again in a moment or ask the salon to sync Square.', 'yatoon-booking-system' ) );
            }
            $bookings = array_merge( $bookings, (array) ( $response['bookings'] ?? array() ) );
            $cursor = (string) ( $response['cursor'] ?? '' );
            ++$pages;
        } while ( $cursor && $pages < 10 );

        $requested = array();
        foreach ( $prepared as $entry ) {
            $staff_square_id = (string) ( $entry['staff']->square_id ?? '' );
            if ( $staff_square_id ) {
                $requested[ $staff_square_id ][] = array(
                    ( clone $entry['start'] )->setTimezone( new DateTimeZone( 'UTC' ) ),
                    ( clone $entry['end'] )->setTimezone( new DateTimeZone( 'UTC' ) ),
                );
            }
        }
        $excluded_square_ids = array_fill_keys( array_filter( array_map( 'strval', (array) $current_square_booking_ids ) ), true );
        foreach ( $bookings as $booking ) {
            if ( isset( $excluded_square_ids[ (string) ( $booking['id'] ?? '' ) ] ) ) { continue;
            }
            if ( in_array( strtoupper( (string) ( $booking['status'] ?? '' ) ), array( 'CANCELLED_BY_CUSTOMER', 'CANCELLED_BY_SELLER', 'DECLINED', 'NO_SHOW' ), true ) || empty( $booking['start_at'] ) ) { continue;
            }
            try { $busy_start = new DateTime( $booking['start_at'] );
} catch ( Exception $e ) { continue; }
            foreach ( (array) ( $booking['appointment_segments'] ?? array() ) as $segment ) {
                $busy_end = ( clone $busy_start )->modify( '+' . max( 1, absint( $segment['duration_minutes'] ?? 0 ) ) . ' minutes' );
                $staff_square_id = (string) ( $segment['team_member_id'] ?? '' );
                foreach ( (array) ( $requested[ $staff_square_id ] ?? array() ) as $window ) {
                    if ( $busy_start < $window[1] && $busy_end > $window[0] ) {
                        return new WP_Error( 'square_staff_conflict', __( 'That staff member is no longer free for this time. The appointment was not changed; choose another available staff member or Any staff.', 'yatoon-booking-system' ) );
                    }
                }
                $busy_start = clone $busy_end;
            }
        }
        return true;
    }

    private function recreate_square_booking_after_change( $square_api, $square_booking_id, array $current_booking, array $new_segments, DateTime $start_dt, $version ) {
        $old_segments = array();
        foreach ( (array) ( $current_booking['appointment_segments'] ?? array() ) as $segment ) {
            if ( is_array( $segment ) ) {
                $old_segments[] = $this->clean_square_segment_for_update( $segment );
            }
        }

        $cancel_resp = $square_api->cancel_booking( $square_booking_id, $version );
        if ( is_wp_error( $cancel_resp ) ) {
            return new WP_Error( 'square_replace_cancel_failed', sprintf(
                /* translators: %s: Square error message. */
                __( 'Square could not replace the booking: %s', 'yatoon-booking-system' ),
                $cancel_resp->get_error_message()
            ) );
        }

        $create_payload = $this->build_square_booking_payload_from_response( $current_booking, $new_segments, $start_dt );
        $create_resp = $square_api->create_booking( $create_payload );
        if ( is_wp_error( $create_resp ) || empty( $create_resp['booking']['id'] ) ) {
            if ( ! empty( $old_segments ) ) {
                $restore_payload = $this->build_square_booking_payload_from_response( $current_booking, $old_segments, $start_dt );
                $square_api->create_booking( $restore_payload );
            }
            $message = is_wp_error( $create_resp )
                ? $create_resp->get_error_message()
                : $this->square_errors_to_message( $create_resp['errors'] ?? array() );
            return new WP_Error( 'square_replace_create_failed', sprintf(
                /* translators: %s: Square error message. */
                __( 'Square could not create the changed appointment: %s', 'yatoon-booking-system' ),
                $message ?: __( 'Please contact us for help.', 'yatoon-booking-system' )
            ) );
        }

        return array(
            'square_booking_id' => (string) $create_resp['booking']['id'],
            'mode'              => 'recreated',
        );
    }

    private function sync_square_changed_services( $square_booking_id, array $prepared, DateTime $start_dt, array $old_rows = array(), $execute = true, array $exclude_square_ids = array() ) {
        if ( ! class_exists( 'YATOON_Square_API' ) ) {
            return new WP_Error( 'square_unavailable', __( 'Square is not available. Please contact us to change services.', 'yatoon-booking-system' ) );
        }

        $square_api = new YATOON_Square_API();
        $sq_resp = $square_api->get_booking( $square_booking_id );
        if ( is_wp_error( $sq_resp ) || empty( $sq_resp['booking'] ) || ! is_array( $sq_resp['booking'] ) ) {
            $details = is_wp_error( $sq_resp ) ? $sq_resp->get_error_message() : __( 'Square returned no booking data.', 'yatoon-booking-system' );
            return new WP_Error( 'square_booking_load_failed', sprintf(
                /* translators: %s: Square error message. */
                __( 'Square could not verify the existing appointment: %s', 'yatoon-booking-system' ),
                $details
            ) );
        }
        $version = null;
        $current_segments = array();
        $version = isset( $sq_resp['booking']['version'] ) ? intval( $sq_resp['booking']['version'] ) : null;
        $current_segments = isset( $sq_resp['booking']['appointment_segments'] ) && is_array( $sq_resp['booking']['appointment_segments'] )
            ? $sq_resp['booking']['appointment_segments']
            : array();

        $old_by_id = array();
        $old_index_by_id = array();
        foreach ( array_values( $old_rows ) as $old_index => $old_row ) {
            $old_by_id[ intval( $old_row->id ) ] = $old_row;
            $old_index_by_id[ intval( $old_row->id ) ] = $old_index;
        }

        $segments = array();
        foreach ( $prepared as $entry ) {
            $service = $entry['service'];
            $staff   = $entry['staff'];
            $booking_id = intval( $entry['booking_id'] ?? 0 );
            if ( $booking_id && isset( $old_by_id[ $booking_id ], $old_index_by_id[ $booking_id ] ) ) {
                $old_row = $old_by_id[ $booking_id ];
                $old_index = $old_index_by_id[ $booking_id ];
                if (
                    intval( $old_row->service_id ) === intval( $service->id )
                    && intval( $old_row->staff_id ) === intval( $staff->id )
                    && ( ! class_exists( 'YATOON_Manage_Service_Catalog' )
                        || YATOON_Manage_Service_Catalog::current_main_option_id( $booking_id ) === absint( $entry['option_id'] ?? 0 ) )
                    && isset( $current_segments[ $old_index ] )
                    && ! empty( $current_segments[ $old_index ]['service_variation_id'] )
                ) {
                    $segments[] = $this->clean_square_segment_for_update( $current_segments[ $old_index ] );
                    continue;
                }
            }

            $selected_variation_id = (string) ( $service->selected_option_square_id ?? '' );
            $preferred_variation_id = $selected_variation_id ?: ( (
                $booking_id
                && isset( $old_by_id[ $booking_id ], $old_index_by_id[ $booking_id ], $current_segments[ $old_index_by_id[ $booking_id ] ]['service_variation_id'] )
                && intval( $old_by_id[ $booking_id ]->service_id ) === intval( $service->id )
            )
                ? (string) $current_segments[ $old_index_by_id[ $booking_id ] ]['service_variation_id']
                : '' );
            $variation = $this->get_square_variation_for_service( $service, $square_api, intval( $entry['booking_id'] ?? 0 ), (string) ( $staff->square_id ?? '' ), $preferred_variation_id );
            if ( is_wp_error( $variation ) ) {
                return $variation;
            }
            if ( ! $variation || empty( $variation['id'] ) || empty( $staff->square_id ) ) {
                return new WP_Error( 'square_mapping_missing', __( 'One of the selected services or staff members is not fully synced with Square. Please contact us to change this appointment.', 'yatoon-booking-system' ) );
            }
            $segment = array(
                'duration_minutes'     => max( 1, intval( $variation['duration'] ) ),
                'service_variation_id' => (string) $variation['id'],
                'team_member_id'       => (string) $staff->square_id,
            );
            if ( ! empty( $variation['version'] ) ) {
                $segment['service_variation_version'] = intval( $variation['version'] );
            }
            $segments[] = $this->clean_square_segment_for_update( $segment );
        }

        if ( empty( $exclude_square_ids ) ) { $exclude_square_ids = array( $square_booking_id );
        }
        $live_safety = $this->square_staff_windows_free( $square_api, $prepared, $exclude_square_ids );
        if ( is_wp_error( $live_safety ) ) { return $live_safety;
        }

        $start_utc = ( clone $start_dt )->setTimezone( new DateTimeZone( 'UTC' ) );
        $idempotency_key = 'yatoon-change-' . substr( hash( 'sha256', $square_booking_id . '|' . $start_utc->format( 'c' ) . '|' . wp_json_encode( $segments ) ), 0, 48 );
        $plan = array(
            'api'                   => $square_api,
            'square_booking_id'     => (string) $square_booking_id,
            'version'               => $version,
            'start_at'              => $start_utc->format( 'Y-m-d\TH:i:s\Z' ),
            'segments'              => $segments,
            'idempotency_key'       => $idempotency_key,
            'original_booking'      => $sq_resp['booking'],
        );
        if ( ! $execute ) {
            return array( 'square_booking_id' => (string) $square_booking_id, 'mode' => 'prepared', 'plan' => $plan );
        }

        return $this->apply_square_change_plan( $plan );
    }

    private function apply_square_change_plan( array $plan ) {
        $square_api = $plan['api'];
        $square_booking_id = (string) $plan['square_booking_id'];
        $segments = (array) $plan['segments'];
        $update_resp = $square_api->update_booking(
            $square_booking_id,
            array(
                'start_at'             => (string) $plan['start_at'],
                'appointment_segments' => $segments,
            ),
            $plan['version'],
            (string) $plan['idempotency_key']
        );

        if ( is_wp_error( $update_resp ) ) {
            if ( $this->recover_applied_square_change( $square_api, $square_booking_id, $segments ) ) {
                return array(
                    'square_booking_id' => (string) $square_booking_id,
                    'mode'              => 'verified_after_ambiguous_response',
                );
            }
            return new WP_Error( 'square_update_failed', sprintf(
                /* translators: %s: Square error message. */
                __( 'Square could not update the services: %s', 'yatoon-booking-system' ),
                $update_resp->get_error_message()
            ), array( 'ambiguous' => true ) );
        }
        if ( isset( $update_resp['errors'] ) ) {
            if ( $this->recover_applied_square_change( $square_api, $square_booking_id, $segments, $update_resp ) ) {
                return array(
                    'square_booking_id' => (string) $square_booking_id,
                    'mode'              => 'verified_after_ambiguous_response',
                );
            }
            $details = $this->square_errors_to_message( $update_resp['errors'] );
            return new WP_Error( 'square_update_failed', $details ? sprintf(
                /* translators: %s: Square error message. */
                __( 'Square could not update the services: %s', 'yatoon-booking-system' ),
                $details
            ) : __( 'Square could not update the services. Please contact us for help.', 'yatoon-booking-system' ) );
        }

        return array(
            'square_booking_id' => (string) $square_booking_id,
            'mode'              => 'updated',
        );
    }

    /** Restore the exact start and segments captured before a multi-booking save. */
    private function rollback_square_change_plan( array $plan ) {
        $square_api = $plan['api'];
        $square_booking_id = (string) $plan['square_booking_id'];
        $original = (array) ( $plan['original_booking'] ?? array() );
        $original_segments = array();
        foreach ( (array) ( $original['appointment_segments'] ?? array() ) as $segment ) {
            if ( is_array( $segment ) ) { $original_segments[] = $this->clean_square_segment_for_update( $segment );
            }
        }
        if ( empty( $original['start_at'] ) || empty( $original_segments ) ) { return false;
        }

        $current = $square_api->get_booking( $square_booking_id );
        if ( is_wp_error( $current ) || empty( $current['booking'] ) ) { return false;
        }
        $current_version = isset( $current['booking']['version'] ) ? intval( $current['booking']['version'] ) : null;
        $idempotency_key = 'yatoon-rollback-' . substr( hash( 'sha256', $square_booking_id . '|' . $original['start_at'] . '|' . wp_json_encode( $original_segments ) ), 0, 46 );
        $response = $square_api->update_booking(
            $square_booking_id,
            array(
                'start_at'             => (string) $original['start_at'],
                'appointment_segments' => $original_segments,
            ),
            $current_version,
            $idempotency_key
        );
        $restored = ! is_wp_error( $response ) && empty( $response['errors'] ) && ! empty( $response['booking'] )
            ? $response['booking']
            : null;
        if ( ! is_array( $restored ) ) {
            $verify = $square_api->get_booking( $square_booking_id );
            $restored = ! is_wp_error( $verify ) && ! empty( $verify['booking'] ) ? $verify['booking'] : null;
        }
        return is_array( $restored )
            && $this->square_booking_matches_segments( $restored, $original_segments )
            && strtotime( (string) ( $restored['start_at'] ?? '' ) ) === strtotime( (string) $original['start_at'] );
    }

    private function format_change_request_lines( array $old_rows, array $prepared ) {
        $old_lines = array();
        foreach ( array_values( $old_rows ) as $idx => $row ) {
            $old_lines[] = sprintf(
                '%d. %s - %s (%s - %s)',
                $idx + 1,
                $row->service_name ?? '-',
                $row->staff_name ?? '-',
                $row->start_time ?? '',
                $row->end_time ?? ''
            );
        }

        $new_lines = array();
        foreach ( array_values( $prepared ) as $idx => $entry ) {
            $new_lines[] = sprintf(
                '%d. %s - %s (%s - %s)',
                $idx + 1,
                $entry['service']->name ?? '-',
                $entry['staff']->name ?? '-',
                $entry['start']->format( 'Y-m-d H:i:s' ),
                $entry['end']->format( 'Y-m-d H:i:s' )
            );
        }

        return array(
            'old' => $old_lines,
            'new' => $new_lines,
        );
    }

    private function ensure_change_requests_table() {
        global $wpdb;
        return $wpdb->prefix . 'yatoon_change_requests';
    }

    private function save_change_request_and_notify( $booking, array $old_rows, array $prepared, $error_message = '' ) {
        global $wpdb;

        $table = $this->ensure_change_requests_table();
        $lines = $this->format_change_request_lines( $old_rows, $prepared );

        $old_data = array_map( static function ( $row ) {
            return array(
                'booking_id' => intval( $row->id ),
                'service_id' => intval( $row->service_id ),
                'service'    => (string) ( $row->service_name ?? '' ),
                'staff_id'   => intval( $row->staff_id ),
                'staff'      => (string) ( $row->staff_name ?? '' ),
                'start_time' => (string) ( $row->start_time ?? '' ),
                'end_time'   => (string) ( $row->end_time ?? '' ),
            );
        }, $old_rows );

        $requested_data = array_map( static function ( $entry ) {
            return array(
                'booking_id' => intval( $entry['booking_id'] ?? 0 ),
                'service_id' => intval( $entry['service']->id ?? 0 ),
                'service'    => (string) ( $entry['service']->name ?? '' ),
                'staff_id'   => intval( $entry['staff']->id ?? 0 ),
                'staff'      => (string) ( $entry['staff']->name ?? '' ),
                'start_time' => $entry['start']->format( 'Y-m-d H:i:s' ),
                'end_time'   => $entry['end']->format( 'Y-m-d H:i:s' ),
            );
        }, $prepared );

        yatoon_db_insert(
            $table,
            array(
                'booking_id'        => intval( $booking->id ),
                'group_booking_id'  => (string) ( $booking->group_booking_id ?? '' ),
                'square_booking_id' => (string) ( $booking->square_booking_id ?? '' ),
                'customer_name'     => (string) ( $booking->customer_name ?? '' ),
                'customer_email'    => (string) ( $booking->customer_email ?? '' ),
                'customer_phone'    => (string) ( $booking->customer_phone ?? '' ),
                'request_type'      => 'change_services',
                'status'            => 'pending',
                'old_data'          => wp_json_encode( $old_data ),
                'requested_data'    => wp_json_encode( $requested_data ),
                'error_message'     => (string) $error_message,
                'created_at'        => current_time( 'mysql' ),
            ),
            array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
        );

        $request_id = intval( $wpdb->insert_id );
        $this->notify_admin_change_request( $booking, $lines, $error_message, $request_id );
        return $request_id;
    }

    private function notify_admin_change_request( $booking, array $lines, $error_message = '', $request_id = 0 ) {
        $admin_email   = get_option( 'yatoon_contact_email', get_option( 'admin_email' ) );
        $business_name = get_option( 'yatoon_business_name', get_bloginfo( 'name' ) );
        $subject = sprintf( '[%s] Appointment Change Request #%d', $business_name, intval( $request_id ) );
        $body = sprintf(
            "A customer requested a service/staff change, but Square could not update the appointment automatically.\n\nCustomer: %s\nEmail: %s\nPhone: %s\nSquare Booking ID: %s\nLocal Booking ID: %s\n\nCurrent appointment:\n%s\n\nRequested change:\n%s\n\nSquare response:\n%s\n\nPlease edit this appointment manually in Square and confirm with the customer.",
            $booking->customer_name ?? '',
            $booking->customer_email ?? '',
            $booking->customer_phone ?? '',
            $booking->square_booking_id ?? '',
            $booking->id ?? '',
            implode( "\n", (array) ( $lines['old'] ?? array() ) ),
            implode( "\n", (array) ( $lines['new'] ?? array() ) ),
            $error_message ?: 'N/A'
        );

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

    private function save_changed_service_rows( $booking, array $old_rows, array &$prepared, $square_booking_id = '' ) {
        global $wpdb;
        $table = $wpdb->prefix . 'yatoon_bookings';
        $group_booking_id = ! empty( $booking->group_booking_id ) ? $booking->group_booking_id : wp_generate_uuid4();
        $kept_ids = array();
        $old_by_id = array();
        foreach ( $old_rows as $old_row ) {
            $old_by_id[ intval( $old_row->id ) ] = $old_row;
        }

        foreach ( $prepared as &$entry ) {
            $service = $entry['service'];
            $staff   = $entry['staff'];
            $existing_row = ! empty( $entry['booking_id'] ) ? ( $old_by_id[ intval( $entry['booking_id'] ) ] ?? null ) : null;
            $total_price = floatval( $service->price );
            // A no-op companion row in a parallel appointment may include
            // Custom Design/add-on pricing that is not part of the main
            // variation price. Preserve its frozen total while another
            // customer's Square booking is being changed.
            if ( $existing_row
                && intval( $existing_row->service_id ) === intval( $service->id )
                && ( ! class_exists( 'YATOON_Manage_Service_Catalog' )
                    || YATOON_Manage_Service_Catalog::current_main_option_id( $existing_row->id ) === absint( $entry['option_id'] ?? 0 ) ) ) {
                $total_price = floatval( $existing_row->total_price );
            }
            $data = array(
                'service_id'        => intval( $service->id ),
                'staff_id'          => intval( $staff->id ),
                'start_time'        => $entry['start']->format( 'Y-m-d H:i:s' ),
                'end_time'          => $entry['end']->format( 'Y-m-d H:i:s' ),
                'total_price'       => $total_price,
                'group_booking_id'  => $group_booking_id,
                'service_order'     => intval( $entry['order'] ),
            );
            $entry_square_booking_id = (string) ( $entry['square_booking_id'] ?? $square_booking_id );
            if ( $entry_square_booking_id ) {
                $data['square_booking_id'] = $entry_square_booking_id;
                $data['square_synced'] = 1;
            }

            $saved_booking_id = 0;
            if ( ! empty( $entry['booking_id'] ) ) {
                $saved_booking_id = intval( $entry['booking_id'] );
                yatoon_db_update(
                    $table,
                    $data,
                    array( 'id' => intval( $entry['booking_id'] ) ),
                    $entry_square_booking_id
                        ? array( '%d', '%d', '%s', '%s', '%f', '%s', '%d', '%s', '%d' )
                        : array( '%d', '%d', '%s', '%s', '%f', '%s', '%d' ),
                    array( '%d' )
                );
                $kept_ids[] = $saved_booking_id;
            } else {
                $source = $old_rows[0];
                $insert = array_merge( array(
                    'customer_name'     => $source->customer_name,
                    'customer_email'    => $source->customer_email,
                    'customer_phone'    => $source->customer_phone,
                    'status'            => $source->status,
                    'notes'             => $source->notes,
                    'payment_status'    => $source->payment_status,
                    'is_any_staff'      => 0,
                ), $data );
                yatoon_db_insert( $table, $insert );
                $saved_booking_id = intval( $wpdb->insert_id );
                $kept_ids[] = $saved_booking_id;
            }
            $entry['booking_id'] = $saved_booking_id;

            if ( $saved_booking_id && class_exists( 'YATOON_Manage_Service_Catalog' ) ) {
                $existing = ! empty( $entry['booking_id'] ) ? ( $old_by_id[ $saved_booking_id ] ?? null ) : null;
                $old_option_id = $existing ? YATOON_Manage_Service_Catalog::current_main_option_id( $saved_booking_id ) : 0;
                $new_option_id = absint( $entry['option_id'] ?? 0 );
                if ( ! $existing || intval( $existing->service_id ) !== intval( $service->id ) || $old_option_id !== $new_option_id ) {
                    YATOON_Manage_Service_Catalog::replace_booking_options( $saved_booking_id, $new_option_id );
                }
            }
        }
        unset( $entry );

        foreach ( $old_rows as $old_row ) {
            if ( ! in_array( intval( $old_row->id ), $kept_ids, true ) ) {
                $cancel_data = array(
                    'status' => 'cancelled',
                );
                $cancel_formats = array( '%s' );
                if ( $this->booking_column_exists( 'cancelled_at' ) ) {
                    $cancel_data['cancelled_at'] = current_time( 'mysql' );
                    $cancel_formats[] = '%s';
                }
                yatoon_db_update(
                    $table,
                    $cancel_data,
                    array( 'id' => intval( $old_row->id ) ),
                    $cancel_formats,
                    array( '%d' )
                );
            }
        }
    }

    private function verify_changed_service_rows( array $prepared ) {
        global $wpdb;

        foreach ( $prepared as $entry ) {
            $booking_id = intval( $entry['booking_id'] ?? 0 );
            if ( ! $booking_id ) {
                continue;
            }

            $row = yatoon_db_get_row( $wpdb->prepare(
                "SELECT service_id, staff_id, start_time, end_time
                 FROM {$wpdb->prefix}yatoon_bookings
                 WHERE id = %d
                 LIMIT 1",
                $booking_id
            ) );

            if ( ! $row ) {
                return new WP_Error( 'local_sync_missing_row', __( 'Square was updated, but the local appointment row could not be found. Please refresh or contact us.', 'yatoon-booking-system' ) );
            }

            $expected_start = $entry['start']->format( 'Y-m-d H:i:s' );
            $expected_end   = $entry['end']->format( 'Y-m-d H:i:s' );
            if (
                intval( $row->service_id ) !== intval( $entry['service']->id )
                || intval( $row->staff_id ) !== intval( $entry['staff']->id )
                || (string) $row->start_time !== $expected_start
                || (string) $row->end_time !== $expected_end
            ) {
                $updated = yatoon_db_update(
                    $wpdb->prefix . 'yatoon_bookings',
                    array(
                        'service_id' => intval( $entry['service']->id ),
                        'staff_id'   => intval( $entry['staff']->id ),
                        'start_time' => $expected_start,
                        'end_time'   => $expected_end,
                        'updated_at' => current_time( 'mysql' ),
                    ),
                    array( 'id' => $booking_id ),
                    array( '%d', '%d', '%s', '%s', '%s' ),
                    array( '%d' )
                );

                if ( $updated === false ) {
                    return new WP_Error( 'local_sync_failed', __( 'Square was updated, but the local appointment display could not be refreshed. Please contact us.', 'yatoon-booking-system' ) );
                }
            }

            if ( class_exists( 'YATOON_Manage_Service_Catalog' ) ) {
                $expected_option_id = absint( $entry['option_id'] ?? 0 );
                if ( YATOON_Manage_Service_Catalog::current_main_option_id( $booking_id ) !== $expected_option_id
                    && ! YATOON_Manage_Service_Catalog::replace_booking_options( $booking_id, $expected_option_id ) ) {
                    return new WP_Error( 'local_option_sync_failed', __( 'The service was updated, but its selected variation could not be saved. Please contact us.', 'yatoon-booking-system' ) );
                }
            }
        }

        return true;
    }

    // --- AJAX: Cancel --------------------------------------------------------

    public function ajax_cancel() {
        check_ajax_referer( 'yatoon_manage_booking', 'nonce' );
        if ( function_exists( 'yatoon_public_rate_limit' ) ) {
            yatoon_public_rate_limit( 'manage_cancel', 6, 10 );
        }
        global $wpdb;

        $token   = sanitize_text_field( wp_unslash( $_POST['token'] ?? '' ) );
        $booking = $this->get_booking_by_token( $token );

        if ( ! $booking ) {
            wp_send_json_error( array( 'message' => __( 'Invalid or expired link.', 'yatoon-booking-system' ) ) );
        }
        $booking = $this->refresh_external_status_if_needed( $booking );

        $active_rows = $this->get_related_bookings( $booking, true );
        if ( empty( $active_rows ) ) {
            wp_send_json_error( array( 'message' => __( 'This appointment is already cancelled.', 'yatoon-booking-system' ) ) );
        }

        $requested_ids_raw = isset( $_POST['booking_ids'] ) ? wp_unslash( $_POST['booking_ids'] ) : '';
        $requested_ids = array();
        if ( is_string( $requested_ids_raw ) && $requested_ids_raw !== '' ) {
            $decoded = json_decode( $requested_ids_raw, true );
            if ( is_array( $decoded ) ) {
                $requested_ids = array_map( 'absint', $decoded );
            }
        } elseif ( is_array( $requested_ids_raw ) ) {
            $requested_ids = array_map( 'absint', $requested_ids_raw );
        }
        $requested_ids = array_values( array_filter( array_unique( $requested_ids ) ) );

        $allowed_rows = array();
        foreach ( $active_rows as $row ) {
            $allowed_rows[ intval( $row->id ) ] = $row;
        }

        if ( ! empty( $requested_ids ) ) {
            $rows_to_cancel = array();
            foreach ( $requested_ids as $requested_id ) {
                if ( isset( $allowed_rows[ $requested_id ] ) ) {
                    $rows_to_cancel[] = $allowed_rows[ $requested_id ];
                }
            }
            if ( empty( $rows_to_cancel ) ) {
                wp_send_json_error( array( 'message' => __( 'Please select at least one appointment item to cancel.', 'yatoon-booking-system' ) ) );
            }
        } else {
            $rows_to_cancel = $active_rows;
        }

        $selected_ids = array_map( static function ( $row ) {
            return intval( $row->id );
        }, $rows_to_cancel );

        foreach ( $rows_to_cancel as $row_to_cancel ) {
            if ( empty( $row_to_cancel->square_booking_id ) ) {
                continue;
            }
            foreach ( $active_rows as $active_row ) {
                if (
                    ! empty( $active_row->square_booking_id )
                    && $active_row->square_booking_id === $row_to_cancel->square_booking_id
                    && ! in_array( intval( $active_row->id ), $selected_ids, true )
                ) {
                    wp_send_json_error( array(
                        'message' => __( 'Some selected services are linked together in one Square appointment. Please select all linked services for that time, or contact us for help changing only part of it.', 'yatoon-booking-system' ),
                    ) );
                }
            }
        }

        $local_tz = $this->get_booking_timezone( $booking );
        $start_dt = $this->parse_booking_datetime( $booking->start_time, $local_tz );
        if ( ! $start_dt ) {
            wp_send_json_error( array( 'message' => __( 'This booking has an invalid scheduled time.', 'yatoon-booking-system' ) ) );
        }
        $min_hours = intval( get_option( 'yatoon_manage_min_hours_before', 2 ) );
        if ( $min_hours > 0 ) {
            $cutoff = new DateTime( 'now', $local_tz );
            $cutoff->modify( "+{$min_hours} hours" );
            if ( $start_dt <= $cutoff ) {
                wp_send_json_error( array( 'message' => sprintf(
                    /* translators: %d: Minimum cancellation notice in hours. */
                    __( 'Cancellations must be made at least %d hour(s) in advance. Please call us directly.', 'yatoon-booking-system' ),
                    $min_hours
                ), ) );
            }
        }

        $table = esc_sql( $wpdb->prefix . 'yatoon_bookings' );

        $platform = $this->get_active_platform();
        $square_api = null;
        $cancelled_square_ids = array();
        $updated_ids = array();
        foreach ( $rows_to_cancel as $row_to_cancel ) {
            if ( $platform === 'vagaro' && ! empty( $row_to_cancel->square_booking_id ) ) {
                wp_send_json_error( array(
                    'message' => __( 'Online cancellation for Vagaro appointments is not available yet. Please contact us for help.', 'yatoon-booking-system' ),
                ) );
            }

            if ( $platform === 'square' && ! empty( $row_to_cancel->square_booking_id ) ) {
                if ( ! in_array( $row_to_cancel->square_booking_id, $cancelled_square_ids, true ) ) {
                    if ( ! $square_api ) {
                        $square_api = new YATOON_Square_API();
                    }
                    $sq_resp = $square_api->get_booking( $row_to_cancel->square_booking_id );
                    $version = null;
                    if ( ! is_wp_error( $sq_resp ) && isset( $sq_resp['booking']['version'] ) ) {
                        $version = intval( $sq_resp['booking']['version'] );
                    }
                    $cancel_resp = $square_api->cancel_booking( $row_to_cancel->square_booking_id, $version );
                    if ( is_wp_error( $cancel_resp ) ) {
                        yatoon_safe_log( 'Square cancel failed for booking ' . $row_to_cancel->id . ': ' . $cancel_resp->get_error_message() );
                        wp_send_json_error( array(
                            'message' => __( 'Square could not cancel this appointment. Nothing was changed locally; please try again or contact us for help.', 'yatoon-booking-system' ),
                        ) );
                    }
                    $cancelled_square_ids[] = $row_to_cancel->square_booking_id;
                }
            }

            $cancel_data = array(
                'status' => 'cancelled',
            );
            $cancel_formats = array( '%s' );
            if ( $this->booking_column_exists( 'cancelled_at' ) ) {
                $cancel_data['cancelled_at'] = current_time( 'mysql' );
                $cancel_formats[] = '%s';
            }

            $updated = yatoon_db_update(
                $table,
                $cancel_data,
                array( 'id' => intval( $row_to_cancel->id ) ),
                $cancel_formats,
                array( '%d' )
            );

            if ( $updated === false ) {
                wp_send_json_error( array(
                    'message' => __( 'We could not update the appointment status locally. Please refresh and try again, or contact us for help.', 'yatoon-booking-system' ),
                ) );
            }
            $updated_ids[] = intval( $row_to_cancel->id );
        }

        if ( ! empty( $updated_ids ) ) {
            $placeholders = implode( ',', array_fill( 0, count( $updated_ids ), '%d' ) );
            $still_active = yatoon_db_get_var( $wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->prefix}yatoon_bookings
                 WHERE id IN ({$placeholders})
                   AND LOWER(COALESCE(status,'')) NOT IN ('cancelled','canceled','cancelled_by_customer','cancelled_by_seller','canceled_by_customer','canceled_by_seller','declined','no_show')",
                $updated_ids
            ) );
            if ( intval( $still_active ) > 0 ) {
                wp_send_json_error( array(
                    'message' => __( 'The appointment did not finish cancelling. Please refresh and try again, or contact us for help.', 'yatoon-booking-system' ),
                ) );
            }
        }

        $notification_booking = ! empty( $rows_to_cancel[0] ) ? $rows_to_cancel[0] : $booking;

        // Fire notifications
        do_action( 'yatoon_booking_cancelled_by_customer', $notification_booking );

        // Send cancel email via notifications class
        $notifications = new Yatoon_Notifications();
        $notifications->send_cancel_email( (array) $notification_booking );

        // Notify admin
        $this->notify_admin_cancel( $notification_booking );

        $cancelled_count = count( $rows_to_cancel );
        $message = $cancelled_count > 1
            ? sprintf(
                /* translators: %d: Number of cancelled appointment items. */
                __( 'Your selected %d appointment items have been cancelled. We hope to see you again soon!', 'yatoon-booking-system' ),
                $cancelled_count
            )
            : __( 'Your appointment has been cancelled. We hope to see you again soon!', 'yatoon-booking-system' );

        wp_send_json_success( array(
            'message'         => $message,
            'cancelled_count' => $cancelled_count,
        ) );
    }

    // --- AJAX: Get reschedule slots ------------------------------------------

    public function ajax_get_reschedule_slots() {
        check_ajax_referer( 'yatoon_manage_booking', 'nonce' );
        if ( function_exists( 'yatoon_public_rate_limit' ) ) {
            yatoon_public_rate_limit( 'manage_slots', 30, 10 );
        }

        $token   = sanitize_text_field( wp_unslash( $_POST['token'] ?? '' ) );
        $date    = sanitize_text_field( wp_unslash( $_POST['date'] ?? '' ) );
        $booking = $this->get_booking_by_token( $token );

        if ( ! $booking || empty( $date ) ) {
            wp_send_json_error( array( 'message' => __( 'Invalid request.', 'yatoon-booking-system' ) ) );
        }
        $booking = $this->refresh_external_status_if_needed( $booking );
        if ( $this->is_cancelled_status( $booking->status ) ) {
            wp_send_json_error( array( 'message' => __( 'This appointment is already cancelled.', 'yatoon-booking-system' ) ) );
        }

        if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
            wp_send_json_error( array( 'message' => __( 'Invalid date format.', 'yatoon-booking-system' ) ) );
        }

        $slots = $this->get_reschedule_slots_for_booking( $booking, $date );

        if ( empty( $slots ) ) {
            wp_send_json_error( array( 'message' => __( 'No available times on this date. Please try another day.', 'yatoon-booking-system' ) ) );
        }

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

    // --- AJAX: Reschedule ----------------------------------------------------

    public function ajax_reschedule() {
        check_ajax_referer( 'yatoon_manage_booking', 'nonce' );
        if ( function_exists( 'yatoon_public_rate_limit' ) ) {
            yatoon_public_rate_limit( 'manage_reschedule', 8, 10 );
        }
        global $wpdb;

        $token      = sanitize_text_field( wp_unslash( $_POST['token'] ?? '' ) );
        $new_time   = sanitize_text_field( wp_unslash( $_POST['new_time'] ?? '' ) ); // 'Y-m-d H:i:s' in local TZ
        $booking    = $this->get_booking_by_token( $token );

        if ( ! $booking ) {
            wp_send_json_error( array( 'message' => __( 'Invalid or expired link.', 'yatoon-booking-system' ) ) );
        }
        $booking = $this->refresh_external_status_if_needed( $booking );
        if ( $this->is_cancelled_status( $booking->status ) ) {
            wp_send_json_error( array( 'message' => __( 'This appointment is already cancelled.', 'yatoon-booking-system' ) ) );
        }

        if ( empty( $new_time ) || ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/', $new_time ) ) {
            wp_send_json_error( array( 'message' => __( 'Please select a valid time slot.', 'yatoon-booking-system' ) ) );
        }

        $local_tz = $this->get_booking_timezone( $booking );

        $new_start_local = new DateTime( $new_time, $local_tz );

        $duration_min = intval( $booking->duration_minutes ?: 60 );
        $new_end_local  = ( clone $new_start_local )->modify( "+{$duration_min} minutes" );

        // 4.6.1: take the SAME per-calendar-day advisory lock that
        // create_booking, the REST API and the staff portal use. Without it the
        // availability re-check below and the UPDATE further down were a
        // textbook TOCTOU window: two customers rescheduling onto overlapping
        // slots for one staff member both passed the check and both wrote.
        // The slot_guard UNIQUE index cannot close this -- it is
        // (staff_id, start_time), so it only rejects identical start times and
        // lets 10:00-11:00 vs 10:30-11:30 through.
        $booking_locks = class_exists( 'YATOON_Booking_Lock' )
            ? YATOON_Booking_Lock::acquire( $new_start_local, $new_end_local )
            : array();
        if ( false === $booking_locks ) {
            wp_send_json_error( array( 'message' => __( 'That calendar is being updated. Please try again.', 'yatoon-booking-system' ) ) );
        }
        if ( $booking_locks ) {
            // Released on shutdown so every wp_send_json_* exit below is covered.
            register_shutdown_function( array( 'YATOON_Booking_Lock', 'release' ), $booking_locks );
        }

        // Validate the selected slot again and ensure it still fits every
        // service/staff segment in a multi-service appointment.
        $available_slots = $this->get_reschedule_slots_for_booking( $booking, $new_start_local->format( 'Y-m-d' ) );
        $available_datetimes = array_column( $available_slots, 'datetime' );
        if ( ! in_array( $new_start_local->format( 'Y-m-d H:i:s' ), $available_datetimes, true ) ) {
            wp_send_json_error( array( 'message' => __( 'That time slot is no longer available. Please choose another.', 'yatoon-booking-system' ) ) );
        }

        $old_start = $booking->start_time;
        $platform = $this->get_active_platform();
        $group_rows = $this->get_related_bookings( $booking, true );
        $group_base_old = $this->parse_booking_datetime( $group_rows[0]->start_time ?? '', $local_tz );

        if ( $platform === 'vagaro' && ! empty( $booking->square_booking_id ) ) {
            wp_send_json_error( array(
                'message' => __( 'Online rescheduling for Vagaro appointments is not available yet. Please contact us for help.', 'yatoon-booking-system' ),
            ) );
        }

        // Sync rescheduled time to Square if this booking was created via Square
        if ( $platform === 'square' && ! empty( $booking->square_booking_id ) ) {
            $square_api = new YATOON_Square_API();
            // Square requires the current booking version to update
            $sq_resp = $square_api->get_booking( $booking->square_booking_id );
            $version = null;
            if ( ! is_wp_error( $sq_resp ) && isset( $sq_resp['booking']['version'] ) ) {
                $version = intval( $sq_resp['booking']['version'] );
            }
            // Square API expects start_at in RFC 3339 UTC format
            $new_start_utc = ( clone $new_start_local )->setTimezone( new DateTimeZone( 'UTC' ) );
            $update_resp = $square_api->update_booking(
                $booking->square_booking_id,
                array( 'start_at' => $new_start_utc->format( 'Y-m-d\TH:i:s\Z' ) ),
                $version
            );
            if ( is_wp_error( $update_resp ) ) {
                yatoon_safe_log( 'Square reschedule failed for booking ' . $booking->id . ': ' . $update_resp->get_error_message() );
                wp_send_json_error( array(
                    'message' => __( 'Square could not reschedule this appointment. Nothing was changed locally; please try again or contact us for help.', 'yatoon-booking-system' ),
                ) );
            }
        }

        // Move the complete appointment group while preserving each service's
        // relative start and duration. External sync above succeeds first, so
        // a remote failure can never leave local data out of sync.
        $wpdb->query( 'START TRANSACTION' );
        $local_update_ok = true;
        foreach ( $group_rows as $group_row ) {
            $old_row_start = $this->parse_booking_datetime( $group_row->start_time, $local_tz );
            $old_row_end   = $this->parse_booking_datetime( $group_row->end_time, $local_tz );
            if ( ! $old_row_start || ! $old_row_end || ! $group_base_old ) {
                $local_update_ok = false;
                break;
            }
            $row_start = ( clone $new_start_local )->modify( ( $old_row_start->getTimestamp() - $group_base_old->getTimestamp() ) . ' seconds' );
            $row_end   = ( clone $row_start )->modify( ( $old_row_end->getTimestamp() - $old_row_start->getTimestamp() ) . ' seconds' );
            $updated = yatoon_db_update(
                $wpdb->prefix . 'yatoon_bookings',
                array(
                    'start_time' => $row_start->format( 'Y-m-d H:i:s' ),
                    'end_time'   => $row_end->format( 'Y-m-d H:i:s' ),
                    'status'     => 'pending',
                ),
                array( 'id' => intval( $group_row->id ) ),
                array( '%s', '%s', '%s' ),
                array( '%d' )
            );
            if ( false === $updated ) {
                $local_update_ok = false;
                break;
            }
        }
        $wpdb->query( $local_update_ok ? 'COMMIT' : 'ROLLBACK' );
        if ( ! $local_update_ok ) {
            wp_send_json_error( array(
                'message' => __( 'The appointment could not be rescheduled locally. Please refresh and contact us if the time changed in the external booking system.', 'yatoon-booking-system' ),
            ) );
        }

        // Refresh token expiry (another N days from now)
        $days    = intval( get_option( 'yatoon_manage_token_days', 7 ) );
        $days    = ( $days >= 1 && $days <= 30 ) ? $days : 7;
        // NOTE: deliberately UTC. This value is read back with strtotime()
        // (line ~218), which also parses in PHP's default zone, so writing
        // and reading agree. Switching only this side to site-local time
        // would shift every manage-link expiry by the site's UTC offset.
        $expires = gmdate( 'Y-m-d H:i:s', strtotime( "+{$days} days" ) );
        yatoon_db_update(
            $wpdb->prefix . 'yatoon_bookings',
            array( 'manage_token_expires' => $expires ),
            array( 'id' => intval( $booking->id ) ),
            array( '%s' ),
            array( '%d' )
        );

        // Update booking object for emails
        $booking->start_time = $new_start_local->format( 'Y-m-d H:i:s' );
        $booking->end_time   = $new_end_local->format( 'Y-m-d H:i:s' );

        // Send reschedule notification
        $notifications = new Yatoon_Notifications();
        $notifications->send_reschedule_email( (array) $booking );
        $this->notify_admin_reschedule( $booking, $old_start );

        do_action( 'yatoon_booking_rescheduled_by_customer', $booking );

        $new_fmt = $new_start_local->format( 'F j, Y \a\t g:i A' );
        wp_send_json_success( array(
            'message' => sprintf(
                /* translators: %s: New appointment date and time. */
                __( 'Your appointment has been rescheduled to %s. A confirmation email has been sent.', 'yatoon-booking-system' ),
                $new_fmt
            ),
        ) );
    }

    // --- Admin notifications -------------------------------------------------

    private function notify_admin_cancel( $booking ) {
        if ( get_option( 'yatoon_manage_notify_admin_cancel', '1' ) !== '1' ) { return;
        }
        $admin_email   = get_option( 'yatoon_contact_email', get_option( 'admin_email' ) );
        $business_name = get_option( 'yatoon_business_name', get_bloginfo( 'name' ) );
        $local_tz      = $this->get_booking_timezone( $booking );
        $start_dt      = $this->parse_booking_datetime( $booking->start_time, $local_tz );

        if ( ! $start_dt ) {
            return;
        }

        $subject = sprintf( '[%s] Appointment Cancelled by Customer', $business_name );
        $body    = sprintf(
            "A customer has cancelled their appointment.\n\nCustomer: %s\nEmail: %s\nService: %s\nWas scheduled: %s\n\nLog into %s to view details.",
            $booking->customer_name,
            $booking->customer_email,
            $booking->service_name ?? '-',
            $start_dt->format( 'F j, Y g:i A' ),
            admin_url( 'admin.php?page=yatoon-booking-system&tab=bookings' )
        );
        wp_mail( $admin_email, $subject, $body );
    }

    private function notify_admin_reschedule( $booking, $old_start ) {
        if ( get_option( 'yatoon_manage_notify_admin_reschedule', '1' ) !== '1' ) { return;
        }
        $admin_email   = get_option( 'yatoon_contact_email', get_option( 'admin_email' ) );
        $business_name = get_option( 'yatoon_business_name', get_bloginfo( 'name' ) );
        $local_tz      = $this->get_booking_timezone( $booking );
        $old_dt        = $this->parse_booking_datetime( $old_start, $local_tz );
        $new_dt        = $this->parse_booking_datetime( $booking->start_time, $local_tz );

        if ( ! $old_dt || ! $new_dt ) {
            return;
        }

        $subject = sprintf( '[%s] Appointment Rescheduled by Customer', $business_name );
        $body    = sprintf(
            "A customer has rescheduled their appointment.\n\nCustomer: %s\nEmail: %s\nService: %s\nOld time: %s\nNew time: %s\n\nLog into %s to view details.",
            $booking->customer_name,
            $booking->customer_email,
            $booking->service_name ?? '-',
            $old_dt->format( 'F j, Y g:i A' ),
            $new_dt->format( 'F j, Y g:i A' ),
            admin_url( 'admin.php?page=yatoon-booking-system&tab=bookings' )
        );
        wp_mail( $admin_email, $subject, $body );
    }
}

Yatoon_Manage_Booking::get_instance();
