<?php
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.SafeRedirect.wp_redirect_wp_redirect, WordPress.WP.I18n.MissingTranslatorsComment, WordPress.WP.I18n.UnorderedPlaceholdersText, WordPress.DateTime.RestrictedFunctions.date_date, WordPress.WP.AlternativeFunctions.rand_mt_rand, WordPress.PHP.DevelopmentFunctions.error_log_error_log, Squiz.PHP.DiscouragedFunctions.Discouraged, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound
/**
 * Yatoon Staff Portal - PIN-based staff schedule PWA
 *
 * Shortcode: [yatoon_staff_portal]
 * Place this shortcode on any page. Staff open that URL,
 * enter their staff PIN, and see their weekly schedule.
 *
 * Admin: Settings -> Staff tab -> PIN column per staff member.
 *
 * AJAX actions (all require yatoon_staff_portal_nonce):
 *   yatoon_staff_portal_login          - verify PIN, return staff_id + name
 *   yatoon_staff_get_schedule          - return 7-day schedule for staff_id
 *   yatoon_staff_update_booking_status - update arrived/no_show/completed
 */

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

class YATOON_Staff_Portal {

    public static function init() {
        add_shortcode( 'yatoon_staff_portal', array( __CLASS__, 'render_shortcode' ) );

        // Public AJAX (staff are not WP users)
        add_action( 'wp_ajax_nopriv_yatoon_staff_portal_login',          array( __CLASS__, 'ajax_login' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_identity_handoff',      array( __CLASS__, 'ajax_identity_handoff' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_portal_logout',         array( __CLASS__, 'ajax_logout' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_get_schedule',          array( __CLASS__, 'ajax_get_schedule' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_update_booking_status', array( __CLASS__, 'ajax_update_status' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_get_booking_form_data',  array( __CLASS__, 'ajax_get_booking_form_data' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_search_clients',         array( __CLASS__, 'ajax_search_clients' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_get_clients',            array( __CLASS__, 'ajax_get_clients' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_get_client_history',     array( __CLASS__, 'ajax_get_client_history' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_save_client',            array( __CLASS__, 'ajax_save_client' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_delete_client',          array( __CLASS__, 'ajax_delete_client' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_send_message',           array( __CLASS__, 'ajax_send_message' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_save_booking',           array( __CLASS__, 'ajax_save_booking' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_cancel_booking',         array( __CLASS__, 'ajax_cancel_booking' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_checkout',               array( __CLASS__, 'ajax_checkout' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_get_time_off',            array( __CLASS__, 'ajax_get_time_off' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_request_time_off',        array( __CLASS__, 'ajax_request_time_off' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_review_time_off',         array( __CLASS__, 'ajax_review_time_off' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_get_profile',             array( __CLASS__, 'ajax_get_profile' ) );
        add_action( 'wp_ajax_nopriv_yatoon_staff_save_profile',            array( __CLASS__, 'ajax_save_profile' ) );

        // Also allow logged-in WP users (admins testing)
        add_action( 'wp_ajax_yatoon_staff_portal_login',          array( __CLASS__, 'ajax_login' ) );
        add_action( 'wp_ajax_yatoon_staff_identity_handoff',      array( __CLASS__, 'ajax_identity_handoff' ) );
        add_action( 'wp_ajax_yatoon_staff_portal_logout',         array( __CLASS__, 'ajax_logout' ) );
        add_action( 'wp_ajax_yatoon_staff_get_schedule',          array( __CLASS__, 'ajax_get_schedule' ) );
        add_action( 'wp_ajax_yatoon_staff_update_booking_status', array( __CLASS__, 'ajax_update_status' ) );
        add_action( 'wp_ajax_yatoon_staff_get_booking_form_data',  array( __CLASS__, 'ajax_get_booking_form_data' ) );
        add_action( 'wp_ajax_yatoon_staff_search_clients',         array( __CLASS__, 'ajax_search_clients' ) );
        add_action( 'wp_ajax_yatoon_staff_get_clients',            array( __CLASS__, 'ajax_get_clients' ) );
        add_action( 'wp_ajax_yatoon_staff_get_client_history',     array( __CLASS__, 'ajax_get_client_history' ) );
        add_action( 'wp_ajax_yatoon_staff_save_client',            array( __CLASS__, 'ajax_save_client' ) );
        add_action( 'wp_ajax_yatoon_staff_delete_client',          array( __CLASS__, 'ajax_delete_client' ) );
        add_action( 'wp_ajax_yatoon_staff_send_message',           array( __CLASS__, 'ajax_send_message' ) );
        add_action( 'wp_ajax_yatoon_staff_save_booking',           array( __CLASS__, 'ajax_save_booking' ) );
        add_action( 'wp_ajax_yatoon_staff_cancel_booking',         array( __CLASS__, 'ajax_cancel_booking' ) );
        add_action( 'wp_ajax_yatoon_staff_checkout',               array( __CLASS__, 'ajax_checkout' ) );
        add_action( 'wp_ajax_yatoon_staff_get_time_off',            array( __CLASS__, 'ajax_get_time_off' ) );
        add_action( 'wp_ajax_yatoon_staff_request_time_off',        array( __CLASS__, 'ajax_request_time_off' ) );
        add_action( 'wp_ajax_yatoon_staff_review_time_off',         array( __CLASS__, 'ajax_review_time_off' ) );
        add_action( 'wp_ajax_yatoon_staff_get_profile',             array( __CLASS__, 'ajax_get_profile' ) );
        add_action( 'wp_ajax_yatoon_staff_save_profile',            array( __CLASS__, 'ajax_save_profile' ) );

        // PWA: manifest + SW rewrite rules
        add_action( 'init',              array( __CLASS__, 'add_rewrite_rules' ) );
        add_filter( 'query_vars',        array( __CLASS__, 'add_query_vars' ) );
        add_action( 'template_redirect', array( __CLASS__, 'serve_pwa_files' ) );

        // Enqueue assets on portal page
        add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );

        // PWA: inject manifest link + meta tags + SW registration on the portal page itself.
        // Without this, the page has no manifest of its own, so "Add to Home Screen"
        // can fall back to whatever site-wide manifest another plugin injects (e.g. pointing
        // at the homepage) instead of this staff-specific one.
        add_action( 'wp_head',            array( __CLASS__, 'portal_pwa_meta' ), 1 );
    }

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

    public static function render_shortcode( $atts ) {
        self::render_portal_assets();
        ob_start();
        $portal_data = array(); // future: pass config
        include YATOON_PLUGIN_DIR . 'public/views/staff-portal.php';
        return ob_get_clean();
    }

    // -- Enqueue -----------------------------------------------------------

    public static function enqueue_assets() {
        global $post;
        $should_load = $post && (
            has_shortcode( $post->post_content, 'yatoon_staff_portal' )
            || ( function_exists( 'has_block' ) && has_block( 'yatoon/staff-portal', $post ) )
        );
        if ( ! $should_load ) { return;
        }
        self::render_portal_assets();
    }

    /**
     * The actual asset-loading work, split out so it can also be called
     * directly from render_shortcode() -- has_shortcode()/has_block() above
     * can't detect an Elementor widget (Elementor stores its content
     * separately, not as text in post_content), so calling this at the
     * point the shortcode itself renders guarantees the CSS/JS still load
     * for that case. Idempotent.
     */
    public static function render_portal_assets() {
        static $already_rendered = false;
        if ( $already_rendered ) { return;
        }
        $already_rendered = true;

        $min = ( ! defined( 'SCRIPT_DEBUG' ) || ! SCRIPT_DEBUG ) && file_exists( YATOON_PLUGIN_DIR . 'assets/css/staff-portal.min.css' ) ? '.min' : '';
        $portal_asset_version = YATOON_VERSION . '-staff-editor-2-booked-source-1';
        wp_enqueue_style( 'yatoon-staff-portal-css', YATOON_PLUGIN_URL . 'assets/css/staff-portal' . $min . '.css', array(), $portal_asset_version );
        wp_enqueue_style( 'yatoon-design-system', YATOON_PLUGIN_URL . 'assets/css/yatoon-design-system' . YATOON_ASSET_SUFFIX . '.css', array( 'yatoon-staff-portal-css' ), YATOON_VERSION );

        $min_js = ( ! defined( 'SCRIPT_DEBUG' ) || ! SCRIPT_DEBUG ) && file_exists( YATOON_PLUGIN_DIR . 'assets/js/staff-portal.min.js' ) ? '.min' : '';
        wp_enqueue_script( 'yatoon-staff-portal-js', YATOON_PLUGIN_URL . 'assets/js/staff-portal' . $min_js . '.js', array( 'jquery' ), $portal_asset_version, true );
        wp_enqueue_script( 'yatoon-ux', YATOON_PLUGIN_URL . 'assets/js/yatoon-ux' . YATOON_ASSET_SUFFIX . '.js', array( 'yatoon-staff-portal-js' ), YATOON_VERSION, true );
        yatoon_set_script_i18n( 'yatoon-staff-portal-js' );
    }

    /**
     * Output <link rel="manifest"> + iOS meta tags on the staff portal page only,
     * so "Add to Home Screen" picks up this page's own name/icon/start_url instead
     * of a site-wide manifest from another plugin.
     */
    public static function portal_pwa_meta() {
        global $post;
        $should_output = $post && (
            has_shortcode( $post->post_content, 'yatoon_staff_portal' )
            || ( function_exists( 'has_block' ) && has_block( 'yatoon/staff-portal', $post ) )
        );
        if ( ! $should_output ) { return;
        }

        $name     = esc_attr( sprintf( __( '%s Staff', 'yatoon-booking-system' ), get_bloginfo( 'name' ) ) );
        $color    = esc_attr( get_option( 'yatoon_primary_color', '#ec4899' ) );
        $manifest = esc_url( home_url( '/yatoon-staff-manifest.json' ) );
        $icon_192 = esc_url( YATOON_PLUGIN_URL . 'assets/images/pwa-icon-192.png' );
        ?>
        <!-- Yatoon PWA: Staff Portal -->
        <link rel="manifest" href="<?php echo esc_url( $manifest ); ?>">
        <meta name="theme-color" content="<?php echo esc_attr( $color ); ?>">
        <meta name="mobile-web-app-capable" content="yes">
        <meta name="apple-mobile-web-app-capable" content="yes">
        <meta name="apple-mobile-web-app-status-bar-style" content="default">
        <meta name="apple-mobile-web-app-title" content="<?php echo esc_attr( $name ); ?>">
        <link rel="apple-touch-icon" href="<?php echo esc_url( $icon_192 ); ?>">
        <?php
    }

    // -- PWA: manifest + SW ------------------------------------------------

    public static function add_rewrite_rules() {
        add_rewrite_rule( '^yatoon-staff-manifest\.json$', 'index.php?yatoon_staff_pwa=manifest', 'top' );
        add_rewrite_rule( '^yatoon-staff-sw\.js$',         'index.php?yatoon_staff_pwa=sw',       'top' );
    }

    public static function add_query_vars( $vars ) {
        $vars[] = 'yatoon_staff_pwa';
        return $vars;
    }

    public static function serve_pwa_files() {
        $type = get_query_var( 'yatoon_staff_pwa' );
        if ( ! $type ) { return;
        }
        $portal_url   = get_permalink( self::get_portal_page_id() ) ?: home_url( '/' );
        $portal_path  = wp_parse_url( $portal_url, PHP_URL_PATH );
        $portal_scope = trailingslashit( $portal_path ? $portal_path : '/' );

        if ( $type === 'manifest' ) {
            header( 'Content-Type: application/manifest+json; charset=utf-8' );
            $name  = sprintf( __( '%s Staff', 'yatoon-booking-system' ), html_entity_decode( get_bloginfo( 'name' ), ENT_QUOTES, 'UTF-8' ) );
            $color = get_option( 'yatoon_primary_color', '#ec4899' );
            echo wp_json_encode( array(
                'name'             => $name,
                'short_name'       => __( 'Staff', 'yatoon-booking-system' ),
                'description'      => __( 'View your appointments and manage bookings.', 'yatoon-booking-system' ),
                'start_url'        => $portal_url,
                'scope'            => $portal_scope,
                'display'          => 'standalone',
                'orientation'      => 'portrait',
                'background_color' => '#ffffff',
                'theme_color'      => $color,
                'lang'             => get_bloginfo( 'language' ),
                'icons'            => array(
                    array( 'src' => YATOON_PLUGIN_URL . 'assets/images/pwa-icon-192.png', 'sizes' => '192x192', 'type' => 'image/png' ),
                    array( 'src' => YATOON_PLUGIN_URL . 'assets/images/pwa-icon-512.png', 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'any maskable' ),
                ),
            ), JSON_UNESCAPED_SLASHES );
            exit;
        }

        if ( $type === 'sw' ) {
            header( 'Content-Type: application/javascript; charset=utf-8' );
            header( 'Cache-Control: no-cache, no-store, must-revalidate' );
            header( 'Service-Worker-Allowed: ' . $portal_scope );
            $sw_file = YATOON_PLUGIN_DIR . 'assets/js/sw-staff.js';
            if ( file_exists( $sw_file ) ) {
                global $wp_filesystem;
                if ( ! function_exists( 'WP_Filesystem' ) ) {
                    require_once ABSPATH . 'wp-admin/includes/file.php';
                }
                WP_Filesystem();
                if ( $wp_filesystem ) {
                    echo $wp_filesystem->get_contents( $sw_file ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Outputs trusted plugin service worker JavaScript.
                }
            }
            exit;
        }
    }

    private static function get_portal_page_id() {
        global $wpdb;
        $ids = yatoon_db_get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            "SELECT ID FROM {$wpdb->posts} WHERE post_status='publish' AND post_content LIKE '%[yatoon_staff_portal]%' LIMIT 1"
        );
        return $ids[0] ?? 0;
    }

    private static function normalize_permissions( $raw ) {
        if ( function_exists( 'yatoon_normalize_staff_portal_permissions' ) ) {
            return yatoon_normalize_staff_portal_permissions( $raw );
        }

        $allowed = array( 'view_schedule', 'view_all_schedules', 'update_status', 'create_booking', 'edit_booking', 'cancel_booking', 'view_clients', 'manage_clients', 'send_messages', 'checkout', 'request_time_off', 'approve_time_off' );
        $parts   = is_array( $raw ) ? $raw : explode( ',', (string) $raw );
        $clean   = array( 'view_schedule' );
        foreach ( $parts as $part ) {
            $key = sanitize_key( trim( (string) $part ) );
            if ( in_array( $key, $allowed, true ) ) {
                $clean[] = $key;
            }
        }
        return implode( ',', array_values( array_unique( $clean ) ) );
    }

    private static function permissions_payload( $raw ) {
        $keys = explode( ',', self::normalize_permissions( $raw ) );
        return array(
            'view_schedule'      => true,
            'view_all_schedules' => in_array( 'view_all_schedules', $keys, true ),
            'update_status'      => in_array( 'update_status', $keys, true ),
            'create_booking'     => in_array( 'create_booking', $keys, true ),
            'edit_booking'       => in_array( 'edit_booking', $keys, true ),
            'cancel_booking'     => in_array( 'cancel_booking', $keys, true ),
            'view_clients'       => in_array( 'view_clients', $keys, true ) || in_array( 'manage_clients', $keys, true ) || in_array( 'send_messages', $keys, true ),
            'manage_clients'     => in_array( 'manage_clients', $keys, true ),
            'send_messages'      => in_array( 'send_messages', $keys, true ),
            'checkout'           => in_array( 'checkout', $keys, true ),
            'request_time_off'   => in_array( 'request_time_off', $keys, true ),
            'approve_time_off'   => in_array( 'approve_time_off', $keys, true ),
        );
    }

    /**
     * Fingerprint the calling client for session binding.
     *
     * Deliberately NOT the raw IP: salon staff move between the shop wifi and
     * mobile data mid-shift, and pinning to an IP would sign them out every
     * time. The user agent is stable across that and still means a stolen
     * token cannot simply be replayed from an attacker's own browser. Hashed
     * with an app salt so nothing identifying is stored.
     *
     * @since 4.5.0
     * @return string
     */
    private static function session_fingerprint() {
        $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ), 0, 255 ) : '';
        return hash_hmac( 'sha256', $ua, wp_salt( 'nonce' ) );
    }

    /**
     * Session lifetime. Shortened from 12h in 4.5.0.
     *
     * 12 hours meant a token minted on the shared front-desk tablet at opening
     * was still valid at closing, for whoever picked the tablet up. Sessions
     * now idle out after 2 hours but slide forward on every authenticated
     * request (see validate_staff_session), so an actively-working stylist is
     * never interrupted while an abandoned tablet stops being a key.
     */
    const SESSION_TTL          = 2 * HOUR_IN_SECONDS;
    const SESSION_ABSOLUTE_TTL = 12 * HOUR_IN_SECONDS;

    private static function create_staff_session( $staff_id ) {
        $token = wp_generate_password( 32, false, false );
        set_transient(
            'ysp_staff_session_' . md5( $token ),
            array(
                'staff_id'    => (int) $staff_id,
                'fingerprint' => self::session_fingerprint(),
                'issued_at'   => time(),
            ),
            self::SESSION_TTL
        );
        return $token;
    }

    /** Build the same session payload for PIN, Google, email, and SMS login. */
    public static function login_payload_for_staff( $staff_id ) {
        global $wpdb;
        $staff = yatoon_db_get_row( $wpdb->prepare(
            "SELECT id,name,portal_role,portal_permissions FROM {$wpdb->prefix}yatoon_staff WHERE id=%d AND is_active=1 LIMIT 1",
            absint( $staff_id )
        ) );
        if ( ! $staff ) { return new WP_Error( 'staff_not_available', __( 'This employee account is not available.', 'yatoon-booking-system' ) ); }
        return array(
            'staff_id' => (int) $staff->id, 'name' => $staff->name,
            'portal_token' => self::create_staff_session( $staff->id ),
            'role' => $staff->portal_role ?: 'staff',
            'permissions' => self::permissions_payload( $staff->portal_permissions ?? '' ),
        );
    }

    public static function ajax_identity_handoff() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );
        $handoff = sanitize_text_field( wp_unslash( $_POST['handoff'] ?? '' ) );
        $staff_id = class_exists( 'YATOON_Google_Identity' ) ? YATOON_Google_Identity::exchange_staff_handoff( $handoff ) : 0;
        $payload = $staff_id ? self::login_payload_for_staff( $staff_id ) : new WP_Error( 'handoff_expired', __( 'Google sign-in expired. Please try again.', 'yatoon-booking-system' ) );
        if ( is_wp_error( $payload ) ) { wp_send_json_error( array( 'message' => $payload->get_error_message() ) ); }
        wp_send_json_success( $payload );
    }

    /**
     * Give the staff portal's own schedule view a chance to self-heal instead
     * of only relying on the admin manually clicking "Sync Now" or waiting
     * for the scheduled cron. Rate-limited so opening/refreshing the
     * schedule repeatedly doesn't hammer the Square API.
     */
    public static function maybe_auto_sync_bookings() {
        if ( get_option( 'yatoon_booking_sync_enabled', '1' ) !== '1' ) {
            return;
        }
        if ( ! class_exists( 'YATOON_Cron' ) || ! class_exists( 'YATOON_Square_API' ) ) {
            return;
        }
        $platform = sanitize_key( (string) get_option( 'yatoon_active_platform', get_option( 'yatoon_booking_platform', 'local' ) ) );
        if ( $platform !== 'square' ) {
            return;
        }
        if ( get_transient( 'yatoon_staff_portal_auto_sync_lock' ) ) {
            return;
        }
        set_transient( 'yatoon_staff_portal_auto_sync_lock', 1, 90 );
        try {
            $cron = new YATOON_Cron();
            $cron->sync_bookings_only();
        } catch ( \Throwable $e ) {
            yatoon_safe_log( 'Staff Portal auto-sync failed: ' . $e->getMessage() );
        }
    }

    /**
     * Validate the caller's staff portal session.
     *
     * 4.4.3: this used to compare the token's staff id and stop there, so a
     * token stayed usable for its full 12 hours even after the admin
     * deactivated that staff member. Deactivating someone has to revoke their
     * access immediately -- that is the whole point of the switch. We now
     * confirm the account is still active on every request, and drop the
     * session the moment it is not.
     *
     * @param int $staff_id
     * @return bool
     */
    private static function validate_staff_session( $staff_id ) {
        $token = sanitize_text_field( wp_unslash( $_POST['portal_token'] ?? '' ) );
        if ( ! $token ) {
            return false;
        }

        $transient_key = 'ysp_staff_session_' . md5( $token );
        $session       = get_transient( $transient_key );
        $staff_id      = (int) $staff_id;

        // 4.5.0: sessions became arrays (staff id + fingerprint + issued_at).
        // Tokens minted by 4.4.x are bare integers and are still in flight for
        // up to 12 hours after the update, so accept them rather than signing
        // the whole salon out mid-shift. They simply carry no binding.
        if ( is_array( $session ) ) {
            $saved_staff_id = (int) ( $session['staff_id'] ?? 0 );
            $issued_at      = (int) ( $session['issued_at'] ?? 0 );
            $fingerprint    = (string) ( $session['fingerprint'] ?? '' );

            // Absolute cap: sliding renewal must not make a session immortal.
            if ( $issued_at && ( time() - $issued_at ) > self::SESSION_ABSOLUTE_TTL ) {
                delete_transient( $transient_key );
                return false;
            }
            if ( $fingerprint && ! hash_equals( $fingerprint, self::session_fingerprint() ) ) {
                delete_transient( $transient_key );
                return false;
            }
        } else {
            $saved_staff_id = (int) $session;
            $session        = array(
                'staff_id'    => $saved_staff_id,
                'fingerprint' => '',
                'issued_at'   => time(),
            );
        }

        if ( ! $saved_staff_id || $saved_staff_id !== $staff_id ) {
            return false;
        }

        global $wpdb;
        $still_active = (int) yatoon_db_get_var( $wpdb->prepare(
            "SELECT id FROM {$wpdb->prefix}yatoon_staff WHERE id = %d AND is_active = 1 LIMIT 1",
            $staff_id
        ) );
        if ( ! $still_active ) {
            delete_transient( $transient_key );
            return false;
        }

        // Sliding renewal: an in-use session keeps working, an idle one dies.
        set_transient( $transient_key, $session, self::SESSION_TTL );

        return true;
    }

    /**
     * AJAX: end a staff portal session.
     *
     * 4.4.3: there was no logout at all -- a token issued on a shared salon
     * tablet stayed valid for 12 hours no matter what the user did. Signing
     * out now actually destroys the server-side session.
     *
     * @since 4.4.3
     * @return void
     */
    public static function ajax_logout() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $token = sanitize_text_field( wp_unslash( $_POST['portal_token'] ?? '' ) );
        if ( $token ) {
            delete_transient( 'ysp_staff_session_' . md5( $token ) );
        }

        wp_send_json_success( array( 'message' => __( 'Signed out.', 'yatoon-booking-system' ) ) );
    }

    public static function authorize_staff_request( $permission = '' ) {
        $staff_id = absint( $_POST['staff_id'] ?? 0 );
        if ( ! $staff_id || ! self::validate_staff_session( $staff_id ) ) {
            return new WP_Error( 'yatoon_staff_session_expired', __( 'Your staff session has expired. Please sign in again.', 'yatoon-booking-system' ) );
        }
        if ( $permission && ! self::staff_has_permission( $staff_id, $permission ) ) {
            return new WP_Error( 'yatoon_staff_permission_denied', __( 'You do not have permission to perform this action.', 'yatoon-booking-system' ) );
        }
        return $staff_id;
    }

    private static function send_staff_auth_error( $permission = '' ) {
        $authorized = self::authorize_staff_request( $permission );
        if ( is_wp_error( $authorized ) ) {
            wp_send_json_error( array( 'message' => $authorized->get_error_message() ) );
        }
        return $authorized;
    }

    private static function staff_has_permission( $staff_id, $permission ) {
        global $wpdb;
        $raw = yatoon_db_get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            "SELECT portal_permissions FROM {$wpdb->prefix}yatoon_staff WHERE id = %d AND is_active = 1 LIMIT 1",
            $staff_id
        ) );
        $payload = self::permissions_payload( $raw );
        return ! empty( $payload[ $permission ] );
    }

    public static function ajax_get_profile() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );
        $staff_id = absint( $_POST['staff_id'] ?? 0 );
        if ( ! $staff_id || ! self::validate_staff_session( $staff_id ) ) {
            wp_send_json_error( array( 'message' => __( 'Your staff session has expired. Please sign in again.', 'yatoon-booking-system' ) ) );
        }
        global $wpdb;
        $profile = yatoon_db_get_row( $wpdb->prepare(
            "SELECT id, name, email, bio, image_url, calendar_color
             FROM {$wpdb->prefix}yatoon_staff
             WHERE id = %d AND is_active = 1
             LIMIT 1",
            $staff_id
        ), ARRAY_A );
        if ( ! $profile ) {
            wp_send_json_error( array( 'message' => __( 'Staff profile not found.', 'yatoon-booking-system' ) ) );
        }
        wp_send_json_success( array( 'profile' => $profile ) );
    }

    public static function ajax_save_profile() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );
        $staff_id = absint( $_POST['staff_id'] ?? 0 );
        if ( ! $staff_id || ! self::validate_staff_session( $staff_id ) ) {
            wp_send_json_error( array( 'message' => __( 'Your staff session has expired. Please sign in again.', 'yatoon-booking-system' ) ) );
        }
        $name  = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );
        $bio   = sanitize_textarea_field( wp_unslash( $_POST['bio'] ?? '' ) );
        $color = sanitize_hex_color( wp_unslash( $_POST['calendar_color'] ?? '' ) );
        if ( '' === $name ) {
            wp_send_json_error( array( 'message' => __( 'Please enter your display name.', 'yatoon-booking-system' ) ) );
        }
        global $wpdb;
        $updated = yatoon_db_update(
            $wpdb->prefix . 'yatoon_staff',
            array(
                'name'           => $name,
                'bio'            => $bio,
                'calendar_color' => $color ?: '#ec4899',
            ),
            array( 'id' => $staff_id ),
            array( '%s', '%s', '%s' ),
            array( '%d' )
        );
        if ( false === $updated ) {
            wp_send_json_error( array( 'message' => __( 'Profile could not be saved.', 'yatoon-booking-system' ) ) );
        }
        wp_send_json_success( array(
            'message' => __( 'Profile saved.', 'yatoon-booking-system' ),
            'profile' => array( 'name' => $name, 'bio' => $bio, 'calendar_color' => $color ?: '#ec4899' ),
        ) );
    }

    // -- AJAX: login -------------------------------------------------------

    public static function ajax_login() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $ip              = self::get_ip();
        $device          = isset( $_POST['device_id'] ) ? sanitize_key( wp_unslash( $_POST['device_id'] ) ) : '';
        $device_key      = $device ? 'ysp_pin_device_' . md5( $device ) : '';
        $ip_key          = 'ysp_pin_ip_' . md5( $ip );
        // Site-wide backstop: device_id and IP are both attacker-controllable
        // (a script can mint a new device_id per request and rotate IPs), so a
        // distributed brute force could otherwise dodge the per-IP/per-device
        // locks entirely. This counter is deliberately NOT cleared on a
        // successful login (see below) so it can't be reset by piggybacking on
        // unrelated, legitimate staff logins elsewhere in the salon.
        $global_key      = 'ysp_pin_global_backstop';
        $ip_attempts     = self::login_attempt_payload( $ip_key );
        $dev_attempts    = $device_key ? self::login_attempt_payload( $device_key ) : array( 'count' => 0, 'locked_until' => 0 );
        $global_attempts = self::login_attempt_payload( $global_key );
        $now           = time();
        $locked_until  = max( (int) $ip_attempts['locked_until'], (int) $dev_attempts['locked_until'], (int) $global_attempts['locked_until'] );
        if ( $locked_until > $now ) {
            wp_send_json_error( array(
                'message'      => sprintf( _n( 'Too many incorrect PIN attempts. Try again in %d minute.', 'Too many incorrect PIN attempts. Try again in %d minutes.', max( 1, (int) ceil( ( $locked_until - $now ) / 60 ) ), 'yatoon-booking-system' ), max( 1, (int) ceil( ( $locked_until - $now ) / 60 ) ) ),
                'locked_until' => $locked_until,
            ) );
        }

        $pin = isset( $_POST['pin'] ) ? preg_replace( '/\D/', '', sanitize_text_field( wp_unslash( $_POST['pin'] ) ) ) : '';
        $pin_length = in_array( (int) get_option( 'yatoon_staff_pin_length', 6 ), array( 4, 6 ), true ) ? (int) get_option( 'yatoon_staff_pin_length', 6 ) : 6;
        if ( strlen( $pin ) !== $pin_length ) {
            wp_send_json_error( array( 'message' => __( 'Invalid PIN format.', 'yatoon-booking-system' ) ) );
        }

        global $wpdb;
        $has_lookup = self::ensure_pin_lookup_column();
        $table      = $wpdb->prefix . 'yatoon_staff';
        $lookup     = self::pin_lookup( $pin );
        $candidate  = null;

        // SECURITY (4.4.3): this used to SELECT every active staff row and run
        // wp_check_password() against each one. wp_check_password() is
        // deliberately slow (bcrypt), so a 50-person salon burned 2-5 seconds
        // of CPU per attempt -- on an unauthenticated endpoint. A few dozen
        // concurrent requests were enough to exhaust the PHP worker pool.
        //
        // The indexed keyed-hash lookup below turns that into ONE bcrypt
        // verification regardless of headcount.
        // 4.5.0: guarded on $has_lookup. The column used to be created here on
        // demand; it is now owned by yatoon_staff_pin_lookup_migration(). If a
        // site has not migrated yet the column does not exist and this query
        // would fail on an unknown column, so skip straight to the legacy
        // bounded-scan path below, which needs no new schema.
        if ( $has_lookup ) {
            $candidate = yatoon_db_get_row( $wpdb->prepare(
                "SELECT id, name, portal_pin, portal_role, portal_permissions
                   FROM {$table}
                  WHERE is_active = 1
                    AND portal_pin <> '' AND portal_pin IS NOT NULL
                    AND portal_pin_lookup = %s
                  LIMIT 1",
                $lookup
            ) );
        }

        // Legacy rows: PINs saved before 4.4.3 have no lookup value, and we
        // cannot backfill them because we only store the bcrypt hash. Those
        // staff still need to sign in, so fall back to a bounded scan of just
        // the un-migrated rows and backfill on success -- each staff member
        // pays this cost exactly once, and the slow path disappears as they
        // sign in. The LIMIT caps worst-case CPU on a site that never migrates.
        if ( ! $candidate ) {
            $legacy_where = $has_lookup
                ? "AND ( portal_pin_lookup = '' OR portal_pin_lookup IS NULL )"
                : '';
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $legacy_where is one of two fixed literals.
            $legacy = yatoon_db_get_results( $wpdb->prepare(
                "SELECT id, name, portal_pin, portal_role, portal_permissions
                   FROM {$table}
                  WHERE is_active = 1
                    AND portal_pin <> '' AND portal_pin IS NOT NULL
                    {$legacy_where}
                  ORDER BY id ASC
                  LIMIT %d",
                25
            ) );
            foreach ( (array) $legacy as $row ) {
                if ( wp_check_password( $pin, $row->portal_pin ) ) {
                    if ( $has_lookup ) {
                        yatoon_db_update(
                            $table,
                            array( 'portal_pin_lookup' => $lookup ),
                            array( 'id' => (int) $row->id ),
                            array( '%s' ),
                            array( '%d' )
                        );
                    }
                    $candidate = $row;
                    break;
                }
            }
        }

        if ( $candidate && wp_check_password( $pin, $candidate->portal_pin ) ) {
            delete_transient( $ip_key );
            delete_transient( $ip_key . '_lock' );
            YATOON_Request_Guard::reset_counter( $ip_key . '_count' );
            if ( $device_key ) {
                delete_transient( $device_key );
                delete_transient( $device_key . '_lock' );
                YATOON_Request_Guard::reset_counter( $device_key . '_count' );
            }
            // $global_key is intentionally left in place; see comment above.
            wp_send_json_success( self::login_payload_for_staff( $candidate->id ) );
        }

        $ip_attempts     = self::record_failed_login_attempt( $ip_key, $ip_attempts );
        $dev_attempts    = $device_key ? self::record_failed_login_attempt( $device_key, $dev_attempts ) : $dev_attempts;
        $global_attempts = self::record_failed_global_pin_attempt( $global_attempts );
        $count        = max( (int) $ip_attempts['count'], (int) $dev_attempts['count'] );
        $locked_until = max( (int) $ip_attempts['locked_until'], (int) $dev_attempts['locked_until'], (int) $global_attempts['locked_until'] );
        if ( $locked_until > $now ) {
            wp_send_json_error( array(
                'message'      => sprintf( _n( 'Too many incorrect PIN attempts. Try again in %d minute.', 'Too many incorrect PIN attempts. Try again in %d minutes.', max( 1, (int) ceil( ( $locked_until - $now ) / 60 ) ), 'yatoon-booking-system' ), max( 1, (int) ceil( ( $locked_until - $now ) / 60 ) ) ),
                'locked_until' => $locked_until,
            ) );
        }
        wp_send_json_error( array(
            'message'            => __( 'Incorrect PIN. Please try again.', 'yatoon-booking-system' ),
            'remaining_attempts' => max( 0, 6 - $count ),
        ) );
    }

    // -- AJAX: get schedule ------------------------------------------------

    public static function ajax_get_schedule() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $staff_id = absint( $_POST['staff_id'] ?? 0 );
        if ( ! $staff_id || ! self::validate_staff_session( $staff_id ) ) { wp_send_json_error( array( 'message' => __( 'Your staff session has expired. Please sign in again.', 'yatoon-booking-system' ) ) );
        }

        self::maybe_auto_sync_bookings();

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

        // Fetch the requested portal week. DB stores local WP time.
        $tz_obj    = yatoon_timezone();
        $date_from = sanitize_text_field( wp_unslash( $_POST['date_from'] ?? '' ) );
        $date_to   = sanitize_text_field( wp_unslash( $_POST['date_to'] ?? '' ) );

        if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date_from ) ) {
            $date_from = ( new DateTime( 'today', $tz_obj ) )->format( 'Y-m-d' );
        }
        if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date_to ) ) {
            $tmp_to = new DateTime( $date_from, $tz_obj );
            $tmp_to->modify( '+6 days' );
            $date_to = $tmp_to->format( 'Y-m-d' );
        }

        $range_start = new DateTime( $date_from . ' 00:00:00', $tz_obj );
        $range_end   = new DateTime( $date_to . ' 23:59:59', $tz_obj );
        if ( $range_end < $range_start ) {
            $range_end = clone $range_start;
            $range_end->modify( '+6 days' )->setTime( 23, 59, 59 );
        }
        $max_end = clone $range_start;
        $max_end->modify( '+30 days' )->setTime( 23, 59, 59 );
        if ( $range_end > $max_end ) {
            $range_end = $max_end;
        }

        $start_dt = $range_start->format( 'Y-m-d H:i:s' );
        $end_dt   = $range_end->format( 'Y-m-d H:i:s' );

        $can_view_all = self::staff_has_permission( $staff_id, 'view_all_schedules' );

        // Fetch bookings with all service names (main + options via GROUP_CONCAT)
        if ( $can_view_all ) {
            $bookings_raw = yatoon_db_get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                "SELECT b.id, b.service_id, b.staff_id, b.customer_name, b.customer_email, b.customer_phone,
                    b.start_time, b.end_time, b.created_at, b.status, b.notes, b.total_price, b.payment_status, b.payment_note,
                    b.square_booking_id, b.square_synced,
                    b.design_attachment_id, b.design_image_url, b.group_booking_id,
                    c.id AS client_id, c.allergies AS client_allergies, c.preferences AS client_preferences,
                    c.notes AS client_notes, c.tags AS client_tags,
                    (SELECT COUNT(*) FROM {$wpdb->prefix}yatoon_bookings cb
                     WHERE (c.id IS NOT NULL) AND ((c.customer_email != '' AND cb.customer_email = c.customer_email) OR (c.customer_phone != '' AND cb.customer_phone = c.customer_phone))) AS client_booking_count,
                    (SELECT MAX(cb2.start_time) FROM {$wpdb->prefix}yatoon_bookings cb2
                     WHERE (c.id IS NOT NULL) AND ((c.customer_email != '' AND cb2.customer_email = c.customer_email) OR (c.customer_phone != '' AND cb2.customer_phone = c.customer_phone))
                     AND cb2.id != b.id) AS client_last_booking,
                    s.name AS main_service_name,
                    (SELECT ps.name
                     FROM {$wpdb->prefix}yatoon_booking_options pbo
                     INNER JOIN {$wpdb->prefix}yatoon_service_options pso ON pso.id = pbo.option_id
                     INNER JOIN {$wpdb->prefix}yatoon_services ps ON ps.id = pso.service_id
                     WHERE pbo.booking_id = b.id
                       AND COALESCE(pso.group_name, '') != 'Add-on'
                     ORDER BY pbo.id ASC LIMIT 1) AS option_parent_service_name,
                    st.name AS staff_name,
                    st.calendar_color AS staff_color,
                    GROUP_CONCAT(so.name ORDER BY bo.id SEPARATOR ', ') AS option_names,
                    GROUP_CONCAT(so.id ORDER BY bo.id SEPARATOR ',') AS option_ids
             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
             LEFT JOIN {$wpdb->prefix}yatoon_clients c        ON c.id = (
                SELECT c2.id FROM {$wpdb->prefix}yatoon_clients c2
                WHERE (c2.customer_email != '' AND c2.customer_email = b.customer_email)
                   OR (c2.customer_phone != '' AND c2.customer_phone = b.customer_phone)
                ORDER BY c2.updated_at DESC LIMIT 1
             )
             LEFT JOIN {$wpdb->prefix}yatoon_booking_options bo ON b.id         = bo.booking_id
             LEFT JOIN {$wpdb->prefix}yatoon_service_options so ON bo.option_id = so.id
             WHERE  b.start_time >= %s
             AND    b.start_time <= %s
             AND    b.status NOT IN ('cancelled','cancelled_by_customer','CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED')
             GROUP  BY b.id
             ORDER  BY b.start_time ASC",
                $start_dt, $end_dt
            ) );
        } else {
            $bookings_raw = yatoon_db_get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                "SELECT b.id, b.service_id, b.staff_id, b.customer_name, b.customer_email, b.customer_phone,
                    b.start_time, b.end_time, b.created_at, b.status, b.notes, b.total_price, b.payment_status, b.payment_note,
                    b.square_booking_id, b.square_synced,
                    b.design_attachment_id, b.design_image_url, b.group_booking_id,
                    c.id AS client_id, c.allergies AS client_allergies, c.preferences AS client_preferences,
                    c.notes AS client_notes, c.tags AS client_tags,
                    (SELECT COUNT(*) FROM {$wpdb->prefix}yatoon_bookings cb
                     WHERE (c.id IS NOT NULL) AND ((c.customer_email != '' AND cb.customer_email = c.customer_email) OR (c.customer_phone != '' AND cb.customer_phone = c.customer_phone))) AS client_booking_count,
                    (SELECT MAX(cb2.start_time) FROM {$wpdb->prefix}yatoon_bookings cb2
                     WHERE (c.id IS NOT NULL) AND ((c.customer_email != '' AND cb2.customer_email = c.customer_email) OR (c.customer_phone != '' AND cb2.customer_phone = c.customer_phone))
                     AND cb2.id != b.id) AS client_last_booking,
                    s.name AS main_service_name,
                    (SELECT ps.name
                     FROM {$wpdb->prefix}yatoon_booking_options pbo
                     INNER JOIN {$wpdb->prefix}yatoon_service_options pso ON pso.id = pbo.option_id
                     INNER JOIN {$wpdb->prefix}yatoon_services ps ON ps.id = pso.service_id
                     WHERE pbo.booking_id = b.id
                       AND COALESCE(pso.group_name, '') != 'Add-on'
                     ORDER BY pbo.id ASC LIMIT 1) AS option_parent_service_name,
                    st.name AS staff_name,
                    st.calendar_color AS staff_color,
                    GROUP_CONCAT(so.name ORDER BY bo.id SEPARATOR ', ') AS option_names,
                    GROUP_CONCAT(so.id ORDER BY bo.id SEPARATOR ',') AS option_ids
             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
             LEFT JOIN {$wpdb->prefix}yatoon_clients c        ON c.id = (
                SELECT c2.id FROM {$wpdb->prefix}yatoon_clients c2
                WHERE (c2.customer_email != '' AND c2.customer_email = b.customer_email)
                   OR (c2.customer_phone != '' AND c2.customer_phone = b.customer_phone)
                ORDER BY c2.updated_at DESC LIMIT 1
             )
             LEFT JOIN {$wpdb->prefix}yatoon_booking_options bo ON b.id         = bo.booking_id
             LEFT JOIN {$wpdb->prefix}yatoon_service_options so ON bo.option_id = so.id
             WHERE  b.staff_id  = %d
             AND    b.start_time >= %s
             AND    b.start_time <= %s
             AND    b.status NOT IN ('cancelled','cancelled_by_customer','CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED')
             GROUP  BY b.id
             ORDER  BY b.start_time ASC",
                $staff_id, $start_dt, $end_dt
            ) );
        }

        // Build service_name and format times
        // DB stores local WP time.
        // Send as Unix timestamp (milliseconds) so JS Date() is unambiguous across all browsers.
        $bookings = array();
        foreach ( $bookings_raw as $b ) {
            // Keep the base service name and any selected options as
            // separate fields. This used to collapse into a single
            // $service_name that showed ONLY the option list (e.g. "With Gel
            // Polish") and silently dropped the actual service name (e.g.
            // "Luxe Pedicure") whenever any options were selected -- which
            // is most bookings -- leaving staff with no way to tell what the
            // appointment was actually for. The Staff Portal UI now shows
            // service_name as the line title and option_names as a subtitle.
            $main_service_name = ! empty( $b->main_service_name ) ? (string) $b->main_service_name : '';
            $option_parent_name = ! empty( $b->option_parent_service_name ) ? (string) $b->option_parent_service_name : '';
            if ( preg_match( '/(?:custom\s*design|nail\s*art)/i', $main_service_name )
                && $option_parent_name
                && ! preg_match( '/(?:custom\s*design|nail\s*art)/i', $option_parent_name ) ) {
                $main_service_name = $option_parent_name;
            }
            $b->service_name = $main_service_name ?: 'Service';
            $option_names = ! empty( $b->option_names ) ? array_map( 'trim', explode( ',', (string) $b->option_names ) ) : array();
            $b->option_names = implode( ', ', array_values( array_unique( array_filter( $option_names ) ) ) );
            if ( ! empty( $b->client_last_booking ) ) {
                try {
                    $last_dt = new DateTime( $b->client_last_booking, $tz_obj );
                    $b->client_last_booking = $last_dt->getTimestamp() * 1000;
                } catch ( Exception $e ) {
                    $b->client_last_booking = 0;
                }
            }

            // Convert local DB time to Unix timestamp (ms) for unambiguous JS parsing
            try {
                $start_dt = new DateTime( $b->start_time, $tz_obj );
                $end_dt   = new DateTime( $b->end_time,   $tz_obj );
                $b->start_time = $start_dt->getTimestamp() * 1000;
                $b->end_time   = $end_dt->getTimestamp()   * 1000;
            } catch ( Exception $e ) {
                $b->start_time = 0;
                $b->end_time   = 0;
            }
            if ( ! empty( $b->created_at ) ) {
                try {
                    $created_dt = new DateTime( $b->created_at, new DateTimeZone( 'UTC' ) );
                    $b->created_at = $created_dt->getTimestamp() * 1000;
                } catch ( Exception $e ) {
                    $b->created_at = 0;
                }
            }

            $bookings[] = $b;
        }

        // Group by local date (using WP timezone) from the Unix timestamp
        $schedule = array();
        foreach ( $bookings as $b ) {
            if ( is_numeric( $b->start_time ) && $b->start_time > 0 ) {
                $dt   = new DateTime( '@' . intval( $b->start_time / 1000 ) );
                $dt->setTimezone( $tz_obj );
                $date = $dt->format( 'Y-m-d' );
            } else {
                $date = substr( (string) $b->start_time, 0, 10 );
            }
            if ( ! isset( $schedule[ $date ] ) ) { $schedule[ $date ] = array();
            }
            $schedule[ $date ][] = $b;
        }

        $staff_roster = $can_view_all
            ? yatoon_db_get_results( "SELECT id, name, calendar_color AS color FROM {$wpdb->prefix}yatoon_staff WHERE is_active = 1 AND is_bookable = 1 ORDER BY sort_order ASC, name ASC" )
            : array( (object) array( 'id' => $staff->id, 'name' => $staff->name, 'color' => $staff->calendar_color ) );

        wp_send_json_success( array(
            'schedule'    => $schedule,
            'staff'       => $staff_roster,
            'role'        => $staff->portal_role ?: 'staff',
            'permissions' => self::permissions_payload( $staff->portal_permissions ?? '' ),
        ) );
    }

    public static function ajax_get_time_off() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );
        global $wpdb;
        $staff_id = absint( $_POST['staff_id'] ?? 0 );
        if ( ! $staff_id || ! self::validate_staff_session( $staff_id ) ) {
            wp_send_json_error( array( 'message' => __( 'Your staff session has expired.', 'yatoon-booking-system' ) ) );
        }
        $can_approve = self::staff_has_permission( $staff_id, 'approve_time_off' );
        if ( ! self::staff_has_permission( $staff_id, 'request_time_off' ) && ! $can_approve ) {
            wp_send_json_error( array( 'message' => __( 'Time off access is not enabled.', 'yatoon-booking-system' ) ) );
        }
        $where = $can_approve ? '1=1' : $wpdb->prepare( 'r.staff_id = %d', $staff_id );
        $requests = yatoon_db_get_results(
            "SELECT r.*, s.name AS staff_name, reviewer.name AS reviewer_name
             FROM {$wpdb->prefix}yatoon_staff_time_off_requests r
             LEFT JOIN {$wpdb->prefix}yatoon_staff s ON s.id = r.staff_id
             LEFT JOIN {$wpdb->prefix}yatoon_staff reviewer ON reviewer.id = r.reviewed_by
             WHERE {$where}
             ORDER BY (r.status = 'pending') DESC, r.start_at DESC
             LIMIT 100"
        );
        wp_send_json_success( array(
            'requests' => $requests,
            'can_approve' => $can_approve,
        ) );
    }

    public static function ajax_request_time_off() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );
        global $wpdb;
        $staff_id = absint( $_POST['staff_id'] ?? 0 );
        if ( ! $staff_id || ! self::validate_staff_session( $staff_id ) || ! self::staff_has_permission( $staff_id, 'request_time_off' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to request time off.', 'yatoon-booking-system' ) ) );
        }
        $date = sanitize_text_field( wp_unslash( $_POST['date'] ?? '' ) );
        $start = sanitize_text_field( wp_unslash( $_POST['start'] ?? '' ) );
        $end = sanitize_text_field( wp_unslash( $_POST['end'] ?? '' ) );
        $all_day = ! empty( $_POST['all_day'] ) ? 1 : 0;
        $reason = sanitize_text_field( wp_unslash( $_POST['reason'] ?? '' ) );
        if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
            wp_send_json_error( array( 'message' => __( 'Choose a valid date.', 'yatoon-booking-system' ) ) );
        }
        if ( $all_day ) {
            $start_at = $date . ' 00:00:00';
            $end_at = $date . ' 23:59:59';
        } else {
            if ( ! preg_match( '/^\d{2}:\d{2}$/', $start ) || ! preg_match( '/^\d{2}:\d{2}$/', $end ) || $end <= $start ) {
                wp_send_json_error( array( 'message' => __( 'Choose a valid start and end time.', 'yatoon-booking-system' ) ) );
            }
            $start_at = $date . ' ' . $start . ':00';
            $end_at = $date . ' ' . $end . ':00';
        }
        yatoon_db_insert(
            $wpdb->prefix . 'yatoon_staff_time_off_requests',
            array(
                'staff_id' => $staff_id,
                'start_at' => $start_at,
                'end_at' => $end_at,
                'is_all_day' => $all_day,
                'reason' => $reason,
                'status' => 'pending',
            ),
            array( '%d', '%s', '%s', '%d', '%s', '%s' )
        );
        wp_send_json_success( array( 'message' => __( 'Time off request sent for approval.', 'yatoon-booking-system' ) ) );
    }

    public static function ajax_review_time_off() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );
        global $wpdb;
        $staff_id = absint( $_POST['staff_id'] ?? 0 );
        $request_id = absint( $_POST['request_id'] ?? 0 );
        $decision = sanitize_key( wp_unslash( $_POST['decision'] ?? '' ) );
        $review_note = sanitize_text_field( wp_unslash( $_POST['review_note'] ?? '' ) );
        if ( ! $staff_id || ! self::validate_staff_session( $staff_id ) || ! self::staff_has_permission( $staff_id, 'approve_time_off' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to review time off.', 'yatoon-booking-system' ) ) );
        }
        if ( ! in_array( $decision, array( 'approved', 'rejected' ), true ) ) {
            wp_send_json_error( array( 'message' => __( 'Invalid review decision.', 'yatoon-booking-system' ) ) );
        }
        $request = yatoon_db_get_row( $wpdb->prepare(
            "SELECT * FROM {$wpdb->prefix}yatoon_staff_time_off_requests WHERE id = %d AND status = 'pending' LIMIT 1",
            $request_id
        ) );
        if ( ! $request ) {
            wp_send_json_error( array( 'message' => __( 'This request was already reviewed.', 'yatoon-booking-system' ) ) );
        }
        yatoon_db_update(
            $wpdb->prefix . 'yatoon_staff_time_off_requests',
            array(
                'status' => $decision,
                'reviewed_by' => $staff_id,
                'review_note' => $review_note,
                'reviewed_at' => current_time( 'mysql' ),
            ),
            array( 'id' => $request_id ),
            array( '%s', '%d', '%s', '%s' ),
            array( '%d' )
        );
        if ( $decision === 'approved' && class_exists( 'YATOON_Business_Hours' ) ) {
            $rules = YATOON_Business_Hours::get_staff_time_off_rules( $request->staff_id );
            $line = (int) $request->is_all_day
                ? substr( $request->start_at, 0, 10 ) . '|all_day|' . $request->reason
                : substr( $request->start_at, 0, 16 ) . '|' . substr( $request->end_at, 0, 16 ) . '|' . $request->reason;
            $lines = array_filter( array_map( 'trim', preg_split( '/\r?\n/', (string) $rules ) ) );
            $lines[] = $line;
            update_option(
                YATOON_Business_Hours::get_staff_time_off_option_key( $request->staff_id ),
                YATOON_Business_Hours::normalize_staff_time_off_rules( implode( "\n", array_unique( $lines ) ) )
            );
        }
        wp_send_json_success( array( 'message' => $decision === 'approved' ? __( 'Time off approved.', 'yatoon-booking-system' ) : __( 'Time off rejected.', 'yatoon-booking-system' ) ) );
    }

    public static function ajax_get_booking_form_data() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $staff_id = self::send_staff_auth_error();
        if ( ! self::staff_has_permission( $staff_id, 'create_booking' ) && ! self::staff_has_permission( $staff_id, 'edit_booking' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to manage appointments.', 'yatoon-booking-system' ) ) );
        }

        global $wpdb;
        $services = yatoon_db_get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            "SELECT id, name, duration_minutes, price, category FROM {$wpdb->prefix}yatoon_services WHERE is_active = 1 ORDER BY category ASC, sort_order ASC, name ASC"
        );
        foreach ( $services as $service ) {
            $service->options = yatoon_db_get_results( $wpdb->prepare(
                "SELECT id, name, duration_minutes, price_modifier, group_name
                 FROM {$wpdb->prefix}yatoon_service_options
                 WHERE service_id = %d
                 ORDER BY CASE WHEN group_name = 'Add-on' THEN 1 ELSE 0 END, sort_order ASC, id ASC",
                $service->id
            ) );
        }

        $can_view_all = self::staff_has_permission( $staff_id, 'view_all_schedules' );
        if ( $can_view_all ) {
            $staff = yatoon_db_get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                "SELECT id, name, calendar_color FROM {$wpdb->prefix}yatoon_staff WHERE is_active = 1 ORDER BY sort_order ASC, name ASC"
            );
        } else {
            $staff = yatoon_db_get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                "SELECT id, name, calendar_color FROM {$wpdb->prefix}yatoon_staff WHERE id = %d AND is_active = 1",
                $staff_id
            ) );
        }

        wp_send_json_success( array(
            'services'    => $services,
            'staff'       => $staff,
            'permissions' => self::permissions_payload( yatoon_db_get_var( $wpdb->prepare( "SELECT portal_permissions FROM {$wpdb->prefix}yatoon_staff WHERE id = %d", $staff_id ) ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        ) );
    }

    public static function ajax_search_clients() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $staff_id = absint( $_POST['staff_id'] ?? 0 );
        $term     = sanitize_text_field( wp_unslash( $_POST['term'] ?? '' ) );
        if ( ! self::validate_staff_session( $staff_id ) ) {
            wp_send_json_error( array( 'message' => __( 'Staff session expired. Please sign in again.', 'yatoon-booking-system' ) ) );
        }
        if ( ! $staff_id || strlen( $term ) < 2 ) {
            wp_send_json_success( array( 'clients' => array() ) );
        }
        if ( ! self::staff_has_permission( $staff_id, 'create_booking' ) && ! self::staff_has_permission( $staff_id, 'edit_booking' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to search clients.', 'yatoon-booking-system' ) ) );
        }

        global $wpdb;
        $like = '%' . $wpdb->esc_like( $term ) . '%';
        $clients = yatoon_db_get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            "SELECT id, customer_name, customer_email, customer_phone, allergies, preferences, tags, notes
             FROM {$wpdb->prefix}yatoon_clients
             WHERE customer_name LIKE %s OR customer_email LIKE %s OR customer_phone LIKE %s
             ORDER BY updated_at DESC, id DESC
             LIMIT 8",
            $like, $like, $like
        ) );

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

    public static function ajax_get_clients() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $staff_id = absint( $_POST['staff_id'] ?? 0 );
        $term     = sanitize_text_field( wp_unslash( $_POST['term'] ?? '' ) );
        if ( ! self::validate_staff_session( $staff_id ) ) {
            wp_send_json_error( array( 'message' => __( 'Staff session expired. Please sign in again.', 'yatoon-booking-system' ) ) );
        }
        if ( ! $staff_id || ! self::staff_has_permission( $staff_id, 'view_clients' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to view clients.', 'yatoon-booking-system' ) ) );
        }

        global $wpdb;
        $where  = '1=1';
        $params = array();
        if ( $term !== '' ) {
            $like     = '%' . $wpdb->esc_like( $term ) . '%';
            $where   .= ' AND (c.customer_name LIKE %s OR c.customer_email LIKE %s OR c.customer_phone LIKE %s)';
            $params[] = $like;
            $params[] = $like;
            $params[] = $like;
        }

        $sql = "SELECT c.id, c.customer_name, c.customer_email, c.customer_phone, c.allergies, c.preferences, c.tags, c.notes,
                       c.created_at, c.updated_at,
                       COUNT(b.id) AS booking_count,
                       MAX(b.start_time) AS last_booking
                FROM {$wpdb->prefix}yatoon_clients c
                LEFT JOIN {$wpdb->prefix}yatoon_bookings b
                       ON (b.customer_phone <> '' AND b.customer_phone = c.customer_phone)
                       OR (b.customer_email <> '' AND b.customer_email = c.customer_email)
                WHERE {$where}
                GROUP BY c.id
                ORDER BY c.updated_at DESC, c.id DESC
                LIMIT 100";
        $clients = $params ? yatoon_db_get_results( $wpdb->prepare( $sql, $params ) ) : yatoon_db_get_results( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery

        wp_send_json_success( array(
            'clients'       => $clients,
            'permissions'   => self::permissions_payload( yatoon_db_get_var( $wpdb->prepare( "SELECT portal_permissions FROM {$wpdb->prefix}yatoon_staff WHERE id = %d", $staff_id ) ) ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            'twilio_ready'  => class_exists( 'YATOON_Twilio' ) && YATOON_Twilio::is_enabled(),
        ) );
    }

    public static function ajax_get_client_history() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $staff_id   = absint( $_POST['staff_id'] ?? 0 );
        $client_id  = absint( $_POST['client_id'] ?? 0 );
        if ( ! self::validate_staff_session( $staff_id ) ) {
            wp_send_json_error( array( 'message' => __( 'Staff session expired. Please sign in again.', 'yatoon-booking-system' ) ) );
        }
        if ( ! $staff_id || ! $client_id || ! self::staff_has_permission( $staff_id, 'view_clients' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to view client history.', 'yatoon-booking-system' ) ) );
        }

        global $wpdb;
        $client = yatoon_db_get_row( $wpdb->prepare(
            "SELECT id, customer_name, customer_email, customer_phone FROM {$wpdb->prefix}yatoon_clients WHERE id = %d LIMIT 1",
            $client_id
        ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        if ( ! $client ) {
            wp_send_json_error( array( 'message' => __( 'Client not found.', 'yatoon-booking-system' ) ) );
        }

        $history = yatoon_db_get_results( $wpdb->prepare(
            "SELECT b.id, b.start_time, b.end_time, b.created_at, b.status, b.payment_status, b.total_price, b.notes,
                    b.square_booking_id, b.square_synced,
                    s.name AS service_name, st.name AS staff_name
             FROM {$wpdb->prefix}yatoon_bookings b
             LEFT JOIN {$wpdb->prefix}yatoon_services s ON b.service_id = s.id
             LEFT JOIN {$wpdb->prefix}yatoon_staff st ON b.staff_id = st.id
             WHERE (b.customer_phone <> '' AND b.customer_phone = %s)
                OR (b.customer_email <> '' AND b.customer_email = %s)
             ORDER BY b.start_time DESC
             LIMIT 50",
            $client->customer_phone,
            $client->customer_email
        ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery

        $tz_obj = yatoon_timezone();
        foreach ( $history as $row ) {
            foreach ( array( 'start_time', 'end_time' ) as $field ) {
                if ( empty( $row->{$field} ) ) {
                    $row->{$field} = 0;
                    continue;
                }
                try {
                    $dt = new DateTime( $row->{$field}, $tz_obj );
                    $row->{$field} = $dt->getTimestamp() * 1000;
                } catch ( Exception $e ) {
                    $row->{$field} = 0;
                }
            }
            if ( ! empty( $row->created_at ) ) {
                try {
                    $created_dt = new DateTime( $row->created_at, new DateTimeZone( 'UTC' ) );
                    $row->created_at = $created_dt->getTimestamp() * 1000;
                } catch ( Exception $e ) {
                    $row->created_at = 0;
                }
            } else {
                $row->created_at = 0;
            }
        }

        wp_send_json_success( array(
            'client'  => $client,
            'history' => $history,
        ) );
    }

    public static function ajax_save_client() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $staff_id = absint( $_POST['staff_id'] ?? 0 );
        if ( ! self::validate_staff_session( $staff_id ) ) {
            wp_send_json_error( array( 'message' => __( 'Staff session expired. Please sign in again.', 'yatoon-booking-system' ) ) );
        }
        if ( ! $staff_id || ! self::staff_has_permission( $staff_id, 'manage_clients' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to manage clients.', 'yatoon-booking-system' ) ) );
        }

        $client_id = absint( $_POST['client_id'] ?? 0 );
        $name      = sanitize_text_field( wp_unslash( $_POST['customer_name'] ?? '' ) );
        $email     = sanitize_email( wp_unslash( $_POST['customer_email'] ?? '' ) );
        $phone     = sanitize_text_field( wp_unslash( $_POST['customer_phone'] ?? '' ) );
        $allergies = sanitize_textarea_field( wp_unslash( $_POST['allergies'] ?? '' ) );
        $prefs     = sanitize_textarea_field( wp_unslash( $_POST['preferences'] ?? '' ) );
        $tags      = sanitize_text_field( wp_unslash( $_POST['tags'] ?? '' ) );
        $notes     = sanitize_textarea_field( wp_unslash( $_POST['notes'] ?? '' ) );

        if ( $name === '' && $phone === '' && $email === '' ) {
            wp_send_json_error( array( 'message' => __( 'Add a name, phone, or email.', 'yatoon-booking-system' ) ) );
        }

        global $wpdb;
        $table = esc_sql( $wpdb->prefix . 'yatoon_clients' );
        $data  = array(
            'customer_name'  => $name,
            'customer_email' => $email,
            'customer_phone' => $phone,
            'allergies'      => $allergies,
            'preferences'    => $prefs,
            'tags'           => $tags,
            'notes'          => $notes,
            'updated_at'     => current_time( 'mysql' ),
        );
        $formats = array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' );

        if ( $client_id ) {
            yatoon_db_update( $table, $data, array( 'id' => $client_id ), $formats, array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        } else {
            $data['created_at'] = current_time( 'mysql' );
            yatoon_db_insert( $table, $data, array_merge( $formats, array( '%s' ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            $client_id = (int) $wpdb->insert_id;
        }

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

    public static function ajax_delete_client() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $staff_id  = absint( $_POST['staff_id'] ?? 0 );
        $client_id = absint( $_POST['client_id'] ?? 0 );
        if ( ! self::validate_staff_session( $staff_id ) ) {
            wp_send_json_error( array( 'message' => __( 'Staff session expired. Please sign in again.', 'yatoon-booking-system' ) ) );
        }
        if ( ! $staff_id || ! $client_id || ! self::staff_has_permission( $staff_id, 'manage_clients' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to delete clients.', 'yatoon-booking-system' ) ) );
        }

        global $wpdb;
        yatoon_db_delete( $wpdb->prefix . 'yatoon_clients', array( 'id' => $client_id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        wp_send_json_success( array( 'message' => __( 'Client deleted.', 'yatoon-booking-system' ) ) );
    }

    private static function whatsapp_link_for( $phone, $message ) {
        $digits = preg_replace( '/\D+/', '', (string) $phone );
        if ( ! $digits ) {
            return '';
        }
        return 'https://wa.me/' . $digits . '?text=' . rawurlencode( $message );
    }

    public static function ajax_send_message() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $staff_id   = absint( $_POST['staff_id'] ?? 0 );
        $client_id  = absint( $_POST['client_id'] ?? 0 );
        $booking_id = absint( $_POST['booking_id'] ?? 0 );
        $channel    = sanitize_key( wp_unslash( $_POST['channel'] ?? 'sms' ) );
        $message    = sanitize_textarea_field( wp_unslash( $_POST['message'] ?? '' ) );
        if ( ! self::validate_staff_session( $staff_id ) ) {
            wp_send_json_error( array( 'message' => __( 'Staff session expired. Please sign in again.', 'yatoon-booking-system' ) ) );
        }
        if ( ! $staff_id || ( ! $client_id && ! $booking_id ) || ! self::staff_has_permission( $staff_id, 'send_messages' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to message clients.', 'yatoon-booking-system' ) ) );
        }
        if ( $message === '' ) {
            wp_send_json_error( array( 'message' => __( 'Message cannot be empty.', 'yatoon-booking-system' ) ) );
        }

        global $wpdb;
        if ( $client_id ) {
            $client = yatoon_db_get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                "SELECT id, customer_name, customer_phone, customer_email FROM {$wpdb->prefix}yatoon_clients WHERE id = %d LIMIT 1",
                $client_id
            ) );
        } else {
            if ( ! self::staff_can_manage_booking( $staff_id, $booking_id, 'send_messages' ) ) {
                wp_send_json_error( array( 'message' => __( 'You do not have permission to message this appointment.', 'yatoon-booking-system' ) ) );
            }
            $client = yatoon_db_get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                "SELECT id, customer_name, customer_phone, customer_email FROM {$wpdb->prefix}yatoon_bookings WHERE id = %d LIMIT 1",
                $booking_id
            ) );
        }
        if ( ! $client || empty( $client->customer_phone ) ) {
            wp_send_json_error( array( 'message' => __( 'This client does not have a phone number.', 'yatoon-booking-system' ) ) );
        }

        if ( $channel === 'whatsapp' ) {
            $link = self::whatsapp_link_for( $client->customer_phone, $message );
            if ( ! $link ) {
                wp_send_json_error( array( 'message' => __( 'Could not build WhatsApp link for this phone.', 'yatoon-booking-system' ) ) );
            }
            wp_send_json_success( array( 'message' => __( 'Open WhatsApp to send.', 'yatoon-booking-system' ), 'whatsapp_url' => $link ) );
        }

        if ( ! class_exists( 'YATOON_Twilio' ) || ! YATOON_Twilio::is_enabled() ) {
            wp_send_json_error( array( 'message' => __( 'SMS requires Twilio to be configured in Settings -> SMS.', 'yatoon-booking-system' ) ) );
        }

        $sent = YATOON_Twilio::send( $client->customer_phone, $message );
        if ( ! $sent ) {
            wp_send_json_error( array( 'message' => __( 'SMS failed. Check Twilio settings and logs.', 'yatoon-booking-system' ) ) );
        }

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

    private static function get_booking_payment_url( $booking ) {
        $pay_url = get_option( 'yatoon_square_checkout_url', '' );
        if ( ! $pay_url ) {
            $pay_url = get_option( 'yatoon_payment_request_url', '' );
        }
        if ( ! $pay_url && ! empty( $booking->manage_token ) && class_exists( 'Yatoon_Manage_Booking' ) ) {
            $pay_url = Yatoon_Manage_Booking::get_manage_url( $booking->manage_token );
        }
        return $pay_url;
    }

    public static function ajax_checkout() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $staff_id    = absint( $_POST['staff_id'] ?? 0 );
        $booking_id  = absint( $_POST['booking_id'] ?? 0 );
        $checkout_op = sanitize_key( wp_unslash( $_POST['checkout_op'] ?? '' ) );
        $channel     = sanitize_key( wp_unslash( $_POST['channel'] ?? 'email' ) );

        if ( ! self::validate_staff_session( $staff_id ) ) {
            wp_send_json_error( array( 'message' => __( 'Staff session expired. Please sign in again.', 'yatoon-booking-system' ) ) );
        }
        if ( ! $staff_id || ! $booking_id || ! self::staff_has_permission( $staff_id, 'checkout' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to checkout appointments.', 'yatoon-booking-system' ) ) );
        }
        if ( ! self::staff_can_manage_booking( $staff_id, $booking_id, 'checkout' ) ) {
            wp_send_json_error( array( 'message' => __( 'You cannot checkout this appointment.', 'yatoon-booking-system' ) ) );
        }

        global $wpdb;
        $booking = yatoon_db_get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            "SELECT b.*, s.name AS service_name, st.name AS staff_name
             FROM {$wpdb->prefix}yatoon_bookings b
             LEFT JOIN {$wpdb->prefix}yatoon_services s ON b.service_id = s.id
             LEFT JOIN {$wpdb->prefix}yatoon_staff st ON b.staff_id = st.id
             WHERE b.id = %d LIMIT 1",
            $booking_id
        ) );
        if ( ! $booking ) {
            wp_send_json_error( array( 'message' => __( 'Booking not found.', 'yatoon-booking-system' ) ) );
        }

        $payment_labels = array(
            'mark_cash'               => array( 'status' => 'paid_square_pos', 'note' => 'Paid in person - cash/manual checkout' ),
            'mark_square_pos'         => array( 'status' => 'paid_square_pos', 'note' => 'Paid in Square POS / Tap to Pay' ),
            'mark_deposit'            => array( 'status' => 'deposit_paid', 'note' => 'Deposit paid' ),
            'mark_waived'             => array( 'status' => 'waived', 'note' => 'Payment waived' ),
            'mark_unpaid'             => array( 'status' => 'unpaid', 'note' => 'Marked unpaid' ),
            'mark_no_show_fee_square' => array( 'status' => 'no_show_fee_charged', 'note' => 'No-show fee charged in Square' ),
            'waive_no_show_fee'       => array( 'status' => 'no_show_fee_waived', 'note' => 'No-show fee waived' ),
        );

        if ( isset( $payment_labels[ $checkout_op ] ) ) {
            $entry = $payment_labels[ $checkout_op ];
            $note  = trim( ( $booking->payment_note ? $booking->payment_note . "\n" : '' ) . $entry['note'] . ' by Staff Portal ' . current_time( 'mysql' ) );

            // If this booking is part of a multi-service visit (shared
            // group_booking_id), apply the same payment status to every
            // service in the group, not just the one row the staff tapped.
            $ids_to_update = array( $booking_id );
            if ( ! empty( $booking->group_booking_id ) ) {
                $sibling_ids = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                    "SELECT id FROM {$wpdb->prefix}yatoon_bookings WHERE group_booking_id = %s",
                    $booking->group_booking_id
                ) );
                if ( $sibling_ids ) {
                    $ids_to_update = array_map( 'intval', $sibling_ids );
                }
            }

            foreach ( $ids_to_update as $id_to_update ) {
                yatoon_db_update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                    $wpdb->prefix . 'yatoon_bookings',
                    array(
                        'payment_status'     => $entry['status'],
                        'payment_note'       => $note,
                        'payment_updated_at' => current_time( 'mysql' ),
                        'updated_at'         => current_time( 'mysql' ),
                    ),
                    array( 'id' => $id_to_update ),
                    array( '%s', '%s', '%s', '%s' ),
                    array( '%d' )
                );
            }
            do_action( 'yatoon_payment_status_changed', $booking_id, $entry['status'], $booking );
            wp_send_json_success( array(
                'message' => __( 'Payment status updated.', 'yatoon-booking-system' ),
                'payment_status' => $entry['status'],
                'pay_url' => self::get_booking_payment_url( $booking ),
                'square_checkout_url' => get_option( 'yatoon_square_checkout_url', '' ),
                'square_app_url' => get_option( 'yatoon_square_app_url', '' ),
            ) );
        }

        if ( $checkout_op !== 'send_request' ) {
            wp_send_json_error( array( 'message' => __( 'Invalid checkout action.', 'yatoon-booking-system' ) ) );
        }

        $pay_url = self::get_booking_payment_url( $booking );
        $business = get_option( 'yatoon_business_name', get_bloginfo( 'name' ) );
        $amount = '$' . number_format( (float) $booking->total_price, 2 );
        $message = sprintf(
            __( 'Hi %1$s, this is %2$s. Your appointment balance is %3$s for %4$s. %5$s', 'yatoon-booking-system' ),
            $booking->customer_name ?: __( 'there', 'yatoon-booking-system' ),
            $business,
            $amount,
            $booking->service_name ?: __( 'your appointment', 'yatoon-booking-system' ),
            $pay_url ? sprintf( __( 'Pay here: %s', 'yatoon-booking-system' ), $pay_url ) : __( 'Please contact us to complete payment.', 'yatoon-booking-system' )
        );

        if ( $channel === 'whatsapp' ) {
            if ( empty( $booking->customer_phone ) ) {
                wp_send_json_error( array( 'message' => __( 'Customer phone not found.', 'yatoon-booking-system' ) ) );
            }
            $link = self::whatsapp_link_for( $booking->customer_phone, $message );
            wp_send_json_success( array( 'message' => __( 'Open WhatsApp to send payment request.', 'yatoon-booking-system' ), 'whatsapp_url' => $link ) );
        }

        if ( $channel === 'sms' ) {
            if ( empty( $booking->customer_phone ) ) {
                wp_send_json_error( array( 'message' => __( 'Customer phone not found.', 'yatoon-booking-system' ) ) );
            }
            if ( ! class_exists( 'YATOON_Twilio' ) || ! YATOON_Twilio::is_enabled() ) {
                wp_send_json_error( array( 'message' => __( 'SMS requires Twilio to be configured in Settings -> SMS.', 'yatoon-booking-system' ) ) );
            }
            if ( ! YATOON_Twilio::send( $booking->customer_phone, $message ) ) {
                wp_send_json_error( array( 'message' => __( 'SMS failed. Check Twilio logs.', 'yatoon-booking-system' ) ) );
            }
        } else {
            if ( empty( $booking->customer_email ) ) {
                wp_send_json_error( array( 'message' => __( 'Customer email not found.', 'yatoon-booking-system' ) ) );
            }
            $subject = sprintf( 'Payment request from %s', $business );
            $body = nl2br( esc_html( $message ) );
            if ( ! wp_mail( $booking->customer_email, $subject, $body, array( 'Content-Type: text/html; charset=UTF-8' ) ) ) {
                wp_send_json_error( array( 'message' => __( 'Email failed.', 'yatoon-booking-system' ) ) );
            }
        }

        yatoon_db_update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            $wpdb->prefix . 'yatoon_bookings',
            array(
                'payment_note'       => trim( ( $booking->payment_note ? $booking->payment_note . "\n" : '' ) . 'Payment request sent by Staff Portal via ' . $channel . ' ' . current_time( 'mysql' ) ),
                'payment_updated_at' => current_time( 'mysql' ),
            ),
            array( 'id' => $booking_id ),
            array( '%s', '%s' ),
            array( '%d' )
        );

        wp_send_json_success( array(
            'message' => strtoupper( $channel ) . ' payment request sent.',
            'pay_url' => $pay_url,
            'square_checkout_url' => get_option( 'yatoon_square_checkout_url', '' ),
            'square_app_url' => get_option( 'yatoon_square_app_url', '' ),
        ) );
    }

    private static function staff_can_manage_booking( $actor_staff_id, $booking_id, $permission ) {
        if ( ! self::staff_has_permission( $actor_staff_id, $permission ) ) {
            return false;
        }
        if ( self::staff_has_permission( $actor_staff_id, 'view_all_schedules' ) ) {
            return true;
        }
        global $wpdb;
        $owner = (int) yatoon_db_get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            "SELECT staff_id FROM {$wpdb->prefix}yatoon_bookings WHERE id = %d LIMIT 1",
            $booking_id
        ) );
        return $owner === (int) $actor_staff_id;
    }

    private static function upsert_client( $name, $email, $phone ) {
        global $wpdb;
        $table = esc_sql( $wpdb->prefix . 'yatoon_clients' );
        $existing = 0;
        if ( $phone ) {
            $existing = (int) yatoon_db_get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE customer_phone = %s LIMIT 1", $phone ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        }
        if ( ! $existing && $email ) {
            $existing = (int) yatoon_db_get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE customer_email = %s LIMIT 1", $email ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        }

        $data = array(
            'customer_name'  => $name,
            'customer_email' => $email,
            'customer_phone' => $phone,
            'updated_at'     => current_time( 'mysql' ),
        );
        if ( $existing ) {
            yatoon_db_update( $table, $data, array( 'id' => $existing ), array( '%s', '%s', '%s', '%s' ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            return $existing;
        }
        $data['created_at'] = current_time( 'mysql' );
        yatoon_db_insert( $table, $data, array( '%s', '%s', '%s', '%s', '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        return (int) $wpdb->insert_id;
    }

    private static function has_conflict( $staff_id, $start, $end, $exclude_booking_id = 0 ) {
        global $wpdb;
        $sql = "SELECT id FROM {$wpdb->prefix}yatoon_bookings
                WHERE staff_id = %d
                  AND status NOT IN ('cancelled','cancelled_by_customer','CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED')
                  AND start_time < %s
                  AND end_time > %s";
        $args = array( $staff_id, $end, $start );
        if ( $exclude_booking_id ) {
            $related_ids = yatoon_db_get_col( $wpdb->prepare(
                "SELECT sibling.id FROM {$wpdb->prefix}yatoon_bookings current
                 INNER JOIN {$wpdb->prefix}yatoon_bookings sibling ON sibling.id=current.id
                    OR (current.square_booking_id!='' AND sibling.square_booking_id=current.square_booking_id)
                    OR (current.group_booking_id!='' AND sibling.group_booking_id=current.group_booking_id)
                 WHERE current.id=%d", $exclude_booking_id
            ) );
            $related_ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $related_ids ) ) ) );
            if ( !$related_ids ) { $related_ids = array( $exclude_booking_id );
            }
            $sql .= ' AND id NOT IN (' . implode( ',', array_fill( 0, count( $related_ids ), '%d' ) ) . ')';
            $args = array_merge( $args, $related_ids );
        }
        $sql .= ' LIMIT 1';
        return (bool) yatoon_db_get_var( $wpdb->prepare( $sql, $args ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
    }

    private static function get_square_customer_id( $name, $email, $phone ) {
        if ( ! class_exists( 'YATOON_Square_API' ) ) {
            return '';
        }

        $square = new YATOON_Square_API();
        $found  = $square->search_customers( $email, $phone );
        if ( ! is_wp_error( $found ) && ! empty( $found['customers'][0]['id'] ) ) {
            return $found['customers'][0]['id'];
        }

        $name_parts = explode( ' ', trim( $name ), 2 );
        $phone_digits = preg_replace( '/[^0-9]/', '', $phone );
        $formatted_phone = $phone_digits ? ( strlen( $phone_digits ) === 10 ? '+1' . $phone_digits : '+' . $phone_digits ) : '';
        $created = $square->create_customer( array(
            'given_name'  => $name_parts[0] ?? $name,
            'family_name' => $name_parts[1] ?? '',
            'email'       => $email,
            'phone'       => $formatted_phone,
        ) );

        return ( ! is_wp_error( $created ) && ! empty( $created['customer']['id'] ) ) ? $created['customer']['id'] : '';
    }

    private static function square_booking_payload( $service, $staff_id, $start_dt, $name, $email, $phone, $notes, $option_ids = array(), $total_duration = 0 ) {
        if ( ! class_exists( 'YATOON_Square_API' ) ) {
            return new WP_Error( 'square_missing', __( 'Square API is not available.', 'yatoon-booking-system' ) );
        }

        global $wpdb;
        $staff = yatoon_db_get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            "SELECT * FROM {$wpdb->prefix}yatoon_staff WHERE id = %d LIMIT 1",
            $staff_id
        ) );
        if ( ! $staff || empty( $staff->square_id ) || empty( $service->square_id ) ) {
            return new WP_Error( 'square_mapping_missing', __( 'This service or staff member is not mapped to Square.', 'yatoon-booking-system' ) );
        }

        $square = new YATOON_Square_API();
        $selected_options = array();
        if ( $option_ids ) {
            $ids = implode( ',', array_map( 'absint', $option_ids ) );
            $selected_options = yatoon_db_get_results(
                "SELECT * FROM {$wpdb->prefix}yatoon_service_options WHERE service_id = " . absint( $service->id ) . " AND id IN ({$ids}) ORDER BY CASE WHEN group_name = 'Add-on' THEN 1 ELSE 0 END, sort_order ASC, id ASC"
            );
        }
        $main = null;
        $addons = array();
        foreach ( $selected_options as $selected_option ) {
            if ( (string) $selected_option->group_name === 'Add-on' ) { $addons[] = $selected_option;
            } elseif ( !$main ) { $main = $selected_option;
            }
        }
        $main_square_id = $main && $main->square_id ? $main->square_id : $service->square_id;
        $main_version   = $main && !empty( $main->square_version ) ? intval( $main->square_version ) : intval( $service->square_version ?? 0 );
        if ( !$main_version ) {
            $cat = $square->get_catalog_object( $main_square_id );
            if ( !is_wp_error( $cat ) && isset( $cat['object']['version'] ) ) { $main_version = intval( $cat['object']['version'] );
            }
        }
        if ( !$main_square_id || !$main_version ) { return new WP_Error( 'square_version_missing', __( 'Could not read the selected Square service version.', 'yatoon-booking-system' ) );
        }

        $addon_duration = array_sum( array_map( function ( $addon ) {
 return max( 1, intval( $addon->duration_minutes ) );
}, $addons ) );
        $effective_total = $total_duration > 0 ? intval( $total_duration ) : max( 5, intval( $main ? $main->duration_minutes : $service->duration_minutes ) + $addon_duration );
        if ( $effective_total <= $addon_duration ) { return new WP_Error( 'duration_too_short', __( 'Duration must be longer than the selected add-on time.', 'yatoon-booking-system' ) );
        }

        $segments = array( array(
            'duration_minutes'          => max( 5, $effective_total - $addon_duration ),
            'service_variation_id'      => $main_square_id,
            'service_variation_version' => $main_version,
            'team_member_id'            => $staff->square_id,
        ), );
        foreach ( $addons as $addon ) {
            $addon_version = intval( $addon->square_version ?? 0 );
            if ( !$addon_version && $addon->square_id ) {
                $cat = $square->get_catalog_object( $addon->square_id );
                if ( !is_wp_error( $cat ) && isset( $cat['object']['version'] ) ) { $addon_version = intval( $cat['object']['version'] );
                }
            }
            if ( $addon->square_id && $addon_version ) { $segments[] = array(
                'duration_minutes' => max( 1, intval( $addon->duration_minutes ) ), 'service_variation_id' => $addon->square_id,
                'service_variation_version' => $addon_version, 'team_member_id' => $staff->square_id,
            );
            }
        }

        $customer_id = self::get_square_customer_id( $name, $email, $phone );
        if ( ! $customer_id ) {
            return new WP_Error( 'square_customer_missing', __( 'Could not create or find the Square customer.', 'yatoon-booking-system' ) );
        }

        return array(
            'location_id'          => get_option( 'yatoon_square_location_id' ),
            'customer_id'          => $customer_id,
            'start_at'             => $start_dt->format( 'c' ),
            'customer_note'        => $notes,
            'seller_note'          => 'Staff Portal booking by ' . $name,
            'appointment_segments' => $segments,
        );
    }

    public static function ajax_save_booking() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $actor_staff_id = self::send_staff_auth_error();
        $booking_id     = absint( $_POST['booking_id'] ?? 0 );
        $target_staff   = absint( $_POST['booking_staff_id'] ?? $actor_staff_id );
        $service_id     = absint( $_POST['service_id'] ?? 0 );
        $date           = sanitize_text_field( wp_unslash( $_POST['date'] ?? '' ) );
        $time           = sanitize_text_field( wp_unslash( $_POST['time'] ?? '' ) );
        $name           = sanitize_text_field( wp_unslash( $_POST['customer_name'] ?? '' ) );
        $email          = sanitize_email( wp_unslash( $_POST['customer_email'] ?? '' ) );
        $phone          = sanitize_text_field( wp_unslash( $_POST['customer_phone'] ?? '' ) );
        $notes          = sanitize_textarea_field( wp_unslash( $_POST['notes'] ?? '' ) );
        $custom_duration = isset( $_POST['duration_minutes'] ) ? absint( $_POST['duration_minutes'] ) : 0;
        $option_ids = array_values( array_unique( array_filter( array_map( 'absint', (array) json_decode( wp_unslash( $_POST['option_ids'] ?? '[]' ), true ) ) ) ) );

        if ( $booking_id ) {
            if ( ! self::staff_can_manage_booking( $actor_staff_id, $booking_id, 'edit_booking' ) ) {
                wp_send_json_error( array( 'message' => __( 'You do not have permission to edit this appointment.', 'yatoon-booking-system' ) ) );
            }
        } elseif ( ! self::staff_has_permission( $actor_staff_id, 'create_booking' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to add appointments.', 'yatoon-booking-system' ) ) );
        }
        if ( ! self::staff_has_permission( $actor_staff_id, 'view_all_schedules' ) ) {
            $target_staff = $actor_staff_id;
        }
        if ( ! $target_staff || ! $service_id || ! $date || ! $time || ! $name || ! $phone ) {
            wp_send_json_error( array( 'message' => __( 'Name, phone, service, staff, date, and time are required.', 'yatoon-booking-system' ) ) );
        }

        global $wpdb;
        $service = yatoon_db_get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}yatoon_services WHERE id = %d AND is_active = 1", $service_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        if ( ! $service ) {
            wp_send_json_error( array( 'message' => __( 'Service not found.', 'yatoon-booking-system' ) ) );
        }
        $duration = max( 5, (int) $service->duration_minutes );
        if ( $custom_duration > 0 ) {
            $duration = min( 600, max( 5, $custom_duration ) ); // clamp to 5 min - 10 hours
        }
        $tz       = yatoon_timezone();
        try {
            $start_dt = new DateTime( $date . ' ' . $time, $tz );
        } catch ( Exception $e ) {
            wp_send_json_error( array( 'message' => __( 'Invalid date or time.', 'yatoon-booking-system' ) ) );
        }
        $end_dt = clone $start_dt;
        $end_dt->modify( '+' . $duration . ' minutes' );
        $start = $start_dt->format( 'Y-m-d H:i:s' );
        $end   = $end_dt->format( 'Y-m-d H:i:s' );
        $booking_locks = YATOON_Booking_Lock::acquire( $start_dt, $end_dt );
        if ( false === $booking_locks ) {
            wp_send_json_error( array( 'message' => __( 'That calendar is being updated. Please try again.', 'yatoon-booking-system' ) ) );
        }
        register_shutdown_function( array( 'YATOON_Booking_Lock', 'release' ), $booking_locks );
        if ( self::has_conflict( $target_staff, $start, $end, $booking_id ) ) {
            wp_send_json_error( array( 'message' => __( 'This staff member already has an appointment at that time.', 'yatoon-booking-system' ) ) );
        }

        self::upsert_client( $name, $email, $phone );

        $platform          = sanitize_key( (string) get_option( 'yatoon_active_platform', 'local' ) );
        $square_booking_id = '';
        if ( $platform === 'square' ) {
            $payload = self::square_booking_payload( $service, $target_staff, $start_dt, $name, $email, $phone, $notes, $option_ids, $duration );
            if ( is_wp_error( $payload ) ) {
                wp_send_json_error( array( 'message' => $payload->get_error_message() ) );
            }
            $square = new YATOON_Square_API();
            if ( $booking_id ) {
                $existing = yatoon_db_get_row( $wpdb->prepare( "SELECT square_booking_id FROM {$wpdb->prefix}yatoon_bookings WHERE id = %d", $booking_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                if ( ! empty( $existing->square_booking_id ) ) {
                    $current = $square->get_booking( $existing->square_booking_id );
                    $version = ( ! is_wp_error( $current ) && isset( $current['booking']['version'] ) ) ? intval( $current['booking']['version'] ) : null;
                    // Preserve every other service in a multi-service Square booking.
                    if ( !is_wp_error( $current ) && !empty( $current['booking']['appointment_segments'] ) ) {
                        $current_segments = $current['booking']['appointment_segments'];
                        $current_main_id = yatoon_db_get_var( $wpdb->prepare(
                            "SELECT o.square_id FROM {$wpdb->prefix}yatoon_booking_options bo INNER JOIN {$wpdb->prefix}yatoon_service_options o ON o.id=bo.option_id WHERE bo.booking_id=%d AND (o.group_name IS NULL OR o.group_name != 'Add-on') AND o.square_id!='' ORDER BY bo.id ASC LIMIT 1", $booking_id
                        ) );
                        $start_index = 0;
                        foreach ( $current_segments as $i => $current_segment ) {
                            if ( $current_main_id && (string) ( $current_segment['service_variation_id'] ?? '' ) === (string) $current_main_id ) { $start_index = $i;
break; }
                        }
                        $end_index = count( $current_segments );
                        $next_main_id = yatoon_db_get_var( $wpdb->prepare(
                            "SELECT o.square_id FROM {$wpdb->prefix}yatoon_bookings b INNER JOIN {$wpdb->prefix}yatoon_booking_options bo ON bo.booking_id=b.id INNER JOIN {$wpdb->prefix}yatoon_service_options o ON o.id=bo.option_id WHERE b.square_booking_id=%s AND b.start_time>(SELECT start_time FROM {$wpdb->prefix}yatoon_bookings WHERE id=%d) AND (o.group_name IS NULL OR o.group_name!='Add-on') AND o.square_id!='' ORDER BY b.start_time ASC, bo.id ASC LIMIT 1", $existing->square_booking_id, $booking_id
                        ) );
                        if ( $next_main_id ) { foreach ( $current_segments as $i => $current_segment ) {
                            if ( $i > $start_index && (string) ( $current_segment['service_variation_id'] ?? '' ) === (string) $next_main_id ) { $end_index = $i;
break; }
                        }
                        }
                        array_splice( $current_segments, $start_index, max(1, $end_index - $start_index), $payload['appointment_segments'] );
                        $payload['appointment_segments'] = $current_segments;
                        if ( $start_index > 0 ) { $payload['start_at'] = $current['booking']['start_at'];
                        }
                    }
                    $updated = $square->update_booking( $existing->square_booking_id, $payload, $version );
                    if ( is_wp_error( $updated ) || ! empty( $updated['errors'] ) ) {
                        $msg = is_wp_error( $updated ) ? $updated->get_error_message() : implode( ' ', wp_list_pluck( $updated['errors'], 'detail' ) );
                        wp_send_json_error( array( 'message' => sprintf( __( 'Square update failed: %s', 'yatoon-booking-system' ), $msg ) ) );
                    }
                    $square_booking_id = $existing->square_booking_id;
                    if ( !empty( $updated['booking'] ) ) { yatoon_reconcile_square_booking_segments( $updated['booking'] );
                    }
                }
            } else {
                $created = $square->create_booking( $payload );
                if ( is_wp_error( $created ) || empty( $created['booking']['id'] ) ) {
                    $msg = is_wp_error( $created ) ? $created->get_error_message() : ( ! empty( $created['errors'] ) ? implode( ' ', wp_list_pluck( $created['errors'], 'detail' ) ) : __( 'No Square booking ID returned.', 'yatoon-booking-system' ) );
                    wp_send_json_error( array( 'message' => sprintf( __( 'Square booking failed: %s', 'yatoon-booking-system' ), $msg ) ) );
                }
                $square_booking_id = $created['booking']['id'];
            }
        }

        $data = array(
            'customer_name'  => $name,
            'customer_email' => $email,
            'customer_phone' => $phone,
            'service_id'     => $service_id,
            'staff_id'       => $target_staff,
            'start_time'     => $start,
            'end_time'       => $end,
            'notes'          => $notes,
            'total_price'    => (float) $service->price,
            'updated_at'     => current_time( 'mysql' ),
        );
        $formats = array( '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%f', '%s' );

        if ( $booking_id ) {
            $ok = yatoon_db_update( $wpdb->prefix . 'yatoon_bookings', $data, array( 'id' => $booking_id ), $formats, array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            if ( $ok === false ) {
                wp_send_json_error( array( 'message' => __( 'Failed to update appointment.', 'yatoon-booking-system' ) ) );
            }
            yatoon_db_delete( $wpdb->prefix . 'yatoon_booking_options', array( 'booking_id' => $booking_id ) );
            foreach ( $option_ids as $option_id ) { yatoon_db_insert( $wpdb->prefix . 'yatoon_booking_options', array( 'booking_id' => $booking_id, 'option_id' => $option_id ) );
            }
            YATOON_Booking_Lock::release( $booking_locks );
            wp_send_json_success( array( 'message' => __( 'Appointment updated.', 'yatoon-booking-system' ), 'booking_id' => $booking_id ) );
        }

        $data['status']         = 'confirmed';
        $data['square_booking_id'] = $square_booking_id;
        $data['square_synced']  = $square_booking_id ? 1 : 0;
        $data['payment_status'] = 'unpaid';
        $data['created_at']     = current_time( 'mysql' );
        $data['created_at_gmt'] = current_time( 'mysql', true );
        $ok = yatoon_db_insert( $wpdb->prefix . 'yatoon_bookings', $data ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        if ( ! $ok ) {
            wp_send_json_error( array( 'message' => __( 'Failed to add appointment.', 'yatoon-booking-system' ) ) );
        }
        $new_booking_id = (int) $wpdb->insert_id;
        foreach ( $option_ids as $option_id ) { yatoon_db_insert( $wpdb->prefix . 'yatoon_booking_options', array( 'booking_id' => $new_booking_id, 'option_id' => $option_id ) );
        }
        YATOON_Booking_Lock::release( $booking_locks );
        wp_send_json_success( array( 'message' => __( 'Appointment added.', 'yatoon-booking-system' ), 'booking_id' => $new_booking_id ) );
    }

    public static function ajax_cancel_booking() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $staff_id   = self::send_staff_auth_error();
        $booking_id = absint( $_POST['booking_id'] ?? 0 );
        if ( ! $staff_id || ! $booking_id || ! self::staff_can_manage_booking( $staff_id, $booking_id, 'cancel_booking' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to cancel this appointment.', 'yatoon-booking-system' ) ) );
        }

        global $wpdb;
        $table   = esc_sql( $wpdb->prefix . 'yatoon_bookings' );
        $booking = yatoon_db_get_row( $wpdb->prepare( "SELECT id, square_booking_id, group_booking_id, customer_email, customer_phone, start_time FROM {$table} WHERE id = %d LIMIT 1", $booking_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        if ( ! $booking ) {
            wp_send_json_error( array( 'message' => __( 'Appointment not found.', 'yatoon-booking-system' ) ) );
        }

        $rows_to_cancel = array( $booking );
        if ( ! empty( $booking->group_booking_id ) ) {
            $group_rows = yatoon_db_get_results( $wpdb->prepare( "SELECT id, square_booking_id, group_booking_id, customer_email, customer_phone, start_time FROM {$table} WHERE group_booking_id = %s", $booking->group_booking_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            if ( ! empty( $group_rows ) ) {
                $rows_to_cancel = $group_rows;
            }
        } elseif ( ! empty( $booking->start_time ) && ( ! empty( $booking->customer_email ) || ! empty( $booking->customer_phone ) ) ) {
            $legacy_group_rows = yatoon_db_get_results( $wpdb->prepare(
                "SELECT id, square_booking_id, group_booking_id, customer_email, customer_phone, start_time FROM {$table}
                 WHERE start_time = %s
                   AND status NOT IN ('cancelled','cancelled_by_customer','CANCELLED_BY_CUSTOMER','CANCELLED_BY_SELLER','DECLINED')
                   AND ((customer_email <> '' AND customer_email = %s) OR (customer_phone <> '' AND customer_phone = %s))",
                $booking->start_time,
                (string) $booking->customer_email,
                (string) $booking->customer_phone
            ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            if ( ! empty( $legacy_group_rows ) ) {
                $rows_to_cancel = $legacy_group_rows;
            }
        }

        if ( get_option( 'yatoon_active_platform', 'local' ) === 'square' && class_exists( 'YATOON_Square_API' ) ) {
            $square               = new YATOON_Square_API();
            $cancelled_square_ids = array();
            foreach ( $rows_to_cancel as $row_to_cancel ) {
                if ( empty( $row_to_cancel->square_booking_id ) || in_array( $row_to_cancel->square_booking_id, $cancelled_square_ids, true ) ) {
                    continue;
                }
                $current = $square->get_booking( $row_to_cancel->square_booking_id );
                $version = ( ! is_wp_error( $current ) && isset( $current['booking']['version'] ) ) ? intval( $current['booking']['version'] ) : null;
                $cancel  = $square->cancel_booking( $row_to_cancel->square_booking_id, $version );
                if ( is_wp_error( $cancel ) || ! empty( $cancel['errors'] ) ) {
                    $msg = is_wp_error( $cancel ) ? $cancel->get_error_message() : implode( ' ', wp_list_pluck( $cancel['errors'], 'detail' ) );
                    wp_send_json_error( array( 'message' => sprintf( __( 'Square cancel failed: %s', 'yatoon-booking-system' ), $msg ) ) );
                }
                $cancelled_square_ids[] = $row_to_cancel->square_booking_id;
            }
        }

        $ids = array_values( array_unique( array_map( 'intval', wp_list_pluck( $rows_to_cancel, 'id' ) ) ) );
        if ( empty( $ids ) ) {
            $ids = array( $booking_id );
        }
        $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
        $ok = yatoon_db_query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            $wpdb->prepare(
                "UPDATE {$table} SET status = %s, updated_at = %s WHERE id IN ({$placeholders})",
                array_merge( array( 'cancelled', current_time( 'mysql' ) ), $ids )
            )
        );
        if ( $ok === false ) {
            wp_send_json_error( array( 'message' => __( 'Failed to cancel appointment.', 'yatoon-booking-system' ) ) );
        }
        $count = count( $ids );
        wp_send_json_success( array(
            'message'         => sprintf( _n( '%d appointment cancelled.', '%d appointments cancelled.', $count, 'yatoon-booking-system' ), $count ),
            'cancelled_ids'   => $ids,
            'cancelled_count' => $count,
        ) );
    }

    // -- AJAX: update status -----------------------------------------------

    public static function ajax_update_status() {
        check_ajax_referer( 'yatoon_staff_portal_nonce', 'nonce' );

        $staff_id   = self::send_staff_auth_error( 'update_status' );
        $booking_id = absint( $_POST['booking_id'] ?? 0 );
        $new_status = sanitize_text_field( wp_unslash( $_POST['status'] ?? '' ) );

        $allowed = array( 'arrived', 'no_show', 'completed', 'confirmed' );
        if ( ! in_array( $new_status, $allowed, true ) ) {
            wp_send_json_error( array( 'message' => __( 'Invalid status.', 'yatoon-booking-system' ) ) );
        }
        if ( ! $staff_id || ! $booking_id ) {
            wp_send_json_error( array( 'message' => __( 'Invalid request.', 'yatoon-booking-system' ) ) );
        }

        global $wpdb;

        $can_view_all = self::staff_has_permission( $staff_id, 'view_all_schedules' );
        if ( $can_view_all ) {
            $booking = yatoon_db_get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                "SELECT id, staff_id FROM {$wpdb->prefix}yatoon_bookings WHERE id = %d LIMIT 1",
                $booking_id
            ) );
        } else {
            // Verify booking belongs to this staff member
            $booking = yatoon_db_get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                "SELECT id, staff_id FROM {$wpdb->prefix}yatoon_bookings WHERE id = %d AND staff_id = %d LIMIT 1",
                $booking_id, $staff_id
            ) );
        }

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

        yatoon_db_update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            $wpdb->prefix . 'yatoon_bookings',
            array( 'status' => $new_status, 'updated_at' => current_time( 'mysql' ) ),
            array( 'id'     => $booking_id ),
            array( '%s', '%s' ),
            array( '%d' )
        );

        do_action( 'yatoon_booking_status_changed', $booking_id, $new_status );

        wp_send_json_success( array( 'message' => __( 'Status updated.', 'yatoon-booking-system' ), 'status' => $new_status ) );
    }

    // -- Helpers -----------------------------------------------------------

    private static function login_attempt_payload( $key ) {
        $legacy = get_transient( $key );
        return array(
            'count'        => max( YATOON_Request_Guard::counter_value( $key . '_count' ), is_array( $legacy ) ? (int) ( $legacy['count'] ?? 0 ) : 0 ),
            'locked_until' => max( (int) get_transient( $key . '_lock' ), is_array( $legacy ) ? (int) ( $legacy['locked_until'] ?? 0 ) : 0 ),
        );
    }

    private static function record_failed_login_attempt( $key, $payload ) {
        $count = YATOON_Request_Guard::consume_counter( $key . '_count', HOUR_IN_SECONDS );
        $lock_minutes = 0;
        if ( $count >= 12 ) {
            $lock_minutes = 60;
        } elseif ( $count >= 9 ) {
            $lock_minutes = 15;
        } elseif ( $count >= 6 ) {
            $lock_minutes = 5;
        }
        $payload = array(
            'count'        => $count,
            'locked_until' => $lock_minutes ? time() + ( $lock_minutes * MINUTE_IN_SECONDS ) : 0,
        );
        if ( $payload['locked_until'] ) {
            set_transient( $key . '_lock', $payload['locked_until'], HOUR_IN_SECONDS );
        }
        return $payload;
    }

    /**
     * Site-wide PIN brute-force backstop. Thresholds are deliberately much
     * higher than the per-IP/per-device ones so ordinary staff mistyping a
     * PIN a few times a day never trips it; it only engages against sustained,
     * distributed guessing across many IPs/devices. See ajax_login().
     */
    private static function record_failed_global_pin_attempt( $payload ) {
        $count = YATOON_Request_Guard::consume_counter( 'ysp_pin_global_backstop_count', HOUR_IN_SECONDS );
        $lock_minutes = 0;
        if ( $count >= 80 ) {
            $lock_minutes = 60;
        } elseif ( $count >= 40 ) {
            $lock_minutes = 15;
        }
        $payload = array(
            'count'        => $count,
            'locked_until' => $lock_minutes ? time() + ( $lock_minutes * MINUTE_IN_SECONDS ) : 0,
        );
        if ( $payload['locked_until'] ) {
            set_transient( 'ysp_pin_global_backstop_lock', $payload['locked_until'], HOUR_IN_SECONDS );
        }
        return $payload;
    }

    /**
     * The client IP, for rate limiting.
     *
     * SECURITY (4.4.3): this used to read HTTP_CF_CONNECTING_IP and
     * HTTP_X_FORWARDED_FOR before falling back to REMOTE_ADDR. Both are just
     * request headers -- on a site that is not actually behind Cloudflare or a
     * reverse proxy, anyone can set them to whatever they like. That made the
     * per-IP PIN lockout worthless: an attacker sends a different
     * X-Forwarded-For on every request and never trips the limit.
     *
     * We now trust only REMOTE_ADDR, which the web server derives from the TCP
     * connection and a client cannot forge. Sites genuinely behind a proxy can
     * opt in per-header via the 'yatoon_trusted_proxy_header' filter, and
     * should ALSO confirm that REMOTE_ADDR is their proxy before doing so --
     * otherwise they are re-opening the same hole.
     *
     * Delegates to YATOON_Request_Guard so the whole plugin agrees on what
     * "the client" means.
     *
     * @return string
     */
    private static function get_ip() {
        if ( class_exists( 'YATOON_Request_Guard' ) ) {
            $ip = YATOON_Request_Guard::client_ip();
            if ( '' !== $ip ) {
                return $ip;
            }
        }

        $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
        return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '0.0.0.0';
    }

    /**
     * Deterministic, keyed lookup value for a staff PIN.
     *
     * Lets us find the one candidate row for a submitted PIN with an indexed
     * lookup instead of bcrypt-checking every staff member (see ajax_login()).
     * Keyed with the site's auth salt so the column is not a plain rainbow
     * table of a 4-6 digit space.
     *
     * This is an INDEX, not a credential. wp_check_password() against
     * portal_pin remains the actual authentication step.
     *
     * @since 4.4.3
     * @param string $pin
     * @return string 64-char hex.
     */
    private static function pin_lookup( $pin ) {
        return hash_hmac( 'sha256', (string) $pin, wp_salt( 'auth' ) );
    }

    /**
     * Ensure the portal_pin_lookup column and its index exist.
     *
     * @since 4.4.3
     * @return bool
     */
    public static function ensure_pin_lookup_column() {
        global $wpdb;
        static $checked = null;
        if ( null !== $checked ) {
            return $checked;
        }

        // 4.5.0: this used to issue ALTER TABLE from inside ajax_login(), which
        // is an UNAUTHENTICATED endpoint -- anyone hitting the staff portal
        // login could trigger DDL. The column and its index are now created by
        // yatoon_staff_pin_lookup_migration() on plugins_loaded. What remains
        // is a read-only probe, cached for the request, so that a site whose
        // migration has not yet run falls back to the legacy bcrypt scan path
        // in ajax_login() rather than erroring on an unknown column.
        $table  = $wpdb->prefix . 'yatoon_staff';
        $exists = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'portal_pin_lookup' ) );

        $checked = (bool) $exists;
        return $checked;
    }
}
