<?php
// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
/**
 * Yatoon Customer Portal
 *
 * Phone-based OTP login -> view / cancel / reschedule appointments + spending history.
 * Verification codes are sent via Twilio (already configured in plugin settings).
 * All booking data is fetched live from Square Customers + Bookings API.
 *
 * Shortcode: [yatoon_customer_portal]
 */

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

class YATOON_Customer_Portal {

    /* Session key prefix */
    const SESSION_KEY  = 'yatoon_portal_customer';
    /* OTP transient prefix (stored in WP options via transients) */
    const OTP_PREFIX   = 'yatoon_otp_';
    /* OTP expiry in seconds */
    const OTP_TTL      = 600; // 10 minutes
    /* How far back to show bookings */
    const HISTORY_DAYS = 180;
    /* How far forward to show upcoming bookings */
    const FUTURE_DAYS  = 30;

    private YATOON_Square_API $square_api;

    public function __construct() {
        $this->square_api = new YATOON_Square_API();

        // Shortcode
        add_shortcode('yatoon_customer_portal', array($this, 'render_shortcode'));

        // Enqueue assets only on pages that use the shortcode
        add_action('wp_enqueue_scripts', array($this, 'maybe_enqueue_assets'));

        // AJAX handlers (both logged-in and guest)
        $actions = array(
            'yatoon_portal_send_otp',
            'yatoon_portal_verify_otp',
            'yatoon_portal_logout',
            'yatoon_portal_get_bookings',
            'yatoon_portal_cancel_booking',
            'yatoon_portal_get_reschedule_slots',
            'yatoon_portal_reschedule_booking',
        );
        foreach ($actions as $action) {
            add_action('wp_ajax_'        . $action, array($this, str_replace('yatoon_portal_', 'handle_', $action)));
            add_action('wp_ajax_nopriv_' . $action, array($this, str_replace('yatoon_portal_', 'handle_', $action)));
        }
    }

    /* ═══════════════════════════════════════════════════════════════
       SESSION HELPERS
    ═══════════════════════════════════════════════════════════════ */

    private function start_session() {
        if (!session_id() && !headers_sent()) {
            if ( function_exists( 'ini_set' ) ) {
                ini_set( 'session.use_strict_mode', '1' );
                ini_set( 'session.use_only_cookies', '1' );
            }
            session_set_cookie_params(array(
                'lifetime' => 0,
                'path'     => COOKIEPATH ? COOKIEPATH : '/',
                'domain'   => COOKIE_DOMAIN ? COOKIE_DOMAIN : '',
                'secure'   => is_ssl(),
                'httponly' => true,
                'samesite' => 'Lax',
            ));
            session_start(); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.session_session_start -- only called within portal-specific methods, not on every page load
        }
    }

    private function set_customer_session(array $customer) {
        $this->start_session();
        $_SESSION[self::SESSION_KEY] = $customer;
    }

    private function get_customer_session() {
        $this->start_session();
        return $_SESSION[self::SESSION_KEY] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- data set by this plugin, not user input
    }

    private function clear_session() {
        $this->start_session();
        $_SESSION = array();
        if ( ini_get( 'session.use_cookies' ) ) {
            $params = session_get_cookie_params();
            setcookie(
                session_name(),
                '',
                array(
                    'expires'  => time() - HOUR_IN_SECONDS,
                    'path'     => $params['path'],
                    'domain'   => $params['domain'],
                    'secure'   => $params['secure'],
                    'httponly' => $params['httponly'],
                    'samesite' => 'Lax',
                )
            );
        }
        session_destroy();
    }

