<?php

namespace BookWellAB;

defined('ABSPATH') || exit;

if (!class_exists('BookWellAB_Frontend')) {

    class BookWellAB_Frontend
    {

        private static $instance = null;
        private static $shortcode_rendered = false;
        private static $booking_app_rendered = false;
        private static $popup_button_used = false;
        private $bwdb;

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

        public function __construct()
        {
            $this->bwdb = new BookWellAB_Database();

            add_shortcode('bookwell_booking', [$this, 'bookwell_booking_shortcode']);
            add_shortcode('bookwell_booking_button', [$this, 'bookwell_booking_button_shortcode']);
            add_action('wp_enqueue_scripts', [$this, 'bookwell_frontend_assets']);
            add_action('wp_footer', [$this, 'maybe_render_popup_booking_host'], 5);

            // AJAX Handlers (Frontend interactions)
            add_action('wp_ajax_bookwellab_get_front_services', [$this, 'bookwellab_get_front_services_func']);
            add_action('wp_ajax_nopriv_bookwellab_get_front_services', [$this, 'bookwellab_get_front_services_func']);

            add_action('wp_ajax_bookwellab_get_front_staff', [$this, 'bookwellab_get_front_staff_func']);
            add_action('wp_ajax_nopriv_bookwellab_get_front_staff', [$this, 'bookwellab_get_front_staff_func']);

            add_action('wp_ajax_bookwellab_get_time_slots', [$this, 'bookwellab_get_time_slots_func']);
            add_action('wp_ajax_nopriv_bookwellab_get_time_slots', [$this, 'bookwellab_get_time_slots_func']);

            add_action('wp_ajax_bookwellab_process_booking', [$this, 'bookwellab_process_booking_func']);
            add_action('wp_ajax_nopriv_bookwellab_process_booking', [$this, 'bookwellab_process_booking_func']);

            add_action('wp_ajax_bookwellab_get_month_availability', [$this, 'bookwellab_get_month_availability_func']);
            add_action('wp_ajax_nopriv_bookwellab_get_month_availability', [$this, 'bookwellab_get_month_availability_func']);
            add_action('wp_ajax_bookwellab_resolve_calendar_month', [$this, 'bookwellab_resolve_calendar_month_func']);
            add_action('wp_ajax_nopriv_bookwellab_resolve_calendar_month', [$this, 'bookwellab_resolve_calendar_month_func']);

            //add_action('wp_ajax_bookwellab_get_customer_by_phone', [$this, 'bookwellab_get_customer_by_phone_func']);

            add_action('wp_ajax_bookwellab_refresh_spam_token', [$this, 'bookwellab_refresh_spam_token_func']);
            add_action('wp_ajax_nopriv_bookwellab_refresh_spam_token', [$this, 'bookwellab_refresh_spam_token_func']);

            add_action('template_redirect', [$this, 'maybe_handle_public_cancel_appointment'], 1);
        }

        public function bookwell_booking_shortcode($atts)
        {
            self::$shortcode_rendered = true;

            $atts = shortcode_atts([
                'staff_id' => null,
                'service_id' => null,
            ], $atts, 'bookwell_booking');

            $sc_staff_id = $atts['staff_id'];
            $sc_service_id = $atts['service_id'];
            $bookwellab_popup_host = false;

            if (!wp_script_is('bookwellab-frontend-booking', 'enqueued')) {
                $this->bookwell_frontend_assets();
            }

            // One booking app mount per page (buttons share the same popup).
            if (self::$booking_app_rendered) {
                return '';
            }
            self::$booking_app_rendered = true;

            ob_start();
            require BOOKWELLAB_HTML_PATH . 'frontend_booking_view.php';
            return ob_get_clean();
        }

        /**
         * Button shortcode that opens the booking wizard popup with optional service/staff.
         *
         * @param array|string $atts Shortcode attributes.
         * @return string
         */
        public function bookwell_booking_button_shortcode($atts)
        {
            self::$shortcode_rendered = true;
            self::$popup_button_used = true;

            $atts = shortcode_atts([
                'staff_id' => '',
                'service_id' => '',
                'label' => '',
                'class' => '',
            ], $atts, 'bookwell_booking_button');

            if (!wp_script_is('bookwellab-frontend-booking', 'enqueued')) {
                $this->bookwell_frontend_assets();
            }

            $label = trim((string) $atts['label']);
            if ($label === '') {
                $labels = BookWellAB_Settings::get_frontend_labels();
                $label = !empty($labels['book_appointment'])
                    ? (string) $labels['book_appointment']
                    : __('Book Appointment', 'bookwellnow-appointment-booking');
            }

            $extra_class = trim((string) $atts['class']);
            $btn_classes = ['bw-booking-popup-btn'];
            if ($extra_class !== '') {
                $parts = preg_split('/\s+/', $extra_class);
                if (is_array($parts)) {
                    foreach ($parts as $part) {
                        $safe = sanitize_html_class($part);
                        if ($safe !== '') {
                            $btn_classes[] = $safe;
                        }
                    }
                }
            }
            $btn_classes = array_unique($btn_classes);

            $service_id = absint($atts['service_id']);
            $staff_id = absint($atts['staff_id']);

            ob_start();
            ?>
            <button
                type="button"
                class="<?php echo esc_attr(implode(' ', $btn_classes)); ?>"
                data-bw-booking-popup="1"
                data-service-id="<?php echo esc_attr($service_id > 0 ? (string) $service_id : ''); ?>"
                data-staff-id="<?php echo esc_attr($staff_id > 0 ? (string) $staff_id : ''); ?>">
                <?php echo esc_html($label); ?>
            </button>
            <?php
            return ob_get_clean();
        }

        /**
         * Render a hidden booking app host when popup buttons are used without [bookwell_booking].
         *
         * @return void
         */
        public function maybe_render_popup_booking_host()
        {
            if (!self::$popup_button_used || self::$booking_app_rendered) {
                return;
            }

            self::$booking_app_rendered = true;
            $sc_staff_id = null;
            $sc_service_id = null;
            $bookwellab_popup_host = true;

            if (!wp_script_is('bookwellab-frontend-booking', 'enqueued')) {
                $this->bookwell_frontend_assets();
            }

            require BOOKWELLAB_HTML_PATH . 'frontend_booking_view.php';
        }

        /**
         * Whether frontend booking assets should load on this request.
         *
         * @return bool
         */
        private function should_load_frontend_assets()
        {
            if (is_admin()) {
                return false;
            }

            if (self::$shortcode_rendered) {
                return true;
            }

            global $post;
            if ($post instanceof \WP_Post) {
                if (has_shortcode($post->post_content, 'bookwell_booking')
                    || has_shortcode($post->post_content, 'bookwell_booking_button')) {
                    return true;
                }
            }

            return false;
        }

        /**
         * Parse bookwell_booking shortcode attributes from the current post.
         *
         * @return array{staff_id: int, service_id: int}
         */
        private function parse_booking_shortcode_atts_from_post()
        {
            $parsed = array(
                'staff_id' => 0,
                'service_id' => 0,
            );

            global $post;
            if (!$post instanceof \WP_Post || !has_shortcode($post->post_content, 'bookwell_booking')) {
                return $parsed;
            }

            if (!preg_match('/\[bookwell_booking([^\]]*)\]/', $post->post_content, $matches)) {
                return $parsed;
            }

            $raw_atts = isset($matches[1]) ? trim((string) $matches[1]) : '';
            $atts = shortcode_parse_atts($raw_atts);
            if (!is_array($atts)) {
                return $parsed;
            }

            $parsed['staff_id'] = isset($atts['staff_id']) ? absint($atts['staff_id']) : 0;
            $parsed['service_id'] = isset($atts['service_id']) ? absint($atts['service_id']) : 0;

            return $parsed;
        }

        /**
         * Clear cached frontend booking payloads (services/staff lists).
         *
         * @return void
         */
        public static function clear_front_booking_cache()
        {
            global $wpdb;

            $transient_like = $wpdb->esc_like('_transient_' . BOOKWELLAB_OPTION_PREFIX) . '%';
            $timeout_like = $wpdb->esc_like('_transient_timeout_' . BOOKWELLAB_OPTION_PREFIX) . '%';

            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Removes persisted plugin transients; {$wpdb->options} is a trusted core table name.
            $wpdb->query(
                $wpdb->prepare(
                    "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
                    $transient_like,
                    $timeout_like
                )
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared

            self::flush_booking_object_cache_group();
        }

        /**
         * Flush the booking object cache group when supported (WordPress 6.1+).
         *
         * @return void
         */
        private static function flush_booking_object_cache_group()
        {
            if (!function_exists('wp_cache_flush_group')) {
                return;
            }

            call_user_func('wp_cache_flush_group', 'bookwellab_booking');
        }

        /**
         * Clear plugin caches after a version update (transients + object cache group).
         *
         * @return void
         */
        public static function flush_upgrade_caches()
        {
            self::clear_front_booking_cache();
            self::purge_legacy_spam_transients();
        }

        /**
         * One-time cleanup for legacy spam transients in wp_options.
         *
         * @return void
         */
        public static function purge_legacy_spam_transients()
        {
            global $wpdb;

            $legacy_s = $wpdb->esc_like('_transient_' . BOOKWELLAB_OPTION_PREFIX . 's_') . '%';
            $legacy_s_timeout = $wpdb->esc_like('_transient_timeout_' . BOOKWELLAB_OPTION_PREFIX . 's_') . '%';
            $legacy_spam = $wpdb->esc_like('_transient_' . BOOKWELLAB_OPTION_PREFIX . 'spam_') . '%';
            $legacy_spam_timeout = $wpdb->esc_like('_transient_timeout_' . BOOKWELLAB_OPTION_PREFIX . 'spam_') . '%';

            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Cleanup of legacy plugin transients from trusted core options table.
            $wpdb->query(
                $wpdb->prepare(
                    "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s",
                    $legacy_s,
                    $legacy_s_timeout,
                    $legacy_spam,
                    $legacy_spam_timeout
                )
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
        }

        /**
         * @param string $transient_key Transient key.
         * @return string Object cache key.
         */
        private function get_front_object_cache_key_from_transient($transient_key)
        {
            $prefix = BookWellAB_Database::get_transient_name('');
            if (strpos($transient_key, $prefix) === 0) {
                return BookWellAB_Database::get_transient_name('oc_' . substr($transient_key, strlen($prefix)));
            }

            return BookWellAB_Database::get_transient_name('oc_' . $transient_key);
        }

        /**
         * Read/write frontend payload using object cache + transients.
         *
         * @param string   $transient_key Cache key.
         * @param callable $builder       Builds payload on cache miss.
         * @return array<mixed>
         */
        private function get_front_cached_payload($transient_key, callable $builder)
        {
            $object_key = $this->get_front_object_cache_key_from_transient($transient_key);

            $cached = wp_cache_get($object_key, 'bookwellab_booking');
            if (is_array($cached)) {
                return $cached;
            }

            $cached = get_transient($transient_key);
            if (is_array($cached)) {
                wp_cache_set($object_key, $cached, 'bookwellab_booking', 5 * MINUTE_IN_SECONDS);
                return $cached;
            }

            $payload = $builder();
            if (!is_array($payload)) {
                return array();
            }

            set_transient($transient_key, $payload, 5 * MINUTE_IN_SECONDS);
            wp_cache_set($object_key, $payload, 'bookwellab_booking', 5 * MINUTE_IN_SECONDS);

            return $payload;
        }

        /**
         * Build bootstrap payload for the booking wizard initial render.
         *
         * @return array
         */
        private function build_front_bootstrap_payload()
        {
            $shortcode_atts = $this->parse_booking_shortcode_atts_from_post();
            $staff_id = (int) $shortcode_atts['staff_id'];
            $service_id = (int) $shortcode_atts['service_id'];

            $services_payload = $this->get_front_services_payload($staff_id);
            $service_ids = array();
            if (!empty($services_payload['services']) && is_array($services_payload['services'])) {
                foreach ($services_payload['services'] as $service_row) {
                    if (!empty($service_row['bw_service_id'])) {
                        $service_ids[] = (int) $service_row['bw_service_id'];
                    }
                }
            }

            $staff_by_service = $this->get_front_staff_by_service_map($service_ids);

            $payload = array(
                'staff_id' => $staff_id,
                'service_id' => $service_id,
                'services' => $services_payload,
                'staff_by_service' => $staff_by_service,
            );

            if ($service_id > 0) {
                $service_key = (string) $service_id;
                $payload['staff'] = isset($staff_by_service[$service_key]) ? $staff_by_service[$service_key] : $this->get_front_staff_payload($service_id);
            }

            return apply_filters('bookwellab_front_bootstrap_payload', $payload);
        }

        /**
         * Default booking wizard steps (slug-based).
         *
         * @param array<string, mixed> $context Optional context for filters.
         * @return array<int, array<string, mixed>>
         */
        public static function get_default_wizard_steps($context = array())
        {
            $steps = array(
                array(
                    'slug' => 'service',
                    'sidebar' => 'sidebar_service',
                    'title' => 'service_content_title',
                ),
                array(
                    'slug' => 'staff',
                    'sidebar' => 'sidebar_staff',
                    'title' => 'staff_content_title',
                ),
                array(
                    'slug' => 'datetime',
                    'sidebar' => 'sidebar_date_time',
                    'title' => 'date_content_title',
                ),
                array(
                    'slug' => 'details',
                    'sidebar' => 'sidebar_details',
                    'title' => 'details_content_title',
                ),
                array(
                    'slug' => 'summary',
                    'sidebar' => 'sidebar_summary',
                    'title' => 'summary_content_title',
                ),
                array(
                    'slug' => 'confirmed',
                    'sidebar' => '',
                    'title' => 'booking_confirmed_title',
                ),
            );

            return apply_filters('bookwellab_booking_wizard_steps', $steps, $context);
        }

        /**
         * Build a comma-separated %d placeholder list for integer IN clauses.
         *
         * @param array<int, int> $ids Integer IDs.
         * @return array{0: string, 1: array<int, int>} Placeholders and values for $wpdb->prepare().
         */
        private function build_absint_in_clause(array $ids)
        {
            $ids = array_values(array_unique(array_filter(array_map('absint', $ids))));
            if (empty($ids)) {
                return array('', array());
            }

            return array(
                implode(', ', array_fill(0, count($ids), '%d')),
                $ids,
            );
        }

        /**
         * Run a SELECT using plugin-controlled table names and optional prepared values.
         *
         * @param string               $sql          SQL query (table names must already be escaped).
         * @param array<int, mixed>    $prepare_args Arguments for $wpdb->prepare().
         * @return array<int, array<string, mixed>>
         */
        private function run_select_query($sql, array $prepare_args = array())
        {
            global $wpdb;

            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names are escaped via esc_sql(); callers cache results with transients/object cache.
            if (!empty($prepare_args)) {
                $sql = $wpdb->prepare($sql, ...$prepare_args);
            }
            $results = $wpdb->get_results($sql, ARRAY_A);
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

            return is_array($results) ? $results : array();
        }

        /**
         * IANA timezone for calendar links (Google ctz / ICS TZID).
         *
         * @return string
         */
        public static function get_front_timezone_string()
        {
            $tz = function_exists('wp_timezone_string') ? (string) wp_timezone_string() : '';
            if ($tz === '') {
                $tz = (string) get_option('timezone_string', '');
            }
            // Manual UTC offset settings are not valid IANA names.
            if ($tz === '' || preg_match('/^UTC([+-]|$)/i', $tz) || preg_match('/^[+-]\d{2}/', $tz)) {
                return '';
            }

            try {
                new \DateTimeZone($tz);
                return $tz;
            } catch (\Exception $e) {
                return '';
            }
        }

        /**
         * WordPress timezone payload for frontend calendar links.
         *
         * @return array{string:string,offset:string,offset_seconds:int,gmt_offset:float}
         */
        public static function get_front_timezone_payload()
        {
            $tz = wp_timezone();
            $now = new \DateTime('now', $tz);
            $offset_seconds = (int) $now->getOffset();

            return [
                'string' => self::get_front_timezone_string(),
                'offset' => $now->format('P'),
                'offset_seconds' => $offset_seconds,
                'gmt_offset' => round($offset_seconds / 3600, 2),
            ];
        }

        /**
         * Convert a site-local date/time to calendar stamps (UTC + offset ISO).
         *
         * @param string $date Y-m-d
         * @param string $time H:i or H:i:s
         * @param int    $duration_minutes Duration in minutes.
         * @return array<string,string>|null
         */
        public static function build_calendar_time_stamps($date, $time, $duration_minutes = 60)
        {
            $date = sanitize_text_field((string) $date);
            $time = sanitize_text_field((string) $time);
            if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
                return null;
            }
            if (preg_match('/^\d{1,2}:\d{2}$/', $time)) {
                $time .= ':00';
            }
            if (!preg_match('/^\d{1,2}:\d{2}:\d{2}$/', $time)) {
                return null;
            }

            $duration_minutes = max(1, (int) $duration_minutes);
            $tz = wp_timezone();

            try {
                $start = new \DateTimeImmutable($date . ' ' . $time, $tz);
            } catch (\Exception $e) {
                return null;
            }

            $end = $start->modify('+' . $duration_minutes . ' minutes');
            $start_utc = $start->setTimezone(new \DateTimeZone('UTC'));
            $end_utc = $end->setTimezone(new \DateTimeZone('UTC'));

            return [
                'start_local' => $start->format('Y-m-d H:i:s'),
                'end_local' => $end->format('Y-m-d H:i:s'),
                'start_utc' => $start_utc->format('Ymd\THis\Z'),
                'end_utc' => $end_utc->format('Ymd\THis\Z'),
                'start_iso' => $start->format('Y-m-d\TH:i:sP'),
                'end_iso' => $end->format('Y-m-d\TH:i:sP'),
                'start_stamp_local' => $start->format('Ymd\THis'),
                'end_stamp_local' => $end->format('Ymd\THis'),
                'offset' => $start->format('P'),
                'timezone' => self::get_front_timezone_string(),
            ];
        }

        /**
         * Default staff avatar when no photo is uploaded.
         *
         * @return string
         */
        public static function get_default_staff_image_url()
        {
            $ver = (string) BOOKWELLAB_VERSION;
            $file = plugin_dir_path(BOOKWELLAB_FILE) . 'assets/images/staff-placeholder.svg';
            if (file_exists($file)) {
                $ver .= '.' . filemtime($file);
            }

            return BOOKWELLAB_URL . 'assets/images/staff-placeholder.svg?ver=' . rawurlencode($ver);
        }

        /**
         * Whether a staff image URL is the plugin placeholder (or empty).
         *
         * @param string $url Image URL.
         * @return bool
         */
        public static function is_default_staff_image($url)
        {
            $url = (string) $url;
            if ($url === '') {
                return true;
            }

            return (bool) preg_match('/(?:default-avatar|staff-placeholder)\.svg/i', $url);
        }

        /**
         * Default service image when no photo is uploaded.
         *
         * @return string
         */
        public static function get_default_service_image_url()
        {
            $ver = (string) BOOKWELLAB_VERSION;
            $file = plugin_dir_path(BOOKWELLAB_FILE) . 'assets/images/service-placeholder.svg';
            if (file_exists($file)) {
                $ver .= '.' . filemtime($file);
            }

            return BOOKWELLAB_URL . 'assets/images/service-placeholder.svg?ver=' . rawurlencode($ver);
        }

        /**
         * Whether a service image URL is the plugin placeholder (or empty).
         *
         * @param string $url Image URL.
         * @return bool
         */
        public static function is_default_service_image($url)
        {
            $url = (string) $url;
            if ($url === '') {
                return true;
            }

            return (bool) preg_match('/service-placeholder\.svg/i', $url);
        }

        /**
         * Public staff columns exposed to the frontend booking form.
         *
         * @return string
         */
        private function get_front_staff_select_columns()
        {
            return 's.bw_staff_id, s.bw_staff_name, s.bw_staff_image, s.bw_staff_position';
        }

        /**
         * Strip private staff fields before sending data to the public booking form.
         *
         * @param array<string, mixed> $row Raw staff row.
         * @return array<string, mixed>
         */
        private function sanitize_front_staff_row(array $row)
        {
            $allowed_keys = array(
                'bw_staff_id',
                'bw_staff_name',
                'bw_staff_image',
                'bw_staff_position',
                'bw_service_price',
                'bw_service_duration_val',
                'bw_service_duration_unit',
                'off_days',
            );

            $sanitized = array();
            foreach ($allowed_keys as $key) {
                if (!array_key_exists($key, $row)) {
                    continue;
                }

                if ($key === 'bw_staff_id') {
                    $sanitized[$key] = absint($row[$key]);
                } elseif ($key === 'off_days') {
                    $sanitized[$key] = is_array($row[$key]) ? $row[$key] : array();
                } elseif ($key === 'bw_staff_image') {
                    $sanitized[$key] = esc_url_raw((string) $row[$key]);
                } elseif (in_array($key, array('bw_service_price', 'bw_service_duration_val'), true)) {
                    $sanitized[$key] = $row[$key];
                } else {
                    $sanitized[$key] = sanitize_text_field((string) $row[$key]);
                }
            }

            if (self::is_default_staff_image($sanitized['bw_staff_image'] ?? '')) {
                $sanitized['bw_staff_image'] = self::get_default_staff_image_url();
            }

            return $sanitized;
        }

        /**
         * Services + categories for the booking form (cacheable).
         *
         * @param int $staff_id Optional staff filter.
         * @return array{categories: array, services: array}
         */
        private function get_front_services_payload($staff_id = 0)
        {
            $staff_id = absint($staff_id);
            $cache_key = BookWellAB_Database::get_transient_name('booking_services_' . $staff_id);

            return $this->get_front_cached_payload(
                $cache_key,
                function () use ($staff_id) {
                    $cat_table = esc_sql($this->bwdb->get_table_name('categories'));
                    $serv_table = esc_sql($this->bwdb->get_table_name('services'));
                    $staff_serv_table = esc_sql($this->bwdb->get_table_name('staff_services'));

                    $categories = $this->run_select_query("SELECT * FROM {$cat_table} ORDER BY bw_category_position ASC");

                    if ($staff_id > 0) {
                        $services = $this->run_select_query(
                            "SELECT s.* FROM {$serv_table} s
                             INNER JOIN {$staff_serv_table} ss ON s.bw_service_id = ss.bw_service_id
                             WHERE ss.bw_staff_id = %d AND s.bw_service_status = 1
                             ORDER BY s.bw_service_position ASC",
                            array($staff_id)
                        );
                    } else {
                        $services = $this->run_select_query(
                            "SELECT * FROM {$serv_table} WHERE bw_service_status = %d ORDER BY bw_service_position ASC",
                            array(1)
                        );
                    }

                    $category_ids_with_active_services = array();
                    foreach ($services as $service_row) {
                        $cid = isset($service_row['bw_category_id']) ? (int) $service_row['bw_category_id'] : 0;
                        if ($cid > 0) {
                            $category_ids_with_active_services[$cid] = true;
                        }
                    }

                    $categories = array_values(
                        array_filter(
                            $categories,
                            static function ($cat_row) use ($category_ids_with_active_services) {
                                $cid = isset($cat_row['bw_category_id']) ? (int) $cat_row['bw_category_id'] : 0;
                                return $cid > 0 && isset($category_ids_with_active_services[$cid]);
                            }
                        )
                    );

                    return array(
                        'categories' => is_array($categories) ? $categories : array(),
                        'services' => is_array($services) ? $services : array(),
                    );
                }
            );
        }

        /**
         * Staff list for a service (cacheable).
         *
         * @param int $service_id Service ID.
         * @return array<int, array>
         */
        private function get_front_staff_payload($service_id)
        {
            $service_id = absint($service_id);
            if ($service_id <= 0) {
                return array();
            }

            $cache_key = BookWellAB_Database::get_transient_name('booking_staff_' . $service_id);

            $staff = $this->get_front_cached_payload(
                $cache_key,
                function () use ($service_id) {
                    $staff_table = esc_sql($this->bwdb->get_table_name('staffs'));
                    $staff_serv_table = esc_sql($this->bwdb->get_table_name('staff_services'));
                    $staff_columns = $this->get_front_staff_select_columns();

                    $staff = $this->run_select_query(
                        "SELECT {$staff_columns}, ss.bw_service_price, ss.bw_service_duration_val, ss.bw_service_duration_unit
                         FROM {$staff_table} s
                         INNER JOIN {$staff_serv_table} ss ON s.bw_staff_id = ss.bw_staff_id
                         WHERE ss.bw_service_id = %d AND s.bw_staff_is_active = 1
                         ORDER BY s.bw_staff_position_order ASC",
                        array($service_id)
                    );

                    if (!empty($staff)) {
                        $staff_ids = array_map(
                            static function ($row) {
                                return isset($row['bw_staff_id']) ? (int) $row['bw_staff_id'] : 0;
                            },
                            $staff
                        );
                        $off_days_map = $this->get_staff_off_days_map($staff_ids);

                        foreach ($staff as $key => $s) {
                            $sid = isset($s['bw_staff_id']) ? (int) $s['bw_staff_id'] : 0;
                            $s['off_days'] = isset($off_days_map[$sid]) ? $off_days_map[$sid] : array();
                            $staff[$key] = $this->sanitize_front_staff_row($s);
                        }
                    }

                    return is_array($staff) ? $staff : array();
                }
            );

            if (!is_array($staff)) {
                return array();
            }

            foreach ($staff as $key => $row) {
                if (self::is_default_staff_image($row['bw_staff_image'] ?? '')) {
                    $staff[$key]['bw_staff_image'] = self::get_default_staff_image_url();
                }
            }

            return $staff;
        }

        /**
         * Staff lists keyed by service ID (single query, cacheable).
         *
         * @param array<int, int> $service_ids Service IDs.
         * @return array<string, array<int, array>>
         */
        private function get_front_staff_by_service_map(array $service_ids)
        {
            $service_ids = array_values(array_unique(array_filter(array_map('absint', $service_ids))));
            if (empty($service_ids)) {
                return array();
            }

            sort($service_ids);
            $cache_key = BookWellAB_Database::get_transient_name('booking_map_' . substr(md5(implode(',', $service_ids)), 0, 24));

            $map = $this->get_front_cached_payload(
                $cache_key,
                function () use ($service_ids) {
                    $staff_table = esc_sql($this->bwdb->get_table_name('staffs'));
                    $staff_serv_table = esc_sql($this->bwdb->get_table_name('staff_services'));
                    $staff_columns = $this->get_front_staff_select_columns();
                    list($in_clause, $in_args) = $this->build_absint_in_clause($service_ids);

                    $rows = $this->run_select_query(
                        "SELECT {$staff_columns}, ss.bw_service_id AS map_service_id, ss.bw_service_price, ss.bw_service_duration_val, ss.bw_service_duration_unit
                         FROM {$staff_table} s
                         INNER JOIN {$staff_serv_table} ss ON s.bw_staff_id = ss.bw_staff_id
                         WHERE ss.bw_service_id IN ({$in_clause}) AND s.bw_staff_is_active = 1
                         ORDER BY ss.bw_service_id ASC, s.bw_staff_position_order ASC",
                        $in_args
                    );

                    $map = array();
                    foreach ($service_ids as $service_id) {
                        $map[(string) $service_id] = array();
                    }

                    if (empty($rows)) {
                        return $map;
                    }

                    $staff_ids = array();
                    foreach ($rows as $row) {
                        if (!empty($row['bw_staff_id'])) {
                            $staff_ids[] = (int) $row['bw_staff_id'];
                        }
                    }
                    $off_days_map = $this->get_staff_off_days_map($staff_ids);

                    foreach ($rows as $row) {
                        $map_service_id = isset($row['map_service_id']) ? (int) $row['map_service_id'] : 0;
                        if ($map_service_id <= 0) {
                            continue;
                        }

                        $staff_member = $row;
                        unset($staff_member['map_service_id']);

                        $staff_member_id = isset($staff_member['bw_staff_id']) ? (int) $staff_member['bw_staff_id'] : 0;
                        $staff_member['off_days'] = isset($off_days_map[$staff_member_id]) ? $off_days_map[$staff_member_id] : array();

                        $map[(string) $map_service_id][] = $this->sanitize_front_staff_row($staff_member);
                    }

                    return $map;
                }
            );

            if (!is_array($map)) {
                return array();
            }

            foreach ($map as $service_key => $staff_list) {
                if (!is_array($staff_list)) {
                    continue;
                }
                foreach ($staff_list as $idx => $row) {
                    if (self::is_default_staff_image($row['bw_staff_image'] ?? '')) {
                        $map[$service_key][$idx]['bw_staff_image'] = self::get_default_staff_image_url();
                    }
                }
            }

            return $map;
        }

        /**
         * Load off-days for multiple staff members in one query.
         *
         * @param array<int, int> $staff_ids Staff IDs.
         * @return array<int, array<int, string>>
         */
        private function get_staff_off_days_map(array $staff_ids)
        {
            $staff_ids = array_values(array_unique(array_filter(array_map('absint', $staff_ids))));
            if (empty($staff_ids)) {
                return array();
            }

            $working_hours_table = esc_sql($this->bwdb->get_table_name('staff_working_hours'));
            list($in_clause, $in_args) = $this->build_absint_in_clause($staff_ids);

            $rows = $this->run_select_query(
                "SELECT bw_staff_id, bw_day_name FROM {$working_hours_table} WHERE bw_is_off = 1 AND bw_staff_id IN ({$in_clause})",
                $in_args
            );

            $map = array();
            foreach ($rows as $row) {
                $sid = isset($row['bw_staff_id']) ? (int) $row['bw_staff_id'] : 0;
                $day = isset($row['bw_day_name']) ? strtolower((string) $row['bw_day_name']) : '';
                if ($sid <= 0 || $day === '') {
                    continue;
                }
                if (!isset($map[$sid])) {
                    $map[$sid] = array();
                }
                $map[$sid][] = $day;
            }

            return $map;
        }

        public function bookwell_frontend_assets()
        {
            if (!$this->should_load_frontend_assets()) {
                return;
            }

            $settings = \BookWellAB\BookWellAB_Settings::get_settings();
            $appearance = isset($settings['appearance']) ? $settings['appearance'] : [];
            $font_family = isset($appearance['font_family']) ? $appearance['font_family'] : '';
            $frontend_font_handle = '';
            $frontend_style_deps = [];

            if (!empty($font_family)) {
                $font_family = sanitize_text_field($font_family);
                $allowed_fonts = \BookWellAB\BookWellAB_Settings::get_google_fonts();
                if (in_array($font_family, $allowed_fonts, true)) {
                    $font_family_query = str_replace(' ', '+', $font_family);
                    $google_font_url = 'https://fonts.googleapis.com/css2?family=' . $font_family_query . ':wght@300;400;500;600;700&display=swap';
                    $frontend_font_handle = 'bookwellab-frontend-google-font-' . sanitize_title($font_family);
            wp_enqueue_style(
                        $frontend_font_handle,
                        esc_url($google_font_url),
                [],
                BOOKWELLAB_VERSION
                    );
                    $frontend_style_deps[] = $frontend_font_handle;
                }
            }

            if (defined('ELEMENTOR_VERSION')) {
                $frontend_style_deps[] = 'elementor-frontend';
            }

            $frontend_css_path = BOOKWELLAB_PATH . 'assets/css/frontend.css';
            $frontend_css_ver = BOOKWELLAB_VERSION;
            if (file_exists($frontend_css_path)) {
                $frontend_css_ver .= '.' . filemtime($frontend_css_path);
            }

            wp_enqueue_style(
                'bookwellab-frontend-style',
                plugin_dir_url(BOOKWELLAB_FILE) . 'assets/css/frontend.css',
                $frontend_style_deps,
                $frontend_css_ver
            );

            // Element Plus CSS
            wp_enqueue_style(
                'bookwellab-elements-style',
                plugin_dir_url(BOOKWELLAB_FILE) . 'assets/css/elements.css',
                [],
                BOOKWELLAB_VERSION
            );

            wp_enqueue_script(
                'bookwellab-vue-script',
                plugin_dir_url(BOOKWELLAB_FILE) . 'assets/js/vue.cjs.min.js',
                [],
                BOOKWELLAB_VERSION,
                true
            );

            wp_enqueue_script(
                'bookwellab-elements-plus-script',
                plugin_dir_url(BOOKWELLAB_FILE) . 'assets/js/elements-plus.js',
                ['bookwellab-vue-script'],
                BOOKWELLAB_VERSION,
                true
            );

            wp_enqueue_script(
                'bookwellab-axios-script',
                plugin_dir_url(BOOKWELLAB_FILE) . 'assets/js/axios.min.js',
                [],
                BOOKWELLAB_VERSION,
                true
            );

            wp_enqueue_script(
                'bookwellab-qs-script',
                plugin_dir_url(BOOKWELLAB_FILE) . 'assets/js/qs.min.js',
                [],
                BOOKWELLAB_VERSION,
                true
            );

            self::enqueue_intl_tel_input_assets();

            $dependencies = [
                'bookwellab-vue-script',
                'bookwellab-elements-plus-script',
                'bookwellab-axios-script',
                'bookwellab-qs-script',
                'bookwellab-intl-tel-input-script',
            ];

            $frontend_booking_path = plugin_dir_path(BOOKWELLAB_FILE) . 'assets/js/frontend_booking.js';
            $frontend_booking_ver = BOOKWELLAB_VERSION;
            if (file_exists($frontend_booking_path)) {
                $frontend_booking_ver .= '.' . filemtime($frontend_booking_path);
            }

            // PayPal SDK is injected from frontend_booking.js (not wp_enqueue_script) because
            // WordPress appends &ver= to external script URLs and PayPal rejects that with HTTP 400.

            wp_enqueue_script(
                'bookwellab-frontend-booking',
                plugin_dir_url(BOOKWELLAB_FILE) . 'assets/js/frontend_booking.js',
                $dependencies,
                $frontend_booking_ver,
                true
            );
            $date_format_setting = isset($settings['general']['date_format']) ? $settings['general']['date_format'] : 'Y-m-d';
            $time_format_setting = isset($settings['general']['time_format']) ? $settings['general']['time_format'] : 'H:i';

            $date_format = ($date_format_setting !== 'inherit') ? $date_format_setting : get_option('date_format');
            $time_format = ($time_format_setting !== 'inherit') ? $time_format_setting : get_option('time_format');
            $week_starts_on = \BookWellAB\BookWellAB_Settings::get_week_starts_on($settings);

            // Derive symbol for frontend JS compatibility
            $currency_code = isset($settings['general']['currency_code']) ? $settings['general']['currency_code'] : 'USD';
            $currencies = \BookWellAB\BookWellAB_Settings::get_currencies();
            $currency_symbol = isset($currencies[$currency_code]) ? $currencies[$currency_code]['symbol'] : '$';

            // Add symbol to settings array for JS
            if (!isset($settings['general']))
                $settings['general'] = [];
            $settings['general']['currency_symbol'] = $currency_symbol;

            $default_country_iso = isset($settings['general']['default_country'])
                ? strtolower(sanitize_text_field((string) $settings['general']['default_country']))
                : 'us';
            if ($default_country_iso === '') {
                $default_country_iso = 'us';
            }

            $spam_token = $this->generate_spam_token_pair();
            $timezone_payload = self::get_front_timezone_payload();

            $front_data = [
                    'is_rtl' => is_rtl(),
                    'ajax_url' => admin_url('admin-ajax.php'),
                    'nonce' => wp_create_nonce('bw_front_nonce'),
                'site_today' => wp_date('Y-m-d', null, wp_timezone()),
                    'date_format' => $date_format,
                    'time_format' => $time_format,
                    'week_starts_on' => $week_starts_on,
                    'week_day_labels' => \BookWellAB\BookWellAB_Settings::get_week_day_labels(true, $settings),
                    'general' => $settings['general'],
                    'default_country_iso' => $default_country_iso,
                    'default_staff_image' => self::get_default_staff_image_url(),
                    'default_service_image' => self::get_default_service_image_url(),
                    'timezone' => $timezone_payload,
                    'timezone_offset' => $timezone_payload['offset'],
                    'timezone_string' => $timezone_payload['string'],
                    'labels' => \BookWellAB\BookWellAB_Settings::get_frontend_labels(),
                'form_fields' => \BookWellAB\BookWellAB_Form_Fields::get_frontend_form_fields(),
                    'payment' => bookwellab_ensure_utf8(isset($settings['payment']) ? $settings['payment'] : []),
                    'appearance' => isset($settings['appearance']) ? $settings['appearance'] : [],
                    'month_names' => array_values($GLOBALS['wp_locale']->month),
                    'spam_token' => $spam_token,
                    'intl_tel_input_utils_url' => plugin_dir_url(BOOKWELLAB_FILE) . 'assets/js/utils.js',
                'bootstrap' => $this->build_front_bootstrap_payload(),
                'wizard_steps' => self::get_default_wizard_steps(),
                'prefill_customer' => \BookWellAB\BookWellAB_Customer_Account::get_booking_prefill_customer(),
            ];

            $front_data = apply_filters('bookwellab_front_script_data', $front_data);

            if (BookWellAB_Paypal::is_frontend_enabled()) {
                $front_data['paypal_enabled'] = true;
                $front_data['paypal_client_id'] = BookWellAB_Paypal::get_frontend_client_id();
                $front_data['paypal_currency'] = BookWellAB_Paypal::get_frontend_currency();
            }

            wp_localize_script(
                'bookwellab-frontend-booking',
                'bookwellab_front_obj',
                $front_data
            );
        }

        /**
         * Enqueue intl-tel-input v28+ assets (CSS, JS with bundled utils, flag sprites).
         */
        public static function enqueue_intl_tel_input_assets()
        {
            $plugin_url = plugin_dir_url(BOOKWELLAB_FILE);

            wp_enqueue_style(
                'bookwellab-intl-tel-input-style',
                $plugin_url . 'assets/css/intlTelInput.css',
                [],
                BOOKWELLAB_VERSION
            );

            // Absolute sprite URLs for caching/minify plugins (PNG for broad browser support).
            $flags_1x_url = esc_url($plugin_url . 'assets/img/flags.png');
            $flags_2x_url = esc_url($plugin_url . 'assets/img/flags-png.png');
            wp_add_inline_style(
                'bookwellab-intl-tel-input-style',
                ':root{--iti-path-flags-1x:url("' . $flags_1x_url . '");--iti-path-flags-2x:url("' . $flags_2x_url . '");}'
                . 'button.iti__selected-country,.iti__selected-country-primary{'
                . 'background:none!important;background-color:transparent!important;border:0!important;box-shadow:none!important;}'
                . 'button.iti__selected-country:focus,.iti__selected-country-primary:hover{'
                . 'background-color:rgba(0,0,0,0.05)!important;}'
                . '.iti__selected-country,.iti__selected-country-primary,.iti__country-container{overflow:visible;}'
                . '.iti__flag{flex-shrink:0;background-color:transparent!important;background-image:var(--iti-path-flags-1x);background-repeat:no-repeat;'
                . 'background-position:calc(var(--iti-flag-offset,100)*var(--iti-flag-width,20px)) 0;'
                . 'background-size:calc(var(--iti-flag-count,244)*var(--iti-flag-width,20px)) var(--iti-flag-height,15px);}'
                . '@supports (background-image:image-set(url("") 1x)){'
                . '.iti__flag{background-image:image-set(var(--iti-path-flags-1x) 1x,var(--iti-path-flags-2x) 2x);}}'
                . '.bw-phone-container .iti__arrow,.bw-phone-wrapper .iti__arrow{flex-shrink:0;display:inline-block;box-sizing:border-box;'
                . 'border-top:0!important;border-left:0!important;'
                . 'border-right:var(--iti-arrow-width,2px) solid var(--iti-icon-color,#555)!important;'
                . 'border-bottom:var(--iti-arrow-width,2px) solid var(--iti-icon-color,#555)!important;'
                . 'background:transparent!important;}'
                . '.iti__search-icon{display:none;}'
                . '.iti__search-input{padding-left:var(--iti-spacer-horizontal);}'
                . '#bookwellab_frontend_app[dir=rtl] .iti__search-input,#bookwellab_frontend_app.bw-rtl .iti__search-input{padding-left:inherit;padding-right:var(--iti-spacer-horizontal);}'
            );

            $intl_tel_input_path = plugin_dir_path(BOOKWELLAB_FILE) . 'assets/js/intlTelInput.min.js';
            $intl_tel_input_ver = BOOKWELLAB_VERSION;
            if (file_exists($intl_tel_input_path)) {
                $intl_tel_input_ver .= '.' . filemtime($intl_tel_input_path);
            }

            wp_enqueue_script(
                'bookwellab-intl-tel-input-script',
                $plugin_url . 'assets/js/intlTelInput.min.js',
                [],
                $intl_tel_input_ver,
                true
            );

            wp_localize_script(
                'bookwellab-intl-tel-input-script',
                'bookwellab_intl_tel_input',
                [
                    'utils_url' => esc_url($plugin_url . 'assets/js/utils.js'),
                ]
            );
        }

        private function get_spam_encryption_secret()
        {
            return hash('sha256', wp_salt('auth'));
        }

        private function encrypt_spam_value($plain_text)
        {
            if (function_exists('openssl_encrypt')) {
                $iv = substr(hash('sha256', wp_salt('secure_auth')), 0, 16);
                $encrypted = openssl_encrypt($plain_text, 'AES-256-CBC', $this->get_spam_encryption_secret(), 0, $iv);
                if ($encrypted !== false) {
                    return $encrypted;
                }
            }
            return base64_encode($plain_text);
        }

        private function decrypt_spam_value($encrypted_text)
        {
            if (function_exists('openssl_decrypt')) {
                $iv = substr(hash('sha256', wp_salt('secure_auth')), 0, 16);
                $decrypted = openssl_decrypt($encrypted_text, 'AES-256-CBC', $this->get_spam_encryption_secret(), 0, $iv);
                if ($decrypted !== false && $decrypted !== null) {
                    return $decrypted;
                }
            }

            $decoded = base64_decode($encrypted_text, true);
            return ($decoded !== false) ? $decoded : '';
        }

        private function generate_spam_token_pair()
        {
            BookWellAB_Database::delete_expired_spam_tokens();

            $token_key = wp_generate_password(20, false, false);
            $token_code = wp_generate_password(28, false, false);
            $token_hash = hash_hmac('sha256', $token_key . '|' . $token_code, wp_salt('nonce'));

            BookWellAB_Database::insert_spam_token($token_key, $token_hash, 20 * MINUTE_IN_SECONDS);

            return [
                'key' => $this->encrypt_spam_value($token_key),
                'code' => $this->encrypt_spam_value($token_code),
            ];
        }

        private function validate_spam_token_pair($encrypted_key, $encrypted_code)
        {
            $token_key = sanitize_text_field($this->decrypt_spam_value($encrypted_key));
            $token_code = sanitize_text_field($this->decrypt_spam_value($encrypted_code));

            if (empty($token_key) || empty($token_code)) {
                return false;
            }

            $stored_hash = BookWellAB_Database::get_spam_token_hash($token_key);
            if (empty($stored_hash)) {
                return false;
            }

            $incoming_hash = hash_hmac('sha256', $token_key . '|' . $token_code, wp_salt('nonce'));
            if (!hash_equals($stored_hash, $incoming_hash)) {
                return false;
            }

            BookWellAB_Database::delete_spam_token($token_key);
            return true;
        }

        private function verify_front_nonce_or_error()
        {
            $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
            if (empty($nonce) || !wp_verify_nonce($nonce, 'bw_front_nonce')) {
                wp_send_json_error(['msg' => esc_html__('Invalid request token', 'bookwellnow-appointment-booking')]);
            }
        }

        private function is_valid_date_ymd($date)
        {
            if (!is_string($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
                return false;
            }
            $dt = \DateTime::createFromFormat('Y-m-d', $date);
            return $dt && $dt->format('Y-m-d') === $date;
        }

        private function normalize_time_his($time)
        {
            if (!is_string($time)) {
                return false;
            }
            $time = trim($time);
            if (preg_match('/^\d{2}:\d{2}$/', $time)) {
                $time .= ':00';
            }
            if (!preg_match('/^\d{2}:\d{2}:\d{2}$/', $time)) {
                return false;
            }
            $dt = \DateTime::createFromFormat('H:i:s', $time);
            if (!$dt || $dt->format('H:i:s') !== $time) {
                return false;
            }
            return $time;
        }

        // --- API Handlers ---
        // phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

        public function bookwellab_get_front_services_func()
        {
            $this->verify_front_nonce_or_error();
            $staff_id = isset($_POST['staff_id']) ? intval(wp_unslash($_POST['staff_id'])) : 0;
            wp_send_json_success($this->get_front_services_payload($staff_id));
        }

        public function bookwellab_get_front_staff_func()
        {
            $this->verify_front_nonce_or_error();
            $service_id = isset($_POST['service_id']) ? intval(wp_unslash($_POST['service_id'])) : 0;

            if ($service_id <= 0) {
                wp_send_json_error(['msg' => esc_html__('Invalid service ID', 'bookwellnow-appointment-booking')]);
            }

            wp_send_json_success(['staff' => $this->get_front_staff_payload($service_id)]);
        }

        private function get_staff_off_days($staff_id)
        {
            global $wpdb;
            $working_hours_table = esc_sql($this->bwdb->get_table_name('staff_working_hours'));
            $off_days = $wpdb->get_results($wpdb->prepare(
                "SELECT bw_day_name FROM $working_hours_table WHERE bw_staff_id = %d AND bw_is_off = 1",
                    $staff_id
                ), ARRAY_A);

            return array_column($off_days, 'bw_day_name');
        }

        private function get_site_today()
        {
            return wp_date('Y-m-d', null, wp_timezone());
        }

        private function get_site_now_hm()
        {
            return wp_date('H:i', null, wp_timezone());
        }

        private function is_past_booking_date($date)
        {
            $date = sanitize_text_field((string) $date);
            if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
                return true;
            }

            return $date < $this->get_site_today();
        }

        private function normalize_slot_hm($slot)
        {
            $slot = trim((string) $slot);
            if (preg_match('/^(\d{1,2}):(\d{2})/', $slot, $matches)) {
                return sprintf('%02d:%02d', (int) $matches[1], (int) $matches[2]);
            }

            return '';
        }

        /**
         * Frontend booking only: remove past time slots for today.
         *
         * @param string               $date  Y-m-d.
         * @param array<int, string>   $slots Slot times.
         * @return array<int, string>
         */
        private function filter_front_time_slots($date, $slots)
        {
            if ($this->is_past_booking_date($date)) {
                return array();
            }

            if (!is_array($slots)) {
                return array();
            }

            if ($date !== $this->get_site_today()) {
                return array_values($slots);
            }

            $now_hm = $this->get_site_now_hm();
            $filtered = array();
            foreach ($slots as $slot) {
                $slot_hm = $this->normalize_slot_hm($slot);
                if ($slot_hm !== '' && $slot_hm >= $now_hm) {
                    $filtered[] = $slot;
                }
            }

            return $filtered;
        }

        /**
         * Frontend booking only: ensure past dates are disabled and not returned with slots.
         *
         * @param array<string, mixed> $result Calendar payload.
         * @return array<string, mixed>
         */
        private function filter_front_calendar_month_result($result)
        {
            if (!is_array($result)) {
                return $result;
            }

            $site_today = $this->get_site_today();
            $disabled_lookup = array();

            if (!empty($result['disabled_dates']) && is_array($result['disabled_dates'])) {
                foreach ($result['disabled_dates'] as $date) {
                    if (is_string($date) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
                        $disabled_lookup[$date] = true;
                    }
                }
            }

            if (!empty($result['time_slots_by_date']) && is_array($result['time_slots_by_date'])) {
                $filtered_slots_by_date = array();
                foreach ($result['time_slots_by_date'] as $date => $slots) {
                    if (!is_string($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || $date < $site_today) {
                        if (is_string($date)) {
                            $disabled_lookup[$date] = true;
                        }
                        continue;
                    }

                    $slots = $this->filter_front_time_slots($date, $slots);
                    if (empty($slots)) {
                        $disabled_lookup[$date] = true;
                        continue;
                    }

                    $filtered_slots_by_date[$date] = array_values($slots);
                }

                $result['time_slots_by_date'] = $filtered_slots_by_date;
            }

            ksort($disabled_lookup);
            $result['disabled_dates'] = array_keys($disabled_lookup);
            $result['has_availability'] = !empty($result['time_slots_by_date']);

            return $result;
        }

        /**
         * Frontend booking only: include all past dates in a month as disabled.
         *
         * @param int                $year           Year.
         * @param int                $month          Month 1-12.
         * @param array<int, string> $disabled_dates Disabled dates from availability check.
         * @return array<int, string>
         */
        private function filter_front_month_disabled_dates($year, $month, $disabled_dates)
        {
            $disabled_lookup = array();
            if (is_array($disabled_dates)) {
                foreach ($disabled_dates as $date) {
                    if (is_string($date) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
                        $disabled_lookup[$date] = true;
                    }
                }
            }

            $site_today = $this->get_site_today();
            $days_in_month = cal_days_in_month(CAL_GREGORIAN, (int) $month, (int) $year);
            for ($day = 1; $day <= $days_in_month; $day++) {
                $date = sprintf('%04d-%02d-%02d', (int) $year, (int) $month, $day);
                if ($date < $site_today) {
                    $disabled_lookup[$date] = true;
                }
            }

            ksort($disabled_lookup);
            return array_keys($disabled_lookup);
        }

        private function get_front_available_slots($date, $staff_id, $service_id)
        {
            if ($this->is_past_booking_date($date)) {
                return array();
            }

            $slots = BookWellAB_Booking_Helper::get_staff_available_slots($date, $staff_id, $service_id, true);
            return $this->filter_front_time_slots($date, $slots);
        }

        private function get_available_slots($date, $staff_id, $service_id)
        {
            return $this->get_front_available_slots($date, $staff_id, $service_id);
        }

        public function bookwellab_get_month_availability_func()
        {
            $this->verify_front_nonce_or_error();
            $year = isset($_POST['year']) ? intval(wp_unslash($_POST['year'])) : 0;
            $month = isset($_POST['month']) ? intval(wp_unslash($_POST['month'])) : 0; // 1-12
            $staff_id = isset($_POST['staff_id']) ? intval(wp_unslash($_POST['staff_id'])) : 0;
            $service_id = isset($_POST['service_id']) ? intval(wp_unslash($_POST['service_id'])) : 0;

            if ($year <= 0 || $month <= 0 || $staff_id <= 0 || $service_id <= 0) {
                wp_send_json_error(['msg' => esc_html__('Invalid request', 'bookwellnow-appointment-booking')]);
            }

            $disabled_dates = BookWellAB_Booking_Helper::get_month_disabled_dates($staff_id, $service_id, $year, $month);
            $disabled_dates = $this->filter_front_month_disabled_dates($year, $month, $disabled_dates);

            wp_send_json_success(array(
                'disabled_dates' => $disabled_dates,
                'site_today' => $this->get_site_today(),
            ));
        }

        public function bookwellab_resolve_calendar_month_func()
        {
            $this->verify_front_nonce_or_error();
            $start_year = isset($_POST['start_year']) ? intval(wp_unslash($_POST['start_year'])) : 0;
            $start_month = isset($_POST['start_month']) ? intval(wp_unslash($_POST['start_month'])) : 0;
            $staff_id = isset($_POST['staff_id']) ? intval(wp_unslash($_POST['staff_id'])) : 0;
            $service_id = isset($_POST['service_id']) ? intval(wp_unslash($_POST['service_id'])) : 0;
            if ($staff_id <= 0 || $service_id <= 0) {
                wp_send_json_error(['msg' => esc_html__('Invalid request', 'bookwellnow-appointment-booking')]);
            }

            $scan_direction = isset($_POST['scan_direction']) ? sanitize_text_field(wp_unslash($_POST['scan_direction'])) : 'forward';

            $this->set_slot_booking_context_from_request($service_id, $this->parse_front_extras_request());

            // Frontend: scan to nearest month with slots; past dates/times filtered below.
            $result = BookWellAB_Booking_Helper::resolve_calendar_month_for_frontend(
                $staff_id,
                $service_id,
                $start_year,
                $start_month,
                $scan_direction
            );

            $result = $this->filter_front_calendar_month_result($result);
            $result['site_today'] = $this->get_site_today();
            $result['site_now_hm'] = $this->get_site_now_hm();

            wp_send_json_success($result);
        }

        public function bookwellab_get_time_slots_func()
        {
            $this->verify_front_nonce_or_error();
            $date = isset($_POST['date']) ? sanitize_text_field(wp_unslash($_POST['date'])) : '';
            $staff_id = isset($_POST['staff_id']) ? intval(wp_unslash($_POST['staff_id'])) : 0;
            $service_id = isset($_POST['service_id']) ? intval(wp_unslash($_POST['service_id'])) : 0;

            if (empty($date) || $staff_id <= 0 || $service_id <= 0) {
                wp_send_json_error(['msg' => esc_html__('Invalid request', 'bookwellnow-appointment-booking')]);
            }

            if ($this->is_past_booking_date($date)) {
                wp_send_json_success(array(
                    'slots' => array(),
                    'site_today' => $this->get_site_today(),
                ));
            }

            $this->set_slot_booking_context_from_request($service_id, $this->parse_front_extras_request());

            $slots = $this->get_available_slots($date, $staff_id, $service_id);

            wp_send_json_success(array(
                'slots' => $slots,
                'site_today' => $this->get_site_today(),
                'site_now_hm' => $this->get_site_now_hm(),
            ));
        }
        // phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

        public function bookwellab_get_customer_by_phone_func()
        {
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
            if (!is_user_logged_in()) {
                wp_send_json_error(['msg' => esc_html__('Unauthorized request', 'bookwellnow-appointment-booking')]);
            }

            if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'bw_front_nonce')) {
                wp_send_json_error(['msg' => esc_html__('Invalid request token', 'bookwellnow-appointment-booking')]);
            }

            $phone_code = isset($_POST['phone_code']) ? sanitize_text_field(wp_unslash($_POST['phone_code'])) : '';
            $raw_phone = isset($_POST['phone']) ? sanitize_text_field(wp_unslash($_POST['phone'])) : '';
            $phone = preg_replace('/\D+/', '', $raw_phone);

            if (empty($phone) || strlen($phone) < 5) {
                wp_send_json_error(['msg' => esc_html__('Invalid phone number', 'bookwellnow-appointment-booking')]);
            }

            global $wpdb;
            $cust_table = esc_sql($this->bwdb->get_table_name('customers'));
            $appt_table = esc_sql($this->bwdb->get_table_name('appointments'));

            $customer = null;
            if (!empty($phone_code)) {
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name comes from trusted plugin schema helper.
                $customer = $wpdb->get_row($wpdb->prepare(
                    "SELECT bw_customer_id, bw_first_name, bw_last_name, bw_email, bw_phone_code, bw_phone, bw_country_slug
                     FROM $cust_table
                     WHERE bw_phone_code = %s AND bw_phone = %s
                     ORDER BY bw_customer_id DESC
                     LIMIT 1",
                    $phone_code,
                    $phone
                ), ARRAY_A);
            }

            if (!$customer) {
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name comes from trusted plugin schema helper.
                $customer = $wpdb->get_row($wpdb->prepare(
                    "SELECT bw_customer_id, bw_first_name, bw_last_name, bw_email, bw_phone_code, bw_phone, bw_country_slug
                     FROM $cust_table
                     WHERE bw_phone = %s
                     ORDER BY bw_customer_id DESC
                     LIMIT 1",
                    $phone
                ), ARRAY_A);
            }

            if (!$customer) {
                wp_send_json_success(['found' => false]);
            }

            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name comes from trusted plugin schema helper.
            $latest_note = $wpdb->get_var($wpdb->prepare(
                "SELECT bw_note FROM $appt_table
                 WHERE bw_customer_id = %d AND bw_note <> ''
                 ORDER BY bw_appointment_id DESC
                 LIMIT 1",
                intval($customer['bw_customer_id'])
            ));

            wp_send_json_success([
                'found' => true,
                'customer' => [
                    'first_name' => isset($customer['bw_first_name']) ? $customer['bw_first_name'] : '',
                    'last_name' => isset($customer['bw_last_name']) ? $customer['bw_last_name'] : '',
                    'email' => isset($customer['bw_email']) ? $customer['bw_email'] : '',
                    'phone' => isset($customer['bw_phone']) ? $customer['bw_phone'] : '',
                    'phone_code' => isset($customer['bw_phone_code']) ? $customer['bw_phone_code'] : '',
                    'country_slug' => isset($customer['bw_country_slug']) ? $customer['bw_country_slug'] : '',
                    'note' => !empty($latest_note) ? $latest_note : '',
                ],
            ]);
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
        }

        public function bookwellab_refresh_spam_token_func()
        {
            if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'bw_front_nonce')) {
                wp_send_json_error(['msg' => esc_html__('Invalid request token', 'bookwellnow-appointment-booking')]);
            }

            wp_send_json_success([
                'spam_token' => $this->generate_spam_token_pair(),
            ]);
        }

        /**
         * Validate booking payload (nonce/spam handled by caller).
         *
         * @param array<string, mixed> $booking_data Raw booking data.
         * @return array<string, mixed>|\WP_Error Normalized booking data or error.
         */
        public function validate_booking_request($booking_data)
        {
            if (empty($booking_data) || !is_array($booking_data)) {
                return new \WP_Error('no_data', esc_html__('No data received', 'bookwellnow-appointment-booking'));
            }

            $service_id = isset($booking_data['service_id']) ? intval($booking_data['service_id']) : 0;
            $staff_id = isset($booking_data['staff_id']) ? intval($booking_data['staff_id']) : 0;
            $date = isset($booking_data['date']) ? sanitize_text_field($booking_data['date']) : '';
            $time = isset($booking_data['time']) ? sanitize_text_field($booking_data['time']) : '';
            $customer = isset($booking_data['customer']) && is_array($booking_data['customer']) ? $booking_data['customer'] : [];
            $payment_gateway = isset($booking_data['payment_gateway']) ? sanitize_text_field($booking_data['payment_gateway']) : 'offline';

            if ($payment_gateway !== '-' && $payment_gateway !== '') {
                $allowed_gateways = self::get_allowed_payment_gateways();
                if (!in_array($payment_gateway, $allowed_gateways, true)) {
                    return new \WP_Error('invalid_gateway', esc_html__('Selected payment method is not available.', 'bookwellnow-appointment-booking'));
                }
            }

            if (!$service_id || !$staff_id || !$date || !$time || empty($customer['email'])) {
                return new \WP_Error('missing_fields', esc_html__('Missing required fields', 'bookwellnow-appointment-booking'));
            }

            if (!$this->is_valid_date_ymd($date)) {
                return new \WP_Error('invalid_date', esc_html__('Invalid appointment date format', 'bookwellnow-appointment-booking'));
            }

            if ($this->is_past_booking_date($date)) {
                return new \WP_Error('past_date', esc_html__('Past dates are not available for booking.', 'bookwellnow-appointment-booking'));
            }

            $time_hm = $this->normalize_slot_hm($time);
            if ($time_hm === '') {
                return new \WP_Error('invalid_time', esc_html__('Invalid appointment time format', 'bookwellnow-appointment-booking'));
            }

            if ($date === $this->get_site_today() && $time_hm < $this->get_site_now_hm()) {
                return new \WP_Error('past_time', esc_html__('Past time slots are not available for booking.', 'bookwellnow-appointment-booking'));
            }

            $normalized_time = $this->normalize_time_his($time);
            if ($normalized_time === false) {
                return new \WP_Error('invalid_time', esc_html__('Invalid appointment time format', 'bookwellnow-appointment-booking'));
            }

            $email_raw = isset($customer['email']) ? trim((string) $customer['email']) : '';
            if ($email_raw === '' || !is_email($email_raw)) {
                return new \WP_Error('invalid_email', esc_html__('Please enter a valid email address.', 'bookwellnow-appointment-booking'));
            }

            $phone = isset($customer['phone']) ? preg_replace('/\D+/', '', (string) $customer['phone']) : '';
            if ($phone === '' || !ctype_digit($phone)) {
                return new \WP_Error('invalid_phone', esc_html__('Please enter a valid phone number (digits only).', 'bookwellnow-appointment-booking'));
            }

            $normalized = [
                'service_id' => $service_id,
                'staff_id' => $staff_id,
                'date' => $date,
                'time' => $normalized_time,
                'payment_gateway' => $payment_gateway,
                'customer' => [
                    'first_name' => isset($customer['first_name']) ? sanitize_text_field($customer['first_name']) : '',
                    'last_name' => isset($customer['last_name']) ? sanitize_text_field($customer['last_name']) : '',
                    'email' => sanitize_email($email_raw),
                    'phone' => $phone,
                    'phone_code' => isset($customer['phone_code']) ? sanitize_text_field($customer['phone_code']) : '',
                    'country_slug' => BookWellAB_Input::sanitize_country_slug($customer['country_slug'] ?? ''),
                    'note' => isset($customer['note']) ? sanitize_textarea_field($customer['note']) : '',
                ],
            ];

            return apply_filters('bookwellab_validated_booking_data', $normalized, $booking_data);
        }

        /**
         * Allowed payment gateway slugs for frontend booking.
         *
         * @return string[]
         */
        public static function get_allowed_payment_gateways()
        {
            $gateways = [];

            $settings = BookWellAB_Settings::get_settings();
            $payment = isset($settings['payment']) && is_array($settings['payment']) ? $settings['payment'] : [];
            if (!isset($payment['enable_offline']) || $payment['enable_offline'] === 'yes') {
                $gateways[] = 'offline';
            }

            if (BookWellAB_Paypal::is_configured()) {
                $gateways[] = 'paypal';
            }

            $gateways = apply_filters('bookwellab_allowed_payment_gateways', $gateways, $payment);

            if (empty($gateways)) {
                $gateways[] = 'offline';
            }

            return array_values(array_unique(array_map('sanitize_key', $gateways)));
        }

        /**
         * Shared validation for offline and PayPal booking submissions.
         *
         * @param array<string, mixed> $booking_data Raw booking payload.
         * @param bool                 $verify_spam  Whether to validate spam token pair.
         * @return array{validated: array<string, mixed>, payable_amount: float}|\WP_Error
         */
        public function prepare_booking_submission($booking_data, $verify_spam = true)
        {
            if (empty($booking_data) || !is_array($booking_data)) {
                return new \WP_Error('no_data', esc_html__('No data received', 'bookwellnow-appointment-booking'));
            }

            if ($verify_spam) {
                $spam_key = isset($booking_data['spam_key']) ? sanitize_text_field($booking_data['spam_key']) : '';
                $spam_code = isset($booking_data['spam_code']) ? sanitize_text_field($booking_data['spam_code']) : '';
                if (!$this->validate_spam_token_pair($spam_key, $spam_code)) {
                    return new \WP_Error('spam', esc_html__('Security verification failed. Please refresh and try again.', 'bookwellnow-appointment-booking'));
                }
            }

            $validated = $this->validate_booking_request($booking_data);
            if (is_wp_error($validated)) {
                return $validated;
            }

            if (!empty($booking_data['extras']) && is_array($booking_data['extras'])) {
                $validated['extras'] = $booking_data['extras'];
            }

            BookWellAB_Booking_Helper::set_slot_booking_context([
                'extras' => isset($validated['extras']) && is_array($validated['extras']) ? $validated['extras'] : [],
                'booking_data' => $validated,
            ]);

            $service_id = (int) $validated['service_id'];
            $staff_id = (int) $validated['staff_id'];
            $date = $validated['date'];
            $time = $validated['time'];

            if (!BookWellAB_Booking_Helper::is_slot_available($date, $time, $staff_id, $service_id, true)) {
                return new \WP_Error('slot_unavailable', esc_html__('This time slot has just been booked. Please select another available time.', 'bookwellnow-appointment-booking'));
            }

            $payable_amount = BookWellAB_Booking_Helper::calculate_booking_payable_amount($validated);
            $frontend_amount = isset($booking_data['service_price']) ? (float) $booking_data['service_price'] : null;
            if ($frontend_amount !== null && abs($frontend_amount - $payable_amount) > 0.01) {
                return new \WP_Error('price_mismatch', esc_html__('The booking total does not match. Please refresh and try again.', 'bookwellnow-appointment-booking'));
            }

            if ($payable_amount <= 0) {
                $validated['payment_gateway'] = '-';
            } else {
                $payment_gateway = isset($validated['payment_gateway']) ? sanitize_text_field($validated['payment_gateway']) : '';
                $allowed_gateways = self::get_allowed_payment_gateways();
                if ($payment_gateway === '' || $payment_gateway === '-' || !in_array($payment_gateway, $allowed_gateways, true)) {
                    return new \WP_Error('invalid_gateway', esc_html__('Selected payment method is not available.', 'bookwellnow-appointment-booking'));
                }
            }

            return [
                'validated' => $validated,
                'payable_amount' => $payable_amount,
            ];
        }

        /**
         * Parse optional extras payload from frontend slot/calendar AJAX requests.
         *
         * @return array<int, array<string, mixed>>
         */
        private function parse_front_extras_request()
        {
            $extras = BookWellAB_Input::get_post_json('extras');
            return is_array($extras) ? $extras : array();
        }

        /**
         * Set request-scoped booking context used by slot duration filters.
         *
         * @param int   $service_id Service ID.
         * @param array $extras     Raw extras payload.
         * @return void
         */
        private function set_slot_booking_context_from_request($service_id, $extras = array())
        {
            BookWellAB_Booking_Helper::set_slot_booking_context(array(
                'service_id' => absint($service_id),
                'extras' => is_array($extras) ? $extras : array(),
            ));
        }

        public function bookwellab_process_booking_func()
        {
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
            // Verify nonce for frontend booking requests.
            if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'bw_front_nonce')) {
                wp_send_json_error(['msg' => esc_html__('Invalid request token', 'bookwellnow-appointment-booking')]);
            }

            // BookWellAB_Input: decoded JSON only; each booking field sanitized below before DB insert.
            $bookingData = BookWellAB_Input::get_post_json('data');
            if (empty($bookingData)) {
                wp_send_json_error(['msg' => esc_html__('No data received', 'bookwellnow-appointment-booking')]);
            }

            $gateway = isset($bookingData['payment_gateway']) ? sanitize_key((string) $bookingData['payment_gateway']) : 'offline';
            if ($gateway === 'offline' || $gateway === '' || $gateway === '-') {
                BookWellAB_Payment_Debug_Log::log(
                    'offline',
                    'offline_booking_request',
                    'BookWellAB_Frontend::bookwellab_process_booking_func',
                    ['booking' => BookWellAB_Payment_Debug_Log::summarize_booking_data($bookingData)]
                );
            }

            $prepared = $this->prepare_booking_submission($bookingData);
            if (is_wp_error($prepared)) {
                if ($gateway === 'offline' || $gateway === '' || $gateway === '-') {
                    BookWellAB_Payment_Debug_Log::log(
                        'offline',
                        'offline_booking_error',
                        'BookWellAB_Frontend::bookwellab_process_booking_func',
                        [
                            'message' => $prepared->get_error_message(),
                            'code' => $prepared->get_error_code(),
                        ]
                    );
                }
                wp_send_json_error(['msg' => $prepared->get_error_message()]);
            }

            $validated = $prepared['validated'];
            $payment_gateway = $validated['payment_gateway'];

            $result = BookWellAB_Booking_Helper::create_appointment_from_booking_data(
                $validated,
                [
                    'payment_gateway' => $payment_gateway,
                    'send_notification' => true,
                ]
            );

            if (empty($result['success'])) {
                $msg = isset($result['msg']) ? $result['msg'] : esc_html__('Failed to create appointment', 'bookwellnow-appointment-booking');
                if ($payment_gateway === 'offline') {
                    BookWellAB_Payment_Debug_Log::log(
                        'offline',
                        'offline_booking_error',
                        'BookWellAB_Frontend::bookwellab_process_booking_func',
                        ['message' => $msg]
                    );
                }
                wp_send_json_error(['msg' => $msg]);
            }

            if ($payment_gateway === 'offline') {
                BookWellAB_Payment_Debug_Log::log(
                    'offline',
                    'offline_booking_success',
                    'BookWellAB_Frontend::bookwellab_process_booking_func',
                    ['appointment_id' => (int) $result['appointment_id']],
                    (int) $result['appointment_id']
                );
            }

            wp_send_json_success([
                'msg' => 'Appointment Booked Successfully!',
                'appointment_id' => $result['appointment_id'],
            ]);
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
        }

        /**
         * Public confirmation payload for post-checkout display (requires valid appointment hash).
         *
         * @param int    $appointment_id Appointment ID.
         * @param string $hash           Appointment hash.
         * @return array<string, mixed>|\WP_Error
         */
        public static function get_public_confirmation_summary($appointment_id, $hash)
        {
            global $wpdb;

            $appointment_id = absint($appointment_id);
            $hash = sanitize_text_field((string) $hash);
            if ($appointment_id <= 0 || $hash === '') {
                return new \WP_Error('invalid_request', esc_html__('Invalid confirmation request.', 'bookwellnow-appointment-booking'));
            }

            $cache_key = 'public_confirmation_' . $appointment_id . '_' . md5($hash);
            $row = wp_cache_get($cache_key, 'bookwellab_booking');

            if (!is_array($row)) {
                $bwdb = new BookWellAB_Database();
                $appt_table = esc_sql($bwdb->get_table_name('appointments'));
                $services_table = esc_sql($bwdb->get_table_name('services'));
                $sql = "SELECT a.*, s.bw_service_duration_val, s.bw_service_duration_unit
                    FROM {$appt_table} a
                    LEFT JOIN {$services_table} s ON s.bw_service_id = a.bw_service_id
                    WHERE a.bw_appointment_id = %d AND a.bw_appointment_hash = %s
                    LIMIT 1";

                // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names come from trusted plugin schema helper; values are bound via prepare().
                $prepared_sql = $wpdb->prepare($sql, $appointment_id, $hash);
                $row = $wpdb->get_row($prepared_sql, ARRAY_A);
                // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

                if (is_array($row) && !empty($row['bw_appointment_id'])) {
                    wp_cache_set($cache_key, $row, 'bookwellab_booking', 5 * MINUTE_IN_SECONDS);
                }
            }

            if (!is_array($row) || empty($row['bw_appointment_id'])) {
                return new \WP_Error('not_found', esc_html__('Booking confirmation not found.', 'bookwellnow-appointment-booking'));
            }

            $name_parts = preg_split('/\s+/', trim((string) $row['bw_customer_name']), 2);
            $first_name = isset($name_parts[0]) ? $name_parts[0] : '';
            $last_name = isset($name_parts[1]) ? $name_parts[1] : '';

            $summary = [
                'appointment_id' => (int) $row['bw_appointment_id'],
                'booking' => [
                    'service_id' => (int) $row['bw_service_id'],
                    'staff_id' => (int) $row['bw_staff_id'],
                    'date' => (string) $row['bw_date'],
                    'time' => (string) $row['bw_time'],
                    'service_price' => isset($row['bw_price']) ? (float) $row['bw_price'] : 0,
                    'customer' => [
                        'first_name' => sanitize_text_field($first_name),
                        'last_name' => sanitize_text_field($last_name),
                        'email' => sanitize_email((string) $row['bw_customer_email']),
                        'phone' => sanitize_text_field((string) $row['bw_customer_phone']),
                        'phone_code' => sanitize_text_field((string) $row['bw_customer_phone_code']),
                        'country_slug' => sanitize_text_field((string) $row['bw_customer_country_slug']),
                        'note' => sanitize_textarea_field((string) $row['bw_note']),
                    ],
                ],
                'service' => [
                    'bw_service_id' => (int) $row['bw_service_id'],
                    'bw_service_name' => sanitize_text_field((string) $row['bw_service_name']),
                    'bw_service_duration_val' => isset($row['bw_service_duration_val']) ? (int) $row['bw_service_duration_val'] : 0,
                    'bw_service_duration_unit' => sanitize_text_field((string) ($row['bw_service_duration_unit'] ?? 'm')),
                    'bw_service_price' => isset($row['bw_price']) ? (float) $row['bw_price'] : 0,
                ],
                'staff' => [
                    'bw_staff_id' => (int) $row['bw_staff_id'],
                    'bw_staff_name' => sanitize_text_field((string) $row['bw_staff_name']),
                ],
            ];

            return apply_filters('bookwellab_confirmation_summary', $summary, $appointment_id, $hash);
        }

        /**
         * Handle public appointment cancellation from email link (login not required).
         *
         * @return void
         */
        public function maybe_handle_public_cancel_appointment()
        {
            if (is_admin()) {
                return;
            }

            $cancel_flag = filter_input(INPUT_GET, 'bookwellab_cancel', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
            $cancel_flag = is_string($cancel_flag) ? $cancel_flag : '';
            if ($cancel_flag !== '1') {
                return;
            }

            $appointment_id = filter_input(INPUT_GET, 'appt_id', FILTER_VALIDATE_INT);
            $appointment_id = is_int($appointment_id) && $appointment_id > 0 ? $appointment_id : 0;

            $hash = filter_input(INPUT_GET, 'hash', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
            $hash = is_string($hash) ? sanitize_text_field($hash) : '';

            $result = BookWellAB_Booking_Helper::cancel_appointment_by_hash($appointment_id, $hash);
            $this->render_cancel_appointment_result_page($result);
            exit;
        }

        /**
         * Render a minimal public page after cancel link is used.
         *
         * @param true|\WP_Error $result Cancellation result.
         * @return void
         */
        private function render_cancel_appointment_result_page($result)
        {
            $page_title = __('Appointment cancellation', 'bookwellnow-appointment-booking');
            $message_type = 'success';
            $message = __('Your appointment has been cancelled successfully.', 'bookwellnow-appointment-booking');

            if (is_wp_error($result)) {
                $error_code = $result->get_error_code();
                if ($error_code === 'already_cancelled') {
                    $message_type = 'info';
            } else {
                    $message_type = 'error';
                }
                $message = $result->get_error_message();
            }

            status_header(200);
            nocache_headers();

            $bookwellab_cancel_page_title = $page_title;
            $bookwellab_cancel_message = $message;
            $bookwellab_cancel_message_type = $message_type;
            $bookwellab_cancel_box_class = 'bw-cancel-result--success';
            if ($message_type === 'error') {
                $bookwellab_cancel_box_class = 'bw-cancel-result--error';
            } elseif ($message_type === 'info') {
                $bookwellab_cancel_box_class = 'bw-cancel-result--info';
            }

            require BOOKWELLAB_HTML_PATH . 'cancel_appointment_result_view.php';
        }
    }
}
