<?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 {
    private static $instance;

    /* Session key prefix. 4.6.2: nothing reads it any more -- the identity
       moved out of $_SESSION into the signed cookie below -- but it stays a
       public constant so an existing reference to it cannot fatal. */
    const SESSION_KEY  = 'yatoon_portal_customer';
    /* Durable first-party customer session (PHP sessions are not shared reliably on every host). */
    const AUTH_COOKIE  = 'yatoon_customer_auth';
    const AUTH_PREFIX  = 'yatoon_customer_auth_';
    const AUTH_TTL     = 43200; // 12 hours.
    /*
     * 4.6.2: browser handle for state that exists BEFORE the customer is
     * signed in -- the half-finished OTP login. The signed auth cookie above
     * cannot carry it, because at that point there is nothing authenticated to
     * sign. Same HMAC-signed opaque-token shape as AUTH_COOKIE, and like it
     * the value is meaningless on its own: the data lives in a transient.
     * Only ever issued from admin-ajax responses, so portal pages themselves
     * stay cacheable -- which is the entire point of dropping PHP sessions.
     */
    const SID_COOKIE     = 'yatoon_portal_sid';
    const PENDING_PREFIX = 'yatoon_portal_pending_';
    const PENDING_TTL    = 900; // 15 minutes -- an OTP login is finished or abandoned well inside this.
    /* One-time OAuth handoff, consumed by the final customer page. */
    const HANDOFF_PREFIX = 'yatoon_customer_handoff_';
    const HANDOFF_TTL    = 120; // 2 minutes.
    /* 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() {
        self::$instance = $this;
        $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'));

        // OAuth callbacks can run through a different proxy/worker than the
        // public booking page. Consume a one-time handoff on the destination
        // request so the customer session is established on the final page.
        add_action('template_redirect', array($this, 'consume_login_handoff'), -20);

        // 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_update_profile',
            'yatoon_portal_redeem_reward',
            'yatoon_portal_cancel_booking',
            'yatoon_portal_get_reschedule_slots',
            'yatoon_portal_reschedule_booking',
            'yatoon_portal_get_change_service_options',
            'yatoon_portal_get_change_service_slots',
            'yatoon_portal_change_booking_service',
            'yatoon_portal_ai_assistant_message',
        );
        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)));
        }

        // The booking-page banner uses a regular POST so signing out still
        // works when JavaScript is blocked or an optimization plugin delays it.
        // This clears only Yatoon's customer identity, never the WordPress user.
        add_action( 'admin_post_yatoon_portal_logout_return', array( $this, 'handle_logout_return' ) );
        add_action( 'admin_post_nopriv_yatoon_portal_logout_return', array( $this, 'handle_logout_return' ) );
    }

    public static function get_instance() {
        return self::$instance;
    }

    /**
     * Resolve the public customer-account page without requiring the merchant
     * to copy its URL into a second settings field.
     */
    public static function portal_url() {
        $configured = trim( (string) get_option( 'yatoon_portal_url', '' ) );
        if ( $configured ) {
            return esc_url_raw( $configured );
        }

        $page_id = absint( get_option( 'yatoon_portal_page_id', 0 ) );
        if ( $page_id && 'publish' === get_post_status( $page_id ) ) {
            return esc_url_raw( get_permalink( $page_id ) );
        }

        global $wpdb;
        $like = '%' . $wpdb->esc_like( '[yatoon_customer_portal' ) . '%';
        $page_id = absint( $wpdb->get_var( $wpdb->prepare(
            "SELECT ID FROM {$wpdb->posts} WHERE post_type='page' AND post_status='publish' AND post_content LIKE %s ORDER BY ID ASC LIMIT 1",
            $like
        ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
        if ( $page_id ) {
            update_option( 'yatoon_portal_page_id', $page_id, false );
            return esc_url_raw( get_permalink( $page_id ) );
        }

        return '';
    }

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

    /*
     * 4.6.2: PHP's native session is gone from this file.
     *
     * start_session() used to run on every portal render, which stamped a
     * PHPSESSID cookie on the page and made it uncacheable for every visitor
     * -- and a native session_start() on the front end is a hard failure in
     * the WP.org Plugin Check. It was also redundant: the signed opaque
     * cookie plus transient below was already the cross-request source of
     * truth (the session only ever mirrored it for the length of one
     * request), which is why removing it does not sign anyone out. The three
     * jobs the session was doing are now:
     *
     *   - the signed-in identity  -> AUTH_COOKIE + transient (already existed)
     *   - within-one-request reuse -> $customer_cache below
     *   - the half-finished login  -> SID_COOKIE + a pending transient
     */

    /** Request-scoped copy of the signed-in customer; replaces $_SESSION[ SESSION_KEY ]. */
    private $customer_cache = null;

    /** Resolve the customer before a theme or page builder sends HTML. */
    public function bootstrap_session() {
        // Restore the customer from the signed cookie. Kept as a public method
        // with the same name because class-yatoon-frontend.php calls it.
        $this->get_customer_session();
    }

    /**
     * 4.6.2: the equivalent of the session_regenerate_id( true ) that used to
     * guard every login point against session fixation.
     *
     * persist_customer_auth() reuses whatever token the browser already
     * presents, so without this a token planted before sign-in would survive
     * into the authenticated session -- the same fixation hole regenerating
     * the id was there to close. Revoking first forces a brand-new token, and
     * the pre-login handle is retired with it.
     */
    private function rotate_portal_session() {
        $this->clear_customer_auth();
        $this->clear_pending_login();
        $this->clear_portal_session_id();
        $this->customer_cache = null;
    }

    /**
     * Signed, opaque, pre-login browser handle. Returns '' when the browser
     * presents none and $create is false, so read paths never set a cookie.
     */
    private function portal_session_id( $create = false ) {
        $raw = isset( $_COOKIE[ self::SID_COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::SID_COOKIE ] ) ) : '';
        if ( $raw && 1 === substr_count( $raw, '.' ) ) {
            list( $sid, $signature ) = explode( '.', $raw, 2 );
            if ( preg_match( '/^[A-Za-z0-9]{32}$/', $sid )
                && preg_match( '/^[a-f0-9]{64}$/', $signature )
                && hash_equals( hash_hmac( 'sha256', $sid, wp_salt( 'auth' ) ), $signature ) ) {
                return $sid;
            }
        }
        return $create ? $this->issue_portal_session_id() : '';
    }

    private function issue_portal_session_id() {
        $sid   = wp_generate_password( 32, false, false );
        $value = $sid . '.' . hash_hmac( 'sha256', $sid, wp_salt( 'auth' ) );
        $this->set_portal_cookie( self::SID_COOKIE, $value, time() + self::PENDING_TTL );
        // Visible to the rest of this same request, exactly as the auth cookie is.
        $_COOKIE[ self::SID_COOKIE ] = $value;
        return $sid;
    }

    private function clear_portal_session_id() {
        $this->set_portal_cookie( self::SID_COOKIE, '', time() - HOUR_IN_SECONDS );
        unset( $_COOKIE[ self::SID_COOKIE ] );
    }

    /** One place for the cookie flags both portal cookies share. */
    private function set_portal_cookie( $name, $value, $expires ) {
        if ( headers_sent() ) { return; }
        setcookie(
            $name,
            $value,
            array(
                'expires'  => $expires,
                // Customers move between /account/, booking pages and OAuth callbacks.
                'path'     => '/',
                'domain'   => defined( 'COOKIE_DOMAIN' ) && COOKIE_DOMAIN ? COOKIE_DOMAIN : '',
                'secure'   => is_ssl(),
                'httponly' => true,
                'samesite' => 'Lax',
            )
        );
    }

    /**
     * 4.6.2: the pending-login bag that used to live in four $_SESSION keys.
     * Server-side in a transient, keyed by the signed handle above, so the
     * browser still holds nothing but an unguessable token.
     */
    private function pending_login_state() {
        $sid = $this->portal_session_id();
        if ( ! $sid ) { return array(); }
        $state = get_transient( self::PENDING_PREFIX . hash( 'sha256', $sid ) );
        return is_array( $state ) ? $state : array();
    }

    /** Merge changes into the pending bag; a null value removes that key. */
    private function update_pending_login( array $changes ) {
        $state = $this->pending_login_state();
        foreach ( $changes as $key => $value ) {
            if ( null === $value ) { unset( $state[ $key ] ); continue; }
            $state[ $key ] = $value;
        }
        $sid = $this->portal_session_id( true );
        set_transient( self::PENDING_PREFIX . hash( 'sha256', $sid ), $state, self::PENDING_TTL );
    }

    /** Create a short-lived, opaque and single-use customer login handoff. */
    public function create_login_handoff( array $customer ) {
        if ( empty( $customer ) ) {
            return '';
        }
        $token = wp_generate_password( 48, false, false );
        set_transient(
            self::HANDOFF_PREFIX . hash( 'sha256', $token ),
            array( 'customer' => $customer, 'created_at' => time() ),
            self::HANDOFF_TTL
        );
        return $token;
    }

    /**
     * Complete OAuth login on the actual booking/account page, then remove
     * the one-time credential from the address bar before rendering HTML.
     */
    public function consume_login_handoff() {
        if ( is_admin() || wp_doing_ajax() || empty( $_GET['yatoon_customer_handoff'] ) ) {
            return;
        }
        $token = sanitize_text_field( wp_unslash( $_GET['yatoon_customer_handoff'] ) );
        if ( ! preg_match( '/^[A-Za-z0-9]{48}$/', $token ) ) {
            return;
        }
        $key = self::HANDOFF_PREFIX . hash( 'sha256', $token );
        $payload = get_transient( $key );
        delete_transient( $key );
        if ( ! is_array( $payload ) || empty( $payload['customer'] ) || ! is_array( $payload['customer'] ) ) {
            return;
        }

        if ( ! defined( 'DONOTCACHEPAGE' ) ) {
            define( 'DONOTCACHEPAGE', true );
        }
        // 4.6.2: was start_session() + session_regenerate_id(); the handoff is
        // a login, so it still retires any pre-existing token first.
        $this->rotate_portal_session();
        $this->set_customer_session( $payload['customer'] );

        $clean_url = remove_query_arg( 'yatoon_customer_handoff' );
        wp_safe_redirect( $clean_url );
        exit;
    }

    private function set_customer_session( array $customer ) {
        // 4.6.2: the session copy became a plain property. The signed cookie
        // plus transient written by persist_customer_auth() was already what
        // carried the login between requests, so nobody is signed out by this.
        $this->customer_cache = $customer;
        $this->persist_customer_auth( $customer );
    }

    private function get_customer_session() {
        // 4.6.2: request-scoped cache in place of $_SESSION[ SESSION_KEY ].
        // Same purpose it always served -- this method is called a dozen times
        // per request and must not re-read the transient every time.
        if ( is_array( $this->customer_cache ) && ! empty( $this->customer_cache ) ) {
            return $this->customer_cache;
        }

        $token = $this->validated_customer_auth_token();
        if ( ! $token ) {
            return null;
        }
        $customer = get_transient( self::AUTH_PREFIX . hash( 'sha256', $token ) );
        if ( ! is_array( $customer ) || empty( $customer ) ) {
            $this->clear_customer_auth();
            return null;
        }

        // 4.6.2: hold it for the rest of this request while keeping the
        // signed, opaque cookie as the cross-request source of truth.
        $this->customer_cache = $customer;
        return $customer;
    }

    /** Return a valid opaque customer token from the signed browser cookie. */
    private function validated_customer_auth_token() {
        $raw = isset( $_COOKIE[ self::AUTH_COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::AUTH_COOKIE ] ) ) : '';
        if ( ! $raw || 1 !== substr_count( $raw, '.' ) ) {
            return '';
        }
        list( $token, $signature ) = explode( '.', $raw, 2 );
        if ( ! preg_match( '/^[A-Za-z0-9]{48}$/', $token ) || ! preg_match( '/^[a-f0-9]{64}$/', $signature ) ) {
            return '';
        }
        $expected = hash_hmac( 'sha256', $token, wp_salt( 'auth' ) );
        return hash_equals( $expected, $signature ) ? $token : '';
    }

    /** Persist only an opaque signed token in the browser; identity stays server-side. */
    private function persist_customer_auth( array $customer ) {
        $token = $this->validated_customer_auth_token();
        if ( ! $token ) {
            $token = wp_generate_password( 48, false, false );
        }
        $signature = hash_hmac( 'sha256', $token, wp_salt( 'auth' ) );
        $value = $token . '.' . $signature;
        set_transient( self::AUTH_PREFIX . hash( 'sha256', $token ), $customer, self::AUTH_TTL );
        if ( ! headers_sent() ) {
            setcookie(
                self::AUTH_COOKIE,
                $value,
                array(
                    'expires'  => time() + self::AUTH_TTL,
                    'path'     => '/',
                    'domain'   => defined( 'COOKIE_DOMAIN' ) && COOKIE_DOMAIN ? COOKIE_DOMAIN : '',
                    'secure'   => is_ssl(),
                    'httponly' => true,
                    'samesite' => 'Lax',
                )
            );
        }
        // The OAuth callback and any same-request rendering can see it too.
        $_COOKIE[ self::AUTH_COOKIE ] = $value;
    }

    /** Revoke the durable customer token and expire it on every customer page. */
    private function clear_customer_auth() {
        $token = $this->validated_customer_auth_token();
        if ( $token ) {
            delete_transient( self::AUTH_PREFIX . hash( 'sha256', $token ) );
        }
        if ( ! headers_sent() ) {
            setcookie(
                self::AUTH_COOKIE,
                '',
                array(
                    'expires'  => time() - HOUR_IN_SECONDS,
                    'path'     => '/',
                    'domain'   => defined( 'COOKIE_DOMAIN' ) && COOKIE_DOMAIN ? COOKIE_DOMAIN : '',
                    'secure'   => is_ssl(),
                    'httponly' => true,
                    'samesite' => 'Lax',
                )
            );
        }
        unset( $_COOKIE[ self::AUTH_COOKIE ] );
    }

    /** Return the verified portal identity for booking-form prefill. */
    public function current_customer() {
        return $this->get_customer_session();
    }

    /**
     * Establish a customer session from a validated Google OIDC assertion.
     * Google identity remains local; Square customer IDs are business records.
     */
    public function login_from_google( array $claims ) {
        $email = sanitize_email( $claims['email'] ?? '' );
        if ( ! $email || empty( $claims['sub'] ) ) {
            return new WP_Error( 'google_identity_invalid', __( 'Google did not return a verified email address.', 'yatoon-booking-system' ) );
        }
        global $wpdb;
        $identity_link = $wpdb->get_row( $wpdb->prepare(
            "SELECT account_id,external_account_id FROM {$wpdb->prefix}yatoon_identity_links WHERE account_type='customer' AND provider='google' AND provider_subject=%s LIMIT 1",
            sanitize_text_field( $claims['sub'] )
        ) );
        $local_client_id = (int) ( $identity_link->account_id ?? 0 );
        $local = null;
        if ( $local_client_id ) {
            $local = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}yatoon_clients WHERE id=%d LIMIT 1", $local_client_id ) );
        } else {
            // A verified Google email may prefill an existing record only when
            // the match is unambiguous. Duplicate emails are never auto-merged.
            $matches = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}yatoon_clients WHERE LOWER(customer_email)=LOWER(%s) ORDER BY id DESC LIMIT 2", $email ) );
            if ( 1 === count( $matches ) ) {
                $local = $matches[0];
                $local_client_id = (int) $local->id;
            }
        }
        $square_ids = array();
        $square_customer = null;
        if ( ! $this->is_local_only_mode() ) {
            $linked_square_id = sanitize_text_field( $identity_link->external_account_id ?? '' );
            if ( $linked_square_id ) {
                $linked_customer = $this->square_api->get_customer( $linked_square_id );
                if ( is_array( $linked_customer ) && ! empty( $linked_customer['id'] ) ) {
                    $square_ids[] = $linked_customer['id'];
                    $square_customer = $linked_customer;
                }
            }
            $result = $this->square_api->search_customers( $email, null );
            $customers = ! is_wp_error( $result ) ? (array) ( $result['customers'] ?? array() ) : array();
            foreach ( $customers as $candidate ) {
                if ( ! empty( $candidate['id'] ) ) { $square_ids[] = sanitize_text_field( $candidate['id'] ); }
            }
            if ( ! $square_customer ) { $square_customer = $customers[0] ?? null; }
        }
        $customer = array(
            'name' => sanitize_text_field( $local->customer_name ?? $claims['name'] ?? 'Valued Customer' ),
            'email' => $email,
            'phone' => sanitize_text_field( $local->customer_phone ?? $square_customer['phone_number'] ?? '' ),
            'square_id' => sanitize_text_field( $square_customer['id'] ?? '' ),
            'all_square_ids' => array_values( array_unique( $square_ids ) ),
            'local_client_id' => $local_client_id,
            'google_sub' => sanitize_text_field( $claims['sub'] ),
            'identity_provider' => 'google',
            'identity_sub' => sanitize_text_field( $claims['sub'] ),
            'picture' => esc_url_raw( $claims['picture'] ?? '' ),
            'local_mode' => $this->is_local_only_mode(),
            'needs_square_link' => ! $this->is_local_only_mode() && empty( $square_ids ),
            'needs_account_link' => empty( $local_client_id ) && empty( $square_ids ),
        );
        // 4.6.2: was start_session() + session_regenerate_id( true ) -- see
        // rotate_portal_session() for how fixation protection survives the
        // removal of PHP sessions.
        $this->rotate_portal_session();
        $this->set_customer_session( $customer );
        return $customer;
    }

    /** Establish a customer session from a verified Apple identity token. */
    public function login_from_apple( array $claims ) {
        $subject = sanitize_text_field( $claims['sub'] ?? '' );
        $email = sanitize_email( $claims['email'] ?? '' );
        if ( ! $subject ) { return new WP_Error( 'apple_identity_invalid', __( 'Apple did not return a valid identity.', 'yatoon-booking-system' ) ); }
        global $wpdb;
        $identity_link = $wpdb->get_row( $wpdb->prepare(
            "SELECT account_id,external_account_id,verified_email FROM {$wpdb->prefix}yatoon_identity_links WHERE account_type='customer' AND provider='apple' AND provider_subject=%s LIMIT 1",
            $subject
        ) );
        if ( ! $email ) { $email = sanitize_email( $identity_link->verified_email ?? '' ); }
        $local_client_id = absint( $identity_link->account_id ?? 0 );
        $local = $local_client_id ? $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}yatoon_clients WHERE id=%d LIMIT 1", $local_client_id ) ) : null;
        if ( ! $local && $email ) {
            $matches = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}yatoon_clients WHERE LOWER(customer_email)=LOWER(%s) ORDER BY id DESC LIMIT 2", $email ) );
            if ( 1 === count( $matches ) ) { $local = $matches[0]; $local_client_id = absint( $local->id ); }
        }
        $square_ids = array();
        $square_customer = null;
        if ( ! $this->is_local_only_mode() ) {
            $linked_square_id = sanitize_text_field( $identity_link->external_account_id ?? '' );
            if ( $linked_square_id ) {
                $linked = $this->square_api->get_customer( $linked_square_id );
                if ( is_array( $linked ) && ! empty( $linked['id'] ) ) { $square_customer = $linked; $square_ids[] = sanitize_text_field( $linked['id'] ); }
            }
            if ( $email && ! $square_customer ) {
                $result = $this->square_api->search_customers( $email, null );
                $matches = ! is_wp_error( $result ) ? (array) ( $result['customers'] ?? array() ) : array();
                foreach ( $matches as $match ) { if ( ! empty( $match['id'] ) ) { $square_ids[] = sanitize_text_field( $match['id'] ); } }
                $square_customer = $matches[0] ?? null;
            }
        }
        $customer = array(
            'name' => sanitize_text_field( $local->customer_name ?? $claims['name'] ?? 'Valued Customer' ),
            'email' => $email,
            'phone' => sanitize_text_field( $local->customer_phone ?? $square_customer['phone_number'] ?? '' ),
            'square_id' => sanitize_text_field( $square_customer['id'] ?? '' ),
            'all_square_ids' => array_values( array_unique( $square_ids ) ),
            'local_client_id' => $local_client_id,
            'apple_sub' => $subject,
            'identity_provider' => 'apple',
            'identity_sub' => $subject,
            'local_mode' => $this->is_local_only_mode(),
            'needs_square_link' => ! $this->is_local_only_mode() && empty( $square_ids ),
            'needs_account_link' => empty( $local_client_id ) && empty( $square_ids ),
            'private_email' => ! empty( $claims['is_private_email'] ),
        );
        // 4.6.2: was start_session() + session_regenerate_id( true ) -- see
        // rotate_portal_session() for how fixation protection survives the
        // removal of PHP sessions.
        $this->rotate_portal_session();
        $this->set_customer_session( $customer );
        return $customer;
    }

    /** Establish a customer session from a Facebook profile validated server-side. */
    public function login_from_facebook( array $claims ) {
        $subject = sanitize_text_field( $claims['sub'] ?? '' );
        $email = sanitize_email( $claims['email'] ?? '' );
        if ( ! $subject ) { return new WP_Error( 'facebook_identity_invalid', __( 'Facebook did not return a valid identity.', 'yatoon-booking-system' ) ); }
        global $wpdb;
        $identity_link = $wpdb->get_row( $wpdb->prepare(
            "SELECT account_id,external_account_id,verified_email FROM {$wpdb->prefix}yatoon_identity_links WHERE account_type='customer' AND provider='facebook' AND provider_subject=%s LIMIT 1", $subject
        ) );
        if ( ! $email ) { $email = sanitize_email( $identity_link->verified_email ?? '' ); }
        $local_client_id = absint( $identity_link->account_id ?? 0 );
        $local = $local_client_id ? $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}yatoon_clients WHERE id=%d LIMIT 1", $local_client_id ) ) : null;
        if ( ! $local && $email ) {
            $matches = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}yatoon_clients WHERE LOWER(customer_email)=LOWER(%s) ORDER BY id DESC LIMIT 2", $email ) );
            if ( 1 === count( $matches ) ) { $local = $matches[0]; $local_client_id = absint( $local->id ); }
        }
        $square_ids = array(); $square_customer = null;
        if ( ! $this->is_local_only_mode() ) {
            $linked_square_id = sanitize_text_field( $identity_link->external_account_id ?? '' );
            if ( $linked_square_id ) { $linked = $this->square_api->get_customer( $linked_square_id ); if ( is_array( $linked ) && ! empty( $linked['id'] ) ) { $square_customer = $linked; $square_ids[] = sanitize_text_field( $linked['id'] ); } }
            if ( $email && ! $square_customer ) { $result = $this->square_api->search_customers( $email, null ); $matches = ! is_wp_error( $result ) ? (array) ( $result['customers'] ?? array() ) : array(); foreach ( $matches as $match ) { if ( ! empty( $match['id'] ) ) { $square_ids[] = sanitize_text_field( $match['id'] ); } } $square_customer = $matches[0] ?? null; }
        }
        $customer = array(
            'name' => sanitize_text_field( $local->customer_name ?? $claims['name'] ?? 'Valued Customer' ), 'email' => $email,
            'phone' => sanitize_text_field( $local->customer_phone ?? $square_customer['phone_number'] ?? '' ), 'square_id' => sanitize_text_field( $square_customer['id'] ?? '' ),
            'all_square_ids' => array_values( array_unique( $square_ids ) ), 'local_client_id' => $local_client_id,
            'facebook_sub' => $subject, 'identity_provider' => 'facebook', 'identity_sub' => $subject,
            'local_mode' => $this->is_local_only_mode(), 'needs_square_link' => ! $this->is_local_only_mode() && empty( $square_ids ),
            'needs_account_link' => empty( $local_client_id ) && empty( $square_ids ),
        );
        // 4.6.2: was start_session() + session_regenerate_id( true ).
        $this->rotate_portal_session(); $this->set_customer_session( $customer ); return $customer;
    }

    /**
     * Sign the customer out.
     *
     * 4.6.2: the old body emptied $_SESSION, expired the PHPSESSID cookie on
     * every plausible path and called session_destroy(). None of that is
     * needed once the identity no longer lives in a PHP session: revoking the
     * auth token deletes the server-side transient the cookie points at, so
     * even a replayed cookie resolves to nothing. A PHPSESSID left in a
     * browser from before the upgrade is inert -- this plugin no longer reads
     * it, and PHP's own garbage collection retires it.
     */
    private function clear_session() {
        $this->clear_customer_auth();
        $this->clear_pending_login();
        $this->clear_portal_session_id();
        $this->customer_cache = null;
    }

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

    private function get_otp_verify_key( $phone ) {
        // 4.6.2: session_id() -> the signed pre-login handle, which is what
        // that call was really standing in for: a per-browser value that
        // exists before sign-in. Created on demand so a browser mid-login
        // counts its own failed attempts rather than sharing a bucket.
        return self::OTP_PREFIX . 'verify_' . md5( $phone . '|' . $this->get_request_ip() . '|' . $this->portal_session_id( true ) );
    }

    /**
     * 4.6.2: dropping the four $_SESSION keys became dropping the one
     * transient that replaced them; the handle cookie itself is left in place
     * so an immediate retry does not need a new one.
     */
    private function clear_pending_login() {
        $sid = $this->portal_session_id();
        if ( $sid ) {
            delete_transient( self::PENDING_PREFIX . hash( 'sha256', $sid ) );
        }
    }

    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;
            }
            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_customer_by_email( string $email ) {
        global $wpdb;
        $email = sanitize_email( $email );
        if ( ! $email ) { return null; }
        $row = $wpdb->get_row( $wpdb->prepare(
            "SELECT customer_name,customer_email,customer_phone FROM {$wpdb->prefix}yatoon_bookings WHERE LOWER(customer_email)=LOWER(%s) ORDER BY id DESC LIMIT 1",
            $email
        ) );
        if ( ! $row ) { return null; }
        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,
        );
    }

    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;
    }

    private function get_local_booking_timezone( $booking ) {
        $snapshot = trim( (string) ( $booking->booking_timezone ?? '' ) );
        if ( $snapshot !== '' ) {
            try {
                return new DateTimeZone( $snapshot );
            } catch ( Exception $e ) {
                // Legacy rows fall back to the current location/platform zone.
            }
        }
        return yatoon_timezone( $booking->location_id ?? null, 'local' );
    }

    private function get_local_booking_group( $booking ) {
        global $wpdb;
        if ( empty( $booking->group_booking_id ) ) {
            return array( $booking );
        }
        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT * FROM {$wpdb->prefix}yatoon_bookings
             WHERE group_booking_id = %s
               AND LOWER(COALESCE(status,'')) NOT IN ('cancelled','canceled','cancelled_by_customer','cancelled_by_seller','declined','no_show')
             ORDER BY service_order ASC, start_time ASC, id ASC",
            $booking->group_booking_id
        ) );
        return $rows ?: array( $booking );
    }

    private function local_staff_window_available( $staff_id, DateTime $start, DateTime $end, array $group_rows ) {
        global $wpdb;
        $ids = array_map( static function ( $row ) {
 return intval( $row->id );
}, $group_rows );
        $exclude = $ids ? ' AND id NOT IN (' . implode( ',', $ids ) . ')' : '';
        $conflict = $wpdb->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','declined','no_show')
               AND start_time < %s AND end_time > %s {$exclude} LIMIT 1",
            intval( $staff_id ), $end->format( 'Y-m-d H:i:s' ), $start->format( 'Y-m-d H:i:s' )
        ) );
        return ! $conflict && ! YATOON_Business_Hours::has_staff_time_off_conflict( $staff_id, $start, $end, $start->getTimezone() );
    }

    private function get_local_group_reschedule_slots( $booking, $date ) {
        $rows = $this->get_local_booking_group( $booking );
        $tz   = $this->get_local_booking_timezone( $booking );
        $base = $rows[0];
        $base_old = new DateTime( $base->start_time, $tz );
        $ids = array_map( static function ( $row ) {
 return intval( $row->id );
}, $rows );
        $slots = YATOON_Business_Hours::get_available_slots_for_date(
            $date, intval( $base->service_id ), intval( $base->staff_id ), $ids, $tz
        );
        if ( count( $rows ) <= 1 ) { return $slots;
        }

        return array_values( array_filter( $slots, function ( $slot ) use ( $rows, $base_old, $tz ) {
            $new_base = new DateTime( $slot['datetime'], $tz );
            foreach ( $rows as $row ) {
                $old_start = new DateTime( $row->start_time, $tz );
                $old_end   = new DateTime( $row->end_time, $tz );
                $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->local_staff_window_available( $row->staff_id, $new_start, $new_end, $rows ) ) { return false;
                }
            }
            return true;
        } ) );
    }

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

    public function render_shortcode( $atts ) {
        if ( class_exists( 'YATOON_Frontend' ) && YATOON_Frontend::get_instance() ) {
            YATOON_Frontend::get_instance()->mark_customer_page_dynamic();
            YATOON_Frontend::get_instance()->render_frontend_assets();
        }
        $this->bootstrap_session();
        $this->render_portal_css();
        $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')
                     || ( function_exists( 'has_block' ) && has_block( 'yatoon/customer-portal', $post ) );
        if (!$needs_portal) { return;
        }
        $this->render_portal_css();
    }

    /**
     * Split out so render_shortcode() can also call it directly -- an
     * Elementor-embedded portal isn't detectable via has_shortcode()/
     * has_block() above (Elementor stores widget content separately, not as
     * text in post_content), so calling this at the point the shortcode
     * actually renders guarantees the CSS still loads for that case too.
     * Idempotent.
     */
    public function render_portal_css() {
        static $already_rendered = false;
        if ( $already_rendered ) { return;
        }
        $already_rendered = true;

        $portal_css_file = SBS_PLUGIN_DIR . 'assets/css/customer-portal' . YATOON_ASSET_SUFFIX . '.css';
        $polish_css_file = SBS_PLUGIN_DIR . 'assets/css/apple-polish' . YATOON_ASSET_SUFFIX . '.css';
        $design_css_file = SBS_PLUGIN_DIR . 'assets/css/yatoon-design-system' . YATOON_ASSET_SUFFIX . '.css';
        $portal_js_file  = SBS_PLUGIN_DIR . 'assets/js/customer-portal' . YATOON_ASSET_SUFFIX . '.js';
        $asset_version = static function ( $file ) {
            return SBS_VERSION . '.' . ( is_file( $file ) ? (string) filemtime( $file ) : '0' );
        };

        wp_enqueue_style(
            'sbs-portal-css',
            SBS_PLUGIN_URL . 'assets/css/customer-portal' . YATOON_ASSET_SUFFIX . '.css',
            array(),
            $asset_version( $portal_css_file )
        );
        wp_enqueue_style(
            'yatoon-apple-polish',
            SBS_PLUGIN_URL . 'assets/css/apple-polish' . YATOON_ASSET_SUFFIX . '.css',
            array('sbs-portal-css'),
            $asset_version( $polish_css_file )
        );
        wp_enqueue_style(
            'yatoon-design-system',
            SBS_PLUGIN_URL . 'assets/css/yatoon-design-system' . YATOON_ASSET_SUFFIX . '.css',
            array('yatoon-apple-polish'),
            $asset_version( $design_css_file )
        );
        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'),
            $asset_version( $portal_js_file ),
            true
        );
        yatoon_set_script_i18n( 'sbs-portal-js' );

        $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');
        $portal_text = static function ( $option, $fallback ) {
            $value = trim( (string) get_option( $option, '' ) );
            return $value !== '' ? $value : $fallback;
        };
        $login_subtitle = $portal_text( 'yatoon_portal_login_subtitle', __( 'Choose a secure sign-in method to view, reschedule, or manage your appointments.', 'yatoon-booking-system' ) );
        // Exact historical option value; this comparison key is intentionally not translated.
        if ( 'Enter your mobile phone number. We will send a code to your email on file.' === $login_subtitle ) {
            $login_subtitle = __( 'Choose a secure sign-in method to view, reschedule, or manage your appointments.', 'yatoon-booking-system' );
        }
        $book_new_url = trim( (string) get_option( 'yatoon_book_new_url', '' ) );
        if ( $book_new_url === '' ) {
            $booking_page_id = absint( get_option( 'yatoon_booking_page_id', 0 ) );
            $book_new_url = $booking_page_id ? get_permalink( $booking_page_id ) : '';
        }
        $identity_return_url = wp_validate_redirect( esc_url_raw( rawurldecode( (string) wp_unslash( $_GET['yatoon_return'] ?? '' ) ) ), '' );
        if ( $identity_return_url && 0 !== strpos( $identity_return_url, home_url() ) ) { $identity_return_url = ''; }
        $identity_destination = $identity_return_url ?: get_permalink();
        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'   => esc_url_raw( $book_new_url ),
            'googleUrl'    => class_exists( 'YATOON_Google_Identity' ) ? YATOON_Google_Identity::start_url( 'customer', $identity_destination ) : '',
            'appleUrl'     => class_exists( 'YATOON_Apple_Identity' ) ? YATOON_Apple_Identity::start_url( $identity_destination ) : '',
            'facebookUrl'  => class_exists( 'YATOON_Facebook_Identity' ) ? YATOON_Facebook_Identity::start_url( $identity_destination ) : '',
            'returnUrl'    => esc_url_raw( $identity_return_url ),
            'initialAuth'  => in_array( sanitize_key( wp_unslash( $_GET['yatoon_auth'] ?? '' ) ), array( 'email', 'sms' ), true ) ? sanitize_key( wp_unslash( $_GET['yatoon_auth'] ) ) : '',
            'emailLoginEnabled' => '1' === (string) get_option( 'yatoon_portal_email_login_enabled', '1' ),
            'smsLoginEnabled' => '1' === (string) get_option( 'yatoon_portal_sms_login_enabled', '1' ) && class_exists( 'YATOON_Twilio' ) && YATOON_Twilio::is_enabled(),
            'changeServiceEnabled' => '1' === (string) get_option( 'yatoon_portal_change_service_enabled', '1' ),
            'aiRescheduleEnabled' => '1' === (string) get_option( 'yatoon_ai_reschedule_assistant_enabled', '1' ) && class_exists( 'YATOON_AI' ) && YATOON_AI::is_text_available(),
            'aiConsentText' => class_exists( 'YATOON_AI' ) ? wp_strip_all_tags( YATOON_AI::consent_text() ) : '',
            'strings'      => array(
                'sendCode'        => $portal_text( 'yatoon_portal_btn_label', 'Request Sign In Code' ),
                'verifying'       => __('Verifying...', 'yatoon-booking-system'),
                'sending'         => __('Sending...', 'yatoon-booking-system'),
                'codeSent'        => __('Verification code sent:', 'yatoon-booking-system'),
                'emailCodeSent'   => __('Verification code sent to your email:', 'yatoon-booking-system'),
                'smsCodeSent'     => __('Verification code sent by text message:', 'yatoon-booking-system'),
                'loginTitle'      => $portal_text( 'yatoon_portal_login_title', 'My Appointments' ),
                'loginBrand'      => get_option('yatoon_portal_login_brand', ''),
                'loginSubtitle'   => $login_subtitle,
                'phonePlaceholder'=> __('Mobile phone (e.g. 317-555-1234)', 'yatoon-booking-system'),
                'emailPlaceholder'=> __('Appointment email address', 'yatoon-booking-system'),
                'codePlaceholder' => __('6-digit code', 'yatoon-booking-system'),
                'verify'          => __('Verify & Sign In', 'yatoon-booking-system'),
                'logout'          => __('Sign Out', 'yatoon-booking-system'),
                'switchAccount'   => __('Switch account', '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'),
                'changeService'   => __('Change Service', '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');

        $channel = sanitize_key( wp_unslash( $_POST['channel'] ?? 'email' ) );
        $purpose = sanitize_key( wp_unslash( $_POST['purpose'] ?? 'login' ) );
        $identifier = trim( sanitize_text_field( wp_unslash( $_POST['identifier'] ?? $_POST['phone'] ?? '' ) ) );
        if ( ! in_array( $channel, array( 'email', 'sms' ), true ) ) { $channel = 'email'; }
        if ( ! in_array( $purpose, array( 'login', 'link' ), true ) ) { $purpose = 'login'; }

        /*
         * Treat the identifier as the source of truth. Older cached portal
         * scripts could submit an email with channel=sms (or a phone with
         * channel=email), producing the absurd opposite validation message.
         * Server-side detection makes the endpoint correct even while a CDN
         * or optimisation plugin is still replacing an old browser asset.
         */
        if ( is_email( $identifier ) ) {
            $channel = 'email';
        } elseif ( false === strpos( $identifier, '@' ) && $this->normalize_phone( $identifier ) ) {
            $channel = 'sms';
        }
        if ( 'email' === $channel && '1' !== (string) get_option( 'yatoon_portal_email_login_enabled', '1' ) ) { wp_send_json_error( array( 'message' => __( 'Email sign-in is disabled.', 'yatoon-booking-system' ) ) ); }
        if ( 'sms' === $channel && '1' !== (string) get_option( 'yatoon_portal_sms_login_enabled', '1' ) ) { wp_send_json_error( array( 'message' => __( 'Text-message sign-in is disabled.', 'yatoon-booking-system' ) ) ); }

        $existing = $this->get_customer_session();
        $existing_provider = sanitize_key( $existing['identity_provider'] ?? ( ! empty( $existing['google_sub'] ) ? 'google' : '' ) );
        $existing_subject = sanitize_text_field( $existing['identity_sub'] ?? $existing['google_sub'] ?? '' );
        if ( 'link' === $purpose && ( ! in_array( $existing_provider, array( 'apple', 'google' ), true ) || ! $existing_subject ) ) {
            wp_send_json_error( array( 'message' => __( 'Sign in with Apple or Google before linking an appointment account.', 'yatoon-booking-system' ) ) );
        }

        if ( 'email' === $channel ) {
            $identifier = sanitize_email( $identifier );
            if ( ! $identifier ) { wp_send_json_error( array( 'message' => __( 'Please enter a valid email address.', 'yatoon-booking-system' ) ) ); }
        } else {
            $identifier = $this->normalize_phone( $identifier );
            if ( ! $identifier ) { wp_send_json_error( array( 'message' => __( 'Please enter a valid US phone number.', 'yatoon-booking-system' ) ) ); }
            if ( ! class_exists( 'YATOON_Twilio' ) || ! YATOON_Twilio::is_enabled() ) {
                wp_send_json_error( array( 'message' => __( 'Text-message sign-in is not available. Please use Google or email.', 'yatoon-booking-system' ) ) );
            }
        }

        $rate_key = self::OTP_PREFIX . 'rate_' . md5( $channel . '|' . $identifier );
        $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 );

        $customer = null;
        if ( $this->is_local_only_mode() ) {
            $customer = 'email' === $channel ? $this->get_local_customer_by_email( $identifier ) : $this->get_local_customer_by_phone( $identifier );
        } else {
            $response = 'email' === $channel
                ? $this->square_api->search_customers( $identifier, null )
                : $this->square_api->search_customers( null, $identifier );
            $matches = ! is_wp_error( $response ) ? (array) ( $response['customers'] ?? array() ) : array();
            if ( 'sms' === $channel ) {
                $matches = array_values( array_filter( $matches, function ( $candidate ) use ( $identifier ) {
                    return $this->phones_match( $candidate['phone_number'] ?? '', $identifier );
                } ) );
            }
            if ( $matches ) {
                $first = $matches[0];
                $ids = array_values( array_unique( array_filter( array_map( static function ( $item ) {
                    return sanitize_text_field( $item['id'] ?? '' );
                }, $matches ) ) ) );
                $customer = array(
                    'phone'          => sanitize_text_field( $first['phone_number'] ?? ( 'sms' === $channel ? $identifier : '' ) ),
                    'email'          => sanitize_email( $first['email_address'] ?? ( 'email' === $channel ? $identifier : '' ) ),
                    'square_id'      => sanitize_text_field( $first['id'] ?? '' ),
                    'all_square_ids' => $ids,
                    'name'           => trim( sanitize_text_field( ( $first['given_name'] ?? '' ) . ' ' . ( $first['family_name'] ?? '' ) ) ) ?: 'Valued Customer',
                    'local_mode'     => false,
                );
            }
        }

        if ( ! $customer ) {
            wp_send_json_error( array( 'message' => __( 'We could not find an appointment account with those details. Please contact us for help.', 'yatoon-booking-system' ) ) );
        }

        $destination = 'email' === $channel ? sanitize_email( $customer['email'] ?? $identifier ) : $this->normalize_phone( $customer['phone'] ?? $identifier );
        if ( ( 'email' === $channel && ! is_email( $destination ) ) || ( 'sms' === $channel && ! $destination ) ) {
            wp_send_json_error( array( 'message' => __( 'That appointment account does not have the selected contact method.', 'yatoon-booking-system' ) ) );
        }

        $code = sprintf( '%06d', random_int( 100000, 999999 ) );
        $challenge = wp_generate_password( 32, false, false );
        set_transient( self::OTP_PREFIX . 'challenge_' . $challenge, wp_hash_password( $code ), self::OTP_TTL );
        // 4.6.2: $_SESSION['yatoon_portal_pending_auth'] -> the pending bag.
        // Same server-side storage, same lifetime as an unfinished login; the
        // browser gets the signed handle cookie on this AJAX response and
        // presents it again on the verify call below.
        $this->update_pending_login( array(
            'pending_auth' => array(
                'challenge' => $challenge, 'channel' => $channel, 'purpose' => $purpose,
                'identifier' => $identifier, 'customer' => $customer, 'attempts' => 0,
            ),
        ) );

        $sent = false;
        if ( 'sms' === $channel ) {
            $sent = (bool) YATOON_Twilio::send( $destination, sprintf( __( 'Your %1$s sign-in code is %2$s. It expires in 10 minutes.', 'yatoon-booking-system' ), get_bloginfo( 'name' ), $code ) );
        } else {
            $sent = wp_mail( $destination, sprintf( __( '[%s] Your verification code', 'yatoon-booking-system' ), get_bloginfo( 'name' ) ), $this->build_otp_email( get_bloginfo( 'name' ), $code ), array( 'Content-Type: text/html; charset=UTF-8' ) );
        }
        if ( ! $sent ) {
            delete_transient( self::OTP_PREFIX . 'challenge_' . $challenge );
            $this->clear_pending_login();
            wp_send_json_error( array( 'message' => __( 'The verification code could not be sent. Please try another sign-in method.', 'yatoon-booking-system' ) ) );
        }

        $masked = 'email' === $channel ? $this->mask_email( $destination ) : substr( $destination, 0, 2 ) . '••••••' . substr( $destination, -2 );
        wp_send_json_success( array( 'channel' => $channel, 'masked_destination' => $masked ) );
        return;

        $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.
        $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 ) );

        // 4.6.2: store phone + masked email in the pending bag instead of
        // $_SESSION. Passing null for the local customer drops that key, which
        // is what the unset() in the replaced code did.
        $this->update_pending_login( array(
            'pending_phone'          => $phone,
            'pending_email'          => $email, // used in verify step
            'pending_local_customer' => $local_customer ? $local_customer : null,
        ) );

        // -- 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');

        // 4.6.2: read the pending login from the transient the signed handle
        // cookie points at, not from $_SESSION. An expired or missing handle
        // yields no pending state, which lands on the same "Session expired"
        // message the customer used to get from a dropped PHP session.
        $pending = $this->pending_login_state()['pending_auth'] ?? null;
        $code = preg_replace( '/\D/', '', (string) ( $_POST['code'] ?? '' ) );
        if ( ! is_array( $pending ) || empty( $pending['challenge'] ) || strlen( $code ) !== 6 ) {
            wp_send_json_error( array( 'message' => __( 'Session expired. Please request a new code.', 'yatoon-booking-system' ) ) );
        }
        $challenge = preg_replace( '/[^A-Za-z0-9]/', '', (string) $pending['challenge'] );
        $otp_key = self::OTP_PREFIX . 'challenge_' . $challenge;
        $stored = get_transient( $otp_key );
        if ( ! $stored || ! wp_check_password( $code, $stored ) ) {
            $pending['attempts'] = (int) ( $pending['attempts'] ?? 0 ) + 1;
            // 4.6.2: the attempt counter has to survive the request, so it is
            // written back to the transient rather than to $_SESSION.
            $this->update_pending_login( array( 'pending_auth' => $pending ) );
            if ( $pending['attempts'] >= 6 ) {
                delete_transient( $otp_key );
                $this->clear_pending_login();
            }
            wp_send_json_error( array( 'message' => __( 'Invalid or expired code. Please try again.', 'yatoon-booking-system' ) ) );
        }
        delete_transient( $otp_key );
        $verified = (array) ( $pending['customer'] ?? array() );

        if ( 'link' === ( $pending['purpose'] ?? 'login' ) ) {
            $current = $this->get_customer_session();
            $provider = sanitize_key( $current['identity_provider'] ?? ( ! empty( $current['google_sub'] ) ? 'google' : '' ) );
            $subject = sanitize_text_field( $current['identity_sub'] ?? $current['google_sub'] ?? '' );
            if ( ! in_array( $provider, array( 'apple', 'google' ), true ) || ! $subject ) {
                $this->clear_pending_login();
                wp_send_json_error( array( 'message' => __( 'The secure link could not be completed. Please sign in again.', 'yatoon-booking-system' ) ) );
            }
            global $wpdb;
            $wpdb->update(
                $wpdb->prefix . 'yatoon_identity_links',
                array( 'verified_email' => sanitize_email( $verified['email'] ?? $current['email'] ?? '' ), 'external_account_id' => sanitize_text_field( $verified['square_id'] ?? '' ), 'last_login_at' => current_time( 'mysql', true ) ),
                array( 'account_type' => 'customer', 'provider' => $provider, 'provider_subject' => $subject ),
                array( '%s', '%s', '%s' ), array( '%s', '%s', '%s' )
            );
            $current['square_id'] = sanitize_text_field( $verified['square_id'] ?? '' );
            $current['all_square_ids'] = array_values( array_unique( array_filter( (array) ( $verified['all_square_ids'] ?? array() ) ) ) );
            $current['phone'] = sanitize_text_field( $verified['phone'] ?? $current['phone'] ?? '' );
            $current['email'] = sanitize_email( $verified['email'] ?? $current['email'] ?? '' );
            $current['name'] = sanitize_text_field( $verified['name'] ?? $current['name'] ?? 'Valued Customer' );
            $current['needs_square_link'] = false;
            $current['needs_account_link'] = false;
            $customer = $current;
        } else {
            $customer = $verified;
        }

        // 4.6.2: was session_regenerate_id( true ) -- a fresh auth token is
        // minted here instead, so the signed-in cookie is never one the
        // browser was carrying before it signed in.
        $this->rotate_portal_session();
        $this->set_customer_session( $customer );
        $this->clear_pending_login();
        wp_send_json_success( array( 'customer' => $customer, 'linked' => 'link' === ( $pending['purpose'] ?? 'login' ) ) );
        return;

        // 4.6.2: pending bag instead of $_SESSION, as above.
        $pending_state = $this->pending_login_state();
        $phone = $pending_state['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 = $pending_state['pending_local_customer'] ?? null; // 4.6.2: pending bag, was $_SESSION
            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,
                // 4.6.2: pending bag, was $_SESSION['yatoon_portal_pending_email'].
                'email'          => isset($sq_customer['email_address']) ? $sq_customer['email_address'] : ( $pending_state['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';
            }
        }

        // 4.6.2: was session_regenerate_id( true ); see rotate_portal_session().
        $this->rotate_portal_session();
        $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();
    }

    /** Sign out from the booking-page banner, then return to that same page. */
    public function handle_logout_return() {
        check_admin_referer( 'yatoon_portal_logout_return' );
        $fallback = home_url( '/' );
        $return   = esc_url_raw( wp_unslash( $_POST['return_url'] ?? $fallback ) );
        $return   = wp_validate_redirect( $return, $fallback );

        // Keep the redirect on this WordPress site even if a crafted form posts
        // a valid-looking external URL.
        if ( strtolower( (string) wp_parse_url( $return, PHP_URL_HOST ) ) !== strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) ) ) {
            $return = $fallback;
        }

        $this->clear_session();
        wp_safe_redirect( add_query_arg( 'yatoon_signed_out', time(), $return ) );
        exit;
    }

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

    private function wallet_table_exists( $table ) {
        global $wpdb;
        return (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === (string) $table;
    }

    private function get_portal_client( array $customer ) {
        global $wpdb;
        $table = $wpdb->prefix . 'yatoon_clients';
        if ( ! $this->wallet_table_exists( $table ) ) { return null;
        }
        $email = strtolower( sanitize_email( $customer['email'] ?? '' ) );
        $phone = $this->normalize_phone( $customer['phone'] ?? '' );
        $rows  = $wpdb->get_results( "SELECT * FROM {$table} ORDER BY id DESC LIMIT 500" );
        foreach ( (array) $rows as $row ) {
            $email_match = $email && ! empty( $row->customer_email ) && strtolower( $row->customer_email ) === $email;
            $phone_match = $phone && ! empty( $row->customer_phone ) && $this->phones_match( $row->customer_phone, $phone );
            if ( $email_match || $phone_match ) { return $row;
            }
        }
        return null;
    }

    private function get_booking_portal_actions( $booking, $is_upcoming ) {
        $payment_status = sanitize_key( (string) ( $booking->payment_status ?? 'unpaid' ) );
        $token          = (string) ( $booking->manage_token ?? '' );
        $booking_id     = absint( $booking->id ?? 0 );
        $unpaid         = in_array( $payment_status, array( '', 'unpaid', 'pending', 'deposit_required', 'deposit_paid' ), true );
        $pay_url        = '';
        $invoice_url    = '';

        if ( $is_upcoming && $unpaid && $booking_id && $token ) {
            $configured = trim( (string) get_option( 'yatoon_payment_request_url', '' ) );
            if ( $configured !== '' ) {
                $pay_url = add_query_arg( array( 'booking' => $booking_id, 'token' => $token ), $configured );
            }
        }
        if ( get_option( 'yatoon_invoice_enabled', '0' ) === '1' && $booking_id && $token && class_exists( 'YATOON_Invoice' ) ) {
            $invoice_url = YATOON_Invoice::get_download_url( $booking_id, $token );
        }

        return array( 'pay_url' => esc_url_raw( $pay_url ), 'invoice_url' => esc_url_raw( $invoice_url ) );
    }

    /** Build the portal wallet only from records belonging to the verified customer. */
    private function get_wallet_payload( array $customer ) {
        global $wpdb;
        $email = strtolower( sanitize_email( $customer['email'] ?? '' ) );
        $phone = $this->normalize_phone( $customer['phone'] ?? '' );
        $matches = function ( $row ) use ( $email, $phone ) {
            $row_email = strtolower( sanitize_email( $row->customer_email ?? $row->recipient_email ?? '' ) );
            $row_phone = (string) ( $row->customer_phone ?? '' );
            return ( $email && $row_email && $email === $row_email ) || ( $phone && $row_phone && $this->phones_match( $row_phone, $phone ) );
        };

        $wallet = array(
            'gift_cards'      => array( 'enabled' => get_option( 'yatoon_gift_card_enabled', '1' ) === '1', 'balance' => '$0.00', 'count' => 0, 'items' => array() ),
            'packages'        => array( 'enabled' => false, 'remaining' => 0, 'items' => array(), 'history' => array() ),
            'membership'      => array( 'enabled' => false, 'items' => array(), 'history' => array() ),
            'loyalty'         => array( 'enabled' => get_option( 'yatoon_loyalty_enabled', '0' ) === '1', 'points' => 0, 'next_reward' => '', 'rewards' => array() ),
            'profile'         => array( 'name' => sanitize_text_field( $customer['name'] ?? '' ), 'email' => $email, 'phone' => sanitize_text_field( $customer['phone'] ?? '' ), 'preferences' => '' ),
            'balance_due'     => '$0.00',
            'payment_history' => array(),
        );

        $gift_table = $wpdb->prefix . 'yatoon_gift_cards';
        if ( $email && $this->wallet_table_exists( $gift_table ) ) {
            $cards = $wpdb->get_results( $wpdb->prepare(
                "SELECT code,balance,status,expires_at FROM {$gift_table} WHERE (LOWER(recipient_email)=%s OR (recipient_email='' AND LOWER(purchased_email)=%s)) AND status='active' ORDER BY created_at DESC LIMIT 20",
                $email,
                $email
            ) );
            $gift_balance = 0.0;
            foreach ( (array) $cards as $card ) {
                $gift_balance += max( 0, (float) $card->balance );
                $wallet['gift_cards']['items'][] = array(
                    'label'   => '•••• ' . substr( preg_replace( '/[^A-Z0-9]/i', '', (string) $card->code ), -4 ),
                    'balance' => '$' . number_format( (float) $card->balance, 2 ),
                    'expires' => $card->expires_at ? mysql2date( get_option( 'date_format' ), $card->expires_at ) : '',
                );
            }
            $wallet['gift_cards']['count']   = count( $wallet['gift_cards']['items'] );
            $wallet['gift_cards']['balance'] = '$' . number_format( $gift_balance, 2 );
        }

        $package_table = $wpdb->prefix . 'yatoon_customer_packages';
        if ( $this->wallet_table_exists( $package_table ) ) {
            $wallet['packages']['enabled'] = true;
            $rows = $wpdb->get_results( "SELECT cp.*,p.name AS item_name FROM {$package_table} cp LEFT JOIN {$wpdb->prefix}yatoon_packages p ON p.id=cp.package_id ORDER BY cp.id DESC LIMIT 250" );
            foreach ( (array) $rows as $row ) {
                if ( ! $matches( $row ) ) { continue;
                }
                $remaining = max( 0, (int) $row->remaining_visits );
                $item = array( 'name' => $row->item_name ?: __( 'Service package', 'yatoon-booking-system' ), 'remaining' => $remaining, 'total' => max( 0, (int) $row->purchased_visits ), 'status' => sanitize_key( $row->status ), 'purchased' => $row->created_at ? mysql2date( get_option( 'date_format' ), $row->created_at ) : '', 'expires' => $row->expires_at ? mysql2date( get_option( 'date_format' ), $row->expires_at ) : '' );
                $wallet['packages']['history'][] = $item;
                if ( $row->status === 'active' && $remaining > 0 ) {
                    $wallet['packages']['remaining'] += $remaining;
                    $wallet['packages']['items'][] = $item;
                }
            }
        }

        $membership_table = $wpdb->prefix . 'yatoon_customer_memberships';
        if ( $this->wallet_table_exists( $membership_table ) ) {
            $wallet['membership']['enabled'] = true;
            $rows = $wpdb->get_results( "SELECT cm.*,m.name AS item_name FROM {$membership_table} cm LEFT JOIN {$wpdb->prefix}yatoon_memberships m ON m.id=cm.membership_id ORDER BY cm.id DESC LIMIT 250" );
            foreach ( (array) $rows as $row ) {
                if ( ! $matches( $row ) ) { continue;
                }
                $item = array( 'name' => $row->item_name ?: __( 'Membership', 'yatoon-booking-system' ), 'status' => sanitize_key( $row->status ), 'started' => $row->starts_at ? mysql2date( get_option( 'date_format' ), $row->starts_at ) : '', 'expires' => $row->expires_at ? mysql2date( get_option( 'date_format' ), $row->expires_at ) : '' );
                $wallet['membership']['history'][] = $item;
                if ( $row->status === 'active' ) { $wallet['membership']['items'][] = $item;
                }
            }
        }

        $clients_table = $wpdb->prefix . 'yatoon_clients';
        if ( $wallet['loyalty']['enabled'] && $this->wallet_table_exists( $clients_table ) ) {
            $clients = $wpdb->get_results( "SELECT customer_name,customer_email,customer_phone,preferences,loyalty_points FROM {$clients_table} ORDER BY id DESC LIMIT 500" );
            foreach ( (array) $clients as $client ) {
                if ( $matches( $client ) ) {
                    $wallet['loyalty']['points'] = max( 0, (int) $client->loyalty_points );
                    $wallet['profile']['name'] = sanitize_text_field( $client->customer_name ?: $wallet['profile']['name'] );
                    $wallet['profile']['preferences'] = sanitize_textarea_field( $client->preferences ?? '' );
                    break;
                }
            }
            $rewards_table = $wpdb->prefix . 'yatoon_loyalty_rewards';
            if ( $this->wallet_table_exists( $rewards_table ) ) {
                $rewards = $wpdb->get_results( "SELECT id,name,points_required,discount_type,discount_value FROM {$rewards_table} WHERE is_enabled=1 ORDER BY points_required ASC LIMIT 30" );
                foreach ( (array) $rewards as $reward ) {
                    $eligible = $wallet['loyalty']['points'] >= (int) $reward->points_required;
                    $wallet['loyalty']['rewards'][] = array( 'id' => (int) $reward->id, 'name' => sanitize_text_field( $reward->name ), 'points' => (int) $reward->points_required, 'eligible' => $eligible, 'value' => $reward->discount_type === 'percent' ? rtrim( rtrim( number_format( (float) $reward->discount_value, 2 ), '0' ), '.' ) . '%' : '$' . number_format( (float) $reward->discount_value, 2 ) );
                    if ( ! $eligible && $wallet['loyalty']['next_reward'] === '' ) { $wallet['loyalty']['next_reward'] = sprintf( __( '%1$s at %2$d points', 'yatoon-booking-system' ), $reward->name, $reward->points_required );
                    }
                }
            }
        }

        $booking_table = $wpdb->prefix . 'yatoon_bookings';
        if ( $this->wallet_table_exists( $booking_table ) ) {
            $rows = $wpdb->get_results( "SELECT id,customer_email,customer_phone,start_time,total_price,tip_amount,payment_status,status FROM {$booking_table} ORDER BY start_time DESC LIMIT 500" );
            $due = 0.0;
            foreach ( (array) $rows as $row ) {
                if ( ! $matches( $row ) ) { continue;
                }
                $status = strtolower( (string) $row->status );
                if ( in_array( $status, array( 'cancelled','canceled','cancelled_by_customer','cancelled_by_seller','declined' ), true ) ) { continue;
                }
                $payment = sanitize_key( (string) $row->payment_status );
                $amount = max( 0, (float) $row->total_price + (float) $row->tip_amount );
                $is_future = mysql2date( 'U', $row->start_time, false ) >= current_time( 'timestamp' );
                if ( $is_future && in_array( $payment, array( '', 'unpaid', 'pending', 'deposit_required' ), true ) ) { $due += $amount;
                }
                if ( count( $wallet['payment_history'] ) < 10 && ! in_array( $payment, array( '', 'unpaid', 'pending' ), true ) ) {
                    $wallet['payment_history'][] = array( 'date' => mysql2date( get_option( 'date_format' ), $row->start_time ), 'amount' => '$' . number_format( $amount, 2 ), 'status' => ucwords( str_replace( '_', ' ', $payment ) ) );
                }
            }
            $wallet['balance_due'] = '$' . number_format( $due, 2 );
        }

        return $wallet;
    }

    public function handle_update_profile() {
        check_ajax_referer( 'yatoon_portal_nonce', 'nonce' );
        $customer = $this->get_customer_session();
        if ( ! $customer ) { wp_send_json_error( array( 'code' => 'not_logged_in' ) );
        }

        $name = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );
        $preferences = sanitize_textarea_field( wp_unslash( $_POST['preferences'] ?? '' ) );
        if ( $name === '' || strlen( $name ) > 240 ) {
            wp_send_json_error( array( 'message' => __( 'Please enter a valid name.', 'yatoon-booking-system' ) ) );
        }
        if ( strlen( $preferences ) > 2000 ) {
            wp_send_json_error( array( 'message' => __( 'Preferences must be 1,000 characters or fewer.', 'yatoon-booking-system' ) ) );
        }

        global $wpdb;
        $client = $this->get_portal_client( $customer );
        $saved = false;
        if ( $client ) {
            $saved = $wpdb->update( $wpdb->prefix . 'yatoon_clients', array( 'customer_name' => $name, 'preferences' => $preferences ), array( 'id' => (int) $client->id ), array( '%s', '%s' ), array( '%d' ) ) !== false;
        } elseif ( $this->wallet_table_exists( $wpdb->prefix . 'yatoon_clients' ) ) {
            $saved = (bool) $wpdb->insert( $wpdb->prefix . 'yatoon_clients', array( 'customer_name' => $name, 'customer_email' => sanitize_email( $customer['email'] ?? '' ), 'customer_phone' => sanitize_text_field( $customer['phone'] ?? '' ), 'preferences' => $preferences ), array( '%s', '%s', '%s', '%s' ) );
        }
        if ( ! $saved ) { wp_send_json_error( array( 'message' => __( 'Profile could not be saved. Please try again.', 'yatoon-booking-system' ) ) );
        }
        $customer['name'] = $name;
        $this->set_customer_session( $customer );
        wp_send_json_success( array( 'message' => __( 'Profile saved.', 'yatoon-booking-system' ), 'customer' => $customer ) );
    }

    public function handle_redeem_reward() {
        check_ajax_referer( 'yatoon_portal_nonce', 'nonce' );
        $customer = $this->get_customer_session();
        if ( ! $customer ) { wp_send_json_error( array( 'code' => 'not_logged_in' ) );
        }
        if ( get_option( 'yatoon_loyalty_enabled', '0' ) !== '1' ) { wp_send_json_error( array( 'message' => __( 'Rewards are not enabled.', 'yatoon-booking-system' ) ) );
        }

        $reward_id = absint( $_POST['reward_id'] ?? 0 );
        global $wpdb;
        $client = $this->get_portal_client( $customer );
        $rewards_table = $wpdb->prefix . 'yatoon_loyalty_rewards';
        $ledger_table  = $wpdb->prefix . 'yatoon_loyalty_ledger';
        if ( ! $client || ! $this->wallet_table_exists( $rewards_table ) || ! $this->wallet_table_exists( $ledger_table ) ) {
            wp_send_json_error( array( 'message' => __( 'Reward account not found.', 'yatoon-booking-system' ) ) );
        }
        $reward = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$rewards_table} WHERE id=%d AND is_enabled=1 LIMIT 1", $reward_id ) );
        if ( ! $reward ) { wp_send_json_error( array( 'message' => __( 'Reward not found.', 'yatoon-booking-system' ) ) );
        }

        // 4.6.2: session_id() -> the client id this redemption debits. The
        // session id was only ever a stand-in for "this customer", and it was
        // a poor one: it changed whenever the session cookie was dropped,
        // which let a determined double-submit slip through. The client id is
        // stable and already resolved above.
        $rate_key = 'yatoon_portal_reward_' . md5( (int) $client->id . '|' . $reward_id );
        if ( get_transient( $rate_key ) ) { wp_send_json_error( array( 'message' => __( 'Please wait a moment before trying again.', 'yatoon-booking-system' ) ) );
        }
        set_transient( $rate_key, 1, 5 );

        $points = max( 0, (int) $reward->points_required );
        $updated = $wpdb->query( $wpdb->prepare(
            "UPDATE {$wpdb->prefix}yatoon_clients SET loyalty_points=loyalty_points-%d WHERE id=%d AND loyalty_points>=%d",
            $points,
            (int) $client->id,
            $points
        ) );
        if ( (int) $updated !== 1 ) { wp_send_json_error( array( 'message' => __( 'Not enough points for this reward.', 'yatoon-booking-system' ) ) );
        }

        $claim = 'YR-' . strtoupper( wp_generate_password( 8, false, false ) );
        $inserted = $wpdb->insert( $ledger_table, array(
            'client_id' => (int) $client->id, 'customer_name' => $client->customer_name, 'customer_email' => $client->customer_email,
            'customer_phone' => $client->customer_phone, 'points_change' => -$points, 'reason' => 'redeemed', 'reward_id' => $reward_id,
            'note' => sprintf( 'Customer portal claim %s: %s', $claim, $reward->name ), 'created_at' => current_time( 'mysql' ),
        ), array( '%d', '%s', '%s', '%s', '%d', '%s', '%d', '%s', '%s' ) );
        if ( ! $inserted ) {
            $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}yatoon_clients SET loyalty_points=loyalty_points+%d WHERE id=%d", $points, (int) $client->id ) );
            wp_send_json_error( array( 'message' => __( 'The reward could not be redeemed. Your points were restored.', 'yatoon-booking-system' ) ) );
        }

        wp_send_json_success( array( 'message' => __( 'Reward redeemed. Show the claim reference at checkout.', 'yatoon-booking-system' ), 'claim' => $claim, 'new_balance' => max( 0, (int) $client->loyalty_points - $points ) ) );
    }

    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 = yatoon_timezone_string();
        $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')->setTimezone( new DateTimeZone('UTC') )->format('Y-m-d\TH:i:s\Z');
            $chunk_end   = ( clone $now )->modify('-' . max(0, $offset - $chunk_days) . ' days')->setTimezone( new DateTimeZone('UTC') )->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 )->setTimezone( new DateTimeZone('UTC') )->format('Y-m-d\TH:i:s\Z');
        $future_end   = ( clone $now )->modify('+' . $total_future . ' days')->setTimezone( new DateTimeZone('UTC') )->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;
                if ( ! $formatted['is_cancelled'] ) {
                    $total_spent += $formatted['price_raw'];
                    ++$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,
            'wallet'      => $this->get_wallet_payload( $customer ),
        ));
    }

    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;

        $groups = array();
        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;
            }

            $group_key = ! empty( $row->group_booking_id ) ? 'group:' . $row->group_booking_id : 'row:' . $row->id;
            $groups[ $group_key ][] = $row;
        }

        foreach ( $groups as $group_rows ) {
            usort( $group_rows, static function ( $a, $b ) {
 return strcmp( $a->start_time, $b->start_time );
} );
            $formatted = $this->format_local_booking( $group_rows, $now );
            if (!$formatted) {
                continue;
            }

            if ($formatted['is_upcoming']) {
                $upcoming[] = $formatted;
            } else {
                $past[] = $formatted;
                if ( ! $formatted['is_cancelled'] ) {
                    $total_spent += $formatted['price_raw'];
                    ++$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,
            'wallet'      => $this->get_wallet_payload( $customer ),
        );
    }

    private function format_local_booking( array $bookings, DateTime $now ) {
        if ( empty( $bookings ) ) { return null;
        }
        $booking = $bookings[0];
        $tz = $this->get_local_booking_timezone( $booking );
        try {
            $start = new DateTime($booking->start_time, $tz);
            $end   = clone $start;
            foreach ( $bookings as $group_row ) {
                $row_start = new DateTime( $group_row->start_time, $tz );
                $row_end   = new DateTime( $group_row->end_time, $tz );
                if ( $row_start < $start ) { $start = $row_start;
                }
                if ( $row_end > $end ) { $end = $row_end;
                }
            }
        } catch (Exception $e) {
            return null;
        }

        global $wpdb;
        $service_names = array();
        $staff_names = array();
        $price_raw = 0.0;
        foreach ( $bookings as $group_row ) {
            $service_names[] = $wpdb->get_var($wpdb->prepare(
                "SELECT name FROM {$wpdb->prefix}yatoon_services WHERE id = %d LIMIT 1", intval($group_row->service_id)
            )) ?: __('Appointment', 'yatoon-booking-system');
            $staff_names[] = $wpdb->get_var($wpdb->prepare(
                "SELECT name FROM {$wpdb->prefix}yatoon_staff WHERE id = %d LIMIT 1", intval($group_row->staff_id)
            )) ?: __('Any Staff', 'yatoon-booking-system');
            $price_raw += floatval( $group_row->total_price ?? 0 );
        }
        $service_name = implode( ' + ', array_unique( $service_names ) );
        $staff_name   = implode( ' + ', array_unique( $staff_names ) );

        $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'),
            'partially_refunded' => __('Partially refunded', 'yatoon-booking-system'),
            'refunded'        => __('Refunded', 'yatoon-booking-system'),
            'waived'          => __('Waived', 'yatoon-booking-system'),
        );
        $payment_status = sanitize_key((string) ( $booking->payment_status ?? 'unpaid' ));
        $portal_actions = $this->get_booking_portal_actions( $booking, $is_upcoming );
        $can_change_service = '1' === (string) get_option( 'yatoon_portal_change_service_enabled', '1' )
            && $is_upcoming && ! $is_cancelled && 1 === count( $bookings )
            && in_array( $payment_status, array( '', 'unpaid', 'pending' ), true );

        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'     => $price_raw,
            'price'         => '$' . number_format($price_raw, 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,
            'can_change_service' => $can_change_service,
            'pay_url'       => $portal_actions['pay_url'],
            'invoice_url'   => $portal_actions['invoice_url'],
            'rebook'        => $this->build_rebook_payload_from_local_rows( $bookings ),
        );
    }

    /**
     * Build a fresh, server-validated selection for the customer portal's
     * "Book Again" action. Historical rows identify the requested items only;
     * all names, prices, durations, rules and staff eligibility come from the
     * current catalogue.
     */
    private function build_rebook_payload_from_local_rows( array $bookings ) {
        $items = array();
        $skipped = 0;
        foreach ( $bookings as $booking ) {
            $item = $this->build_rebook_item(
                absint( $booking->service_id ?? 0 ),
                absint( $booking->id ?? 0 ),
                absint( $booking->staff_id ?? 0 )
            );
            if ( $item ) {
                $items[] = $item;
            } else {
                ++$skipped;
            }
        }
        return array( 'version' => 1, 'services' => $items, 'skipped' => $skipped );
    }

    /** Build a Book Again payload directly from Square when no local mirror exists. */
    private function build_rebook_payload_from_square_booking( array $booking ) {
        global $wpdb;
        $items = array();
        $skipped = 0;
        foreach ( (array) ( $booking['appointment_segments'] ?? array() ) as $segment ) {
            $variation_id = sanitize_text_field( $segment['service_variation_id'] ?? '' );
            $service_id = 0;
            $option_ids = array();
            if ( $variation_id ) {
                $option = $wpdb->get_row( $wpdb->prepare(
                    "SELECT id, service_id FROM {$wpdb->prefix}yatoon_service_options WHERE square_id=%s LIMIT 1",
                    $variation_id
                ) );
                if ( $option ) {
                    $service_id = absint( $option->service_id );
                    $option_ids[] = absint( $option->id );
                } else {
                    $service_id = absint( $wpdb->get_var( $wpdb->prepare(
                        "SELECT id FROM {$wpdb->prefix}yatoon_services WHERE square_id=%s LIMIT 1",
                        $variation_id
                    ) ) );
                }
            }
            $staff_id = 0;
            if ( ! empty( $segment['team_member_id'] ) && empty( $segment['any_team_member'] ) ) {
                $staff_id = absint( $wpdb->get_var( $wpdb->prepare(
                    "SELECT id FROM {$wpdb->prefix}yatoon_staff WHERE square_id=%s LIMIT 1",
                    sanitize_text_field( $segment['team_member_id'] )
                ) ) );
            }
            $item = $this->build_rebook_item( $service_id, 0, $staff_id, $option_ids );
            if ( $item ) {
                $items[] = $item;
            } else {
                ++$skipped;
            }
        }
        return array( 'version' => 1, 'services' => $items, 'skipped' => $skipped );
    }

    /** Resolve one historical selection against today's catalogue and staff. */
    private function build_rebook_item( $service_id, $booking_id = 0, $preferred_staff_id = 0, array $forced_option_ids = array() ) {
        global $wpdb;
        $service_id = absint( $service_id );
        if ( ! $service_id ) { return null; }

        $service = $wpdb->get_row( $wpdb->prepare(
            "SELECT * FROM {$wpdb->prefix}yatoon_services WHERE id=%d AND is_active=1 LIMIT 1",
            $service_id
        ) );
        if ( ! $service ) { return null; }

        $requested_option_count = 0;
        if ( $booking_id ) {
            $requested_option_count = absint( $wpdb->get_var( $wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->prefix}yatoon_booking_options WHERE booking_id=%d",
                absint( $booking_id )
            ) ) );
            $options = $wpdb->get_results( $wpdb->prepare(
                "SELECT so.* FROM {$wpdb->prefix}yatoon_booking_options bo
                 INNER JOIN {$wpdb->prefix}yatoon_service_options so ON so.id=bo.option_id
                 WHERE bo.booking_id=%d AND so.service_id=%d ORDER BY bo.id ASC",
                absint( $booking_id ), $service_id
            ) );
        } elseif ( $forced_option_ids ) {
            $ids = array_values( array_filter( array_map( 'absint', $forced_option_ids ) ) );
            $requested_option_count = count( $ids );
            if ( $ids ) {
                $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
                $query = $wpdb->prepare(
                    "SELECT * FROM {$wpdb->prefix}yatoon_service_options WHERE service_id=%d AND id IN ($placeholders) ORDER BY sort_order ASC, id ASC",
                    array_merge( array( $service_id ), $ids )
                );
                $options = $wpdb->get_results( $query );
            } else {
                $options = array();
            }
        } else {
            $options = array();
        }

        $has_current_main_options = (bool) $wpdb->get_var( $wpdb->prepare(
            "SELECT 1 FROM {$wpdb->prefix}yatoon_service_options
             WHERE service_id=%d AND LOWER(TRIM(COALESCE(group_name,''))) <> 'add-on' LIMIT 1",
            $service_id
        ) );
        $has_selected_main_option = false;
        foreach ( (array) $options as $option ) {
            if ( 'add-on' !== strtolower( trim( (string) ( $option->group_name ?? '' ) ) ) ) {
                $has_selected_main_option = true;
                break;
            }
        }
        $options_complete = count( (array) $options ) === $requested_option_count
            && ( ! $has_current_main_options || $has_selected_main_option );

        $base_price = (float) $service->price;
        $option_payload = array();
        foreach ( (array) $options as $option ) {
            $is_addon = 'add-on' === strtolower( trim( (string) ( $option->group_name ?? '' ) ) );
            $modifier = (float) $option->price_modifier;
            $option_payload[] = array(
                'id' => absint( $option->id ),
                'name' => sanitize_text_field( $option->name ),
                'price' => $is_addon ? $modifier : $base_price + $modifier,
                'price_modifier' => $modifier,
                'duration' => absint( $option->duration_minutes ),
                'group' => sanitize_text_field( $option->group_name ),
                'is_addon' => $is_addon,
                'linked_service_id' => null,
                'allowDesignUpload' => ! empty( $service->allow_design_upload ),
                'square_id' => sanitize_text_field( $option->square_id ?? '' ),
            );
        }

        $staff = $wpdb->get_results( $wpdb->prepare(
            "SELECT DISTINCT s.id, s.name FROM {$wpdb->prefix}yatoon_staff s
             INNER JOIN {$wpdb->prefix}yatoon_staff_services ss ON ss.staff_id=s.id
             WHERE ss.service_id=%d AND s.is_active=1 AND s.is_bookable=1
             ORDER BY s.sort_order ASC, s.name ASC",
            $service_id
        ) );
        if ( empty( $staff ) ) {
            $staff = $wpdb->get_results(
                "SELECT id, name FROM {$wpdb->prefix}yatoon_staff
                 WHERE is_active=1 AND is_bookable=1 ORDER BY sort_order ASC, name ASC"
            );
        }
        $eligible_ids = array_map( 'absint', wp_list_pluck( (array) $staff, 'id' ) );
        $selected_staff = null;
        $preferred_staff_id = absint( $preferred_staff_id );
        if ( $preferred_staff_id && in_array( $preferred_staff_id, $eligible_ids, true ) ) {
            foreach ( $staff as $member ) {
                if ( absint( $member->id ) === $preferred_staff_id ) {
                    $selected_staff = array( 'id' => $preferred_staff_id, 'name' => sanitize_text_field( $member->name ), 'price_override' => null );
                    break;
                }
            }
        } elseif ( ! $preferred_staff_id && count( $eligible_ids ) >= 2 ) {
            $selected_staff = array( 'id' => 0, 'name' => __( 'Any Staff', 'yatoon-booking-system' ), 'price_override' => null );
        }

        return array(
            'service' => array(
                'id' => $service_id,
                'name' => sanitize_text_field( $service->name ),
                'price' => $base_price,
                'taxRate' => class_exists( 'YATOON_Tax' ) && YATOON_Tax::is_enabled() ? (float) YATOON_Tax::rate_for_service( $service_id ) : 0,
                'duration' => absint( $service->duration_minutes ),
                'description' => wp_strip_all_tags( (string) ( $service->description ?? '' ) ),
                'image_url' => esc_url_raw( $service->image_url ?? '' ),
                'category' => sanitize_text_field( $service->category ?? '' ),
                'requirePolicy' => ! empty( $service->require_policy ),
                'requireConsent' => ! empty( $service->require_consent ),
                'allowStaffSelect' => ! empty( $service->allow_staff_select ),
                'allowDesignUpload' => ! empty( $service->allow_design_upload ),
            ),
            'options' => $option_payload,
            'optionsComplete' => $options_complete,
            'staff' => $selected_staff,
            'eligibleStaffIds' => $eligible_ids,
            'guestLabel' => __( 'Me', 'yatoon-booking-system' ),
            'groupAppointment' => false,
        );
    }

    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)
        $session_customer = $this->get_customer_session();
        if ( ! is_array( $session_customer ) ) { $session_customer = 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';
        $cancelled_statuses = array('CANCELLED','CANCELED','CANCELLED_BY_CUSTOMER','CANCELED_BY_CUSTOMER','CANCELLED_BY_SELLER','CANCELED_BY_SELLER','DECLINED');
        $is_cancelled = in_array($status, $cancelled_statuses, true);
        $is_upcoming  = $start > $now && ! $is_cancelled;

        // 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;
        global $wpdb;
        $local_mirrors = $wpdb->get_results( $wpdb->prepare(
            "SELECT * FROM {$wpdb->prefix}yatoon_bookings WHERE square_booking_id=%s ORDER BY id ASC",
            sanitize_text_field( $sb['id'] )
        ) );
        $local_mirror = $local_mirrors ? $local_mirrors[0] : null;
        $payment_status = '';
        $portal_actions = array( 'pay_url' => '', 'invoice_url' => '' );
        if ( $local_mirror ) {
            $price_raw = max( 0, (float) $local_mirror->total_price );
            $payment_status = sanitize_key( (string) $local_mirror->payment_status );
            $portal_actions = $this->get_booking_portal_actions( $local_mirror, $is_upcoming );
        }
        $can_change_service = '1' === (string) get_option( 'yatoon_portal_change_service_enabled', '1' )
            && $local_mirror && $is_upcoming && ! $is_cancelled && 1 === count( (array) ( $sb['appointment_segments'] ?? array() ) )
            && in_array( $payment_status, array( '', 'unpaid', 'pending' ), true );

        $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_status,
            'payment_label' => $payment_status ? ucwords( str_replace( '_', ' ', $payment_status ) ) : ( 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,
            'can_change_service' => $can_change_service,
            'pay_url'       => $portal_actions['pay_url'],
            'invoice_url'   => $portal_actions['invoice_url'],
            'rebook'        => $local_mirrors
                ? $this->build_rebook_payload_from_local_rows( $local_mirrors )
                : $this->build_rebook_payload_from_square_booking( $sb ),
        );
    }

    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
        $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
            $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
        $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', 'yatoon-booking-system' )));
        }

        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')
                );
            }

            if ( class_exists( 'YATOON_Notifications' ) ) {
                $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'];

        if ( ! $this->square_booking_belongs_to_customer( $bk, $customer ) ) {
            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 ) {
                if ( class_exists( 'YATOON_Notifications' ) ) {
                    $notif = new YATOON_Notifications();
                    $notif->send_cancel_email( $local );
                }
                if ( class_exists( 'YATOON_WhatsApp' ) ) { YATOON_WhatsApp::send_cancel( $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', 'yatoon-booking-system' )));
        }

        $result = $this->resolve_reschedule_slots( $booking_id, $customer, $date );
        if ( isset( $result['error'] ) ) {
            wp_send_json_error( array( 'message' => $result['error'] ) );
        }

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

    /**
     * Resolve open reschedule slots for a booking + date. Shared by the
     * manual date-picker flow (handle_get_reschedule_slots) and the AI
     * reschedule assistant (handle_ai_parse_reschedule) so both read
     * availability through the exact same logic. Returns array('slots' =>
     * [...]) on success or array('error' => 'message') on failure — this
     * helper never emits JSON itself.
     */
    private function resolve_reschedule_slots( $booking_id, array $customer, $date ) {
        if ($this->is_local_only_mode()) {
            $local = $this->get_local_booking_row($booking_id, $customer);
            if (!$local) {
                return array( 'error' => __('Booking not found.', 'yatoon-booking-system') );
            }

            $slots = $this->get_local_group_reschedule_slots( $local, $date );

            if (empty($slots)) {
                return array( 'error' => __('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'] ?? '' ) : '';
                if ( ! $slot_value ) {
                    continue;
                }
                $formatted[] = array(
                    'start_at' => str_replace( ' ', 'T', $slot_value ),
                    'display'  => $slot_label,
                    'staff_ids'=> array((string) intval($local->staff_id)),
                );
            }

            return array( 'slots' => $formatted );
        }

        // 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'])) {
            return array( 'error' => __( 'Booking not found', 'yatoon-booking-system' ) );
        }
        $bk  = $sq_booking['booking'];
        $seg = $bk['appointment_segments'][0] ?? null;
        if (!$seg) {
            return array( 'error' => __( 'No segments', 'yatoon-booking-system' ) );
        }

        $variation_id = isset($seg['service_variation_id']) ? $seg['service_variation_id'] : '';
        $staff_id     = isset($seg['team_member_id']) ? $seg['team_member_id'] : null;
        $timezone     = yatoon_timezone_string();
        $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
            $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) {
            return array( 'error' => __( 'Unable to load available times. Please call us to reschedule.', 'yatoon-booking-system' ) );
        }

        $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'),
            );
        }

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

    /* ═══════════════════════════════════════════════════════════════
       AJAX: AI BOOKING ASSISTANT
       Customer types a free-text request in "My Appointments" (e.g. "next
       Thursday afternoon" or "actually just cancel it"). AI only classifies
       the intent and, for reschedule, extracts a date + time-of-day
       preference — it never sees or invents availability. Real data always
       has the final say: resolve_reschedule_slots() (the same code the
       manual date-picker uses) supplies the real open slots for reschedule,
       and cancel proposals read the booking's actual current time before
       ever being shown. Confirming a proposal calls the existing, unchanged
       handle_reschedule_booking() / handle_cancel_booking() — no new
       booking-mutation path is added by this assistant.
    ═══════════════════════════════════════════════════════════════ */

    public function handle_ai_assistant_message() {
        check_ajax_referer('yatoon_portal_nonce', 'nonce');
        if (function_exists('yatoon_public_rate_limit')) {
            yatoon_public_rate_limit('portal_ai_reschedule', 8, 10);
        }

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

        if ( get_option( 'yatoon_ai_reschedule_assistant_enabled', '1' ) !== '1' ) {
            wp_send_json_error( array( 'message' => __( 'The AI assistant is turned off.', 'yatoon-booking-system' ) ) );
        }
        if ( ! class_exists( 'YATOON_AI' ) || ! YATOON_AI::is_text_available() ) {
            wp_send_json_error( array(
                'message'      => class_exists( 'YATOON_AI' ) ? YATOON_AI::status_message() : __( 'AI is not available on this site.', 'yatoon-booking-system' ),
                'ai_unavailable' => true,
            ) );
        }

        $booking_id    = sanitize_text_field( wp_unslash( $_POST['booking_id'] ?? '' ) );
        $message       = trim( sanitize_textarea_field( wp_unslash( $_POST['message'] ?? '' ) ) );
        $message_length = function_exists( 'mb_strlen' ) ? mb_strlen( $message ) : strlen( $message );
        if ( ! $booking_id || $message_length < 2 ) {
            wp_send_json_error( array( 'message' => __( "Tell us what you'd like to do with this appointment.", 'yatoon-booking-system' ) ) );
        }
        if ( $message_length > 400 ) {
            wp_send_json_error( array( 'message' => __( 'That message is a bit long — try a shorter description.', 'yatoon-booking-system' ) ) );
        }

        $ai = new YATOON_AI();
        $consent_receipt = sanitize_text_field( wp_unslash( $_POST['ai_consent_receipt'] ?? '' ) );
        if ( ! $ai->consume_consent_receipt( $consent_receipt ) ) {
            wp_send_json_error( array( 'message' => __( 'Please agree before sending your request to the configured third-party AI provider.', 'yatoon-booking-system' ) ) );
        }
        $screened = $ai->screen_ai_input( $message );
        if ( ! empty( $screened['blocked'] ) ) {
            wp_send_json_error( array( 'message' => __( 'For your privacy, remove payment-card, password, or account details before using the AI helper.', 'yatoon-booking-system' ) ) );
        }
        $message = trim( (string) $screened['safe_text'] );
        $privacy_notice = ! empty( $screened['redacted'] )
            ? __( 'A sentence containing health information was kept on this device and was not sent to the AI provider. Contact the salon directly for advice involving allergies, pregnancy, medication, or other health needs.', 'yatoon-booking-system' )
            : '';
        $safe_length = function_exists( 'mb_strlen' ) ? mb_strlen( $message ) : strlen( $message );
        if ( $safe_length < 2 ) {
            wp_send_json_success( array(
                'need_clarification' => true,
                'reply'              => $privacy_notice,
                'privacy_notice'     => $privacy_notice,
                'local_only'         => true,
            ) );
        }

        $today_local = current_time( 'Y-m-d' );
        $tz_name     = yatoon_timezone_string();

        $prompt  = "Today's date is {$today_local} ({$tz_name} timezone). ";
        $prompt .= "A salon customer is managing an existing appointment and wrote this request in their own words:\n\n\"" . $message . "\"\n\n";
        $prompt .= 'First classify their intent as exactly one of "reschedule", "cancel", or "unclear". ';
        $prompt .= 'If the intent is "reschedule", also work out ONE target calendar date (the next sensible upcoming date matching what they described — if they name a weekday with no date, use the next occurrence of that weekday; treat "today"/"tomorrow" literally) and a rough time-of-day preference. ';
        $prompt .= 'Respond with ONLY a compact JSON object, no other text, no markdown fencing, in exactly this shape: {"intent":"reschedule","date":"YYYY-MM-DD","time_pref":"morning","exact_time":null,"clarify":null}. time_pref must be one of "morning","afternoon","evening","any". If the customer gave a specific time, set exact_time to a 24-hour "HH:MM" value and leave time_pref as "any". For "cancel" intent, leave date/time_pref/exact_time null. If you cannot confidently classify the intent or determine a date for a reschedule, set intent to "unclear" and set "clarify" to a short one-sentence follow-up question written in the same language as the customer message, leaving the other fields null.';

        $ai->enforce_global_ai_cap();
        $raw = $ai->quick_generate_text( $prompt, 'You classify a short customer message about an existing salon appointment and, for reschedule requests, extract a structured date/time preference. You never invent appointment availability or perform any action yourself — a separate system checks real data and the customer must confirm before anything changes.' );
        if ( is_wp_error( $raw ) ) {
            wp_send_json_error( array( 'message' => $raw->get_error_message(), 'privacy_notice' => $privacy_notice ) );
        }

        $parsed = $this->parse_ai_json_object( $raw );
        $intent = is_array( $parsed ) ? sanitize_key( $parsed['intent'] ?? 'unclear' ) : 'unclear';

        if ( ! $parsed || 'unclear' === $intent || ! empty( $parsed['clarify'] ) ) {
            wp_send_json_success( array(
                'need_clarification' => true,
                'reply' => ( isset( $parsed['clarify'] ) && '' !== trim( (string) $parsed['clarify'] ) )
                    ? sanitize_text_field( $parsed['clarify'] )
                    : __( "Sorry, I didn't catch that — could you tell me what you'd like to do? For example: \"next Thursday afternoon\" or \"cancel it\".", 'yatoon-booking-system' ),
                'privacy_notice' => $privacy_notice,
            ) );
        }

        if ( 'cancel' === $intent ) {
            $summary = $this->describe_booking_time( $booking_id, $customer );
            if ( null === $summary ) {
                wp_send_json_error( array( 'message' => __( 'Booking not found.', 'yatoon-booking-system' ) ) );
            }
            wp_send_json_success( array(
                'proposal' => array(
                    'type'       => 'cancel',
                    'booking_id' => $booking_id,
                    'summary'    => $summary,
                ),
                'reply' => sprintf(
                    /* translators: %s: existing appointment date/time */
                    __( 'Want me to cancel your %s appointment?', 'yatoon-booking-system' ),
                    $summary
                ),
                'privacy_notice' => $privacy_notice,
            ) );
        }

        // intent === 'reschedule' from here on.
        $date = sanitize_text_field( $parsed['date'] ?? '' );
        if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) || $date < $today_local ) {
            wp_send_json_success( array(
                'need_clarification' => true,
                'reply' => __( 'I couldn\'t work out a valid upcoming date from that — could you name a specific day, like "this Friday" or a calendar date?', 'yatoon-booking-system' ),
                'privacy_notice' => $privacy_notice,
            ) );
        }

        $result = $this->resolve_reschedule_slots( $booking_id, $customer, $date );
        if ( isset( $result['error'] ) ) {
            wp_send_json_success( array(
                'need_clarification' => true,
                'reply' => sprintf(
                    /* translators: 1: requested date, 2: reason there are no slots */
                    __( 'I checked %1$s, but %2$s Want to try a different day?', 'yatoon-booking-system' ),
                    gmdate( 'M j', strtotime( $date ) ),
                    lcfirst( $result['error'] )
                ),
                'privacy_notice' => $privacy_notice,
            ) );
        }

        $slots      = (array) $result['slots'];
        $time_pref  = sanitize_key( $parsed['time_pref'] ?? 'any' );
        $exact_time = sanitize_text_field( $parsed['exact_time'] ?? '' );
        $chosen     = $this->pick_best_reschedule_slot( $slots, $time_pref, $exact_time );

        if ( ! $chosen ) {
            wp_send_json_success( array(
                'need_clarification' => true,
                'reply' => sprintf(
                    /* translators: %s: requested date */
                    __( "I don't see any open times on %s. Want to try a different day?", 'yatoon-booking-system' ),
                    gmdate( 'M j', strtotime( $date ) )
                ),
                'privacy_notice' => $privacy_notice,
            ) );
        }

        wp_send_json_success( array(
            'proposal' => array(
                'type'       => 'reschedule',
                'date'       => $date,
                'date_label' => gmdate( 'l, M j', strtotime( $date ) ),
                'start_at'   => $chosen['start_at'],
                'display'    => $chosen['display'],
                'staff_ids'  => $chosen['staff_ids'] ?? array(),
            ),
            'reply' => sprintf(
                /* translators: 1: weekday + date, 2: proposed time */
                __( 'How about %1$s at %2$s?', 'yatoon-booking-system' ),
                gmdate( 'l, M j', strtotime( $date ) ),
                $chosen['display']
            ),
            'privacy_notice' => $privacy_notice,
        ) );
    }

    /**
     * Human-readable current date/time for a booking, used only to phrase
     * the AI cancel-confirmation prompt. Returns null if the booking can't
     * be found — never guesses.
     */
    private function describe_booking_time( $booking_id, array $customer ) {
        if ( $this->is_local_only_mode() ) {
            $local = $this->get_local_booking_row( $booking_id, $customer );
            if ( ! $local || empty( $local->start_time ) ) {
                return null;
            }
            try {
                $dt = new DateTime( $local->start_time );
                return $dt->format( 'M j \a\t g:i A' );
            } catch ( Exception $e ) {
                return null;
            }
        }

        $sq_booking = $this->square_api->get_booking( $booking_id );
        if ( is_wp_error( $sq_booking ) || empty( $sq_booking['booking']['start_at'] ) ) {
            return null;
        }
        try {
            $dt = new DateTime( $sq_booking['booking']['start_at'] );
            $dt->setTimezone( new DateTimeZone( yatoon_timezone_string() ) );
            return $dt->format( 'M j \a\t g:i A' );
        } catch ( Exception $e ) {
            return null;
        }
    }

    /**
     * Parse a JSON object out of raw AI text, tolerating stray prose or code
     * fences the model may add despite instructions.
     */
    private function parse_ai_json_object( $text ) {
        $text = trim( wp_strip_all_tags( (string) $text ) );
        $text = trim( str_replace( array( '```json', '```' ), '', $text ) );
        $start = strpos( $text, '{' );
        $end   = strrpos( $text, '}' );
        if ( false === $start || false === $end || $end < $start ) {
            return null;
        }
        $json    = substr( $text, $start, $end - $start + 1 );
        $decoded = json_decode( $json, true );
        return is_array( $decoded ) ? $decoded : null;
    }

    /**
     * Choose the real slot that best matches a loose time-of-day preference
     * or an exact HH:MM, from the already-verified list of open slots for
     * the day. Always returns a real slot from the list (never null) unless
     * the list itself is empty.
     */
    private function pick_best_reschedule_slot( array $slots, $time_pref, $exact_time ) {
        if ( empty( $slots ) ) {
            return null;
        }

        if ( $exact_time && preg_match( '/^\d{2}:\d{2}$/', $exact_time ) ) {
            list( $target_h, $target_m ) = array_map( 'intval', explode( ':', $exact_time ) );
            $target_minutes = $target_h * 60 + $target_m;
            $best = null;
            $best_diff = null;
            foreach ( $slots as $slot ) {
                $hm = $this->reschedule_slot_minutes( $slot );
                if ( null === $hm ) {
                    continue;
                }
                $diff = abs( $hm - $target_minutes );
                if ( null === $best_diff || $diff < $best_diff ) {
                    $best_diff = $diff;
                    $best = $slot;
                }
            }
            if ( $best ) {
                return $best;
            }
        }

        $ranges = array(
            'morning'   => array( 0, 720 ),     // before 12:00
            'afternoon' => array( 720, 1020 ),  // 12:00–17:00
            'evening'   => array( 1020, 1440 ), // after 17:00
        );
        if ( isset( $ranges[ $time_pref ] ) ) {
            list( $lo, $hi ) = $ranges[ $time_pref ];
            foreach ( $slots as $slot ) {
                $hm = $this->reschedule_slot_minutes( $slot );
                if ( null !== $hm && $hm >= $lo && $hm < $hi ) {
                    return $slot;
                }
            }
        }

        // "any" preference, or nothing matched the requested window — offer
        // the first open slot of the day rather than refusing outright.
        return $slots[0];
    }

    private function reschedule_slot_minutes( $slot ) {
        $start_at = is_array( $slot ) ? ( $slot['start_at'] ?? '' ) : '';
        if ( ! $start_at ) {
            return null;
        }
        try {
            $dt = new DateTime( str_replace( 'T', ' ', $start_at ) );
            return ( (int) $dt->format( 'H' ) ) * 60 + (int) $dt->format( 'i' );
        } catch ( Exception $e ) {
            return null;
        }
    }

    /* ═══════════════════════════════════════════════════════════════
       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', 'yatoon-booking-system' )));
        }

        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')));
            }

            $tz = $this->get_local_booking_timezone( $local );
            $new_start_dt = new DateTime($new_start, $tz);
            $valid_slots = $this->get_local_group_reschedule_slots( $local, $new_start_dt->format( 'Y-m-d' ) );
            if ( ! in_array( $new_start_dt->format( 'Y-m-d H:i:s' ), array_column( $valid_slots, 'datetime' ), true ) ) {
                wp_send_json_error(array('message' => __('That time slot is no longer available. Please choose another.', 'yatoon-booking-system')));
            }

            global $wpdb;
            $rows = $this->get_local_booking_group( $local );
            $old_base = new DateTime( $rows[0]->start_time, $tz );
            $wpdb->query( 'START TRANSACTION' );
            $ok = true;
            foreach ( $rows as $row ) {
                $old_row_start = new DateTime( $row->start_time, $tz );
                $old_row_end   = new DateTime( $row->end_time, $tz );
                $row_start = ( clone $new_start_dt )->modify( ( $old_row_start->getTimestamp() - $old_base->getTimestamp() ) . ' seconds' );
                $row_end   = ( clone $row_start )->modify( ( $old_row_end->getTimestamp() - $old_row_start->getTimestamp() ) . ' seconds' );
                if ( false === $wpdb->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( $row->id ) ), array('%s', '%s', '%s'), array('%d')
                ) ) {
                    $ok = false;
                    break;
                }
            }
            $wpdb->query( $ok ? 'COMMIT' : 'ROLLBACK' );
            if ( ! $ok ) { wp_send_json_error( array( 'message' => __( 'The appointment could not be rescheduled. Nothing was changed.', 'yatoon-booking-system' ) ) );
            }

            $updated = $this->get_local_booking_row($booking_id, $customer);
            if ($updated && class_exists('YATOON_Notifications')) {
                $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', 'yatoon-booking-system' )));
        }
        $bk = $sq_booking['booking'];

        if ( ! $this->square_booking_belongs_to_customer( $bk, $customer ) ) {
            wp_send_json_error( array( 'message' => __( 'Permission denied', 'yatoon-booking-system' ) ) );
        }

        // 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 = yatoon_timezone_string();
        $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 ) {
                if ( class_exists( 'YATOON_Notifications' ) ) {
                    $notif = new YATOON_Notifications();
                    $notif->send_reschedule_email( $local );
                }
                if ( class_exists( 'YATOON_WhatsApp' ) ) { YATOON_WhatsApp::send_reschedule( $local );
                }
            }
        }

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

    private function square_booking_belongs_to_customer( array $booking, array $customer ) {
        $customer_id = sanitize_text_field( $booking['customer_id'] ?? '' );
        $ids = array_values( array_unique( array_filter( array_map( 'sanitize_text_field', (array) ( $customer['all_square_ids'] ?? array() ) ) ) ) );
        if ( ! empty( $customer['square_id'] ) && ! in_array( $customer['square_id'], $ids, true ) ) { $ids[] = sanitize_text_field( $customer['square_id'] ); }
        if ( $customer_id ) { return in_array( $customer_id, $ids, true ); }

        global $wpdb;
        $mirror = $wpdb->get_row( $wpdb->prepare( "SELECT customer_email,customer_phone FROM {$wpdb->prefix}yatoon_bookings WHERE square_booking_id=%s LIMIT 1", sanitize_text_field( $booking['id'] ?? '' ) ) );
        if ( ! $mirror ) { return false; }
        $email_match = ! empty( $customer['email'] ) && ! empty( $mirror->customer_email ) && strtolower( $customer['email'] ) === strtolower( $mirror->customer_email );
        $phone_match = ! empty( $customer['phone'] ) && $this->phones_match( $customer['phone'], $mirror->customer_phone ?? '' );
        return $email_match || $phone_match;
    }

    private function get_change_service_context( $booking_id, array $customer ) {
        if ( '1' !== (string) get_option( 'yatoon_portal_change_service_enabled', '1' ) ) {
            return new WP_Error( 'disabled', __( 'Online service changes are disabled.', 'yatoon-booking-system' ) );
        }
        $cutoff = max( 0, absint( get_option( 'yatoon_manage_min_hours_before', 2 ) ) );
        if ( $this->is_local_only_mode() ) {
            $row = $this->get_local_booking_row( $booking_id, $customer );
            if ( ! $row || ! empty( $row->group_booking_id ) ) { return new WP_Error( 'not_editable', __( 'This appointment cannot be changed online.', 'yatoon-booking-system' ) ); }
            if ( ! in_array( sanitize_key( $row->payment_status ?? '' ), array( '', 'unpaid', 'pending' ), true ) ) { return new WP_Error( 'payment_locked', __( 'Paid or deposit appointments must be changed by the salon.', 'yatoon-booking-system' ) ); }
            $tz = $this->get_local_booking_timezone( $row );
            if ( new DateTime( $row->start_time, $tz ) <= ( new DateTime( 'now', $tz ) )->modify( "+{$cutoff} hours" ) ) { return new WP_Error( 'cutoff', __( 'This appointment is too close to its start time to change online.', 'yatoon-booking-system' ) ); }
            return array( 'mode' => 'local', 'local' => $row, 'timezone' => $tz );
        }

        $result = $this->square_api->get_booking( sanitize_text_field( $booking_id ) );
        $booking = ! is_wp_error( $result ) ? (array) ( $result['booking'] ?? array() ) : array();
        if ( ! $booking || ! $this->square_booking_belongs_to_customer( $booking, $customer ) || 1 !== count( (array) ( $booking['appointment_segments'] ?? array() ) ) ) {
            return new WP_Error( 'not_editable', __( 'This appointment cannot be changed online.', 'yatoon-booking-system' ) );
        }
        global $wpdb;
        $mirror = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}yatoon_bookings WHERE square_booking_id=%s ORDER BY id ASC LIMIT 1", sanitize_text_field( $booking_id ) ) );
        if ( ! $mirror ) { return new WP_Error( 'not_synced', __( 'This Square appointment must be changed by the salon because its local payment record is unavailable.', 'yatoon-booking-system' ) ); }
        if ( ! in_array( sanitize_key( $mirror->payment_status ?? '' ), array( '', 'unpaid', 'pending' ), true ) ) { return new WP_Error( 'payment_locked', __( 'Paid or deposit appointments must be changed by the salon.', 'yatoon-booking-system' ) ); }
        $tz = yatoon_timezone();
        if ( new DateTime( $booking['start_at'] ?? 'now', new DateTimeZone( 'UTC' ) ) <= ( new DateTime( 'now', new DateTimeZone( 'UTC' ) ) )->modify( "+{$cutoff} hours" ) ) { return new WP_Error( 'cutoff', __( 'This appointment is too close to its start time to change online.', 'yatoon-booking-system' ) ); }
        return array( 'mode' => 'square', 'booking' => $booking, 'local' => $mirror, 'timezone' => $tz );
    }

    private function resolve_change_service_choice( $service_id, $option_id = 0 ) {
        global $wpdb;
        $service = $wpdb->get_row( $wpdb->prepare( "SELECT id,name,price,duration_minutes,square_id FROM {$wpdb->prefix}yatoon_services WHERE id=%d AND is_active=1 LIMIT 1", absint( $service_id ) ) );
        if ( ! $service ) { return null; }
        $option = null;
        if ( $option_id ) {
            $option = $wpdb->get_row( $wpdb->prepare( "SELECT id,name,duration_minutes,price_modifier,square_id,square_version,group_name FROM {$wpdb->prefix}yatoon_service_options WHERE id=%d AND service_id=%d AND COALESCE(group_name,'')<>'Add-on' LIMIT 1", absint( $option_id ), absint( $service_id ) ) );
            if ( ! $option ) { return null; }
        }
        $duration = absint( $option->duration_minutes ?? $service->duration_minutes );
        return array(
            'service_id' => (int) $service->id, 'option_id' => (int) ( $option->id ?? 0 ),
            'name' => sanitize_text_field( $service->name . ( $option && strtolower( $option->name ) !== strtolower( $service->name ) ? ' – ' . $option->name : '' ) ),
            'duration' => max( 5, $duration ), 'price' => max( 0, (float) $service->price + (float) ( $option->price_modifier ?? 0 ) ),
            'variation_id' => sanitize_text_field( $option->square_id ?? $service->square_id ),
            'variation_version' => absint( $option->square_version ?? 0 ),
        );
    }

    public function handle_get_change_service_options() {
        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( wp_unslash( $_POST['booking_id'] ?? '' ) );
        $context = $this->get_change_service_context( $booking_id, $customer );
        if ( is_wp_error( $context ) ) { wp_send_json_error( array( 'message' => $context->get_error_message() ) ); }
        global $wpdb;
        $services = $wpdb->get_results( "SELECT id,name,price,duration_minutes,square_id FROM {$wpdb->prefix}yatoon_services WHERE is_active=1 ORDER BY sort_order ASC,name ASC" );
        $choices = array();
        foreach ( (array) $services as $service ) {
            $options = $wpdb->get_results( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}yatoon_service_options WHERE service_id=%d AND COALESCE(group_name,'')<>'Add-on' ORDER BY sort_order ASC,id ASC", $service->id ) );
            if ( ! $options ) { $options = array( (object) array( 'id' => 0 ) ); }
            foreach ( $options as $option ) {
                $choice = $this->resolve_change_service_choice( $service->id, $option->id );
                if ( ! $choice || ( 'square' === $context['mode'] && ! $choice['variation_id'] ) ) { continue; }
                $choices[] = array_merge( $choice, array( 'price_label' => '$' . number_format( $choice['price'], 2 ) ) );
            }
        }
        wp_send_json_success( array( 'choices' => $choices, 'mode' => $context['mode'] ) );
    }

    public function handle_get_change_service_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( wp_unslash( $_POST['booking_id'] ?? '' ) );
        $date = sanitize_text_field( wp_unslash( $_POST['date'] ?? '' ) );
        $choice = $this->resolve_change_service_choice( absint( $_POST['service_id'] ?? 0 ), absint( $_POST['option_id'] ?? 0 ) );
        $context = $this->get_change_service_context( $booking_id, $customer );
        if ( is_wp_error( $context ) || ! $choice || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) { wp_send_json_error( array( 'message' => is_wp_error( $context ) ? $context->get_error_message() : __( 'Invalid service or date.', 'yatoon-booking-system' ) ) ); }
        $slots = array();
        if ( 'local' === $context['mode'] ) {
            global $wpdb;
            $staff_ids = $wpdb->get_col( $wpdb->prepare( "SELECT s.id FROM {$wpdb->prefix}yatoon_staff s INNER JOIN {$wpdb->prefix}yatoon_staff_services ss ON ss.staff_id=s.id WHERE ss.service_id=%d AND s.is_active=1 AND s.is_bookable=1 ORDER BY s.sort_order,s.name", $choice['service_id'] ) );
            if ( ! $staff_ids ) { $staff_ids = $wpdb->get_col( "SELECT id FROM {$wpdb->prefix}yatoon_staff WHERE is_active=1 AND is_bookable=1 ORDER BY sort_order,name" ); }
            $by_time = array();
            foreach ( array_map( 'absint', $staff_ids ) as $staff_id ) {
                foreach ( YATOON_Business_Hours::get_available_slots_for_date( $date, $choice['service_id'], $staff_id, $context['local']->id, $context['timezone'], $choice['duration'] ) as $slot ) {
                    $key = $slot['datetime'];
                    if ( ! isset( $by_time[ $key ] ) ) { $by_time[ $key ] = array( 'start_at' => str_replace( ' ', 'T', $key ), 'display' => $slot['display'], 'staff_ids' => array() ); }
                    $by_time[ $key ]['staff_ids'][] = (string) $staff_id;
                }
            }
            $slots = array_values( $by_time );
        } else {
            $tz = $context['timezone'];
            $start = ( new DateTime( $date . ' 00:00:00', $tz ) )->format( 'c' );
            $end = ( new DateTime( $date . ' 23:59:59', $tz ) )->format( 'c' );
            $response = $this->square_api->search_availability( $choice['variation_id'], $start, $end, null );
            if ( is_wp_error( $response ) ) { wp_send_json_error( array( 'message' => $response->get_error_message() ) ); }
            foreach ( (array) ( $response['availabilities'] ?? array() ) as $availability ) {
                if ( empty( $availability['start_at'] ) ) { continue; }
                $display = new DateTime( $availability['start_at'] ); $display->setTimezone( $tz );
                $slots[] = array( 'start_at' => $availability['start_at'], 'display' => $display->format( 'g:i A' ), 'staff_ids' => array_values( array_filter( array_column( (array) ( $availability['appointment_segments'] ?? array() ), 'team_member_id' ) ) ) );
            }
        }
        wp_send_json_success( array( 'slots' => $slots, 'choice' => $choice ) );
    }

    public function handle_change_booking_service() {
        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( wp_unslash( $_POST['booking_id'] ?? '' ) );
        $new_start = sanitize_text_field( wp_unslash( $_POST['new_start_at'] ?? '' ) );
        $staff_id = sanitize_text_field( wp_unslash( $_POST['staff_id'] ?? '' ) );
        $choice = $this->resolve_change_service_choice( absint( $_POST['service_id'] ?? 0 ), absint( $_POST['option_id'] ?? 0 ) );
        $context = $this->get_change_service_context( $booking_id, $customer );
        if ( is_wp_error( $context ) || ! $choice || ! $new_start || ! $staff_id ) { wp_send_json_error( array( 'message' => is_wp_error( $context ) ? $context->get_error_message() : __( 'Please select a service and available time.', 'yatoon-booking-system' ) ) ); }
        global $wpdb;
        $price = class_exists( 'YATOON_Tax' ) ? YATOON_Tax::payable_total( $choice['price'], $choice['service_id'] ) : $choice['price'];
        if ( 'local' === $context['mode'] ) {
            $staff_id = absint( $staff_id );
            $assigned_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}yatoon_staff_services WHERE service_id=%d", $choice['service_id'] ) );
            if ( $assigned_count && ! (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}yatoon_staff_services WHERE service_id=%d AND staff_id=%d", $choice['service_id'], $staff_id ) ) ) { wp_send_json_error( array( 'message' => __( 'That employee is not available for the selected service.', 'yatoon-booking-system' ) ) ); }
            $start = new DateTime( $new_start, $context['timezone'] );
            $valid = YATOON_Business_Hours::get_available_slots_for_date( $start->format( 'Y-m-d' ), $choice['service_id'], $staff_id, $context['local']->id, $context['timezone'], $choice['duration'] );
            if ( ! in_array( $start->format( 'Y-m-d H:i:s' ), array_column( $valid, 'datetime' ), true ) ) { wp_send_json_error( array( 'message' => __( 'That time is no longer available.', 'yatoon-booking-system' ) ) ); }
            $end = ( clone $start )->modify( '+' . $choice['duration'] . ' minutes' );
            $ok = $wpdb->update( $wpdb->prefix . 'yatoon_bookings', array( 'service_id' => $choice['service_id'], 'staff_id' => $staff_id, 'start_time' => $start->format( 'Y-m-d H:i:s' ), 'end_time' => $end->format( 'Y-m-d H:i:s' ), 'total_price' => $price, 'status' => 'pending' ), array( 'id' => absint( $context['local']->id ) ), array( '%d','%d','%s','%s','%f','%s' ), array( '%d' ) );
            if ( false === $ok ) { wp_send_json_error( array( 'message' => __( 'The appointment could not be changed.', 'yatoon-booking-system' ) ) ); }
            $wpdb->delete( $wpdb->prefix . 'yatoon_booking_options', array( 'booking_id' => absint( $context['local']->id ) ), array( '%d' ) );
            if ( $choice['option_id'] ) { $wpdb->insert( $wpdb->prefix . 'yatoon_booking_options', array( 'booking_id' => absint( $context['local']->id ), 'option_id' => $choice['option_id'] ), array( '%d', '%d' ) ); }
        } else {
            $date = new DateTime( $new_start ); $date->setTimezone( $context['timezone'] );
            $day_start = ( new DateTime( $date->format( 'Y-m-d' ) . ' 00:00:00', $context['timezone'] ) )->format( 'c' );
            $day_end = ( new DateTime( $date->format( 'Y-m-d' ) . ' 23:59:59', $context['timezone'] ) )->format( 'c' );
            $availability = $this->square_api->search_availability( $choice['variation_id'], $day_start, $day_end, $staff_id );
            $valid = false;
            foreach ( (array) ( is_wp_error( $availability ) ? array() : ( $availability['availabilities'] ?? array() ) ) as $slot ) { if ( ! empty( $slot['start_at'] ) && strtotime( $slot['start_at'] ) === strtotime( $new_start ) ) { $valid = true; break; } }
            if ( ! $valid ) { wp_send_json_error( array( 'message' => __( 'That time is no longer available.', 'yatoon-booking-system' ) ) ); }
            if ( ! $choice['variation_version'] ) { $catalog = $this->square_api->get_catalog_object( $choice['variation_id'] ); $choice['variation_version'] = absint( $catalog['object']['version'] ?? 0 ); }
            $booking = $context['booking'];
            $segment = array( 'service_variation_id' => $choice['variation_id'], 'team_member_id' => $staff_id, 'duration_minutes' => $choice['duration'] );
            if ( $choice['variation_version'] ) { $segment['service_variation_version'] = $choice['variation_version']; }
            $payload = array( 'version' => absint( $booking['version'] ?? 0 ), 'start_at' => $new_start, 'location_id' => sanitize_text_field( $booking['location_id'] ?? get_option( 'yatoon_square_location_id' ) ), 'customer_id' => sanitize_text_field( $booking['customer_id'] ?? '' ), 'appointment_segments' => array( $segment ) );
            if ( ! $payload['customer_id'] ) { unset( $payload['customer_id'] ); }
            $result = $this->square_api->update_booking( $booking_id, $payload );
            if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); }
            if ( $context['local'] ) {
                $local_start = new DateTime( $new_start ); $local_start->setTimezone( $context['timezone'] );
                $local_end = ( clone $local_start )->modify( '+' . $choice['duration'] . ' minutes' );
                $local_staff = absint( $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}yatoon_staff WHERE square_id=%s LIMIT 1", $staff_id ) ) );
                $wpdb->update( $wpdb->prefix . 'yatoon_bookings', array( 'service_id' => $choice['service_id'], 'staff_id' => $local_staff, 'start_time' => $local_start->format( 'Y-m-d H:i:s' ), 'end_time' => $local_end->format( 'Y-m-d H:i:s' ), 'total_price' => $price, 'status' => 'confirmed' ), array( 'id' => absint( $context['local']->id ) ), array( '%d','%d','%s','%s','%f','%s' ), array( '%d' ) );
                $wpdb->delete( $wpdb->prefix . 'yatoon_booking_options', array( 'booking_id' => absint( $context['local']->id ) ), array( '%d' ) );
                if ( $choice['option_id'] ) { $wpdb->insert( $wpdb->prefix . 'yatoon_booking_options', array( 'booking_id' => absint( $context['local']->id ), 'option_id' => $choice['option_id'] ), array( '%d', '%d' ) ); }
            }
        }
        $updated = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}yatoon_bookings WHERE id=%d LIMIT 1", absint( $context['local']->id ) ) );
        if ( $updated ) {
            if ( class_exists( 'YATOON_Notifications' ) ) {
                $notif = new YATOON_Notifications();
                $notif->send_reschedule_email( (array) $updated );
            }
            if ( class_exists( 'YATOON_Twilio' ) ) { YATOON_Twilio::send_reschedule_sms( (array) $updated ); }
            if ( class_exists( 'YATOON_WhatsApp' ) ) { YATOON_WhatsApp::send_reschedule( $updated ); }
        }
        wp_send_json_success( array( 'message' => __( 'Service and appointment time updated successfully.', 'yatoon-booking-system' ), 'service' => $choice['name'], 'price' => '$' . number_format( $price, 2 ) ) );
    }

    /* ═══════════════════════════════════════════════════════════════
       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 '';
    }
}