    private function get_request_ip() {
        return sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? 'unknown' ) );
    }

    private function get_otp_verify_key( $phone ) {
        return self::OTP_PREFIX . 'verify_' . md5( $phone . '|' . $this->get_request_ip() . '|' . session_id() );
    }

    private function clear_pending_login() {
        unset(
            $_SESSION['yatoon_portal_pending_phone'],
            $_SESSION['yatoon_portal_pending_email'],
            $_SESSION['yatoon_portal_pending_local_customer']
        );
    }

    private function is_local_only_mode() {
        return get_option('yatoon_active_platform', 'local') === 'local';
    }

    private function phones_match(string $phone_a, string $phone_b) {
        $norm_a = $this->normalize_phone($phone_a);
        $norm_b = $this->normalize_phone($phone_b);
        return $norm_a && $norm_b && $norm_a === $norm_b;
    }

    private function get_local_customer_by_phone(string $phone) {
        global $wpdb;

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results(
            "SELECT customer_name, customer_email, customer_phone
             FROM {$wpdb->prefix}yatoon_bookings
             WHERE customer_phone <> ''
             ORDER BY id DESC
             LIMIT 250"
        );

        foreach ($rows as $row) {
            if (!$this->phones_match($row->customer_phone ?? '', $phone)) {
                continue;
            }
            if (empty($row->customer_email) || !is_email($row->customer_email)) {
                continue;
            }

            return array(
                'phone'        => $this->normalize_phone($row->customer_phone),
                'email'        => sanitize_email($row->customer_email),
                'name'         => sanitize_text_field($row->customer_name ?: 'Valued Customer'),
                'square_id'    => null,
                'all_square_ids' => array(),
                'local_mode'   => true,
            );
        }

        return null;
    }

    private function get_local_booking_row($booking_id, array $customer) {
        global $wpdb;

        $booking = $wpdb->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",
            intval($booking_id)
        ));

        if (!$booking) {
            return null;
        }

        $phone_match = !empty($customer['phone']) && $this->phones_match($booking->customer_phone ?? '', $customer['phone']);
        $email_match = !empty($customer['email']) && !empty($booking->customer_email) && strtolower($booking->customer_email) === strtolower($customer['email']);

        if (!$phone_match && !$email_match) {
            return null;
        }

        return $booking;
    }

    /* ═══════════════════════════════════════════════════════════════
       SHORTCODE RENDER
    ═══════════════════════════════════════════════════════════════ */

    public function render_shortcode($atts) {
        $atts = shortcode_atts(array('class' => ''), $atts);
        $extra_class = esc_attr($atts['class']);

        ob_start();
        ?>
        <div id="sbs-portal-wrap" class="sbs-portal-wrap <?php echo esc_attr( $extra_class ); ?>">
            <div id="sbs-portal-inner">
                <!-- Content injected by JS after AJAX calls -->
                <div class="sbs-portal-loading" style="display:none;">
                    <span class="sbs-portal-spinner"></span>
                </div>
            </div>
        </div>
        <?php
        return ob_get_clean();
    }

    public function maybe_enqueue_assets() {
        global $post;
        if (!$post) return;
        $needs_portal = has_shortcode($post->post_content, 'sbs_customer_portal') || has_shortcode($post->post_content, 'yatoon_customer_portal')
                     || has_shortcode($post->post_content, 'square_booking');
        if (!$needs_portal) return;

        wp_enqueue_style(
            'sbs-portal-css',
            SBS_PLUGIN_URL . 'assets/css/customer-portal' . YATOON_ASSET_SUFFIX . '.css',
            array(),
            SBS_VERSION
        );
        wp_enqueue_style(
            'yatoon-apple-polish',
            SBS_PLUGIN_URL . 'assets/css/apple-polish' . YATOON_ASSET_SUFFIX . '.css',
            array('sbs-portal-css'),
            SBS_VERSION
        );
        wp_enqueue_style(
            'yatoon-design-system',
            SBS_PLUGIN_URL . 'assets/css/yatoon-design-system' . YATOON_ASSET_SUFFIX . '.css',
            array('yatoon-apple-polish'),
            SBS_VERSION
        );
        wp_add_inline_style('yatoon-apple-polish', yatoon_apple_polish_inline_css());
        wp_enqueue_script(
            'sbs-portal-js',
            SBS_PLUGIN_URL . 'assets/js/customer-portal' . YATOON_ASSET_SUFFIX . '.js',
            array('jquery'),
            SBS_VERSION,
            true
        );

        $primary     = get_option('yatoon_portal_primary_color', get_option('yatoon_primary_color', '#ec4899'));
        $banner_bg   = get_option('yatoon_portal_banner_bg',   '#fdf2f8');
        $banner_text = get_option('yatoon_portal_banner_text', '#9d174d');
        wp_localize_script('sbs-portal-js', 'yatoonPortal', array(
            'ajaxurl'      => admin_url('admin-ajax.php'),
            'nonce'        => wp_create_nonce('yatoon_portal_nonce'),
            'primaryColor' => $primary,
            'bannerBg'     => $banner_bg,
            'bannerText'   => $banner_text,
            'bookingPage'  => get_permalink(),
            'bookNewUrl'   => get_option('yatoon_book_new_url', ''),
            'strings'      => array(
                'sendCode'        => get_option('yatoon_portal_btn_label', 'Request Sign In Code'),
                'verifying'       => __('Verifying...', 'yatoon-booking-system'),
                'sending'         => __('Sending...', 'yatoon-booking-system'),
                'codeSent'        => __('Verification code sent to your email:', 'yatoon-booking-system'),
                'loginTitle'      => get_option('yatoon_portal_login_title', 'My Appointments'),
                'loginBrand'      => get_option('yatoon_portal_login_brand', ''),
                'loginSubtitle'   => get_option('yatoon_portal_login_subtitle', 'Enter your mobile phone number. We\'ll send a code to your email on file.'),
                'phonePlaceholder'=> __('Mobile phone (e.g. 317-555-1234)', 'yatoon-booking-system'),
                'codePlaceholder' => __('6-digit code', 'yatoon-booking-system'),
                'verify'          => __('Verify & Sign In', 'yatoon-booking-system'),
                'logout'          => __('Sign Out', 'yatoon-booking-system'),
                'upcoming'        => __('Upcoming Appointments', 'yatoon-booking-system'),
                'past'            => __('Past Appointments', 'yatoon-booking-system'),
                'spending'        => __('Spending History', 'yatoon-booking-system'),
                'noUpcoming'      => __('No upcoming appointments.', 'yatoon-booking-system'),
                'noPast'          => __('No past appointments found.', 'yatoon-booking-system'),
                'cancel'          => __('Cancel Appointment', 'yatoon-booking-system'),
                'reschedule'      => __('Reschedule', 'yatoon-booking-system'),
                'confirmCancel'   => __('Are you sure you want to cancel this appointment?', 'yatoon-booking-system'),
                'cancelSuccess'   => __('Appointment cancelled successfully.', 'yatoon-booking-system'),
                'rescheduleTitle' => __('Choose a New Time', 'yatoon-booking-system'),
                'bookNew'         => __('Book New Appointment', 'yatoon-booking-system'),
                'rebook'          => __('Book Again', 'yatoon-booking-system'),
                'totalSpent'      => __('Total Spent', 'yatoon-booking-system'),
                'visits'          => __('Visits', 'yatoon-booking-system'),
                'resendCode'      => __('Resend code', 'yatoon-booking-system'),
                'changePhone'     => __('Change phone number', 'yatoon-booking-system'),
            ),
        ));
    }

    /* ═══════════════════════════════════════════════════════════════
       AJAX: SEND OTP
       Flow: customer enters phone -> look up in Square -> send OTP
             to the email address Square has on file for that number.
    ═══════════════════════════════════════════════════════════════ */

    public function handle_send_otp() {
        check_ajax_referer('yatoon_portal_nonce', 'nonce');

        $raw_phone = isset($_POST['phone']) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : '';
        $phone     = $this->normalize_phone($raw_phone);

        if (!$phone) {
            wp_send_json_error(array('message' => __('Please enter a valid US phone number.', 'yatoon-booking-system')));
        }

        // Rate limit by both account identifier and source IP.
        $rate_key   = self::OTP_PREFIX . 'rate_' . md5($phone);
        $ip_rate_key = self::OTP_PREFIX . 'rate_ip_' . md5( $this->get_request_ip() );
        $rate_count = (int) get_transient($rate_key);
        $ip_rate_count = (int) get_transient( $ip_rate_key );
        if ($rate_count >= 3 || $ip_rate_count >= 12) {
            wp_send_json_error(array('message' => __('Too many attempts. Please wait a few minutes and try again.', 'yatoon-booking-system')));
        }
        set_transient($rate_key, $rate_count + 1, self::OTP_TTL);
        set_transient( $ip_rate_key, $ip_rate_count + 1, self::OTP_TTL );

        $email = '';
        $local_customer = null;

        if ($this->is_local_only_mode()) {
            $local_customer = $this->get_local_customer_by_phone($phone);
            $email = $local_customer['email'] ?? '';
        } else {
            // -- Look up customer in Square by phone --------------------------
            $sq_resp     = $this->square_api->search_customers(null, $phone);
            $customers   = (!is_wp_error($sq_resp) && !empty($sq_resp['customers'])) ? $sq_resp['customers'] : array();
            $sq_customer = $customers[0] ?? null;
            $email       = $sq_customer['email_address'] ?? '';

            if (!$email || !is_email($email)) {
                wp_send_json_error(array(
                    'message' => __('We could not find an appointment with that phone number. Please contact us for help.', 'yatoon-booking-system'),
                ));
            }
        }

        if (!$email || !is_email($email)) {
            wp_send_json_error(array(
                'message' => __('We could not find an appointment with that phone number. Please contact us for help.', 'yatoon-booking-system'),
            ));
        }

        // Generate & store OTP keyed to phone.
        $this->start_session();
        $otp     = sprintf('%06d', random_int(100000, 999999));
        $otp_key = self::OTP_PREFIX . md5($phone);
        set_transient($otp_key, wp_hash_password($otp), self::OTP_TTL);
        delete_transient( $this->get_otp_verify_key( $phone ) );

        // Store phone + masked email in session
        $_SESSION['yatoon_portal_pending_phone'] = $phone;
        $_SESSION['yatoon_portal_pending_email'] = $email; // used in verify step
        if ($local_customer) {
            $_SESSION['yatoon_portal_pending_local_customer'] = $local_customer;
        } else {
            unset($_SESSION['yatoon_portal_pending_local_customer']);
        }

        // -- Send verification email via wp_mail --------------------------
        $biz_name  = get_option('blogname', '');
        $biz_email = get_option('admin_email');
        /* translators: %s: business name. */
        $subject   = sprintf(__('[%s] Your verification code', 'yatoon-booking-system'), $biz_name);
        $body      = $this->build_otp_email($biz_name, $otp);

        $mail_content_type_filter = static function() { return 'text/html'; };
        add_filter('wp_mail_content_type', $mail_content_type_filter);
        $sent = wp_mail($email, $subject, $body, array(
            'From: ' . $biz_name . ' <' . $biz_email . '>',
            'Reply-To: ' . $biz_email,
        ));
        remove_filter('wp_mail_content_type', $mail_content_type_filter);

        if (!$sent) {
            wp_send_json_error(array('message' => __('Failed to send email. Please try again or contact us.', 'yatoon-booking-system')));
        }

        // Return masked email so front-end can display "Code sent to j***@gmail.com"
        $masked = $this->mask_email($email);
        wp_send_json_success(array(
        /* translators: %s: masked customer email address. */
        'message'      => sprintf(__('Verification code sent to %s', 'yatoon-booking-system'), $masked),
            'masked_email' => $masked,
        ));
    }

    /** Mask email for display: john@gmail.com -> j***@gmail.com */
    private function mask_email(string $email) {
        [$local, $domain] = explode('@', $email, 2);
        $masked_local = substr($local, 0, 1) . str_repeat('*', max(strlen($local) - 1, 3));
        return $masked_local . '@' . $domain;
    }

    /** Build HTML verification email body */
    private function build_otp_email(string $biz_name, string $otp) {
        $primary     = get_option('yatoon_portal_primary_color', get_option('yatoon_primary_color', '#ec4899'));
        $banner_bg   = get_option('yatoon_portal_banner_bg',   '#fdf2f8');
        $banner_text = get_option('yatoon_portal_banner_text', '#9d174d');
        return '<!DOCTYPE html><html><body style="margin:0;padding:0;background:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px;">
<table width="520" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.08);">
  <tr><td style="background:' . esc_attr($primary) . ';padding:28px 32px;text-align:center;">
    <h1 style="margin:0;color:#fff;font-size:22px;font-weight:700;">' . esc_html($biz_name) . '</h1>
  </td></tr>
  <tr><td style="padding:36px 32px;text-align:center;">
    <p style="margin:0 0 8px;font-size:15px;color:#374151;">Your verification code is:</p>
    <div style="display:inline-block;background:#f3f4f6;border-radius:12px;padding:18px 36px;margin:16px 0;">
      <span style="font-size:38px;font-weight:800;letter-spacing:10px;color:' . esc_attr($primary) . ';">' . esc_html($otp) . '</span>
    </div>
    <p style="margin:16px 0 0;font-size:13px;color:#9ca3af;">This code expires in <strong>10 minutes</strong>.<br>If you did not request this, you can ignore this email.</p>
  </td></tr>
  <tr><td style="padding:20px 32px;border-top:1px solid #f0f0f0;text-align:center;">
    <p style="margin:0;font-size:12px;color:#d1d5db;">&copy; ' . gmdate('Y') . ' ' . esc_html($biz_name) . '</p>
  </td></tr>
</table></td></tr></table></body></html>';
    }

    /* ═══════════════════════════════════════════════════════════════
       AJAX: VERIFY OTP
    ═══════════════════════════════════════════════════════════════ */

    public function handle_verify_otp() {
        check_ajax_referer('yatoon_portal_nonce', 'nonce');

        $this->start_session();
        $phone = $_SESSION['yatoon_portal_pending_phone'] ?? '';
        $code  = isset($_POST['code']) ? preg_replace('/\D/', '', $_POST['code']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash

        if (!$phone || !$code) {
            wp_send_json_error(array('message' => __('Session expired. Please request a new code.', 'yatoon-booking-system')));
        }

        $otp_key = self::OTP_PREFIX . md5($phone);
        $stored  = get_transient($otp_key);
        $verify_key = $this->get_otp_verify_key( $phone );
        $failed_attempts = (int) get_transient( $verify_key );

        $valid_code = $stored && (
            wp_check_password( $code, $stored )
            || ( is_string( $stored ) && hash_equals( $stored, $code ) )
        );

        if (!$valid_code) {
            $failed_attempts++;
            set_transient( $verify_key, $failed_attempts, self::OTP_TTL );
            if ( $failed_attempts >= 6 ) {
                delete_transient( $otp_key );
                delete_transient( $verify_key );
                $this->clear_pending_login();
                wp_send_json_error(array('message' => __('Too many incorrect codes. Please request a new code.', 'yatoon-booking-system')));
            }
            wp_send_json_error(array('message' => __('Invalid or expired code. Please try again.', 'yatoon-booking-system')));
        }

        // Valid - delete OTP
        delete_transient($otp_key);
        delete_transient($verify_key);

        if ($this->is_local_only_mode()) {
            $customer = $_SESSION['yatoon_portal_pending_local_customer'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            if (!$customer) {
                wp_send_json_error(array('message' => __('Session expired. Please request a new code.', 'yatoon-booking-system')));
            }
        } else {
            // Re-fetch Square customer by phone - collect ALL matching IDs
            $sq_resp     = $this->square_api->search_customers(null, $phone);
            $customers   = (!is_wp_error($sq_resp) && !empty($sq_resp['customers'])) ? $sq_resp['customers'] : array();
            $sq_customer = !empty($customers) ? $customers[0] : null;
            // Collect all customer IDs associated with this phone
            $all_square_ids = array();
            foreach ($customers as $c) {
                if (!empty($c['id'])) $all_square_ids[] = $c['id'];
            }

            $customer = array(
                'phone'          => $phone,
                'email'          => isset($sq_customer['email_address']) ? $sq_customer['email_address'] : (isset($_SESSION['yatoon_portal_pending_email']) ? $_SESSION['yatoon_portal_pending_email'] : ''),
                'square_id'      => isset($sq_customer['id']) ? $sq_customer['id'] : null,
                'all_square_ids' => $all_square_ids,
                'name'           => trim((isset($sq_customer['given_name']) ? $sq_customer['given_name'] : '') . ' ' . (isset($sq_customer['family_name']) ? $sq_customer['family_name'] : '')),
                'local_mode'     => false,
            );
            if (!$customer['name']) $customer['name'] = 'Valued Customer';
        }

        session_regenerate_id( true );
        $this->set_customer_session($customer);
        $this->clear_pending_login();

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

    /* ═══════════════════════════════════════════════════════════════
       AJAX: LOGOUT
    ═══════════════════════════════════════════════════════════════ */

    public function handle_logout() {
        check_ajax_referer('yatoon_portal_nonce', 'nonce');
        $this->clear_session();
        wp_send_json_success();
    }

    /* ═══════════════════════════════════════════════════════════════
       AJAX: GET BOOKINGS + SPENDING
    ═══════════════════════════════════════════════════════════════ */

    public function handle_get_bookings() {
        check_ajax_referer('yatoon_portal_nonce', 'nonce');

        $customer = $this->get_customer_session();
        if (!$customer) {
            wp_send_json_error(array('code' => 'not_logged_in'));
        }

        $timezone = get_option('timezone_string') ?: 'America/New_York';
        $tz       = new DateTimeZone($timezone);
        $now      = new DateTime('now', $tz);

        if ($this->is_local_only_mode()) {
            $local = $this->get_local_bookings_payload($customer, $tz, $now);
            wp_send_json_success($local);
        }

        // Square API max 31 days per request - split into 30-day chunks
        $sq_bookings = array();
        $chunk_days  = 28; // safe under 31-day limit
        $total_past  = self::HISTORY_DAYS;
        $total_future= self::FUTURE_DAYS;

        // Past chunks (oldest to newest)
        for ($offset = $total_past; $offset > 0; $offset -= $chunk_days) {
            $chunk_start = (clone $now)->modify('-' . $offset . ' days')->format('Y-m-d\TH:i:s\Z');
            $chunk_end   = (clone $now)->modify('-' . max(0, $offset - $chunk_days) . ' days')->format('Y-m-d\TH:i:s\Z');
            $chunk       = $this->fetch_customer_bookings($customer['square_id'], $chunk_start, $chunk_end, isset($customer['email']) ? $customer['email'] : '');
            $sq_bookings = array_merge($sq_bookings, $chunk);
        }
        // Future chunk
        $future_start = (clone $now)->format('Y-m-d\TH:i:s\Z');
        $future_end   = (clone $now)->modify('+' . $total_future . ' days')->format('Y-m-d\TH:i:s\Z');
        $chunk        = $this->fetch_customer_bookings($customer['square_id'], $future_start, $future_end, isset($customer['email']) ? $customer['email'] : '');
        $sq_bookings  = array_merge($sq_bookings, $chunk);

        $upcoming = array();
        $past     = array();
        $total_spent = 0.0;
        $visit_count = 0;

        foreach ($sq_bookings as $sb) {
            $formatted = $this->format_booking($sb, $tz, $now);
            if (!$formatted) continue;

            if ($formatted['is_upcoming']) {
                $upcoming[] = $formatted;
            } else {
                $past[]        = $formatted;
                $total_spent  += $formatted['price_raw'];
                if (!in_array($formatted['status'], array('CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED'))) {
                    $visit_count++;
                }
            }
        }

        // Sort: upcoming ASC, past DESC
        usort($upcoming, function($a,$b) { return strcmp($a['start_at_raw'], $b['start_at_raw']); });
        usort($past,     function($a,$b) { return strcmp($b['start_at_raw'], $a['start_at_raw']); });

        wp_send_json_success(array(
            'customer'    => $customer,
            'upcoming'    => $upcoming,
            'past'        => array_slice($past, 0, 50), // cap at 50 past records
            'total_spent' => '$' . number_format($total_spent, 2),
            'visit_count' => $visit_count,
        ));
    }

    private function get_local_bookings_payload(array $customer, DateTimeZone $tz, DateTime $now) {
        global $wpdb;

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results("SELECT * FROM {$wpdb->prefix}yatoon_bookings ORDER BY start_time DESC LIMIT 250");

        $upcoming = array();
        $past = array();
        $total_spent = 0.0;
        $visit_count = 0;

        foreach ($rows as $row) {
            $phone_match = !empty($customer['phone']) && $this->phones_match($row->customer_phone ?? '', $customer['phone']);
            $email_match = !empty($customer['email']) && !empty($row->customer_email) && strtolower($row->customer_email) === strtolower($customer['email']);
            if (!$phone_match && !$email_match) {
                continue;
            }

            $formatted = $this->format_local_booking($row, $tz, $now);
            if (!$formatted) {
                continue;
            }

            if ($formatted['is_upcoming']) {
                $upcoming[] = $formatted;
            } else {
                $past[] = $formatted;
                $total_spent += $formatted['price_raw'];
                if (!$formatted['is_cancelled']) {
                    $visit_count++;
                }
            }
        }

        usort($upcoming, function($a, $b) { return strcmp($a['start_at_raw'], $b['start_at_raw']); });
        usort($past, function($a, $b) { return strcmp($b['start_at_raw'], $a['start_at_raw']); });

        return array(
            'customer'    => $customer,
            'upcoming'    => $upcoming,
            'past'        => array_slice($past, 0, 50),
            'total_spent' => '$' . number_format($total_spent, 2),
            'visit_count' => $visit_count,
        );
    }

    private function format_local_booking($booking, DateTimeZone $tz, DateTime $now) {
        try {
            $start = new DateTime($booking->start_time, $tz);
            $end   = new DateTime($booking->end_time, $tz);
        } catch (Exception $e) {
            return null;
        }

        global $wpdb;
        $service_name = $wpdb->get_var($wpdb->prepare(
            "SELECT name FROM {$wpdb->prefix}yatoon_services WHERE id = %d LIMIT 1",
            intval($booking->service_id)
        )) ?: __('Appointment', 'yatoon-booking-system');
        $staff_name = $wpdb->get_var($wpdb->prepare(
            "SELECT name FROM {$wpdb->prefix}yatoon_staff WHERE id = %d LIMIT 1",
            intval($booking->staff_id)
        )) ?: __('Any Staff', 'yatoon-booking-system');

        $status = strtoupper((string) ($booking->status ?? 'PENDING'));
        $is_cancelled = in_array($status, array('CANCELLED', 'CANCELLED_BY_CUSTOMER', 'CANCELLED_BY_SELLER', 'DECLINED'), true);
        $is_upcoming = $start > $now && !$is_cancelled;
        $duration_min = max(0, intval(($end->getTimestamp() - $start->getTimestamp()) / 60));

        $label_map = array(
            'PENDING' => __('Pending', 'yatoon-booking-system'),
            'CONFIRMED' => __('Confirmed', 'yatoon-booking-system'),
            'CANCELLED' => __('Cancelled', 'yatoon-booking-system'),
            'CANCELLED_BY_CUSTOMER' => __('Cancelled by you', 'yatoon-booking-system'),
            'CANCELLED_BY_SELLER' => __('Cancelled by salon', 'yatoon-booking-system'),
        );
        $payment_label_map = array(
            'unpaid'          => __('Pay at salon', 'yatoon-booking-system'),
            'paid_square_pos' => __('Paid in salon', 'yatoon-booking-system'),
            'deposit_paid'    => __('Deposit paid', 'yatoon-booking-system'),
            'refunded'        => __('Refunded', 'yatoon-booking-system'),
            'waived'          => __('Waived', 'yatoon-booking-system'),
        );
        $payment_status = sanitize_key((string) ($booking->payment_status ?? 'unpaid'));

        return array(
            'id'            => (string) intval($booking->id),
            'start_at_raw'  => $start->format('Y-m-d H:i:s'),
            'date'          => $start->format('l, F j, Y'),
            'time'          => $start->format('g:i A') . ' - ' . $end->format('g:i A'),
            'services'      => $service_name,
            'staff'         => $staff_name,
            'duration_min'  => $duration_min,
            'status'        => $status,
            'status_label'  => $label_map[$status] ?? $status,
            'is_upcoming'   => $is_upcoming,
            'is_cancelled'  => $is_cancelled,
            'price_raw'     => floatval($booking->total_price ?? 0),
            'price'         => '$' . number_format(floatval($booking->total_price ?? 0), 2),
            'payment_status'=> $payment_status,
            'payment_label' => $payment_label_map[$payment_status] ?? ucwords(str_replace('_', ' ', $payment_status)),
            'can_cancel'    => $is_upcoming && !$is_cancelled,
            'can_reschedule'=> $is_upcoming && !$is_cancelled,
        );
    }

    private function fetch_customer_bookings($sq_customer_id, $start_at, $end_at, $email = '') {
        $all    = array();

        // Get all customer IDs from session (handles duplicate/merged customers)
        $this->start_session();
        $session_customer = isset($_SESSION[self::SESSION_KEY]) ? $_SESSION[self::SESSION_KEY] : array();
        $all_ids = isset($session_customer['all_square_ids']) ? $session_customer['all_square_ids'] : array();
        if ($sq_customer_id && !in_array($sq_customer_id, $all_ids)) {
            $all_ids[] = $sq_customer_id;
        }
        if (empty($all_ids)) return array();

        // Query Square once per customer ID
        foreach ($all_ids as $cid) {
            $cursor = null;
            $safety = 0;
            do {
                $qp = array(
                    'location_id'  => get_option('yatoon_square_location_id', ''),
                    'limit'        => 100,
                    'start_at_min' => $start_at,
                    'start_at_max' => $end_at,
                    'customer_id'  => $cid,
                );
                if ($cursor) $qp['cursor'] = $cursor;
                $resp = $this->square_api_get('/v2/bookings?' . http_build_query($qp));
                if (is_wp_error($resp)) break;
                if (!empty($resp['bookings'])) $all = array_merge($all, $resp['bookings']);
                $cursor = isset($resp['cursor']) ? $resp['cursor'] : null;
                $safety++;
            } while ($cursor && $safety < 10);
        }

        return $all;
    }

    /** Call Square API GET directly (workaround since make_request is private) */
    private function square_api_get($path) {
        $access_token = YATOON_Crypto::get_option('yatoon_square_access_token', '');
        $env          = get_option('yatoon_square_environment', 'production');
        $base_url     = $env === 'sandbox'
            ? 'https://connect.squareupsandbox.com'
            : 'https://connect.squareup.com';

        $resp = wp_remote_get($base_url . $path, array(
            'headers' => array(
                'Square-Version' => '2024-01-17',
                'Authorization'  => 'Bearer ' . $access_token,
                'Content-Type'   => 'application/json',
            ),
            'timeout' => 20,
        ));

        if (is_wp_error($resp)) return $resp;
        $body = json_decode(wp_remote_retrieve_body($resp), true);
        return $body ?: array();
    }

    private function format_booking(array $sb, DateTimeZone $tz, DateTime $now) {
        if (empty($sb['start_at'])) return null;

        $start = new DateTime($sb['start_at']);
        $start->setTimezone($tz);

        $status     = $sb['status'] ?? 'UNKNOWN';
        $is_upcoming = $start > $now && !in_array($status, array('CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED'));
        $is_cancelled = in_array($status, array('CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED'));

        // Build service description from segments
        $services  = array();
        $staff_ids = array();
        $total_min = 0;
        foreach (($sb['appointment_segments'] ?? array()) as $seg) {
            $dur  = intval(isset($seg['duration_minutes']) ? $seg['duration_minutes'] : 0);
            $name = $this->lookup_variation_name(isset($seg['service_variation_id']) ? $seg['service_variation_id'] : '');
            $services[] = $name;
            $total_min += $dur;
            // Collect staff - only if explicitly chosen by customer (any_team_member = false or not set)
            if (!empty($seg['team_member_id']) && empty($seg['any_team_member'])) {
                $staff_ids[] = $seg['team_member_id'];
            }
        }
        // Lookup staff names
        $staff_label = 'Any Staff';
        if (!empty($staff_ids)) {
            $staff_names = array();
            foreach (array_unique($staff_ids) as $tid) {
                $sname = $this->lookup_staff_name($tid);
                if ($sname) $staff_names[] = $sname;
            }
            if (!empty($staff_names)) $staff_label = implode(', ', $staff_names);
        }

        // Price: try to fetch from Square order/invoice - use 0 if unavailable
        $price_raw = 0.0;

        $cancelled_label_map = array(
            'CANCELLED_BY_CUSTOMER' => __('Cancelled by you',  'yatoon-booking-system'),
            'CANCELLED_BY_SELLER'   => __('Cancelled by salon', 'yatoon-booking-system'),
            'DECLINED'              => __('Declined', 'yatoon-booking-system'),
            'ACCEPTED'              => __('Confirmed', 'yatoon-booking-system'),
            'PENDING'               => __('Pending', 'yatoon-booking-system'),
            'NO_SHOW'               => __('No-show', 'yatoon-booking-system'),
        );

        // Compute end time
        $end = clone $start;
        $end->modify("+{$total_min} minutes");

        return array(
            'id'           => $sb['id'],
            'start_at_raw' => $sb['start_at'],
            'date'         => $start->format('l, F j, Y'),
            'time'         => $start->format('g:i A') . ' - ' . $end->format('g:i A'),
            'services'     => implode(' + ', $services) ?: 'Appointment',
            'staff'        => $staff_label,
            'duration_min' => $total_min,
            'status'       => $status,
            'status_label' => $cancelled_label_map[$status] ?? $status,
            'is_upcoming'  => $is_upcoming,
            'is_cancelled' => $is_cancelled,
            'price_raw'    => $price_raw,
            'price'        => $price_raw > 0 ? '$' . number_format($price_raw, 2) : '-',
            'payment_status'=> '',
            'payment_label' => get_option('yatoon_deposit_enabled', '0') === '1'
                ? __('Payment handled at booking or salon', 'yatoon-booking-system')
                : __('Pay at salon', 'yatoon-booking-system'),
            'can_cancel'   => $is_upcoming && !$is_cancelled,
            'can_reschedule'=> $is_upcoming && !$is_cancelled,
        );
    }

    private $_variation_cache = array();
    private function lookup_variation_name($variation_sq_id) {
        if (!$variation_sq_id) return 'Service';
        if (isset($this->_variation_cache[$variation_sq_id])) {
            return $this->_variation_cache[$variation_sq_id];
        }
        global $wpdb;

        // Try yatoon_service_options first (variation level)
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT so.name as var_name, s.name as svc_name
             FROM {$wpdb->prefix}yatoon_service_options so
             LEFT JOIN {$wpdb->prefix}yatoon_services s ON s.id = so.service_id
             WHERE so.square_id = %s LIMIT 1",
            $variation_sq_id
        ));

        if ($row) {
            $var_name = trim($row->var_name ?: '');
            $svc_name = trim($row->svc_name ?: '');
            // Generic/meaningless variation names to skip
            $generic = array('regular','standard','default','normal','base','option','service','');
            if ($svc_name && in_array(strtolower($var_name), $generic)) {
                // Just show the parent service name
                $name = $svc_name;
            } elseif ($svc_name && $var_name && strtolower($var_name) !== strtolower($svc_name)) {
                $name = $svc_name . ' - ' . $var_name;
            } elseif ($var_name) {
                $name = $var_name;
            } else {
                $name = $svc_name ?: 'Service';
            }
        } else {
            // Fallback: try matching variation ID against yatoon_services.square_id
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
            $name = $wpdb->get_var($wpdb->prepare(
                "SELECT name FROM {$wpdb->prefix}yatoon_services WHERE square_id = %s LIMIT 1",
                $variation_sq_id
            )) ?: '';

            // Also try square_variation_id column if it exists
            if (!$name) {
                $name = $wpdb->get_var($wpdb->prepare(
                    "SELECT s.name FROM {$wpdb->prefix}yatoon_services s
                     INNER JOIN {$wpdb->prefix}yatoon_service_options so ON so.service_id = s.id
                     WHERE so.square_id = %s LIMIT 1",
                    $variation_sq_id
                )) ?: 'Service';
            }
        }

        $this->_variation_cache[$variation_sq_id] = $name;
        return $name;
    }

    private $_staff_cache = array();
    private function lookup_staff_name($team_member_id) {
        if (!$team_member_id) return '';
        if (isset($this->_staff_cache[$team_member_id])) {
            return $this->_staff_cache[$team_member_id];
        }
        global $wpdb;
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $name = $wpdb->get_var($wpdb->prepare(
            "SELECT name FROM {$wpdb->prefix}yatoon_staff WHERE square_id = %s LIMIT 1",
            $team_member_id
        ));
        $this->_staff_cache[$team_member_id] = $name ?: '';
        return $name ?: '';
    }

    /* ═══════════════════════════════════════════════════════════════
       AJAX: CANCEL BOOKING
    ═══════════════════════════════════════════════════════════════ */

    public function handle_cancel_booking() {
        check_ajax_referer('yatoon_portal_nonce', 'nonce');

        $customer = $this->get_customer_session();
        if (!$customer) wp_send_json_error(array('code' => 'not_logged_in'));

        $booking_id = isset($_POST['booking_id']) ? sanitize_text_field( wp_unslash( $_POST['booking_id'] ) ) : '';
        if (!$booking_id) wp_send_json_error(array('message' => 'Missing booking ID'));

        if ($this->is_local_only_mode()) {
            $local = $this->get_local_booking_row($booking_id, $customer);
            if (!$local) {
                wp_send_json_error(array('message' => __('Booking not found.', 'yatoon-booking-system')));
            }

            global $wpdb;
            if ( ! empty( $local->group_booking_id ) ) {
                $wpdb->update(
                    $wpdb->prefix . 'yatoon_bookings',
                    array('status' => 'cancelled'),
                    array('group_booking_id' => $local->group_booking_id),
                    array('%s'),
                    array('%s')
                );
            } else {
                $wpdb->update(
                    $wpdb->prefix . 'yatoon_bookings',
                    array('status' => 'cancelled'),
                    array('id' => intval($local->id)),
                    array('%s'),
                    array('%d')
                );
            }

            $notif = new YATOON_Notifications();
            $notif->send_cancel_email((array) $local);

            wp_send_json_success(array('message' => __('Appointment cancelled successfully.', 'yatoon-booking-system')));
        }

        // Verify this booking belongs to this customer
        $sq_booking = $this->square_api->get_booking($booking_id);
        if (is_wp_error($sq_booking) || empty($sq_booking['booking'])) {
            wp_send_json_error(array('message' => __('Booking not found.', 'yatoon-booking-system')));
        }
        $bk = $sq_booking['booking'];

        // Security check: booking must belong to this customer
        // Allow if booking has no customer_id (manually added by staff) OR matches any of customer's square IDs
        if (!empty($bk['customer_id'])) {
            $all_ids = isset($customer['all_square_ids']) ? $customer['all_square_ids'] : array();
            if (!empty($customer['square_id']) && !in_array($customer['square_id'], $all_ids)) {
                $all_ids[] = $customer['square_id'];
            }
            if (!empty($all_ids) && !in_array($bk['customer_id'], $all_ids)) {
                wp_send_json_error(array('message' => __('You do not have permission to cancel this booking.', 'yatoon-booking-system')));
            }
        }

        $version = intval($bk['version'] ?? 0);
        $result  = $this->square_api->cancel_booking($booking_id, $version);

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

        // Update local DB too
        global $wpdb;
        $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prefix . 'yatoon_bookings', // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
            array('status' => 'cancelled'),
            array('square_booking_id' => $booking_id)
        );

        // Send cancellation notification if customer opted in
        if ( ! empty( $_POST['notify_customer'] ) && intval( $_POST['notify_customer'] ) === 1 ) {
            $local = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
                "SELECT * FROM {$wpdb->prefix}yatoon_bookings WHERE square_booking_id = %s LIMIT 1",
                $booking_id
            ) );
            if ( $local ) {
                $notif = new YATOON_Notifications();
                $notif->send_cancel_email( $local );
            }
        }

        wp_send_json_success(array('message' => __('Appointment cancelled successfully.', 'yatoon-booking-system')));
    }

    /* ═══════════════════════════════════════════════════════════════
       AJAX: GET RESCHEDULE SLOTS
    ═══════════════════════════════════════════════════════════════ */

    public function handle_get_reschedule_slots() {
        check_ajax_referer('yatoon_portal_nonce', 'nonce');

        $customer = $this->get_customer_session();
        if (!$customer) wp_send_json_error(array('code' => 'not_logged_in'));

        $booking_id = sanitize_text_field($_POST['booking_id'] ?? '');
        $date       = sanitize_text_field($_POST['date'] ?? '');

        if (!$booking_id || !$date) {
            wp_send_json_error(array('message' => 'Missing parameters'));
        }

        if ($this->is_local_only_mode()) {
            $local = $this->get_local_booking_row($booking_id, $customer);
            if (!$local) {
                wp_send_json_error(array('message' => __('Booking not found.', 'yatoon-booking-system')));
            }

            $slots = Yatoon_Business_Hours::get_available_slots_for_date(
                $date,
                intval($local->service_id),
                intval($local->staff_id),
                intval($local->id)
            );

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

            $formatted = array();
            foreach ($slots as $slot) {
                $slot_value = is_array($slot) ? ($slot['datetime'] ?? '') : $slot;
                $slot_label = is_array($slot) ? ($slot['display'] ?? '') : '';
                $ts = $slot_value ? strtotime($slot_value) : false;
                if (false === $ts) {
                    continue;
                }
                $formatted[] = array(
                    'start_at' => gmdate('Y-m-d\\TH:i:s', $ts),
                    'display'  => $slot_label ? $slot_label : gmdate('g:i A', $ts),
                    'staff_ids'=> array((string) intval($local->staff_id)),
                );
            }

            wp_send_json_success(array('slots' => $formatted, 'date' => $date));
        }

        // Fetch original booking to get variation & duration
        $sq_booking = $this->square_api->get_booking($booking_id);
        if (is_wp_error($sq_booking) || empty($sq_booking['booking'])) {
            wp_send_json_error(array('message' => 'Booking not found'));
        }
        $bk  = $sq_booking['booking'];
        $seg = $bk['appointment_segments'][0] ?? null;
        if (!$seg) wp_send_json_error(array('message' => 'No segments'));

        $variation_id = isset($seg['service_variation_id']) ? $seg['service_variation_id'] : '';
        $staff_id     = isset($seg['team_member_id']) ? $seg['team_member_id'] : null;
        $timezone     = get_option('timezone_string') ?: 'America/New_York';
        $tz           = new DateTimeZone($timezone);

        $start = (new DateTime($date . ' 00:00:00', $tz))->format('c');
        $end   = (new DateTime($date . ' 23:59:59', $tz))->format('c');

        $resp = null;

        // Try with original service_variation_id first
        if ($variation_id) {
            $try = $this->square_api->search_availability($variation_id, $start, $end, $staff_id);
            if (!is_wp_error($try)) {
                // Check if Square returned a "not bookable" error
                $has_not_bookable = false;
                if (!empty($try['errors'])) {
                    foreach ($try['errors'] as $err) {
                        if (isset($err['code']) && strpos($err['code'], 'NOT_BOOKABLE') !== false) {
                            $has_not_bookable = true;
                        }
                        if (isset($err['detail']) && strpos($err['detail'], 'not bookable') !== false) {
                            $has_not_bookable = true;
                        }
                    }
                }
                if (!$has_not_bookable) {
                    $resp = $try;
                }
            }
        }

        // Fallback: find any bookable service variation and use that to get available slots
        if (!$resp || empty($resp['availabilities'])) {
            // Get all services from local DB and try each one until we get slots
            global $wpdb;
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
            $variations = $wpdb->get_col("SELECT square_id FROM {$wpdb->prefix}yatoon_service_options WHERE square_id != '' LIMIT 10");
            foreach ($variations as $var_id) {
                if ($var_id === $variation_id) continue; // already tried
                $try = $this->square_api->search_availability($var_id, $start, $end, $staff_id);
                if (!is_wp_error($try) && empty($try['errors']) && !empty($try['availabilities'])) {
                    $resp = $try;
                    break;
                }
            }
            // Last resort: try without staff filter
            if (!$resp || empty($resp['availabilities'])) {
                foreach ($variations as $var_id) {
                    $try = $this->square_api->search_availability($var_id, $start, $end, null);
                    if (!is_wp_error($try) && empty($try['errors']) && !empty($try['availabilities'])) {
                        $resp = $try;
                        break;
                    }
                }
            }
        }

        if (!$resp) {
            wp_send_json_error(array('message' => 'Unable to load available times. Please call us to reschedule.'));
        }

        $slots = array();
        foreach (($resp['availabilities'] ?? array()) as $av) {
            if (empty($av['start_at'])) continue;
            $t = new DateTime($av['start_at']);
            $t->setTimezone($tz);
            $slots[] = array(
                'start_at' => $av['start_at'],
                'display'  => $t->format('g:i A'),
                'staff_ids'=> array_column($av['appointment_segments'] ?? array(), 'team_member_id'),
            );
        }

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

    /* ═══════════════════════════════════════════════════════════════
       AJAX: RESCHEDULE BOOKING
    ═══════════════════════════════════════════════════════════════ */

    public function handle_reschedule_booking() {
        check_ajax_referer('yatoon_portal_nonce', 'nonce');

        $customer = $this->get_customer_session();
        if (!$customer) wp_send_json_error(array('code' => 'not_logged_in'));

        $booking_id = sanitize_text_field($_POST['booking_id'] ?? '');
        $new_start  = sanitize_text_field($_POST['new_start_at'] ?? '');
        $staff_id   = sanitize_text_field($_POST['staff_id'] ?? '');

        if (!$booking_id || !$new_start) {
            wp_send_json_error(array('message' => 'Missing parameters'));
        }

        if ($this->is_local_only_mode()) {
            $local = $this->get_local_booking_row($booking_id, $customer);
            if (!$local) {
                wp_send_json_error(array('message' => __('Booking not found.', 'yatoon-booking-system')));
            }

            $timezone = get_option('timezone_string') ?: 'America/New_York';
            $tz = new DateTimeZone($timezone);
            $new_start_dt = new DateTime($new_start, $tz);
            $duration_min = max(1, intval((strtotime($local->end_time) - strtotime($local->start_time)) / 60));
            $new_end_dt = clone $new_start_dt;
            $new_end_dt->modify("+{$duration_min} minutes");

            if (!Yatoon_Business_Hours::is_valid_slot(
                $new_start_dt->format('Y-m-d\\TH:i:s'),
                intval($local->service_id),
                intval($local->staff_id),
                intval($local->id)
            )) {
                wp_send_json_error(array('message' => __('That time slot is no longer available. Please choose another.', 'yatoon-booking-system')));
            }

            global $wpdb;
            $wpdb->update(
                $wpdb->prefix . 'yatoon_bookings',
                array(
                    'start_time' => $new_start_dt->format('Y-m-d H:i:s'),
                    'end_time'   => $new_end_dt->format('Y-m-d H:i:s'),
                    'status'     => 'pending',
                ),
                array('id' => intval($local->id)),
                array('%s', '%s', '%s'),
                array('%d')
            );

            $updated = $this->get_local_booking_row($booking_id, $customer);
            if ($updated) {
                $notif = new YATOON_Notifications();
                $notif->send_reschedule_email((array) $updated);
            }

            wp_send_json_success(array(
                'message' => __('Appointment rescheduled successfully!', 'yatoon-booking-system'),
            ));
        }

        // Fetch original
        $sq_booking = $this->square_api->get_booking($booking_id);
        if (is_wp_error($sq_booking) || empty($sq_booking['booking'])) {
            wp_send_json_error(array('message' => 'Booking not found'));
        }
        $bk = $sq_booking['booking'];

        // Security check: allow if no customer_id or matches any of customer's IDs
        if (!empty($bk['customer_id'])) {
            $all_ids = isset($customer['all_square_ids']) ? $customer['all_square_ids'] : array();
            if (!empty($customer['square_id']) && !in_array($customer['square_id'], $all_ids)) {
                $all_ids[] = $customer['square_id'];
            }
            if (!empty($all_ids) && !in_array($bk['customer_id'], $all_ids)) {
                wp_send_json_error(array('message' => 'Permission denied'));
            }
        }

        // Build minimal update payload - only fields Square accepts on PUT /v2/bookings/:id
        $clean_segments = array();
        if (!empty($bk['appointment_segments'])) {
            foreach ($bk['appointment_segments'] as $seg) {
                $clean_seg = array(
                    'service_variation_id'      => isset($seg['service_variation_id']) ? $seg['service_variation_id'] : '',
                    'service_variation_version' => isset($seg['service_variation_version']) ? intval($seg['service_variation_version']) : 0,
                    'team_member_id'            => $staff_id ? $staff_id : (isset($seg['team_member_id']) ? $seg['team_member_id'] : ''),
                    'duration_minutes'          => isset($seg['duration_minutes']) ? intval($seg['duration_minutes']) : 60,
                );
                // Only include team_member_id if not empty
                if (empty($clean_seg['team_member_id'])) unset($clean_seg['team_member_id']);
                $clean_segments[] = $clean_seg;
            }
        }

        $updated_bk = array(
            'version'              => isset($bk['version']) ? intval($bk['version']) : 0,
            'start_at'             => $new_start,
            'location_id'          => isset($bk['location_id']) ? $bk['location_id'] : get_option('yatoon_square_location_id'),
            'appointment_segments' => $clean_segments,
        );
        if (!empty($bk['customer_id'])) {
            $updated_bk['customer_id'] = $bk['customer_id'];
        }

        $result = $this->square_api->update_booking($booking_id, $updated_bk);

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

        // Update local DB
        $timezone = get_option('timezone_string') ?: 'America/New_York';
        $tz       = new DateTimeZone($timezone);
        $start_dt = new DateTime($new_start);
        $start_dt->setTimezone($tz);
        $end_dt   = clone $start_dt;
        $total_min = array_sum(array_column($bk['appointment_segments'] ?? array(), 'duration_minutes'));
        $end_dt->modify("+{$total_min} minutes");

        global $wpdb;
        $wpdb->update($wpdb->prefix . 'yatoon_bookings', array( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
            'start_time' => $start_dt->format('Y-m-d H:i:s'),
            'end_time'   => $end_dt->format('Y-m-d H:i:s'),
            'status'     => 'confirmed',
        ), array('square_booking_id' => $booking_id));

        // Send reschedule notification if customer opted in
        if ( ! empty( $_POST['notify_customer'] ) && intval( $_POST['notify_customer'] ) === 1 ) {
            $local = $wpdb->get_row( $wpdb->prepare(
                "SELECT * FROM {$wpdb->prefix}yatoon_bookings WHERE square_booking_id = %s LIMIT 1",
                $booking_id
            ) );
            if ( $local ) {
                $notif = new YATOON_Notifications();
                $notif->send_reschedule_email( $local );
            }
        }

        wp_send_json_success(array(
            'message' => __('Appointment rescheduled successfully!', 'yatoon-booking-system'),
        ));
    }

    /* ═══════════════════════════════════════════════════════════════
       UTILITY
    ═══════════════════════════════════════════════════════════════ */

    private function normalize_phone(string $phone) {
        $digits = preg_replace('/\D/', '', $phone);
        if (strlen($digits) === 10) return '+1' . $digits;
        if (strlen($digits) === 11 && $digits[0] === '1') return '+' . $digits;
        return '';
    }
}
